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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
27a82eead1
commit
ee94e5851c
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user