From 6a03ebe045519db02e7ecec4557d91f6e798d513 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:07:29 +0300 Subject: [PATCH] fix: deleting a montage piece leaves no ghost; pieces never read past an off-grid clip; calmer montage default; context-window overlap hidden when batched; Engine pane honours list conditions; Generate and Update wording Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/generate/index.js | 4 ++-- app/ui/features/montage/build.js | 4 ++-- app/ui/features/montage/build.test.js | 12 ++++++++++++ app/ui/features/montage/index.js | 2 +- app/ui/features/settings/field.js | 2 +- app/ui/features/tour/index.js | 2 +- app/ui/features/welcome/index.js | 2 +- app/ui/shell/edits.js | 1 + modules/sampling/context_windows/__init__.py | 2 +- 9 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 5b5b789f..4d4d3b2e 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -135,7 +135,7 @@ export default { 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."); + if (!made && !stopped && !said) tell("Nothing to generate: every scene is left out, a video clip or an Auto Montage piece."); } finally { busy = false; draw(); } } app.runner.units = (units, times) => runUnits(units, times); @@ -150,7 +150,7 @@ export default { if (key === drawn) return; // progress ticks must not rebuild the buttons drawn = key; const working = busy || held || g.state().phase === QUEUED || g.state().phase === RUNNING; // also a run this page did not start - const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, title: "Generate every scene on the timeline, except left-out scenes and video clips. An earlier render of a scene stays as a take.", + const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, title: "Generate every scene on the timeline, except left-out scenes, video clips and Auto Montage pieces. An earlier render of a scene stays as a take.", onClick: () => runUnits(unitsOf(p.scenes)) }); const one = c.button.sm({ label: picked > 1 ? `Selected (${picked})` : "Selected", title: "Generate only the picked scenes", disabled: working || !picked, onClick: () => runUnits(unitsOf(pickedScenes())) }); const stop = c.button.sm({ label: "■ Stop", tone: "danger", title: "Stop the run that is going", onClick: () => { stopped = true; g.cancel(); } }); diff --git a/app/ui/features/montage/build.js b/app/ui/features/montage/build.js index 23c5bfa2..54b96cc2 100644 --- a/app/ui/features/montage/build.js +++ b/app/ui/features/montage/build.js @@ -33,9 +33,9 @@ export function build(p, { leadId, poolIds, segmentFrames = 100, decay = 1, rand const pool = (poolIds || []).map(find).filter((s) => s && playable(s, p)); if (!leadSrc || !pool.length) return 0; const base = Math.max(9, Math.round(segmentFrames)), dk = Math.min(1, Math.max(0.3, decay)); - const leadSegs = chop(snapFrames(leadSrc.total * leadSrc.fps), base, dk); + const leadSegs = chop(snapFrames(leadSrc.total * leadSrc.fps, "floor"), base, dk); // Each pool clip is its own queue read front to back: chance only picks WHICH clip to draw from next. - const queues = pool.map((sc) => ({ sc, src: playable(sc, p), cursor: 0, chunks: chop(snapFrames(playable(sc, p).total * playable(sc, p).fps), base, 1) })).filter((q) => q.chunks.length); + const queues = pool.map((sc) => ({ sc, src: playable(sc, p), cursor: 0, chunks: chop(snapFrames(playable(sc, p).total * playable(sc, p).fps, "floor"), base, 1) })).filter((q) => q.chunks.length); if (!leadSegs.length || !queues.length) return 0; const next = () => { let open = queues.filter((q) => q.cursor < q.chunks.length); diff --git a/app/ui/features/montage/build.test.js b/app/ui/features/montage/build.test.js index 7e843f49..d38c317d 100644 --- a/app/ui/features/montage/build.test.js +++ b/app/ui/features/montage/build.test.js @@ -46,3 +46,15 @@ test("build: nothing is built without a rendered lead and a rendered pool; the p assert.equal(build(p, { leadId: "L", poolIds: ["N"] }), 0); assert.equal(JSON.stringify(p), before); }); + +test("build: deleting a piece leaves no ghost, and an off-grid clip is never read past its end", () => { + const p = project(); + p.scene_renders.L = { media: media("L") }; + p.scenes[0].frames = 103; p.scenes[0].frames_mode = "timeline"; // 103 is off the 8k+1 grid; its nearest grid length, 105, is past the end + build(p, { leadId: "L", poolIds: ["A"], segmentFrames: 33, decay: 1, random: () => 0 }); + const lastLead = p.scenes.filter((s) => s.reused).filter((_, i) => i % 2 === 0).pop(); + assert.ok(p.scene_renders[lastLead.id].inSec * 25 + lastLead.frames <= 103, "no piece plays past the clip"); + const piece = p.scenes.find((s) => s.reused); + removeScene(p, piece.id); + assert.equal((p.scene_ghosts || []).some((g) => g.id === piece.id), false); +}); diff --git a/app/ui/features/montage/index.js b/app/ui/features/montage/index.js index f04bdad9..a051c127 100644 --- a/app/ui/features/montage/index.js +++ b/app/ui/features/montage/index.js @@ -23,7 +23,7 @@ export default { let lead = (picked[0] || list[0]).id; const pool = new Set(picked.slice(1).map((s) => s.id)); const leadFrames = Math.round(playable(picked[0] || list[0], p.project).total * playable(picked[0] || list[0], p.project).fps); - let seg = Math.max(9, snapFrames(leadFrames / 4, "floor")), decay = 0.85; // about four lead pieces, not one lump + let seg = Math.max(9, snapFrames(leadFrames / 2.5, "floor")), decay = 0.85; // a handful of lead pieces, not one lump and not a flicker const label = (s) => `${snippet(s)} — ${Math.round(playable(s, p.project).total * playable(s, p.project).fps)}f`; const body = c.region.stack({ gap: "md" }); const draw = () => body.set([ diff --git a/app/ui/features/settings/field.js b/app/ui/features/settings/field.js index 5cb9c936..82f133a0 100644 --- a/app/ui/features/settings/field.js +++ b/app/ui/features/settings/field.js @@ -3,7 +3,7 @@ import { composer as c } from "../../composer/composer.js"; /** A `when` clause names sibling settings on the same module that must hold a value for the row to show. */ -export const whenSatisfied = (values, id, when) => !when || Object.entries(when).every(([k, v]) => (values[id] || {})[k] === v); +export const whenSatisfied = (values, id, when) => !when || Object.entries(when).every(([k, v]) => (Array.isArray(v) ? v : [v]).includes((values[id] || {})[k])); export function settingRow(id, name, spec, values, onChange) { const label = spec.label || name; diff --git a/app/ui/features/tour/index.js b/app/ui/features/tour/index.js index f5ef42fd..0a25980b 100644 --- a/app/ui/features/tour/index.js +++ b/app/ui/features/tour/index.js @@ -11,7 +11,7 @@ const STEPS = [ { at: zone(2), side: "left", title: "Properties", text: "Project settings (size, length, postfix) and the picked scene's prompt, source, references and length." }, { at: zone(3), side: "top", title: "Timeline", text: "One clip per scene. Drag the edges to trim, press S to split, Space to play. Lanes below carry separated sound." }, { at: button("◆ Composer"), side: "top", title: "Composer", text: "Write the whole story in one box, cut at the cut word; keep templates, $variables and shortcuts; chat with the prompt enhancer." }, - { at: button("▶ Generate"), side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes and video clips; an earlier render of a scene stays as a take. “Generate this scene” in Properties makes just the picked one; rate a result so FunPack learns your taste." }, + { at: button("▶ Generate"), side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes, video clips and Auto Montage pieces; an earlier render of a scene stays as a take. “Generate this scene” in Properties makes just the picked one; rate a result so FunPack learns your taste." }, { at: () => document.querySelector(".cx-frame-main"), side: "bottom", title: "FAQ", text: "Nothing happens? Check the ComfyUI chip at the top right and the Log window. Wrong look? Settings ▸ Appearance. Lost a layout? View ▸ Reset Layout. Lost a project? File ▸ Open recent. That is the tour: Help ▸ Start tour runs it again." }, ]; diff --git a/app/ui/features/welcome/index.js b/app/ui/features/welcome/index.js index f1e7c540..63ea4812 100644 --- a/app/ui/features/welcome/index.js +++ b/app/ui/features/welcome/index.js @@ -38,7 +38,7 @@ export default { const recent = app.project.project; const done = () => { markSeen(); if (win) win.close("done"); }; const tiles = c.region.stack({ gap: "sm", children: [ - card("Update", "Pull the newest version from origin and restart ComfyUI", () => app.maintenance.update()), card("Switch branch", "Pick another branch", () => app.maintenance.switchBranch()), + card("Update", "Pull the newest version from origin; ComfyUI restarts if it changed and nothing is generating", () => app.maintenance.update()), card("Switch branch", "Pick another branch", () => app.maintenance.switchBranch()), card("Restart ComfyUI", "Reload the server without updating", () => app.maintenance.restart()), card("Rollback update", "Undo the last update", () => app.maintenance.rollback())] }); tiles.node.classList.add("fp-tiles"); win = c.modal.generic({ diff --git a/app/ui/shell/edits.js b/app/ui/shell/edits.js index dacca4e7..6c7b699f 100644 --- a/app/ui/shell/edits.js +++ b/app/ui/shell/edits.js @@ -22,6 +22,7 @@ function ghostOrDrop(p, sc, afterId, pending) { const renders = p.scene_renders || (p.scene_renders = {}); const r = renders[sc.id]; delete renders[sc.id]; + if (sc.reused) return; // a montage piece is a copy: nothing to regenerate, so nothing to leave behind p.scene_ghosts = (p.scene_ghosts || []).filter((g) => g.id !== sc.id); if (!(r && r.media) && !pending) return; p.scene_ghosts.push({ diff --git a/modules/sampling/context_windows/__init__.py b/modules/sampling/context_windows/__init__.py index 58f9fa8c..e6d6b9dd 100644 --- a/modules/sampling/context_windows/__init__.py +++ b/modules/sampling/context_windows/__init__.py @@ -41,7 +41,7 @@ SETTINGS = { "when": {"enabled": True}}, "overlap": {"type": "int", "default": 40, "min": 0, "max": 512, "step": 8, "label": "Window overlap (frames)", "hint": "Frames neighbouring windows share. More overlap joins smoother but runs slower; it must be shorter than the window.", - "when": {"enabled": True}}, + "when": {"enabled": True, "schedule": [v for v in SCHEDULES if v != "batched"]}}, "schedule": {"type": "enum", "default": "standard_uniform", "label": "Window schedule", "hint": "standard_uniform: windows shift a little each step. standard_static: the same windows every step. looped_uniform: the end wraps round to the start. batched: back-to-back windows with no overlap.", "options": [{"value": v, "label": v} for v in SCHEDULES], "when": {"enabled": True}},