diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index fdbcfa2f..17d84c6b 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -177,3 +177,11 @@ test("a refusal said while a shot is being queued names the scene", async () => await app.runner.units(["c"]); assert.match(document.body.textContent.replace(before, ""), /Scene 3: Length is 5000/); }); + +test("a scene removed while it was being made: the result is not dropped in silence, it says where the file is", async () => { + const { app, doc } = rig("idle", { generate: async () => { doc.scenes.splice(0, 2); return true; } }); + app.project.editFor = async (_id, fn) => fn(doc); + const before = document.body.textContent; + await app.runner.units(["a"]); + assert.match(document.body.textContent.replace(before, ""), /was removed while it was being made.*a\.mp4/); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 8689127e..2d453714 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -109,7 +109,8 @@ export default { break; } if (!images.length) { tell("ComfyUI finished without a result. Try again."); break; } - await record(pid, unit, images[images.length - 1], frames, g.run.state.promptId); // the next shot may continue from this one: it must be on the clip first + const kept = await record(pid, unit, images[images.length - 1], frames, g.run.state.promptId); // the next shot may continue from this one: it must be on the clip first + if (kept === false) tell(`Scene ${snap.scenes.findIndex((s) => s.id === root.id) + 1} was removed while it was being made, so its result is not on the timeline. It is in ComfyUI's output folder as ${images[images.length - 1].filename}.`); snap.scene_renders = structuredClone(p.project.scene_renders || {}); // ...and in what the next shot reads } if (!made && !stopped && !said) tell("Nothing to generate: every scene is left out or is a video clip."); diff --git a/app/ui/features/inspector/hub.test.js b/app/ui/features/inspector/hub.test.js new file mode 100644 index 00000000..008f5ff0 --- /dev/null +++ b/app/ui/features/inspector/hub.test.js @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; + +let hub; +test.before(async () => { setupDom(); hub = (await import("./index.js")).default; }); +test.after(() => teardownDom()); + +test("a scene's text changed elsewhere (the Story box) shows in the Prompt box, but not under the caret", async () => { + let fire; + const doc = { id: "P", frame_rate: 25, num_frames_per_scene: 49, scenes: [{ id: "a", text: "old" }], scene_renders: {} }; + const p = { project: doc, get scenes() { return doc.scenes; }, get selected() { return doc.scenes[0]; }, selectedId: "a", + setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) }; + const host = document.createElement("div"); + document.body.append(host); + hub.setup({ host, app: { project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } }); + const box = () => [...host.querySelectorAll("textarea")][0]; + assert.equal(box().value, "old"); + doc.scenes[0].text = "from story"; + fire("change"); + assert.equal(box().value, "from story"); + box().focus(); + doc.scenes[0].text = "while typing"; + fire("change"); + assert.equal(box().value, "from story"); // not rebuilt under the caret + box().blur(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(box().value, "while typing"); // caught up once the focus left +}); diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index 8eee44a5..4ab21c8c 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -24,9 +24,14 @@ export default { app.title && app.title("inspector", `Scene · ${p.scenes.indexOf(sc) + 1}${unit.length > 1 ? ` · cut ${unit.indexOf(sc) + 1}/${unit.length}` : ""}`); }; const sections = app.sceneSections || []; // extra rows other features put under the scene's own: { rows(scene, project), key(scene, project) } + // The scene's text is in the key, so an edit made elsewhere (the Story box) is shown -- but not while this panel's + // own box is being typed in: that would rebuild it under the caret. It catches up when the focus leaves. + let shownText; + const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); + body.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); })); function draw(force) { // not on every keystroke: that would rebuild the box being typed in const sc = p.selected, open = p.project; - const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode, open.export_size_from, open.width, open.height, open.scenes.map((s) => s.frames_mode === "custom"), Object.keys(open.scene_renders || {}), open.scenes.length] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`; + const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode, open.export_size_from, open.width, open.height, open.scenes.map((s) => s.frames_mode === "custom"), Object.keys(open.scene_renders || {}), open.scenes.length] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, (sc.source || {}).type, sc.excluded, typing() ? shownText : (shownText = sc.text), ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`; titled(); if (!force && next === key) return; key = next; diff --git a/core/media.py b/core/media.py index 011a75ec..6d34c6a5 100644 --- a/core/media.py +++ b/core/media.py @@ -107,15 +107,16 @@ def get(mid: str) -> dict | None: return next((it for it in _load_index() if it.get("id") == mid), None) -def save_upload(orig_name: str, data: bytes) -> dict: +def save_upload(orig_name: str, data: bytes, limit=...) -> dict: """Write one uploaded file and record it. Raises ValueError for anything this store refuses -- an empty upload, an extension not on the allowlist, or a file over MAX_BYTES -- so the route can turn that into a 400 naming why, rather than a file silently written and then never explained.""" if not data: raise ValueError("that file is empty") - if len(data) > MAX_BYTES: - raise ValueError(f"that file is over the {MAX_BYTES // (1024 * 1024)}MB limit") + limit = MAX_BYTES if limit is ... else limit # an upload's limit (read now); a file the server made itself has none + if limit is not None and len(data) > limit: + raise ValueError(f"that file is over the {limit // (1024 * 1024)}MB limit") ext = Path(orig_name or "").suffix.lower() if ext not in ALLOWED_EXT: raise ValueError(f"{ext or 'that file type'} is not something FunPack imports") diff --git a/core/tests/test_media.py b/core/tests/test_media.py index 9ceddf09..0eae61ed 100644 --- a/core/tests/test_media.py +++ b/core/tests/test_media.py @@ -322,3 +322,15 @@ def test_a_photo_thumbnail_is_turned_upright_like_the_browser_shows_it(store): img.save(buf, "JPEG", exif=exif) entry = media.save_upload("p.jpg", buf.getvalue()) assert Image.open(media.thumb_path_for(entry["id"])).size == (160, 320) + + +def test_a_file_the_server_made_itself_is_not_held_to_the_upload_limit(store, monkeypatch): + from core import media + monkeypatch.setattr(media, "MAX_BYTES", 10) + import pytest + with pytest.raises(ValueError, match="limit"): + media.save_upload("a.mp4", b"x" * 11) + try: + media.save_upload("a.mp4", b"x" * 11, limit=None) + except ValueError as exc: + assert "limit" not in str(exc) diff --git a/modules/output/render/__init__.py b/modules/output/render/__init__.py index 9534e70d..01c80ceb 100644 --- a/modules/output/render/__init__.py +++ b/modules/output/render/__init__.py @@ -139,7 +139,7 @@ def routes(table, base, web): name += ".mp4" try: data = await asyncio.to_thread(_clip_bytes, clip) - entry = media.save_upload(name, data) + entry = media.save_upload(name, data, limit=None) # our own render: a long one is over the upload limit except (files.ClipError, stitch.RenderError) as exc: return bad(str(exc), 400) except ValueError as exc: