From ee94e5851cd8beffd7c00f0bf3c08aa97b80cfe3 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:14:58 +0300 Subject: [PATCH] feat: show per-file upload progress in the media bin Uploads now go through XHR instead of fetch so byte-level progress is visible; the drop zone shows a bar, percent, and filename instead of looking stale while the backend receives the file. Co-Authored-By: Claude Sonnet 5 --- movie_editor/frontend/api.js | 27 ++++++++++++++++++++++----- movie_editor/frontend/mediabrowser.js | 26 ++++++++++++++++++++++---- movie_editor/frontend/store.js | 23 ++++++++++++++++++++++- movie_editor/frontend/styles.css | 6 ++++++ 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index f77e845a..5bd9f183 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -112,11 +112,28 @@ deleteMedia: (id) => j("DELETE", API(`/media/${encodeURIComponent(id)}`)), renameMedia: (id, name) => j("PATCH", API(`/media/${encodeURIComponent(id)}`), { name }), importClipToMediaBin: (clip, name) => j("POST", API("/media/import-clip"), { clip, name: name || null }), - async uploadMedia(file) { - const fd = new FormData(); fd.append("file", file, file.name); - const res = await fetch(API("/media"), { method: "POST", body: fd }); - if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText); - return res.json(); + // XMLHttpRequest, not fetch: fetch has no cross-browser upload-progress event, and a + // multi-MB video/photo upload over a slow or messy connection is exactly the case where + // the caller most wants to show something moving instead of a stalled-looking UI. + // onProgress(loadedBytes, totalBytes) fires repeatedly during the upload; totalBytes may + // be 0 if the browser can't compute it (rare, e.g. a chunked body β€” never for a plain File). + uploadMedia(file, onProgress) { + return new Promise((resolve, reject) => { + const fd = new FormData(); fd.append("file", file, file.name); + const xhr = new XMLHttpRequest(); + xhr.open("POST", API("/media")); + if (onProgress) { + xhr.upload.onprogress = (e) => onProgress(e.loaded, e.lengthComputable ? e.total : 0); + } + xhr.onload = () => { + let body = {}; + try { body = JSON.parse(xhr.responseText || "{}"); } catch (_) {} + if (xhr.status >= 200 && xhr.status < 300) resolve(body); + else reject(new Error(body.detail || xhr.statusText || `HTTP ${xhr.status}`)); + }; + xhr.onerror = () => reject(new Error("Network error during upload")); + xhr.send(fd); + }); }, // models / node slots diff --git a/movie_editor/frontend/mediabrowser.js b/movie_editor/frontend/mediabrowser.js index c394d5f2..9d1c8814 100644 --- a/movie_editor/frontend/mediabrowser.js +++ b/movie_editor/frontend/mediabrowser.js @@ -457,10 +457,28 @@ const exportId = _exportableMediaId(st, items); const wrap = el("div", "bin" + (mediaSelectMode ? " media-select-mode" : "")); - const drop = el("div", "mediabin"); - drop.append(el("div", "big", "🎞")); - drop.append(el("div", null, "Drop images, video & audio here")); - drop.append(el("div", "pj-meta", "or click to browse Β· drag onto a clip to set its anchor")); + const up = st.mediaUpload; + const drop = el("div", "mediabin" + (up ? " uploading" : "")); + if (up) { + // Was silent before: uploadMedia's own loop ran with no visible state at all, so a + // batch of photos or one big video over a slow connection just looked like the drop + // zone had eaten the files and done nothing. + const pct = up.size > 0 ? Math.min(100, Math.round((up.loaded / up.size) * 100)) : null; + drop.append(el("div", "big", "⬆")); + drop.append(el("div", null, up.total > 1 + ? `Uploading ${up.current}/${up.total}: ${up.name}` + : `Uploading ${up.name}`)); + const barWrap = el("div", "media-upload-bar"); + const bar = el("div", "media-upload-bar-fill"); + bar.style.width = (pct == null ? 0 : pct) + "%"; + barWrap.append(bar); + drop.append(barWrap); + drop.append(el("div", "pj-meta", pct == null ? "uploading…" : `${pct}%`)); + } else { + drop.append(el("div", "big", "🎞")); + drop.append(el("div", null, "Drop images, video & audio here")); + drop.append(el("div", "pj-meta", "or click to browse Β· drag onto a clip to set its anchor")); + } const file = el("input"); file.type = "file"; file.accept = "image/*,video/*,audio/*"; file.multiple = true; file.style.display = "none"; file.onchange = () => { if (file.files.length) S.uploadMedia([...file.files]); file.value = ""; }; drop.onclick = () => file.click(); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 6994f60a..4ed167a2 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -28,6 +28,7 @@ models: { slots: [] }, // pluggable node config (shared with Models modal) mediaBin: [], // uploaded assets [{id,name,kind,...}] mediaPreviewId: null, // transient image preview in the player (not scene assignment) + mediaUpload: null, // in-progress upload: {current,total,name,loaded,size} or null shortcuts: [], // prompt shortcut library shortcutCategories: [], // managed grouping list: [{name, sub_categories:[]}] imageTargets: [], // where an image asset can be wired [{value,label}] @@ -4421,8 +4422,28 @@ // ── media bin + libraries ───────────────────────────────────────────────────── async function loadMedia() { try { state.mediaBin = (await API.listMedia()).media || []; } catch (_) { state.mediaBin = []; } notify(); } + // state.mediaUpload: null when idle, else { current, total, name, loaded, size } -- the + // drop zone had no way to show this at all before, so a batch of photos/a big video + // uploading over a slow connection just looked like nothing was happening. async function uploadMedia(files) { - for (const f of files) { try { await API.uploadMedia(f); } catch (e) { console.error("upload failed", e); } } + const list = [...files]; + let lastNotify = 0; + for (let i = 0; i < list.length; i++) { + const f = list[i]; + set({ mediaUpload: { current: i + 1, total: list.length, name: f.name, loaded: 0, size: f.size } }); + lastNotify = Date.now(); + try { + await API.uploadMedia(f, (loaded, total) => { + state.mediaUpload = { ...state.mediaUpload, loaded, size: total || f.size }; + // XHR fires this many times a second -- notify() rebuilds the whole media grid, + // so this throttles to a still-smooth ~8fps instead of hammering a full re-render + // on every tick. The final `loaded === total` (upload done) always gets through. + const now = Date.now(); + if (now - lastNotify >= 120 || loaded >= total) { lastNotify = now; notify(); } + }); + } catch (e) { console.error("upload failed", e); } + } + state.mediaUpload = null; await loadMedia(); } async function deleteMedia(id) { diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 4f02e4c9..ec5c388d 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -646,8 +646,14 @@ input[type=number] { font-family: var(--font-ui); } color: var(--faint); font-size: 12px; transition: border-color .15s, background .15s; } .mediabin.drag { border-color: var(--teal); background: var(--teal-soft); color: var(--teal); } +.mediabin.uploading { border-style: solid; border-color: var(--accent); color: var(--text); cursor: default; } .mediabin .big { font-size: 26px; opacity: .5; margin-bottom: 8px; } .mediabin { cursor: pointer; padding: 18px 14px; } +.media-upload-bar { + width: 100%; max-width: 220px; height: 5px; margin: 8px auto 4px; + background: var(--ink-2); border-radius: 3px; overflow: hidden; +} +.media-upload-bar-fill { height: 100%; background: var(--accent); transition: width .12s linear; } .soon { font-family: var(--font-ui); font-size: 10px; letter-spacing: 1px; color: var(--faint); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; } /* bin tabs (Media / Shortcuts / Transitions) */