From dcc6cba02f229039b5f033a2bff5ec834080ee46 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:01:41 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20Project=20tab=20follows=20Length/FPS=20c?= =?UTF-8?q?hanges;=20seed=20can=20be=20kept=20(Sampler=20=E2=96=B8=20New?= =?UTF-8?q?=20seed=20every=20Generate),=20last=20seed=20shown=20with=20Kee?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/ui/features/generate/hub.test.js | 15 +++++++- app/ui/features/generate/index.js | 5 ++- app/ui/features/generate/inputs.js | 4 +- app/ui/features/generate/inputs.test.js | 6 +++ app/ui/features/inspector/index.js | 2 +- app/ui/features/sampler/index.js | 51 ++++++++++++++++--------- 6 files changed, 59 insertions(+), 24 deletions(-) diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 3b141396..6787411f 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -13,7 +13,7 @@ const rig = (phase, extra = {}) => { state() { return this.run.state; }, on: (k, fn) => { handlers[k] = fn; }, subscribe: () => () => {}, cancel() {}, waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra }; const project = { project: doc, get scenes() { return doc.scenes; }, selected: null, - edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc) }; + edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc), pref: (k, d) => ((doc.editor_settings || {})[k] ?? d) }; const app = { project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: {} }] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} }; const host = document.createElement("div"); hub.setup({ host, app }); @@ -196,3 +196,16 @@ test("a picture no longer in the media bin is dropped from the scene before the assert.equal(doc.scenes[0].source_image, ""); assert.match(document.body.textContent, /no longer in the media bin/); }); + +test("with New seed every Generate off, the seed node's own number runs, and the seed used is kept for the Sampler window", async () => { + const sent = []; + const { app, doc } = rig("idle", { generate: async ({ inputs }) => { sent.push(inputs); return true; } }); + app.pipeline.slots = () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: { seed: 42 } }]; + doc.editor_settings = { random_seed: false }; + await app.runner.units(["a"], 1); + assert.equal((sent[0].s || {}).seed, undefined, "nothing overrides the node's 42"); + assert.equal(app.lastRun.seed, 42); + doc.editor_settings = {}; + await app.runner.units(["a"], 1); + assert.notEqual(sent[1].s.seed, undefined, "the default draws a fresh one"); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 8144a64b..5a5cf4cc 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -80,6 +80,7 @@ export default { return hit || had; }, false); if (prune(snap)) { tell("Some pictures this project used are no longer in the media bin; they were removed from their scenes."); p.edit(prune); } + const fixedSeed = times === 1 && p.pref("random_seed", true) === false; // takes are compared across seeds: always fresh ones const drawn = new Map(); const same = times > 1 ? (body) => { const key = JSON.stringify(body); if (!drawn.has(key)) drawn.set(key, expand(body)); return drawn.get(key); } : expand; for (const unit of Array.from({ length: times }, () => units).flat()) { @@ -91,8 +92,10 @@ 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, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, frames, hooks: app.inputHooks, prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root); } catch { return []; } }) }); + const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, frames, hooks: app.inputHooks, ...(fixedSeed ? { seed: null } : {}), prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root); } 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 if (stopped) break; if (noPrompt && !made) tell("This pipeline has no prompt input, so the scene text is not sent."); notes.forEach(tell); diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index 1df5af32..ca5d9575 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -5,7 +5,7 @@ import { DRIVEN } from "../../shell/scenes.js"; export const rolesAt = (slots, at) => (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.at === at).map((role) => ({ slot: s, role }))); -/** `frames`: how long this unit is (its clips together); a fresh `seed` per call so a re-roll is a different result. */ +/** `frames`: how long this unit is (its clips together); a fresh `seed` per call so a re-roll is a different result (`seed: null` keeps the node's own). */ export async function buildInputs({ project, scene, slots, expand, frames, hooks = [], prefix = [], seed = () => Math.floor(Math.random() * 2 ** 31) }) { const raw = {}; const put = (slot, name, value) => { raw[slot.id] = { ...raw[slot.id], [name]: value }; }; @@ -14,7 +14,7 @@ export async function buildInputs({ project, scene, slots, expand, frames, hooks const v = role.at === "project.negative" ? project.negative : DRIVEN[role.drives] ? (role.drives === "frames" && frames ? frames : project[DRIVEN[role.drives]]) : (project.video || {})[role.input]; if (v !== undefined && v !== null && v !== "") put(slot, role.input, v); } - for (const { slot, role } of rolesAt(slots, "generation.seed")) put(slot, role.input, seed()); + if (seed) for (const { slot, role } of rolesAt(slots, "generation.seed")) put(slot, role.input, seed()); // null: the seed node's own number runs const source = rolesAt(slots, "assets.source_image")[0]; if (source) put(source.slot, "media_id", project.generation_mode === "t2v" ? "" : scene.source_image || ""); // "from a prompt" starts every shot without a picture const { overrides, unwired } = wireReferences(scene.references || [], slots, source ? [source.slot.id] : []); diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index da60befa..c1cceb3a 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -70,3 +70,9 @@ test("the no-text note goes by what is sent: a carried frame counts, a picture t assert.equal(await noted({}, { text: "" }, carry), false); assert.equal(await noted({ generation_mode: "t2v" }, { text: "", source_image: "m1" }), true); }); + +test("a fixed seed (seed: null) sends no seed: the seed node's own number runs", async () => { + const s = [{ id: "n", roles: [{ at: "generation.seed", input: "seed" }], inputs: { seed: 7 } }]; + const { inputs } = await buildInputs({ project: {}, scene: {}, slots: s, expand: async () => null, seed: null }); + assert.equal((inputs.n || {}).seed, undefined); +}); diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index 479eeca9..a7d6a710 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -33,7 +33,7 @@ export default { body.node.addEventListener("focusout", (e) => { if (!(e.relatedTarget && body.node.contains(e.relatedTarget))) setTimeout(() => { if (!typing()) draw(); }); }); function draw(force) { // not on every keystroke: that would rebuild the box being typed in const sc = p.selected, open = p.project; - const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode, open.export_size_from, open.width, open.height, open.scenes.map((s) => s.frames_mode === "custom"), Object.keys(open.scene_renders || {}), open.scenes.length] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, (sc.source || {}).type, sc.excluded, typing() ? shownText : (shownText = sc.text), ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`; + const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.num_frames_per_scene, open.frame_rate, JSON.stringify(open.video || {}), open.postfix_enabled !== false, open.generation_mode, open.export_size_from, open.width, open.height, open.scenes.map((s) => s.frames_mode === "custom"), Object.keys(open.scene_renders || {}), open.scenes.length] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, (sc.source || {}).type, sc.excluded, typing() ? shownText : (shownText = sc.text), ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`; titled(); if (!force && next === key) return; key = next; diff --git a/app/ui/features/sampler/index.js b/app/ui/features/sampler/index.js index bf883293..15ed6144 100644 --- a/app/ui/features/sampler/index.js +++ b/app/ui/features/sampler/index.js @@ -16,30 +16,43 @@ export default { const latest = {}; // per input, the value whose save is still on its way: newer than the slot const sampling = () => { const ok = ps.usefulness(); return ps.activeModules().filter((m) => (m.category || "") === "sampling" && Object.keys(m.settings || {}).length && ok(m)); }; const marked = () => (ps.slots() || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "generation.sampling" && r.input && !Array.isArray((slot.inputs || {})[r.input])).map((role) => ({ slot, role }))); + const seeds = () => (ps.slots() || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "generation.seed" && r.input).map((role) => ({ slot, role }))); const describe = async () => { - const missing = [...new Set(marked().map(({ slot }) => slot.node))].filter((n) => !(n in specs)); + const missing = [...new Set([...marked(), ...seeds()].map(({ slot }) => slot.node))].filter((n) => !(n in specs)); if (missing.length) try { Object.assign(specs, (await app.api.describeNodes(missing)).nodes || {}); unreadable = false; } catch { unreadable = true; } // asked again on the next change }; + const setter = ({ slot, role }) => async (v) => { // compared with the newest value: the one drawn may be several saves old + const key = `${slot.id}.${role.input}`; + const now = (ps.slots() || []).find((s) => s.id === slot.id) || slot; + if (JSON.stringify(key in latest ? latest[key] : (now.inputs || {})[role.input]) === JSON.stringify(v)) return; + latest[key] = v; + await ps.save({ inputs: { [slot.id]: { [role.input]: v } } }); + if (ps.settled) await ps.settled(); // a save queued behind another returns before it lands + if (latest[key] !== v) return; // a newer edit of this input is the one that counts + delete latest[key]; // landed or not, the slot is the truth again + const after = (ps.slots() || []).find((s) => s.id === slot.id) || {}; + if (JSON.stringify((after.inputs || {})[role.input]) !== JSON.stringify(v)) { // not saved: show what will really run, and why + c.toast.warn({ text: [...(ps.saveNotes ? ps.saveNotes() : [])].find((n) => /could not save/i.test(n)) || `${role.label || role.input} was not saved.` }); + draw(); + } + }; const inputRow = ({ slot, role }) => { const w = ((specs[slot.node] || {}).widgets || []).find((x) => x.name === role.input); if (!w) return null; - const set = async (v) => { // compared with the newest value: the one drawn may be several saves old - const key = `${slot.id}.${role.input}`; - const now = (ps.slots() || []).find((s) => s.id === slot.id) || slot; - if (JSON.stringify(key in latest ? latest[key] : (now.inputs || {})[role.input]) === JSON.stringify(v)) return; - latest[key] = v; - await ps.save({ inputs: { [slot.id]: { [role.input]: v } } }); - if (ps.settled) await ps.settled(); // a save queued behind another returns before it lands - if (latest[key] !== v) return; // a newer edit of this input is the one that counts - delete latest[key]; // landed or not, the slot is the truth again - const after = (ps.slots() || []).find((s) => s.id === slot.id) || {}; - if (JSON.stringify((after.inputs || {})[role.input]) !== JSON.stringify(v)) { // not saved: show what will really run, and why - c.toast.warn({ text: [...(ps.saveNotes ? ps.saveNotes() : [])].find((n) => /could not save/i.test(n)) || `${role.label || role.input} was not saved.` }); - draw(); - } - }; + const set = setter({ slot, role }); return c.settingsRow.default({ label: role.label || w.name, hint: w.tooltip || "", control: widgetControl({ ...w, name: role.label || w.name }, (slot.inputs || {})[role.input] ?? w.default, set) }); }; + // Seed: fresh every Generate (the default), or the seed node's own number, so the same settings give the same clip. + const seedRows = () => { + const p = app.project, list = seeds(); + if (!list.length || !p || !p.project) return []; + const fresh = p.pref("random_seed", true) !== false, last = app.lastRun && app.lastRun.seed; + const keep = () => { p.setPref("random_seed", false); Promise.all(list.map((x) => setter(x)(last))).then(draw); }; + return [c.toggle.default({ label: "New seed every Generate", hint: fresh ? "Each Generate picks a random seed; the number on the seed node is not used." : "Every Generate uses the Seed below: same settings, same clip. Takes ×N still use fresh seeds.", + checked: fresh, onChange: (v) => { p.setPref("random_seed", v); draw(); } }), + ...(fresh ? [] : list.map(inputRow)), + ...(Number.isInteger(last) ? [c.settingsRow.default({ label: "Last seed used", hint: "The seed of the last clip generated.", control: c.button.sm({ label: `Keep ${last}`, tone: "ghost", title: "Use this seed from now on", onClick: keep }) })] : [])]; + }; const button = c.button.sm({ label: "⏱ Sampler", tone: "neutral", title: "Steps, sampler, scheduler and sampling settings: one click, no modal.", onClick: () => (pop ? pop.close() : open()) }); button.node.hidden = true; host.append(button.node); @@ -55,15 +68,15 @@ export default { const why = (ps.saveNotes ? ps.saveNotes() : []).find((n) => /could not save/i.test(n)); if (why) c.toast.warn({ text: why }); // refused, or not saved: said here, not only on the Engine page draw(); - }; - const rows = marked().map(inputRow); + }; + const rows = [...marked().map(inputRow), ...seedRows()]; const unread = marked().length && !rows.some(Boolean) && (unreadable || marked().every(({ slot }) => slot.node in specs)) ? [c.hint.default({ text: "Steps, sampler and scheduler could not be read from the sampler node: change them in Settings ▸ Models & Pipeline." })] : []; body.set([...unread, ...rows, ...mods.flatMap((m) => [mods.length > 1 ? c.label.section({ text: m.title || m.id }) : null, ...Object.entries(m.settings).filter(([, spec]) => whenSatisfied(values, m.id, spec.when)).map(([name, spec]) => settingRow(m.id, name, spec, values, change))])].filter(Boolean)); }; const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); const off = ps.subscribe(() => { if (pop && !typing()) describe().then(draw); }); - const show = () => { button.node.hidden = !sampling().length && !marked().length; if (button.node.hidden && pop) pop.close(); }; + const show = () => { button.node.hidden = !sampling().length && !marked().length && !seeds().length; if (button.node.hidden && pop) pop.close(); }; ps.ensureLoaded().then(show); const offShow = ps.subscribe(show);