diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a143102..6fb5096a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,90 @@
## [Unreleased]
+## [3.4.1] - 2026-07-31
+
+### Added
+- **A second sampling pass on the Chain Sampler.** Give it a schedule and every scene is
+ sampled twice: pass 1 runs the main sigmas in full, then pass 2 runs its own schedule in
+ full, starting from pass 1's finished clip. Nothing is cut short and nothing is derived —
+ total steps are simply the two added up — and pass 2 re-enters through ComfyUI's ordinary
+ img2img noise scaling, so **its first sigma is a literal strength dial** (0.8 reworks the
+ shot, 0.4 polishes, 0.2 is detail work). The i2v anchor stays pinned across the split. In
+ the Cutting Room it is one field, **Second pass schedule**, under FunPack Studio ▸ Sampler
+ algorithm: typing a schedule enables the pass, clearing it turns the pass off.
+- **An optional operation between the two passes** (`none` by default — nothing runs unless
+ you pick one). `sharpen` runs one forward of Lightricks' trained 2× latent upsampler and
+ resamples straight back to the original size: no video-model calls, a fraction of a step,
+ and pass 2 then re-denoises the result, which is what makes it stick. `upscale_2x` keeps
+ the 2×, so pass 2 runs at four times the pixels and the scene decodes at double resolution
+ (3–5× the cost of the second half, and the i2v pin is dropped — the scene report says so).
+ Both use the same upsampler file segmented detailing does, and the model picker now appears
+ alongside the operation instead of only under segmented detailing.
+- **Cut the opening off i2v scenes** (`cut_opening_frames`, Chain Sampler ▸ Timing & Seed).
+ The anchor is a pinned frame at position 0: it carries identity, style and composition
+ better than anything that weakens it on the way in — and it is also literally the first
+ frame you see, so every i2v scene opens on the exact reference still. The scene is
+ generated exactly as normal, anchor at full strength, **no extra sampling**, and the
+ opening is then cut off the finished clip: an i2v generation that reads as t2v. Nothing is
+ regrown, so the scene comes out that much shorter and the audio is cropped to match.
+- **Context windows** for scenes longer than the model's comfortable window (ComfyUI core's
+ own mechanism, audio-aware on LTX — nothing ported). Engages only past the window length,
+ so shorter scenes pay nothing.
+- **The progress readout says what is running**, not just a step count: `sampling 17/26 ·
+ scene 2/3 · pass 2 of 2`. The second pass also announces itself on the ComfyUI console the
+ moment it starts, rather than only in the run report afterwards.
+- **Prompt `$name` variables in Easy Gen** — the shorthand-for-a-full-phrase layer the
+ Cutting Room's Composer already had (`$vid = "High quality, high fidelity realistic video,
+ motion blur, cinematic fog"`). A **$ Variables** button in the prompt bar opens its own
+ window with editable name/value rows, a count badge, and a live warning for names used but
+ never declared or for a variable that references itself. Same `project.variables` field as
+ the Cutting Room, so a project carries its variables between the two UIs; substitution
+ still happens inside Studio dead-last — after shortcut expansion and after the scene split
+ — which is what makes a `$var` work *inside* a shortcut's replacement while a value
+ containing a comma or a trigger word can never move a scene cut.
+- **An Interrupt button in Easy Gen.** The progress panel now carries the same
+ **■ Interrupt** control the Cutting Room's player has — Easy Gen previously had no way to
+ stop a run short of the ComfyUI queue. A stopped run reports "Generation stopped." instead
+ of a failure (an interrupted job is recorded as an error with no media, which is
+ indistinguishable from a real crash at the API level), and any partial media it did write
+ is still shown.
+
+- **Easy Gen re-attaches to a running generation after a page reload** — the Cutting Room
+ already did.
+
+### Changed
+- **Variable values are now auto-growing text boxes** (both UIs) instead of one-line inputs —
+ a long phrase was effectively uneditable in a single-line field.
+- **Plateau step-cache and context windows are mutually exclusive** — the cache cannot tell
+ one window from another within a step, so it is skipped with a note in the scene report.
+
+### Fixed
+- **Context windows never worked.** The schedule names were ComfyUI core's spelled backwards
+ (`uniform_standard` where core says `standard_uniform`), so every choice except `batched`
+ raised a `ValueError` out of the sampler and **failed the whole render**. Core's names are
+ the choices now; the old spellings are still accepted and mapped onto them, so a saved
+ project keeps generating. An unknown name is refused with a reason naming what your
+ ComfyUI accepts, instead of escaping as an exception.
+- The same call also passed a keyword core's window handler does not always take, which was
+ reported as *"ComfyUI core too old"* — silently switching off a feature that core fully
+ supports. Unsupported keywords are dropped now, with a note naming the one setting that
+ degrades.
+- **A rejected prompt now says why.** ComfyUI's top-level error for a validation failure is a
+ fixed blob ("Prompt outputs failed validation", no details); the part naming the node, the
+ widget and the bad value is in `node_errors`, which the editor never read — so every
+ rejected prompt reported the same nothing. Both are shown.
+- **Bypass no longer refuses a node over an output nothing uses.** A node that emits an extra
+ output the graph never wires (an IC-LoRA loader's `latent_downscale_factor`, say) could not
+ be bypassed at all, because a passthrough was demanded for every output rather than for the
+ ones actually consumed.
+- **The progress bar counted only the first pass**, so a second pass overflowed it and then
+ jumped backwards at the next scene.
+- **Identity transfer** follows ComfyUI's new RoPE matrix layout.
+- **Models & Pipeline** treats ComfyUI V3 MultiType widgets as widgets rather than required
+ sockets, so a node using them no longer reads as unsatisfied.
+- The latent upsampler loads on ComfyUI v0.29.0, which moved it onto DynamicVram and made
+ `operations` a required argument.
+
## [3.4.0] - 2026-07-24
### Added
diff --git a/README.md b/README.md
index 71fc8afb..0c646b0c 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,14 @@ and several bugfixes (a bypass toggle that could silently do nothing, a state-sy
could revert Models & Pipeline edits, and more) — see the [CHANGELOG](CHANGELOG.md) for the
full list.
+**3.4.1** adds a **second sampling pass** on the Chain Sampler — give it a schedule and every
+scene is sampled twice, with an optional latent sharpen/2× upscale between the passes — plus
+**cut the opening** for i2v scenes (keep the anchor's identity transfer at full strength, then
+cut the reference still out of the finished clip), **context windows** for scenes longer than
+the model's comfortable window, and a progress readout that says which scene and which pass is
+running. Context windows, which never actually worked before, are fixed — see the
+[CHANGELOG](CHANGELOG.md) for the full list.
+
See [`docs/MovieEditor.md`](docs/MovieEditor.md) for complete Cutting Room documentation.
## Installation
diff --git a/detailing.py b/detailing.py
index fe01765a..ed075251 100644
--- a/detailing.py
+++ b/detailing.py
@@ -169,7 +169,16 @@ def load_latent_upsampler(model_name):
raise ValueError(
f"{model_name} is not an LTX latent upsampler (expected post_upsample_res_blocks keys).")
config = json.loads(metadata["config"])
- model = LatentUpsampler.from_config(config).to(
+ # ComfyUI v0.29.0 (upstream f8a3fd9d) moved the upsampler onto DynamicVram and made
+ # `operations` a required argument of from_config; older cores build nn.Modules
+ # directly and reject the kwarg. Rentals run whatever they pulled, so accept both.
+ try:
+ import comfy.ops
+
+ model = LatentUpsampler.from_config(config, operations=comfy.ops.disable_weight_init)
+ except TypeError:
+ model = LatentUpsampler.from_config(config)
+ model = model.to(
dtype=comfy.model_management.vae_dtype(allowed_dtypes=[torch.bfloat16, torch.float32]))
model.load_state_dict(sd)
model.eval()
diff --git a/easy_gen/frontend/app.js b/easy_gen/frontend/app.js
index 2c2b114e..36a7d8ea 100644
--- a/easy_gen/frontend/app.js
+++ b/easy_gen/frontend/app.js
@@ -1,8 +1,7 @@
// Easy Gen entrypoint: wires the big-preview / prompt / upload / generate bar
// to the shared easy_store.js + preview.js, and opens the "FunPack Easy Gen"
-// project picker on boot. No re-attach-on-reload support yet (the Editor's
-// /api/active flow) — a reload while a run is in flight loses the progress
-// view, though the run itself still completes server-side.
+// project picker on boot. Reloading during a run re-attaches to it via the
+// Editor's /api/active flow — see reattachRunning() below.
(function () {
const S = window.Store;
const API = window.MovieEditorAPI;
@@ -45,11 +44,21 @@
}
preview.setOnClearUpload(removeUpload);
+ // True whenever a run of ours is in flight — a fresh Generate OR one re-attached after a
+ // reload. render() runs on every store notify (health poll, keystroke), so without this
+ // it would re-enable Generate underneath a running generation.
+ let generating = false;
+ function setGenerating(on) {
+ generating = on;
+ generateBtn.disabled = on || !S.get().project;
+ generateBtn.textContent = on ? "Generating…" : "Generate";
+ }
+
let lastProjectId = null;
function render(st) {
const p = st.project;
nameEl.textContent = p ? p.name : "No project";
- generateBtn.disabled = !p;
+ generateBtn.disabled = generating || !p;
uploadBtn.disabled = !p;
galleryBtn.disabled = !p;
saveBtn.disabled = !p;
@@ -66,9 +75,13 @@
const pid = p ? p.id : null;
if (pid !== lastProjectId) {
lastProjectId = pid;
- preview.stopPolling();
- preview.showEmpty();
- generateBtn.textContent = "Generate";
+ // Not while a run is in flight: the generation is still going server-side, and
+ // tearing down its poll here would orphan it exactly like a reload used to.
+ if (!generating) {
+ preview.stopPolling();
+ preview.showEmpty();
+ generateBtn.textContent = "Generate";
+ }
}
}
S.subscribe(render);
@@ -124,19 +137,17 @@
alert("Type a prompt first.");
return;
}
- generateBtn.disabled = true;
- generateBtn.textContent = "Generating…";
+ setGenerating(true);
try {
await S.save();
const resetSession = S.takeResetSessionFlag();
const res = await API.generate(st.project.id, null, null, resetSession, EASY_MODE_OVERRIDES);
preview.watch(st.project.id, res.prompt_id, {
- onDone: () => { generateBtn.disabled = false; generateBtn.textContent = "Generate"; },
+ onDone: () => setGenerating(false),
});
} catch (e) {
preview.showError("Generate failed: " + (e.message || e));
- generateBtn.disabled = false;
- generateBtn.textContent = "Generate";
+ setGenerating(false);
}
};
@@ -177,6 +188,42 @@
}
};
+ // Re-attach to a generation that was already running when the page (re)loaded. Easy Gen
+ // queues through the Editor's own /generate route, so its prompts are already stamped
+ // with the editor client_id + run identity and come back from /api/active — nothing was
+ // missing server-side, Easy Gen simply never asked. Same shape as the Cutting Room's
+ // store.js resumeRunningGeneration, minus the per-scene recording it has no UI for.
+ async function reattachRunning() {
+ let act = null;
+ try { act = await API.active(); } catch (_) { return; }
+ let last = null;
+ // Loop: a montage queued from the Cutting Room can have further runs behind the one
+ // that happens to be executing now, and Generate should stay blocked until they drain.
+ while (act && act.running && act.prompt_id && act.prompt_id !== last) {
+ last = act.prompt_id;
+ setGenerating(true);
+ const st = S.get();
+ const queued = act.pending > 0 ? ` · ${act.pending} more queued` : "";
+ if (st.project && act.pid && st.project.id === act.pid) {
+ // Ours, and the project it targets is loaded — full re-attach, result included.
+ await new Promise((resolve) => {
+ preview.watch(act.pid, act.prompt_id, {
+ onDone: () => resolve(),
+ note: `Reconnected after reload${queued}`,
+ });
+ });
+ } else {
+ // Started against a different project (typically from the Cutting Room). Do NOT
+ // switch projects out from under the user to claim it — just watch the queue so
+ // Generate stays blocked and Interrupt is offered while the GPU is busy.
+ await preview.monitor(
+ act.prompt_id, `A generation is running elsewhere${queued}`);
+ }
+ try { act = await API.active(); } catch (_) { act = null; }
+ }
+ setGenerating(false);
+ }
+
async function boot() {
S.refreshHealth();
S.loadLibraries();
@@ -189,10 +236,15 @@
});
const lastId = localStorage.getItem(LAST_PROJECT_KEY);
+ let loaded = false;
if (lastId) {
- try { await S.loadProject(lastId); return; } catch (_) { /* fall through to picker */ }
+ try { await S.loadProject(lastId); loaded = true; } catch (_) { /* fall through to picker */ }
}
- window.ProjectMenu.open({ dismissable: false });
+ if (!loaded) window.ProjectMenu.open({ dismissable: false });
+ // After the project is in place, so a run targeting it re-attaches fully (result and
+ // all) instead of falling back to the queue-watch path. Not awaited — it runs for as
+ // long as the generation does.
+ reattachRunning();
}
boot();
diff --git a/easy_gen/frontend/easy.css b/easy_gen/frontend/easy.css
index da5908a1..b3adc30b 100644
--- a/easy_gen/frontend/easy.css
+++ b/easy_gen/frontend/easy.css
@@ -58,13 +58,16 @@ a.brand:hover .brand-sub { color: var(--amber-2); }
height: 6px; border-radius: 4px; background: linear-gradient(90deg, var(--amber), var(--amber-2));
width: 0%; transition: width .2s ease; margin-bottom: 6px;
}
-.easy-progress-label { font-size: 12px; color: var(--muted); font-family: var(--font-mono); }
+.easy-progress-row { display: flex; align-items: center; gap: 10px; }
+.easy-progress-label { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); font-family: var(--font-mono);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.easy-progress-stop:disabled { opacity: .6; cursor: default; }
.easy-bar {
flex: none; display: flex; align-items: flex-end; gap: 12px;
padding: 14px 16px; border-top: 1px solid var(--line); background: var(--ink-1);
}
-.easy-upload-btn, .easy-gallery-btn { flex: none; align-self: stretch; padding: 0 16px; }
+.easy-upload-btn, .easy-gallery-btn, .easy-vars-btn { flex: none; align-self: stretch; padding: 0 16px; }
.easy-prompt {
flex: 1; resize: none; background: var(--ink-2); border: 1px solid var(--line);
border-radius: var(--radius); color: var(--text); font: 14px/1.4 var(--font-ui);
@@ -75,6 +78,26 @@ a.brand:hover .brand-sub { color: var(--amber-2); }
.easy-generate-btn { padding: 10px 26px; font-size: 14px; }
.easy-advanced-btn { align-self: center; }
+/* Prompt $name variables — own modal window, opened from the header button. */
+.easy-vars-hint { color: var(--muted); font-size: 12px; line-height: 1.5; margin-bottom: 8px; }
+.easy-vars-hint.easy-var-warn { color: var(--amber); }
+.easy-vars-empty { color: var(--faint); font-size: 12px; }
+.easy-var-list { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
+.easy-var-row { display: flex; gap: 6px; align-items: flex-start; width: 100%; }
+.easy-var-row > .easy-var-dollar, .easy-var-row > .easy-var-eq, .easy-var-row > .btn { margin-top: 6px; }
+.easy-var-dollar { color: var(--amber); font-family: var(--font-mono); opacity: .8; }
+.easy-var-eq { color: var(--muted); }
+.easy-var-in {
+ background: var(--ink-2); border: 1px solid var(--line); border-radius: var(--radius);
+ color: var(--text); font: 13px/1.4 var(--font-ui); padding: 6px 9px; min-width: 0;
+}
+.easy-var-in:focus { outline: none; border-color: var(--amber); }
+.easy-var-name { flex: 0 0 22%; font-family: var(--font-mono); }
+/* Value is an auto-growing textarea (JS sets the height) — no manual resize handle, no scrollbar. */
+.easy-var-val { flex: 1; resize: none; overflow: hidden; }
+.easy-var-row .btn { flex: 0 0 auto; }
+#easy-vars-btn.easy-vars-warn { color: var(--amber); border-color: var(--amber); }
+
.es-io-controls { display: flex; align-items: center; gap: 8px; }
.es-io-mode { background: var(--ink-2); border: 1px solid var(--line); color: var(--text);
border-radius: 6px; font-size: 12px; padding: 4px 6px; }
diff --git a/easy_gen/frontend/easy_store.js b/easy_gen/frontend/easy_store.js
index c1deaafa..a4a3e6cf 100644
--- a/easy_gen/frontend/easy_store.js
+++ b/easy_gen/frontend/easy_store.js
@@ -292,6 +292,22 @@
_scheduleEngineSave(600);
}
+ // ── prompt variables ($name) ───────────────────────────────────────────────
+ // Project-scoped find/replace, stored in the same project.variables field the Editor
+ // uses; the generate endpoint already forwards it to the builder, and Studio resolves
+ // it dead-last (after shortcut expansion + the scene split) inside refine_v2.
+ function projectVariables() { return (state.project && state.project.variables) || []; }
+ function setProjectVariables(list) {
+ if (!state.project) return;
+ // A leading $ is stripped so "$vid" and "vid" declare the same variable. Fully-empty
+ // rows are dropped on persist (the UI keeps showing them while they're being filled in),
+ // so an accidental "Add variable" click never sticks to the project.
+ patchProjectQuiet({ variables: (Array.isArray(list) ? list : []).map((v) => ({
+ name: String((v && v.name) || "").replace(/^\$+/, "").trim(),
+ value: String((v && v.value != null) ? v.value : ""),
+ })).filter((v) => v.name || v.value) });
+ }
+
function setConditioningSlot(slotId) { patchProject({ conditioning_slot: slotId || "funpack" }); }
function setSamplerSlot(slotId) { patchProject({ sampler_slot: slotId || "funpack" }); }
function setSamplerInput(name, value) {
@@ -359,6 +375,7 @@
applyGlobalPrompt, scheduleGlobalPromptApply,
setSceneMedia, save,
patchProject, patchProjectQuiet,
+ projectVariables, setProjectVariables,
setConditioningSlot, setSamplerSlot,
setSamplerInput, setSamplerInputNow, unsetSamplerInput,
setStudioInput, setStudioInputNow,
diff --git a/easy_gen/frontend/easy_variables.js b/easy_gen/frontend/easy_variables.js
new file mode 100644
index 00000000..5f8d9d75
--- /dev/null
+++ b/easy_gen/frontend/easy_variables.js
@@ -0,0 +1,225 @@
+// Easy Gen's prompt `$name` variables — quick replacements for full phrases,
+// e.g. $vid = "High quality, high fidelity realistic video, motion blur, cinematic fog".
+//
+// Opens as its own modal off the header's "$ Variables" button (same modal-overlay/.modal
+// shell as easy_gallery.js / project_menu.js) — deliberately NOT docked into the main
+// screen, which stays prompt + preview only.
+//
+// Storage is project.variables ([{name, value}]) — the SAME project field the Editor's
+// Composer writes and the same one movie_editor/server.py already hands to the builder on
+// every generate ("variables": list(target.variables or [])), so nothing backend-side is
+// new: templates.resolve_variables substitutes them inside refine_v2 DEAD LAST, after
+// shortcut expansion and after the transition split. That order is what makes them work
+// INSIDE shortcuts (a shortcut whose replacement contains $vid still resolves) while a
+// value containing a comma or a trigger word can never move a scene cut.
+//
+// Undefined names stay literal ($foo passes through as text) and a self-referencing chain
+// is left literal rather than looping — the hint line warns about both before you burn a
+// generation on it.
+(function () {
+ const { el, clear } = window.dom;
+ const S = window.Store;
+
+ const toggleBtn = document.getElementById("easy-vars-btn");
+ const promptEl = document.getElementById("easy-prompt");
+ if (!toggleBtn) return;
+
+ let overlay = null;
+ let listEl = null;
+ let hintEl = null;
+
+ function vars() {
+ return (S.projectVariables() || []).map((v) => ({
+ name: String((v && v.name) || ""),
+ value: String((v && v.value != null) ? v.value : ""),
+ }));
+ }
+
+ // Cycle detection over the declared variables — mirrors the safe-degrade guard in
+ // templates.resolve_variables, but reports the loop instead of silently leaving it literal.
+ function detectCycles() {
+ const map = {};
+ (S.projectVariables() || []).forEach((v) => {
+ const k = String((v && v.name) || "").replace(/^\$+/, "").trim();
+ if (k) map[k] = String((v && v.value) || "");
+ });
+ const refs = (n) => (String(map[n] || "").match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || [])
+ .map((m) => m.slice(1)).filter((r) => r in map);
+ const color = {}, path = [], cycles = [];
+ const dfs = (n) => {
+ color[n] = 1; path.push(n);
+ for (const r of refs(n)) {
+ if (color[r] === 1) cycles.push(path.slice(path.indexOf(r)).concat(r));
+ else if (!color[r]) dfs(r);
+ }
+ path.pop(); color[n] = 2;
+ };
+ Object.keys(map).forEach((n) => { if (!color[n]) dfs(n); });
+ return cycles;
+ }
+
+ // A cycle wins over the undeclared list. Only the visible prompt is scanned — a $var used
+ // solely inside a shortcut's replacement text is legitimate and stays silent.
+ function warning() {
+ const cycles = detectCycles();
+ if (cycles.length) {
+ return {
+ bad: true,
+ msg: "⚠ Variable loop: " + cycles[0].map((n) => "$" + n).join(" → ")
+ + " — references itself. Rework it; it won't expand.",
+ };
+ }
+ const declared = new Set((S.projectVariables() || [])
+ .map((v) => String((v && v.name) || "").replace(/^\$+/, "").trim()).filter(Boolean));
+ const txt = promptEl ? promptEl.value : "";
+ const used = new Set((txt.match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((m) => m.slice(1)));
+ const undeclared = [...used].filter((n) => !declared.has(n));
+ if (undeclared.length) {
+ return {
+ bad: false,
+ msg: "Referencing " + undeclared.map((n) => "$" + n).join(", ")
+ + " — never declared, passed through as plain text.",
+ };
+ }
+ return { bad: false, msg: "" };
+ }
+
+ // Header button: enabled only with a project, carries the count, and goes amber on a loop
+ // so a broken variable is visible without opening the window.
+ function updateButton() {
+ const st = S.get();
+ toggleBtn.disabled = !st.project;
+ const n = (S.projectVariables() || []).filter((v) => String((v && v.name) || "").trim()).length;
+ toggleBtn.textContent = "$ Variables" + (n ? ` (${n})` : "");
+ toggleBtn.classList.toggle("easy-vars-warn", !!warning().bad);
+ }
+
+ function updateHint() {
+ if (!hintEl) return;
+ const w = warning();
+ hintEl.textContent = w.msg;
+ hintEl.classList.toggle("easy-var-warn", !!w.bad);
+ hintEl.style.display = w.msg ? "" : "none";
+ updateButton();
+ }
+
+ // Grow a value textarea to fit its text (measured after a reset to "auto", or it can only
+ // ever get taller). Deferred once on mount because scrollHeight reads 0 before layout.
+ function autoGrow(ta) {
+ const fit = () => {
+ ta.style.height = "auto";
+ // scrollHeight covers content + padding; under border-box the borders must be added
+ // back or every line is clipped by ~2px and the textarea scrolls anyway.
+ const cs = getComputedStyle(ta);
+ const border = cs.boxSizing === "border-box"
+ ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)
+ : -(parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom));
+ ta.style.height = (ta.scrollHeight + (border || 0)) + "px";
+ };
+ fit();
+ if (!ta.dataset.grown) { ta.dataset.grown = "1"; setTimeout(fit, 0); }
+ }
+
+ // Working copy for the open window. It is the source of truth for what's on screen —
+ // a half-typed row (blank name AND value) is dropped by the store on persist, so
+ // re-reading the project after every edit would delete the row being filled in.
+ let rows = [];
+
+ function renderList() {
+ if (!listEl) return;
+ clear(listEl);
+ // Every keystroke commits to the project (debounced save); only add/remove re-renders,
+ // so an input never loses focus mid-word.
+ const commit = () => { S.setProjectVariables(rows); updateHint(); };
+
+ rows.forEach((v, i) => {
+ const row = el("div", "easy-var-row");
+ row.append(el("span", "easy-var-dollar", "$"));
+ const nm = el("input", "easy-var-in easy-var-name");
+ nm.value = v.name; nm.placeholder = "name";
+ nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); };
+ row.append(nm);
+ row.append(el("span", "easy-var-eq", "="));
+ // Values are usually long phrases, so this is a textarea that grows with its content —
+ // a one-line input makes editing "High quality, high fidelity realistic video, …" a
+ // horizontal-scrolling guessing game.
+ const vv = el("textarea", "easy-var-in easy-var-val");
+ vv.rows = 1;
+ vv.value = v.value; vv.placeholder = "value (may reference $other)";
+ vv.oninput = () => { v.value = vv.value; autoGrow(vv); commit(); };
+ row.append(vv);
+ autoGrow(vv);
+ const rm = el("button", "btn ghost tiny", "✕");
+ rm.type = "button";
+ rm.title = "Remove variable";
+ rm.onclick = () => { rows.splice(i, 1); S.setProjectVariables(rows); renderList(); updateHint(); };
+ row.append(rm);
+ listEl.append(row);
+ });
+ if (!rows.length) listEl.append(el("div", "easy-vars-empty", "No variables yet."));
+
+ const add = el("button", "btn ghost tiny easy-var-add", "+ Add variable");
+ add.type = "button";
+ add.onclick = () => { rows.push({ name: "", value: "" }); S.setProjectVariables(rows); renderList(); updateHint(); };
+ listEl.append(add);
+ }
+
+ function close() {
+ if (overlay) { overlay.remove(); overlay = null; }
+ listEl = null; hintEl = null;
+ updateButton();
+ }
+
+ function open() {
+ if (!S.get().project) return;
+ close();
+ overlay = el("div", "modal-overlay");
+ const box = el("div", "modal modal-wide");
+
+ const head = el("div", "modal-head");
+ head.append(el("div", "modal-title", "Variables"));
+ const x = el("button", "btn ghost tiny", "✕");
+ x.onclick = close;
+ head.append(el("div", "modal-head-right"), x);
+ box.append(head);
+
+ const content = el("div", "modal-content easy-vars");
+ content.append(el("div", "easy-vars-hint",
+ "Shorthand for full phrases. Type $name in the prompt (or inside a shortcut's replacement) "
+ + "and it's swapped for the text below at generation — after shortcuts expand and after the "
+ + "prompt splits into scenes, so a value can never move a scene cut."));
+ hintEl = el("div", "easy-vars-hint easy-var-hint");
+ content.append(hintEl);
+ listEl = el("div", "easy-var-list");
+ content.append(listEl);
+ box.append(content);
+
+ overlay.append(box);
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
+ document.addEventListener("keydown", function onKey(e) {
+ if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
+ });
+ document.body.append(overlay);
+
+ rows = vars();
+ renderList();
+ updateHint();
+ }
+
+ toggleBtn.onclick = () => (overlay ? close() : open());
+
+ // A project switch replaces the whole variable set — rebuild the open window's rows.
+ let renderedProjectId = null;
+ S.subscribe((st) => {
+ const pid = st.project ? st.project.id : null;
+ if (pid !== renderedProjectId) {
+ renderedProjectId = pid;
+ if (overlay) { rows = vars(); renderList(); updateHint(); return; }
+ }
+ updateButton();
+ });
+ if (promptEl) promptEl.addEventListener("input", () => { if (overlay) updateHint(); else updateButton(); });
+
+ updateButton();
+ window.EasyVariables = { open, close };
+})();
diff --git a/easy_gen/frontend/index.html b/easy_gen/frontend/index.html
index 095ad4a7..1a13a2e0 100644
--- a/easy_gen/frontend/index.html
+++ b/easy_gen/frontend/index.html
@@ -69,6 +69,8 @@
title="Upload an image or video to guide generation">⬆ Upload
+
@@ -98,6 +100,7 @@
+
diff --git a/easy_gen/frontend/preview.js b/easy_gen/frontend/preview.js
index 9902058d..00b462b0 100644
--- a/easy_gen/frontend/preview.js
+++ b/easy_gen/frontend/preview.js
@@ -22,7 +22,14 @@
progressWrap.hidden = true;
const bar = el("div", "easy-progress-bar");
const label = el("div", "easy-progress-label");
- progressWrap.append(bar, label);
+ // Same "■ Interrupt" affordance as the Cutting Room's player readout, and it lives in the
+ // progress panel so it only exists while there's something to stop.
+ const stopBtn = el("button", "gen-interrupt easy-progress-stop", "■ Interrupt");
+ stopBtn.type = "button";
+ stopBtn.title = "Stop the current generation";
+ const progressRow = el("div", "easy-progress-row");
+ progressRow.append(label, stopBtn);
+ progressWrap.append(bar, progressRow);
root.append(inner, closeBtn, badge, progressWrap);
let pollTimer = null;
@@ -132,14 +139,80 @@
draw();
}
- async function watch(projectId, promptId, { onDone } = {}) {
+ // Wire the Interrupt button for whatever run is being followed. Returns a getter for
+ // the "the user asked for this" flag, since an interrupted job is indistinguishable
+ // from a failed one at the API level and both callers below need to tell them apart.
+ function _armStopButton(onFlag) {
+ stopBtn.disabled = false;
+ stopBtn.textContent = "■ Interrupt";
+ stopBtn.onclick = async () => {
+ onFlag(true);
+ stopBtn.disabled = true;
+ stopBtn.textContent = "Interrupting…";
+ label.textContent = "Interrupting…";
+ try { await API.interrupt(); }
+ catch (e) {
+ // The run may well still be going — say so instead of leaving a dead button.
+ onFlag(false);
+ stopBtn.disabled = false;
+ stopBtn.textContent = "■ Interrupt";
+ label.textContent = "Could not interrupt: " + (e.message || e);
+ }
+ };
+ }
+
+ // Follow a run we cannot map to the loaded project — one queued from the Cutting Room,
+ // or from Easy Gen under a different project before a reload. There is no history to
+ // poll for a result here, so this just watches ComfyUI's queue via /api/active: enough
+ // to keep Generate blocked and offer Interrupt while the GPU is busy.
+ function monitor(promptId, note) {
+ return new Promise((resolve) => {
+ stopPolling();
+ errorMsg = null;
+ progressWrap.hidden = false;
+ label.textContent = note || "A generation is running…";
+ bar.style.width = "0%";
+ let interrupted = false;
+ _armStopButton((v) => { interrupted = v; });
+ const tick = async () => {
+ let act = null;
+ try { act = await API.active(); }
+ catch (_) { pollTimer = setTimeout(tick, 2000); return; } // transient — keep watching
+ if (!act || !act.running || act.prompt_id !== promptId) {
+ stopPolling();
+ progressWrap.hidden = true;
+ resolve();
+ return;
+ }
+ if (!interrupted) {
+ try {
+ const pr = await API.progress();
+ if (pr.max > 0) {
+ bar.style.width = Math.min(100, Math.round((pr.value / pr.max) * 100)) + "%";
+ label.textContent = `${note || "A generation is running"} · ${pr.value}/${pr.max}`;
+ }
+ } catch (_) { /* transient */ }
+ }
+ pollTimer = setTimeout(tick, 2000);
+ };
+ tick();
+ });
+ }
+
+ async function watch(projectId, promptId, { onDone, note } = {}) {
stopPolling();
errorMsg = null;
progressWrap.hidden = false;
- label.textContent = "Starting…";
+ label.textContent = note || "Starting…";
bar.style.width = "0%";
+ // An interrupted job lands in ComfyUI's history as status_str "error" with no media —
+ // indistinguishable from a real failure at the API level. Remember that the user asked
+ // for it so the poll below reports "stopped", not "Generation failed".
+ let interrupted = false;
+ _armStopButton((v) => { interrupted = v; });
progressTimer = setInterval(async () => {
+ if (interrupted) return; // don't paint "Sampling…" over "Interrupting…"
try {
const pr = await API.progress();
if (pr.max > 0) {
@@ -165,14 +238,19 @@
stopPolling();
progressWrap.hidden = true;
const media = res.media && res.media.length ? res.media[res.media.length - 1] : null;
- if (media) setGenerated(media, projectId); else showEmpty("Generation finished but produced no media.");
- if (onDone) onDone(true, res);
+ // A run stopped mid-sampling can still have written partial media — show it if so,
+ // it's the whole point of interrupting a long run you can already tell is wrong.
+ if (media) setGenerated(media, projectId);
+ else if (interrupted) showEmpty("Generation stopped.");
+ else showEmpty("Generation finished but produced no media.");
+ if (onDone) onDone(!interrupted, res);
return;
}
if (res.state === "error") {
stopPolling();
progressWrap.hidden = true;
- showError(res.error || "Generation failed.");
+ if (interrupted) showEmpty("Generation stopped.");
+ else showError(res.error || "Generation failed.");
if (onDone) onDone(false, res);
return;
}
@@ -188,6 +266,7 @@
showEmpty,
showError,
watch,
+ monitor,
stopPolling,
};
}
diff --git a/identity_transfer.py b/identity_transfer.py
index 172c26a3..a37d9627 100644
--- a/identity_transfer.py
+++ b/identity_transfer.py
@@ -121,25 +121,73 @@ def append_context_tokens(conditioning, tokens):
return out
+def _segment_phase(length, seg_value, theta, device, dtype):
+ """(cos, sin) of phase = seg_value * theta^(-d/length) over the frequency axis."""
+ d = torch.arange(length, device=device, dtype=torch.float32)
+ phase = seg_value * (theta ** (-d / float(length)))
+ return phase.cos().to(dtype), phase.sin().to(dtype)
+
+
def rotate_overlap_freqs(pe, ref_len, seg_value, theta=10000.0):
"""Rotate the LAST ref_len tokens' RoPE freqs by phase = seg_value * theta^(-d/L)
- (port of ltx_core rope.apply_segment_phase). pe = (cos, sin, [split_flag])."""
+ (port of ltx_core rope.apply_segment_phase).
+
+ This phase tag is what separates the appended reference tokens from the target's real
+ frame-0 tokens — they deliberately share the frame-0 coordinate grid, so without it the
+ model reads the reference as literal frame 0 and renders the clip's opening as the
+ reference image. Never skip it silently; raise instead (see samplers.py's prepare_pe).
+
+ ComfyUI ships the positional embeddings in two layouts, told apart at runtime:
+
+ * ``(cos, sin, split_mode)`` — tokens on axis -2, frequencies on -1. ComfyUI before
+ 7c59a07 (2026-07-24).
+ * ``(rotation_matrix, split_mode)`` — ``(B, T, heads, D, 2, 2)`` holding
+ ``[[cos, -sin], [sin, cos]]``, from ComfyUI 7c59a07 "Use comfy kitchen rope
+ functions in ltx models" onward.
+
+ Both encode R(angle); the phase composes onto it as R(angle + phase).
+ """
if ref_len <= 0 or seg_value == 0.0:
return pe
- cos, sin = pe[0], pe[1]
+ if not isinstance(pe, (list, tuple)) or len(pe) < 2:
+ raise TypeError(
+ f"unexpected LTX positional-embedding payload {type(pe).__name__} — "
+ f"cannot apply the identity source-phase tag")
+ head, second = pe[0], pe[1]
rest = tuple(pe[2:])
- L = cos.shape[-1]
- d = torch.arange(L, device=cos.device, dtype=torch.float32)
- rate = theta ** (-d / float(L))
- phase = seg_value * rate
- pc = phase.cos().to(cos.dtype)
- ps = phase.sin().to(sin.dtype)
- idx = [slice(None)] * cos.dim()
- idx[-2] = slice(cos.shape[-2] - ref_len, cos.shape[-2])
+
+ if torch.is_tensor(second):
+ # Legacy cos/sin pair: (..., T, L) for both the split and interleaved rope types.
+ cos, sin = head, second
+ pc, ps = _segment_phase(cos.shape[-1], seg_value, theta, cos.device, cos.dtype)
+ idx = [slice(None)] * cos.dim()
+ idx[-2] = slice(cos.shape[-2] - ref_len, cos.shape[-2])
+ idx = tuple(idx)
+ c0, s0 = cos[idx], sin[idx]
+ cos = cos.clone()
+ sin = sin.clone()
+ cos[idx] = c0 * pc - s0 * ps
+ sin[idx] = s0 * pc + c0 * ps
+ return (cos, sin, *rest)
+
+ # Rotation-matrix form. The 2x2 block is [[c, -s], [s, c]], so c/s read off column 0.
+ mat = head
+ if not torch.is_tensor(mat) or mat.dim() < 4 or tuple(mat.shape[-2:]) != (2, 2):
+ raise TypeError(
+ f"unexpected LTX rope matrix shape {tuple(mat.shape) if torch.is_tensor(mat) else type(mat).__name__} — "
+ f"expected (B, T, heads, D, 2, 2); cannot apply the identity source-phase tag")
+ if mat.shape[1] < ref_len:
+ raise ValueError(
+ f"LTX rope matrix has {mat.shape[1]} tokens on axis 1, fewer than the "
+ f"{ref_len} reference tokens to tag")
+ pc, ps = _segment_phase(mat.shape[-3], seg_value, theta, mat.device, mat.dtype)
+ idx = [slice(None)] * mat.dim()
+ idx[1] = slice(mat.shape[1] - ref_len, mat.shape[1]) # tokens live on axis 1 here
idx = tuple(idx)
- c0, s0 = cos[idx], sin[idx]
- cos = cos.clone()
- sin = sin.clone()
- cos[idx] = c0 * pc - s0 * ps
- sin[idx] = s0 * pc + c0 * ps
- return (cos, sin, *rest)
+ blk = mat[idx]
+ c0, s0 = blk[..., 0, 0], blk[..., 1, 0]
+ c1 = c0 * pc - s0 * ps
+ s1 = s0 * pc + c0 * ps
+ mat = mat.clone()
+ mat[idx] = torch.stack((c1, -s1, s1, c1), dim=-1).reshape(blk.shape)
+ return (mat, second, *rest)
diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py
index 6c2f342b..36993599 100644
--- a/movie_editor/backend/bridge.py
+++ b/movie_editor/backend/bridge.py
@@ -580,6 +580,38 @@ class ComfyError(RuntimeError):
EDITOR_CLIENT_ID = "funpack-movie-editor"
+def _format_prompt_rejection(data: dict, status: int) -> str:
+ """Why ComfyUI refused a prompt, in words.
+
+ Its top-level `error` for a validation failure is a fixed blob ("Prompt outputs failed
+ validation", details ""), and the part that names the node, the widget and the bad value
+ lives in `node_errors` — which the old `error or node_errors` never reached, because the
+ generic blob is always truthy. So every rejected prompt reported the same nothing.
+ """
+ parts = []
+ err = data.get("error")
+ if isinstance(err, dict):
+ head = str(err.get("message") or err.get("type") or "").strip()
+ detail = str(err.get("details") or "").strip()
+ if head:
+ parts.append(f"{head} ({detail})" if detail else head)
+ elif err:
+ parts.append(str(err))
+ for node_id, entry in (data.get("node_errors") or {}).items():
+ if not isinstance(entry, dict):
+ parts.append(f"node {node_id}: {entry}")
+ continue
+ who = str(entry.get("class_type") or f"node {node_id}")
+ for item in entry.get("errors") or []:
+ if not isinstance(item, dict):
+ parts.append(f"{who}: {item}")
+ continue
+ msg = str(item.get("message") or item.get("type") or "").strip()
+ detail = str(item.get("details") or "").strip()
+ parts.append(f"{who}: {msg}{f' — {detail}' if detail else ''}".strip())
+ return "; ".join(p for p in parts if p) or f"HTTP {status}"
+
+
async def queue_prompt(graph: dict, client_id: Optional[str] = None,
extra_data: Optional[dict] = None) -> dict:
payload = {"prompt": graph, "client_id": client_id or uuid.uuid4().hex}
@@ -589,7 +621,7 @@ async def queue_prompt(graph: dict, client_id: Optional[str] = None,
async with s.post(_url("/prompt"), json=payload) as r:
data = await r.json()
if r.status >= 400 or data.get("node_errors"):
- raise ComfyError(data.get("error") or data.get("node_errors") or f"HTTP {r.status}")
+ raise ComfyError(_format_prompt_rejection(data, r.status))
return data
@@ -658,8 +690,28 @@ def reset_progress() -> None:
def current_progress() -> dict:
+ """Sampler step counter, plus what the Chain Sampler says it is doing right now.
+
+ ComfyUI's progress channel is numbers only, so a chain that samples several scenes —
+ some of them twice, with a second pass — is one anonymous bar. The sampler publishes a
+ short phase label ("scene 2/3 · pass 2 of 2") that rides along here. Absent (an older
+ sampler, a non-FunPack job) it is simply an empty string and the UI shows the numbers
+ alone, exactly as before.
+ """
_install_progress_hook()
- return {"value": _progress["value"], "max": _progress["max"]}
+ label = ""
+ try:
+ # Read run_phase's state straight off `sys` — deliberately NO import on this path.
+ # The editor polls this every 700ms DURING sampling, on ComfyUI's event loop, so it
+ # must do nothing that can block behind the worker thread. run_phase keeps its state
+ # there anyway (its writer and any reader can hold different module objects), which
+ # makes the direct read both the cheapest and the correct one.
+ _phase = getattr(sys, "_funpack_run_phase", None)
+ if isinstance(_phase, dict):
+ label = str(_phase.get("label") or "")
+ except Exception: # noqa: BLE001 — a readout must never break the progress poll
+ pass
+ return {"value": _progress["value"], "max": _progress["max"], "label": label}
async def is_running(prompt_id: str) -> bool:
diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py
index f6b5d9d4..7cfc3174 100644
--- a/movie_editor/backend/builder.py
+++ b/movie_editor/backend/builder.py
@@ -25,7 +25,8 @@ from typing import Any, Optional
from . import config
from . import pipeline_wiring
-from .nodes import WIDGET_PRIMITIVES, connection_inputs, node_outputs, _combo_default, _combo_choices
+from .nodes import (WIDGET_PRIMITIVES, connection_inputs, node_outputs, type_accepts,
+ widget_type_of, _combo_default, _combo_choices)
# ── fixed core ────────────────────────────────────────────────────────────────
# logical id -> class_type
@@ -62,6 +63,12 @@ CORE_LINKS: dict[str, dict[str, tuple[str, int]]] = {
"cond": {"positive": ("studio", 1), "negative": ("studio", 2), "frame_rate": ("fps", 0)},
"sampler": {"model": ("studio", 0), "positive": ("cond", 0), "negative": ("cond", 1),
"sampler": ("studio", 4), "sigmas": ("studio", 5),
+ # Studio has always emitted a second schedule (low_pass_sigmas, output 7);
+ # the Editor just never wired it. second_pass reads it as pass 2's schedule
+ # and ignores it when empty, so this link costs nothing when the feature is
+ # off. Only the SIGMAS is taken — pass 2 reuses the main sampler object, so
+ # the low pass's sampler-type settings deliberately do not apply.
+ "second_pass_sigmas": ("studio", 7),
"latent_template": ("concat", 0), "refinement_key_input": ("keyloader", 0),
"num_frames_per_scene": ("frames", 0)},
"concat": {"video_latent": ("studio", 12)},
@@ -116,7 +123,7 @@ def _ordered_widget_names(node_def: dict) -> list[str]:
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
if opts.get("forceInput"):
continue # forceInput STRING is a socket, never a widget value
- if isinstance(t, list) or t in WIDGET_PRIMITIVES:
+ if isinstance(t, list) or widget_type_of(t, opts) in WIDGET_PRIMITIVES:
names.append(name)
return names
@@ -158,18 +165,19 @@ def _widget_defaults(node_def: Optional[dict]) -> dict:
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
if opts.get("forceInput"):
continue
+ wt = widget_type_of(t, opts) if isinstance(t, str) else None
if isinstance(t, list):
out[name] = opts.get("default", t[0] if t else None)
- elif isinstance(t, str) and "COMBO" in t.upper():
+ elif wt and "COMBO" in wt.upper():
# "COMBO" or a V3 dynamic combo (COMFY_DYNAMICCOMBO_V3): emit the selected
# key string (options may be {"key": ...} dicts), not the option object.
out[name] = _combo_default(opts)
- elif t in WIDGET_PRIMITIVES:
+ elif wt in WIDGET_PRIMITIVES:
# Always emit a value for every widget — ComfyUI's frontend does, and a
# required widget with no declared default (e.g. ImageTransform's bboxes)
# otherwise goes missing and ComfyUI rejects the prompt. Fall back to a
# type-appropriate empty so generation isn't blocked.
- out[name] = opts.get("default", _WIDGET_EMPTY.get(t, ""))
+ out[name] = opts.get("default", _WIDGET_EMPTY.get(wt, ""))
return out
@@ -192,7 +200,7 @@ def _widget_choices(node_def: Optional[dict]) -> dict:
if isinstance(t, list):
if t:
out[name] = t
- elif isinstance(t, str) and "COMBO" in t.upper():
+ elif isinstance(t, str) and "COMBO" in (widget_type_of(t, opts) or "").upper():
choices = _combo_choices(opts)
if choices:
out[name] = choices
@@ -627,7 +635,7 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic
def _options(t, builtin_label, self_cid):
opts = [{"value": "", "label": f"built-in: {builtin_label}"}]
- for val, lbl in producers.get(t, []):
+ for val, lbl in _matching_producers(producers, t):
if val.startswith(f"core:{self_cid}:"):
continue
opts.append({"value": val, "label": lbl})
@@ -714,6 +722,18 @@ def _producers(graph, slots, slot_node_id, slot_def, object_info):
return out
+def _matching_producers(producers: dict, t: str) -> list:
+ """Producers that can feed an input of type `t`. A union type ("IMAGE,MASK") is fed by
+ any of its members, so collect across them instead of matching the string exactly."""
+ if t in producers:
+ return producers[t]
+ out = []
+ for pt, lst in producers.items():
+ if type_accepts(t, pt):
+ out.extend(lst)
+ return out
+
+
def _reaches_upstream(graph, start, goal):
"""True if `goal` is upstream of `start` — i.e. `start` transitively consumes `goal`'s
output. Used to decide whether wiring `start`'s output into a node would close a loop."""
@@ -838,7 +858,7 @@ def _autowire(graph, slots, slot_node_id, slot_def, object_info, producers, repo
continue
if isinstance(node["inputs"].get(inp), list):
continue # already wired (explicit/core)
- raw = [p for p in producers.get(t, []) if p[0] != node_id]
+ raw = [p for p in _matching_producers(producers, t) if p[0] != node_id]
# Never auto-wire a source that already depends on this node — that closes a loop and
# ComfyUI rejects the whole graph. Drop such candidates so auto-wire stays acyclic.
cands = [p for p in raw if not _reaches_upstream(graph, p[0], node_id)]
@@ -880,26 +900,42 @@ def _apply_bypass(graph, slots, slot_node_id, slot_def, report):
continue
nd = slot_def.get(s["id"]) or {}
outs = node_outputs(nd)
- by_type: dict[str, list[str]] = {}
- for ci in connection_inputs(nd):
- by_type.setdefault(ci["type"], []).append(ci["name"])
+ cis = connection_inputs(nd)
+ # Only the outputs something in the graph actually CONSUMES need a passthrough. A
+ # node can emit an output nothing here reads — LTXICLoRALoaderModelOnly returns a
+ # FLOAT (latent_downscale_factor) alongside its MODEL, and the editor's graph wires
+ # only the MODEL — and demanding a matching input for an output that feeds nothing
+ # would refuse a bypass that is completely unambiguous for every link that exists.
+ consumed = set()
+ for nid, ndata in graph.items():
+ if nid == sid:
+ continue
+ for val in (ndata.get("inputs") or {}).values():
+ if isinstance(val, list) and len(val) == 2 and val[0] == sid:
+ consumed.add(val[1])
passthrough = {}
- ok = True
+ blocked = None
for i, o in enumerate(outs):
- names = by_type.get(o["type"])
- if not names or len(names) != 1:
- ok = False
+ if i not in consumed:
+ continue
+ # A union-typed input ("IMAGE,MASK") can carry any of its members through.
+ names = [ci["name"] for ci in cis if type_accepts(ci["type"], o["type"])]
+ if len(names) != 1:
+ blocked = (o, names)
break
passthrough[i] = graph[sid]["inputs"].get(names[0])
- if not ok:
+ if blocked is not None:
# Silently leaving the node active would mean a user who explicitly bypassed it
# (e.g. to skip an i2v preprocessing node) gets generation output as if they
# hadn't — with no visible sign why. Block instead: bypass either does what was
# asked or the run stops with a clear reason, never a silent no-op.
+ _o, _names = blocked
msg = (
f"{s.get('node_class')}: bypass needs exactly one input matching each output's "
- f"type to pass through — this node doesn't have one, so it can't be safely "
- f"bypassed. Remove it or rewire it with a single matching input per output type.")
+ f"type to pass through, and its '{_o['name']}' output ({_o['type']}) has "
+ f"{'no matching input' if not _names else 'more than one'} — so it can't be "
+ f"safely bypassed. Remove it, give it a single matching input for that type, "
+ f"or leave that output unconnected.")
report["unsatisfied"].append(msg)
report["blocking"].append(msg)
continue
diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py
index a2b8b840..4af32be4 100644
--- a/movie_editor/backend/nodes.py
+++ b/movie_editor/backend/nodes.py
@@ -36,6 +36,47 @@ def _is_widget_type(t) -> bool:
return isinstance(t, str) and (t in _WIDGET_TYPES or _is_combo_type(t))
+def type_parts(t) -> list[str]:
+ """Member types of a ComfyUI type string. A V3 MultiType input serializes its members
+ comma-joined — "FLOAT,INT" (an int widget that also accepts a float link), "IMAGE,MASK"
+ (a socket taking either) — so a single string can name several types."""
+ if not isinstance(t, str):
+ return []
+ return [p.strip() for p in t.split(",") if p.strip()]
+
+
+def widget_type_of(t, opts: dict | None = None) -> str | None:
+ """The widget type an input renders as, or None if it is a pure graph socket.
+
+ A V3 MultiType wrapped around a widget input (io.MultiType.Input(io.Int.Input(...), ...))
+ is a WIDGET in ComfyUI's frontend, not a socket: it carries "widgetType" in its options
+ and lists the widget's own type first, e.g. LTXVEmptyLatentAudio.frame_rate arriving as
+ ("FLOAT,INT", {"widgetType": "INT", "default": 25}). Treating it as a required socket
+ would demand a source for a field the user simply types into."""
+ opts = opts or {}
+ wt = opts.get("widgetType")
+ if _is_widget_type(wt):
+ return wt
+ parts = type_parts(t)
+ if parts and all(_is_widget_type(p) for p in parts):
+ return parts[0]
+ return None
+
+
+def link_types(t) -> list[str]:
+ """The graph-connection member types of a type string (widget members dropped)."""
+ return [_normalize_type(p) for p in type_parts(t) if not _is_widget_type(p)]
+
+
+def type_accepts(input_type: str, output_type: str) -> bool:
+ """Whether an output of `output_type` can feed an input of `input_type`. Either side may
+ be a union, so they match when any member type is shared."""
+ a, b = type_parts(input_type), type_parts(output_type)
+ if not a or not b:
+ return input_type == output_type
+ return bool({_normalize_type(x) for x in a} & {_normalize_type(y) for y in b})
+
+
def _combo_choices(opts: dict) -> list:
"""Normalise combo options to plain string/int choices. V3 dynamic combos express
options as dicts {"key": , "inputs": [...]}; standard combos are list[str]."""
@@ -120,8 +161,9 @@ def _all_input_types(node_def: dict) -> list[str]:
for group in ("required", "optional"):
for spec in (inp.get(group) or {}).values():
t = spec[0] if isinstance(spec, list) and spec else None
- if isinstance(t, str) and t not in _WIDGET_TYPES:
- types.append(_normalize_type(t))
+ opts = spec[1] if isinstance(spec, list) and len(spec) > 1 and isinstance(spec[1], dict) else {}
+ if isinstance(t, str) and not widget_type_of(t, opts):
+ types.extend(link_types(t))
return types
@@ -153,15 +195,17 @@ def connection_inputs(node_def: dict) -> list[dict]:
if not isinstance(t, str):
continue
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
- if _is_widget_type(t):
+ wt = widget_type_of(t, opts)
+ if wt:
# A widget-typed input is a SOCKET (wireable) only when forceInput is set —
# e.g. refinement_key_input is ("STRING", {"forceInput": True}). It always
# has a widget fallback, so it's NEVER auto-required (it must not block
# generation when left unwired). Plain widgets (incl. V3 dynamic combos
- # like COMFY_DYNAMICCOMBO_V3) are handled by widget_inputs, not here.
+ # like COMFY_DYNAMICCOMBO_V3 and MultiType widgets like ("FLOAT,INT", ...))
+ # are handled by widget_inputs, not here.
if not opts.get("forceInput"):
continue
- out.append({"name": name, "type": _normalize_type(t), "required": False})
+ out.append({"name": name, "type": _normalize_type(wt), "required": False})
continue
# V3 nodes can mark a required-group input as optional via the flag.
is_required = group == "required" and not opts.get("optional", False)
@@ -201,6 +245,9 @@ def widget_inputs(node_def: dict) -> list[dict]:
if opts.get("forceInput"):
continue
field = {"name": name, "required": group == "required", "options": opts}
+ # A MultiType widget ("FLOAT,INT") renders as its widget member, not the union.
+ if isinstance(t, str):
+ t = widget_type_of(t, opts) or t
if isinstance(t, list):
# Old-style combo: t is the list of choices (e.g. folder_paths filenames).
field["kind"] = "combo"
@@ -307,7 +354,7 @@ def ports_from_input_types(label: str, node_key: str, input_types: dict) -> list
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
# Expose typed sockets, plus forceInput widget sockets (e.g. the STRING
# refinement_key_input on Studio/Sampler) so they can be wired manually.
- if _is_widget_type(t) and not opts.get("forceInput"):
+ if widget_type_of(t, opts) and not opts.get("forceInput"):
continue
ports.append({"id": f"{node_key}.{name}", "node": label, "input": name,
"type": t, "label": f"{label} · {name}"})
diff --git a/movie_editor/frontend/composer.js b/movie_editor/frontend/composer.js
index ec48cc59..10be2ab9 100644
--- a/movie_editor/frontend/composer.js
+++ b/movie_editor/frontend/composer.js
@@ -284,6 +284,24 @@
// ── variables panel (below the global prompt) ───────────────────────────────────
// The list IS the editor: editable name/value rows + Add/Remove. Resolution happens at
// generation, after shortcuts + split (Studio), so nothing here touches the timeline.
+
+ // Grow a value textarea to fit its text (measured after a reset to "auto", or it can only
+ // ever get taller). Deferred once on mount because scrollHeight reads 0 before layout.
+ function autoGrowVar(ta) {
+ const fit = () => {
+ ta.style.height = "auto";
+ // scrollHeight covers content + padding; under border-box the borders must be added
+ // back or every line is clipped by ~2px and the textarea scrolls anyway.
+ const cs = getComputedStyle(ta);
+ const border = cs.boxSizing === "border-box"
+ ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)
+ : -(parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom));
+ ta.style.height = (ta.scrollHeight + (border || 0)) + "px";
+ };
+ fit();
+ if (!ta.dataset.grown) { ta.dataset.grown = "1"; setTimeout(fit, 0); }
+ }
+
function composeVariables() {
const box = el("div", "compose-vars");
const toggle = el("button", "btn ghost tiny composer-var-toggle",
@@ -310,10 +328,15 @@
nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); updateVarHint(); };
row.append(nm);
row.append(el("span", "compose-var-eq", "="));
- const vv = el("input", "lib-in compose-var-val");
+ // Values are usually long phrases, so this grows with its content — a one-line input
+ // makes editing "High quality, high fidelity realistic video, …" a horizontal-
+ // scrolling guessing game.
+ const vv = el("textarea", "lib-in compose-var-val");
+ vv.rows = 1;
vv.value = v.value; vv.placeholder = "value (may reference $other)";
- vv.oninput = () => { v.value = vv.value; commit(); updateVarHint(); };
+ vv.oninput = () => { v.value = vv.value; autoGrowVar(vv); commit(); updateVarHint(); };
row.append(vv);
+ autoGrowVar(vv);
const rm = el("button", "btn ghost tiny", "✕");
rm.title = "Remove variable";
rm.onclick = () => { vars.splice(i, 1); commit(); render(); };
diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js
index f0543596..ff2c21bd 100644
--- a/movie_editor/frontend/engine_settings.js
+++ b/movie_editor/frontend/engine_settings.js
@@ -208,7 +208,7 @@
{ name: "debug_log", label: "Debug log", kind: "bool", default: false, dependsOn: "identity_transfer_enabled",
hint: "Print per-scene identity-transfer shape/status logs to the ComfyUI console." },
{ name: "plateau_cache", label: "Plateau step-cache (speed)", kind: "bool", default: false,
- hint: "EXPERIMENTAL speed: the near-pure-noise early steps carry almost no signal, so the transformer output barely changes across them. Computes it once at the top of the plateau and reuses it for the rest, skipping ~3-4 of 8 transformer passes. Deterministic given seed (safe in Batch Training) but an approximation — A/B it before trusting on finals. Much of wall-clock time is outside the sampler, so total speedup is smaller than the forward count suggests. UNVALIDATED LIVE." },
+ hint: "Ignored while Context windows is on — the cache can't tell one window from another within a step, so it's skipped with a note in the scene report. EXPERIMENTAL speed: the near-pure-noise early steps carry almost no signal, so the transformer output barely changes across them. Computes it once at the top of the plateau and reuses it for the rest, skipping ~3-4 of 8 transformer passes. Deterministic given seed (safe in Batch Training) but an approximation — A/B it before trusting on finals. Much of wall-clock time is outside the sampler, so total speedup is smaller than the forward count suggests. UNVALIDATED LIVE." },
{ name: "plateau_cache_threshold", label: "Plateau threshold (sigma)", kind: "float", default: 0.975, min: 0.5, max: 0.999, step: 0.005, dependsOn: "plateau_cache",
hint: "Steps with sigma at or above this count as the reusable plateau. Higher = fewer steps cached (safer); lower = more cached (faster, more approximation). 0.975 catches the documented noise plateau while leaving structure formation fully computed." },
{ name: "segmented_detailing", label: "Segmented detailing (region refine)", kind: "bool", default: false,
@@ -226,6 +226,25 @@
{ name: "detail_denoise", label: "Detail re-noise strength", kind: "float", default: 0.85, min: 0.3, max: 0.99, step: 0.05,
deps: [{ name: "segmented_detailing" }, { name: "detail_mode", value: "repair" }],
hint: "How much noise the crop gets re-noised to before its 3-step refine (0.85 is the official LTX 2.3 recipe's own value). Higher = more freedom to genuinely reconstruct the region (fix bad anatomy), risking drift from the surrounding frame; lower = closer to a plain upscale — looks 'detailed' as interpolation but doesn't actually repair it. If the result looks upscaled but not corrected, raise this. Only used in 'repair' mode." },
+ { name: "cut_opening_frames", label: "Cut the opening (frames)", kind: "int", default: 0, min: 0, max: 512, step: 8,
+ hint: "Let the i2v anchor do its work, then cut it out of the clip. The anchor is a pinned frame at position 0, so it carries character detail, style and composition better than anything that weakens it on the way in (ALG blurs it and loses detail; Best-FaceID tokens approximate it and lose some too) — but it is also literally the first frame you see. The scene is generated exactly as normal, with the anchor pinned at full strength the whole way and no extra sampling, and this many frames are then dropped off the FRONT of the finished clip: an i2v generation that reads as t2v. 0 = off. 8 (one latent frame) removes just the anchor itself, which is usually not enough — the anchor is followed by a settling-in stretch where the shot is still leaving the reference still and little is happening, and on a prompt asking for immediate action that dead time is exactly what you want gone. 48 was the value that worked on a 768×768×305@30 chain with a quick-cut prompt; treat it as a starting point for this pipeline, not a universal default. NOTHING IS REGROWN: the scene comes out that much SHORTER than the length you set, and the audio is cropped to match — every surviving frame was generated as part of one continuous shot, with no invented ending. Needs an anchor image; skipped (with the reason in the scene report) on continuation scenes and on scenes carrying guide frames or JoyAI audio memory." },
+ { name: "second_pass_op", label: "Between-pass operation", kind: "combo", choices: ["none", "sharpen", "upscale_2x"], default: "none",
+ hint: "OPTIONAL operation applied to the latent between the two passes — 'none' by default, nothing runs unless you pick one. Both operations need the LTX 2.3 spatial upsampler in models/latent_upscale_models — the same ~1 GB file segmented detailing uses, found automatically or downloaded once on first use (watch the ComfyUI console). If it can't be obtained the second pass still runs, with the operation skipped and the reason in the scene report. 'sharpen': one forward of Lightricks' trained 2x latent upsampler, resampled straight back to the original size. No video-model calls, so it costs a fraction of a step; pass 2 then re-denoises the sharpened latent, which is what makes it stick. It adds detail consistent with what's already there and CANNOT fix wrong structure (an extra finger stays an extra finger, just sharper). 'upscale_2x': the same upsampler, but kept at 2x — pass 2 runs at four times the pixels and the scene decodes at double resolution. That's 3-5x the cost of the second half, and it drops the i2v pin (the anchor and its mask are the old size, and rescaling them would be inventing an anchor), so the scene can drift from the reference image; the scene report says when that happens. Both use the same upsampler file as segmented detailing. Video only — audio is never reshaped." },
+ { name: "context_windows", label: "Context windows (long scenes)", kind: "bool", default: false,
+ hint: "EXPERIMENTAL: denoise a scene that's LONGER than the model's comfortable window as overlapping windows instead of one giant pass — ComfyUI core's own mechanism, audio-aware on LTX (it maps each video window to its audio window and re-slices anchors, guides and JoyAI memory per window). Engages only on scenes longer than the window length below; shorter scenes are untouched and pay nothing. Cost at the defaults (145/40) is about 1.45× the per-frame work, since each window re-does its 40-frame overlap — offset by attention getting cheaper the longer the scene is (quadratic in one pass, near-flat when windowed). Roughly break-even around 200 frames, a net win past ~300. Needs ComfyUI v0.29.0 or newer; on older builds it's skipped with a note in the scene report. UNVALIDATED LIVE." },
+ { name: "context_window_length", label: "Window length (frames)", kind: "int", default: 145, min: 9, max: 2049, step: 8, dependsOn: "context_windows",
+ hint: "Window size in real frames. A scene at or below this length skips windowing entirely, so this doubles as the engage threshold. Keep it at or under the length the model already generates well in one pass — the point is to stay inside that range while the scene as a whole goes past it." },
+ { name: "context_window_overlap", label: "Window overlap (frames)", kind: "int", default: 40, min: 0, max: 512, step: 8, dependsOn: "context_windows",
+ hint: "How many frames consecutive windows share. This is the only thing carrying motion and appearance across a window boundary, and also the only extra compute this costs. Too low shows as a seam or a motion hitch at the boundary; too high pays for frames you already have." },
+ { name: "context_window_schedule", label: "Window schedule", kind: "combo", choices: ["standard_uniform", "standard_static", "looped_uniform", "batched"], default: "standard_uniform", dependsOn: "context_windows",
+ legacy: { uniform_standard: "standard_uniform", static_standard: "standard_static", uniform_looped: "looped_uniform" },
+ hint: "How windows are laid out each step (ComfyUI core's own schedule names). 'standard_uniform' (default) shifts the grid between steps so boundaries never bake in — safest. 'standard_static' keeps fixed cut points (cheapest, but a bad boundary stays bad). 'looped_uniform' wraps the end into the start for looping content. 'batched' uses disjoint chunks with no overlap logic (fastest, weakest continuity)." },
+ { name: "context_window_fuse", label: "Window blend", kind: "combo", choices: ["pyramid", "relative", "flat", "overlap-linear"], default: "pyramid", dependsOn: "context_windows",
+ hint: "How overlapping windows are weighted when merged. 'pyramid' (default) fades each window toward its edges so seams go soft. 'flat' averages equally (can smear). Change this if boundaries look ghosted rather than merely misaligned." },
+ { name: "context_window_freenoise", label: "FreeNoise blending", kind: "bool", default: true, dependsOn: "context_windows",
+ hint: "Shuffle rather than redraw the starting noise between windows, so overlapping regions begin from correlated noise. Free (a one-time permutation) and core's own default because it measurably improves how windows blend. Turn off only to A/B whether it's helping." },
+ { name: "context_window_retain_first", label: "Pin anchor in every window", kind: "bool", default: false, dependsOn: "context_windows",
+ hint: "Keep latent frame 0 (the i2v anchor) inside every window instead of just the first. Turn on if later windows drift away from the reference image. Off by default because on a continuation scene frame 0 is the carried tail of the previous scene, not the anchor — pinning it everywhere can make the scene read as static. Turn it off again if motion stalls." },
];
const SAMPLER_KNOB_MAP = Object.fromEntries(SAMPLER_KNOBS.map((k) => [k.name, k]));
@@ -312,7 +331,11 @@
}
} else if (k.kind === "combo") {
ctrl = el("select"); ctrl.dataset.k = "si-" + k.name;
- (k.choices || []).forEach((c) => { const o = el("option", null, c); o.value = c; if (c === val) o.selected = true; ctrl.append(o); });
+ // A renamed choice: show what the stored value MEANS, and write the new name back, so
+ // the dropdown never quietly displays a different setting from the one that will run.
+ const shown = (k.legacy && k.legacy[val]) || val;
+ if (shown !== val) S.setSamplerInput(k.name, shown);
+ (k.choices || []).forEach((c) => { const o = el("option", null, c); o.value = c; if (c === shown) o.selected = true; ctrl.append(o); });
ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.value);
} else if (k.kind === "text") {
ctrl = el("input"); ctrl.type = "text";
@@ -348,10 +371,10 @@
// ── views (inner-sidebar categories) ───────────────────────────────────────
const CHAIN_VIEW_KNOBS = {
chain_continuity: ["carry_i2v_guides"],
- chain_timing: ["frame_overlap", "transition_duration", "use_same_seed"],
+ chain_timing: ["frame_overlap", "transition_duration", "use_same_seed", "cut_opening_frames"],
chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "taste_nearest_prompt", "output_guidance", "output_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold"],
chain_decode: ["decode_noise_scale", "decode_timestep", "decode_tile_size"],
- chain_experimental: ["plateau_cache", "plateau_cache_threshold", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "detail_mode", "detail_denoise", "mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
+ chain_experimental: ["context_windows", "context_window_length", "context_window_overlap", "context_window_schedule", "context_window_fuse", "context_window_freenoise", "context_window_retain_first", "plateau_cache", "plateau_cache_threshold", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "detail_mode", "detail_denoise", "mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
};
function countChainView(p, id) {
@@ -524,7 +547,19 @@
const next = JSON.stringify({ ...cur, samplers: updatedSamplers });
if (quiet) S.setStudioInput("studio_settings", next);
else S.setStudioInputNow("studio_settings", next);
+ syncSecondPassFromSchedule(updatedSamplers, quiet);
}
+ // The second pass is driven by ONE thing: the schedule. Typing one turns it on; clearing
+ // it turns it off. There is no cut and no re-entry point to configure — pass 1 always runs
+ // the main schedule in full and pass 2 always runs this one in full.
+ function syncSecondPassFromSchedule(samplers, quiet) {
+ const raw = String(samplers?.low?.sigmas || "").replace(/;/g, ",");
+ const vals = raw.split(",").map((v) => parseFloat(v.trim())).filter((v) => !isNaN(v));
+ const set = quiet ? S.setSamplerInput : S.setSamplerInputNow;
+ if (vals.length >= 2) set("second_pass", true); // one number is not a schedule
+ else S.unsetSamplerInput("second_pass");
+ }
+
try {
window.SamplerPanel.render(box, samplers,
(s) => persistSamplers(s, true),
@@ -534,6 +569,20 @@
err.style.color = "var(--danger)";
box.append(err);
}
+ // Second pass lives here rather than under Experimental: at its defaults the split is
+ // behaviour-neutral (pass 2 resumes from exactly the state pass 1 handed over), so it
+ // is a sampler setting, not a gamble. The only control is the schedule field in the
+ // panel above — enable and cut are derived from it (see syncSecondPassFromSchedule);
+ // all that is left here is the optional between-pass operation.
+ const g = group(pane, "Second pass");
+ renderKnobList(g, st, ["second_pass_op"]);
+ const si = st.project.sampler_inputs || {};
+ if (si.second_pass_op && si.second_pass_op !== "none") {
+ // Both operations are one forward of the same trained latent upsampler segmented
+ // detailing uses, so the model choice belongs here too — otherwise picking 'sharpen'
+ // silently depends on a file the user was never shown.
+ renderDetailUpsampler(pane, si, "Between-pass operation: upsampler model");
+ }
}
function renderChainContinuity(pane, st) {
@@ -659,9 +708,12 @@
return _detailUpsamplerChoices;
}
- function renderDetailUpsampler(pane, si) {
- if (!si.segmented_detailing) return;
- const g = group(pane, "Segmented detailing: upsampler model");
+ // Two features load this same file: segmented detailing and the second pass's
+ // between-pass operation (sharpen / upscale_2x). It used to render only under
+ // Experimental and only while detailing was on, so someone using 'sharpen' alone got no
+ // upsampler control and no warning that a ~1 GB download was involved.
+ function renderDetailUpsampler(pane, si, title) {
+ const g = group(pane, title || "Latent upsampler model");
if (!_detailUpsamplerChoices) {
g.append(hintEl("Loading upsampler list…"));
ensureDetailUpsamplerChoices();
@@ -704,7 +756,9 @@
renderChainKnobsView(pane, st, "chain_experimental", "Experimental",
"Research techniques — off by default; expect quality/overhead trade-offs.");
const si = st.project.sampler_inputs || {};
- renderDetailUpsampler(pane, si);
+ if (si.segmented_detailing) {
+ renderDetailUpsampler(pane, si, "Segmented detailing: upsampler model");
+ }
if (!si.identity_transfer_enabled) return;
const g = group(pane, "Best-FaceID: ArcFace projector");
if (!_loraChoices) {
diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js
index 9c5b14df..6584ea7d 100644
--- a/movie_editor/frontend/models.js
+++ b/movie_editor/frontend/models.js
@@ -134,7 +134,7 @@
function allowedSources(slot, ci) {
const all = sources(slot, ci.type);
if (!pipelineLocked()) return all;
- if (slot.role === "image_processing" && ci.name === "image" && ci.type === "IMAGE") {
+ if (slot.role === "image_processing" && ci.name === "image" && typeAccepts(ci.type, "IMAGE")) {
return all.filter((s) => !s.value || s.value === "timeline" || s.value.startsWith("out:"));
}
return all.filter((s) => !s.value || s.value.startsWith("out:") || s.value === "timeline");
@@ -361,7 +361,7 @@
// that feeds nothing must not be flagged or block generation.
if (slotIsActive(slot)) (spec.connection_inputs || []).forEach((ci) => {
if (!ci.required) return;
- if (ci.type === "IMAGE") return; // a scene/timeline image can always feed an IMAGE input
+ if (typeAccepts(ci.type, "IMAGE")) return; // a scene/timeline image can always feed an IMAGE input
const src = (slot.input_sources || {})[ci.name];
if (src && src !== "auto") return; // explicitly sourced (incl. "timeline")
const incoming = (config.slots || []).some((s2) =>
@@ -546,16 +546,24 @@
}
const _KIND2T = { int: "INT", float: "FLOAT", string: "STRING", boolean: "BOOLEAN" };
+ // A ComfyUI type string can name several types at once — a V3 MultiType socket serializes
+ // its members comma-joined ("IMAGE,MASK"). An output feeds an input when they share one.
+ function typeAccepts(inputType, outputType) {
+ if (inputType === outputType) return true;
+ const parts = (t) => String(t || "").split(",").map((p) => p.trim()).filter(Boolean);
+ const a = parts(inputType), b = parts(outputType);
+ return a.some((x) => b.includes(x));
+ }
function destinations(slot, type) {
const out = [{ value: "", label: "— unwired —" }];
// Global editor outputs: wire your final video/audio producer here and the editor shows
// it (works with or without the built-in pipeline). IMAGE -> video, AUDIO -> audio.
- if (type === "IMAGE") out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
- if (type === "AUDIO") out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
- ports.filter((p) => p.type === type).forEach((p) => out.push({ value: "port:" + p.id, label: p.label }));
+ if (typeAccepts(type, "IMAGE")) out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
+ if (typeAccepts(type, "AUDIO")) out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
+ ports.filter((p) => typeAccepts(p.type, type)).forEach((p) => out.push({ value: "port:" + p.id, label: p.label }));
config.slots.filter((s) => s.id !== slot.id).forEach((s2) => {
const c2 = specFor(s2);
- (c2?.connection_inputs || []).filter((ci) => ci.type === type).forEach((ci) =>
+ (c2?.connection_inputs || []).filter((ci) => typeAccepts(ci.type, type)).forEach((ci) =>
out.push({ value: `node:${s2.id}:${ci.name}`, label: `${slotFullLabel(s2)} · ${ci.name}` }));
// Widget inputs can also receive a connection (ComfyUI converts a widget to an
// input when wired) — e.g. EmptyLatentVideo.width/height. Offer them as targets.
@@ -569,12 +577,12 @@
// Source IDs: "" = auto, "out::", "core::", "timeline" (IMAGE only).
function sources(slot, type) {
const out = [{ value: "", label: "(auto-wire)" }];
- if (type === "IMAGE") out.push({ value: "timeline", label: "Timeline (scene image)" });
- coreProducers.filter((p) => p.type === type).forEach((p) =>
+ if (typeAccepts(type, "IMAGE")) out.push({ value: "timeline", label: "Timeline (scene image)" });
+ coreProducers.filter((p) => typeAccepts(type, p.type)).forEach((p) =>
out.push({ value: p.id, label: p.label }));
config.slots.filter((s) => s.id !== slot.id).forEach((s2) => {
const c2 = specFor(s2);
- (c2?.outputs || []).filter((o) => o.type === type).forEach((o) =>
+ (c2?.outputs || []).filter((o) => typeAccepts(type, o.type)).forEach((o) =>
out.push({ value: `out:${s2.id}:${o.name}`, label: `${slotFullLabel(s2)} → ${o.name}` }));
});
return out;
diff --git a/movie_editor/frontend/sampler_panel.js b/movie_editor/frontend/sampler_panel.js
index 2be73342..72a3a252 100644
--- a/movie_editor/frontend/sampler_panel.js
+++ b/movie_editor/frontend/sampler_panel.js
@@ -335,9 +335,32 @@
return () => quiet ? save(s) : saveNow(s);
}
- // Only the high-pass sampler is wired in the Movie Editor graph
- // (studio outputs 4+5 → chain sampler; outputs 6+7 are not connected).
+ // The high-pass sampler is the one the Movie Editor graph runs (studio outputs 4+5).
renderPass(container, "high", "Sampler", s.high, mkSave(true), mkSave(false));
+
+ // Second pass schedule. Studio's low-pass SIGMAS (output 7) is wired to the chain
+ // sampler's second_pass_sigmas, so this field alone is pass 2's schedule — deliberately
+ // NOT the whole low-pass sampler panel, because only its sigmas are connected: pass 2
+ // reuses the sampler configured above, and rendering its algorithm settings here would
+ // offer knobs that quietly do nothing.
+ sectionTag(container, "Second pass");
+ const spSig = textCtrl(s.low.sigmas, "e.g. 0.812, 0.6, 0.35, 0.15, 0.0", "sp-second-sig",
+ (v) => { s.low.sigmas = v; mkSave(true)(); });
+ row(container, "Second pass schedule", spSig);
+hint(container, "Fill this in and each scene is sampled in two passes; leave it empty and "
+ + "it isn't. Comma-separated floats, high to low, ending at 0. Pass 1 "
+ + "runs the main Sigmas schedule above in full, then pass 2 runs THIS "
+ + "one in full, so the total is simply the two added up (a 9-step main "
+ + "plus a 4-step second pass is 13). Pass 2 starts from the finished "
+ + "clip: it is handed in as the latent and the sampler noises it to your "
+ + "first sigma itself, exactly as any img2img does — no extra step in "
+ + "between. That first sigma is therefore the strength dial, and it is "
+ + "literal: at 0.8 pass 2 starts from 80% fresh noise over 20% of the "
+ + "pass-1 picture and will rework the shot (and look soft if it has few "
+ + "steps to resolve it); at 0.4 it is 40/60 and polishes; at 0.2 it is "
+ + "nearly pure detail work. The rest of the list sets how many steps it "
+ + "gets. To make pass 1 "
+ + "shorter, shorten the schedule above; nothing here cuts it short.");
}
window.SamplerPanel = { render, defaultSamplers, defaultPass };
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index c003a954..dc8da836 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -3211,17 +3211,22 @@
_clearGenTimers();
let pendingStreak = 0;
let transientStreak = 0;
- // Faster step-progress poll (sampler current/total steps).
+ // Faster step-progress poll (sampler current/total steps, plus what it says it's
+ // doing — "scene 2/3 · pass 2 of 2"). The message is rebuilt from prefix + elapsed
+ // the same way the slower poll below builds it, rather than regex-stripping the last
+ // one off the end: a suffix the strip pattern doesn't know about (the phase label)
+ // would otherwise be re-appended every tick and grow without limit.
progressTimer = setInterval(async () => {
if (_interrupted) return;
try {
const pr = await API.progress();
if (pr && pr.max > 0) {
- const head = (state.gen.msg || prefix).replace(/\s*·\s*sampling \d+\/\d+$/, "");
+ const phase = pr.label ? ` · ${pr.label}` : "";
updateGenProgress({
step: pr.value,
maxStep: pr.max,
- msg: `${head} · sampling ${pr.value}/${pr.max}`,
+ phase: pr.label || "",
+ msg: `${prefix} ${_elapsed()} · sampling ${pr.value}/${pr.max}${phase}`,
});
}
} catch (_) {}
@@ -3257,7 +3262,8 @@
return;
}
const step = (state.gen.maxStep > 0) ? ` · sampling ${state.gen.step}/${state.gen.maxStep}` : "";
- updateGenProgress({ state: s.state, msg: `${prefix} ${_elapsed()}${step}` });
+ const phase = state.gen.phase ? ` · ${state.gen.phase}` : "";
+ updateGenProgress({ state: s.state, msg: `${prefix} ${_elapsed()}${step}${phase}` });
}
} catch (e) {
if (_isTransientTunnelError(e)) {
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index 2caaa071..e0a778a3 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -1194,11 +1194,14 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
.compose-var-hint { margin-top: 4px; }
.compose-var-hint.compose-var-warn { color: var(--amber); }
.compose-var-list { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
-.compose-var-row { display: flex; gap: 6px; align-items: center; }
+.compose-var-row { display: flex; gap: 6px; align-items: flex-start; }
+.compose-var-row > .compose-var-dollar, .compose-var-row > .compose-var-eq,
+.compose-var-row > .btn { margin-top: 5px; }
.compose-var-dollar { color: var(--amber); font-family: var(--font-mono); opacity: 0.8; }
.compose-var-eq { color: var(--muted); }
.compose-var-row .compose-var-name { flex: 0 0 30%; min-width: 0; }
-.compose-var-row .compose-var-val { flex: 1; min-width: 0; }
+/* Value is an auto-growing textarea (JS sets the height) — no resize handle, no scrollbar. */
+.compose-var-row .compose-var-val { flex: 1; min-width: 0; resize: none; overflow: hidden; }
.compose-var-row .btn { flex: 0 0 auto; }
/* config validation banner */
diff --git a/movie_editor/server.py b/movie_editor/server.py
index d605b257..cd2c9ae0 100644
--- a/movie_editor/server.py
+++ b/movie_editor/server.py
@@ -2264,14 +2264,14 @@ if web is not None and PromptServer is not None:
oi = {}
out = []
for p in nodes.pipeline_ports(oi):
- if p.get("type") == "IMAGE":
+ if nodes.type_accepts(p.get("type") or "", "IMAGE"):
out.append({"value": "port:" + p["id"], "label": p["label"]})
pid = req.query.get("pid")
models_cfg = _project_models(projects.get(pid)) if pid else nodes.load_models()
for slot in models_cfg.get("slots", []):
nd = oi.get(slot.get("node_class")) or {}
for ci in nodes.connection_inputs(nd):
- if ci["type"] == "IMAGE":
+ if nodes.type_accepts(ci["type"], "IMAGE"):
label = (slot.get("label") or slot.get("node_class")) + " · " + ci["name"]
out.append({"value": f"node:{slot['id']}:{ci['name']}", "label": label})
return web.json_response({"targets": out})
diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py
index 6d22ff65..6bd28bb5 100644
--- a/movie_editor/tests/test_builder.py
+++ b/movie_editor/tests/test_builder.py
@@ -64,6 +64,13 @@ OI = {
"strength_model": ["FLOAT", {"default": 1.0}],
"strength_clip": ["FLOAT", {"default": 1.0}]}},
"output": ["MODEL", "CLIP"], "output_name": ["MODEL", "CLIP"]},
+ # Real shape of LTXICLoRALoaderModelOnly: a MODEL passthrough plus a FLOAT the graph
+ # never reads. strength_model is a widget, so no connection input matches that FLOAT.
+ "LTXICLoRALoaderModelOnly": {
+ "input": {"required": {"model": ["MODEL"],
+ "lora_name": [["x.safetensors"]],
+ "strength_model": ["FLOAT", {"default": 1.0}]}},
+ "output": ["MODEL", "FLOAT"], "output_name": ["model", "latent_downscale_factor"]},
}
PARAMS = {"prompt": "scene one", "seed": 42, "num_frames_per_scene": 121, "frame_rate": 24}
@@ -207,6 +214,36 @@ def test_ambiguous_type_is_reported_not_guessed():
assert any("FunPackLTXAVSceneChainSampler" in a and ".vae" in a for a in report["ambiguous"])
+def test_multitype_widget_input_does_not_block_and_keeps_its_value():
+ # LTXVEmptyLatentAudio.frame_rate arrives as a V3 MultiType widget ("FLOAT,INT" with a
+ # widgetType hint). It is a field, not a socket: it must not demand a source (nothing
+ # outputs "FLOAT,INT", so the run was blocked) and its value must reach the graph.
+ oi = dict(OI)
+ oi["LTXVEmptyLatentAudio"] = {
+ "input": {"required": {
+ "frames_number": ["INT", {"default": 97}],
+ "frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25}],
+ "audio_vae": ["VAE"],
+ }},
+ "output": ["LATENT"], "output_name": ["Latent"]}
+ models = {"full_control": True, "slots": [
+ {"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
+ {"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
+ {"id": "v", "node_class": "VaeLoader", "inputs": {},
+ "wires": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}},
+ {"id": "av", "node_class": "VaeLoader", "inputs": {},
+ "wires": {"VAE": ["node:ae:audio_vae", "port:LTXVAudioVAEDecode.audio_vae"]}},
+ {"id": "ae", "node_class": "LTXVEmptyLatentAudio", "inputs": {"frame_rate": 30},
+ "wires": {"Latent": "port:LTXVConcatAVLatent.audio_latent"}},
+ ]}
+ graph, report = builder.build(oi, models, PARAMS)
+ assert report["blocking"] == []
+ assert not any("frame_rate" in m for m in report["unsatisfied"] + report["ambiguous"])
+ assert graph["slot_ae"]["inputs"]["frame_rate"] == 30 # user value, not a link
+ assert graph["slot_ae"]["inputs"]["frames_number"] == 97 # default still emitted
+ assert graph["slot_ae"]["inputs"]["audio_vae"] == ["slot_av", 0]
+
+
def _loaders():
return [
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
@@ -344,6 +381,24 @@ def test_non_bypassed_lora_stays_in_graph():
assert graph["studio"]["inputs"]["clip"] == ["slot_lora", 1]
+def test_bypass_ignores_outputs_nothing_is_wired_to():
+ """LTXICLoRALoaderModelOnly emits a FLOAT (latent_downscale_factor) next to its MODEL,
+ and nothing in the editor's graph reads it. Demanding a matching input for an output
+ that feeds nothing refused a bypass that is unambiguous for every link that exists —
+ only CONSUMED outputs need a passthrough."""
+ models = {"full_control": True, "slots": [
+ {"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"}, "wires": {}},
+ {"id": "ic", "node_class": "LTXICLoRALoaderModelOnly", "bypassed": True,
+ "inputs": {"lora_name": "x.safetensors", "strength_model": 0.8, "model": ["slot_u", 0]},
+ "wires": {"MODEL": "port:FunPackStudio.model"}},
+ ]}
+ graph, report = builder.build(OI, models, PARAMS)
+ assert "slot_ic" not in graph
+ assert graph["studio"]["inputs"]["model"] == ["slot_u", 0]
+ assert not any("bypass" in b for b in report["blocking"])
+ assert any("LTXICLoRALoaderModelOnly bypassed" in w for w in report["wired"])
+
+
def test_bypass_on_node_with_no_matching_input_blocks_generation():
# VaeLoader has no connection_input at all, so there's nothing to pass an output through.
# A bypass a user explicitly asked for must never be silently ignored — generation blocks
diff --git a/movie_editor/tests/test_nodes.py b/movie_editor/tests/test_nodes.py
index 7de08512..999f8157 100644
--- a/movie_editor/tests/test_nodes.py
+++ b/movie_editor/tests/test_nodes.py
@@ -154,6 +154,47 @@ def test_v1_combo_and_force_input():
assert ci["blocks"] == "SELECTEDDITBLOCKS"
+def test_multitype_widget_is_a_widget_not_a_required_socket():
+ """A V3 MultiType built around a widget input serializes comma-joined with a widgetType
+ hint — e.g. LTXVEmptyLatentAudio.frame_rate as ("FLOAT,INT", {"widgetType": "INT"}).
+ It is a field the user types into; treating it as a socket demanded a source that no
+ node produces and blocked generation."""
+ oi = dict(OI)
+ oi["LTXVEmptyLatentAudio"] = {
+ "input": {"required": {
+ "frames_number": ["INT", {"default": 97}],
+ "frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25, "min": 1}],
+ "batch_size": ["INT", {"default": 1}],
+ "audio_vae": ["VAE"],
+ }},
+ "output": ["LATENT"], "output_name": ["Latent"],
+ "display_name": "LTXV Empty Latent Audio",
+ }
+ desc = nodes.describe_node(oi, "LTXVEmptyLatentAudio")
+ widgets = {w["name"]: w for w in desc["inputs"]}
+ assert widgets["frame_rate"]["kind"] == "int" # renders as the widget it is
+ assert widgets["frame_rate"]["default"] == 25
+ ci = {c["name"]: c["type"] for c in desc["connection_inputs"]}
+ assert ci == {"audio_vae": "VAE"} # frame_rate is NOT a socket
+ # ...and it still qualifies for the audio_encoder role (union widget isn't an input type)
+ assert "LTXVEmptyLatentAudio" in [c["class"] for c in nodes.candidates(oi, "audio_encoder")]
+
+
+def test_multitype_socket_stays_a_socket_and_matches_any_member():
+ """A MultiType with no widget member ("IMAGE,MASK") is a real socket — it must still be
+ offered as one, and fed by a producer of either member type."""
+ oi = {"ImageResizeMatch": {"input": {"required": {"image": ["IMAGE"],
+ "match": ["IMAGE,MASK"]}},
+ "output": ["IMAGE"], "display_name": "Resize to Match"}}
+ ci = {c["name"]: (c["type"], c["required"]) for c in nodes.describe_node(oi, "ImageResizeMatch")["connection_inputs"]}
+ assert ci["match"] == ("IMAGE,MASK", True)
+ assert nodes.type_accepts("IMAGE,MASK", "MASK")
+ assert nodes.type_accepts("IMAGE,MASK", "IMAGE")
+ assert not nodes.type_accepts("IMAGE,MASK", "LATENT")
+ assert nodes.widget_type_of("IMAGE,MASK", {}) is None
+ assert nodes.widget_type_of("FLOAT,INT", {}) == "FLOAT" # no hint → first member wins
+
+
def test_models_store_roundtrip(tmp_path, monkeypatch):
from movie_editor.backend import config
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
diff --git a/pyproject.toml b/pyproject.toml
index 24fba925..5d40e6b0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,7 +2,7 @@
[project]
name = "ComfyUI-FunPack" # Unique identifier for your node. Immutable after creation.
description = "An AI movie studio inside ComfyUI: the Cutting Room browser-based video editor with timeline, per-scene prompts and one-click montage generation, plus a self-improving engine that learns from your ratings. Includes the full node stack (Studio, Refiner V2, LTXAV Scene Chain Sampler) for classic graph workflows."
-version = "3.4.0" # Custom Node version. Must be semantically versioned.
+version = "3.4.1" # Custom Node version. Must be semantically versioned.
license = { text = "GNU GPL v3" }
dependencies = [] # Filled in from requirements.txt
diff --git a/run_phase.py b/run_phase.py
new file mode 100644
index 00000000..7e12f7e8
--- /dev/null
+++ b/run_phase.py
@@ -0,0 +1,64 @@
+"""What the sampler is doing RIGHT NOW, as a short label a UI can poll.
+
+ComfyUI's progress channel carries only (value, max) — no text — so a chain that samples
+several scenes, some of them twice, reads as one undifferentiated bar: you can watch the
+number climb without knowing which scene it belongs to or whether the second pass has
+started. The Chain Sampler publishes a label here as it moves through the run, and the
+Movie Editor's ``/api/progress`` hands it to the UI alongside the numbers.
+
+**The state lives on `sys`, not in this module's globals, and that is load-bearing.** The
+writer (samplers.py) reaches this file as a package-relative import, so it gets
+``ComfyUI-FunPack.run_phase``; the reader (movie_editor/backend/bridge.py) imports FunPack
+modules top-level by name after putting the root on sys.path, so it would get a SECOND,
+separate ``run_phase`` module object — two copies, two sets of globals, and a label that
+never arrives. Hanging the dict off ``sys`` (unambiguously one object per process) makes
+both import paths share it. Stateless helpers like `conditioning` do not care which copy
+they are; anything holding mutable state across that boundary does.
+
+Everything here is BEST-EFFORT and never affects sampling: it is a live readout, not state
+anything is derived from. Nothing reads it back to make a decision, so a label left over
+from a crashed run is cosmetic — the next run overwrites it and ``clear()`` empties it —
+and it is not the kind of cross-request cache that would violate "the UI is the only source
+of truth".
+"""
+import sys
+
+# Part of this module's contract, not an implementation detail: movie_editor's bridge READS
+# THIS ATTRIBUTE DIRECTLY rather than importing this module. That poll runs every 700ms on
+# ComfyUI's event loop while the worker thread is sampling, so it must not touch the import
+# system or anything else that can block. Renaming this key means updating bridge.py too
+# (tests/test_run_phase.py pins the two together).
+_SYS_KEY = "_funpack_run_phase"
+
+
+def _state() -> dict:
+ st = getattr(sys, _SYS_KEY, None)
+ if not isinstance(st, dict):
+ st = {"label": "", "seq": 0}
+ setattr(sys, _SYS_KEY, st)
+ return st
+
+
+def set_phase(label: str) -> None:
+ """Publish the current phase, e.g. "scene 2/3 · pass 2 of 2"."""
+ try:
+ st = _state()
+ st["label"] = str(label or "")
+ st["seq"] += 1
+ except Exception: # noqa: BLE001 — a readout must never break a render
+ pass
+
+
+def clear() -> None:
+ """Nothing is sampling. Call from a finally, so an interrupt doesn't leave a label up."""
+ set_phase("")
+
+
+def current() -> dict:
+ """{"label": str, "seq": int}. `seq` only ever increases, so a poller can tell "still
+ the same phase" from "this phase came round again"."""
+ try:
+ st = _state()
+ return {"label": st["label"], "seq": st["seq"]}
+ except Exception: # noqa: BLE001
+ return {"label": "", "seq": 0}
diff --git a/samplers.py b/samplers.py
index 225db714..8d610f02 100644
--- a/samplers.py
+++ b/samplers.py
@@ -1140,6 +1140,8 @@ def _sample_const_rf_full(model, x, sigmas, extra_args, callback, disable,
correction_blend = max(0.0, min(1.0, float(correction_blend)))
eta_final = max(0.0, min(float(eta), float(eta_final)))
+ sched_steps = max(1, int(len(sigmas)) - 1)
+
if not motion_pulse_steps:
_, _, motion_pulse_steps, motion_pulse_noise = _prepare_dynamic_sigmas(
sigmas, high_quality_pct, motion_pulse_mode, motion_pulse_start_pct,
@@ -1150,14 +1152,13 @@ def _sample_const_rf_full(model, x, sigmas, extra_args, callback, disable,
for item in (motion_pulse_steps or []) if isinstance(item, dict)
}
- late_start = _get_late_start_index(total_steps, high_quality_pct)
+ late_start = _get_late_start_index(sched_steps, high_quality_pct)
quality_sigma_start = float(sigmas[late_start].item()) if late_start < sigmas.shape[0] else None
- num_quality_steps = total_steps - late_start
+ num_quality_steps = sched_steps - late_start
s_in = x.new_ones([x.shape[0]])
prev_denoised = None
prev_h = None
- quality_step_index = 0
_kvlock_state = {}
# Audio-safe sampling: on a packed LTXAV latent, keep ancestral noise + steering on the
@@ -1245,7 +1246,8 @@ def _sample_const_rf_full(model, x, sigmas, extra_args, callback, disable,
if num_quality_steps <= 1:
effective_blend = correction_blend
else:
- effective_blend = 0.0 if quality_step_index < (num_quality_steps // 2) else correction_blend
+ q_idx = max(0, i - late_start)
+ effective_blend = 0.0 if q_idx < (num_quality_steps // 2) else correction_blend
dt = sigma_next - sigma
d1 = k_diffusion_sampling.to_d(x, sigma, denoised_eff)
if effective_blend > 0.0:
@@ -1261,7 +1263,6 @@ def _sample_const_rf_full(model, x, sigmas, extra_args, callback, disable,
# Heun changed x with a corrected direction; invalidate AB2 history.
prev_denoised = None
prev_h = None
- quality_step_index += 1
else:
# Early phase: ancestral RF euler (matches sample_euler_ancestral_RF), with
# eta decay toward the quality boundary, using the AB2 denoised estimate.
@@ -1808,10 +1809,12 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No
if total_steps <= 0:
return x
+ sched_steps = max(1, int(len(sigmas)) - 1)
+
order = max(1, min(2, int(order)))
s_noise = max(0.0, min(0.5, float(s_noise)))
- final_correction_steps = max(0, min(total_steps // 2, int(final_correction_steps)))
- correction_start_idx = total_steps - final_correction_steps
+ final_correction_steps = max(0, min(sched_steps // 2, int(final_correction_steps)))
+ correction_start_idx = sched_steps - final_correction_steps
_RESCUE_LOG["warned_no_memory"] = False
_RESCUE_LOG["warned_no_prompt_match"] = False
@@ -1976,8 +1979,8 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No
# the schedule. Early/high-sigma steps stay near 1st-order euler (the denoised estimate
# is rough there and full AB2 overshoots); late/detail steps get full AB2. Reuses the
# already-computed AB2 estimate, so no extra model evals. No-op at order=1.
- if ab2_ramp and total_steps > 1:
- w = i / (total_steps - 1) # 0 at first step -> 1 at last
+ if ab2_ramp and sched_steps > 1:
+ w = i / (sched_steps - 1) # 0 at first step -> 1 at last
denoised_eff = denoised + (denoised_eff - denoised) * w
# Audio rides plain 1st-order euler: keep the (ramped) AB2 estimate for video,
@@ -2440,7 +2443,7 @@ class FunPackLTXAVSceneChainSampler:
}),
"plateau_cache": ("BOOLEAN", {
"default": False,
- "tooltip": "EXPERIMENTAL speed (MixCache/Chorus-family step-caching, adapted to LTX2.3's distilled schedule): the near-pure-noise plateau steps (sigma above plateau_cache_threshold) carry almost no signal, so the model's output barely changes across them. This computes the full transformer forward once at the top of the plateau, then REUSES that output for the remaining plateau steps instead of recomputing — skipping those transformer passes entirely. On the default 8-step schedule (sigmas 1.0→0.975 are the plateau) that's ~3-4 of 8 forwards skipped. DETERMINISTIC given seed (no diversity/rating impact, safe in Batch Training) but an APPROXIMATION — validate A/B before trusting on final renders. Note: much of wall-clock time is outside the sampler (encode/decode), so sampler speedup ≠ total speedup. Off by default. UNVALIDATED LIVE.",
+ "tooltip": "EXPERIMENTAL speed (MixCache/Chorus-family step-caching, adapted to LTX2.3's distilled schedule). IGNORED while context_windows is on (the cache can't tell one window from another within a step) — the scene report says so. Mechanism: the near-pure-noise plateau steps (sigma above plateau_cache_threshold) carry almost no signal, so the model's output barely changes across them. This computes the full transformer forward once at the top of the plateau, then REUSES that output for the remaining plateau steps instead of recomputing — skipping those transformer passes entirely. On the default 8-step schedule (sigmas 1.0→0.975 are the plateau) that's ~3-4 of 8 forwards skipped. DETERMINISTIC given seed (no diversity/rating impact, safe in Batch Training) but an APPROXIMATION — validate A/B before trusting on final renders. Note: much of wall-clock time is outside the sampler (encode/decode), so sampler speedup ≠ total speedup. Off by default. UNVALIDATED LIVE.",
}),
"plateau_cache_threshold": ("FLOAT", {
"default": 0.975, "min": 0.5, "max": 0.999, "step": 0.005,
@@ -2482,6 +2485,59 @@ class FunPackLTXAVSceneChainSampler:
"default": "repair",
"tooltip": "'repair' (default): upsample the crop, then re-denoise it through the video model for 3 extra steps — can genuinely fix wrong structure (bad anatomy) but costs real compute (~4x region area x 3 steps). 'sharpen': stop after the upsampler's own forward pass — no video-model calls at all, close to free — good for a region that's blurry/under-resolved but already correctly shaped; it CANNOT fix wrong structure (an extra finger stays an extra finger, just sharper), since a super-resolution net only adds detail consistent with what's already there.",
}),
+ "cut_opening_frames": ("INT", {
+ "default": 0, "min": 0, "max": 512, "step": 8,
+ "tooltip": "Let the i2v anchor do its work, then cut it out of the clip: generate the scene exactly as normal (anchor pinned at full strength the whole way, nothing weakened, no extra sampling), then drop this many frames off the FRONT of the finished clip. The anchor is a pinned latent frame at position 0 — it transfers identity, style and composition better than anything that softens it on the way in (ALG blurs it and loses character detail; Best-FaceID tokens approximate it and lose some too), but it is also literally the first frame you see, so every i2v scene opens on the exact reference still. Cutting it afterwards keeps the transfer and removes the tell: an i2v generation that reads as t2v. 0 (default) = off. The value is in REAL frames and is converted with the VAE's own time scale; one latent frame (8 real frames at the standard scale) removes just the anchor itself, which is usually NOT enough — the anchor is followed by a settling-in stretch where the shot is still leaving the reference still and little is happening yet, and on a prompt that asks for immediate action that dead time is exactly what you want gone (48 was the value that worked on a 768x768x305@30 i2v chain with a quick-cut prompt — a starting point for this pipeline, not a universal default). NOTHING IS REGROWN: the scene comes out that much SHORTER than the length you asked for, and the audio is cropped to match. That is the trade — every surviving frame was generated as part of one continuous shot, with no invented ending. Needs a pinned i2v anchor; skipped with the reason in the scene report on continuation scenes and on scenes carrying guide frames or JoyAI audio memory.",
+ }),
+ "context_windows": ("BOOLEAN", {
+ "default": False,
+ "tooltip": "EXPERIMENTAL: denoise a scene LONGER than the model's native window as overlapping context windows instead of one giant pass (ComfyUI core's own comfy.context_windows, LTX2 branch — nothing ported). Each step splits the scene into windows of context_window_length frames, denoises each, and fuses the overlaps back together. Core's LTXAV path is genuinely audio-aware: it unpacks the packed AV latent, maps each video window onto its proportional AUDIO window, and re-slices denoise masks, keyframe_idxs and guide_attention_entries per window — so i2v anchors, mid-scene guides and JoyAI memory keep working inside a window. Cost at the defaults (145/40): about 1.45x the per-frame work, because each window re-does its 40-frame overlap, offset against attention getting CHEAPER the longer the scene is (attention is quadratic in one pass, near-flat when windowed) - roughly break-even around 200 frames, a net win past ~300. Engages ONLY when the scene is longer than context_window_length — shorter scenes are untouched and pay nothing. Off by default. UNVALIDATED LIVE.",
+ }),
+ "context_window_length": ("INT", {
+ "default": 145, "min": 9, "max": 2049, "step": 8,
+ "tooltip": "Window size in REAL frames (must be 8n+1; core rounds down to latent frames). A scene at or below this length skips windowing entirely, so this doubles as the engage threshold. Keep it at or under the length the model actually generates well in one pass — the whole point is to stay inside that range while the scene as a whole goes past it.",
+ }),
+ "context_window_overlap": ("INT", {
+ "default": 40, "min": 0, "max": 512, "step": 8,
+ "tooltip": "How many real frames consecutive windows share. This is the ONLY thing carrying motion/appearance continuity across a window boundary, and it is also the only extra compute this feature costs (overlap/length = the redundant fraction). Too low and boundaries show as a seam or a motion hitch; too high and you pay for frames you already have.",
+ }),
+ # The three legacy spellings stay in the LIST, not just in the alias map:
+ # ComfyUI validates combo values at QUEUE time, before the node ever runs, so
+ # a project saved with the old name would be rejected outright and the alias
+ # would never get a chance. They resolve to core's names below.
+ "context_window_schedule": (["standard_uniform", "standard_static", "looped_uniform", "batched",
+ "uniform_standard", "static_standard", "uniform_looped"], {
+ "default": "standard_uniform",
+ "tooltip": "How the windows are laid out across the scene, per step. These are ComfyUI core's own schedule names (comfy.context_windows). 'standard_uniform' (default, core's own LTXV default) shifts the window grid between steps so boundaries land in different places each step and never bake in — the safest general choice. 'standard_static' keeps the same fixed cut points every step (cheapest, but a bad boundary stays bad). 'looped_uniform' wraps the last window into the first, for seamless looping content. 'batched' denoises disjoint chunks with no overlap logic (fastest, weakest continuity). Projects saved with the old reversed spellings (uniform_standard / static_standard / uniform_looped) are still accepted and mapped onto these.",
+ }),
+ "context_window_fuse": (["pyramid", "relative", "flat", "overlap-linear"], {
+ "default": "pyramid",
+ "tooltip": "Weighting used to blend overlapping windows back together. 'pyramid' (default) fades each window toward its edges, so the middle of a window dominates and seams get soft. 'flat' averages equally (can smear). 'relative' and 'overlap-linear' weight by position within the overlap. Change this if boundaries look soft/ghosted rather than merely misaligned.",
+ }),
+ "context_window_freenoise": ("BOOLEAN", {
+ "default": True,
+ "tooltip": "Shuffle (rather than redraw) the initial noise between windows so overlapping regions start from correlated noise. Costs nothing — it is a one-time permutation of the starting noise — and is core's default for LTXV because it measurably improves how well windows blend. Turn it off only to A/B whether it is helping.",
+ }),
+ "context_window_retain_first": ("BOOLEAN", {
+ "default": False,
+ "tooltip": "Pin latent frame 0 (the i2v anchor) into EVERY window, in both the conditioning and the noise latent, instead of only the first window. Helps when later windows drift away from the reference image. Off by default because on a CONTINUATION scene frame 0 is the carried tail of the previous scene, not the anchor — pinning it there re-shows the same content in every window and can read as the scene going static. Turn it on if later windows lose the reference; turn it off if the scene stops moving.",
+ }),
+ # NOTE: append-only — keep new sampler widgets at the END of this block so the
+ # builder's positional reference-workflow mapping (extract_widgets) stays aligned.
+ "second_pass": ("BOOLEAN", {
+ "default": False,
+ "tooltip": "Sample each scene in TWO passes. Pass 1 runs the main sigmas schedule in full, exactly as written; pass 2 then runs the second_pass_sigmas schedule in full, exactly as written, starting from pass 1's finished clip. Nothing is cut short and nothing is derived — to make pass 1 shorter, shorten the main schedule. Total steps are simply the two schedules added up (a 9-step main plus a 4-step second pass is 13). Pass 1's finished latent is simply handed to pass 2 as its latent_image and the sampler noises it to the schedule's first sigma itself, exactly as any img2img does — there is no extra step in between. That first sigma is therefore the strength dial, and it is literal (CONST scaling: x = s*noise + (1-s)*picture): at 0.8 pass 2 starts from 80% fresh noise over 20% of the pass-1 picture and reworks the shot, looking soft if the schedule has few steps to resolve it; at 0.4 it is 40/60 and polishes; at 0.2 it is nearly pure detail work. Requires a second_pass_sigmas schedule; without one the pass is skipped with a note.",
+ }),
+ "second_pass_op": (["none", "sharpen", "upscale_2x"], {
+ "default": "none",
+ "tooltip": "OPTIONAL latent-space operation applied between the two passes — 'none' by default, nothing runs unless you pick one. 'sharpen': one forward of Lightricks' trained 2x latent upsampler, resampled straight back to the original size. No video-model calls at all, so it costs a fraction of a step; pass 2 then re-denoises the sharpened latent, which is what makes it stick. It adds detail consistent with what is already there and CANNOT fix structure that is wrong (an extra finger stays an extra finger, just sharper) — the same limit segmented detailing's sharpen mode documents. 'upscale_2x': the same upsampler, but the result is KEPT at 2x, so pass 2 runs at four times the pixels and the scene decodes at double resolution. That is 3-5x the sampling cost of the second half and it drops the i2v pin (the anchor and its mask are the old size, and rescaling them would be inventing an anchor) — the scene report says so when it happens. Both use the same upsampler file as segmented detailing (detail_upsampler, 'auto' downloads the official one on first use). Video stream only; audio is never reshaped.",
+ }),
+ # A connection socket, never a widget — safe at the end, and it must stay after
+ # every widget above (see the widgets_values note at the top of this block).
+ "second_pass_sigmas": ("SIGMAS", {
+ "tooltip": "The schedule pass 2 runs — required for second_pass, and it is run EXACTLY as written, high to low, ending at 0. Wire any scheduler here, or type sigmas in the Editor. Pass 1 has already finished the main schedule by this point, so pass 2 starts from a clean clip and re-enters by re-noising it up to this schedule's FIRST sigma: that value is the strength dial (near 1.0 reworks the shot, low values only polish it), and the rest of the schedule sets how many steps it gets. A schedule that ascends, or that stops above 0, is refused with the reason — both would silently produce a distorted or under-denoised clip rather than fail loudly.",
+
+ }),
},
"hidden": {
"unique_id": "UNIQUE_ID",
@@ -2537,6 +2593,18 @@ class FunPackLTXAVSceneChainSampler:
raise ValueError("Scene chain latents must have a time dimension at index 2.")
return int(tensor.shape[2])
+ def _context_scene_latent_frames(self, chunk):
+ """Video latent frames in this scene's chunk, or None if it can't be read.
+
+ Reported only — core decides for itself whether a scene is long enough to window.
+ Read off the VIDEO tensor (index 0 of the nested AV latent), while the chunk is
+ still unpacked 5D; after comfy packs it the time axis is gone.
+ """
+ try:
+ return self._tensor_frames(self._latent_tensors(chunk)[0])
+ except Exception:
+ return None
+
def _latent_tensors(self, latent):
samples = latent.get("samples")
if self._is_nested(samples):
@@ -2884,6 +2952,23 @@ class FunPackLTXAVSceneChainSampler:
result.pop("noise_mask", None)
return result
+ def _set_phase(self, label):
+ """Publish what is running right now for the editor's progress readout.
+
+ The ComfyUI progress channel is (value, max) only, so without this a multi-scene
+ chain — especially one sampling some scenes twice — is a single anonymous bar.
+ Best-effort in every direction: a missing module or a failed write must never
+ affect the render.
+ """
+ try:
+ try:
+ from . import run_phase as _rp
+ except ImportError: # loaded as a top-level module (tests, direct import)
+ import run_phase as _rp
+ _rp.set_phase(label)
+ except Exception: # noqa: BLE001
+ pass
+
def _sample_chunk(self, model, sampler, sigmas, seed, cfg, positive, negative, latent,
pbar=None, step_offset=0, alg_guide_tail_frames=0,
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
@@ -2911,7 +2996,6 @@ class FunPackLTXAVSceneChainSampler:
extra_options["alg_guide_tail_frames"] = int(alg_guide_tail_frames)
extra_options["alg_guide_blur_strength"] = float(alg_guide_blur_strength)
extra_options["alg_guide_blur_sigma_threshold"] = float(alg_guide_blur_sigma_threshold)
-
# EXPERIMENTAL Bounded Attention: model-level attention hooks (sampler-agnostic, unlike
# the toggles above which only work on Distilled Flow), so install/remove here rather
# than via extra_options. Cheap to attempt (no-ops fast without the right metadata).
@@ -2929,6 +3013,209 @@ class FunPackLTXAVSceneChainSampler:
latent.pop("noise_mask", None)
return latent
+ # ---------------------------------------------------------------------------
+ # cut_opening_frames — let the i2v anchor do its work, then cut it out of the clip.
+ #
+ # The anchor is a real, PINNED latent frame at temporal position 0: it constrains
+ # identity/style/composition all the way down the schedule, and it is also literally
+ # the first frame you see. The scene is sampled exactly as normal — the anchor is
+ # never weakened, blurred or approximated — and the opening is then cut off the
+ # FINISHED latent, so it can be the former without being the latter. An i2v
+ # generation that reads as t2v, for no extra sampling at all.
+ #
+ # Nothing is regrown to replace what is cut: the scene simply comes out that much
+ # shorter. Regrowing it was tried (slide the frames left, refill the freed tail,
+ # finish the schedule on the result) and the invented ending consistently came out
+ # with worse or missing movement, which is not worth paying steps for.
+ # ---------------------------------------------------------------------------
+
+ def _anchor_pinned_frames(self, chunk):
+ """How many leading latent frames this chunk pins (mask < 1) — i.e. the i2v anchor.
+
+ 0 means a genuine t2v scene with no anchor image: there is nothing to shift, and
+ sliding anyway would just discard real generated frames. Reading the CHUNK's own
+ mask rather than the template's is deliberate — it reflects what this scene actually
+ got, including per-scene anchors.
+ """
+ try:
+ tensors = self._latent_tensors(chunk)
+ masks = self._latent_masks(chunk, len(tensors))
+ if not masks or masks[0] is None:
+ return 0
+ return self._protected_prefix_frames(masks[0], self._tensor_frames(tensors[0]))
+ except Exception:
+ return 0
+
+ def _second_pass_schedule(self, alt_sigmas):
+ """Validate the pass-2 schedule. Returns (sigmas, reason); one of them is None.
+
+ There is nothing to derive here and nothing to cut. Pass 1 runs the main schedule in
+ full, exactly as written; pass 2 then runs THIS schedule in full, exactly as written.
+ Pass 2 therefore starts from a FINISHED clip, which is the ordinary img2img re-entry
+ (comfy's CONST scaling, x = s*noise + (1-s)*clean, valid precisely because the input
+ is clean). To make pass 1 shorter, shorten the main schedule.
+
+ A hand-typed schedule is the one input that can be malformed, and both ways it can
+ be are silent in the OUTPUT rather than loud at runtime, so they are caught here.
+ """
+ if not isinstance(alt_sigmas, torch.Tensor) or alt_sigmas.numel() < 2:
+ return None, ("no second-pass schedule — set one (comma-separated sigmas, high to "
+ "low, ending at 0) or turn the second pass off")
+ vals = [float(v) for v in alt_sigmas.tolist()]
+ eps = 1e-4
+ if any(b > a + eps for a, b in zip(vals, vals[1:])):
+ return None, ("second-pass schedule must descend — "
+ f"{', '.join(f'{v:g}' for v in vals)} goes back up, which walks the "
+ f"trajectory backwards. List the sigmas high to low")
+ if vals[-1] > eps:
+ return None, (f"second-pass schedule ends at {vals[-1]:g}, not 0 — the scene would "
+ f"come out partially denoised (noise artefacts). End it at 0")
+ if vals[0] <= eps:
+ return None, "second-pass schedule starts at 0 — there is nothing for it to denoise"
+ return alt_sigmas, None
+
+ def _second_pass_operate(self, latent, op, upsampler, vae):
+ """Apply the chosen latent-space operation between the two passes.
+
+ All of these are OPTIONS — "none" is the default and the whole feature is opt-in.
+ Everything here touches the VIDEO stream only: the audio stream sits alongside it in
+ the same nested latent and has no spatial axes to scale, and reshaping it is exactly
+ the class of change that has corrupted audio before.
+
+ - "sharpen": Lightricks' trained 2x LatentUpsampler forward, then area-downscale back
+ to the original size. No video-model calls at all, so it costs a fraction of one
+ step. It adds detail consistent with what is already there; it cannot fix structure
+ that is wrong (the same limit segmented detailing's 'sharpen' mode documents).
+ Pass 2 then re-denoises the sharpened latent, which is what makes it stick.
+ - "upscale_2x": the same upsampler, but the result is KEPT at 2x. Pass 2 therefore
+ runs at four times the pixels, and the scene decodes at double resolution. This is
+ the expensive one by a wide margin — it is here because it was asked for, not
+ because it is a good default.
+
+ Returns (latent, note). The latent is returned unchanged with a note when the op
+ cannot run, so a missing upsampler degrades to "second pass, no operation" rather
+ than failing the whole render.
+ """
+ if op in (None, "none", ""):
+ return latent, None
+ # Validate the name BEFORE touching the upsampler: an unrecognised op should cost
+ # nothing and say so, not pay for a full upsampler forward and then be discarded.
+ if op not in ("sharpen", "upscale_2x"):
+ return latent, f"second_pass_op={op} skipped: unknown operation"
+ if upsampler is None:
+ return latent, f"second_pass_op={op} skipped: no latent upsampler could be loaded"
+ try:
+ from . import detailing as _d
+ except ImportError: # loaded as a top-level module (tests, direct import)
+ import detailing as _d
+ result = self._clone_latent(latent)
+ tensors = self._latent_tensors(result)
+ video = tensors[0]
+ h, w = int(video.shape[-2]), int(video.shape[-1])
+ try:
+ up = _d._run_upsampler(upsampler, video, vae)
+ except Exception as exc: # noqa: BLE001
+ return latent, f"second_pass_op={op} skipped: upsampler failed ({exc})"
+ if op == "sharpen":
+ tensors[0] = _d._downscale_to(up, h, w)
+ note = f"second_pass_op(sharpen: 2x upsampler pass, resampled back to {h}x{w})"
+ else: # "upscale_2x"
+ tensors[0] = up
+ note = (f"second_pass_op(upscale_2x: {h}x{w} -> {up.shape[-2]}x{up.shape[-1]} — "
+ f"pass 2 runs at {(up.shape[-2] * up.shape[-1]) // max(1, h * w)}x the pixels)")
+ if self._is_nested(result.get("samples")):
+ result["samples"] = comfy.nested_tensor.NestedTensor(tensors)
+ else:
+ result["samples"] = tensors[0]
+ return result, note
+
+ def _restore_pinned_prefix(self, state, chunk):
+ """Put the i2v pin back on a latent that is about to be handed to a second pass.
+
+ _sample_chunk drops noise_mask from what it returns, so without this a second pass
+ would run UNPINNED and re-denoise the anchor frame, letting the reference drift. The
+ pinned region's values are copied back from the original chunk as well, since pass 2
+ re-noises everything it is handed, including frames that are supposed to be frozen.
+ """
+ mask = chunk.get("noise_mask")
+ pinned = self._anchor_pinned_frames(chunk)
+ if mask is None or pinned <= 0:
+ return state
+ try:
+ # A resolution-changing second_pass_op (upscale_2x) leaves the chunk's anchor and
+ # mask the wrong size. Rescaling them here would be inventing an anchor; the
+ # caller reports the lost pin instead.
+ if self._latent_tensors(state)[0].shape[-2:] != self._latent_tensors(chunk)[0].shape[-2:]:
+ return state
+ except Exception:
+ return state
+ result = self._clone_latent(state)
+ try:
+ src = self._latent_tensors(chunk)[0]
+ dst = self._latent_tensors(result)
+ if self._tensor_frames(dst[0]) >= pinned and self._tensor_frames(src) >= pinned:
+ dst[0][:, :, :pinned] = src[:, :, :pinned].to(
+ device=dst[0].device, dtype=dst[0].dtype)
+ if self._is_nested(result.get("samples")):
+ result["samples"] = comfy.nested_tensor.NestedTensor(dst)
+ else:
+ result["samples"] = dst[0]
+ except Exception:
+ return state
+ result["noise_mask"] = self._clone_value(mask)
+ return result
+
+ def _cut_opening_latent(self, latent, drop):
+ """Cut the first `drop` latent frames off a FINISHED scene latent.
+
+ The i2v anchor is a pinned frame at position 0: it transfers identity, style and
+ composition at full strength all the way down the schedule, and it is also literally
+ the first frame of the clip, so every i2v scene opens on the exact reference still.
+ Cutting it (plus the settling-in stretch behind it, where the shot is still leaving
+ that still) afterwards keeps the transfer and removes the tell.
+
+ Nothing is regrown, so the scene simply comes out `drop` latent frames SHORTER than
+ the length that was asked for. That is the whole trade: every surviving frame was
+ generated as part of one continuous shot, with no invented ending and no joint.
+
+ Nothing downstream constrains the new size: LTXAV's video patchifier has temporal
+ patch size 1 (SymmetricPatchifier(1) -> no divisibility rule on latent frames), the
+ streams reach the transformer as independent tensors with no enforced video:audio
+ ratio, and VAE decode is (f-1)*scale+1 for any f >= 1.
+
+ What DOES matter is proportion. Audio timings come from the audio stream's own index
+ and video's from its own RoPE, so cropping the two by different amounts of TIME
+ desynchronises them silently — no error, just sound that drifts. Each extra stream's
+ kept length is therefore derived from the new VIDEO length directly, never by
+ subtracting a separately-rounded drop.
+
+ Returns (latent, dropped).
+ """
+ result = self._clone_latent(latent)
+ tensors = self._latent_tensors(result)
+ video = tensors[0]
+ frames = self._tensor_frames(video)
+ drop = max(0, min(int(drop), frames - 1))
+ if drop <= 0:
+ return result, 0
+ tensors[0] = video[:, :, drop:]
+ kept = frames - drop
+ for _idx in range(1, len(tensors)):
+ _t = tensors[_idx]
+ if _t is None:
+ continue
+ _n = self._tensor_frames(_t)
+ _keep = max(1, min(_n, int(round(_n * kept / max(1, frames)))))
+ if _keep < _n:
+ tensors[_idx] = _t[:, :, _n - _keep:]
+ if self._is_nested(result.get("samples")):
+ result["samples"] = comfy.nested_tensor.NestedTensor(tensors)
+ else:
+ result["samples"] = tensors[0]
+ # The pin belongs to a frame that no longer exists.
+ result.pop("noise_mask", None)
+ return result, drop
+
def _output_connected(self, prompt, unique_id, output_index):
"""Return True if the given output slot index is wired to any downstream node."""
if not prompt or not unique_id:
@@ -4285,6 +4572,132 @@ class FunPackLTXAVSceneChainSampler:
except Exception:
pass
+ # Wrapper keys used by context windows; core registers them under these names.
+ _CTX_WRAPPER_KEYS = ("ContextWindows_prepare_sampling", "ContextWindows_sampler_sample")
+
+ # Core's schedule identifiers put the words the other way round (comfy.context_windows
+ # ContextSchedules: "standard_uniform", "standard_static", "looped_uniform", "batched").
+ # This knob shipped with the readable-but-wrong spellings, so every schedule except
+ # "batched" raised ValueError out of get_matching_context_schedule and failed the whole
+ # render. The knob now offers core's own names; these aliases keep a project saved with
+ # the old ones working instead of failing at generation time.
+ _CTX_SCHEDULE_ALIASES = {
+ "uniform_standard": "standard_uniform",
+ "static_standard": "standard_static",
+ "uniform_looped": "looped_uniform",
+ }
+
+ def _install_context_windows(self, model, length, overlap, schedule, fuse,
+ freenoise, retain_first):
+ """Hand this scene's denoise to ComfyUI core's context-window sampler.
+
+ Nothing is ported: core owns the whole mechanism (comfy.context_windows), we only
+ build its handler and hang it on this scene's model_options, the same way the stock
+ LTXVContextWindows node does. Core picks it up inside comfy.samplers.calc_cond_batch,
+ which every FunPack sampler already goes through (they all call model(x, sigma, ...)
+ on the CFGGuider rather than apply_model directly), so no sampler change is needed.
+
+ Returns (remove_fn, latent_len, None), or (None, None, reason) when it cannot run.
+ Everything that can go wrong here is a mismatch with the installed core, and every
+ one of them used to surface as either the wrong explanation or a failed render, so
+ the reason is carried back and reported per scene rather than inferred.
+
+ The capability gate below is the important part: the handler class itself
+ has existed for a long time for unpacked video, but the packed AV latent needs
+ BaseModel.map_context_window_to_modalities (+ resize_cond_for_context_window) to
+ unpack the AV stream, map each video window onto its audio window and re-slice
+ keyframe_idxs / guide_attention_entries. Without those, enabling this on LTXAV would
+ window the packed tensor blindly and quietly wreck audio and guides — the same class
+ of packed-vs-unpacked mistake that killed DynamicConditioning. Refuse instead.
+ """
+ try:
+ import comfy.context_windows as _cw
+ import comfy.patcher_extension as _pe
+ except ImportError:
+ return None, None, ("this ComfyUI build has no context-window support at all "
+ "(needs ComfyUI >= v0.29.0)")
+ base = getattr(model, "model", None)
+ if not hasattr(base, "map_context_window_to_modalities"):
+ return None, None, ("this ComfyUI build has no LTXAV context-window support "
+ "(needs the core context-windows change, ComfyUI >= v0.29.0)")
+ # Real frames -> latent frames, exactly as core's LTXVContextWindows node does it.
+ latent_len = max(((int(length) - 1) // 8) + 1, 1)
+ latent_overlap = max(int(overlap) // 8, 0)
+ retain = "0" if retain_first else ""
+ # Resolve the names against CORE's vocabulary, not ours, and say what it accepts —
+ # these raise ValueError, which used to escape and fail the render outright.
+ _sched_name = self._CTX_SCHEDULE_ALIASES.get(str(schedule), str(schedule))
+ try:
+ _schedule = _cw.get_matching_context_schedule(_sched_name)
+ except ValueError:
+ _known = ", ".join(sorted(getattr(_cw, "CONTEXT_MAPPING", {}) or {}))
+ return None, None, (f"context_window_schedule={schedule!r} is not a schedule this "
+ f"ComfyUI knows{f' — it accepts {_known}' if _known else ''}")
+ try:
+ _fuse = _cw.get_matching_fuse_method(str(fuse))
+ except ValueError:
+ _known = ", ".join(sorted(getattr(_cw, "FUSE_MAPPING", {}) or {}))
+ return None, None, (f"context_window_fuse={fuse!r} is not a blend this ComfyUI "
+ f"knows{f' — it accepts {_known}' if _known else ''}")
+ kwargs = dict(
+ context_schedule=_schedule,
+ fuse_method=_fuse,
+ context_length=latent_len,
+ context_overlap=latent_overlap,
+ context_stride=1,
+ closed_loop=False,
+ dim=2,
+ freenoise=bool(freenoise),
+ cond_retain_index_list=retain,
+ latent_retain_index_list=retain,
+ split_conds_to_windows=False,
+ )
+ # Core's handler has gained and lost keywords over time (latent_retain_index_list is
+ # absent on some builds). Passing one it doesn't take raised TypeError and the whole
+ # feature was then reported as "core too old", which is a different and misleading
+ # thing. Drop the extras instead, and name the one knob that stops working.
+ _dropped = []
+ try:
+ import inspect as _inspect
+ _accepted = set(_inspect.signature(_cw.IndexListContextHandler).parameters)
+ if "kwargs" not in _accepted:
+ _dropped = [k for k in kwargs if k not in _accepted]
+ for k in _dropped:
+ kwargs.pop(k)
+ except (TypeError, ValueError):
+ pass
+ try:
+ handler = _cw.IndexListContextHandler(**kwargs)
+ except TypeError as exc:
+ return None, None, (f"this ComfyUI's context-window handler takes different "
+ f"arguments than expected ({exc})")
+ prev = model.model_options.get("context_handler")
+ had_prev = "context_handler" in model.model_options
+ model.model_options["context_handler"] = handler
+ _cw.create_prepare_sampling_wrapper(model)
+ if freenoise:
+ _cw.create_sampler_sample_wrapper(model)
+
+ def _remove():
+ if had_prev:
+ model.model_options["context_handler"] = prev
+ else:
+ model.model_options.pop("context_handler", None)
+ for wrapper_type, key in (
+ (_pe.WrappersMP.PREPARE_SAMPLING, self._CTX_WRAPPER_KEYS[0]),
+ (_pe.WrappersMP.SAMPLER_SAMPLE, self._CTX_WRAPPER_KEYS[1]),
+ ):
+ try:
+ model.remove_wrappers_with_key(wrapper_type, key)
+ except Exception:
+ pass
+
+ _note = None
+ if retain_first and "latent_retain_index_list" in _dropped:
+ _note = ("this ComfyUI's context-window handler has no latent_retain_index_list, "
+ "so 'pin anchor in every window' applies to conditioning only")
+ return _remove, latent_len, _note
+
def _build_plateau_cache_wrapper(self, model, threshold):
"""Plateau step-cache (MixCache/Chorus-family, adapted to LTX2.3's distilled schedule).
@@ -4369,6 +4782,19 @@ class FunPackLTXAVSceneChainSampler:
orig_prepare_timestep = ltxv._prepare_timestep
orig_prepare_pe = ltxv._prepare_positional_embeddings
orig_process_output = ltxv._process_output
+ # These wrappers reach into LTXBaseModel internals, so a ComfyUI refactor can break
+ # one of them. Falling back silently is how a broken source-phase tag once passed as
+ # "Best-FaceID makes the clip open on the reference image" — report each failure once
+ # per install (not per step) so the cause is visible in the console immediately.
+ _warned: set = set()
+
+ def _warn_once(what, exc):
+ if what in _warned:
+ return
+ _warned.add(what)
+ print(f"[FunPackSceneChain] identity_transfer: {what} failed ({type(exc).__name__}: {exc}) "
+ f"— identity conditioning is NOT being applied correctly this run. "
+ f"This usually means a ComfyUI update changed the LTX model internals.")
def process_input(self_ltxv, x, keyframe_idxs, denoise_mask, **kw):
out = orig_process_input(x, keyframe_idxs, denoise_mask, **kw)
@@ -4397,7 +4823,8 @@ class FunPackLTXAVSceneChainSampler:
else:
xx, pix = vx, vco
return xx, pix, add
- except Exception:
+ except Exception as e:
+ _warn_once("reference-token append (_process_input)", e)
self_ltxv._funpack_id_ref_len = 0
return out
@@ -4426,7 +4853,10 @@ class FunPackLTXAVSceneChainSampler:
v_pe = _rotate_overlap_freqs(v_pe, ref_len, seg_value)
return [(v_pe, cross_v), pe[1]]
return _rotate_overlap_freqs(pe, ref_len, seg_value)
- except Exception:
+ except Exception as e:
+ # Untagged reference tokens share the target's frame-0 grid, so the model
+ # renders them AS frame 0 — the clip opens on the reference image.
+ _warn_once("source-phase RoPE tag (_prepare_positional_embeddings)", e)
return pe
def process_output(self_ltxv, x, embedded_timestep, keyframe_idxs, **kw):
@@ -4453,8 +4883,8 @@ class FunPackLTXAVSceneChainSampler:
x = x[:, :x.shape[1] - ref_len]
if hasattr(embedded_timestep, "shape") and embedded_timestep.dim() >= 2 and embedded_timestep.shape[1] > 1:
embedded_timestep = embedded_timestep[:, : embedded_timestep.shape[1] - ref_len]
- except Exception:
- pass
+ except Exception as e:
+ _warn_once("reference-token trim (_process_output)", e)
return orig_process_output(x, embedded_timestep, keyframe_idxs, **kw)
ltxv._process_input = types.MethodType(process_input, ltxv)
@@ -4683,6 +5113,11 @@ class FunPackLTXAVSceneChainSampler:
segmented_detailing=False, detail_targets="hands",
detail_upsampler="None", detail_strength=1.0, detail_threshold=0.35,
detail_max_area=0.35, detail_denoise=0.85, detail_mode="repair",
+ context_windows=False, context_window_length=145, context_window_overlap=40,
+ context_window_schedule="standard_uniform", context_window_fuse="pyramid",
+ context_window_freenoise=True, context_window_retain_first=False,
+ cut_opening_frames=0,
+ second_pass=False, second_pass_op="none", second_pass_sigmas=None,
unique_id=None, prompt=None):
if not isinstance(positive, list) or not positive:
raise ValueError("positive conditioning must contain at least one scene entry.")
@@ -4741,6 +5176,15 @@ class FunPackLTXAVSceneChainSampler:
dynashift=dynashift, dynashift_strength=dynashift_strength,
dynashift_threshold=dynashift_threshold,
plateau_cache=plateau_cache, plateau_cache_threshold=plateau_cache_threshold,
+ context_windows=context_windows, context_window_length=context_window_length,
+ context_window_overlap=context_window_overlap,
+ context_window_schedule=context_window_schedule,
+ context_window_fuse=context_window_fuse,
+ context_window_freenoise=context_window_freenoise,
+ context_window_retain_first=context_window_retain_first,
+ cut_opening_frames=cut_opening_frames,
+ second_pass=second_pass, second_pass_op=second_pass_op,
+ second_pass_sigmas=second_pass_sigmas,
)
max_scene_count = max(1, int(max_scenes))
@@ -4854,7 +5298,17 @@ class FunPackLTXAVSceneChainSampler:
if first_scene_seed is None:
first_scene_seed = int(seed)
- steps_per_scene = max(1, int(len(sigmas)) - 1)
+ # A second pass samples every scene twice, so its steps have to be in the total or
+ # the bar overflows on scene 1 and jumps backwards on scene 2 (each scene's offset
+ # is a multiple of steps_per_scene). The pass-2 schedule is one node input shared by
+ # every scene, so it can be measured once here — and validated the same way the loop
+ # will, so an unusable schedule doesn't inflate the total for a pass that gets skipped.
+ _pass2_steps = 0
+ if second_pass:
+ _sp_probe, _ = self._second_pass_schedule(second_pass_sigmas)
+ if _sp_probe is not None:
+ _pass2_steps = max(0, int(_sp_probe.numel()) - 1)
+ steps_per_scene = max(1, (int(len(sigmas)) - 1) + _pass2_steps)
total_sampling_steps = scene_count * steps_per_scene
pbar = None
try:
@@ -4873,6 +5327,7 @@ class FunPackLTXAVSceneChainSampler:
_identity_overlap_state: dict = {}
_detail_upsampler_model = None # lazy: resolved+loaded at the first detailed scene
_detail_disabled_reason = None # set on resolve/load failure: don't retry per scene
+ _ctx_unsupported_reported = False # print the "core too old" line once, not per scene
for scene_index, scene_cond in enumerate(scene_conditionings):
scene_positive = [scene_cond]
@@ -4992,11 +5447,56 @@ class FunPackLTXAVSceneChainSampler:
_v2a_handles = []
_identity_overlap_handle = None
_plateau_stats = None
+ _ctx_remove = None
try:
+ # Context windows: core-owned, installed first so every FunPack wrapper below
+ # sits inside the per-window forward rather than around the whole clip.
+ if context_windows:
+ _ctx_remove, _ctx_latent_len, _ctx_reason = self._install_context_windows(
+ model, context_window_length, context_window_overlap,
+ context_window_schedule, context_window_fuse,
+ context_window_freenoise, context_window_retain_first)
+ if _ctx_remove is None:
+ # Every reason here is a mismatch with the installed core, so it is
+ # the same for every scene — print it once, report it on each.
+ if not _ctx_unsupported_reported:
+ print(f"[FunPackSceneChain] context_windows requested but "
+ f"{_ctx_reason} — skipped.")
+ _ctx_unsupported_reported = True
+ run_mechanisms.append(f"context_windows(SKIPPED: {_ctx_reason})")
+ else:
+ if _ctx_reason:
+ run_mechanisms.append(f"context_windows NOTE: {_ctx_reason}")
+ # Core engages windowing only when the scene is longer than the window,
+ # and logs that decision itself; say so here too so a scene that silently
+ # sampled whole doesn't read as the feature being broken.
+ _scene_latent_len = self._context_scene_latent_frames(chunk)
+ if _scene_latent_len is not None and _scene_latent_len <= _ctx_latent_len:
+ run_mechanisms.append(
+ f"context_windows(inactive: scene {_scene_latent_len} <= window "
+ f"{_ctx_latent_len} latent frames — raise num_frames_per_scene or "
+ f"lower context_window_length)")
+ else:
+ run_mechanisms.append(
+ f"context_windows({context_window_schedule},{context_window_fuse},"
+ f"len={_ctx_latent_len},ovl={max(int(context_window_overlap) // 8, 0)}"
+ f"{',freenoise' if context_window_freenoise else ''}"
+ f"{',retain_first' if context_window_retain_first else ''})")
# Innermost wrapper (installed first): caches the raw base-model forward on the
# near-noise plateau so later plateau steps reuse it. All guidance wrappers below
# layer around it and still post-process each step's (cached-or-fresh) prediction.
- if plateau_cache:
+ #
+ # MUTUALLY EXCLUSIVE with context windows: the cache is keyed by
+ # (input.shape, cond_or_uncond), and every window within one step calls the model
+ # with the SAME shape. Windows 2..N would therefore be handed window 1's cached
+ # prediction on every plateau step — one window's content bleeding across the whole
+ # clip. Context windows wins (it's a capability, the cache is a speed experiment);
+ # say so in the report rather than silently dropping one of two ticked boxes.
+ if plateau_cache and _ctx_remove is not None:
+ run_mechanisms.append(
+ "plateau_cache(SKIPPED: incompatible with context_windows — the per-step "
+ "cache cannot tell two windows apart)")
+ elif plateau_cache:
_plateau_stats = self._build_plateau_cache_wrapper(model, plateau_cache_threshold)
# Loop temporal style (auto director's funpack_temporal_loop): Mobius latent
# roll. Installed BELOW the guidance wrappers (embed guidance / slider /
@@ -5101,14 +5601,129 @@ class FunPackLTXAVSceneChainSampler:
)
run_mechanisms.append(f"identity_transfer_arcface(id_strength={id_strength})")
_t_sample0 = _time.perf_counter()
- sampled = self._sample_chunk(
- model, sampler, sigmas, scene_seed, cfg, scene_positive, scene_negative, chunk,
+ _sample_kwargs = dict(
pbar=pbar, step_offset=scene_index * steps_per_scene,
alg_guide_tail_frames=(guide_tail if (alg_blur_guides and guide_tail > 0) else 0),
alg_guide_blur_strength=alg_guide_blur_strength,
alg_guide_blur_sigma_threshold=alg_guide_blur_sigma_threshold,
bounded_attention_enabled=bounded_attention_enabled,
)
+ # cut_opening_frames: let the real, untouched i2v anchor condition the scene
+ # at full strength, then cut it out of the finished clip instead of weakening
+ # it on the way in (ALG blurs the anchor and loses character detail; overlap
+ # tokens approximate it and lose some too). Eligibility is decided here, while
+ # the pre-sampling chunk is still around; the cut itself is a post-process on
+ # the finished latent, below, and costs no sampling at all.
+ _cut_drop = 0
+ if int(cut_opening_frames) > 0:
+ _cut_drop = self._expected_latent_frames(
+ int(cut_opening_frames) + 1, time_scale) - 1
+ if carried + soft_carried > 0:
+ _reason = ("continuation scene — the opening is carried frames from the "
+ "previous scene, not an anchor, and cutting them would break "
+ "the join with it")
+ elif guide_tail > 0 or audio_tail > 0:
+ _reason = ("scene is carrying appended frames (mid_scene_guide / carried "
+ "i2v guides / JoyAI audio memory) that are stripped after "
+ "sampling — the audio crop is derived from the video length, "
+ "which those extra frames would make wrong")
+ elif self._context_scene_latent_frames(chunk) is None:
+ _reason = "scene latent could not be read"
+ elif _cut_drop <= 0:
+ _reason = (f"cut_opening_frames={cut_opening_frames} is under one latent "
+ f"frame at this VAE time scale")
+ elif self._anchor_pinned_frames(chunk) <= 0:
+ # No pinned prefix = a genuine t2v scene (no anchor image attached).
+ # Cutting here would throw away real generated frames for nothing —
+ # this is the "fake t2v" trick, it needs a real anchor to fake it WITH.
+ _reason = ("scene has no pinned i2v anchor (t2v scene) — nothing to cut "
+ "out; attach an anchor image, or set cut_opening_frames to 0")
+ else:
+ _reason = None
+ if _reason is not None:
+ run_mechanisms.append(f"cut_opening_frames(SKIPPED: {_reason})")
+ _cut_drop = 0
+ # Optional second pass. The shape is deliberately plain: pass 1 runs the
+ # main schedule IN FULL, exactly as written, then pass 2 runs its own
+ # schedule IN FULL, exactly as written. Nothing is cut short and nothing is
+ # derived — to make pass 1 shorter, shorten the main schedule. Pass 2 starts
+ # from a FINISHED clip, so it re-enters through comfy's ordinary CONST noise
+ # scaling, valid precisely because the input is clean. Total steps are simply
+ # the two schedules added up.
+ _sp_b = None
+ if second_pass:
+ _sp_b, _sp_reason = self._second_pass_schedule(second_pass_sigmas)
+ if _sp_b is None:
+ run_mechanisms.append(f"second_pass(SKIPPED: {_sp_reason})")
+ _scene_label = (f"scene {scene_index + 1}/{scene_count}"
+ if scene_count > 1 else "")
+ self._set_phase(f"{_scene_label}{' · ' if _scene_label else ''}"
+ f"{'pass 1 of 2' if _sp_b is not None else 'sampling'}")
+ _full = self._sample_chunk(
+ model, sampler, sigmas, scene_seed, cfg, scene_positive,
+ scene_negative, chunk, **_sample_kwargs)
+ if _sp_b is None:
+ sampled = _full
+ else:
+ _sp_state = _full
+ if second_pass_op not in (None, "none", ""):
+ # The op runs on the FINISHED pass-1 clip, which is the only state
+ # it makes sense on: sharpening a half-denoised latent sharpens the
+ # noise in it. Shares segmented detailing's lazily-loaded upsampler.
+ if _detail_upsampler_model is None and _detail_disabled_reason is None:
+ try:
+ try:
+ from . import detailing as _det
+ except ImportError:
+ import detailing as _det
+ _r = _det.resolve_upsampler_name(detail_upsampler)
+ _detail_upsampler_model = _det.load_latent_upsampler(_r)
+ except Exception as _exc: # noqa: BLE001
+ _detail_disabled_reason = str(_exc)
+ # resolve_upsampler_name prints when it DOWNLOADS; nothing
+ # printed when it can't, so a silent skip looked like the op
+ # having run. Say it on the console too, once.
+ print(f"[FunPackSceneChain] second_pass_op={second_pass_op} "
+ f"needs the latent upsampler and it could not be "
+ f"loaded: {_detail_disabled_reason} — the second pass "
+ f"still runs, without the operation.")
+ _sp_state, _op_note = self._second_pass_operate(
+ _sp_state, second_pass_op, _detail_upsampler_model, vae)
+ if _op_note:
+ # "no upsampler could be loaded" on its own doesn't say WHY —
+ # missing huggingface_hub, a failed download, an unreadable file
+ # are all different fixes. Carry the real reason through.
+ if _detail_upsampler_model is None and _detail_disabled_reason:
+ _op_note = f"{_op_note} ({_detail_disabled_reason})"
+ run_mechanisms.append(_op_note)
+ # A second pass must KEEP the i2v anchor pinned — otherwise pass 2
+ # re-denoises the reference frame and the scene drifts away from the
+ # image it was supposed to start from.
+ _sp_before = _sp_state
+ _sp_state = self._restore_pinned_prefix(_sp_state, chunk)
+ if _sp_state is _sp_before and self._anchor_pinned_frames(chunk) > 0:
+ # Only happens when the op changed the resolution — say so, because
+ # an unpinned pass 2 can drift off the reference image.
+ run_mechanisms.append(
+ "second_pass NOTE: the i2v anchor could not stay pinned for pass 2 "
+ "because second_pass_op changed the latent resolution — pass 2 runs "
+ "unpinned and may drift from the reference image.")
+ _sp_kw = dict(_sample_kwargs)
+ _sp_kw["step_offset"] = _sample_kwargs["step_offset"] + (int(sigmas.numel()) - 1)
+ # Announced BEFORE it runs: the after-the-fact run_mechanisms line says
+ # a second pass happened, which is no help while you are waiting on one.
+ self._set_phase(f"{_scene_label}{' · ' if _scene_label else ''}pass 2 of 2")
+ print(f"[FunPack AV] second pass starting"
+ f"{' on ' + _scene_label if _scene_label else ''}: "
+ f"{int(_sp_b.numel()) - 1} steps from sigma {float(_sp_b[0].item()):g}")
+ sampled = self._sample_chunk(
+ model, sampler, _sp_b, scene_seed + 4242, cfg, scene_positive,
+ scene_negative, _sp_state, **_sp_kw)
+ run_mechanisms.append(
+ f"second_pass({int(sigmas.numel()) - 1} steps + "
+ f"{int(_sp_b.numel()) - 1} steps = "
+ f"{int(sigmas.numel()) + int(_sp_b.numel()) - 2} total, "
+ f"pass 2 from {float(_sp_b[0].item()):g})")
_scene_sample_s = _time.perf_counter() - _t_sample0
_phase_sampling += _scene_sample_s
if _plateau_stats is not None:
@@ -5118,6 +5733,11 @@ class FunPackLTXAVSceneChainSampler:
run_mechanisms.append(
f"plateau_cache(skipped {_reused}/{_total} plateau fwd, thr={plateau_cache_threshold})")
finally:
+ # Nothing is sampling once this scene is out of the sampler — including on an
+ # interrupt, which is exactly when a stale "pass 2 of 2" would linger.
+ self._set_phase("")
+ if _ctx_remove is not None:
+ _ctx_remove()
self._remove_v2a_scale(_v2a_handles)
self._strip_identity_overlap(_identity_overlap_handle)
if model.model_options.get("model_function_wrapper") is not _scene_base_wrapper:
@@ -5125,6 +5745,25 @@ class FunPackLTXAVSceneChainSampler:
model.model_options["model_function_wrapper"] = _scene_base_wrapper
else:
model.model_options.pop("model_function_wrapper", None)
+ if _cut_drop > 0:
+ # The finished clip, minus its opening. Nothing is regrown, so the scene is
+ # now shorter than the length that was asked for — say so in the report
+ # rather than leaving it to be inferred from the output.
+ _before_cut = self._tensor_frames(self._latent_tensors(sampled)[0])
+ sampled, _dropped = self._cut_opening_latent(sampled, _cut_drop)
+ _after_cut = self._tensor_frames(self._latent_tensors(sampled)[0])
+ _pinned_n = self._anchor_pinned_frames(chunk)
+ run_mechanisms.append(
+ f"cut_opening_frames(dropped {_dropped} of {_pinned_n} pinned latent "
+ f"frames — clip CROPPED to {_after_cut} of {_before_cut} latent frames, "
+ f"~{int(cut_opening_frames)} real frames shorter)")
+ if _dropped < _pinned_n:
+ # Part of the anchor is still visible — the one outcome this exists to
+ # prevent, so name the fix.
+ run_mechanisms.append(
+ f"cut_opening_frames WARNING: {_pinned_n - _dropped} anchor latent "
+ f"frame(s) still in the clip — raise cut_opening_frames to at least "
+ f"{_pinned_n * max(1, int(time_scale))}")
if carried + soft_carried > 0:
sampled = self._crop_video_head(sampled, carried + soft_carried)
if guide_tail > 0:
@@ -5145,7 +5784,7 @@ class FunPackLTXAVSceneChainSampler:
if _detail_upsampler_model is None:
_resolved = _detailing.resolve_upsampler_name(detail_upsampler)
_detail_upsampler_model = _detailing.load_latent_upsampler(_resolved)
- print(f"[FunPackSceneChain] segmented detailing upsampler: {_resolved}")
+ print(f"[FunPackSceneChain] latent upsampler loaded: {_resolved}")
_t_detail0 = _time.perf_counter()
sampled, _detail_note = _detailing.detail_refine_scene(
self, model, vae, sampler, scene_positive, scene_negative, sampled,
@@ -5439,7 +6078,14 @@ class FunPackLTXAVSceneChainSampler:
output_guidance=False, output_guidance_strength=0.02,
dynashift=False, dynashift_strength=0.3, dynashift_threshold=0.6,
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
- plateau_cache=False, plateau_cache_threshold=0.975):
+ plateau_cache=False, plateau_cache_threshold=0.975,
+ context_windows=False, context_window_length=145,
+ context_window_overlap=40, context_window_schedule="standard_uniform",
+ context_window_fuse="pyramid", context_window_freenoise=True,
+ context_window_retain_first=False,
+ cut_opening_frames=0,
+ second_pass=False, second_pass_op="none",
+ second_pass_sigmas=None):
"""Sample one chain per Studio-packed variant entry (seed + index), persisting each result
(latent + preview + per-entry cond + manifest) under ComfyUI temp for rating in Studio.
Reuses sample() per entry with only the seed changed, so each entry is a clean generation."""
@@ -5493,6 +6139,15 @@ class FunPackLTXAVSceneChainSampler:
dynashift=dynashift, dynashift_strength=dynashift_strength,
dynashift_threshold=dynashift_threshold,
plateau_cache=plateau_cache, plateau_cache_threshold=plateau_cache_threshold,
+ context_windows=context_windows, context_window_length=context_window_length,
+ context_window_overlap=context_window_overlap,
+ context_window_schedule=context_window_schedule,
+ context_window_fuse=context_window_fuse,
+ context_window_freenoise=context_window_freenoise,
+ context_window_retain_first=context_window_retain_first,
+ cut_opening_frames=cut_opening_frames,
+ second_pass=second_pass, second_pass_op=second_pass_op,
+ second_pass_sigmas=second_pass_sigmas,
unique_id=None, prompt=None,
)
last = out
diff --git a/tests/test_context_windows.py b/tests/test_context_windows.py
new file mode 100644
index 00000000..1498aaab
--- /dev/null
+++ b/tests/test_context_windows.py
@@ -0,0 +1,295 @@
+"""Context-window install/remove contract on the Scene Chain sampler.
+
+The mechanism itself is ComfyUI core's (comfy.context_windows) — nothing here tests
+core's windowing math. What IS ours, and what these cover:
+
+1. The capability gate. Core's handler class predates LTXAV support; only builds that
+ also have BaseModel.map_context_window_to_modalities can unpack the packed AV latent
+ and re-slice guides/audio per window. On an older core we must REFUSE, not window a
+ packed tensor blindly (the DynamicConditioning failure mode).
+2. Real-frame -> latent-frame conversion matches core's own LTXVContextWindows node.
+3. Install/remove leaves the shared model exactly as found, including on an interrupt
+ mid-scene — the same guarantee _strip_funpack_scene_wrappers gives the function
+ wrappers, but for patcher wrappers and the context_handler model_option.
+4. The NAMES are core's, not ours. The knob shipped with readable-but-wrong spellings
+ ("uniform_standard"); core calls them the other way round ("standard_uniform"), so
+ every schedule except "batched" raised ValueError out of the sampler and failed the
+ whole render. Core's names are the choices now, the old ones are aliased, and an
+ unknown one is refused with a reason naming what core does accept.
+5. A keyword this core does not take is DROPPED, not fatal. latent_retain_index_list is
+ absent on some builds; passing it raised TypeError, which was reported as "ComfyUI
+ core too old" — so a core that fully supports windowing had the feature silently
+ switched off. Install anyway and name the knob that stops working.
+
+Install returns (remove_fn, latent_len, note) or (None, None, reason): every failure here
+is a mismatch with the installed core, and reporting the WRONG one is how (5) hid.
+"""
+import sys
+import types
+from pathlib import Path
+
+import torch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+for _name in (
+ "comfy", "comfy.k_diffusion", "comfy.k_diffusion.sampling",
+ "comfy.model_sampling", "comfy.nested_tensor", "comfy.sample",
+ "comfy.samplers", "comfy.utils",
+):
+ sys.modules.setdefault(_name, types.ModuleType(_name))
+sys.modules["comfy.nested_tensor"].NestedTensor = object
+
+import samplers # noqa: E402
+
+
+class _Handler:
+ def __init__(self, **kwargs):
+ self.kwargs = kwargs
+
+
+def _install_fake_core(monkeypatch, ltxav_support=True, accept_latent_retain=True):
+ """Stand in for comfy.context_windows + comfy.patcher_extension.
+
+ Records what the sampler asks core to do, so the test asserts on the request
+ rather than on core's behaviour.
+ """
+ calls = {"prepare": 0, "sampler_sample": 0, "removed": []}
+
+ cw = types.ModuleType("comfy.context_windows")
+
+ def _handler(**kwargs):
+ if not accept_latent_retain and "latent_retain_index_list" in kwargs:
+ raise TypeError("unexpected keyword argument 'latent_retain_index_list'")
+ return _Handler(**kwargs)
+
+ cw.IndexListContextHandler = _handler
+ # Core's real vocabulary (comfy.context_windows ContextSchedules / ContextFuseMethods),
+ # and core's real behaviour on anything else: ValueError.
+ cw.CONTEXT_MAPPING = dict.fromkeys(
+ ["standard_uniform", "standard_static", "looped_uniform", "batched"], object())
+ cw.FUSE_MAPPING = dict.fromkeys(["pyramid", "relative", "flat", "overlap-linear"], object())
+
+ def _sched(name):
+ if name not in cw.CONTEXT_MAPPING:
+ raise ValueError(f"Unknown context_schedule '{name}'.")
+ return f"sched:{name}"
+
+ def _fuse_of(name):
+ if name not in cw.FUSE_MAPPING:
+ raise ValueError(f"Unknown fuse_method '{name}'.")
+ return f"fuse:{name}"
+
+ cw.get_matching_context_schedule = _sched
+ cw.get_matching_fuse_method = _fuse_of
+
+ def _prep(model):
+ calls["prepare"] += 1
+
+ def _samp(model):
+ calls["sampler_sample"] += 1
+
+ cw.create_prepare_sampling_wrapper = _prep
+ cw.create_sampler_sample_wrapper = _samp
+
+ pe = types.ModuleType("comfy.patcher_extension")
+ pe.WrappersMP = types.SimpleNamespace(
+ PREPARE_SAMPLING="prepare_sampling", SAMPLER_SAMPLE="sampler_sample")
+
+ monkeypatch.setitem(sys.modules, "comfy.context_windows", cw)
+ monkeypatch.setitem(sys.modules, "comfy.patcher_extension", pe)
+
+ inner = types.SimpleNamespace()
+ if ltxav_support:
+ inner.map_context_window_to_modalities = lambda *a, **k: None
+
+ model = types.SimpleNamespace(
+ model_options={},
+ model=inner,
+ remove_wrappers_with_key=lambda t, k: calls["removed"].append((t, k)),
+ )
+ return model, calls
+
+
+def _install(sampler, model, **overrides):
+ kwargs = dict(length=145, overlap=40, schedule="uniform_standard",
+ fuse="pyramid", freenoise=True, retain_first=False)
+ kwargs.update(overrides)
+ return sampler._install_context_windows(model, **kwargs)
+
+
+def test_refuses_on_core_without_ltxav_window_support(monkeypatch):
+ model, calls = _install_fake_core(monkeypatch, ltxav_support=False)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, reason = _install(s, model)
+ assert remove is None and "LTXAV context-window support" in reason
+ # Nothing installed: a refusal must not half-configure the shared model.
+ assert "context_handler" not in model.model_options
+ assert calls["prepare"] == 0 and calls["sampler_sample"] == 0
+
+
+def test_refuses_when_the_handler_rejects_an_argument_outright(monkeypatch):
+ """A TypeError we could not see coming from the signature. Still a refusal — but one
+ that reports the argument mismatch instead of blaming the core version."""
+ model, calls = _install_fake_core(monkeypatch, accept_latent_retain=False)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, reason = _install(s, model)
+ assert remove is None and "different" in reason
+ assert "context_handler" not in model.model_options
+
+
+def test_real_frames_convert_to_latent_frames_like_core_node(monkeypatch):
+ model, _ = _install_fake_core(monkeypatch)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, latent_len, _ = _install(s, model, length=145, overlap=40)
+ # Core's LTXVContextWindows: ((145 - 1) // 8) + 1 = 19, 40 // 8 = 5.
+ assert latent_len == 19
+ handler = model.model_options["context_handler"]
+ assert handler.kwargs["context_length"] == 19
+ assert handler.kwargs["context_overlap"] == 5
+ assert handler.kwargs["dim"] == 2
+ remove()
+
+
+def test_retain_first_sets_both_cond_and_latent_retain(monkeypatch):
+ model, _ = _install_fake_core(monkeypatch)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, _ = _install(s, model, retain_first=True)
+ handler = model.model_options["context_handler"]
+ # i2v anchors live in the noise latent, not only in conditioning channels, so both
+ # lists must be set — cond alone would drop the anchor from later windows.
+ assert handler.kwargs["cond_retain_index_list"] == "0"
+ assert handler.kwargs["latent_retain_index_list"] == "0"
+ remove()
+
+
+def test_freenoise_off_skips_the_sampler_sample_wrapper(monkeypatch):
+ model, calls = _install_fake_core(monkeypatch)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, _ = _install(s, model, freenoise=False)
+ assert calls["prepare"] == 1
+ assert calls["sampler_sample"] == 0
+ remove()
+
+
+def test_remove_restores_model_options_and_drops_both_wrappers(monkeypatch):
+ model, calls = _install_fake_core(monkeypatch)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, _ = _install(s, model)
+ assert "context_handler" in model.model_options
+ remove()
+ assert "context_handler" not in model.model_options
+ assert calls["removed"] == [
+ ("prepare_sampling", "ContextWindows_prepare_sampling"),
+ ("sampler_sample", "ContextWindows_sampler_sample"),
+ ]
+
+
+def test_remove_restores_a_pre_existing_foreign_handler(monkeypatch):
+ """A user-placed LTXVContextWindows node upstream must survive our scene."""
+ model, _ = _install_fake_core(monkeypatch)
+ foreign = object()
+ model.model_options["context_handler"] = foreign
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, _ = _install(s, model)
+ assert model.model_options["context_handler"] is not foreign
+ remove()
+ assert model.model_options["context_handler"] is foreign
+
+
+def test_scene_latent_frame_count_reads_the_video_stream():
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ chunk = {"samples": torch.zeros(1, 128, 23, 8, 8)}
+ assert s._context_scene_latent_frames(chunk) == 23
+ # Unreadable latent reports None rather than raising: this value is only ever used
+ # for the scene report, never for control flow.
+ assert s._context_scene_latent_frames({"samples": None}) is None
+
+
+# ── the names are core's, not ours ──────────────────────────────────────────
+
+def test_every_schedule_the_node_offers_actually_resolves(monkeypatch):
+ """The bug in one line: the node offered spellings core has never had, so choosing any
+ of them raised ValueError out of the sampler and killed the render. Every name the combo
+ accepts must now reach a real core schedule — directly or through the alias map."""
+ model, _ = _install_fake_core(monkeypatch)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ offered = s.INPUT_TYPES()["optional"]["context_window_schedule"][0]
+ known = set(sys.modules["comfy.context_windows"].CONTEXT_MAPPING)
+ assert known <= set(offered) # nothing core supports is missing from the knob
+ for name in offered:
+ model, _ = _install_fake_core(monkeypatch)
+ assert _install(s, model, schedule=name)[0] is not None, name
+
+
+def test_the_legacy_names_stay_in_the_combo_list_not_only_in_the_alias_map():
+ """ComfyUI validates combo values at QUEUE time, before the node runs. Dropping the old
+ spellings from the list would reject a saved project outright and the alias would never
+ get a chance — which is a worse failure than the one being fixed."""
+ offered = samplers.FunPackLTXAVSceneChainSampler().INPUT_TYPES()[
+ "optional"]["context_window_schedule"][0]
+ for legacy in samplers.FunPackLTXAVSceneChainSampler._CTX_SCHEDULE_ALIASES:
+ assert legacy in offered, legacy
+
+
+def test_the_old_reversed_spellings_still_resolve(monkeypatch):
+ """A project saved before the names were corrected must keep generating, not fail."""
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ for old, new in (("uniform_standard", "standard_uniform"),
+ ("static_standard", "standard_static"),
+ ("uniform_looped", "looped_uniform")):
+ model, _ = _install_fake_core(monkeypatch)
+ remove, _, reason = _install(s, model, schedule=old)
+ assert remove is not None, (old, reason)
+ assert model.model_options["context_handler"].kwargs["context_schedule"] == f"sched:{new}"
+
+
+def test_an_unknown_schedule_is_refused_with_what_core_accepts(monkeypatch):
+ model, calls = _install_fake_core(monkeypatch)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, reason = _install(s, model, schedule="nonsense")
+ assert remove is None
+ assert "nonsense" in reason and "standard_uniform" in reason
+ # A refusal must not half-configure the shared model.
+ assert "context_handler" not in model.model_options
+ assert calls["prepare"] == 0
+
+
+def test_an_unknown_fuse_method_is_refused_with_what_core_accepts(monkeypatch):
+ model, _ = _install_fake_core(monkeypatch)
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, _, reason = _install(s, model, fuse="wobble")
+ assert remove is None
+ assert "wobble" in reason and "overlap-linear" in reason
+
+
+# ── a keyword this core does not take ───────────────────────────────────────
+
+class _NoLatentRetainHandler:
+ """The shape of the build that reported itself as 'core too old': core's handler minus
+ latent_retain_index_list, declared in the signature so it can be seen before calling."""
+ def __init__(self, context_schedule, fuse_method, context_length=1, context_overlap=0,
+ context_stride=1, closed_loop=False, dim=0, freenoise=False,
+ cond_retain_index_list=(), split_conds_to_windows=False):
+ self.kwargs = dict(context_schedule=context_schedule, context_length=context_length,
+ cond_retain_index_list=cond_retain_index_list)
+
+
+def test_an_unsupported_keyword_is_dropped_and_windowing_still_runs(monkeypatch):
+ model, calls = _install_fake_core(monkeypatch)
+ sys.modules["comfy.context_windows"].IndexListContextHandler = _NoLatentRetainHandler
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ remove, latent_len, note = _install(s, model, retain_first=True)
+ assert remove is not None and latent_len == 19
+ assert calls["prepare"] == 1
+ # The one knob that really does stop working is named, not swallowed.
+ assert "latent_retain_index_list" in note
+ remove()
+
+
+def test_no_note_when_retain_first_is_off(monkeypatch):
+ """Nothing was lost, so nothing to report — a note here would be noise on every scene."""
+ model, _ = _install_fake_core(monkeypatch)
+ sys.modules["comfy.context_windows"].IndexListContextHandler = _NoLatentRetainHandler
+ s = samplers.FunPackLTXAVSceneChainSampler()
+ assert _install(s, model, retain_first=False)[2] is None
diff --git a/tests/test_cut_opening.py b/tests/test_cut_opening.py
new file mode 100644
index 00000000..6d997bee
--- /dev/null
+++ b/tests/test_cut_opening.py
@@ -0,0 +1,160 @@
+"""cut_opening_frames — "fake t2v": generate with the real untouched i2v anchor, then cut it.
+
+The point of the feature is that the anchor is NOT weakened on the way in (ALG blurs it and
+loses character detail; Best-FaceID overlap tokens approximate it and lose some too) — it is
+pinned at full strength for the whole schedule and the opening is then cut off the FINISHED
+clip. Nothing is regrown to replace it (regrowing was tried and the invented ending came out
+with worse or missing movement), so the invariants worth testing are:
+
+1. The clip really does get SHORTER — no tail is invented, no joint is blended, and every
+ surviving frame is the original untouched one.
+2. Video and audio still describe the same duration. Audio timing comes from the audio
+ stream's own index, so cropping the two by different amounts desyncs the clip silently.
+3. The pin goes with the frame it belonged to.
+4. The cut can never consume the whole clip, and never mutates its input.
+5. The t2v guard: no pinned anchor means there is nothing to cut out.
+"""
+import sys
+import types
+from pathlib import Path
+
+import pytest
+import torch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+for _name in (
+ "comfy", "comfy.k_diffusion", "comfy.k_diffusion.sampling",
+ "comfy.model_sampling", "comfy.nested_tensor", "comfy.sample",
+ "comfy.samplers", "comfy.utils",
+):
+ sys.modules.setdefault(_name, types.ModuleType(_name))
+sys.modules["comfy.nested_tensor"].NestedTensor = object
+
+import samplers # noqa: E402
+
+
+class _FakeNested(list):
+ """Minimal stand-in for comfy's NestedTensor: enough for _is_nested + unbind."""
+ is_nested = True
+
+ def unbind(self):
+ return list(self)
+
+
+@pytest.fixture
+def nested_stub():
+ """Give the module a usable NestedTensor for the packed AV latents.
+
+ Snapshot/restore: these comfy stubs are SHARED module objects across every test module,
+ so leaving mutations behind changes other files' results depending on collection order.
+ """
+ comfy_mod = sys.modules["comfy"]
+ nested_mod = sys.modules["comfy.nested_tensor"]
+ saved = (nested_mod.NestedTensor, getattr(comfy_mod, "nested_tensor", None))
+ nested_mod.NestedTensor = _FakeNested
+ comfy_mod.nested_tensor = nested_mod # attribute access, not just sys.modules
+ yield
+ nested_mod.NestedTensor = saved[0]
+ if saved[1] is None:
+ delattr(comfy_mod, "nested_tensor")
+ else:
+ comfy_mod.nested_tensor = saved[1]
+
+
+def _node():
+ return samplers.FunPackLTXAVSceneChainSampler()
+
+
+def _latent(frames=10):
+ video = torch.arange(frames, dtype=torch.float32).view(1, 1, frames, 1, 1).repeat(1, 4, 1, 2, 2)
+ return {"samples": video, "noise_mask": torch.zeros_like(video)}
+
+
+def _av_latent(video_frames=10, audio_frames=20):
+ """A packed-style AV latent: video [B,C,F,H,W] + audio [B,C,T] at its own rate."""
+ video = torch.arange(video_frames, dtype=torch.float32).view(
+ 1, 1, video_frames, 1, 1).repeat(1, 4, 1, 2, 2)
+ audio = torch.arange(audio_frames, dtype=torch.float32).view(1, 1, audio_frames).repeat(1, 4, 1)
+ return {"samples": _FakeNested([video, audio]), "noise_mask": torch.zeros_like(video)}
+
+
+# ── the cut ─────────────────────────────────────────────────────────────────
+
+def test_cut_shortens_the_clip_instead_of_regrowing_a_tail():
+ cut, dropped = _node()._cut_opening_latent(_latent(frames=10), 3)
+ assert dropped == 3
+ # 10 - 3, not 10: nothing is invented at the end, which is the whole point.
+ assert cut["samples"].shape[2] == 7
+ # And what survives is the ORIGINAL frames 3..9, untouched — no blend, no joint.
+ assert [int(v) for v in cut["samples"][0, 0, :, 0, 0]] == [3, 4, 5, 6, 7, 8, 9]
+
+
+def test_cut_drops_the_pin_with_the_frame_it_belonged_to():
+ latent = _latent(frames=10)
+ assert "noise_mask" in latent
+ cut, _ = _node()._cut_opening_latent(latent, 1)
+ assert "noise_mask" not in cut
+
+
+def test_cut_never_consumes_the_whole_clip():
+ cut, dropped = _node()._cut_opening_latent(_latent(frames=4), 99)
+ assert dropped == 3 # clamped to frames - 1
+ assert cut["samples"].shape[2] == 1
+
+
+def test_zero_drop_is_a_no_op():
+ cut, dropped = _node()._cut_opening_latent(_latent(frames=6), 0)
+ assert dropped == 0
+ assert torch.equal(cut["samples"], _latent(frames=6)["samples"])
+
+
+def test_source_latent_is_never_mutated():
+ latent = _latent(frames=8)
+ before = latent["samples"].clone()
+ _node()._cut_opening_latent(latent, 3)
+ assert torch.equal(latent["samples"], before)
+
+
+# ── audio stays in sync ─────────────────────────────────────────────────────
+
+def test_cut_keeps_audio_and_video_describing_the_same_duration(nested_stub):
+ """Audio timing comes from the audio stream's own index and video's from its own RoPE,
+ so cropping them by different amounts of TIME desyncs the clip silently — no error,
+ just drifting sound. The kept lengths must stay in proportion."""
+ cut, _ = _node()._cut_opening_latent(_av_latent(10, 20), 3)
+ video, audio = cut["samples"].unbind()
+ assert video.shape[2] == 7 and audio.shape[2] == 14 # 7/10 == 14/20
+ # Cropped from the HEAD, matching the video — the tail is what survives.
+ assert [int(v) for v in audio[0, 0]] == list(range(6, 20))
+
+
+def test_cut_keeps_proportion_when_the_rate_does_not_divide_evenly(nested_stub):
+ """The rounding trap: deriving audio's KEPT length from the new video length keeps the
+ two in proportion; subtracting a separately-rounded drop can leave them a frame apart."""
+ for v_frames, a_frames, drop in ((10, 15, 3), (9, 14, 4), (13, 7, 5), (10, 21, 7)):
+ cut, _ = _node()._cut_opening_latent(_av_latent(v_frames, a_frames), drop)
+ video, audio = cut["samples"].unbind()
+ assert audio.shape[2] == max(1, round(a_frames * video.shape[2] / v_frames))
+ assert audio.shape[2] >= 1 and video.shape[2] >= 1
+
+
+# ── the t2v guard ───────────────────────────────────────────────────────────
+
+def test_pinned_frames_counts_the_anchor_prefix():
+ n = _node()
+ frames = 6
+ video = torch.zeros(1, 4, frames, 2, 2)
+ mask = torch.ones(1, 4, frames, 2, 2)
+ mask[:, :, :2] = 0.0 # two pinned anchor frames
+ assert n._anchor_pinned_frames({"samples": video, "noise_mask": mask}) == 2
+
+
+def test_pinned_frames_is_zero_for_a_real_t2v_scene():
+ """No anchor image attached — Easy Gen's default. Cutting would throw away real content,
+ so the caller must skip: this is the 'fake t2v' trick and it needs an anchor to fake with."""
+ n = _node()
+ video = torch.zeros(1, 4, 6, 2, 2)
+ assert n._anchor_pinned_frames({"samples": video, "noise_mask": torch.ones(1, 4, 6, 2, 2)}) == 0
+ assert n._anchor_pinned_frames({"samples": video}) == 0 # no mask at all
+ assert n._anchor_pinned_frames({"samples": None}) == 0 # unreadable -> refuse, don't raise
diff --git a/tests/test_identity_transfer.py b/tests/test_identity_transfer.py
index bbdd1d84..b67f5a87 100644
--- a/tests/test_identity_transfer.py
+++ b/tests/test_identity_transfer.py
@@ -76,6 +76,64 @@ def test_rotate_overlap_freqs_rotates_only_last_ref_len_tokens():
assert split is False
+def _rope_matrix(angles, heads):
+ """Build ComfyUI's post-7c59a07 layout from per-(B,T,H,D) angles: (B,T,H,D,2,2)
+ holding [[cos, -sin], [sin, cos]] — mirrors freqs_cis_matrix's own stack/reshape."""
+ c, s = angles.cos(), angles.sin()
+ return torch.stack((c, -s, s, c), dim=-1).reshape(*angles.shape, 2, 2)
+
+
+def test_rotate_overlap_freqs_matrix_layout_rotates_only_last_ref_len_tokens():
+ """ComfyUI 7c59a07 ("Use comfy kitchen rope functions in ltx models") replaced the
+ (cos, sin, split_mode) payload with (rotation_matrix, split_mode). The old code read
+ pe[1] as the sin tensor — it's a bool now — so the source-phase tag was silently
+ skipped and the reference tokens read as literal frame 0."""
+ B, T, H, D = 2, 10, 3, 4
+ ref_len = 3
+ angles = torch.linspace(0.0, 3.0, B * T * H * D).reshape(B, T, H, D)
+ mat = _rope_matrix(angles, H)
+ out_mat, split = idt.rotate_overlap_freqs((mat, True), ref_len, 2.0)
+ assert split is True
+ assert out_mat.shape == mat.shape
+ # Tokens before the reference block are untouched...
+ assert torch.equal(out_mat[:, : T - ref_len], mat[:, : T - ref_len])
+ # ...and the reference block actually moved.
+ assert not torch.equal(out_mat[:, T - ref_len :], mat[:, T - ref_len :])
+ # The 2x2 structure survives: still [[c, -s], [s, c]] with unit determinant.
+ c, s = out_mat[..., 0, 0], out_mat[..., 1, 0]
+ assert torch.allclose(out_mat[..., 0, 1], -s, atol=1e-6)
+ assert torch.allclose(out_mat[..., 1, 1], c, atol=1e-6)
+ assert torch.allclose(c**2 + s**2, torch.ones_like(c), atol=1e-5)
+
+
+def test_rotate_overlap_freqs_matrix_matches_legacy_cos_sin_rotation():
+ """Both layouts must apply the SAME phase, so upgrading ComfyUI doesn't change what
+ Best-FaceID conditioning the model sees."""
+ B, T, H, D = 1, 6, 2, 4
+ ref_len, seg = 2, 2.0
+ angles = torch.linspace(0.0, 2.0, B * T * H * D).reshape(B, T, H, D)
+ # Legacy split layout is (B, H, T, D): tokens on axis -2, frequencies on -1.
+ legacy = angles.transpose(1, 2)
+ l_cos, l_sin, _ = idt.rotate_overlap_freqs((legacy.cos(), legacy.sin(), True), ref_len, seg)
+ m_out, _ = idt.rotate_overlap_freqs((_rope_matrix(angles, H), True), ref_len, seg)
+ m_cos = m_out[..., 0, 0].transpose(1, 2)
+ m_sin = m_out[..., 1, 0].transpose(1, 2)
+ assert torch.allclose(m_cos, l_cos, atol=1e-6)
+ assert torch.allclose(m_sin, l_sin, atol=1e-6)
+
+
+def test_rotate_overlap_freqs_raises_on_unknown_payload():
+ """A future layout change must fail loudly, not skip the tag and quietly turn the
+ reference tokens into frame 0."""
+ import pytest
+ with pytest.raises(TypeError):
+ idt.rotate_overlap_freqs(("not-a-tensor", True), 2, 2.0)
+ with pytest.raises(TypeError):
+ idt.rotate_overlap_freqs((torch.zeros(2, 3, 4), True), 2, 2.0) # no trailing (2, 2)
+ with pytest.raises(ValueError):
+ idt.rotate_overlap_freqs((_rope_matrix(torch.zeros(1, 2, 1, 4), 1), True), 5, 2.0)
+
+
# ── identity_transfer.append_context_tokens ────────────────────────────────────
def test_append_context_tokens_pads_and_extends_mask():
@@ -269,6 +327,25 @@ def test_prepare_pe_rotates_only_the_ref_token_tail():
s._strip_identity_overlap(handle)
+def test_prepare_pe_rotates_matrix_layout_end_to_end():
+ """Regression guard for ComfyUI 7c59a07: with the rotation-matrix payload the wrapper
+ must still tag the ref tokens. It used to raise inside rotate_overlap_freqs and get
+ swallowed by prepare_pe's except, leaving the reference readable as frame 0."""
+ s = _sampler()
+ ltxv = _FakeLTXV()
+ B, T, H, D = 1, 10, 2, 4
+ angles = torch.linspace(0.0, 3.0, B * T * H * D).reshape(B, T, H, D)
+ matrix = _rope_matrix(angles, H)
+ ltxv._prepare_positional_embeddings = lambda pixel_coords, frame_rate, x_dtype: (matrix, True)
+ handle = s._install_identity_overlap(_fake_model(ltxv), _ref_latent(), 2.0)
+ ltxv._process_input(torch.zeros(1, 5, 8), None, None) # ref_len=2
+ out_mat, split_mode = ltxv._prepare_positional_embeddings(None, 25, torch.float32)
+ assert split_mode is True
+ assert torch.equal(out_mat[:, : T - 2], matrix[:, : T - 2]) # target tokens untouched
+ assert not torch.equal(out_mat[:, T - 2 :], matrix[:, T - 2 :]) # ref tokens tagged
+ s._strip_identity_overlap(handle)
+
+
def test_prepare_pe_noop_when_ref_len_zero():
s = _sampler()
ltxv = _FakeLTXV()
diff --git a/tests/test_run_phase.py b/tests/test_run_phase.py
new file mode 100644
index 00000000..f62c5224
--- /dev/null
+++ b/tests/test_run_phase.py
@@ -0,0 +1,64 @@
+"""run_phase — the live "what is the sampler doing" label behind the progress readout.
+
+The one invariant that actually breaks in production: the WRITER (samplers.py) reaches this
+module as a package-relative import and the READER (movie_editor's bridge) imports FunPack
+modules top-level by name, so the two can hold different module objects. State in module
+globals would be two separate copies and the label would never arrive. It lives on `sys`
+for exactly that reason, and this pins it down.
+"""
+import importlib.util
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+import run_phase # noqa: E402
+
+
+def _second_import():
+ """A second, independent module object for the same file — what the other import path
+ produces at runtime."""
+ spec = importlib.util.spec_from_file_location("other_pkg.run_phase", ROOT / "run_phase.py")
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+def test_both_import_paths_see_the_same_label():
+ other = _second_import()
+ run_phase.set_phase("scene 2/3 · pass 2 of 2")
+ assert other.current()["label"] == "scene 2/3 · pass 2 of 2"
+ other.clear()
+ assert run_phase.current()["label"] == ""
+
+
+def test_seq_only_ever_increases():
+ """A poller uses it to tell "still the same phase" from "this phase came round again",
+ so it must never reset — not even when the label is cleared."""
+ before = run_phase.current()["seq"]
+ run_phase.set_phase("a")
+ run_phase.set_phase("a")
+ run_phase.clear()
+ assert run_phase.current()["seq"] == before + 3
+
+
+def test_a_bad_label_never_raises():
+ """This is a readout on the sampling path: it must not be able to fail a render."""
+ run_phase.set_phase(None)
+ assert run_phase.current()["label"] == ""
+ run_phase.set_phase(12)
+ assert run_phase.current()["label"] == "12"
+ run_phase.clear()
+
+
+def test_the_bridge_reads_the_same_sys_key_without_importing_this_module():
+ """movie_editor's progress poll runs every 700ms on ComfyUI's event loop WHILE the
+ worker thread samples, so it reads the label straight off `sys` rather than importing
+ anything. That makes the key name a contract between two files — pin it."""
+ src = (ROOT / "movie_editor" / "backend" / "bridge.py").read_text()
+ assert run_phase._SYS_KEY == "_funpack_run_phase"
+ assert f'getattr(sys, "{run_phase._SYS_KEY}"' in src
+ # ...and the poll must not have grown an import back.
+ poll = src.split("def current_progress(")[1].split("\ndef ")[0]
+ assert "import_module" not in poll and "_funpack_attr" not in poll
diff --git a/tests/test_scene_chain_sampler.py b/tests/test_scene_chain_sampler.py
index 3d199e89..45e23a4a 100644
--- a/tests/test_scene_chain_sampler.py
+++ b/tests/test_scene_chain_sampler.py
@@ -2,6 +2,7 @@ import sys
import types
from pathlib import Path
+import pytest
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -67,6 +68,10 @@ def fake_sample_custom(model, noise, cfg, sampler, sigmas, positive, negative, l
"positive": positive,
"negative": negative,
"cfg": cfg,
+ "steps": max(0, int(sigmas.numel()) - 1),
+ # What the sampler is telling the UI it's doing, AS this call runs — the point of
+ # the label is that it is live, so reading it afterwards would prove nothing.
+ "phase": run_phase.current()["label"],
"latent_image": _sample_snapshot(latent_image),
"noise_mask": _sample_snapshot(noise_mask),
})
@@ -90,6 +95,21 @@ comfy_sample_mod = types.ModuleType("comfy.sample")
comfy_samplers_mod = types.ModuleType("comfy.samplers")
comfy_utils_mod = types.ModuleType("comfy.utils")
+
+class FakeProgressBar:
+ """Records what the sampler declared as the run's total step count."""
+ last = None
+
+ def __init__(self, total):
+ self.total = int(total)
+ self.value = 0
+ FakeProgressBar.last = self
+
+ def update_absolute(self, value, total=None, preview=None):
+ self.value = int(value)
+
+
+comfy_utils_mod.ProgressBar = FakeProgressBar
comfy_nested_mod.NestedTensor = FakeNestedTensor
comfy_sample_mod.prepare_noise = fake_prepare_noise
comfy_sample_mod.sample_custom = fake_sample_custom
@@ -111,7 +131,8 @@ sys.modules.setdefault("comfy.sample", comfy_sample_mod)
sys.modules.setdefault("comfy.samplers", comfy_samplers_mod)
sys.modules.setdefault("comfy.utils", comfy_utils_mod)
-from samplers import FunPackLTXAVSceneChainSampler
+import run_phase # noqa: E402
+from samplers import FunPackLTXAVSceneChainSampler # noqa: E402
class FakeVAE:
@@ -738,3 +759,95 @@ def test_plateau_cache_keys_cond_and_uncond_separately():
assert stats["computed"] == 2 # cond + uncond each computed once
assert stats["reused"] == 2 # then each reused once
assert calls["n"] == 2
+
+
+# ── second pass: the progress bar and the phase readout ─────────────────────
+
+@pytest.fixture
+def live_stubs():
+ """Force this file's comfy fakes onto whatever module objects are actually live.
+
+ The module-level `sys.modules.setdefault` above only wins when this file is imported
+ FIRST — under the full suite another test module has usually registered bare comfy
+ stubs already, so the fakes here are silently discarded and every sample()-calling test
+ in this file fails on the missing attribute. Snapshot/restore rather than leaving them
+ behind, so the next module's expectations are not the ones broken instead."""
+ targets = [
+ (sys.modules["comfy.sample"], "prepare_noise", fake_prepare_noise),
+ (sys.modules["comfy.sample"], "sample_custom", fake_sample_custom),
+ (sys.modules["comfy.utils"], "ProgressBar", FakeProgressBar),
+ (sys.modules["comfy.nested_tensor"], "NestedTensor", FakeNestedTensor),
+ (sys.modules["comfy"], "sample", sys.modules["comfy.sample"]),
+ (sys.modules["comfy"], "utils", sys.modules["comfy.utils"]),
+ (sys.modules["comfy"], "nested_tensor", sys.modules["comfy.nested_tensor"]),
+ ]
+ saved = [(mod, name, getattr(mod, name, _MISSING)) for mod, name, _ in targets]
+ for mod, name, value in targets:
+ setattr(mod, name, value)
+ yield
+ for mod, name, value in saved:
+ if value is _MISSING:
+ delattr(mod, name)
+ else:
+ setattr(mod, name, value)
+
+
+_MISSING = object()
+
+
+def _second_pass_run(scene_count=2, main=torch.tensor([1.0, 0.5, 0.0]),
+ second=torch.tensor([0.4, 0.2, 0.0])):
+ sample_calls.clear()
+ run_phase.clear()
+ node = FunPackLTXAVSceneChainSampler()
+ return node.sample(
+ model=FakeModel(),
+ vae=FakeVAE(),
+ positive=[scene_cond(i) for i in range(scene_count)],
+ negative=[(torch.zeros(1, 2, 3), {})],
+ sampler=object(),
+ sigmas=main,
+ seed=10,
+ latent_template={"samples": torch.zeros(1, 2, 5, 3, 3)},
+ num_frames_per_scene=5,
+ frame_overlap=0,
+ cfg=1.5,
+ max_scenes=8,
+ second_pass=True,
+ second_pass_sigmas=second,
+ )
+
+
+def test_second_pass_progress_total_counts_both_passes(live_stubs):
+ """A second pass samples every scene twice. If its steps aren't in the total, the bar
+ overflows during scene 1 and then jumps BACKWARDS at scene 2, whose offset is a
+ multiple of the (too small) per-scene stride."""
+ _second_pass_run(scene_count=2)
+ # 2 main steps + 2 second-pass steps, per scene, over 2 scenes.
+ assert [c["steps"] for c in sample_calls] == [2, 2, 2, 2]
+ assert FakeProgressBar.last.total == 2 * (2 + 2)
+
+
+def test_progress_total_ignores_an_unusable_second_pass_schedule(live_stubs):
+ """The loop skips a malformed schedule with a note, so the total must not reserve steps
+ for a pass that never runs — the bar would stall short of the end."""
+ _second_pass_run(scene_count=2, second=torch.tensor([0.4, 0.2])) # doesn't reach 0
+ assert [c["steps"] for c in sample_calls] == [2, 2]
+ assert FakeProgressBar.last.total == 2 * 2
+
+
+def test_second_pass_says_which_pass_is_running(live_stubs):
+ """The run report names the second pass only after the fact, which is no help while
+ you're waiting on one. The live label has to say it AS it happens."""
+ _second_pass_run(scene_count=2)
+ assert [c["phase"] for c in sample_calls] == [
+ "scene 1/2 · pass 1 of 2", "scene 1/2 · pass 2 of 2",
+ "scene 2/2 · pass 1 of 2", "scene 2/2 · pass 2 of 2",
+ ]
+ # ...and nothing is sampling once the node returns.
+ assert run_phase.current()["label"] == ""
+
+
+def test_phase_label_drops_the_scene_number_on_a_single_scene_run(live_stubs):
+ _second_pass_run(scene_count=1)
+ assert [c["phase"] for c in sample_calls] == ["pass 1 of 2", "pass 2 of 2"]
diff --git a/tests/test_second_pass.py b/tests/test_second_pass.py
new file mode 100644
index 00000000..d5f143db
--- /dev/null
+++ b/tests/test_second_pass.py
@@ -0,0 +1,195 @@
+"""second_pass — a general two-pass split on the Chain Sampler.
+
+A scene can be finished differently from how it was started. The invariants that matter
+here are the ones a split can silently get wrong:
+
+1. BOTH schedules run in full, exactly as written. Pass 1 is not cut short and pass 2 is
+ not derived from it — total steps are simply the two added up. (An earlier version cut
+ pass 1 at pass 2's first sigma; it silently shortened the run and distorted the result.)
+2. A hand-typed schedule is the one malformed-able input, and both ways it can be wrong
+ are silent in the OUTPUT rather than loud at runtime, so they are refused up front.
+3. Nothing is ever a silent no-op: every refusal comes back with a reason naming the fix.
+4. The i2v anchor stays PINNED for pass 2 — without this pass 2 re-denoises the reference
+ frame and the scene drifts off the image it was supposed to start from.
+"""
+import sys
+import types
+from pathlib import Path
+
+import torch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+for _name in (
+ "comfy", "comfy.k_diffusion", "comfy.k_diffusion.sampling",
+ "comfy.model_sampling", "comfy.nested_tensor", "comfy.sample",
+ "comfy.samplers", "comfy.utils", "comfy.model_management",
+):
+ sys.modules.setdefault(_name, types.ModuleType(_name))
+sys.modules["comfy.nested_tensor"].NestedTensor = object
+# _run_upsampler (detailing.py) moves tensors to the torch device and reads the VAE's
+# per-channel statistics; on CPU with vae=None both are no-ops, which is what we want here.
+sys.modules["comfy.model_management"].get_torch_device = lambda: torch.device("cpu")
+sys.modules["comfy"].model_management = sys.modules["comfy.model_management"]
+
+import samplers # noqa: E402
+
+
+# The user's own 9-step schedule.
+SCHEDULE = torch.tensor([1.0, 0.955, 0.893, 0.812, 0.715, 0.603, 0.482, 0.241, 0.121, 0.0])
+
+
+def _node():
+ return samplers.FunPackLTXAVSceneChainSampler()
+
+
+# ── the pass-2 schedule ─────────────────────────────────────────────────────
+
+def _sched(alt):
+ return _node()._second_pass_schedule(alt)
+
+
+def test_a_well_formed_schedule_is_taken_exactly_as_written():
+ """Nothing is derived, cut or continued. Pass 1 runs the main schedule in full and pass 2
+ runs this one in full, so on the user's 9-step main a 4-step second pass is 13 steps —
+ not a re-slicing of the main schedule."""
+ alt = torch.tensor([0.812, 0.6, 0.35, 0.15, 0.0])
+ out, reason = _sched(alt)
+ assert reason is None and out is alt
+ assert (SCHEDULE.numel() - 1) + (out.numel() - 1) == 13
+
+
+def test_an_ascending_schedule_is_refused():
+ """Both malformed cases are silent in the OUTPUT rather than loud at runtime, so they
+ have to be caught before sampling. An ascending pair walks the trajectory backwards."""
+ out, reason = _sched(torch.tensor([0.812, 0.4, 0.55, 0.0]))
+ assert out is None and "must descend" in reason
+
+
+def test_a_schedule_that_stops_short_of_zero_is_refused():
+ """Stopping above 0 leaves the clip partially denoised — the noise-artefact symptom."""
+ out, reason = _sched(torch.tensor([0.812, 0.5, 0.25]))
+ assert out is None and "not 0" in reason
+
+
+def test_equal_neighbouring_sigmas_are_not_treated_as_ascending():
+ out, reason = _sched(torch.tensor([0.812, 0.6, 0.6, 0.0]))
+ assert reason is None and out is not None
+
+
+def test_a_missing_or_degenerate_schedule_is_refused_with_a_reason():
+ """second_pass with nothing to run must say so, not silently sample once."""
+ for alt in (None, torch.tensor([]), torch.tensor([0.5]), "0.8, 0.0"):
+ out, reason = _sched(alt)
+ assert out is None and reason
+
+
+def test_a_schedule_starting_at_zero_is_refused():
+ out, reason = _sched(torch.tensor([0.0, 0.0]))
+ assert out is None and "nothing for it to denoise" in reason
+
+
+# ── the pin survives the split ──────────────────────────────────────────────
+
+def test_the_i2v_anchor_is_re_pinned_for_pass_two():
+ """_sample_chunk drops noise_mask from what it returns, so without this pass 2 runs
+ unpinned and re-denoises the reference frame."""
+ frames = 6
+ clean = torch.arange(frames, dtype=torch.float32).view(1, 1, frames, 1, 1).repeat(1, 4, 1, 2, 2)
+ mask = torch.ones_like(clean)
+ mask[:, :, :1] = 0.0 # one pinned anchor frame
+ chunk = {"samples": clean, "noise_mask": mask}
+ # A "state" where the anchor frame has been polluted (as pass 2's re-noise would).
+ state = {"samples": torch.full_like(clean, -99.0)}
+
+ out = _node()._restore_pinned_prefix(state, chunk)
+ assert out.get("noise_mask") is not None
+ # The pinned frame is back to the clean anchor...
+ assert torch.equal(out["samples"][:, :, :1], clean[:, :, :1])
+ # ...and nothing else was touched.
+ assert torch.equal(out["samples"][:, :, 1:], state["samples"][:, :, 1:])
+
+
+def test_restore_is_a_no_op_on_a_t2v_scene():
+ """No pinned prefix = no anchor to protect; the state must come back untouched."""
+ clean = torch.zeros(1, 4, 6, 2, 2)
+ state = {"samples": torch.full_like(clean, 3.0)}
+ for chunk in ({"samples": clean, "noise_mask": torch.ones_like(clean)},
+ {"samples": clean}):
+ assert _node()._restore_pinned_prefix(state, chunk) is state
+
+
+# ── the optional between-pass operations ────────────────────────────────────
+
+class _FakeUpsampler:
+ """Stands in for Lightricks' LatentUpsampler: 2x nearest on the spatial axes."""
+ def __call__(self, x):
+ return x.repeat_interleave(2, dim=-1).repeat_interleave(2, dim=-2)
+
+ def parameters(self):
+ yield torch.zeros(1)
+
+ def to(self, *a, **k):
+ return self
+
+ def cpu(self):
+ return self
+
+
+def _video(f=4, h=8, w=8, fill=2.0):
+ return {"samples": torch.full((1, 4, f, h, w), fill)}
+
+
+def test_no_operation_is_the_default_and_returns_the_same_object():
+ """The whole feature is opt-in: 'none' must not clone, resample or touch anything."""
+ lat = _video()
+ for op in ("none", None, ""):
+ out, note = _node()._second_pass_operate(lat, op, _FakeUpsampler(), None)
+ assert out is lat and note is None
+
+
+def test_sharpen_comes_back_at_the_original_size():
+ """sharpen is upsampler -> downscale: the latent pass 2 receives must be the SAME shape,
+ or it is silently an upscale and costs four times as much."""
+ out, note = _node()._second_pass_operate(_video(h=8, w=8), "sharpen", _FakeUpsampler(), None)
+ assert out["samples"].shape == (1, 4, 4, 8, 8)
+ assert "sharpen" in note
+
+
+def test_upscale_2x_keeps_the_larger_latent():
+ out, note = _node()._second_pass_operate(_video(h=8, w=8), "upscale_2x", _FakeUpsampler(), None)
+ assert out["samples"].shape == (1, 4, 4, 16, 16)
+ assert "4x the pixels" in note
+
+
+def test_a_missing_upsampler_degrades_instead_of_failing_the_render():
+ """A whole montage must not die because one optional sharpen could not load its model."""
+ lat = _video()
+ out, note = _node()._second_pass_operate(lat, "sharpen", None, None)
+ assert out is lat and "no latent upsampler" in note
+
+
+def test_an_upsampler_that_throws_degrades_too():
+ class _Boom(_FakeUpsampler):
+ def __call__(self, x):
+ raise RuntimeError("out of memory")
+ lat = _video()
+ out, note = _node()._second_pass_operate(lat, "sharpen", _Boom(), None)
+ assert out is lat and "upsampler failed" in note
+
+
+def test_an_unknown_operation_is_reported_not_guessed():
+ lat = _video()
+ out, note = _node()._second_pass_operate(lat, "enhance", _FakeUpsampler(), None)
+ assert out is lat and "unknown operation" in note
+
+
+def test_the_pin_is_dropped_when_the_operation_changed_the_resolution():
+ """upscale_2x leaves the chunk's anchor and mask the wrong size. Rescaling them would be
+ inventing an anchor, so the pin is dropped — and the caller reports it."""
+ clean = torch.zeros(1, 4, 4, 8, 8)
+ mask = torch.ones_like(clean)
+ mask[:, :, :1] = 0.0
+ chunk = {"samples": clean, "noise_mask": mask}
+ upscaled = {"samples": torch.zeros(1, 4, 4, 16, 16)}
+ assert _node()._restore_pinned_prefix(upscaled, chunk) is upscaled