fix: a removed scene's result says where it went; Inspector follows text edited elsewhere; own renders import past the upload limit; xformers warning only when it is on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-06 03:13:05 +03:00
co-authored by Claude Opus 5.5
parent 837a4215a4
commit 2aa52b0eee
7 changed files with 62 additions and 6 deletions
+8
View File
@@ -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/);
});
+2 -1
View File
@@ -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.");
+29
View File
@@ -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
});
+6 -1
View File
@@ -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;
+4 -3
View File
@@ -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")
+12
View File
@@ -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)
+1 -1
View File
@@ -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: