Fast_deploy / ComfyUI-MiniMaxH3-Context-Loop /web /h3_chain_plan_core.mjs
dzmdd's picture
Upload folder using huggingface_hub (part 3)
8fb6433 verified
Raw History Blame Contribute Delete
76.3 kB
// Pure data helpers for the H3 Chain Plan editor. Keep this module free of
// ComfyUI/browser dependencies so its timing and serialization can be tested.
export const FPS = 24;
export const MAX_SHOTS = 128;
export const MAX_CHAPTERS = MAX_SHOTS;
export const MAX_H3_FRAMES = 3592;
export const MAX_SEED = 18446744073709551615n;
export const CONTINUATION_MODES = Object.freeze([
"guide", "tone_carry_guide", "latent_guide", "tapered_guide",
"masked_av", "tapered_av", "feathered_av", "audio_feathered_av",
"drift_control_av", "color_stable_drift_av",
]);
export const CONTEXT_SPATIAL_PROXY_MODES = Object.freeze([
"off", "rgb_5_6", "latent_5_6",
]);
export const SCENE_LORA_ROUTES = Object.freeze([
"base", ..."abcdefghijklmnopqrstuvwxyz",
]);
export const SCENE_PROMPT_SEED_MODES = Object.freeze([
"inherit", "fixed", "randomize",
]);
const RETIRED_CONTINUATION_MODES = Object.freeze({
feathered_av_rgb: "feathered_av",
});
export const H3_CONTEXT_LENGTHS = Object.freeze([
1, 5, 22, 39, 56, 73, 90, 107, 124,
141, 158, 175, 192, 209, 226, 243,
]);
export const AV_CONTEXT_LENGTHS = Object.freeze([39, 90, 141, 192, 243]);
export const VIDEO_ONLY_AV_CONTEXT_LENGTHS = Object.freeze(
H3_CONTEXT_LENGTHS.filter((value) => value >= 5),
);
export const AUTO_SCENE_COLORS = Object.freeze([
"#6ea8fe", "#ffb86b", "#63d69f", "#c493ff",
"#ff7fa6", "#55d6e8", "#e6cb65", "#ff7878",
"#83c5ff", "#b7db68", "#d991ff", "#72d4c2",
]);
export function automaticSceneColor(index) {
const ordinal = Number.isFinite(Number(index)) ? Math.trunc(Number(index)) : 0;
return AUTO_SCENE_COLORS[((ordinal % AUTO_SCENE_COLORS.length)
+ AUTO_SCENE_COLORS.length) % AUTO_SCENE_COLORS.length];
}
function normalizedSeedInteger(value, label = "Seed") {
try {
const seed = typeof value === "bigint" ? value : BigInt(value ?? 0);
if (seed < 0n || seed > MAX_SEED) throw new Error();
return seed;
} catch (_error) {
throw new Error(`${label} must be an unsigned 64-bit integer.`);
}
}
const SHA256_CONSTANTS = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function rotateRight(value, bits) {
return (value >>> bits) | (value << (32 - bits));
}
function sha256Fallback(bytes) {
const length = Math.ceil((bytes.length + 9) / 64) * 64;
const padded = new Uint8Array(length);
padded.set(bytes);
padded[bytes.length] = 0x80;
const bitLength = BigInt(bytes.length) * 8n;
const view = new DataView(padded.buffer);
view.setUint32(length - 8, Number((bitLength >> 32n) & 0xffffffffn));
view.setUint32(length - 4, Number(bitLength & 0xffffffffn));
const state = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const words = new Uint32Array(64);
for (let offset = 0; offset < length; offset += 64) {
for (let index = 0; index < 16; index += 1) {
words[index] = view.getUint32(offset + index * 4);
}
for (let index = 16; index < 64; index += 1) {
const left = words[index - 15];
const right = words[index - 2];
const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3);
const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10);
words[index] = (words[index - 16] + sigma0
+ words[index - 7] + sigma1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = state;
for (let index = 0; index < 64; index += 1) {
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const choose = (e & f) ^ (~e & g);
const temporary1 = (h + sum1 + choose
+ SHA256_CONSTANTS[index] + words[index]) >>> 0;
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const majority = (a & b) ^ (a & c) ^ (b & c);
const temporary2 = (sum0 + majority) >>> 0;
h = g;
g = f;
f = e;
e = (d + temporary1) >>> 0;
d = c;
c = b;
b = a;
a = (temporary1 + temporary2) >>> 0;
}
for (const [index, value] of [a, b, c, d, e, f, g, h].entries()) {
state[index] = (state[index] + value) >>> 0;
}
}
const digest = new Uint8Array(32);
const digestView = new DataView(digest.buffer);
state.forEach((value, index) => digestView.setUint32(index * 4, value));
return digest;
}
export async function derivedSceneSeed(
baseSeed, index, shotId, cryptoSource = globalThis.crypto,
) {
const seedBase = normalizedSeedInteger(baseSeed, "Base seed");
const ordinal = Number(index);
if (!Number.isInteger(ordinal) || ordinal < 1) {
throw new Error("Scene index must be a positive integer.");
}
const payload = `${seedBase}:${ordinal}:${String(shotId)}`;
const encoded = new TextEncoder().encode(payload);
const digest = typeof cryptoSource?.subtle?.digest === "function"
? new Uint8Array(await cryptoSource.subtle.digest("SHA-256", encoded))
: sha256Fallback(encoded);
const bytes = digest.subarray(0, 8);
let result = 0n;
for (const byte of bytes) result = (result << 8n) | BigInt(byte);
return result.toString();
}
export function randomSceneSeed(randomSource = globalThis.crypto) {
if (typeof randomSource?.getRandomValues !== "function") {
throw new Error("Secure browser randomness is unavailable.");
}
const words = new Uint32Array(2);
randomSource.getRandomValues(words);
return ((BigInt(words[0]) << 32n) | BigInt(words[1])).toString();
}
export function scenePromptSeedMode(shot = {}) {
const raw = shot?.prompt_seed_mode;
if (raw == null || String(raw).trim() === "") {
return Object.hasOwn(shot ?? {}, "prompt_seed") ? "fixed" : "inherit";
}
const mode = String(raw).trim().toLowerCase();
if (!SCENE_PROMPT_SEED_MODES.includes(mode)) {
throw new Error(
`Prompt seed mode must be one of ${SCENE_PROMPT_SEED_MODES.join(", ")}.`,
);
}
return mode;
}
export function setScenePromptSeedMode(
shot, requested, randomSource = globalThis.crypto,
) {
const mode = String(requested ?? "inherit").trim().toLowerCase();
if (!SCENE_PROMPT_SEED_MODES.includes(mode)) {
throw new Error(
`Prompt seed mode must be one of ${SCENE_PROMPT_SEED_MODES.join(", ")}.`,
);
}
if (mode === "inherit") {
delete shot.prompt_seed_mode;
delete shot.prompt_seed;
} else if (mode === "randomize") {
shot.prompt_seed_mode = "randomize";
delete shot.prompt_seed;
} else {
shot.prompt_seed_mode = "fixed";
if (!Object.hasOwn(shot, "prompt_seed")) {
shot.prompt_seed = randomSceneSeed(randomSource);
} else {
shot.prompt_seed = normalizedSeedInteger(
shot.prompt_seed, "Prompt seed",
).toString();
}
}
return mode;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function protectSeedIntegers(source) {
// JSON.parse rounds integers above 2^53. The Python node accepts seed
// strings, so quote numeric `seed` values before parsing and preserve all
// uint64 digits exactly. This scanner only touches actual JSON object keys,
// never text that happens to contain `\"seed\": 123` inside a prompt.
let output = "";
let index = 0;
while (index < source.length) {
if (source[index] !== '"') {
output += source[index++];
continue;
}
const start = index;
index += 1;
while (index < source.length) {
if (source[index] === "\\") {
index += 2;
continue;
}
if (source[index] === '"') {
index += 1;
break;
}
index += 1;
}
const token = source.slice(start, index);
output += token;
let key;
try {
key = JSON.parse(token);
} catch (_error) {
continue;
}
if (key !== "seed" && key !== "prompt_seed") {
continue;
}
let cursor = index;
while (/\s/.test(source[cursor] || "")) cursor += 1;
if (source[cursor] !== ":") continue;
cursor += 1;
while (/\s/.test(source[cursor] || "")) cursor += 1;
const match = source.slice(cursor).match(/^-?\d+(?=\s*[,}])/);
if (!match) continue;
output += source.slice(index, cursor);
output += JSON.stringify(match[0]);
index = cursor + match[0].length;
}
return output;
}
export function promptValueToText(value, label = "prompt") {
if (Array.isArray(value)) {
if (!value.every((line) => typeof line === "string")) {
throw new Error(`${label} line arrays may contain only strings.`);
}
return value.join("\n");
}
if (value === undefined || value === null) return "";
return String(value);
}
export function promptTextToLines(value) {
return String(value ?? "").replace(/\r\n?/g, "\n").split("\n");
}
export function parsePlanJson(source) {
let raw;
try {
raw = JSON.parse(protectSeedIntegers(String(source ?? "")));
} catch (error) {
throw new Error(`Invalid plan JSON: ${error.message}`);
}
if (Array.isArray(raw)) raw = {shots: raw};
if (!raw || typeof raw !== "object") {
throw new Error("The plan must be an object or a bare list of scenes.");
}
if (!Array.isArray(raw.shots) || raw.shots.length === 0) {
throw new Error("The plan needs at least one scene in shots.");
}
if (raw.shots.length > MAX_SHOTS) {
throw new Error(`The plan supports at most ${MAX_SHOTS} scenes.`);
}
const plan = clone(raw);
const hasTopLevelDefaults = Object.hasOwn(plan, "duration_seconds")
|| Object.hasOwn(plan, "steps");
if (hasTopLevelDefaults) {
const defaults = plan.defaults
&& typeof plan.defaults === "object"
&& !Array.isArray(plan.defaults)
? {...plan.defaults} : {};
// Accept the common human-authored shorthand and serialize it back in
// the canonical shape. Explicit defaults.* values win when both forms
// are present.
if (!Object.hasOwn(defaults, "duration_seconds")
&& Object.hasOwn(plan, "duration_seconds")) {
defaults.duration_seconds = plan.duration_seconds;
}
if (!Object.hasOwn(defaults, "steps") && Object.hasOwn(plan, "steps")) {
defaults.steps = plan.steps;
}
plan.defaults = defaults;
delete plan.duration_seconds;
delete plan.steps;
}
plan.shots = plan.shots.map((shot, offset) => {
if (typeof shot === "string") {
return {prompt: promptTextToLines(shot)};
}
if (!shot || typeof shot !== "object" || Array.isArray(shot)) {
throw new Error(`Scene ${offset + 1} must be an object or prompt string.`);
}
const normalized = {...shot};
normalized.prompt = promptTextToLines(
promptValueToText(shot.prompt, `Scene ${offset + 1} prompt`),
);
if (Object.hasOwn(normalized, "lora_route")) {
const route = sceneLoRARoute(normalized);
if (route === "base") delete normalized.lora_route;
else normalized.lora_route = route;
}
if (Object.hasOwn(normalized, "prompt_seed_mode")
|| Object.hasOwn(normalized, "prompt_seed")) {
const mode = scenePromptSeedMode(normalized);
if (mode === "inherit") {
delete normalized.prompt_seed_mode;
delete normalized.prompt_seed;
} else if (mode === "randomize") {
normalized.prompt_seed_mode = "randomize";
delete normalized.prompt_seed;
} else {
if (!Object.hasOwn(normalized, "prompt_seed")) {
throw new Error(
`Scene ${offset + 1} fixed prompt seed needs prompt_seed.`,
);
}
normalized.prompt_seed_mode = "fixed";
normalized.prompt_seed = normalizedSeedInteger(
normalized.prompt_seed,
`Scene ${offset + 1} prompt seed`,
).toString();
}
}
return normalized;
});
const shotIds = plan.shots.map((shot, offset) => safeShotId(
shot?.id,
`clip_${String(offset + 1).padStart(4, "0")}`,
));
const shotIdSet = new Set(shotIds);
if (Object.hasOwn(plan, "chapters")) {
if (!Array.isArray(plan.chapters)) {
throw new Error("chapters must be a list.");
}
if (plan.chapters.length > MAX_CHAPTERS) {
throw new Error(`The plan supports at most ${MAX_CHAPTERS} chapters.`);
}
const usedIds = new Set();
const usedStarts = new Set();
plan.chapters = plan.chapters.map((chapter, offset) => {
if (!chapter || typeof chapter !== "object" || Array.isArray(chapter)) {
throw new Error(`Chapter ${offset + 1} must be an object.`);
}
const fallbackId = `chapter_${String(offset + 1).padStart(2, "0")}`;
const id = safeChapterId(chapter.id, fallbackId);
if (usedIds.has(id)) throw new Error(`Duplicate chapter id: ${id}.`);
usedIds.add(id);
const startSceneId = safeShotId(
chapter.start_scene_id,
shotIds[Math.min(offset, shotIds.length - 1)],
);
if (!shotIdSet.has(startSceneId)) {
throw new Error(
`Chapter ${offset + 1} starts at missing scene ${startSceneId}.`,
);
}
if (usedStarts.has(startSceneId)) {
throw new Error(`Only one chapter may start at scene ${startSceneId}.`);
}
usedStarts.add(startSceneId);
return {
id,
title: String(chapter.title ?? `Chapter ${offset + 1}`).trim()
.slice(0, 160) || `Chapter ${offset + 1}`,
start_scene_id: startSceneId,
text: promptValueToText(chapter.text ?? chapter.notes ?? "", `Chapter ${offset + 1} text`),
...(chapter.resolution != null
? {resolution:normalizeChapterResolution(chapter.resolution)} : {}),
};
}).sort((left, right) => (
shotIds.indexOf(left.start_scene_id) - shotIds.indexOf(right.start_scene_id)
));
if (plan.chapters.length === 0) delete plan.chapters;
}
if (Object.hasOwn(plan, "prompt_prefix")) {
plan.prompt_prefix = promptTextToLines(
promptValueToText(plan.prompt_prefix, "prompt_prefix"),
);
} else if (Object.hasOwn(plan, "global_prompt")) {
plan.global_prompt = promptTextToLines(
promptValueToText(plan.global_prompt, "global_prompt"),
);
}
return plan;
}
export function planToJson(plan) {
return JSON.stringify(plan, null, 2);
}
export function sharedPrompt(plan) {
const key = Object.hasOwn(plan, "prompt_prefix")
? "prompt_prefix"
: Object.hasOwn(plan, "global_prompt") ? "global_prompt" : "prompt_prefix";
return {key, text: promptValueToText(plan[key] ?? "", key)};
}
export function setSharedPrompt(plan, text) {
const current = sharedPrompt(plan);
plan[current.key] = promptTextToLines(text);
}
export function safeShotId(value, fallback) {
let text = String(value ?? "").trim().replace(/[^A-Za-z0-9._-]+/g, "_");
text = text.replace(/^[._-]+|[._-]+$/g, "");
return (text || fallback).slice(0, 96);
}
export function renamePlanShot(plan, index, requestedId) {
const shots = Array.isArray(plan?.shots) ? plan.shots : [];
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= shots.length) {
throw new Error("Scene rename target is outside the Plan.");
}
const shot = shots[position];
const fallback = `clip_${String(position + 1).padStart(4, "0")}`;
const previousId = safeShotId(shot?.id, fallback);
const nextId = safeShotId(requestedId, previousId);
const duplicate = shots.some((candidate, offset) => (
offset !== position && safeShotId(
candidate?.id,
`clip_${String(offset + 1).padStart(4, "0")}`,
) === nextId
));
if (duplicate) {
throw new Error(`Another scene already uses the ID “${nextId}”.`);
}
if (nextId === previousId) return {previousId, id:nextId, changed:false};
shot.id = nextId;
for (const chapter of Array.isArray(plan?.chapters) ? plan.chapters : []) {
if (String(chapter?.start_scene_id ?? "") === previousId) {
chapter.start_scene_id = nextId;
}
}
// Authored visual-context links use stable scene IDs. Numeric spellings
// deliberately remain scene indexes and must not be rewritten as IDs.
if (!/^\d+$/.test(previousId)) {
for (const candidate of shots) {
for (const field of [
"visual_context_source", "visual_context_lead_source",
"audio_context_source", "audio_context_lead_source",
]) {
if (String(candidate?.[field] ?? "").trim() === previousId) {
candidate[field] = nextId;
}
}
for (const block of Array.isArray(
candidate?.visual_context_blocks,
) ? candidate.visual_context_blocks : []) {
if (String(block?.source ?? "").trim() === previousId) {
block.source = nextId;
}
}
}
}
return {previousId, id:nextId, changed:true};
}
export function normalizeChapterResolution(value) {
if (value == null) return null;
if (typeof value !== "object" || Array.isArray(value)
|| Object.keys(value).length !== 2
|| !["width", "height"].every((key) => (
Number.isInteger(value[key]) && value[key] >= 32
&& value[key] <= 16384 && value[key] % 32 === 0
))) {
throw new Error("Chapter width and height must be multiples of 32 between 32 and 16384.");
}
return {width:value.width, height:value.height};
}
export function safeChapterId(value, fallback = "chapter") {
return safeShotId(value, fallback);
}
export function orderedChapters(plan) {
const shots = Array.isArray(plan?.shots) ? plan.shots : [];
const order = new Map(shots.map((shot, offset) => [
safeShotId(shot?.id, `clip_${String(offset + 1).padStart(4, "0")}`),
offset,
]));
return (Array.isArray(plan?.chapters) ? plan.chapters : [])
.filter((chapter) => order.has(chapter?.start_scene_id))
.slice()
.sort((left, right) => (
order.get(left.start_scene_id) - order.get(right.start_scene_id)
));
}
export function uniqueChapterId(chapters, requested = "chapter") {
const used = new Set((chapters ?? []).map((chapter, offset) => safeChapterId(
chapter?.id,
`chapter_${String(offset + 1).padStart(2, "0")}`,
)));
const base = safeChapterId(requested, "chapter");
if (!used.has(base)) return base;
for (let suffix = 2; suffix <= MAX_CHAPTERS + 1; suffix += 1) {
const candidate = `${base}_${suffix}`.slice(0, 96);
if (!used.has(candidate)) return candidate;
}
return `${base}_${Date.now()}`.slice(0, 96);
}
export function makeChapter(plan, startIndex = 0) {
const shots = Array.isArray(plan?.shots) ? plan.shots : [];
const index = Math.max(0, Math.min(shots.length - 1, Number(startIndex) || 0));
const startSceneId = safeShotId(
shots[index]?.id,
`clip_${String(index + 1).padStart(4, "0")}`,
);
const chapters = Array.isArray(plan.chapters) ? plan.chapters : [];
if (chapters.some((chapter) => chapter?.start_scene_id === startSceneId)) {
throw new Error(`A chapter already starts before scene ${index + 1}.`);
}
const ordinal = chapters.length + 1;
const chapter = {
id: uniqueChapterId(chapters, `chapter_${String(ordinal).padStart(2, "0")}`),
title: `Chapter ${ordinal}`,
start_scene_id: startSceneId,
text: "",
};
plan.chapters = [...chapters, chapter];
return chapter;
}
export function removePlanShot(plan, index) {
const shots = Array.isArray(plan?.shots) ? plan.shots : [];
if (index < 0 || index >= shots.length) return null;
const removedId = safeShotId(
shots[index]?.id,
`clip_${String(index + 1).padStart(4, "0")}`,
);
const [removed] = shots.splice(index, 1);
if (Array.isArray(plan.chapters)) {
const replacement = shots[index] ?? shots[index - 1] ?? null;
const replacementId = replacement ? safeShotId(
replacement.id,
`clip_${String(Math.max(1, index + 1)).padStart(4, "0")}`,
) : null;
const occupied = new Set(plan.chapters
.filter((chapter) => chapter.start_scene_id !== removedId)
.map((chapter) => chapter.start_scene_id));
plan.chapters = plan.chapters.filter((chapter) => {
if (chapter.start_scene_id !== removedId) return true;
if (!replacementId || occupied.has(replacementId)) return false;
chapter.start_scene_id = replacementId;
occupied.add(replacementId);
return true;
});
if (plan.chapters.length === 0) delete plan.chapters;
}
return removed;
}
export function uniqueShotId(shots, requested = "scene") {
const used = new Set(shots.map((shot, offset) => safeShotId(
shot?.id,
`clip_${String(offset + 1).padStart(4, "0")}`,
)));
const base = safeShotId(requested, "scene");
if (!used.has(base)) return base;
for (let suffix = 2; suffix <= MAX_SHOTS + 1; suffix += 1) {
const tail = `_${suffix}`;
const candidate = `${base.slice(0, 96 - tail.length)}${tail}`;
if (!used.has(candidate)) return candidate;
}
return `${base}_${Date.now()}`.slice(0, 96);
}
export function makeShot(shots = []) {
const ordinal = shots.length + 1;
return {
id: uniqueShotId(shots, `scene_${String(ordinal).padStart(2, "0")}`),
prompt: ["Describe this scene."],
};
}
export function duplicateShot(shots, index) {
if (!Array.isArray(shots) || !Number.isInteger(index)
|| index < 0 || index >= shots.length) {
throw new Error("Scene duplication target is outside the Plan.");
}
const original = [...shots];
const ids = original.map((shot, offset) => safeShotId(
shot.id, `clip_${String(offset + 1).padStart(4, "0")}`,
));
const duplicated = clone(shots[index]);
duplicated.id = uniqueShotId(shots, `${safeShotId(
duplicated.id,
`scene_${String(index + 1).padStart(2, "0")}`,
)}_copy`);
// Pin legacy implicit identities before insertion shifts their positions.
// Existing ID-based links and delegated prompts must keep naming the same
// original scene, not the new occupant of its old index.
original.forEach((shot, offset) => {
if (!String(shot.id ?? "").trim()) shot.id = ids[offset];
});
shots.splice(index + 1, 0, duplicated);
const authored = value => value !== undefined && value !== null
&& !(typeof value === "string" && !value.trim());
function remap(shot, oldIndex, newIndex) {
function source(holder, key, followsPrevious = false) {
const raw = holder[key];
const numeric = typeof raw === "number" || (
typeof raw === "string" && /^\d+$/.test(raw.trim())
);
let prior;
if (numeric && Number.isInteger(Number(raw))) prior = Number(raw) - 1;
else if (typeof raw === "string") {
if (["", "previous", "immediate"].includes(raw.trim().toLowerCase())) return;
prior = ids.indexOf(raw.trim());
if (prior !== ids.lastIndexOf(raw.trim())) return;
} else return;
// Do not silently repair invalid authored references.
if (prior < 0 || prior >= oldIndex || !Number.isInteger(prior)) return;
const shifted = prior + (prior > index ? 1 : 0);
const next = followsPrevious && prior === oldIndex - 1
? newIndex - 1 : shifted;
if (numeric) {
if (next !== prior) holder[key] = typeof raw === "number" ? next + 1 : String(next + 1);
} else if (followsPrevious && prior === oldIndex - 1
&& next !== shifted) {
// Purely numeric IDs are interpreted as positions by the
// validator, so use a numeric position for those targets.
const id = safeShotId(
shots[next].id, `clip_${String(next + 1).padStart(4, "0")}`,
);
holder[key] = /^\d+$/.test(String(id).trim()) ? next + 1 : id;
}
}
for (const kind of ["visual", "audio"]) {
// A plain predecessor tail follows the newly inserted scene.
// Explicit windows, composed context and deliberately older
// sources retain their original clip identities and frame ranges.
const linear = !authored(shot[`${kind}_context_start_frame`])
&& !authored(shot[`${kind}_context_lead_source`])
&& !Object.hasOwn(shot, `${kind}_context_blocks`);
source(shot, `${kind}_context_source`, linear);
source(shot, `${kind}_context_lead_source`);
}
if (Array.isArray(shot.visual_context_blocks)) {
for (const block of shot.visual_context_blocks) {
if (!block || typeof block !== "object" || Array.isArray(block)) continue;
source(block, "source", shot.visual_context_blocks.length === 1
&& !authored(block.start_frame));
}
}
}
original.forEach((shot, offset) => remap(
shot, offset, offset + (offset > index ? 1 : 0),
));
remap(duplicated, index, index + 1);
return duplicated;
}
export function moveShot(shots, from, to) {
if (from === to || from < 0 || from >= shots.length || to < 0 || to >= shots.length) {
return;
}
const [shot] = shots.splice(from, 1);
shots.splice(to, 0, shot);
}
export function h3FrameLength(seconds) {
const numeric = Number(seconds);
if (!Number.isFinite(numeric) || numeric <= 0) {
throw new Error("Duration must be a finite positive number.");
}
const requested = Math.max(5, Math.ceil(numeric * FPS - 1e-9));
const length = requested + ((5 - (requested % 17)) % 17);
if (length > MAX_H3_FRAMES) {
throw new Error(
`Duration rounds to ${length} frames; H3's largest valid length is ${MAX_H3_FRAMES}.`,
);
}
return length;
}
export function shotLengthMode(shot) {
if (shot?.length !== undefined || shot?.frames !== undefined) return "frames";
if (shot?.duration_seconds !== undefined) return "seconds";
return "default";
}
export function setShotLengthMode(shot, mode, fallbackSeconds = 15) {
if (!shot || typeof shot !== "object" || Array.isArray(shot)) {
throw new Error("Scene length settings require a scene object.");
}
if (!["default", "seconds", "frames"].includes(mode)) {
throw new Error(`Unknown scene length mode “${mode}”.`);
}
const currentMode = shotLengthMode(shot);
if (currentMode === mode) return shot;
let currentSeconds;
if (currentMode === "frames") {
const frames = Number(shot.length ?? shot.frames);
currentSeconds = Number.isFinite(frames) && frames > 0
? frames / FPS : Number(fallbackSeconds);
} else if (currentMode === "seconds") {
currentSeconds = Number(shot.duration_seconds);
} else {
currentSeconds = Number(fallbackSeconds);
}
if (!Number.isFinite(currentSeconds) || currentSeconds <= 0) {
currentSeconds = 15;
}
// Compute the replacement before deleting the active representation. If
// conversion fails (for example an out-of-range duration), the scene stays
// untouched and the editor can continue to report the original problem.
const replacement = mode === "frames" ? h3FrameLength(currentSeconds)
: mode === "seconds" ? currentSeconds : null;
delete shot.length;
delete shot.frames;
delete shot.duration_seconds;
if (mode === "frames") shot.length = replacement;
if (mode === "seconds") shot.duration_seconds = replacement;
return shot;
}
export function sceneContinuationMode(shot, planDefault = "guide") {
const rawFallback = String(planDefault ?? "guide");
const fallback = RETIRED_CONTINUATION_MODES[rawFallback] ?? rawFallback;
if (!CONTINUATION_MODES.includes(fallback)) {
throw new Error(`Unknown Plan continuation mode “${fallback}”.`);
}
const rawMode = shot?.continuation_mode ?? fallback;
const mode = RETIRED_CONTINUATION_MODES[rawMode] ?? rawMode;
if (!CONTINUATION_MODES.includes(mode)) {
throw new Error(`Unknown scene continuation mode “${String(mode)}”.`);
}
return mode;
}
export function sceneLoRARoute(shot) {
const route = String(shot?.lora_route ?? "base").trim().toLowerCase()
|| "base";
if (!SCENE_LORA_ROUTES.includes(route)) {
throw new Error(
`Scene LoRA route must be one of ${SCENE_LORA_ROUTES.join(", ")}.`,
);
}
return route;
}
export function sceneContextLength(shot, planDefault = 22) {
const fallback = Number(planDefault);
if (!H3_CONTEXT_LENGTHS.includes(fallback)) {
throw new Error(`Unknown Plan context length “${String(planDefault)}”.`);
}
const value = shot?.context_length;
if (value === undefined || value === null
|| (typeof value === "string" && !value.trim())) return fallback;
const resolved = Number(value);
if (typeof value === "boolean" || !Number.isInteger(resolved)
|| (resolved !== 0 && !H3_CONTEXT_LENGTHS.includes(resolved))) {
throw new Error(
`Scene context length must be 0 or one of ${H3_CONTEXT_LENGTHS.join(", ")}.`,
);
}
return resolved;
}
function resolvePriorSceneSource(plan, index, raw, field, defaultPrevious) {
const shots = plan?.shots;
const target = Number(index);
if (!Array.isArray(shots) || !Number.isInteger(target)
|| target < 1 || target > shots.length) {
throw new Error(`${field} has an invalid target scene.`);
}
if (target === 1) return null;
if (raw === undefined || raw === null
|| (typeof raw === "string" && [
"", "previous", "immediate",
].includes(raw.trim().toLowerCase()))) {
return defaultPrevious ? target - 1 : null;
}
if (typeof raw === "boolean") {
throw new Error(
`${field} must name an earlier scene ID or index.`,
);
}
let source = null;
const numeric = Number(raw);
if ((typeof raw === "number" || (typeof raw === "string"
&& /^\d+$/.test(raw.trim()))) && Number.isInteger(numeric)) {
source = numeric;
} else if (typeof raw === "string") {
const wanted = raw.trim();
const matches = shots.map((shot, offset) => safeShotId(
shot?.id, `clip_${String(offset + 1).padStart(4, "0")}`,
) === wanted ? offset + 1 : null).filter(Boolean);
if (matches.length === 1) source = matches[0];
}
if (source === null) {
throw new Error(
`${field} “${String(raw)}” does not match a scene ID or index.`,
);
}
if (source < 1 || source >= target) {
throw new Error(
`${field} for scene ${target} must point to an earlier scene.`,
);
}
return source;
}
export function sceneVisualContextSource(plan, index) {
return resolvePriorSceneSource(
plan, index, plan?.shots?.[Number(index) - 1]?.visual_context_source,
"Visual context source", true,
);
}
export function sceneVisualContextLeadSource(plan, index) {
return resolvePriorSceneSource(
plan, index,
plan?.shots?.[Number(index) - 1]?.visual_context_lead_source,
"Composed context lead source", false,
);
}
export function sceneVisualContextLeadFrames(shot, contextLength) {
const raw = shot?.visual_context_lead_frames;
if (raw === undefined || raw === null
|| (typeof raw === "string" && !raw.trim())) return 0;
const resolved = Number(raw);
const allowed = visualContextLeadFrameOptions(contextLength);
if (typeof raw === "boolean" || !Number.isInteger(resolved)
|| !allowed.includes(resolved)) {
throw new Error(
`Composed context lead frames must be one of ${allowed.join(", ")} and smaller than this scene's ${contextLength}-frame total.`,
);
}
return resolved;
}
export function visualContextBoundaryFrames(contextLength) {
const total = Number(contextLength);
if (!Number.isInteger(total) || total < 1) return Object.freeze([]);
const boundaries = [];
for (let frame = 1; frame < total; frame += 1) {
if (h3NativeFrameBoundaryStep(frame) !== null) boundaries.push(frame);
}
return Object.freeze(boundaries);
}
export function visualContextMaximumBlocks(contextLength) {
const total = Number(contextLength);
if (!Number.isInteger(total) || total < 1) return 0;
return visualContextBoundaryFrames(total).length + 1;
}
export function visualContextPartitionFromBoundaries(
contextLength, rawBoundaries,
) {
const total = Number(contextLength);
const boundaries = Array.from(rawBoundaries ?? [], Number);
if (!Number.isInteger(total) || total < 1) {
throw new Error("Visual context partition requires a positive total.");
}
let previous = 0;
const frames = [];
const allowed = new Set(visualContextBoundaryFrames(total));
for (const boundary of boundaries) {
if (!Number.isInteger(boundary) || boundary <= previous
|| boundary >= total || !allowed.has(boundary)) {
throw new Error(
`Visual context boundary ${String(boundary)} is not an ordered H3 latent boundary inside ${total} frames.`,
);
}
frames.push(boundary - previous);
previous = boundary;
}
frames.push(total - previous);
return Object.freeze(frames);
}
export function visualContextDefaultPartition(contextLength, blockCount) {
const total = Number(contextLength);
const count = Number(blockCount);
const candidates = visualContextBoundaryFrames(total);
if (!Number.isInteger(count) || count < 1
|| count > candidates.length + 1) {
throw new Error(
`${total}-frame visual context supports between 1 and ${candidates.length + 1} ordered blocks.`,
);
}
if (count === 1) return Object.freeze([total]);
// Choose monotonic H3 boundaries nearest evenly spaced target positions.
// This gives a stable, balanced starting point without enumerating the
// combinatorial set of every possible N-way partition.
const selected = [];
let minimumIndex = 0;
for (let cut = 1; cut < count; cut += 1) {
const remainingCuts = count - cut - 1;
const maximumIndex = candidates.length - remainingCuts - 1;
const wanted = total * cut / count;
let chosen = minimumIndex;
for (let index = minimumIndex; index <= maximumIndex; index += 1) {
if (Math.abs(candidates[index] - wanted)
< Math.abs(candidates[chosen] - wanted)) chosen = index;
}
selected.push(candidates[chosen]);
minimumIndex = chosen + 1;
}
return visualContextPartitionFromBoundaries(total, selected);
}
function visualContextBlockStartValue(
value, rawFrames, deliveredFrames, spanFrames, prefixFrames, label,
) {
const holder = {};
if (value !== undefined && value !== null
&& !(typeof value === "string" && !value.trim())) {
holder.visual_context_start_frame = value;
}
try {
return sceneVisualContextStartFrame(
holder, rawFrames, deliveredFrames, spanFrames, false,
prefixFrames,
);
} catch (error) {
throw new Error(`${label}: ${error.message}`);
}
}
export function sceneVisualContextBlocks(plan, index, contextLength) {
const shots = plan?.shots;
const target = Number(index);
const total = Number(contextLength);
if (!Array.isArray(shots) || !Number.isInteger(target)
|| target < 1 || target > shots.length) {
throw new Error("Visual context blocks have an invalid target scene.");
}
if (!Number.isInteger(total) || total < 0) {
throw new Error("Visual context blocks require a valid context total.");
}
const shot = shots[target - 1] ?? {};
if (target === 1 || total === 0) {
if (Object.hasOwn(shot, "visual_context_blocks")) {
throw new Error(
"visual_context_blocks requires a scene after scene 1 with positive visual context.",
);
}
return Object.freeze([]);
}
if (Object.hasOwn(shot, "visual_context_blocks")) {
const legacyFields = [
"visual_context_source", "visual_context_start_frame",
"visual_context_lead_source", "visual_context_lead_frames",
"visual_context_lead_start_frame",
].filter((field) => Object.hasOwn(shot, field));
if (legacyFields.length) {
throw new Error(
`visual_context_blocks cannot be combined with legacy fields: ${legacyFields.join(", ")}.`,
);
}
if (!Array.isArray(shot.visual_context_blocks)
|| !shot.visual_context_blocks.length) {
throw new Error(
"visual_context_blocks must contain at least one ordered block.",
);
}
const blocks = [];
let consumed = 0;
for (let offset = 0; offset < shot.visual_context_blocks.length;
offset += 1) {
const raw = shot.visual_context_blocks[offset];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(
`Visual context block ${offset + 1} must be an object.`,
);
}
const frames = Number(raw.frames);
if (!Number.isInteger(frames) || frames < 1) {
throw new Error(
`Visual context block ${offset + 1} frames must be a positive integer.`,
);
}
const source = resolvePriorSceneSource(
plan, target, raw.source,
`Visual context block ${offset + 1} source`, true,
);
const startValue = raw.start_frame;
if (startValue !== undefined && startValue !== null
&& (!(Number.isInteger(Number(startValue)))
|| Number(startValue) < 0)) {
throw new Error(
`Visual context block ${offset + 1} start_frame must be a non-negative integer.`,
);
}
consumed += frames;
if (consumed < total
&& h3NativeFrameBoundaryStep(consumed) === null) {
throw new Error(
`Visual context block ${offset + 1} ends at frame ${consumed}, which is not an H3 latent boundary.`,
);
}
blocks.push(Object.freeze({
source,
sourceId:safeShotId(
shots[source - 1]?.id,
`clip_${String(source).padStart(4, "0")}`,
),
frames,
startFrame:startValue === undefined || startValue === null
|| (typeof startValue === "string" && !startValue.trim())
? null : Number(startValue),
}));
}
if (consumed !== total) {
throw new Error(
`Visual context blocks total ${consumed} frames; this scene requires ${total}.`,
);
}
if (total !== 1 && h3NativeFrameBoundaryStep(total) === null) {
throw new Error(
`${total}-frame visual context does not end on H3's latent lattice.`,
);
}
return Object.freeze(blocks);
}
// Released one/two-block plans remain byte-for-byte unchanged on disk.
// Expose them as a list only to the new planner/runtime adapter.
const recentSource = sceneVisualContextSource(plan, target);
const leadSource = sceneVisualContextLeadSource(plan, target);
const leadFrames = leadSource === null
? 0 : sceneVisualContextLeadFrames(shot, total);
const result = [];
if (leadSource !== null) result.push(Object.freeze({
source:leadSource,
sourceId:safeShotId(
shots[leadSource - 1]?.id,
`clip_${String(leadSource).padStart(4, "0")}`,
),
frames:leadFrames,
startFrame:Object.hasOwn(shot, "visual_context_lead_start_frame")
? Number(shot.visual_context_lead_start_frame) : null,
}));
result.push(Object.freeze({
source:recentSource,
sourceId:safeShotId(
shots[recentSource - 1]?.id,
`clip_${String(recentSource).padStart(4, "0")}`,
),
frames:total - leadFrames,
startFrame:Object.hasOwn(shot, "visual_context_start_frame")
? Number(shot.visual_context_start_frame) : null,
}));
return Object.freeze(result);
}
export function h3NativeFrameBoundaryStep(frame) {
const resolved = Number(frame);
if (!Number.isInteger(resolved) || resolved < 0) return null;
if (resolved % 17 === 0) return 5 * (resolved / 17);
if (resolved >= 5 && (resolved - 5) % 17 === 0) {
return 2 + 5 * ((resolved - 5) / 17);
}
return null;
}
export function nativeContextWindowStarts(
rawFrames, deliveredFrames, spanFrames, prefixFrames = 0,
) {
const raw = Number(rawFrames);
const delivered = Number(deliveredFrames);
const span = Number(spanFrames);
const prefix = Number(prefixFrames);
if (!Number.isInteger(raw) || !Number.isInteger(delivered)
|| !Number.isInteger(span) || !Number.isInteger(prefix)
|| raw < delivered || delivered < 1 || span < 1
|| span > delivered || prefix < 0) return Object.freeze([]);
const latest = delivered - span;
if (span === 1 && prefix === 0) return Object.freeze([latest]);
const targetStart = h3NativeFrameBoundaryStep(prefix);
const targetEnd = h3NativeFrameBoundaryStep(prefix + span);
if (targetStart === null || targetEnd === null) return Object.freeze([]);
const expectedSteps = targetEnd - targetStart;
const trim = raw - delivered;
const starts = [];
for (let start = 0; start <= latest; start += 1) {
const sourceStart = h3NativeFrameBoundaryStep(trim + start);
const sourceEnd = h3NativeFrameBoundaryStep(trim + start + span);
if (sourceStart !== null && sourceEnd !== null
&& sourceStart % 5 === targetStart % 5
&& sourceEnd - sourceStart === expectedSteps) {
starts.push(start);
}
}
return Object.freeze(starts);
}
export function nearestNativeContextWindowStart(starts, requested) {
const options = Array.isArray(starts) ? starts : [];
if (!options.length) return null;
const value = Number(requested);
if (!Number.isFinite(value)) return options.at(-1);
return options.reduce((nearest, candidate) => (
Math.abs(candidate - value) < Math.abs(nearest - value)
? candidate : nearest
), options[0]);
}
export function sceneVisualContextStartFrame(
shot, rawFrames, deliveredFrames, spanFrames, lead = false,
prefixFrames = 0,
) {
const field = lead
? "visual_context_lead_start_frame"
: "visual_context_start_frame";
const rawFramesResolved = Number(rawFrames);
const delivered = Number(deliveredFrames);
const span = Number(spanFrames);
if (!Number.isInteger(rawFramesResolved) || rawFramesResolved < delivered
|| !Number.isInteger(delivered) || delivered < 0
|| !Number.isInteger(span) || span < 0) {
throw new Error(`${field} requires valid raw, delivered, and context frame counts.`);
}
if (span < 1) {
if (Object.hasOwn(shot ?? {}, field)) {
throw new Error(`${field} requires a positive visual context span.`);
}
return delivered;
}
const latest = delivered - span;
if (latest < 0) {
throw new Error(
`${field} requests ${span} frames from a source delivering only ${delivered}.`,
);
}
const starts = nativeContextWindowStarts(
rawFramesResolved, delivered, span, prefixFrames,
);
if (!starts.length) {
throw new Error(
`${field} has no native latent-aligned ${span}-frame window inside the source's ${rawFramesResolved} raw / ${delivered} delivered frames.`,
);
}
const authored = shot?.[field];
if (authored === undefined || authored === null
|| (typeof authored === "string" && !authored.trim())) return starts.at(-1);
const resolved = Number(authored);
if (typeof authored === "boolean" || !Number.isInteger(resolved)
|| resolved < 0 || resolved > latest) {
throw new Error(
`${field} must be between 0 and ${latest} so its ${span}-frame window fits inside the source's ${delivered} delivered frames.`,
);
}
if (!starts.includes(resolved)) {
const nearest = nearestNativeContextWindowStart(starts, resolved);
throw new Error(
`${field}=${resolved} is not on H3's native temporal latent lattice. Use ${nearest} (nearest aligned start).`,
);
}
return resolved;
}
export function visualContextLeadFrameOptions(contextLength) {
const total = Number(contextLength);
if (!Number.isInteger(total)) return Object.freeze([]);
const values = new Set();
for (const nativeRun of H3_CONTEXT_LENGTHS) {
if (nativeRun < 5 || nativeRun >= total) continue;
values.add(nativeRun);
const inverse = total - nativeRun;
if (inverse >= 5 && inverse < total) values.add(inverse);
}
return Object.freeze([...values].sort((left, right) => left - right));
}
export function visualContextCompositions() {
const compositions = [];
for (const total of H3_CONTEXT_LENGTHS) {
for (const lead of visualContextLeadFrameOptions(total)) {
compositions.push(Object.freeze({
total,
lead,
recent: total - lead,
value: `${total}:${lead}`,
label: `${total} total · ${lead} + ${total - lead}`,
}));
}
}
return Object.freeze(compositions);
}
export function sceneAudioContextLength(
shot, planDefault = 22, videoContextLength = 22,
) {
const fallback = Number(planDefault);
if (!Number.isInteger(fallback) || fallback < 0 || fallback > 240) {
throw new Error("Plan audio context length must be between 0 and 240 frames.");
}
const value = shot?.audio_context_length;
if (value === undefined || value === null
|| (typeof value === "string" && !value.trim())) {
return fallback || Number(videoContextLength);
}
const resolved = Number(value);
if (typeof value === "boolean" || !Number.isInteger(resolved)
|| resolved < 0 || resolved > 240) {
throw new Error("Scene audio context length must be between 0 and 240 frames.");
}
return resolved;
}
export function sceneAudioContextUnlocked(shot) {
const value = shot?.audio_context_unlocked ?? false;
if (typeof value !== "boolean") {
throw new Error("audio_context_unlocked must be true or false.");
}
return value;
}
export function sceneAudioContextSource(plan, index) {
return resolvePriorSceneSource(
plan, index,
plan?.shots?.[Number(index) - 1]?.audio_context_source,
"Audio context source", true,
);
}
export function sceneAudioContextLeadSource(plan, index) {
return resolvePriorSceneSource(
plan, index,
plan?.shots?.[Number(index) - 1]?.audio_context_lead_source,
"Composed audio lead source", false,
);
}
export function audioContextLeadFrameOptions(contextLength) {
const total = Number(contextLength);
if (!Number.isInteger(total) || total < 2) return Object.freeze([]);
const expected = Math.round(total / FPS * 40);
return Object.freeze(Array.from({length:total - 1}, (_item, offset) => (
offset + 1
)).filter((lead) => (
Math.round(lead / FPS * 40)
+ Math.round((total - lead) / FPS * 40) === expected
)));
}
export function sceneAudioContextLeadFrames(shot, contextLength) {
const raw = shot?.audio_context_lead_frames;
if (raw === undefined || raw === null
|| (typeof raw === "string" && !raw.trim())) return 0;
const resolved = Number(raw);
const allowed = audioContextLeadFrameOptions(contextLength);
if (typeof raw === "boolean" || !Number.isInteger(resolved)
|| !allowed.includes(resolved)) {
throw new Error(
`Composed audio lead frames must be an exact 40 Hz split between 1 and ${Math.max(0, Number(contextLength) - 1)}.`,
);
}
return resolved;
}
export function audioContextWindowStarts(
rawFrames, deliveredFrames, spanFrames,
) {
const raw = Number(rawFrames);
const delivered = Number(deliveredFrames);
const span = Number(spanFrames);
if (!Number.isInteger(raw) || !Number.isInteger(delivered)
|| !Number.isInteger(span) || raw < delivered || delivered < 1
|| span < 1 || span > delivered) return Object.freeze([]);
const trim = raw - delivered;
const expected = Math.round(span / FPS * 40);
const starts = [];
for (let start = 0; start <= delivered - span; start += 1) {
const rawStart = trim + start;
if (Math.round((rawStart + span) / FPS * 40)
- Math.round(rawStart / FPS * 40) === expected) {
starts.push(start);
}
}
return Object.freeze(starts);
}
export function sceneAudioContextStartFrame(
shot, rawFrames, deliveredFrames, spanFrames, lead = false,
) {
const field = lead
? "audio_context_lead_start_frame" : "audio_context_start_frame";
const starts = audioContextWindowStarts(
rawFrames, deliveredFrames, spanFrames,
);
if (!starts.length) {
throw new Error(
`${field} has no exact ${spanFrames}-frame/40 Hz window in this source.`,
);
}
const authored = shot?.[field];
if (authored === undefined || authored === null
|| (typeof authored === "string" && !authored.trim())) {
return starts.at(-1);
}
const resolved = Number(authored);
const latest = Number(deliveredFrames) - Number(spanFrames);
if (typeof authored === "boolean" || !Number.isInteger(resolved)
|| resolved < 0 || resolved > latest) {
throw new Error(
`${field} must be between 0 and ${latest} so its ${spanFrames}-frame window fits.`,
);
}
if (!starts.includes(resolved)) {
const nearest = nearestNativeContextWindowStart(starts, resolved);
throw new Error(
`${field}=${resolved} does not preserve the exact 40 Hz duration. Use ${nearest} (nearest).`,
);
}
return resolved;
}
export function sceneVideoBlendFrames(
shot, planDefault = 0, videoContextLength = 22,
) {
const fallback = Number(planDefault);
const context = Number(videoContextLength);
if (!Number.isInteger(fallback) || fallback < 0) {
throw new Error("Plan video blend frames must be a non-negative integer.");
}
if (!Number.isInteger(context) || context < 0) {
throw new Error("Scene video context must be a non-negative integer.");
}
const value = shot?.video_blend_frames;
if (value === undefined || value === null
|| (typeof value === "string" && !value.trim())) {
return Math.min(fallback, context);
}
const resolved = Number(value);
if (typeof value === "boolean" || !Number.isInteger(resolved)
|| resolved < 0 || resolved > context) {
throw new Error(
`Scene video blend frames must be between 0 and its context length (${context}).`,
);
}
return resolved;
}
export function validateH3Length(value) {
const length = Number(value);
if (!Number.isInteger(length) || length < 5 || length > MAX_H3_FRAMES || length % 17 !== 5) {
throw new Error(
`Exact length must be 5..${MAX_H3_FRAMES} frames with length % 17 == 5.`,
);
}
return length;
}
function sceneRawFrames(shot, defaultDuration) {
if (shot.length !== undefined && shot.length !== null && shot.length !== "") {
return validateH3Length(shot.length);
}
if (shot.frames !== undefined && shot.frames !== null && shot.frames !== "") {
return validateH3Length(shot.frames);
}
const duration = shot.duration_seconds ?? defaultDuration;
return h3FrameLength(duration);
}
function validateSeed(seed) {
if (seed === undefined || seed === null || seed === "") return;
let numeric;
try {
numeric = BigInt(String(seed));
} catch (_error) {
throw new Error("Seed must be an unsigned 64-bit integer.");
}
if (numeric < 0n || numeric > MAX_SEED) {
throw new Error("Seed is outside the unsigned 64-bit range.");
}
}
export function calculatePlanTiming(plan, settings = {}) {
const errors = [];
const rows = [];
const contextLength = Number(settings.contextLength ?? 22);
const audioContextLength = Number(settings.audioContextLength ?? 22);
const videoBlendFrames = Number(settings.videoBlendFrames ?? 0);
const encodeMode = settings.encodeMode ?? "video";
const anchorMode = settings.anchorMode ?? "head";
const planContinuationMode = settings.continuationMode ?? "guide";
const generatedContinuity = String(
settings.generatedContinuity ?? "on",
);
const sourceAudioTarget = String(settings.sourceAudioTarget ?? "off");
const nodeDefaultDuration = Number(settings.defaultDurationSeconds ?? 15);
const planDefaultDuration = Number(plan?.defaults?.duration_seconds ?? nodeDefaultDuration);
const defaultSteps = Number(plan?.defaults?.steps ?? settings.defaultSteps ?? 20);
const hasSharedPrompt = sharedPrompt(plan ?? {}).text.trim().length > 0;
if (!H3_CONTEXT_LENGTHS.includes(contextLength)) {
errors.push(`Context length must be one of ${H3_CONTEXT_LENGTHS.join(", ")}.`);
}
if (!Number.isInteger(audioContextLength)
|| audioContextLength < 0 || audioContextLength > 240) {
errors.push("Audio context length must be between 0 and 240 frames.");
}
if (!Number.isInteger(videoBlendFrames) || videoBlendFrames < 0
|| videoBlendFrames > contextLength) {
errors.push(
`Video blend frames must be between 0 and context length (${contextLength}).`,
);
}
if (!Number.isFinite(planDefaultDuration) || planDefaultDuration <= 0) {
errors.push("Default duration must be a finite positive number.");
}
if (!Number.isInteger(defaultSteps) || defaultSteps < 1 || defaultSteps > 10000) {
errors.push("Default steps must be between 1 and 10000.");
}
const ids = new Set();
let stitchedFrames = 0;
for (let offset = 0; offset < (plan?.shots?.length ?? 0); offset += 1) {
const shot = plan.shots[offset];
const index = offset + 1;
const fallback = `clip_${String(index).padStart(4, "0")}`;
const id = safeShotId(shot.id, fallback);
const rowErrors = [];
if (ids.has(id)) rowErrors.push(`Duplicate normalized scene id “${id}”.`);
ids.add(id);
if (!promptValueToText(shot.prompt, `Scene ${index} prompt`).trim()
&& !hasSharedPrompt) {
rowErrors.push("Scene and shared prompts are both empty.");
}
const steps = Number(shot.steps ?? defaultSteps);
if (!Number.isInteger(steps) || steps < 1 || steps > 10000) {
rowErrors.push("Steps must be between 1 and 10000.");
}
try {
validateSeed(shot.seed);
} catch (error) {
rowErrors.push(error.message);
}
let loraRoute = "base";
try {
loraRoute = sceneLoRARoute(shot);
} catch (error) {
rowErrors.push(error.message);
}
let sceneContext = contextLength;
try {
sceneContext = sceneContextLength(shot, contextLength);
} catch (error) {
rowErrors.push(error.message);
}
if (index === 1 && (Object.hasOwn(
shot, "visual_context_start_frame",
) || Object.hasOwn(shot, "visual_context_blocks"))) {
rowErrors.push(
"Scene 1 cannot select a saved-scene context window.",
);
}
let visualContextSource = index > 1 ? index - 1 : null;
let visualContextLeadSource = null;
let visualContextLeadFrames = 0;
let visualContextBlocks = [];
try {
visualContextBlocks = sceneVisualContextBlocks(
plan, index, sceneContext,
);
if (visualContextBlocks.length) {
visualContextSource = visualContextBlocks.at(-1).source;
}
if (visualContextBlocks.length === 2) {
visualContextLeadSource = visualContextBlocks[0].source;
visualContextLeadFrames = visualContextBlocks[0].frames;
}
} catch (error) {
rowErrors.push(error.message);
}
let sceneAudioContext = audioContextLength || sceneContext;
try {
sceneAudioContext = sceneAudioContextLength(
shot, audioContextLength, sceneContext,
);
} catch (error) {
rowErrors.push(error.message);
}
let sceneBlendFrames = Math.min(
Math.max(0, videoBlendFrames), Math.max(0, sceneContext),
);
try {
sceneBlendFrames = sceneVideoBlendFrames(
shot, videoBlendFrames, sceneContext,
);
if (sceneBlendFrames > 0 && anchorMode !== "head") {
rowErrors.push("Video blending requires head anchor mode.");
}
if (index > 1 && sceneBlendFrames > 0 && (
visualContextBlocks.length !== 1
|| visualContextBlocks.some((block) => (
block.source !== index - 1 || block.startFrame !== null
))
)) {
rowErrors.push(
"Non-linear, composed, or windowed visual context requires 0 assembly blend frames; the timeline still cuts from the immediately previous scene.",
);
}
} catch (error) {
rowErrors.push(error.message);
}
let continuationMode = "guide";
try {
continuationMode = sceneContinuationMode(
shot, planContinuationMode,
);
if (sceneContext > 0 && [
"masked_av", "tapered_av", "feathered_av",
"audio_feathered_av", "drift_control_av",
"color_stable_drift_av",
].includes(
continuationMode,
)) {
const preservesGeneratedAudioPrefix = (
generatedContinuity === "on"
&& sourceAudioTarget !== "locked"
&& sceneAudioContext > 0
);
if ([
"masked_av", "feathered_av", "audio_feathered_av",
].includes(continuationMode)
&& !VIDEO_ONLY_AV_CONTEXT_LENGTHS.includes(sceneContext)) {
rowErrors.push(
"Video-only AV continuation requires at least 5 context frames.",
);
}
if (preservesGeneratedAudioPrefix
&& !AV_CONTEXT_LENGTHS.includes(sceneContext)) {
rowErrors.push(
"AV generated-audio continuity requires an exact shared video/audio boundary: 39, 90, 141, 192, or 243 context frames. Turn generated continuity off (or set scene audio context to 0) for a video-only AV test.",
);
}
if (encodeMode !== "video") {
rowErrors.push("AV mask continuation requires video encode mode.");
}
if (anchorMode !== "head") {
rowErrors.push("AV mask continuation requires head anchor mode.");
}
if (continuationMode === "tapered_av" && sceneContext !== 39) {
rowErrors.push(
"Detail AV currently requires exactly 39 context frames.",
);
}
if (["drift_control_av", "color_stable_drift_av"].includes(
continuationMode,
) && sceneContext !== 39) {
rowErrors.push(
"Drift-Control AV and Color-Stable Drift AV currently require exactly 39 context frames.",
);
}
}
if (sceneContext > 0 && continuationMode === "latent_guide") {
if (sceneContext < 5) {
rowErrors.push(
"Latent Guide requires a context length of at least 5 frames.",
);
}
if (encodeMode !== "video") {
rowErrors.push("Latent Guide requires video encode mode.");
}
}
} catch (error) {
rowErrors.push(error.message);
}
let audioContextUnlocked = false;
let audioContextSource = index > 1 ? index - 1 : null;
let audioContextLeadSource = null;
let audioContextLeadFrames = 0;
const effectiveAudioSelectionSpan = [
"masked_av", "tapered_av", "feathered_av",
"audio_feathered_av", "drift_control_av",
"color_stable_drift_av",
].includes(continuationMode) && sceneAudioContext > 0
? sceneContext : sceneAudioContext;
try {
audioContextUnlocked = sceneAudioContextUnlocked(shot);
if (audioContextUnlocked) {
if (index === 1) {
rowErrors.push(
"Scene 1 cannot unlock saved-scene audio context.",
);
}
if (sourceAudioTarget === "locked") {
rowErrors.push(
"Unlocked audio context cannot be combined with Lip-sync to source audio.",
);
} else if (generatedContinuity !== "on") {
rowErrors.push(
"Unlocked audio context requires Generated continuity for this scene.",
);
}
if (effectiveAudioSelectionSpan <= 0) {
rowErrors.push(
"Unlocked audio context requires a positive audio context length.",
);
}
audioContextSource = sceneAudioContextSource(plan, index);
audioContextLeadSource = sceneAudioContextLeadSource(
plan, index,
);
if (audioContextLeadSource !== null) {
audioContextLeadFrames = sceneAudioContextLeadFrames(
shot, effectiveAudioSelectionSpan,
);
} else if (Object.hasOwn(
shot, "audio_context_lead_frames",
)) {
rowErrors.push(
"Composed audio lead frames require an audio lead source.",
);
} else if (Object.hasOwn(
shot, "audio_context_lead_start_frame",
)) {
rowErrors.push(
"Composed audio lead start requires an audio lead source.",
);
}
} else if ([
"audio_context_source", "audio_context_start_frame",
"audio_context_lead_source", "audio_context_lead_frames",
"audio_context_lead_start_frame",
].some((field) => Object.hasOwn(shot, field))) {
rowErrors.push(
"Independent audio fields require Audio context to be unlocked.",
);
}
} catch (error) {
rowErrors.push(error.message);
}
const contextSpatialProxy = String(
shot.context_spatial_proxy ?? "off",
).trim().toLowerCase() || "off";
if (!CONTEXT_SPATIAL_PROXY_MODES.includes(contextSpatialProxy)) {
rowErrors.push(
`Unknown boundary spatial proxy “${contextSpatialProxy}”.`,
);
} else if (contextSpatialProxy !== "off") {
if (sceneContext <= 0) {
rowErrors.push(
"Boundary spatial proxy requires positive video context.",
);
}
if (contextSpatialProxy === "rgb_5_6" && ![
"guide", "tone_carry_guide", "tapered_guide",
].includes(continuationMode)) {
rowErrors.push(
"Low-grid 5/6 boundary proxy requires Guide, Tone Carry Guide, or Detail Guide.",
);
}
if (contextSpatialProxy === "latent_5_6" && ![
"masked_av", "tapered_av", "feathered_av",
"audio_feathered_av", "drift_control_av",
"color_stable_drift_av",
].includes(continuationMode)) {
rowErrors.push(
"Latent 5/6 boundary proxy requires an AV continuation mode.",
);
}
if (index === 1) {
rowErrors.push(
"Scene 1 cannot use a 5/6 boundary proxy because imported context has no sampled predecessor latent.",
);
}
}
let rawFrames = 0;
try {
rawFrames = sceneRawFrames(shot, planDefaultDuration);
} catch (error) {
rowErrors.push(error.message);
}
let deliveredFrames = rawFrames;
let generationStartFrame = stitchedFrames;
if (index > 1 && anchorMode === "head") {
if (sceneContext > 0 && rawFrames <= sceneContext) {
rowErrors.push(
`${rawFrames} raw frames are not longer than the ${sceneContext}-frame overlap.`,
);
}
deliveredFrames = Math.max(0, rawFrames - sceneContext);
generationStartFrame = stitchedFrames - sceneContext;
}
rows.push({
index,
id,
rawFrames,
rawSeconds: rawFrames / FPS,
deliveredFrames,
deliveredSeconds: deliveredFrames / FPS,
generationStartFrame,
contextLength: sceneContext,
visualContextSource,
visualContextSourceId: visualContextSource === null ? null
: safeShotId(
plan.shots[visualContextSource - 1]?.id,
`clip_${String(visualContextSource).padStart(4, "0")}`,
),
visualContextLeadSource,
visualContextLeadSourceId: visualContextLeadSource === null ? null
: safeShotId(
plan.shots[visualContextLeadSource - 1]?.id,
`clip_${String(visualContextLeadSource).padStart(4, "0")}`,
),
visualContextLeadFrames,
visualContextStartFrame:null,
visualContextLeadStartFrame:null,
visualContextBlocks,
audioContextUnlocked,
audioContextSource,
audioContextSourceId: audioContextSource === null ? null
: safeShotId(
plan.shots[audioContextSource - 1]?.id,
`clip_${String(audioContextSource).padStart(4, "0")}`,
),
audioContextLeadSource,
audioContextLeadSourceId: audioContextLeadSource === null ? null
: safeShotId(
plan.shots[audioContextLeadSource - 1]?.id,
`clip_${String(audioContextLeadSource).padStart(4, "0")}`,
),
audioContextLeadFrames,
audioContextStartFrame:null,
audioContextLeadStartFrame:null,
videoBlendFrames: sceneBlendFrames,
audioContextLength: [
"masked_av", "tapered_av", "feathered_av",
"audio_feathered_av", "drift_control_av",
"color_stable_drift_av",
].includes(
continuationMode,
)
? ((generatedContinuity === "on"
&& sourceAudioTarget !== "locked"
&& sceneAudioContext > 0) ? sceneContext : 0)
: sceneAudioContext,
preservesGeneratedAudioPrefix: (
generatedContinuity === "on"
&& sourceAudioTarget !== "locked"
&& sceneAudioContext > 0
),
continuationMode,
contextSpatialProxy,
loraRoute,
errors: rowErrors,
});
stitchedFrames += deliveredFrames;
}
for (let offset = 1; offset < rows.length; offset += 1) {
const target = rows[offset];
let prefixFrames = 0;
target.visualContextBlocks = target.visualContextBlocks.map(
(block, blockOffset) => {
const source = rows[block.source - 1] ?? null;
let startFrame = block.startFrame;
if (!source) {
target.errors.push(
`Visual context block ${blockOffset + 1} has no source scene.`,
);
} else if (source.deliveredFrames < block.frames) {
target.errors.push(
`Visual context block ${blockOffset + 1} source scene ${source.index} delivers fewer than ${block.frames} required frames.`,
);
} else {
try {
startFrame = visualContextBlockStartValue(
block.startFrame, source.rawFrames,
source.deliveredFrames, block.frames,
prefixFrames,
`Visual context block ${blockOffset + 1}`,
);
} catch (error) {
target.errors.push(error.message);
}
}
const resolved = Object.freeze({...block, startFrame});
prefixFrames += block.frames;
return resolved;
},
);
const source = target.visualContextBlocks.at(-1) ?? null;
target.visualContextStartFrame = source?.startFrame ?? null;
const lead = target.visualContextBlocks.length === 2
? target.visualContextBlocks[0] : null;
target.visualContextLeadStartFrame = lead?.startFrame ?? null;
if (target.audioContextUnlocked) {
const audioSource = target.audioContextSource === null ? null
: rows[target.audioContextSource - 1];
const recentAudioFrames = target.audioContextLength
- target.audioContextLeadFrames;
if (audioSource && audioSource.deliveredFrames < recentAudioFrames) {
target.errors.push(
`Selected audio source scene ${audioSource.index} delivers fewer than ${recentAudioFrames} required frames.`,
);
}
if (audioSource) {
try {
target.audioContextStartFrame = sceneAudioContextStartFrame(
plan.shots[offset], audioSource.rawFrames,
audioSource.deliveredFrames, recentAudioFrames, false,
);
} catch (error) {
target.errors.push(error.message);
}
}
const audioLead = target.audioContextLeadSource === null ? null
: rows[target.audioContextLeadSource - 1];
if (audioLead
&& audioLead.deliveredFrames < target.audioContextLeadFrames) {
target.errors.push(
`Selected composed-audio lead scene ${audioLead.index} delivers fewer than ${target.audioContextLeadFrames} required frames.`,
);
}
if (audioLead) {
try {
target.audioContextLeadStartFrame = sceneAudioContextStartFrame(
plan.shots[offset], audioLead.rawFrames,
audioLead.deliveredFrames,
target.audioContextLeadFrames, true,
);
} catch (error) {
target.errors.push(error.message);
}
}
}
}
for (const row of rows) {
for (const error of row.errors) errors.push(`Scene ${row.index}: ${error}`);
}
return {
shots: rows,
totalFrames: stitchedFrames,
totalSeconds: stitchedFrames / FPS,
errors,
};
}
export function formatClock(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "—";
const wholeMinutes = Math.floor(seconds / 60);
const remainder = seconds - wholeMinutes * 60;
return wholeMinutes
? `${wholeMinutes}:${remainder.toFixed(3).padStart(6, "0")}`
: `${remainder.toFixed(3)}s`;
}