Media upload: show percent while sending, read the body in 1MB chunks, write off the event loop

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J6rGxH5eLym2ADye6YDgEW
This commit is contained in:
Claude
2026-10-17 23:09:37 +00:00
parent cd4c576e6e
commit dfc8153cb3
3 changed files with 32 additions and 6 deletions
+1 -1
View File
@@ -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); } });
+18 -3
View File
@@ -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 || []));
+13 -2
View File
@@ -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: