248 lines
13 KiB
JavaScript
248 lines
13 KiB
JavaScript
// "Extend video" graph builder. Instantiates an I2V template (VERBATIM — its
|
|
// lockModels recipe already matches the source clip's models) into an off-screen
|
|
// LGraph and overrides ONLY the start image (the source video's last frame) and
|
|
// the prompt, so the next clip continues from where the source ended. Mirrors
|
|
// buildReposeGraph (repose-from-image.js). The last frame is staged server-side
|
|
// by core/extend_api.py (/promptchain/extend/prepare).
|
|
|
|
import { api } from "../../../scripts/api.js";
|
|
import { resolveInstalledVariant } from "./model-bridge.js";
|
|
import { offscreenIdBase } from "./offscreen-graph.js";
|
|
|
|
const EMPTY_WORKFLOW = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 };
|
|
|
|
function freshWorkflowId() {
|
|
return globalThis.crypto?.randomUUID ? crypto.randomUUID() : `${Date.now().toString(16)}-extend`;
|
|
}
|
|
|
|
function setNamedWidget(node, name, value) {
|
|
const w = node?.widgets?.find((x) => x.name === name);
|
|
if (!w) return false;
|
|
if (Array.isArray(w.options?.values) && value != null && !w.options.values.includes(value)) {
|
|
w.options.values.push(value); // make a not-yet-listed file selectable
|
|
}
|
|
w.value = value;
|
|
w.callback?.(value);
|
|
return true;
|
|
}
|
|
|
|
// opts: { templateId, lastFrameFilename, promptDoc, length }
|
|
// Returns { graph, workflowId } or null.
|
|
export async function buildExtendGraph(opts) {
|
|
const LG = window.LiteGraph;
|
|
if (!LG) return null;
|
|
|
|
let template;
|
|
try {
|
|
const res = await api.fetchApi(`/promptchain/templates/${encodeURIComponent(opts.templateId)}`);
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
template = data?.template || data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!template?.nodes?.length) return null;
|
|
|
|
const workflowId = freshWorkflowId();
|
|
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
|
|
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
|
|
const created = new Array(template.nodes.length).fill(null);
|
|
// Keeps the dual experts on one downloaded quant when remapping baked variants.
|
|
const variantRef = { token: "" };
|
|
|
|
for (let i = 0; i < template.nodes.length; i++) {
|
|
const tpl = template.nodes[i];
|
|
const node = LG.createNode(tpl.type);
|
|
if (!node) { console.warn(`[Extend] unknown node type ${tpl.type}`); return null; }
|
|
graph.add(node);
|
|
if (tpl.title) node.title = tpl.title;
|
|
// Copy widget values verbatim — lockModels keeps the proven recipe (model /
|
|
// LoRA / VAE / CLIP files, steps, cfg, shift). Only the pieces below vary.
|
|
if (tpl.widgets_values?.length && node.widgets?.length) {
|
|
for (let w = 0; w < Math.min(tpl.widgets_values.length, node.widgets.length); w++) {
|
|
const widget = node.widgets[w];
|
|
if (widget.type === "IMAGEUPLOAD" || widget.type === "button") continue;
|
|
let val = tpl.widgets_values[w];
|
|
// The template bakes a fixed GGUF quant / safetensors precision; remap it
|
|
// to whichever variant of the same model file the user actually installed
|
|
// (same fix applyTemplate does — buildExtendGraph doesn't route through it).
|
|
if (typeof val === "string" && /\.(gguf|safetensors|ckpt)$/i.test(val)) {
|
|
val = resolveInstalledVariant(val, widget.options?.values, variantRef);
|
|
}
|
|
if (Array.isArray(widget.options?.values) && val != null && !widget.options.values.includes(val)) {
|
|
widget.options.values.push(val);
|
|
}
|
|
widget.value = val;
|
|
widget.callback?.(val);
|
|
}
|
|
}
|
|
created[i] = node;
|
|
}
|
|
|
|
// ── overrides: start image = source's last frame; optional clip dims/length/
|
|
// fps. Every override is OPTIONAL — an omitted opt keeps the template's tuned
|
|
// value, so default Extend behaviour is unchanged. ──
|
|
for (const node of created) {
|
|
if (!node) continue;
|
|
const type = node.comfyClass || node.type;
|
|
if (type === "LoadImage") {
|
|
setNamedWidget(node, "image", opts.lastFrameFilename);
|
|
} else if (type === "WanImageToVideo" || type === "WanFirstLastFrameToVideo") {
|
|
if (opts.length) setNamedWidget(node, "length", opts.length);
|
|
if (opts.width) setNamedWidget(node, "width", opts.width);
|
|
if (opts.height) setNamedWidget(node, "height", opts.height);
|
|
} else if (type === "CreateVideo" && opts.outFps != null) {
|
|
setNamedWidget(node, "fps", opts.outFps);
|
|
}
|
|
}
|
|
|
|
// ── advanced sampler overrides (all optional). The 14B recipe is DUAL-EXPERT:
|
|
// two KSamplerAdvanced (the high-noise expert runs the first slice of the step
|
|
// schedule, the low-noise expert the rest) + two ModelSamplingSD3. Identify the
|
|
// high expert by its add_noise="enable" and preserve the template's proportional
|
|
// step split so the MoE handoff stays intact; single-expert recipes (one
|
|
// sampler) just take the full [0, steps] window. ──
|
|
const widgetVal = (node, name) => node?.widgets?.find((w) => w.name === name)?.value;
|
|
const ksamplers = created.filter((n) => n && (n.comfyClass || n.type) === "KSamplerAdvanced");
|
|
const shiftNodes = created.filter((n) => n && (n.comfyClass || n.type) === "ModelSamplingSD3");
|
|
const highK = ksamplers.find((n) => widgetVal(n, "add_noise") === "enable") || ksamplers[0] || null;
|
|
const lowK = ksamplers.find((n) => n !== highK) || null;
|
|
|
|
if (opts.cfg != null) for (const k of ksamplers) setNamedWidget(k, "cfg", opts.cfg);
|
|
if (opts.sampler) for (const k of ksamplers) setNamedWidget(k, "sampler_name", opts.sampler);
|
|
if (opts.scheduler) for (const k of ksamplers) setNamedWidget(k, "scheduler", opts.scheduler);
|
|
if (opts.shift != null) for (const m of shiftNodes) setNamedWidget(m, "shift", opts.shift);
|
|
// Seed: a pinned seed fixes both experts; unpinned must inject a FRESH random
|
|
// seed each run, because the off-screen api.queuePrompt path never fires the
|
|
// "randomize" widget control — the baked noise_seed (0) would otherwise repeat
|
|
// byte-identical every Extend / Run Again. The high expert drives the noise
|
|
// (low has add_noise disabled), so randomizing it is enough.
|
|
if (opts.seed != null) {
|
|
for (const k of ksamplers) {
|
|
setNamedWidget(k, "noise_seed", opts.seed);
|
|
setNamedWidget(k, "control_after_generate", "fixed");
|
|
}
|
|
} else if (highK) {
|
|
setNamedWidget(highK, "noise_seed", Math.floor(Math.random() * 2 ** 31));
|
|
}
|
|
if (opts.steps != null && highK) {
|
|
const origSteps = Number(widgetVal(highK, "steps")) || opts.steps;
|
|
const origEnd = Number(widgetVal(highK, "end_at_step"));
|
|
setNamedWidget(highK, "steps", opts.steps);
|
|
setNamedWidget(highK, "start_at_step", 0);
|
|
if (lowK) {
|
|
const ratio = origSteps > 0 && origEnd > 0 ? origEnd / origSteps : 0.5;
|
|
const boundary = Math.max(1, Math.min(opts.steps - 1, Math.round(opts.steps * ratio)));
|
|
setNamedWidget(highK, "end_at_step", boundary);
|
|
setNamedWidget(lowK, "steps", opts.steps);
|
|
setNamedWidget(lowK, "start_at_step", boundary);
|
|
setNamedWidget(lowK, "end_at_step", opts.steps);
|
|
} else {
|
|
setNamedWidget(highK, "end_at_step", opts.steps);
|
|
}
|
|
}
|
|
|
|
// ── wiring ──
|
|
for (const conn of template.connections || []) {
|
|
const from = created[conn.from_node_idx];
|
|
const to = created[conn.to_node_idx];
|
|
if (from && to) {
|
|
try { from.connect(conn.from_slot, to, conn.to_slot); } catch (e) { console.warn("[Extend] wire failed", e); }
|
|
}
|
|
}
|
|
|
|
// ── prompt: PromptChain_PromptChain wired via anchorConnections so the raw doc
|
|
// compiles SERVER-SIDE (same engine repose/upscale use). Mirrors buildReposeGraph. ──
|
|
const anchors = template.anchorConnections || [];
|
|
if (anchors.length) {
|
|
const pc = LG.createNode("PromptChain_PromptChain");
|
|
if (!pc) { console.warn("[Extend] PromptChain_PromptChain unavailable (install the pack)"); return null; }
|
|
graph.add(pc);
|
|
const promptW = pc.widgets?.find((w) => w.name === "prompt");
|
|
if (promptW) { promptW.value = opts.promptDoc || ""; promptW.callback?.(promptW.value); }
|
|
pc.properties = pc.properties || {};
|
|
Object.assign(pc.properties, {
|
|
pcrMode: "switch", pcrSwitchIndex: 1, pcrLocked: false, pcrDisabled: false,
|
|
pcrCachedOutput: "", pcrCachedNegOutput: "", pcrCachedRegions: "",
|
|
});
|
|
const maxSlot = Math.max(...anchors.map((c) => c.from_slot));
|
|
if (!pc.outputs || pc.outputs.length <= maxSlot) {
|
|
const ref = LG.createNode(pc.comfyClass || pc.type);
|
|
if (ref?.outputs) {
|
|
for (let i = pc.outputs?.length || 0; i < ref.outputs.length; i++) {
|
|
pc.addOutput(ref.outputs[i].name, ref.outputs[i].type);
|
|
}
|
|
}
|
|
}
|
|
for (const conn of anchors) {
|
|
const to = created[conn.to_node_idx];
|
|
if (to) { try { pc.connect(conn.from_slot, to, conn.to_slot); } catch (e) { console.warn("[Extend] anchor wire failed", e); } }
|
|
}
|
|
}
|
|
|
|
// ── optional stitch: prepend the SOURCE clip so the result is ONE continuous
|
|
// video. Load the source -> frames; drop the continuation's duplicate first
|
|
// frame (ImageFromBatch from index 1 — it equals the source's last frame);
|
|
// concatenate (source + trimmed continuation) and re-encode through the
|
|
// template's own CreateVideo at the source fps. All core video/image nodes; if
|
|
// any is missing we silently leave the continuation-only graph intact. ──
|
|
// The continuation is native 16fps. A source can only be cleanly joined with it
|
|
// when the source is also 16 or a whole-number RIFE multiple of 16 (32/48/64) —
|
|
// those we either join directly or match by interpolating the continuation up.
|
|
// A non-multiple source (24fps 5B, foreign 25/30/60) can't be reconciled without
|
|
// frame-rate conversion, so we SKIP the stitch (save the continuation on its own)
|
|
// rather than silently slow the source half by re-encoding it at 16.
|
|
const NATIVE_FPS = 16;
|
|
const srcFps = Math.round(opts.fps || NATIVE_FPS);
|
|
const fpsMult = Math.round(srcFps / NATIVE_FPS);
|
|
const cleanForStitch = srcFps === NATIVE_FPS || (fpsMult >= 2 && srcFps === fpsMult * NATIVE_FPS);
|
|
if (opts.stitch && opts.sourceVideoFilename && !cleanForStitch) {
|
|
console.warn(`[Extend] source ${srcFps}fps can't be cleanly joined with a 16fps continuation; saving continuation only`);
|
|
}
|
|
if (opts.stitch && opts.sourceVideoFilename && cleanForStitch) {
|
|
const reg = LG.registered_node_types || {};
|
|
const need = ["LoadVideo", "GetVideoComponents", "ImageBatch", "ImageFromBatch"];
|
|
const vae = created.find((n) => n && (n.comfyClass || n.type) === "VAEDecode");
|
|
const cv = created.find((n) => n && (n.comfyClass || n.type) === "CreateVideo");
|
|
if (need.every((t) => reg[t]) && vae && cv) {
|
|
const load = LG.createNode("LoadVideo"); graph.add(load); setNamedWidget(load, "file", opts.sourceVideoFilename);
|
|
const comp = LG.createNode("GetVideoComponents"); graph.add(comp);
|
|
const drop = LG.createNode("ImageFromBatch"); graph.add(drop);
|
|
setNamedWidget(drop, "batch_index", 1);
|
|
setNamedWidget(drop, "length", 4096); // clamps to "rest of the batch" (default 1 would keep one frame)
|
|
const batch = LG.createNode("ImageBatch"); graph.add(batch);
|
|
try {
|
|
// Source is 16 or a clean RIFE multiple (32/48/64 — guaranteed by
|
|
// cleanForStitch). When it's a multiple, bring the native-16 continuation
|
|
// up to the same density first so both halves play at one rate; this is
|
|
// what makes "extend an already-smoothed clip" just work. RIFE is present
|
|
// whenever the source was smoothed.
|
|
const matchInterp = fpsMult >= 2 && reg["RIFE VFI"];
|
|
let contFrames = vae; // node feeding the continuation frames into the join
|
|
if (matchInterp) {
|
|
const rife = LG.createNode("RIFE VFI"); graph.add(rife);
|
|
setNamedWidget(rife, "ckpt_name", "rife49.pth");
|
|
setNamedWidget(rife, "multiplier", fpsMult);
|
|
vae.connect(0, rife, 0); // continuation 16fps -> interpolate up to source density
|
|
contFrames = rife;
|
|
}
|
|
load.connect(0, comp, 0); // source VIDEO -> GetVideoComponents.video
|
|
contFrames.connect(0, drop, 0); // (matched) continuation frames -> drop frame 0
|
|
comp.connect(0, batch, 0); // source frames -> ImageBatch.image1
|
|
drop.connect(0, batch, 1); // trimmed continuation -> ImageBatch.image2
|
|
batch.connect(0, cv, 0); // concatenated -> CreateVideo.images (replaces the VAEDecode link)
|
|
// Encode at the source's rate when matched (both halves now that density),
|
|
// else native 16 (the source was already 16). Never relabel un-matched
|
|
// native frames to a higher fps — that's the fast-play bug.
|
|
setNamedWidget(cv, "fps", matchInterp ? srcFps : NATIVE_FPS);
|
|
} catch (e) {
|
|
console.warn("[Extend] stitch wiring failed; producing continuation-only clip", e);
|
|
}
|
|
} else {
|
|
console.warn("[Extend] stitch requested but core video nodes unavailable; continuation-only");
|
|
}
|
|
}
|
|
|
|
return { graph, workflowId };
|
|
}
|