From 5a98b2ed355c299243226a581546bea69462da3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:54:51 +0000 Subject: [PATCH] Picker wheel: Quick A/B (every enhancement off, last run's seed, generates the picked scene right away, then restores) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SZzdHoWjZ83CzN8GQwBeUU --- app/ui/features/generate/hub.test.js | 25 +++++++++++++++++++++++++ app/ui/features/generate/index.js | 21 +++++++++++++++++++-- app/ui/features/wheel/hub.test.js | 19 +++++++++++++++++++ app/ui/features/wheel/index.js | 3 +++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 43f1d5c2..59f4f40c 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -210,3 +210,28 @@ test("with New seed every Generate off, the seed node's own number runs, and the await app.runner.units(["a"], 1); assert.notEqual(sent[1].s.seed, undefined, "the default draws a fresh one"); }); + +test("Quick A/B runs the picked scene with every enhancement off and the last run's seed, then puts the enhancements back", async () => { + const sent = []; + let off = false; + const { app, doc } = rig("idle", { generate: async ({ inputs }) => { sent.push({ inputs, off }); return true; } }); + app.pipeline.allOff = () => off; + app.pipeline.setAllOff = async (on) => { off = on; }; + app.selection.ids = ["a"]; + await app.runner.quickAB(); + assert.equal(sent.length, 0, "no earlier run, nothing to compare with"); + assert.match(document.body.textContent, /generate once first/); + app.lastRun.seed = 1234; + doc.editor_settings = {}; // the default: a fresh seed every Generate, which Quick A/B must override + await app.runner.quickAB(); + assert.equal(sent.length, 1); + assert.equal(sent[0].off, true, "enhancements were off while the run was queued"); + assert.equal(sent[0].inputs.s.seed, 1234); + assert.equal(off, false, "and are on again afterwards"); + assert.equal(app.lastRun.seed, 1234); + await app.runner.units(["a"], 1); + assert.notEqual(sent[1].inputs.s.seed, 1234, "the next ordinary Generate is fresh again"); + off = true; // already plain: stays plain + await app.runner.quickAB(); + assert.equal(off, true); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index bdb06122..2aa1839d 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -51,7 +51,8 @@ export default { }).catch(() => tell("The result could not be saved to its project.")); }; // `times` > 1: the same shots again, only the seed differing (the prompt's random picks are drawn once), so the takes can be compared. - async function runUnits(units, times = 1) { + // `once`: for a single run only - {seed} uses that seed on every seed input; onQueued runs once ComfyUI has taken (or refused) the run. + async function runUnits(units, times = 1, once = {}) { if (busy) { tell("A run is already going: wait for it, or Stop it."); return; } busy = true; stopped = false; draw(); const pid = p.project.id; @@ -97,7 +98,7 @@ export default { if (group.every((s) => s.excluded) || !isGenerative(root)) continue; // all left out, or not made by the model // The unit is made once at the length of its clips together (each cut adds one shared frame). const frames = group.reduce((t, s) => t + effFrames(s, snap), 0) - (group.length - 1); - const { inputs, unwired, left, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, nodeInfo: app.api.wireTargets, frames, hooks: app.inputHooks, ...(fixedSeed ? { seed: null } : {}), prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root, snap); } catch { return []; } }) }); + const { inputs, unwired, left, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, nodeInfo: app.api.wireTargets, frames, hooks: app.inputHooks, ...(Number.isInteger(once.seed) ? { seed: () => once.seed } : fixedSeed ? { seed: null } : {}), prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root, snap); } catch { return []; } }) }); app.lastRun.typed = (root.text || "").trim(); // what a Chat comment made now would be about const seeded = rolesAt(frozen, "generation.seed")[0]; if (seeded) app.lastRun.seed = (inputs[seeded.slot.id] || {})[seeded.role.input] ?? (seeded.slot.inputs || {})[seeded.role.input]; // shown in ⏱ Sampler, to keep a liked one @@ -113,6 +114,7 @@ export default { current = snap.scenes.findIndex((s) => s.id === root.id) + 1; const queued = await g.generate({ sceneId: root.id, projectId: pid, inputs, slots: frozen }); current = 0; + if (once.onQueued) await once.onQueued(); if (!queued) { done.cancel(); const n = snap.scenes.findIndex((s) => s.id === root.id) + 1; // which clip: in a run of several, the toast alone does not say @@ -139,6 +141,21 @@ export default { } finally { busy = false; draw(); } } app.runner.units = (units, times) => runUnits(units, times); + /** Quick A/B: the picked scene(s) once more with every enhancement off and the last run's seed, so the two clips differ only by the enhancements. Nothing stays changed afterwards. */ + app.runner.quickAB = async () => { + const seed = app.lastRun && app.lastRun.seed; + if (busy) { tell("A run is already going: wait for it, or Stop it."); return; } + if (!p.project || !Number.isInteger(seed)) { tell("Quick A/B needs a run to compare with: generate once first."); return; } + const units = unitsOf(pickedScenes()); + if (!units.length) return; + const was = app.pipeline.allOff(); + let restored = was; + const restore = async () => { if (!restored) { restored = true; await app.pipeline.setAllOff(false); } }; // as soon as ComfyUI has the run: later edits are the person's again + try { + if (!was) { restored = false; await app.pipeline.setAllOff(true); } + await runUnits(units, 1, { seed, onQueued: restore }); + } finally { await restore().catch(() => tell("Enhancements could not be switched back on: use Enable enhancements on the wheel.")); } + }; /** One run for the Training window: the pipeline as it is, its LoRA loader pointed at `lora`, the picked scene (else the first one) and the project's own settings. * Nothing is put on the timeline; resolves to {image} or {error}. */ app.runner.preview = async ({ lora, strength = 1 }) => { diff --git a/app/ui/features/wheel/hub.test.js b/app/ui/features/wheel/hub.test.js index d63eab8f..b81ef4cd 100644 --- a/app/ui/features/wheel/hub.test.js +++ b/app/ui/features/wheel/hub.test.js @@ -81,3 +81,22 @@ test("the wheel offers Clear references and the guidance switch, both opt-in, an assert.ok(!find("clear-refs").when(), "nothing to clear without references"); } finally { off(); } }); + +test("Quick A/B is an opt-in wheel action, offered only once there is a seed to reuse, and starts the run", async () => { + server(); + localStorage.clear(); + const project = createProject({}); + await project.start(); + let ran = 0; + const app = { dom: createDom(), project, pipeline: { allOff: () => false, setAllOff: async () => {} }, actions: [], editorSettings: [], lastRun: { n: 0 }, runner: { quickAB: async () => { ran += 1; } } }; + const off = hub.setup({ host: document.createElement("div"), app }); + try { + const a = app.actions.find((x) => x.id === "quick-ab"); + assert.ok(a && a.optIn && a.label === "Quick A/B"); + assert.ok(!a.when(), "hidden until a run has made a seed"); + app.lastRun.seed = 7; + assert.ok(a.when()); + await a.run(); + assert.equal(ran, 1); + } finally { off(); } +}); diff --git a/app/ui/features/wheel/index.js b/app/ui/features/wheel/index.js index b1dc248c..3b92a59b 100644 --- a/app/ui/features/wheel/index.js +++ b/app/ui/features/wheel/index.js @@ -39,6 +39,9 @@ export default { offer(app, { id: "camera-off", optIn: true, icon: "◎", get label() { return pipe().cameraOff?.() ? "Enable camera move enhancements" : "Disable camera move enhancements"; }, when: () => Boolean(pipe() && pipe().cameraOff && pipe().setCameraOff && pipe().modulesById?.().conditioning_shot_camera), run: async () => { const on = !pipe().cameraOff(); await pipe().setCameraOff(on); say(on ? "Camera move enhancements disabled" : "Camera move enhancements enabled again"); } }), + offer(app, { id: "quick-ab", optIn: true, label: "Quick A/B", icon: "⇄", + when: () => Boolean(app.runner && app.runner.quickAB && pipe() && pipe().setAllOff && Number.isInteger(app.lastRun && app.lastRun.seed)), + run: () => app.runner.quickAB() }), offer(app, { id: "clear-refs", optIn: true, label: "Clear references", icon: "⌧", when: () => Boolean(p() && p().selected && (p().selected.references || []).length), run: async () => { await p().setScene(p().selected.id, "references", []); say("References cleared for this scene"); } }),