diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index 523b4117..1e65d987 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -95,7 +95,7 @@ export default { const note = c.text.sm({ text: "" }); const drop = c.dropzone.default({ label: "Drop or choose files", hint: "images, clips, audio", onFiles: async (files) => { - try { const r = await api.uploadMedia(files, (i, n, name) => note.setText(`Uploading ${i + 1}/${n}: ${name}`)); (r.problems || []).forEach(tell); await refresh(); } catch (err) { tell(err.message); } + try { const r = await api.uploadMedia(files, (i, n, name, f) => note.setText(`Uploading ${i + 1}/${n}: ${name}${f ? ` ${f >= 1 ? "(saving...)" : `${Math.round(f * 100)}%`}` : ""}`)); (r.problems || []).forEach(tell); await refresh(); } catch (err) { tell(err.message); } note.setText(""); } }); const seg = (label, options, key) => c.segmented.sm({ label, options, value: show[key], onChange: (v) => { show[key] = v; draw(true); } }); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 6a7adff4..13852131 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -19,6 +19,20 @@ export async function call(method, path, body) { return payload; } +/** POST a form. fetch cannot report bytes sent, so use XHR where it exists (browsers) and fetch elsewhere. + * Resolves a fetch-like {ok, status, json()}. */ +function post(url, form, onFraction) { + if (typeof XMLHttpRequest === "undefined") return fetch(url, { method: "POST", body: form }); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) onFraction(e.loaded / e.total); }; + xhr.onerror = () => reject(new Error("the upload was interrupted")); + xhr.onload = () => resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, statusText: xhr.statusText, json: async () => JSON.parse(xhr.responseText) }); + xhr.send(form); + }); +} + export const api = { pipeline: () => call("GET", "/api/pipeline"), editPipeline: (body) => call("POST", "/api/pipeline", body), @@ -167,15 +181,16 @@ export const api = { deleteMedia: (id) => call("DELETE", `/api/media/${encodeURIComponent(id)}`), /** Upload files as media, one request each so one refusal (too big, wrong type) does not sink the rest. * Resolves {media, problems}. Not JSON, so not through call(). */ - async uploadMedia(files, onProgress) { // onProgress(done, total, name) before each file + async uploadMedia(files, onProgress) { // onProgress(done, total, name, fraction) before each file, then again as bytes go out const out = { media: [], problems: [] }; let done = 0; for (const file of files) { - if (onProgress) onProgress(done++, files.length, file.name); + const at = done++; + if (onProgress) onProgress(at, files.length, file.name, 0); const form = new FormData(); form.append("file", file, file.name); try { - const res = await fetch(`${BASE}/api/media`, { method: "POST", body: form }); + const res = await post(`${BASE}/api/media`, form, (f) => onProgress && onProgress(at, files.length, file.name, f)); const payload = await res.json().catch(() => null); if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage out.media.push(...(payload.media || [])); out.problems.push(...(payload.problems || [])); diff --git a/core/routes.py b/core/routes.py index 935c4bc9..5fdc6c93 100644 --- a/core/routes.py +++ b/core/routes.py @@ -971,9 +971,20 @@ def register(routes, prefix=None): break if not part.filename: continue # a form field that is not a file - data = await part.read(decode=False) + # 1MB reads (the default is 8KB: thousands of loop turns for a clip) and a + # stop at the limit, so a too-big file is refused without buffering all of it + buf = bytearray() + while True: + chunk = await part.read_chunk(1024 * 1024) + if not chunk: + break + buf += chunk + if len(buf) > media.MAX_BYTES: + break + data = bytes(buf) try: - saved.append(media.save_upload(part.filename, data)) + # to_thread: writing a big clip must not stall the one event loop ComfyUI has + saved.append(await asyncio.to_thread(media.save_upload, part.filename, data)) except ValueError as exc: problems.append(f"{part.filename}: {exc}") if not saved and problems: