From 03cd77e0cda309792b86eaf9f3cf34eae478feb5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:32:31 +0300 Subject: [PATCH] fix: a picture gone from the media bin is dropped from its scene at Generate (said), not sent to fail in ComfyUI; the References field names it (deleted) Co-Authored-By: Claude Opus 5.5 --- app/ui/features/generate/hub.test.js | 11 +++++++++++ app/ui/features/generate/index.js | 10 ++++++++++ app/ui/features/inspector/mediapick.js | 10 +++++----- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 17d84c6b..3b141396 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -185,3 +185,14 @@ test("a scene removed while it was being made: the result is not dropped in sile await app.runner.units(["a"]); assert.match(document.body.textContent.replace(before, ""), /was removed while it was being made.*a\.mp4/); }); + +test("a picture no longer in the media bin is dropped from the scene before the run, and said, instead of failing in ComfyUI", async () => { + const { app, doc } = rig("idle", { generate: async () => true }); + doc.scenes[0].references = ["deadbeef0000", "aaaaaaaaaaaa"]; + doc.scenes[0].source_image = "deadbeef0001"; + app.api.media = async () => ({ media: [{ id: "aaaaaaaaaaaa" }] }); + await app.runner.units(["a"], 1); + assert.deepEqual(doc.scenes[0].references, ["aaaaaaaaaaaa"]); + assert.equal(doc.scenes[0].source_image, ""); + assert.match(document.body.textContent, /no longer in the media bin/); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index f845a320..8144a64b 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -70,6 +70,16 @@ export default { if (times > 1 && !rolesAt(frozen, "generation.seed").length) { tell("This pipeline has no seed input the app controls, so the takes would all come out the same."); return; } // The project too: text, lengths and references edited after the click reach the next Generate, not this one's later shots. const snap = structuredClone(p.project); + // A picture deleted from the bin (in another project, or on another machine) is still named by id here: it would fail the run in ComfyUI. + const bin = await Promise.resolve().then(() => app.api.media()).then((r) => new Set((r.media || []).map((m) => m.id)), () => null); + const dead = (id) => bin && id && !bin.has(id); + const prune = (pr) => pr.scenes.reduce((hit, s) => { + const had = dead(s.source_image) || (s.references || []).some(dead); + if (dead(s.source_image)) s.source_image = ""; + if (had) s.references = (s.references || []).filter((r) => !dead(r)); + return hit || had; + }, false); + if (prune(snap)) { tell("Some pictures this project used are no longer in the media bin; they were removed from their scenes."); p.edit(prune); } const drawn = new Map(); const same = times > 1 ? (body) => { const key = JSON.stringify(body); if (!drawn.has(key)) drawn.set(key, expand(body)); return drawn.get(key); } : expand; for (const unit of Array.from({ length: times }, () => units).flat()) { diff --git a/app/ui/features/inspector/mediapick.js b/app/ui/features/inspector/mediapick.js index bec89634..1230bc22 100644 --- a/app/ui/features/inspector/mediapick.js +++ b/app/ui/features/inspector/mediapick.js @@ -1,7 +1,7 @@ // The media bin as a choice, for fields that need one picture or several: a window listing what is in it. import { composer as c } from "../../composer/composer.js"; -import { learn, names } from "../../shell/bin.js"; +import { bin, learn, names } from "../../shell/bin.js"; let asked = false; @@ -14,15 +14,15 @@ export async function look(api) { /** The names behind some ids; looks the bin up once if any are not known yet, then says so with `again`. */ export function namesOf(ids, api, again) { if (!asked && ids.some((id) => !names.has(id))) { asked = true; look(api).then(again, () => { asked = false; }); } - return ids.map((id) => names.get(id) || "…"); + return ids.map((id) => names.get(id) || (bin.version ? "(deleted)" : "…")); // a listing has been seen and this id was not in it } /** -> the chosen ids in the order picked, or null when the window was closed. `multiple` false gives at most one. */ export async function pick(api, { title, kinds, multiple, current = [] }) { asked = false; - const bin = (await look(api)).filter((m) => kinds.includes(m.kind)); - if (!bin.length) { c.toast.warn({ text: `Nothing to pick: the media bin has no ${kinds.join(" or ")} yet.` }); return null; } - const items = bin.map((m) => ({ value: m.id, label: `${m.name} (${m.kind})` })); + const found = (await look(api)).filter((m) => kinds.includes(m.kind)); + if (!found.length) { c.toast.warn({ text: `Nothing to pick: the media bin has no ${kinds.join(" or ")} yet.` }); return null; } + const items = found.map((m) => ({ value: m.id, label: `${m.name} (${m.kind})` })); return new Promise((resolve) => { let chosen = multiple ? current.filter((id) => names.has(id)) : []; const body = multiple ? c.checklist.default({ label: title, items, values: chosen, onChange: (v) => { chosen = v; } })