Files

166 lines
14 KiB
JavaScript

// What one run sends: the scene's prompt (expanded with anchor/postfix/$variables), the project's video
// settings, the scene's picked media -- each placed by the ROLE a pipeline slot declares, never by slot id.
import { MAX_REFERENCES, referenceNumbers, sceneReferences, wireReferences, routeFor } from "../../shell/reference_wiring.js";
import { kinds, sounds } from "../../shell/bin.js";
import { DRIVEN } from "../../shell/scenes.js";
import { activeVideoInput, videoInputJson } from "../../shell/video_input.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 (`seed: null` keeps the node's own). */
export async function buildInputs({ project, scene, slots, expand, nodeInfo, 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 }; };
for (const { slot, role } of (slots || []).flatMap((s) => (s.roles || []).filter((r) => (r.at || "").startsWith("project.") && r.input).map((role) => ({ slot: s, role })))) {
const video = project.video || {};
const v = role.at === "project.negative" ? project.negative : role.at === "project.resolution_basis" ? project.resolution_basis
: DRIVEN[role.drives] ? (role.drives === "frames" && frames ? frames : project[DRIVEN[role.drives]]) : video[role.input] ?? video[role.was]; // `was`: what an older pipeline called it, so a saved size still counts
if (v !== undefined && v !== null && v !== "") put(slot, role.input, v);
}
if (seed) for (const { slot, role } of rolesAt(slots, "generation.seed")) put(slot, role.input, seed()); // null: the seed node's own number runs
for (const slot of (slots || []).filter((s) => s.node === "FunUIShotCamera")) put(slot, "project", project.id || ""); // what the node keeps per project (the words focus infusion draws on), also for pipelines saved before it was asked for
const source = rolesAt(slots, "assets.source_image")[0];
const plan = sceneReferences(scene, project.fixed_references);
// Hooks run first: one may add pictures to this run's references (a carried frame takes the next free slot), which the
// start and the wiring below must see. What else they return (inputs, notes) is merged further down, where it always was.
const outs = [], hookNotes = [], carried = [];
for (const hook of hooks) {
let out;
try { out = (await hook({ project, scene, slots })) || {}; } catch (err) { hookNotes.push(`An add-on's input could not be added to this run: ${err.message}`); continue; }
outs.push(out);
for (const id of out.references || []) if (id && !plan.ids.includes(id)) { plan.ids = [...plan.ids, id]; if (!kinds.has(id)) kinds.set(id, "image"); carried.push(id); }
}
// A carried picture goes after everything the scene and the project hold, not into a gap below a pinned reference (that would move the pinned one's model input off its number).
// With no number left it is not placed at all: it is counted and said.
const noRoom = [];
if (carried.length) {
const own = plan.ids.filter((id) => !carried.includes(id)), nums = referenceNumbers(own, plan.pins);
let top = Math.max(0, ...nums);
const pins = { ...plan.pins };
for (const id of carried) { if (top < MAX_REFERENCES) pins[id] = ++top; else noRoom.push(id); }
plan.pins = pins;
plan.ids = plan.ids.filter((id) => !noRoom.includes(id));
}
// The scene's own picture (source_image) and its references are separate inputs with separate jobs: the picture gives the shape
// (where a pipeline takes one), the references feed the model. Neither is read to decide the other.
// A pipeline that takes whole media where it takes a picture (LTX): the first reference is the start:
// a VIDEO first (it is then not also sent as a reference: nothing takes it there), else the first picture (which stays a
// reference too, so an identity input still gets it). A video-only pipeline (video-to-video) looks at videos alone.
const takesVideo = Boolean(source && source.role.video_frames), videoOnly = Boolean(source && (source.role.video_only || (slots || []).some((s) => s.node === "VAEEncode" && ((s.inputs || {}).pixels || [])[0] === source.slot.id))); // a pipeline that encodes the start itself is video-to-video, whether or not it was saved saying so
const numberOf = referenceNumbers(plan.ids, plan.pins); // the start is the lowest-numbered reference of its kind, whatever the list order (a pinned one is listed first)
const firstByNumber = (test) => plan.ids.map((id, i) => [id, numberOf[i]]).filter(([id]) => test(id)).sort((a, b) => a[1] - b[1]).map(([id]) => id)[0];
const wanted = (id) => kinds.get(id) === "video" || (!videoOnly && kinds.get(id) === "image");
const fromRefs = takesVideo
? firstByNumber((id) => kinds.get(id) === "video") || (videoOnly ? null : firstByNumber(wanted)) : null;
const leave = new Set(fromRefs && kinds.get(fromRefs) === "video" ? [fromRefs] : []); // the start video is not also a reference, but it keeps its number: the others do not move up
const startId = takesVideo ? fromRefs || "" : scene.source_image; // nothing picked: text to video follows by itself
if (source) put(source.slot, "media_id", startId || ""); // "from a prompt" starts every shot without a picture
// A pipeline that can take a whole video where it takes a picture (LTX) says so on the role: a picked VIDEO then
// supplies its frames (prepared as Video input says); the preset wires the frames output where the picture goes (a
// wired input cannot be overridden per run), and that output is the first frame alone until `full` is set.
if (takesVideo && startId && kinds.get(startId) === "video") {
put(source.slot, "full", true);
const use = activeVideoInput(project);
if (use) put(source.slot, "prepare", videoInputJson(use, project, scene));
}
// A project saved before the preset said so still has this shape: a picture-resizing slot is enough to know a stand-in fits.
const resizer = (slots || []).find((s) => s.node === "ImageTransformKJ");
const like = source && (source.role.blank_like || (resizer && { slot: resizer.id, width: "target_width", height: "target_height" }));
if (like && !raw[source.slot.id].media_id) { // text-only: a plain picture of the size the pipeline asks for stands in
const target = (slots || []).find((s) => s.id === like.slot) || { inputs: {} };
const size = (name) => (raw[target.id] || {})[name] ?? (target.inputs || {})[name];
put(source.slot, "blank_width", Number(size(like.width)) || 0);
put(source.slot, "blank_height", Number(size(like.height)) || 0);
}
// A reference is placed from what the nodes offer, so a pipeline someone edited needs nothing declared for it.
const needed = plan.ids.some((id) => !leave.has(id));
const info = new Map();
if (needed && nodeInfo) {
const classes = new Set();
for (const s of slots || []) if ((s.roles || []).some((r) => r.wireTo)) {
classes.add(s.node);
for (const r of s.roles) if (r.wireTo) { const t = (slots || []).find((x) => x.id === r.wireTo.slot); if (t) classes.add(t.node); }
}
await Promise.all([...classes].map((c) => Promise.resolve(nodeInfo(c)).then((d) => info.set(c, d), () => {})));
}
const routeOf = (kind, slot, role) => {
const target = role && role.wireTo && (slots || []).find((x) => x.id === role.wireTo.slot);
return target ? routeFor(kind, info.get(target.node), info.get(slot.node), target.id) : null;
};
let { overrides, unwired, left } = wireReferences(plan.ids, slots, source ? [source.slot.id] : [], plan.pins, (id) => kinds.get(id), routeOf, (id) => sounds.has(id), leave);
// An edited pipeline can keep a reference loader but not the node it fed (the identity node deleted, say): a value for a
// slot that is not there would refuse the whole run, so that reference is said to be unused instead.
const have = new Set((slots || []).map((s) => s.id));
for (const id of Object.keys(overrides)) if (!have.has(id)) {
const link = Object.values(overrides[id]).find((v) => Array.isArray(v));
delete overrides[id];
unwired += 1; left.push({ id: link && (overrides[link[0]] || {}).media_id, at: "a reference", why: `the slot it feeds (${id}) is not in this pipeline` });
}
for (const [id, fields] of Object.entries(overrides)) raw[id] = { ...raw[id], ...fields };
// The prompt enhancer is shown the first picture reference (the picture an image-start model starts from); the aspect source only gives the shape.
// With no picture reference there is nothing to show, whatever picture the pipeline was saved wiring to it.
const shown = firstByNumber((id) => kinds.get(id) === "image" && !leave.has(id));
const holder = shown && Object.keys(overrides).find((k) => overrides[k].media_id === shown);
for (const e of (slots || []).filter((s) => s.node === "FunUIEnhancePrompt")) {
if (holder) put(e, "image", [holder, 0]); else put(e, "use_image", false);
}
// A reference video is got ready as the project (or the scene) says; with nothing switched on it loads as before.
const prepare = activeVideoInput(project);
if (prepare) for (const [id, fields] of Object.entries(overrides)) if (fields.full && fields.media_id && kinds.get(fields.media_id) === "video") raw[id].prepare = videoInputJson(prepare, project, scene);
// Nothing to feed the model (no picture or video where the pipeline looks for one): a role can say what to change so the
// clip is made from the prompt alone -- bypass the anchor, run the whole schedule -- and a plain picture stands in.
let textOnly = false;
const empties = (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.text_fallback && r.input === "media_id" && !(raw[s.id] || {}).media_id).map((role) => ({ slot: s, fb: role.text_fallback })));
for (const { slot, fb } of empties) {
for (const x of fb.set || []) { const t = (slots || []).find((y) => y.id === x.slot); if (t) put(t, x.input, x.value); }
const near = (n, m) => (m > 1 ? Math.max(m, Math.floor(n / m) * m) : n);
if (fb.size) { // the stand-in's size is the latent's own
const from = (slots || []).find((y) => y.id === fb.size.slot) || { inputs: {} };
const dim = (name) => Number((raw[from.id] || {})[name] ?? (from.inputs || {})[name]) || 0;
put(slot, "blank_width", dim(fb.size.width)); put(slot, "blank_height", dim(fb.size.height));
} else if (fb.round_size) {
for (const name of ["blank_width", "blank_height"]) put(slot, name, near(Number((raw[slot.id] || {})[name] ?? (slot.inputs || {})[name]) || 0, fb.round_size));
}
if (fb.round_frames) { const n = Number((raw[slot.id] || {}).blank_frames ?? (slot.inputs || {}).blank_frames) || 0; if (n > 1) put(slot, "blank_frames", fb.round_frames * Math.round((n - 1) / fb.round_frames) + 1); }
textOnly = true;
}
// A picked start picture that no slot takes would otherwise be ignored without a word.
// A video-only start slot (video to video) takes no picture either: the scene's own picture is named, not dropped.
const ignored = scene.source_image && (!source || (videoOnly && startId !== scene.source_image));
if (ignored) left.push({ id: scene.source_image, at: "assets.source_image", why: source ? "this pipeline starts from a video, not a picture" : "this pipeline starts from no picture" });
for (const id of noRoom) { unwired += 1; left.push({ id, at: "assets.reference", what: "a carried picture", why: `no reference number is left after the pinned ones (the highest, ${MAX_REFERENCES}, is taken)` }); }
const prompt = rolesAt(slots, "generation.prompt")[0];
let failedExpand = false;
if (prompt) {
const text = scene.text || "";
const body = await expand({ text, anchor: [...prefix, project.anchor].filter((x) => x && String(x).trim()).join(" "), postfix: project.postfix, postfix_enabled: project.postfix_enabled, variables: project.variables })
.catch(() => null); // a failing prompt-craft feature must not block the run: send what was typed
put(prompt.slot, prompt.role.input, body && typeof body.text === "string" ? body.text : text);
if (!(body && typeof body.text === "string")) failedExpand = true;
}
// Features that add inputs of their own (a Chat comment for the enhancer, say) say so through a hook: { inputs: {slot: {name: value}}, notes: [text] }.
const noVideo = takesVideo && videoOnly && !startId ? ["This pipeline works from a video: pick one as the start, or add it under References."] : [];
const placed = [];
const notes = failedExpand ? ["The prompt could not be expanded (shortcuts, $variables, anchor, postfix): the text was sent as typed."] : [];
if (!textOnly) notes.push(...noVideo);
else notes.push("Nothing to feed the model, so this shot is made from the prompt alone (text to video).");
notes.push(...hookNotes);
// A picture a hook added to the references: where it landed is recorded in the Log (if it found no place, the "did not fit" note says so).
for (const id of carried) {
const slotId = Object.keys(overrides).find((k) => overrides[k].media_id === id);
const at = slotId && ((slots || []).find((x) => x.id === slotId)?.roles || []).map((r) => /^assets\.reference_(\d+)$/.exec(r.at || "")).find(Boolean);
if (at) placed.push({ media_id: id, reference: Number(at[1]) });
}
for (const out of outs) {
for (const [id, fields] of Object.entries(out.inputs || {})) raw[id] = { ...raw[id], ...fields };
notes.push(...(out.notes || []));
}
// Not refused (a picture alone may be the point), but a shot with nothing to go on is said, not silently made.
// Judged on what is SENT (a carried frame counts; a picture t2v blanks, or one this pipeline has no input for, does not).
const picture = (source && (raw[source.slot.id] || {}).media_id) || Object.keys(overrides).length;
if (prompt && !String((raw[prompt.slot.id] || {})[prompt.role.input] ?? "").trim() && !picture) notes.push("This shot has no text and no start picture, so the model makes whatever it likes.");
return { inputs: raw, unwired: unwired + (ignored && !source ? 1 : 0), unusedPicture: ignored && source ? scene.source_image : null, left, noPrompt: !prompt, notes, placed };
}