fix: Project tab follows Length/FPS changes; seed can be kept (Sampler ▸ New seed every Generate), last seed shown with Keep
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
88b9388dec
commit
dcc6cba02f
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] : []);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user