Files
DumiFlex 18e9243542 feat(tutorials): review round 3 (callout hold rule, A17 rewrite, AI3 image ending, starter library) (#357)
Callout hold rule, dead-pause fix after runs, A17 rewrite (toggle variables first), AI3 ends by rendering with the built pack's top bundle, AI1 line, box lines in A16/T10/T12, N13 at 20x, and three starter-library items (Daydream, Lights on or off, Shot per frame).
2026-11-11 22:58:26 +01:00

334 lines
18 KiB
TypeScript

// Nodes 5: WP Context Loop in depth. Loop → Context (Role, Setting, Time of day, Lighting from time,
// Framing, Companion) → Assembler → Show Any (Easy-Use, it lists every frame). What runs again and how
// a loop differs from ComfyUI's batch size and Run count; the Edit frame chips (a frame's own pick, a
// lock on one frame, a module off on one frame and on for one frame only); Override Context seed and
// the strategies; the seeds panel; Ctrl-click to skip a frame and Alt-click to lock its seed; the
// values input; a derivation on $iteration ("Shot per frame", in the tutorial library) against a frame edit;
// then WP Seed List, Hold across run and Bypass loop. Prompt-only: no model.
import { tutorial } from "../tutorial.js";
tutorial(
"N05",
{
title: "WP Context Loop in depth",
description: "Everything the Context Loop does: frames vs ComfyUI's batch, editing one frame, skipping and locking frames, seeds and strategies, a list of values, and how it works with the nodes around it.",
learn: "Loop vs batch size vs Run count, Edit frame chips, per-frame locks and on/off, Override Context seed, strategies, the seeds panel, Ctrl/Alt-click, values, $iteration rules, Seed List, Hold, Bypass loop.",
},
async (t) => {
const page = t.page;
// A highlight on a Show Any waits until the run's text is in it, not just until the run ends.
// The node's text, its fields' values included: innerText skips what a textarea holds, so a
// wait on it alone ran to its 15 s limit (a still screen after every run).
const textOf = (node: string) =>
t.node(node)
.evaluate((el) => `${(el as HTMLElement).innerText} ${[...el.querySelectorAll("textarea, input")].map((f) => (f as HTMLInputElement).value).join(" ")}`)
.catch(() => "");
const filled = async (node: string, re: RegExp) => {
for (let i = 0; i < 60; i++) {
if (re.test(await textOf(node))) break;
await t.beat(250);
}
if (!re.test(await textOf(node))) t.h.log(`${node} never showed ${re}`);
await t.beat(400);
};
const chip = (loop: string, n: number | "base") => t.node(loop).locator(`[data-test="loop-frame-${n}"]`);
// Ctrl / Alt held through one click, as a person does it.
const modClick = async (key: "Control" | "Alt", target: ReturnType<typeof chip>) => {
await t.moveTo(await t.centre(target));
await page.keyboard.down(key);
await t.click(target);
await page.keyboard.up(key);
await t.beat(600);
};
const editRow = async (ctx: string, name: string) => {
await t.click(t.moduleRow(ctx, name).locator(".wp-module-name"), { right: true });
await t.beat(600);
await t.click(page.getByRole("menuitem", { name: "Edit" }));
await t.beat(900);
return page.getByRole("dialog").last();
};
const n = await t.h.step("What runs again", async () => {
await t.go("/");
await t.speedUp();
const loop = await t.addNode("WP Context Loop", { x: 270, y: 170 });
const ctx = await t.addNode("WP Context", { x: 790, y: 170 });
await t.wire(loop, "context", ctx, "upstream");
await t.pickModules(ctx, ["Role", "Setting", "Time of day", "Lighting from time", "Framing", "Companion"]);
const asm = await t.addNode("WP Prompt Assembler", { x: 1340, y: 170 });
await t.wire(ctx, "context", asm, "context");
await t.click(t.node(asm).locator("textarea, [contenteditable='true']").first());
await t.type("frame $iteration: $framing of $role $setting $time, $lighting. $companion", { cps: 40 });
await t.click(t.node(loop).locator('[data-test="loop-iteration-internal"]'));
const text = await t.addNode("Show Any", { x: 1340, y: 640 });
await t.wire(asm, "prompt", text, "anything");
const more = t.node(loop).locator('[data-widget-name="count"] button').last();
for (let i = 0; i < 3; i++) await t.click(more);
await t.normalSpeed();
await t.say("A loop with count four, before a Context with six items, an Assembler, and Show Any to list every prompt. iteration is printed, so each prompt says its frame.");
await t.waitVoice();
await t.callout("after", [t.node(ctx), t.node(asm), t.node(text)], "Runs once per frame", 6);
await t.say("The loop goes before your first Context. Everything after it runs once per frame: the Contexts, the Assembler, a Cleaner, Debug, and the sampler.");
await t.waitVoice();
await t.uncallout("after");
await t.say("Run.");
await t.run();
await filled(text, /frame 4/);
await t.callout("out", t.node(text), "Four frames, four sets of picks", 6);
await t.waitVoice();
await t.uncallout("out");
return { loop, ctx, asm, text };
});
await t.h.step("Not ComfyUI's batch", async () => {
await t.say("This isn't the same as ComfyUI's batch. There are three ways to get four images, and they do different things.");
const latent = await t.addNode("Empty Latent Image", { x: 790, y: 760 });
await t.waitVoice();
await t.callout("bs", t.node(latent).locator('[data-widget-name="batch_size"]').first(), "One prompt, four noise seeds", 6);
await t.say("Batch size on the latent: one prompt, one sampler pass, four noise seeds. All four images have the same picks.");
await t.waitVoice();
await t.uncallout("bs");
const count = page.locator('[data-testid="batch-count-edit"], .batch-count, [aria-label*="Batch count" i]').first();
const runBox = (await count.count()) ? count : page.getByRole("button", { name: "Run", exact: true }).first();
await t.callout("rc", runBox, "Four separate runs", 6);
await t.say("The run count next to Run queues four separate runs. With the seed on randomize each one rolls new picks, but no node ever sees the four together.");
await t.waitVoice();
await t.uncallout("rc");
await t.callout("loop", t.node(n.loop), "One run, four frames", 6);
await t.say("The loop is one run with four frames. Each frame has its own picks, prompt and seed, and you can edit, skip or lock it on its own.");
await t.waitVoice();
await t.say("And the nodes after it get all four as one list, so an Image Filter, Label and Grid or a Seed List can work across the frames.");
await t.waitVoice();
await t.uncallout("loop");
await t.say("They stack, too: four frames with a batch size of two make eight images, two per prompt.");
await t.waitVoice();
// Off the canvas again: the rest of the video doesn't use it.
await page.evaluate((id) => {
const g = window.app?.graph as unknown as { getNodeById(i: string | number): unknown; remove(n: unknown): void } | undefined;
const node = g?.getNodeById(id) ?? g?.getNodeById(Number(id));
if (g && node) g.remove(node);
window.app?.canvas?.setDirty(true, true);
}, latent);
await t.beat(500);
});
await t.h.step("Edit one frame", async () => {
const chips = t.node(n.loop).locator('[data-test="loop-frames-grid"]');
await t.callout("chips", chips, "base, then each frame", 5);
await t.say("Edit frame has a chip per frame. Base is what every frame shares. Click a frame, and the row editors on the Context edit that frame only.");
await t.waitVoice();
await t.uncallout("chips");
await t.say("Click two. Then edit Time of day, and leave only at night.");
await t.click(chip(n.loop, 2));
await t.beat(700);
const modal = await editRow(n.ctx, "Time of day");
await t.callout("banner", modal.locator('[data-test="modal-frame-banner"]'), "Only frame 2", 6);
await t.say("The banner says it: changes here affect frame two only. Revert puts the frame back to the base.");
await t.waitVoice();
await t.uncallout("banner");
for (const v of ["at dawn", "at sunset"]) {
await t.click(modal.locator(`[data-test="opt-check"][aria-label="Use option ${v}"]`));
await t.beat(400);
}
await t.click(modal.locator('[data-test="wcm-save"]'));
await t.beat(900);
await t.callout("ov", t.moduleRow(n.ctx, "Time of day").locator('[data-test="mod-override"]'), "Changed on frame 2", 5);
await t.say("The row shows the override while frame two is open.");
await t.waitVoice();
await t.uncallout("ov");
await t.say("Lock works per frame too. Lock Role here, and frame two keeps its role while the other frames still roll.");
await t.click(t.moduleRow(n.ctx, "Role").locator('[data-test="row-action-lock"]'));
await t.beat(700);
await t.waitVoice();
await t.callout("grey", [t.moduleRow(n.ctx, "Role").locator('[data-test="row-action-internal"]')], undefined, 4);
await t.say("Hide from prompt and Hold across run are greyed out on a frame: they always apply to every frame.");
await t.waitVoice();
await t.uncallout("grey");
await t.say("Now frame three, and untick Lighting from time. Frame three gets no lighting.");
await t.click(chip(n.loop, 3));
await t.beat(600);
await t.click(t.moduleRow(n.ctx, "Lighting from time").locator('[data-test="module-enabled"]'));
await t.beat(700);
await t.waitVoice();
await t.say("It works the other way round too. On base, untick Companion. Then on frame four, tick it again: only frame four gets a companion.");
await t.click(chip(n.loop, "base"));
await t.beat(600);
await t.click(t.moduleRow(n.ctx, "Companion").locator('[data-test="module-enabled"]'));
await t.beat(600);
await t.click(chip(n.loop, 4));
await t.beat(600);
await t.click(t.moduleRow(n.ctx, "Companion").locator('[data-test="module-enabled"]'));
await t.beat(700);
await t.waitVoice();
await t.click(chip(n.loop, "base"));
await t.beat(500);
await t.say("Run.");
await t.run();
await filled(n.text, /frame 4/);
await t.callout("out", t.node(n.text), "2 at night, 3 unlit, a companion on 4", 6);
await t.say("Frame two is at night, frame three has no lighting, and only frame four has a companion.");
await t.waitVoice();
await t.uncallout("out");
});
await t.h.step("Override Context seed", async () => {
const sw = t.node(n.loop).locator('[data-test="loop-override-toggle"]');
await t.callout("ov", sw, "Off: each Context keeps its seed", 4);
await t.say("Override Context seed is off by default. Each Context keeps its own seed, and the loop changes it a little per frame.");
await t.waitVoice();
await t.say("Switch it on, and the loop's own seed decides every frame. Same loop seed, same batch, every time.");
await t.click(sw);
await t.beat(700);
await t.waitVoice();
await t.uncallout("ov");
const strat = t.node(n.loop).locator('[aria-label="Seed strategy"]');
await t.callout("st", strat, "Which numbers, not how varied", 4);
await t.say("Strategy picks how the frame seeds are worked out: hash looks random, sequential counts up, and stride takes big even jumps. It changes the numbers, not how varied the frames are.");
await t.click(t.node(n.loop).locator('[data-test="loop-strategy-sequential"]'));
await t.beat(700);
await t.waitVoice();
await t.uncallout("st");
await t.say("Run, so the loop has seeds to remember.");
await t.run();
await filled(n.text, /frame 4/);
await t.waitVoice();
});
await t.h.step("The seeds panel", async () => {
await t.say("Per-iteration seeds lists the seed each frame will use. With sequential, they count up from the base.");
await t.click(t.node(n.loop).locator('[data-test="loop-seeds-btn"]'));
await t.beat(900);
const modal = page.locator('[data-test="seed-modal"]').last();
await t.waitVoice();
await t.say("Lock a frame to keep its seed. Lock all and Unlock all work on the whole list.");
await t.click(modal.locator('[data-test="seedrow-lock"]').nth(0));
await t.beat(600);
await t.click(modal.locator('[data-test="mx-seed-lockall"]'));
await t.beat(700);
await t.click(modal.locator('[data-test="mx-seed-unlockall"]'));
await t.beat(700);
await t.waitVoice();
await t.callout("cp", [modal.locator('[data-test="mx-seed-copy"]'), modal.locator('[data-test="mx-seed-paste"]')], "Move seeds between loops", 4);
await t.say("Copy and Paste carry the seeds over as text, to another loop or a Seed List.");
await t.waitVoice();
await t.uncallout("cp");
const prev = modal.locator('[data-test="seedrow-lockprev"]').nth(2);
await t.callout("prev", prev, "The seed frame 3 used last run", 4);
await t.say("Liked frame three last time? Prev locks the seed it used on that run, even if the list has moved on since.");
await t.click(prev);
await t.beat(700);
await t.waitVoice();
await t.uncallout("prev");
await t.click(modal.locator('[data-test="seed-modal-done"]'));
await t.beat(600);
});
await t.h.step("Skip and lock from the chips", async () => {
// No label: it would sit on the chips the line is about.
await t.callout("hint", t.node(n.loop).locator('[data-test="loop-frames-hint"]'), undefined, 4);
await t.say("The chips have two shortcuts, written under them. Ctrl-click a frame to skip it. Ctrl-click four.");
await t.waitVoice();
await t.uncallout("hint");
await modClick("Control", chip(n.loop, 4));
await t.waitVoice();
await t.say("Alt-click a frame to lock its seed. Alt-click one.");
await modClick("Alt", chip(n.loop, 1));
await t.waitVoice();
await t.say("Run. Frame four makes nothing now, so the loop gives three prompts.");
await t.run();
await filled(n.text, /frame 3/);
await t.callout("out", t.node(n.text), "Frame 4 skipped", 5);
await t.waitVoice();
await t.uncallout("out");
await t.say("Ctrl-click it again to bring it back.");
await modClick("Control", chip(n.loop, 4));
await t.waitVoice();
});
await t.h.step("A list of values", async () => {
await t.say("The values input takes a list. Wire in a text with one line per frame.");
const vals = await t.addNode("Text (Multiline)", { x: 270, y: 760 });
await t.click(t.node(vals).locator("textarea").first());
await t.type("a lighthouse\na windmill\na stone bridge", { cps: 18 });
await t.beat(400);
await t.wire(vals, "STRING", n.loop, "values");
await t.beat(700);
await t.callout("note", t.node(n.loop).locator('[data-test="loop-values-note"]'), "One frame per line", 5);
await t.say("Each line becomes a frame, and writes iteration value. The number of lines sets the count.");
await t.waitVoice();
await t.uncallout("note");
await t.say("Add it to the prompt.");
await t.click(t.node(n.asm).locator("textarea, [contenteditable='true']").first());
await page.keyboard.press("End");
await t.type(" In the background: $iteration_value.", { cps: 18 });
await t.run();
await filled(n.text, /stone bridge/);
await t.callout("out", t.node(n.text), "Three lines, three frames", 5);
await t.waitVoice();
await t.uncallout("out");
});
await t.h.step("Rules on $iteration", async () => {
await t.say("A frame edit lives on this canvas only. For a pattern you can share, write a rule on iteration. Add Shot per frame: frame one gets a wide establishing shot, the others a close-up.");
await t.pickModules(n.ctx, ["Shot per frame"]);
await t.waitVoice();
await t.click(t.node(n.asm).locator("textarea, [contenteditable='true']").first());
await page.keyboard.press("Control+Home");
await t.type("$shot, ", { cps: 16 });
await t.run();
await filled(n.text, /establishing/);
await t.callout("out", t.node(n.text), "Frame 1 wide, the rest close", 5);
await t.say("A rule is saved in bundles and works in any loop. A frame edit is quicker for one canvas.");
await t.waitVoice();
await t.uncallout("out");
});
await t.h.step("With the other nodes", async () => {
await t.say("A plain KSampler uses one image seed for every frame. Add a WP Seed List.");
const sl = await t.addNode("WP Seed List", { x: 790, y: 760 });
await t.wire(n.loop, "loop_config", sl, "loop_config");
await t.beat(400);
// It lands low, under the caption: bring it up into view while it's talked about.
const back = await t.zoomOn(sl, { scale: 0.9 });
await t.say("Loop config goes into it, and its seed into the sampler, for one image seed per frame.");
await t.callout("ovr", t.node(sl).locator('[data-test="seedlist-override-count-toggle"]'), "Count and strategy follow the loop", 5);
await t.say("Its count and strategy follow the loop by themselves, so frame one goes with seed one, frame two with seed two.");
await t.waitVoice();
await t.uncallout("ovr");
await t.setView(back);
await t.say("Hold across run, on a row's menu, rolls once per run and keeps that pick on every frame. Hold Setting, and all three frames share one place.");
await t.click(t.moduleRow(n.ctx, "Setting").locator(".wp-module-name"), { right: true });
await t.beat(600);
await t.click(page.getByRole("menuitem", { name: "Hold across run" }));
await t.beat(700);
await t.run();
await filled(n.text, /stone bridge/);
await t.callout("out", t.node(n.text), "One place, three frames", 5);
await t.waitVoice();
await t.uncallout("out");
await t.say("And Bypass loop runs a single frame without unwiring anything, handy while you build.");
await t.click(t.node(n.loop).locator('[data-test="loop-bypass-toggle"]'));
await t.beat(600);
await t.run();
await t.beat(800);
await t.waitVoice();
await t.click(t.node(n.loop).locator('[data-test="loop-bypass-toggle"]'));
await t.beat(500);
// No room for a Debug node on this canvas: point at where it goes instead.
const out = t.node(n.ctx).locator('[data-slot-key$="-out-0"]').first();
await t.callout("dbg", (await out.count()) ? out : t.node(n.ctx), "WP Debug goes here", 6);
await t.say("To see each frame's picks, and which frame had an item off, wire a WP Debug after the Context. The Debug video goes through it.");
await t.waitVoice();
await t.uncallout("dbg");
});
await t.h.step("Outro", async () => {
await t.say("Reach for the loop when you want a set of prompts you can compare, edit and keep frame by frame. The Context Loop page in the docs has every field.");
});
},
);