Files
olivv-cs-ComfyUI-FunPack/app/legacy/store.js
T

5128 lines
227 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Central state + data actions. Views subscribe and re-render their zone.
(function () {
const API = window.MovieEditorAPI;
const state = {
projects: [], // [{id,name,scene_count,updated_at}]
project: null, // full Project
selectedSceneId: null, // focus clip (inspector / toolbar)
selectedSceneIds: [], // multi-select for generate / timeline
selectedOverlayId: null, // graphics overlay (text/image) on overlay lane
selectedAudioTrackId: null, // inserted audio lane (overlay / separated)
transitions: [], // prompt split-marker library (FunPack transition DB; generation only)
nleEffects: [], // post-render clip effects (zoom, blur, fades)
nleVideoTransitions: [], // post-render seam blends (crossfade, wipe, …)
health: null, // {ok, comfy_url, template_exists}
preview: null, // {combined_prompt, parsed, warning, parse_error}
gen: { state: "idle", promptId: null, media: [], msg: "" },
// Per-scene render mapping: { sceneId: { media, inSec } } — `inSec` is the IN-point
// within that source video this clip starts at (so split halves / deleted clips play
// the correct portion). Not persisted to disk; cleared on project switch.
sceneRenders: {},
// Removed scenes that still had a render: shown as timeline/preview ghosts until the
// neighboring run is regenerated (preview-only; not included in generation/export).
sceneGhosts: [],
notice: "",
saving: false,
unsaved: false,
models: { slots: [] }, // pluggable node config (shared with Models modal)
mediaBin: [], // uploaded assets [{id,name,kind,...}]
mediaPreviewId: null, // transient image preview in the player (not scene assignment)
mediaUpload: null, // in-progress upload: {current,total,name,loaded,size} or null
shortcuts: [], // prompt shortcut library
shortcutCategories: [], // managed grouping list: [{name, sub_categories:[]}]
imageTargets: [], // where an image asset can be wired [{value,label}]
ratingLabels: [], // FunPack Studio V2 rating options
};
// ── editor settings ─────────────────────────────────────────────────────────
// Editor preferences. Govern how the prompt is parsed/edited, not what is generated
// from already-distributed scenes. They live in this browser's localStorage AND, once
// a project is open, are mirrored into `project.editor_settings`: on a fresh machine
// (a newly rented GPU box) localStorage is empty and the server-side revolver sidecar
// does not exist, so without the mirror every one of these is back at its default and
// has to be set by hand again. Opening a project restores what it remembers.
const EDITOR_SETTINGS_KEY = "funpack_editor_settings";
const EDITOR_SETTINGS_DEFAULTS = {
autocomplete: true, suggestions: true,
// "Anchor as guide" i2v bypass: declare a custom i2v node so it can be forced
// to a state (value-only) on anchor_guide runs. anchorGuideBypass = {node, input, value}.
anchorGuideHasI2v: false, anchorGuideBypass: null,
// Snapshot of the shortcut revolver, which is NOT a browser setting — it lives
// server-side next to the shortcut DB because previews and generation share one
// cycle. Carried here purely so the project can put it back on a new instance;
// the server copy stays the single source of truth while the app is running.
revolver: null, // {enabled, random} | null = never recorded
// Up to three toolbar shortcuts: [slot1, slot2, slot3], each null or
// {kind:"section"|"node", id, label}. Node ids are project-scoped, which is why these
// ride with the project rather than living in the browser alone.
pinnedButtons: null,
// Post-render upscale (Settings ▸ Editor): follows the project to a new rental.
upscaleMode: "never", upscaleModel: "",
};
function _loadEditorSettings() {
try {
const raw = JSON.parse(localStorage.getItem(EDITOR_SETTINGS_KEY) || "{}");
return { ...EDITOR_SETTINGS_DEFAULTS, ...(raw && typeof raw === "object" ? raw : {}) };
} catch (_) { return { ...EDITOR_SETTINGS_DEFAULTS }; }
}
let _editorSettings = _loadEditorSettings();
function _persistEditorSettings() {
try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {}
}
function getEditorSettings() { return { ..._editorSettings }; }
function getEditorSetting(key) { return _editorSettings[key]; }
function setEditorSetting(key, val) {
// Mirrored into the open project so it travels with the file. Deliberately BEFORE the
// unchanged-value return: a browser that was already configured before the project
// learned to remember would otherwise never record anything, because re-picking the
// value you already have is a no-op — and the setting you never touch again is exactly
// the one you lose on the next machine. Silent, since changing a preference is not a
// timeline edit and should not mark the project dirty in the UI.
if (state.project) {
const stored = state.project.editor_settings || {};
if (JSON.stringify(stored[key]) !== JSON.stringify(val)) {
state.project.editor_settings = { ...stored, [key]: val };
scheduleSaveSilent();
}
}
if (JSON.stringify(_editorSettings[key]) === JSON.stringify(val)) return;
_editorSettings = { ..._editorSettings, [key]: val };
_persistEditorSettings();
notify();
}
/** Put a freshly-opened project's remembered preferences back in force.
*
* Key-by-key, and only for keys the project actually carries: a project saved before
* this existed has no `editor_settings` at all, and that must leave the current browser
* alone rather than resetting it to defaults. Applied wholesale (not via
* setEditorSetting) so the anchor toggle's re-split side effect doesn't fire — the
* caller re-syncs the prompt from the timeline straight after this anyway.
*
* The revolver is the one that isn't ours to set: it is a server setting shared by every
* browser and by generation. We POST it only when it actually differs, so opening a
* project that agrees with the server costs nothing and never restarts a live cycle.
*/
function _applyProjectEditorSettings(project) {
if (!project) return;
const saved = project.editor_settings || {};
const keys = Object.keys(saved).filter((k) => k !== "revolver" && k in EDITOR_SETTINGS_DEFAULTS);
if (keys.length) {
const next = { ..._editorSettings };
keys.forEach((k) => { next[k] = saved[k]; });
_editorSettings = next;
_persistEditorSettings();
} else {
// A project from before this existed, opened on a machine that IS configured: adopt
// this browser's settings rather than leaving the project blank, so the very next
// move carries them. Nothing changes here and now — these already are the settings
// in force — it only stops the project from travelling empty.
project.editor_settings = { ...(project.editor_settings || {}), ...getEditorSettings() };
delete project.editor_settings.revolver; // the server's copy is authoritative below
scheduleSaveSilent();
}
const rev = saved.revolver;
if (rev && typeof rev === "object") {
_editorSettings = { ..._editorSettings, revolver: { enabled: !!rev.enabled, random: !!rev.random } };
_persistEditorSettings();
API.revolverSettings().then((cur) => {
if (!!cur.enabled === !!rev.enabled && !!cur.random === !!rev.random) return;
return API.setRevolverSettings({ enabled: !!rev.enabled, random: !!rev.random })
.then(() => notify());
}).catch(() => {});
return;
}
// Nothing recorded yet: take whatever the server has now, so the mode you already set
// by hand travels with the project without you having to toggle it to register it.
API.revolverSettings().then((cur) => {
const snap = { enabled: !!cur.enabled, random: !!cur.random };
_editorSettings = { ..._editorSettings, revolver: snap };
_persistEditorSettings();
if (state.project === project) {
project.editor_settings = { ...(project.editor_settings || {}), revolver: snap };
scheduleSaveSilent();
}
}).catch(() => {});
}
const listeners = new Set();
const SAVE_DEBOUNCE_MS = 5000; // discrete edits (dropdowns, toggles)
const SAVE_SILENT_DEBOUNCE_MS = 20000; // typing in fields — flushed on blur / generate
let saveTimer = null;
let _localDirty = false; // local edits newer than the in-flight save snapshot
let _commitPromise = null; // avoid overlapping saves stomping newer anchor edits
let _commitQueued = false;
// Force a re-render from the authoritative saved project after the next commit. Set by
// source/anchor changes: those don't change the prompt-preview key or selection, so commit()
// would replace state.project = saved WITHOUT re-rendering, leaving the timeline showing the
// stale optimistic DOM (scene prompt text could read blank until reload/regenerate).
let _renderAfterSave = false;
let _selectionAnchorId = null; // shift-click range anchor
// Autosave suspension, held while a settings surface is open (see suspendSave). A
// debounce firing mid-session saves a half-edited value and echoes it back over
// state.project — "I set 0.5 and it went back to 1.0". Counted: surfaces nest.
let _saveSuspended = 0;
// In-flight reload of state.models (funpack-models-changed). commit() copies it into
// project.models, so committing mid-reload would write the pre-edit node list.
let _modelsLoad = null;
function notify() { listeners.forEach((fn) => { try { fn(state); } catch (e) { console.error(e); } }); }
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
function set(patch) { Object.assign(state, patch); notify(); }
function _syncEditorStateToProject() {
if (!state.project) return;
state.project.scene_renders = JSON.parse(JSON.stringify(state.sceneRenders || {}));
state.project.scene_ghosts = JSON.parse(JSON.stringify(state.sceneGhosts || []));
// Keep project.models aligned with the Models modal / saveModels API so timeline
// autosave (commit before generate) cannot resurrect deleted pipeline nodes.
state.project.models = JSON.parse(JSON.stringify(state.models || { slots: [] }));
}
function _historyRecord() {
if (window.EditorHistory?.isCoalescing()) return;
if (window.EditorHistory && !window.EditorHistory.isApplying()) window.EditorHistory.record(window.Store);
}
function notifyHistoryState() {
try { window.dispatchEvent(new CustomEvent("funpack-history-state")); } catch (_) {}
}
function scheduleSaveFromHistory() {
_localDirty = true;
if (_saveSuspended) { _notifySaveChip(); return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = null; commit(); }, SAVE_DEBOUNCE_MS);
_notifySaveChip();
}
// Update generation progress WITHOUT a full notify(): a store notify rebuilds the whole
// editor (inspector/timeline/player), which during a long generation kept interrupting the
// user (couldn't save frames to the bin or add effects). Progress ticks instead mutate
// state.gen quietly and fire a DOM event the player listens to, updating only the readout.
function updateGenProgress(patch) {
Object.assign(state.gen, patch);
try { window.dispatchEvent(new CustomEvent("funpack-gen-progress", { detail: state.gen })); } catch (_) {}
}
// Same idea as updateGenProgress: XHR upload progress fires many times a second, and a
// full notify() would rebuild the whole media bin (thumbnails, project list, any open
// <select>) on every tick. Mutate quietly and let mediabrowser.js patch its own bar/percent
// in place instead.
function updateMediaUploadProgress(patch) {
if (!state.mediaUpload) return;
Object.assign(state.mediaUpload, patch);
try { window.dispatchEvent(new CustomEvent("funpack-media-upload-progress", { detail: state.mediaUpload })); } catch (_) {}
}
function get() { return state; }
function _promptPreviewKey(project) {
if (!project) return "";
return JSON.stringify({
anchor: project.anchor,
postfix: project.postfix,
postfix_enabled: project.postfix_enabled,
global_prompt: project.global_prompt,
intro_transition: project.intro_transition,
scenes: (project.scenes || []).map((s) => ({
text: s.text,
excluded: s.excluded,
transition_to_next: s.transition_to_next,
source_type: s.source?.type,
media_ref: s.source?.media_ref,
})),
});
}
let _lastPreviewKey = "";
function _invalidateGlobalPromptDraft() {
try { window.dispatchEvent(new CustomEvent("funpack-invalidate-global-prompt")); } catch (_) {}
}
function _dispatchGlobalPromptUpdated(text) {
try {
window.dispatchEvent(new CustomEvent("funpack-global-prompt-updated", { detail: { text: text || "" } }));
} catch (_) {}
}
function _groupGenerativeUnits(scenes) {
const units = [];
for (const sc of scenes || []) {
const uid = genUnitId(sc);
if (units.length && units[units.length - 1][0] === uid) units[units.length - 1][1].push(sc);
else units.push([uid, [sc]]);
}
return units;
}
function _rootFromGroup(group) {
return (group || []).find((s) => !isGenSubclip(s)) || group[0] || null;
}
// The Story box's text: every scene, one marker word between each (core/story.py
// is the split; this is the same layout `join` writes). The anchor is its own field
// and is not in here. Empty scenes stay -- a story is exactly its scenes.
let _storyMarker = "qcut";
let _storyWords = ["qcut"];
async function loadStoryMarkers() {
const before = _storyMarker;
try {
_storyWords = (await API.storyMarkers()).markers || _storyWords;
_storyMarker = _storyWords[0] || "qcut";
} catch (_) { return; }
// Changed elsewhere (another tab): the box still holds the old separator, and the
// next edit would merge every scene. Rewrite it from the scenes.
if (before !== _storyMarker && state.project && !globalPromptApplyPending()) syncGlobalPromptFromTimeline();
}
// A scene whose own text holds a cut word makes the Story show one scene more than
// the timeline has. Said where the person looks; it cannot be prevented, only seen.
function _storyClash(project) {
const re = new RegExp("(?<![\\p{L}\\p{N}_])(?:" + _storyWords.map((w) => w.trim().split(/\s+/)
.map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")).join("|") + ")(?![\\p{L}\\p{N}_])", "iu");
return _storyRoots(project).find((s) => re.test(s.text || ""));
}
// Another tab or window may have changed the words; the box must write the server's.
window.addEventListener("focus", () => loadStoryMarkers());
// The scenes the Story shows: one root per generation unit, never video clips or sub-clips.
function _storyRoots(project) {
const active = (project.scenes || []).filter((s) => !s.excluded && isGenerativeScene(s));
return _groupGenerativeUnits(active).map(([, group]) => _rootFromGroup(group)).filter(Boolean);
}
const CLASH_NOTICE = "A scene's own text contains a cut word";
const SPLIT_NOTICE = "Could not split the Story";
const dropNotice = (prefix) => { if ((state.notice || "").startsWith(prefix)) state.notice = ""; };
function buildGlobalPromptFromTimeline(project) {
if (!project) return "";
const texts = _storyRoots(project).map((r) => (r.text || "").trim());
return texts.join(`\n${_storyMarker}\n`);
}
function _patchAffectsCombinedPrompt(patch) {
if (!patch || typeof patch !== "object") return false;
return patch.text != null
|| patch.transition_to_next != null
|| patch.excluded != null
|| patch.anchor != null
|| patch.intro_transition != null
|| patch.scenes != null;
}
let _timelinePromptTimer = null;
let _pinnedGlobalPrompt = null;
function _restorePinnedGlobalPrompt() {
const pin = (_pinnedGlobalPrompt || "").trim();
if (!pin || !state.project) return;
state.project.global_prompt = pin;
state.preview = {
...(state.preview || {}),
display_prompt: pin,
combined_prompt: pin,
};
// The pin is the last COMPLETED apply — while newer typed text is still debouncing or
// mid-parse, broadcasting it would rewind the Compose textarea to older text.
if (!globalPromptApplyPending()) _dispatchGlobalPromptUpdated(pin);
}
function syncGlobalPromptFromTimeline() {
if (!state.project) return;
_pinnedGlobalPrompt = null;
const built = buildGlobalPromptFromTimeline(state.project);
_failedText = null;
dropNotice(SPLIT_NOTICE);
const clash = _storyClash(state.project);
if (clash && !state.notice) state.notice = `${CLASH_NOTICE} (${_storyWords.join(", ")}), so the Story shows it as two scenes. Remove the word from that scene.`;
else if (!clash) dropNotice(CLASH_NOTICE);
const prev = state.project.global_prompt || "";
if (built === prev && (state.preview?.display_prompt || "") === built) return;
state.project.global_prompt = built;
state.preview = {
...(state.preview || {}),
display_prompt: built,
combined_prompt: built,
};
_lastPreviewKey = _promptPreviewKey(state.project);
_invalidateGlobalPromptDraft();
_dispatchGlobalPromptUpdated(built);
notify();
clearTimeout(_timelinePromptTimer);
_timelinePromptTimer = setTimeout(() => refreshPreview(true), 300);
}
let _globalApplyTimer = null;
let _pendingGlobalPromptText = null; // latest typed global prompt awaiting distribution
let _applySeq = 0; // ordering token — an older apply must never clobber a newer one
let _applyInFlight = 0;
// True while typed global-prompt text is still debouncing or mid-parse. During that window
// any funpack-global-prompt-updated dispatch would carry STALE text (the last completed
// apply), so consumers (Composer textarea) must not overwrite the user's draft with it.
function globalPromptApplyPending() {
return _pendingGlobalPromptText != null || !!_globalApplyTimer || _applyInFlight > 0 || _failedText != null;
}
function scheduleGlobalPromptApply(text) {
_pendingGlobalPromptText = text;
clearTimeout(_globalApplyTimer);
_globalApplyTimer = setTimeout(() => {
_globalApplyTimer = null;
const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null;
applyGlobalPromptQuiet(t).catch((e) => console.warn("Global prompt apply failed:", e));
}, 500);
}
// Materialize a still-debounced global-prompt edit into scene.text NOW (screen = truth). Called
// before generate/save so a prompt typed faster than the 500ms debounce isn't left undistributed
// — otherwise we'd save (and generate from) stale scene text while the screen shows the new prompt.
async function flushGlobalPromptApply() {
if (_globalApplyTimer) { clearTimeout(_globalApplyTimer); _globalApplyTimer = null; }
if (_pendingGlobalPromptText != null) {
const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null;
await applyGlobalPromptQuiet(t);
}
// A split already in flight (its debounce fired before Generate) is not "pending" any
// more -- wait for it, or Generate would run the scenes the box no longer shows.
await _lastApply.catch(() => {});
if (_failedText != null) await applyGlobalPromptQuiet(_failedText).catch(() => {});
if (_failedText != null) throw new Error("The Story could not be split into scenes (is the server reachable?) -- the scenes were not updated, so nothing was generated.");
}
function _normalizeSceneText(t) {
return String(t || "").trim().replace(/\s+/g, " ");
}
function _newSceneId() {
return "s" + Math.random().toString(36).slice(2, 11);
}
function _newSceneFromParsed(ps) {
const id = _newSceneId();
return {
id,
gen_unit_id: id,
text: ps.text || "",
transition_to_next: "",
source: { type: window.PipelineCaps?.defaultSceneSourceType(state) || "carry" },
excluded: false,
};
}
// Restore a ghost as a fresh timeline clip (new id — never reuse the ghost's id).
function _materializeSceneFromGhost(ghost, text) {
const id = _newSceneId();
const sc = {
id,
gen_unit_id: id,
text: text || ghost.text || "",
transition_to_next: "",
source: { type: window.PipelineCaps?.defaultSceneSourceType(state) || "carry" },
excluded: false,
frames: ghost.frames,
frames_mode: ghost.frames_mode,
fps: ghost.fps,
fps_mode: ghost.fps_mode,
effects: JSON.parse(JSON.stringify(ghost.effects || {})),
audio_volume: ghost.audio_volume,
audio_separated: false,
};
if (ghost.media) {
state.sceneRenders[id] = {
media: ghost.media,
inSec: ghost.inSec || 0,
durationSec: ghost.durationSec != null ? ghost.durationSec : undefined,
};
}
return sc;
}
function _afterAnchorForRemoved(removedSc, oldActive, keptSceneIds) {
const idx = oldActive.findIndex((s) => s.id === removedSc.id);
for (let j = idx - 1; j >= 0; j--) {
if (keptSceneIds.has(oldActive[j].id)) return oldActive[j].id;
}
return null;
}
// Keep separated audio on the timeline when its source clip is removed.
function _detachSeparatedAudioForScene(sc) {
if (!state.project?.audio_tracks) return;
for (const t of state.project.audio_tracks) {
if (t.kind !== "separated" || t.scene_id !== sc.id) continue;
t.start_sec = sceneTimelineOffsetSec(sc.id);
delete t.scene_id;
}
}
// Ghost a removed scene when it had a render; otherwise drop it entirely.
function _ghostOrDropScene(sc, afterSceneId, ghosts) {
const r = state.sceneRenders[sc.id];
const inFlight = _genInFlightIds.has(sc.id);
_detachSeparatedAudioForScene(sc);
delete state.sceneRenders[sc.id];
let out = (ghosts || []).filter((g) => g.id !== sc.id);
if (!r?.media && !inFlight) return out;
out.push({
id: sc.id,
afterSceneId: afterSceneId || null,
gen_unit_id: genUnitId(sc),
text: sc.text || "",
frames: sc.frames,
frames_mode: sc.frames_mode,
fps: sc.fps,
fps_mode: sc.fps_mode,
effects: JSON.parse(JSON.stringify(sc.effects || {})),
audio_volume: sc.audio_volume,
media: r?.media || null,
inSec: r?.inSec || 0,
pendingGen: inFlight && !r?.media,
});
return out;
}
function _mergeGenerativeWithVideoClips(oldScenes, generativeNext) {
const oldOrder = (oldScenes || []).filter((s) => !s.excluded);
const videoIds = new Set(oldOrder.filter(isVideoClip).map((s) => s.id));
if (!videoIds.size) return generativeNext;
const byId = new Map((oldScenes || []).map((s) => [s.id, s]));
const out = [];
let g = 0;
for (const sc of oldOrder) {
if (videoIds.has(sc.id)) {
out.push(JSON.parse(JSON.stringify(byId.get(sc.id) || sc)));
} else if (isGenerativeScene(sc)) {
if (g < generativeNext.length) out.push(generativeNext[g++]);
}
}
while (g < generativeNext.length) out.push(generativeNext[g++]);
return out;
}
function _generativeRoots(oldScenes) {
const active = (oldScenes || []).filter((s) => !s.excluded && isGenerativeScene(s));
return _groupGenerativeUnits(active).map(([, group]) => _rootFromGroup(group)).filter(Boolean);
}
function _markGenUnitUsed(uid, usedOld, oldAll) {
for (const s of oldAll) {
if (genUnitId(s) === uid) usedOld.add(s.id);
}
}
function _reuseGenerativeRoot(root, text) {
const sc = JSON.parse(JSON.stringify(root));
sc.text = text || "";
sc.cut_offset_frames = 0;
sc.gen_unit_id = sc.id;
return sc;
}
// Global prompt is authoritative. A scene's id carries its source/anchor AND its render
// (state.sceneRenders[id]), so a parsed slot must map onto the old root that owns its
// CONTENT — never just the root that happens to sit at the same array index. Matching by
// POSITION first was the "stale scene" bug: a reorder/insert/delete in the global prompt
// shifted indices, gluing one scene's text onto another scene's anchor+render (and that
// wrong mapping then got saved, so it survived restart and key-clears). So:
// Pass 1 — exact normalized-text match (nearest original position breaks ties): pins
// unchanged scenes to their own root through any reorder/insert/delete.
// Pass 2 — leftover slots inherit the leftover roots IN ORDER: an in-place text edit
// keeps its render/anchor; a slot with no root left becomes a fresh scene.
// Unmatched old roots are ghosted or dropped (unchanged). The only irreducible case is a
// scene that is BOTH reordered AND fully rewritten with no unchanged neighbour to anchor
// it — genuinely ambiguous from text alone, so Pass 2 falls back to a positional guess.
function _alignScenesFromParsed(oldScenes, parsedScenes, ghosts) {
const oldAll = (oldScenes || []).filter((s) => !s.excluded && isGenerativeScene(s));
const oldRoots = _generativeRoots(oldScenes);
const usedOld = new Set();
const usedGhost = new Set();
const next = [];
let ghostsOut = [...(ghosts || [])];
const parsed = parsedScenes || [];
// Match each parsed slot to the old root that owns its CONTENT — exact text first
// (nearest original position breaks ties), then leftover slots take leftover roots in
// order. The pure index-based kernel lives in scene_align.js so the two passes can be
// unit-tested in node (see scene_align.test.js). Both sides are normalized identically
// here before matching, so the kernel can compare raw strings.
const matchIdx = window.SceneAlign.matchParsedToRoots(
parsed.map((p) => _normalizeSceneText(p.text)),
oldRoots.map((r) => _normalizeSceneText(r.text)),
);
const matchByIdx = matchIdx.map((ri) => (ri >= 0 ? oldRoots[ri] : null));
for (const root of matchByIdx) {
if (root) _markGenUnitUsed(genUnitId(root), usedOld, oldAll);
}
// Materialize: matched root → reuse (keeps id/source/render); else ghost by text; else new.
for (let i = 0; i < parsed.length; i++) {
const ps = parsed[i];
const pt = _normalizeSceneText(ps.text);
const match = matchByIdx[i];
if (match) {
const root = isGenSubclip(match) ? (genUnitRoot(genUnitId(match)) || match) : match;
next.push(_reuseGenerativeRoot(root, ps.text));
continue;
}
const ghost = pt
? ghostsOut.find((g) => !usedGhost.has(g.id) && _normalizeSceneText(g.text) === pt)
: null;
if (ghost) {
usedGhost.add(ghost.id);
next.push(_materializeSceneFromGhost(ghost, ps.text));
continue;
}
next.push(_newSceneFromParsed(ps));
}
const keptSceneIds = new Set(next.map((s) => s.id).filter(Boolean));
for (const root of oldRoots) {
if (usedOld.has(root.id)) continue;
_markGenUnitUsed(genUnitId(root), usedOld, oldAll);
const afterId = _afterAnchorForRemoved(root, oldRoots, keptSceneIds);
ghostsOut = _ghostOrDropScene(root, afterId, ghostsOut);
}
ghostsOut = ghostsOut.filter((g) => !usedGhost.has(g.id));
const merged = _mergeGenerativeWithVideoClips(oldScenes, next);
return { next: merged, ghosts: ghostsOut, usedGhost };
}
function _normalizeGlobalPromptParse(_trimmed, v) {
const scenes = Array.isArray(v?.scenes) ? v.scenes : [];
return { scenes: scenes.length ? scenes : [{ text: "" }], transitions: [] };
}
function _afterTimelineStructureChange() {
_pinnedGlobalPrompt = null;
_ensureTimelineOrder();
_syncSeparatedAudioTracks();
syncGlobalPromptFromTimeline();
}
async function _distributeGlobalPrompt(text, res) {
const trimmed = String(text || "").trim();
const v = _normalizeGlobalPromptParse(trimmed, res.parsed_verbatim || res.parsed_raw || res.parsed || {});
if (!(v.scenes || []).length) return false;
_pinnedGlobalPrompt = trimmed;
state.project.global_prompt = trimmed;
const old = state.project.scenes || [];
const ghosts = state.sceneGhosts || [];
const { next, ghosts: ghostsOut } = _alignScenesFromParsed(old, v.scenes, ghosts);
for (const sc of next) {
sc.transition_to_next = "";
}
_applyDetectedTransitions(next, v.transitions, text);
state.project.scenes = next;
state.sceneGhosts = ghostsOut;
// The global prompt is authoritative on plan order — reconcile the cut order to it now
// (no-op when the user has pinned a manual order; otherwise tracks the plan).
_ensureTimelineOrder();
const prevSel = state.selectedSceneId;
const stillSel = prevSel && next.some((s) => s.id === prevSel);
const firstId = stillSel ? prevSel : (next[0]?.id || null);
state.selectedSceneId = firstId;
state.selectedSceneIds = firstId ? [firstId] : [];
_selectionAnchorId = firstId;
state.preview = {
...(state.preview || {}),
combined_prompt: trimmed,
display_prompt: trimmed,
parsed: res.parsed,
parsed_raw: res.parsed_raw,
parsed_verbatim: res.parsed_verbatim,
for_generation: false,
};
_lastPreviewKey = _promptPreviewKey(state.project);
_invalidateGlobalPromptDraft();
_dispatchGlobalPromptUpdated(trimmed);
notify();
return true;
}
let _lastApply = Promise.resolve();
let _failedText = null; // the last typed Story whose split failed, kept so Generate can retry it
function applyGlobalPromptQuiet(text) {
const p = _applyGlobalPromptQuiet(text);
_lastApply = p;
return p;
}
async function _applyGlobalPromptQuiet(text) {
// Same shape as commit()'s guard above, and needed for the same reason:
// this has an internal closure-scoped caller (applyPromptTemplate) besides its exported
// property, so blocking Store.applyGlobalPromptQuiet from tour.js alone
// would miss both. It reaches a real, unmocked API.parsePrompt network
// call, so it needs its own gate here rather than relying on
// scheduleSaveSilent()/commit() downstream to cover it.
if (window.__FUNPACK_TOUR__) return false;
if (!state.project) return false;
const trimmed = String(text || "").trim();
if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) { _applySeq++; _failedText = null; return true; }
const seq = ++_applySeq;
_applyInFlight++;
try {
_historyRecord();
let res;
try { res = await API.parsePrompt(state.project.id, trimmed); }
catch (e) {
console.warn("Global prompt parse failed:", e);
if (seq === _applySeq) {
_failedText = trimmed;
state.notice = SPLIT_NOTICE + " into scenes — the scenes were not updated. Check the connection and keep typing, or Generate will retry.";
notify();
}
return false;
}
// The user typed more while this parse was in flight: a newer apply is scheduled (or
// already running). Distributing this OLDER text now would re-split the timeline to a
// stale prompt and dispatch a stale updated event — drop it and let the newer one win.
if (seq !== _applySeq || _pendingGlobalPromptText != null) return false;
if (!await _distributeGlobalPrompt(trimmed, res)) return false;
_failedText = null;
dropNotice(SPLIT_NOTICE);
clearTimeout(saveTimer);
saveTimer = null;
_localDirty = true;
scheduleSaveSilent();
refreshPreview(true);
return true;
} finally {
_applyInFlight--;
}
}
function _syncPromptPreview() {
return (async () => {
try {
await flushSave();
await refreshPreview(true);
} catch (e) { console.error("prompt preview sync failed", e); }
})();
}
function _notifySaveChip() {
state.unsaved = _localDirty || !!saveTimer;
try {
window.dispatchEvent(new CustomEvent("funpack-save-status", {
detail: { saving: !!state.saving, unsaved: !!state.unsaved },
}));
} catch (_) {}
}
// ── project lifecycle ──────────────────────────────────────────────────────
async function refreshProjectList(quiet) {
const { projects } = await API.listProjects();
state.projects = projects;
if (!quiet) notify();
}
function _genUnitHasRender(unitId) {
return (state.project?.scenes || []).some(
(s) => genUnitId(s) === unitId && !!(state.sceneRenders[s.id]?.media?.filename)
);
}
// Ratings apply to a specific render — drop stale ratings when nothing is playable.
function _clearOrphanRatings() {
if (!state.project) return false;
let cleared = false;
const seen = new Set();
for (const sc of state.project.scenes || []) {
if (isVideoClip(sc)) {
if (sc.rating) { sc.rating = ""; cleared = true; }
continue;
}
const uid = genUnitId(sc);
if (seen.has(uid)) continue;
seen.add(uid);
const root = genUnitRoot(uid) || sc;
if (root.rating && !_genUnitHasRender(uid)) {
root.rating = "";
cleared = true;
}
for (const s of state.project.scenes) {
if (genUnitId(s) === uid && s.id !== root.id && s.rating) {
s.rating = "";
cleared = true;
}
}
}
return cleared;
}
let _validateRendersToken = 0;
// state.notice is one shared field with several unrelated writers (the
// stale-render sweep below, GB's "warn" note, loadProject errors, a
// dropped-reference notice, exportSelected/saveSelectedToMediaBin's own
// confirmations). _recordSegment clears it on a successful render, but
// only to retire THIS sweep's own "N saved renders no longer on disk"
// message once a regen fixes it -- not to blank whatever unrelated
// notice happens to be showing. A hand-tracked "is this mine" flag was
// tried first and missed two writers that set state.notice through the
// generic set() helper (round 3 of extensive_testing on the
// reference-wiring feature) -- every future writer would have to
// remember to clear it, the same class of bug this was fixing. Deriving
// ownership from the text itself needs nothing remembered.
const STALE_RENDERS_NOTICE_RE = /^\d+ saved renders? no longer on disk/;
async function _validateSceneRenders() {
if (!state.project) return;
const token = ++_validateRendersToken;
const snapshot = JSON.parse(JSON.stringify(state.sceneRenders || {}));
const ids = Object.keys(snapshot);
if (!ids.length) {
if (_clearOrphanRatings()) {
_syncEditorStateToProject();
scheduleSave();
notify();
}
return;
}
let missing = 0;
await Promise.all(ids.map(async (id) => {
if (token !== _validateRendersToken) return;
const r = snapshot[id];
if (!r?.media?.filename) {
if (token !== _validateRendersToken) return;
if (state.sceneRenders[id]) delete state.sceneRenders[id];
missing++;
return;
}
try {
const res = await fetch(API.resultUrl(state.project.id, r.media), { method: "HEAD" });
// Drop a render ONLY on a definitive "file is gone" (404). A busy server, a dropped
// tunnel, or any 5xx is transient — deleting here (and autosaving the deletion!)
// permanently lost clips when the UI reloaded while another run was still saving.
if (res.status === 404) {
if (token !== _validateRendersToken) return;
const cur = state.sceneRenders[id];
if (cur && cur.media?.filename === r.media.filename) delete state.sceneRenders[id];
missing++;
} else if (res.ok && token === _validateRendersToken) {
// File is on disk: reconcile a stale plan-estimate length with the real encoded
// duration for standalone renders (retroactively fixes pre-existing clips on load).
_probeRenderDuration(id);
}
} catch (_) {
// Network-level failure — can't tell missing from unreachable; keep the render.
}
}));
if (token !== _validateRendersToken) return;
const clearedRatings = _clearOrphanRatings();
if (missing || clearedRatings) {
if (missing) {
state.notice = `${missing} saved render${missing > 1 ? "s" : ""} no longer on disk — regenerate those clips.`;
}
_syncEditorStateToProject();
scheduleSave();
notify();
}
}
function clearNotice() {
if (!state.notice) return;
state.notice = "";
notify();
}
async function loadProject(id) {
let loaded;
try {
loaded = await API.getProject(id);
} catch (e) {
// Corrupt / incompatible project file: keep the current view, surface why instead of
// leaving the app in a half-loaded state.
state.notice = `Could not open this project: ${e && e.message ? e.message : e}`;
notify();
return;
}
state.project = loaded;
_failedText = null;
state.notice = "";
_applyProjectEditorSettings(loaded);
const first = state.project.scenes[0]?.id || null;
state.selectedSceneId = first;
state.selectedSceneIds = first ? [first] : [];
state.selectedOverlayId = null;
state.selectedAudioTrackId = null;
_selectionAnchorId = first;
state.gen = { state: "idle", promptId: null, media: [], msg: "" };
state.sceneRenders = JSON.parse(JSON.stringify(state.project.scene_renders || {}));
state.sceneGhosts = JSON.parse(JSON.stringify(state.project.scene_ghosts || []));
_ensureTimelineOrder();
normalizeOverlayLanes(state.project);
state.models = JSON.parse(JSON.stringify(state.project.models || { slots: [] }));
if (_clearOrphanRatings()) {
_syncEditorStateToProject();
scheduleSaveSilent();
}
_genInFlightIds.clear();
_removedDuringGen.clear();
if (window.EditorHistory) window.EditorHistory.clear();
notify();
syncGlobalPromptFromTimeline();
_syncSeparatedAudioTracks();
refreshPreview();
await loadModels();
_validateSceneRenders();
window.PipelineSetup?.maybePrompt?.();
}
async function newProject(name) {
const p = await API.createProject(name || "Untitled montage");
await refreshProjectList();
await loadProject(p.id);
// A project born on a configured machine inherits that configuration, so it can carry
// it away later. Without this it would only start remembering once a preference was
// toggled — and the settings you never have to touch are exactly the ones you'd lose.
if (state.project && state.project.id === p.id) {
state.project.editor_settings = { ...getEditorSettings() };
scheduleSaveSilent();
}
}
async function deleteProject(id) {
await API.deleteProject(id);
state.project = null; state.selectedSceneId = null; state.selectedSceneIds = [];
_selectionAnchorId = null;
await refreshProjectList();
if (state.projects[0]) await loadProject(state.projects[0].id);
else {
notify();
window.Onboarding?.open?.();
}
}
function downloadProject() {
if (!state.project) return;
const a = document.createElement("a");
a.href = API.downloadProjectUrl(state.project.id);
a.download = "";
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
async function importProject(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
const p = await API.importProject(data);
await refreshProjectList();
await loadProject(p.id);
} catch (e) { alert("Import failed: " + e.message); }
}
// ── conditioning / sampler role ────────────────────────────────────────────────
function setConditioningSlot(slotId) {
patchProject({ conditioning_slot: slotId || "funpack" });
}
function setSamplerSlot(slotId) {
patchProject({ sampler_slot: slotId || "funpack" });
}
function setSamplerInput(name, value) {
if (!state.project) return;
const prev = state.project.sampler_inputs || {};
patchProjectQuiet({ sampler_inputs: { ...prev, [name]: value } });
}
function setSamplerInputNow(name, value) {
if (!state.project) return;
const prev = state.project.sampler_inputs || {};
patchProject({ sampler_inputs: { ...prev, [name]: value } });
}
function unsetSamplerInput(name) {
if (!state.project) return;
const prev = { ...(state.project.sampler_inputs || {}) };
delete prev[name];
patchProjectQuiet({ sampler_inputs: prev });
}
function setStudioInput(name, value) {
if (!state.project) return;
const prev = state.project.studio_inputs || {};
patchProjectQuiet({ studio_inputs: { ...prev, [name]: value } });
}
function setStudioInputNow(name, value) {
if (!state.project) return;
const prev = state.project.studio_inputs || {};
patchProject({ studio_inputs: { ...prev, [name]: value } });
}
// ── editing ────────────────────────────────────────────────────────────────
// Autosave persists in the background — scheduling a save must NOT rebuild the UI
// (that was closing dropdowns and stealing clicks). Full notify only when the user
// edits something visible, or when prompt preview actually needs refreshing.
function scheduleSave() {
_localDirty = true;
if (_saveSuspended) { _notifySaveChip(); return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = null; commit(); }, SAVE_DEBOUNCE_MS);
_notifySaveChip();
}
// For free-text / number fields: queue a save without re-rendering; blur/generate flush.
function scheduleSaveSilent() {
_localDirty = true;
if (_saveSuspended) { _notifySaveChip(); return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = null; commit(); }, SAVE_SILENT_DEBOUNCE_MS);
_notifySaveChip();
}
// Hold scheduled saves until the matching resumeSave(). Edits still land locally and
// still mark the project unsaved; only the network write waits. An explicit flushSave()
// (Generate, restart, tab hidden) ignores the hold.
function suspendSave() {
_saveSuspended++;
clearTimeout(saveTimer);
saveTimer = null;
}
// Balanced with suspendSave(). Returns the commit promise if this released the last
// hold and there was something to write, else null.
function resumeSave() {
if (_saveSuspended > 0) _saveSuspended--;
if (_saveSuspended > 0) return null;
return flushSave();
}
// Commit any pending save immediately (field blur / before generate). Returns the
// commit promise when it flushed, else null.
function flushSave() {
clearTimeout(saveTimer); saveTimer = null;
flushModelsSave(); // persist any debounced exposed-control (node input) edits too
if (!_localDirty && !_commitPromise) return null;
return commit();
}
async function commit(opts = {}) {
// Every PROJECT-save path in this module funnels through here before it
// can reach a real network write -- scheduleSave()'s own setTimeout
// calls this closure-scoped function directly, so patching Store.commit
// (or any individual caller) from outside cannot stop it. The tour's own
// welcome copy promises "nothing here touches your real files," and
// enumerating every caller in tour.js's blocklist missed ones reachable
// outside a click (keyboard shortcuts bypass the tour's click-blocking
// backdrop entirely, since CSS pointer-events does nothing to keydown
// listeners) -- so the guard belongs here, once, covering every project-
// save path by construction instead of by an ever-growing enumeration.
// NOTE: this is not the one true funnel for every mutation tour.js needs
// to worry about -- shortcut/transition/category CRUD (saveShortcut,
// deleteTransition, etc.) call their own API.* functions directly and
// never reach commit() at all; those still need their own entries in
// tour.js's patchStore() blocklist.
if (window.__FUNPACK_TOUR__) return;
if (!state.project) return;
if (_commitPromise) { _commitQueued = true; return _commitPromise; }
const skipPreviewRefresh = !!opts.skipPreviewRefresh;
_commitPromise = (async () => {
if (_modelsLoad) { try { await _modelsLoad; } catch (_) {} }
// Sync BEFORE snapshotting: the snapshot is what gets uploaded, and a snapshot taken
// first carries the models config (and renders/ghosts) from the PREVIOUS sync — every
// autosave then silently reverted exposed-control edits (bypass/input/link changes
// saved via /models) by overwriting p.models on the server with the stale copy.
_syncEditorStateToProject();
const snapshot = JSON.parse(JSON.stringify(state.project));
const previewKeyBefore = _promptPreviewKey(snapshot);
const selBefore = JSON.stringify(state.selectedSceneIds || []);
const metaBefore = { name: snapshot.name, scene_count: (snapshot.scenes || []).length };
_localDirty = false;
state.saving = true;
_notifySaveChip();
try {
const saved = await _retryOnTunnel(() => API.saveProject(snapshot.id, snapshot));
// If the user edited again while this save was in flight (e.g. dropped a new
// anchor), do NOT replace local state with the older snapshot we just wrote.
if (_localDirty) scheduleSave();
else state.project = saved;
state.saving = false;
_pruneSelection();
const selChanged = JSON.stringify(state.selectedSceneIds || []) !== selBefore;
const previewKeyNow = _promptPreviewKey(state.project);
const previewStale = previewKeyNow !== previewKeyBefore || previewKeyNow !== _lastPreviewKey;
const metaChanged = saved.name !== metaBefore.name
|| (saved.scenes || []).length !== metaBefore.scene_count;
if (metaChanged) refreshProjectList(true);
if (previewStale && !skipPreviewRefresh) { refreshPreview(true); _renderAfterSave = false; }
else if (selChanged || _renderAfterSave) { notify(); _renderAfterSave = false; }
_notifySaveChip();
} catch (e) {
_localDirty = true;
state.saving = false;
_notifySaveChip();
console.error("save failed", e);
} finally {
_commitPromise = null;
if (_commitQueued) { _commitQueued = false; commit(); }
}
})();
return _commitPromise;
}
function _sceneHasManualSource(sc) {
if (!sc?.source) return false;
if (isVideoClip(sc)) return true;
const t = sc.source.type;
if (t === "image" || t === "v2v" || t === "video") return true;
return t === "mixed" && !!sc.source.media_ref;
}
function _propagateProjectSettingsToScenes(before, patch) {
const p = state.project;
if (!p) return;
const touches = ["num_frames_per_scene", "frame_rate", "width", "height"].some((k) => k in patch);
if (!touches) return;
for (const sc of p.scenes || []) {
// Editing the project's length wins over a length the TIMELINE gave a shot. A resize,
// trim or split moves a shot off project length as a side effect of a drag, and it
// never moved back — so the field looked live while reaching nothing.
//
// "custom" is the exception, and the same exception the rest of the app already makes:
// a shot set to Custom is locked against timeline resize too (resizeScene, splitScene).
// It is the one length the user typed on purpose, so it is the one this leaves alone.
if (patch.num_frames_per_scene != null && !isVideoClip(sc)
&& (sc.frames_mode || "project") !== "custom") {
sc.frames_mode = "project";
sc.frames = null;
}
if (patch.frame_rate != null && (sc.fps_mode || "project") === "project") {
sc.fps = null;
}
if (_sceneHasManualSource(sc)) continue;
if (patch.width != null && (sc.width == null || sc.width === before.width)) sc.width = null;
if (patch.height != null && (sc.height == null || sc.height === before.height)) sc.height = null;
}
}
function patchProject(patch) {
if (!state.project) return;
_historyRecord();
const before = {
num_frames_per_scene: state.project.num_frames_per_scene,
frame_rate: state.project.frame_rate,
width: state.project.width,
height: state.project.height,
};
Object.assign(state.project, patch);
_propagateProjectSettingsToScenes(before, patch);
if (_patchAffectsCombinedPrompt(patch)) syncGlobalPromptFromTimeline();
notify();
scheduleSave();
}
// Whether this patch is about to throw away lengths the timeline gave shots. Quiet patches
// record no history — they fire per keystroke — but this one discards work, so it gets one
// undo point. Only the FIRST keystroke of a burst finds anything left in "timeline" mode,
// so the stack gets one entry rather than one per character.
function _willDiscardTimelineLengths(patch) {
if (!state.project || patch.num_frames_per_scene == null) return false;
return (state.project.scenes || []).some(
(s) => s && !isVideoClip(s) && (s.frames_mode || "project") === "timeline");
}
function patchProjectQuiet(patch) {
if (!state.project) return;
if (_willDiscardTimelineLengths(patch)) _historyRecord();
const before = {
num_frames_per_scene: state.project.num_frames_per_scene,
frame_rate: state.project.frame_rate,
width: state.project.width,
height: state.project.height,
};
Object.assign(state.project, patch);
_propagateProjectSettingsToScenes(before, patch);
if (_patchAffectsCombinedPrompt(patch)) syncGlobalPromptFromTimeline();
if (["num_frames_per_scene", "frame_rate", "width", "height"].some((k) => k in patch)) notify();
scheduleSaveSilent();
}
// ── prompt variables ($name) + global-prompt templates ──────────────────────
function projectVariables() { return (state.project && state.project.variables) || []; }
function setProjectVariables(list) {
if (!state.project) return;
// Normalize: strip a leading $, drop fully-empty rows on persist (kept while editing in the UI).
patchProjectQuiet({ variables: (Array.isArray(list) ? list : []).map((v) => ({
name: String((v && v.name) || "").replace(/^\$+/, "").trim(),
value: String((v && v.value != null) ? v.value : ""),
})) });
}
function promptTemplates() { return (state.project && state.project.prompt_templates) || []; }
// A template is the scenes themselves -- not the Story text, whose cut word may be gone
// by the time it is applied.
async function savePromptTemplate(name) {
if (!state.project) return;
const nm = String(name || "").trim();
if (!nm) return;
try { await flushGlobalPromptApply(); } // what was just typed counts
catch (e) {
state.notice = "The template was not saved: the Story text could not be split (" + (e && e.message || e) + ").";
notify();
return;
}
const snapshot = {
name: nm,
anchor: state.project.anchor || "",
scenes: _storyRoots(state.project).map((r) => (r.text || "").trim()),
variables: JSON.parse(JSON.stringify(state.project.variables || [])),
};
const tpls = (state.project.prompt_templates || []).filter((t) => t && t.name !== nm);
tpls.push(snapshot);
patchProject({ prompt_templates: tpls, active_prompt_template: nm });
}
function deletePromptTemplate(name) {
if (!state.project) return;
const patch = {
prompt_templates: (state.project.prompt_templates || []).filter((t) => t && t.name !== name),
};
// Deleting the one in use leaves nothing selected — the prompt itself is untouched,
// it is just no longer "from" a template.
if (state.project.active_prompt_template === name) patch.active_prompt_template = "";
patchProject(patch);
}
function renamePromptTemplate(oldName, newName) {
if (!state.project) return false;
const nm = String(newName || "").trim();
if (!nm || nm === oldName) return false;
const list = state.project.prompt_templates || [];
if (list.some((t) => t && t.name === nm)) return false; // caller reports the clash
const patch = {
prompt_templates: list.map((t) => (t && t.name === oldName ? { ...t, name: nm } : t)),
};
if (state.project.active_prompt_template === oldName) patch.active_prompt_template = nm;
patchProject(patch);
return true;
}
async function applyPromptTemplate(name) {
if (!state.project) return;
const tpl = (state.project.prompt_templates || []).find((t) => t && t.name === name);
if (!tpl) return;
_historyRecord(); // before anything changes, so undo restores anchor and variables too
// Restore the saved variables first, then distribute the scenes (re-splits the timeline).
patchProjectQuiet({
variables: JSON.parse(JSON.stringify(tpl.variables || [])),
active_prompt_template: name,
...(typeof tpl.anchor === "string" ? { anchor: tpl.anchor } : {}),
});
if (!Array.isArray(tpl.scenes)) {
// A v4 template is one prompt with no scene cuts, and its anchor is inside the text.
state.notice = "This template was saved by an older version as one prompt: it applies as a single scene "
+ "(its anchor stays inside the text). Split it with a cut word to get scenes.";
notify();
return applyGlobalPromptQuiet(tpl.prompt || "");
}
const text = tpl.scenes.join(`\n${_storyMarker}\n`);
await _distributeGlobalPrompt(text, { parsed_verbatim: { scenes: tpl.scenes.map((t) => ({ text: t })) } });
scheduleSaveSilent();
}
// Back to no template. An emptied Story applies like any other text (one empty scene,
// recorded for undo); this is the deliberate operation that also drops the pin.
function clearGlobalPrompt() {
if (!state.project) return false;
// Drop any debounced edit first. It carries the text that is being cleared, and would
// otherwise land half a second later and redistribute it back onto the scenes.
if (_globalApplyTimer) { clearTimeout(_globalApplyTimer); _globalApplyTimer = null; }
_pendingGlobalPromptText = null;
_historyRecord();
// Everything the global prompt is built from — anchor + transitions + scene texts —
// so the box really does end up empty rather than empty-looking. The postfix is a
// separate project setting that outlives any one prompt, and is left alone.
(state.project.scenes || []).forEach((sc) => {
sc.text = "";
sc.transition_to_next = "";
});
patchProject({
anchor: "",
intro_transition: "",
active_prompt_template: "",
});
syncGlobalPromptFromTimeline();
return true;
}
function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; }
function genUnitId(sc) { return (sc && sc.gen_unit_id) || (sc && sc.id) || ""; }
function isVideoClip(sc) { return (sc && sc.source && sc.source.type) === "video"; }
function isGenerativeScene(sc) { return !!sc && !isVideoClip(sc); }
function isGenSubclip(sc) { return !!((sc && sc.cut_offset_frames) > 0); }
/** Timeline clip whose preview is driven by source_in on a media-bin file (not render inSec alone). */
function playsFromSourceMedia(sc) {
if (!sc?.source) return false;
if (isVideoClip(sc)) return !!sc.source.media_ref;
return sc.source.type === "v2v" && !!sc.source.media_ref;
}
function genUnitRoot(unitId) {
const group = (state.project?.scenes || []).filter((s) => genUnitId(s) === unitId);
return group.find((s) => !isGenSubclip(s)) || group[0] || null;
}
function genUnitSceneIds(unitId) {
return (state.project?.scenes || []).filter((s) => genUnitId(s) === unitId && !s.excluded).map((s) => s.id);
}
function _patchSceneTarget(id, patch) {
const s = scene(id); if (!s) return null;
const root = isGenSubclip(s) ? genUnitRoot(genUnitId(s)) : s;
const targetId = (root && isGenSubclip(s) && (patch.text != null || patch.rating != null || patch.source != null))
? root.id : id;
return scene(targetId);
}
function _anchorPatchChanged(before, patch) {
if (!patch.source) return false;
const b = before?.source || {};
const next = { ...b, ...patch.source };
return next.media_ref !== b.media_ref
|| (patch.source.type != null && next.type !== (b.type || "carry"));
}
// Dropping or picking a new i2v anchor keeps the cached render for preview/rating but
// stamps the pre-change source on the snapshot so UI can warn (like prompt mismatch).
function _stampRenderSourceBeforeAnchorChange(target) {
if (!target || !state.project) return;
const uid = genUnitId(target);
const root = genUnitRoot(uid) || target;
const src = root?.source || {};
const sourceType = src.type || "carry";
const mediaRef = src.media_ref || null;
const text = (root?.text || "").trim();
const anchor = (state.project.anchor || "").trim();
(state.project.scenes || []).filter((sc) => genUnitId(sc) === uid).forEach((sc) => {
const r = state.sceneRenders[sc.id];
if (!r?.media) return;
const rp = r.renderPrompt || {};
r.renderPrompt = {
text: rp.text ?? text,
anchor: rp.anchor ?? anchor,
sourceType: rp.sourceType ?? sourceType,
mediaRef: rp.mediaRef !== undefined ? rp.mediaRef : mediaRef,
sourceSnapshotted: true,
};
});
}
// Source lives on the gen-unit root; editorial subclips must mirror it for UI + saves.
function _syncGenUnitSource(root) {
if (!root || !state.project) return;
const src = JSON.parse(JSON.stringify(root.source || {}));
const uid = genUnitId(root);
(state.project.scenes || []).forEach((sc) => {
if (genUnitId(sc) === uid) sc.source = JSON.parse(JSON.stringify(src));
});
}
function _applyScenePatch(id, patch, quiet) {
const t = _patchSceneTarget(id, patch); if (!t) return;
const anchorChanged = _anchorPatchChanged(t, patch);
if (anchorChanged) _stampRenderSourceBeforeAnchorChange(t);
const merged = { ...patch };
if (merged.source) merged.source = { ...(t.source || {}), ...merged.source };
if (!quiet && !window.EditorHistory?.isApplying()) _historyRecord();
Object.assign(t, merged);
if (merged.source) _syncGenUnitSource(genUnitRoot(genUnitId(t)) || t);
if (anchorChanged) {
clearTimeout(saveTimer); saveTimer = null;
_localDirty = true; _renderAfterSave = true; notify(); commit();
} else if (_patchAffectsCombinedPrompt(merged)) {
syncGlobalPromptFromTimeline();
if (quiet) scheduleSaveSilent();
else { notify(); scheduleSave(); }
} else if (quiet) scheduleSaveSilent();
else { notify(); scheduleSave(); }
}
function patchScene(id, patch) { _applyScenePatch(id, patch, false); }
function patchSceneQuiet(id, patch) {
const t = _patchSceneTarget(id, patch) || scene(id); if (!t) return;
const anchorChanged = _anchorPatchChanged(t, patch);
if (anchorChanged) _stampRenderSourceBeforeAnchorChange(t);
const merged = { ...patch };
if (merged.source) merged.source = { ...(t.source || {}), ...merged.source };
Object.assign(t, merged);
if (merged.source) _syncGenUnitSource(genUnitRoot(genUnitId(t)) || t);
if (anchorChanged) {
clearTimeout(saveTimer); saveTimer = null;
_localDirty = true; _renderAfterSave = true; notify(); commit();
} else {
if (_patchAffectsCombinedPrompt(merged)) syncGlobalPromptFromTimeline();
scheduleSaveSilent();
}
}
function _sceneOrder() { return (state.project?.scenes || []).map((s) => s.id); }
function _pruneSelection() {
if (!state.project) {
state.selectedSceneIds = [];
state.selectedSceneId = null;
_selectionAnchorId = null;
return;
}
const valid = new Set(_sceneOrder());
state.selectedSceneIds = (state.selectedSceneIds || []).filter((sid) => valid.has(sid));
// null selectedSceneId = project settings view — keep it unless focus id vanished.
if (state.selectedSceneId !== null && !valid.has(state.selectedSceneId))
state.selectedSceneId = state.selectedSceneIds[0] || state.project.scenes[0]?.id || null;
if (state.selectedSceneId && !state.selectedSceneIds.includes(state.selectedSceneId))
state.selectedSceneIds = [...state.selectedSceneIds, state.selectedSceneId];
if (!state.selectedSceneIds.length && state.selectedSceneId)
state.selectedSceneIds = [state.selectedSceneId];
if (_selectionAnchorId && !valid.has(_selectionAnchorId))
_selectionAnchorId = state.selectedSceneId;
}
// Plain click = single; ⌘/Ctrl = toggle; Shift = range from anchor.
function selectScene(id, opts) {
opts = opts || {};
const additive = !!opts.additive;
const range = !!opts.range;
const order = _sceneOrder();
if (id == null) {
state.selectedSceneId = null;
state.selectedSceneIds = [];
_selectionAnchorId = null;
notify();
return;
}
if (!scene(id)) return;
state.selectedOverlayId = null;
state.selectedAudioTrackId = null;
state.mediaPreviewId = null; // tapping a clip exits the transient media-bin preview
if (range && _selectionAnchorId && order.includes(_selectionAnchorId)) {
const a = order.indexOf(_selectionAnchorId);
const b = order.indexOf(id);
const lo = Math.min(a, b);
const hi = Math.max(a, b);
const rangeIds = order.slice(lo, hi + 1);
if (additive) {
const set = new Set([...(state.selectedSceneIds || []), ...rangeIds]);
state.selectedSceneIds = order.filter((sid) => set.has(sid));
} else state.selectedSceneIds = rangeIds;
state.selectedSceneId = id;
notify();
return;
}
if (additive) {
const set = new Set(state.selectedSceneIds || []);
if (set.has(id)) {
set.delete(id);
if (state.selectedSceneId === id)
state.selectedSceneId = [...set].pop() || null;
} else {
set.add(id);
state.selectedSceneId = id;
}
state.selectedSceneIds = order.filter((sid) => set.has(sid));
_selectionAnchorId = id;
notify();
return;
}
state.selectedSceneId = id;
state.selectedSceneIds = [id];
_selectionAnchorId = id;
notify();
}
function selectedSceneCount() {
return (state.selectedSceneIds || []).filter((sid) => {
const s = scene(sid);
return s && !s.excluded;
}).length;
}
function addScene() {
if (!state.project) return;
_historyRecord();
const srcType = window.PipelineCaps?.defaultSceneSourceType(state) || "carry";
const s = { text: "", transition_to_next: "", source: { type: srcType }, excluded: false, frames_mode: "project", fps_mode: "project" };
state.project.scenes.push(s);
window.Timeline?.requestAutoFit?.();
notify(); scheduleSave(); // server assigns id; reselect after commit
}
function _snapshotSceneForArchive(sc) {
const r = state.sceneRenders[sc.id];
return {
text: sc.text || "",
source: JSON.parse(JSON.stringify(sc.source || {})),
guides: JSON.parse(JSON.stringify(sc.guides || [])),
effects: JSON.parse(JSON.stringify(sc.effects || {})),
frames: sc.frames,
frames_mode: sc.frames_mode,
fps: sc.fps,
fps_mode: sc.fps_mode,
transition_to_next: sc.transition_to_next || "",
video_transition: sc.video_transition || "",
transition_frames: sc.transition_frames,
rating: sc.rating || "",
audio_volume: sc.audio_volume,
audio_separated: sc.audio_separated,
gen_unit_id: sc.gen_unit_id,
cut_offset_frames: sc.cut_offset_frames,
source_in: sc.source_in,
source_dur: sc.source_dur,
width: sc.width,
height: sc.height,
render: r ? JSON.parse(JSON.stringify(r)) : null,
};
}
function _applySceneArchive(sc, archive) {
if (!archive) return;
sc.text = archive.text || "";
sc.source = JSON.parse(JSON.stringify(archive.source || { type: "carry" }));
sc.guides = JSON.parse(JSON.stringify(archive.guides || []));
sc.effects = JSON.parse(JSON.stringify(archive.effects || {}));
sc.frames = archive.frames;
sc.frames_mode = archive.frames_mode;
sc.fps = archive.fps;
sc.fps_mode = archive.fps_mode;
sc.transition_to_next = archive.transition_to_next || "";
sc.video_transition = archive.video_transition || "";
sc.transition_frames = archive.transition_frames;
sc.rating = archive.rating || "";
sc.audio_volume = archive.audio_volume;
sc.audio_separated = !!archive.audio_separated;
sc.gen_unit_id = archive.gen_unit_id;
sc.cut_offset_frames = archive.cut_offset_frames || 0;
sc.source_in = archive.source_in || 0;
sc.source_dur = archive.source_dur;
sc.width = archive.width;
sc.height = archive.height;
sc.scene_archive = null;
if (archive.render) state.sceneRenders[sc.id] = JSON.parse(JSON.stringify(archive.render));
}
function convertToVideo(sceneId) {
if (!state.project) return;
const sc = scene(sceneId);
if (!sc || isVideoClip(sc)) return;
const mediaRef = (sc.source && sc.source.media_ref) || null;
const asset = mediaRef ? (state.mediaBin || []).find((m) => m.id === mediaRef) : null;
if (asset?.kind !== "video") {
alert("Assign a video file to this clip first (Media Browser or drag onto the clip), then Convert to video.");
return;
}
_lockSceneAsVideo(sceneId, mediaRef, { archive: true });
}
function _clearUnitSceneRenders(unitId) {
const live = new Set((state.project?.scenes || []).filter((s) => genUnitId(s) === unitId).map((s) => s.id));
for (const id of Object.keys(state.sceneRenders || {})) {
const s = scene(id);
if (!s) continue;
if (genUnitId(s) === unitId && !live.has(id)) delete state.sceneRenders[id];
}
live.forEach((id) => { delete state.sceneRenders[id]; });
}
function _lockSceneAsVideo(sceneId, mediaId, { archive = true } = {}) {
if (!state.project || !mediaId) return;
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
if (!asset || asset.kind !== "video") return;
_historyRecord();
const sc = scene(sceneId);
if (!sc) return;
const uid = genUnitId(sc);
const root = genUnitRoot(uid) || sc;
const archiveSnap = archive && !isVideoClip(root) ? _snapshotSceneForArchive(root) : null;
state.project.scenes = state.project.scenes.filter(
(s) => genUnitId(s) !== uid || s.id === root.id
);
_clearUnitSceneRenders(uid);
const fps = state.project.frame_rate || 25;
const dur = asset.duration_sec || root.source_dur || null;
Object.assign(root, {
scene_archive: archiveSnap || root.scene_archive || null,
source: { type: "video", media_ref: mediaId },
gen_unit_id: root.id,
cut_offset_frames: 0,
source_dur: dur,
rating: root.rating || "",
});
if (dur != null) {
root.frames_mode = "timeline";
root.frames = snapFrames(dur * fps);
}
if (state.selectedSceneId && !scene(state.selectedSceneId)) {
state.selectedSceneId = root.id;
state.selectedSceneIds = [root.id];
_selectionAnchorId = root.id;
}
if (!asset.duration_sec) _probeVideoClipDuration(root.id, mediaId);
_afterTimelineStructureChange();
notify(); scheduleSave();
}
function convertToScene(sceneId) {
if (!state.project) return;
const sc = scene(sceneId);
if (!sc || !isVideoClip(sc)) return;
_historyRecord();
if (sc.scene_archive) {
_applySceneArchive(sc, sc.scene_archive);
} else {
const mediaRef = sc.source?.media_ref || null;
sc.scene_archive = null;
sc.source = { type: "v2v", media_ref: mediaRef };
if (!sc.text) sc.text = "";
sc.gen_unit_id = sc.gen_unit_id || sc.id;
}
_afterTimelineStructureChange();
notify(); scheduleSave();
}
function addVideoClip(mediaId) {
if (!state.project || !mediaId) return;
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
if (!asset || asset.kind !== "video") return;
_historyRecord();
const id = _newSceneId();
const fps = state.project.frame_rate || 25;
const dur = asset.duration_sec || null;
state.project.scenes.push({
id,
gen_unit_id: id,
text: "",
transition_to_next: "",
source: { type: "video", media_ref: mediaId },
source_dur: dur,
frames_mode: dur != null ? "timeline" : "project",
frames: dur != null ? snapFrames(dur * fps) : null,
excluded: false,
});
state.selectedSceneId = id;
state.selectedSceneIds = [id];
_selectionAnchorId = id;
window.Timeline?.requestAutoFit?.();
notify(); scheduleSave();
if (!dur) _probeVideoClipDuration(id, mediaId);
}
// Drop an image onto the timeline → a new GENERATIVE scene at the project's
// default length, anchored to that image (i2v). Empty prompt; the user fills it
// in. Mirrors addScene (server assigns the id) but pre-sets the image anchor.
function addImageClip(mediaId) {
if (!state.project || !mediaId) return;
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
if (!asset || asset.kind !== "image") return;
_historyRecord();
state.project.scenes.push({
text: "",
transition_to_next: "",
source: { type: "image", media_ref: mediaId },
excluded: false,
frames_mode: "project",
fps_mode: "project",
});
window.Timeline?.requestAutoFit?.();
notify(); scheduleSave(); // server assigns id; reselect after commit
}
function _applyVideoClipDuration(sceneId, durSec) {
const sc = scene(sceneId);
if (!sc || !isVideoClip(sc) || !durSec || !isFinite(durSec)) return;
const fps = (sc.fps_mode !== "project" && sc.fps != null) ? sc.fps : (state.project?.frame_rate || 25);
patchSceneQuiet(sceneId, {
source_dur: durSec,
frames: snapFrames(durSec * fps),
frames_mode: sc.frames_mode === "custom" ? "custom" : "timeline",
});
}
function _probeVideoClipDuration(sceneId, mediaId) {
const v = document.createElement("video");
v.preload = "metadata";
v.src = API.mediaUrl(mediaId);
// Always release the probe's media connection — detached <video>s hold their socket
// until GC and count against Chrome's per-origin connection cap.
const release = () => { try { v.removeAttribute("src"); v.load(); } catch (_) {} };
v.onerror = release;
v.onloadedmetadata = () => {
const dur = v.duration;
release();
if (!dur || !isFinite(dur)) return;
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
if (asset && !asset.duration_sec) asset.duration_sec = dur;
_applyVideoClipDuration(sceneId, dur);
};
}
// True when a render's media file belongs to this scene ALONE — i.e. the scene spans the
// whole encoded file from its start. Multi-scene chains share one file and partition it via
// inSec/durationSec slices, so for those the plan-based slice length is the source of truth
// and must NOT be replaced by the whole-file duration.
function _renderIsStandalone(sceneId) {
const r = state.sceneRenders[sceneId];
if (!r?.media?.filename) return false;
if ((r.inSec || 0) > 0.001) return false;
const fname = r.media.filename;
let n = 0;
for (const k of Object.keys(state.sceneRenders)) {
if (state.sceneRenders[k]?.media?.filename === fname) n++;
}
return n === 1;
}
// A generated render's frozen durationSec is the PLAN estimate (snapped frames/fps). The
// actual encoded file can be slightly longer (LTXAV muxes audio; container fps rounding), so
// the clip drew shorter than the real media. For a standalone render (whole file = this scene)
// probe the real duration and adopt it — sceneDurationSec already routes render.durationSec for
// project-mode scenes, so the timeline/playback then match the file. Idempotent: once adopted,
// the values match and re-probing is a no-op.
function _probeRenderDuration(sceneId) {
if (!state.project) return;
const r = state.sceneRenders[sceneId];
if (!r?.media || !(r.media.kind === "videos" || r.media.kind === "gifs")) return;
if (!_renderIsStandalone(sceneId)) return;
const fname = r.media.filename;
const tok = r._editTok || 0; // snapshot before the async probe
const v = document.createElement("video");
v.preload = "metadata";
v.muted = true;
const release = () => { try { v.removeAttribute("src"); v.load(); } catch (_) {} };
v.onloadedmetadata = () => {
const dur = v.duration;
release(); // metadata read — free the connection before touching state
if (!dur || !isFinite(dur)) return;
const cur = state.sceneRenders[sceneId];
if (!cur || cur.media?.filename !== fname) return; // scene moved on; don't clobber
if ((cur._editTok || 0) !== tok) return; // split/re-length happened mid-probe; stale
const old = cur.durationSec;
if (old != null && Math.abs(old - dur) < 0.05) return;
cur.durationSec = dur; // adopt the real encoded length
// A separated-audio track was pinned to the OLD (plan) length at separation time. If it
// still matches that length it was auto-pinned, so carry it along; if it differs the user
// trimmed it by hand — leave that alone.
if (old != null) {
const t = separatedTrackForScene(sceneId);
if (t && t.pinned_dur != null && Math.abs(t.pinned_dur - old) < 0.02) {
t.pinned_dur = dur;
if (t.source_dur != null) t.source_dur = dur;
}
}
notify();
scheduleSaveSilent();
};
v.onerror = release;
v.src = API.resultUrl(state.project.id, r.media);
}
function _removeSceneNoHistory(id) {
if (!state.project) return;
const arr = state.project.scenes;
const idx = arr.findIndex((s) => s.id === id);
if (idx < 0) return;
const sc = arr[idx];
const unitId = genUnitId(sc);
const unitMates = arr.filter((s) => genUnitId(s) === unitId);
if (!isGenSubclip(sc) && unitMates.length > 1) {
const next = unitMates.find((s) => s.id !== id);
if (next) {
next.cut_offset_frames = 0;
next.text = sc.text || next.text;
next.rating = sc.rating || next.rating;
next.source = JSON.parse(JSON.stringify(sc.source || next.source || {}));
if (playsFromSourceMedia(next)) {
const fps = (sc.fps_mode !== "project" && sc.fps != null) ? sc.fps : (state.project.frame_rate || 25);
const removedDur = (isVideoClip(sc) && sc.source_dur != null)
? sc.source_dur
: ((sc.frames != null ? sc.frames : state.project.num_frames_per_scene) / fps);
// Legacy splits never bumped source_in — advance the kept half past the deleted head.
if ((next.source_in || 0) <= (sc.source_in || 0) + 0.001) {
next.source_in = (sc.source_in || 0) + removedDur;
}
}
const removedOffset = sc.frames || 0;
unitMates.filter((s) => s.id !== id).forEach((s) => {
if ((s.cut_offset_frames || 0) > (sc.cut_offset_frames || 0))
s.cut_offset_frames = Math.max(0, (s.cut_offset_frames || 0) - removedOffset);
});
}
}
const inFlight = _genInFlightIds.has(id);
if (inFlight) _removedDuringGen.add(id);
const newAnchor = idx > 0 ? arr[idx - 1].id : null;
state.sceneGhosts = (state.sceneGhosts || []).map((g) =>
g.afterSceneId === id ? { ...g, afterSceneId: newAnchor } : g
);
state.sceneGhosts = _ghostOrDropScene(sc, newAnchor, state.sceneGhosts);
state.project.scenes = arr.filter((s) => s.id !== id);
state.selectedSceneIds = (state.selectedSceneIds || []).filter((sid) => sid !== id);
if (state.selectedSceneId === id)
state.selectedSceneId = state.selectedSceneIds[0] || state.project.scenes[0]?.id || null;
if (_selectionAnchorId === id) _selectionAnchorId = state.selectedSceneId;
}
function removeScene(id) {
if (!state.project) return;
_historyRecord();
_removeSceneNoHistory(id);
_afterTimelineStructureChange();
window.Timeline?.requestAutoFit?.();
notify(); scheduleSave();
}
// Remove a scene from the PLAN. If its gen-unit has a generated clip, KEEP that clip on the
// timeline: mark the unit `excluded` (so generation + prompt skip it everywhere) and
// `removed_from_plan` (hide from the plan strip; export keeps it because it has a render).
// If nothing was generated, it's a plain delete.
function removeFromPlan(id) {
if (!state.project) return;
const s = scene(id); if (!s) return;
const uid = genUnitId(s);
const unit = (state.project.scenes || []).filter((x) => genUnitId(x) === uid);
const rendered = isVideoClip(s) || unit.some((x) => state.sceneRenders[x.id] && state.sceneRenders[x.id].media);
if (!rendered) { removeScene(id); return; }
_historyRecord();
unit.forEach((x) => { x.excluded = true; x.removed_from_plan = true; });
syncGlobalPromptFromTimeline();
notify(); scheduleSave();
}
// Bring a removed-from-plan scene back into the plan (re-enable generation).
function restoreToPlan(id) {
if (!state.project) return;
const s = scene(id); if (!s) return;
const uid = genUnitId(s);
_historyRecord();
(state.project.scenes || []).filter((x) => genUnitId(x) === uid)
.forEach((x) => { x.excluded = false; x.removed_from_plan = false; });
syncGlobalPromptFromTimeline();
notify(); scheduleSave();
}
function removeSelectedScenes() {
if (!state.project) return;
let ids = [...new Set(state.selectedSceneIds || [])].filter((sid) => scene(sid));
if (!ids.length && state.selectedSceneId) ids = [state.selectedSceneId];
if (!ids.length) return;
if (ids.length > 1 && !confirm(`Remove ${ids.length} selected clips?`)) return;
if (ids.length === 1) { removeScene(ids[0]); return; }
_historyRecord();
ids.map((id) => ({ id, idx: state.project.scenes.findIndex((s) => s.id === id) }))
.filter((x) => x.idx >= 0)
.sort((a, b) => b.idx - a.idx)
.forEach(({ id }) => _removeSceneNoHistory(id));
_afterTimelineStructureChange();
window.Timeline?.requestAutoFit?.();
notify(); scheduleSave();
}
function dismissGhost(id) {
state.sceneGhosts = (state.sceneGhosts || []).filter((g) => g.id !== id);
_removedDuringGen.delete(id);
notify();
}
// Duration of a live scene or a removed-scene ghost snapshot.
function segmentDurationSec(seg) {
const p = state.project;
if (!p || !seg) return 0;
if (seg.kind === "gap") return Math.max(0, +(seg.gapSec || 0));
if (seg.kind === "scene") {
const sc = seg.scene;
if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur;
return sceneDurationSec(sc);
}
return _ghostDurationSec(seg.ghost);
}
// ── timeline (cut) order — independent of plan (scenes) order ─────────────────
// The timeline is the RESULT; its order is project.timeline_order (scene ids), which the
// plan can never move. Self-healing: keep existing order, drop ids whose scene is gone,
// append new scenes in plan order. Empty falls back to plan order (back-compat / fresh).
function _ensureTimelineOrder() {
const p = state.project; if (!p) return;
const ids = (p.scenes || []).map((s) => s.id);
// Until the user explicitly pins a custom cut order (◀ ▶), the timeline follows the plan.
// This keeps an edit to the global prompt — which rebuilds the plan in its own order — from
// leaving a stale id sequence behind that scrambles the clips against their plan badges.
if (!p.timeline_manually_ordered) { p.timeline_order = ids; return; }
const idSet = new Set(ids);
const cur = (Array.isArray(p.timeline_order) ? p.timeline_order : []).filter((id) => idSet.has(id));
const inCur = new Set(cur);
for (const id of ids) if (!inCur.has(id)) cur.push(id);
p.timeline_order = cur;
}
function orderedTimelineScenes() {
const p = state.project; if (!p) return [];
const byId = new Map((p.scenes || []).map((s) => [s.id, s]));
const order = (Array.isArray(p.timeline_order) && p.timeline_order.length)
? p.timeline_order : (p.scenes || []).map((s) => s.id);
const out = []; const seen = new Set();
for (const id of order) { const s = byId.get(id); if (s && !seen.has(id)) { out.push(s); seen.add(id); } }
for (const s of (p.scenes || [])) if (!seen.has(s.id)) { out.push(s); seen.add(s.id); }
return out;
}
// Reorder the CUT (timeline_order) only — never the plan. Selected clip ± 1 slot.
function moveTimelineClip(id, delta) {
const p = state.project; if (!p) return;
_ensureTimelineOrder();
const order = p.timeline_order;
const i = order.indexOf(id); if (i < 0) return;
const j = Math.max(0, Math.min(order.length - 1, i + delta));
if (i === j) return;
_historyRecord();
order.splice(i, 1); order.splice(j, 0, id);
// The user has now pinned a custom cut order — a global-prompt edit must preserve it
// rather than re-deriving the order from the plan.
p.timeline_manually_ordered = true;
notify(); scheduleSave();
}
// Preview/timeline layout: result clips (cut order) interleaved with removed-scene ghosts.
function buildPreviewSegments() {
const p = state.project;
if (!p) return [];
const ghosts = state.sceneGhosts || [];
const byAnchor = new Map();
for (const g of ghosts) {
const key = g.afterSceneId || "__start__";
if (!byAnchor.has(key)) byAnchor.set(key, []);
byAnchor.get(key).push(g);
}
const placed = new Set();
const ordered = [];
const pushGhost = (g) => {
if (placed.has(g.id)) return;
placed.add(g.id);
ordered.push({ kind: "ghost", ghost: g, id: `ghost:${g.id}` });
};
for (const g of (byAnchor.get("__start__") || [])) pushGhost(g);
for (const sc of orderedTimelineScenes()) {
ordered.push({ kind: "scene", scene: sc, id: sc.id });
for (const g of (byAnchor.get(sc.id) || [])) pushGhost(g);
const gapSec = Math.max(0, +(sc.gap_after_sec || 0));
if (gapSec > 0.001) {
ordered.push({ kind: "gap", id: `gap:${sc.id}`, afterSceneId: sc.id, gapSec });
}
}
// Orphans (stale afterSceneId) still show — appended after live clips.
for (const g of ghosts) { if (!placed.has(g.id)) pushGhost(g); }
let acc = 0;
return ordered.map((seg) => {
const durationSec = segmentDurationSec(seg);
const out = { ...seg, offsetSec: acc, durationSec };
acc += durationSec;
return out;
});
}
function audioTrackEndSec(t) {
if (!t) return 0;
const start = t.start_sec || 0;
if (t.source_dur != null) return start + t.source_dur;
if (t.pinned_dur != null) return start + t.pinned_dur;
if (isSeparatedAudioTrack(t)) return start + separatedTrackDurSec(t);
const asset = (state.mediaBin || []).find((m) => m.id === t.media_ref);
if (asset?.duration_sec) return start + asset.duration_sec;
return start + 1;
}
function previewTotalSec() {
const p = state.project;
if (!p) return 0;
const segs = buildPreviewSegments();
let total = segs.length ? segs[segs.length - 1].offsetSec + segs[segs.length - 1].durationSec : 0;
for (const ov of p.overlay_tracks || []) {
const end = (ov.start_sec || 0) + (ov.duration_sec || 0);
if (end > total) total = end;
}
for (const t of p.audio_tracks || []) {
const end = audioTrackEndSec(t);
if (end > total) total = end;
}
return total;
}
function hasOverlayOrAudioContent() {
const p = state.project;
if (!p) return false;
if ((p.audio_tracks || []).length) return true;
return (p.overlay_tracks || []).some((ov) => (ov.duration_sec || 0) > 0);
}
// Valid frame counts are per model family — LTX 8k+1, MiniMax H3 17k+5 — so the grid comes
// from PipelineCaps rather than being hardcoded here. Off-grid lengths do not fail softly:
// the H3 latent node snaps its own length up while the sampler expects the number the
// project asked for, and the run dies on the mismatch.
function _snap(n, mode) {
if (window.PipelineCaps?.snapFramesTo) return window.PipelineCaps.snapFramesTo(n, state, mode);
n = Math.round(Number(n) || 0);
const k = (n - 1) / 8;
const r = mode === "floor" ? Math.floor(k) : mode === "ceil" ? Math.ceil(k) : Math.round(k);
return Math.max(9, r * 8 + 1);
}
function snapFrames(n) { return _snap(n, "round"); }
function snapFramesFloor(n) { return _snap(n, "floor"); }
function snapFramesCeil(n) { return _snap(n, "ceil"); }
function _framesFromDuration(durationSec, fps, curFrames) {
const raw = Math.max(1, durationSec) * fps;
const cur = curFrames != null ? curFrames : snapFrames(raw);
let frames;
if (raw < cur - 0.01) frames = snapFramesFloor(raw);
else if (raw > cur + 0.01) frames = snapFramesCeil(raw);
else frames = cur;
if (frames === cur && Math.abs(durationSec - cur / fps) > 0.02) {
// one whole grid step in the direction the drag went (8 frames on LTX, 17 on H3)
const step = window.PipelineCaps?.frameGrid ? window.PipelineCaps.frameGrid(state).step : 8;
frames = raw < cur ? snapFramesFloor(cur - step) : cur + step;
}
return frames;
}
function sceneEffFrames(sc, project) {
const p = project || state.project;
if (!sc || !p) return p?.num_frames_per_scene || 97;
if ((sc.frames_mode || "project") === "project") return p.num_frames_per_scene || 97;
return sc.frames != null ? sc.frames : p.num_frames_per_scene;
}
// Generative scenes the project's Frames field does not reach. Editing that field now
// resets a timeline-derived length (see _propagateProjectSettingsToScenes), so the only
// ones left are those set to Custom on purpose.
function scenesOverridingProjectFrames() {
if (!state.project) return [];
return (state.project.scenes || []).filter(
(s) => s && !isVideoClip(s) && s.frames_mode === "custom");
}
// Put those back on the project's length too — the explicit version of what editing the
// Frames field does implicitly, for the one mode that field deliberately leaves alone.
// Video clips keep theirs: their length is the source file's, not a preference.
function useProjectFramesEverywhere() {
if (!state.project) return;
const targets = scenesOverridingProjectFrames();
if (!targets.length) return;
_historyRecord();
targets.forEach((s) => { s.frames_mode = "project"; s.frames = null; });
_afterTimelineStructureChange();
notify();
scheduleSave();
}
function sceneEffFps(sc, project) {
const p = project || state.project;
if (!sc || !p) return p?.frame_rate || 25;
if ((sc.fps_mode || "project") === "project") return p.frame_rate || 25;
return sc.fps != null ? sc.fps : p.frame_rate;
}
function trimSceneLeft(id, trimSec) {
if (!state.project) return;
const s = scene(id); if (!s || s.frames_mode === "custom") return;
const trim = Math.max(0.05, trimSec);
_historyRecord();
if (isVideoClip(s)) {
const dur = s.source_dur != null ? s.source_dur : sceneDurationSec(s);
if (trim >= dur - 0.05) return;
s.source_in = (s.source_in || 0) + trim;
s.source_dur = Math.max(0.1, dur - trim);
if ((s.frames_mode || "project") === "project") s.frames_mode = "timeline";
_syncSeparatedAudioTracks();
notify();
scheduleSave();
return;
}
const fps = sceneEffFps(s);
const curFrames = Math.max(1, Math.round(sceneDurationSec(s) * fps)); // trim from what's shown
const trimFrames = Math.max(1, Math.round(trim * fps));
const rawNext = curFrames - trimFrames;
if (rawNext < 9) return;
let nextFrames = snapFramesFloor(rawNext);
if (nextFrames >= curFrames) nextFrames = Math.max(9, curFrames - 8);
if (nextFrames >= curFrames) return;
const actualTrimFrames = curFrames - nextFrames;
s.frames = nextFrames;
s.source_in = (s.source_in || 0) + actualTrimFrames / fps;
if ((s.frames_mode || "project") === "project") s.frames_mode = "timeline";
_syncSeparatedAudioTracks();
notify();
scheduleSave();
}
function slipScene(id, deltaSec) {
if (!state.project) return;
const s = scene(id); if (!s) return;
_historyRecord();
s.source_in = Math.max(0, (s.source_in || 0) + deltaSec);
notify();
scheduleSave();
}
function undo() { window.EditorHistory?.undo(window.Store); }
function redo() { window.EditorHistory?.redo(window.Store); }
// Length (s) this scene was ACTUALLY rendered at, frozen at generation time. Null until
// generated (or for old renders saved before this was captured — they fall back to plan).
function renderDurationSec(sceneId) {
const r = state.sceneRenders[sceneId];
return r && r.durationSec != null ? r.durationSec : null;
}
// The PLANNED length (s) from the per-scene frames/fps modes — what the next Generate will
// produce. Ignores any existing render. Use this for "what will generate", not playback.
function _planDurationSec(sc) {
const p = state.project; if (!p || !sc) return 0;
if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur;
const fps = sceneEffFps(sc, p) || 25;
const frames = sceneEffFrames(sc, p) || 1;
return frames / fps;
}
// A scene's duration in seconds for layout / playback / trimming. A generated render is
// IMMUTABLE: once made it keeps the length it was rendered at, so changing the project or
// scene default can never truncate it or make its tail unreachable — regenerate or trim to
// change it. An explicit per-scene length (timeline/custom mode), a slip-trim duration, or
// a split (which clears the frozen length) all take precedence and fall back to the plan.
function sceneDurationSec(sc) {
if (!sc) return 0;
const rd = renderDurationSec(sc.id);
if (rd != null && !isVideoClip(sc) && sc.source_dur == null
&& (sc.frames_mode || "project") === "project") {
return rd;
}
return _planDurationSec(sc);
}
// Insert or adjust a black/silent pause after this clip (seconds).
function setSceneGapAfter(id, sec) {
if (!state.project) return;
const s = scene(id);
if (!s) return;
const gap = Math.max(0, +sec || 0);
if (Math.abs((s.gap_after_sec || 0) - gap) < 0.001) return;
patchScene(id, { gap_after_sec: gap });
}
// Resize a clip by setting its frame count from a target duration (seconds) × fps.
function resizeScene(id, durationSec) {
if (!state.project) return;
const s = scene(id); if (!s) return;
if (s.frames_mode === "custom") return;
const fps = sceneEffFps(s);
const curDur = sceneDurationSec(s);
const target = Math.max(0.1, durationSec);
if (Math.abs(target - curDur) <= 0.02) return;
_historyRecord();
if (isVideoClip(s)) {
s.source_dur = target;
s.frames = snapFrames(target * fps);
} else {
const curFrames = Math.max(1, Math.round(sceneDurationSec(s) * fps)); // resize from what's shown
const nextFrames = _framesFromDuration(target, fps, curFrames);
if (nextFrames === curFrames) return;
s.frames = nextFrames;
}
if ((s.frames_mode || "project") === "project") s.frames_mode = "timeline";
_syncSeparatedAudioTracks();
notify();
scheduleSaveSilent();
}
// Split a clip in two at `atFrames` (defaults to the midpoint). Both halves stay one
// generative unit — Generate collapses them back into a single uncut scene.
function splitScene(id, atFrames) {
if (!state.project) return;
_historyRecord();
const arr = state.project.scenes;
const i = arr.findIndex((s) => s.id === id); if (i < 0) return;
const s = arr[i];
const effFps = sceneEffFps(s);
let frames = sceneEffFrames(s);
if (isVideoClip(s) && s.source_dur != null) frames = snapFrames(s.source_dur * effFps);
const cut = snapFrames(atFrames != null ? atFrames : frames / 2);
if (cut <= 9 || cut >= frames) return;
const cutSec = cut / effFps;
const unitId = genUnitId(s);
const baseOffset = s.cut_offset_frames || 0;
const second = JSON.parse(JSON.stringify(s));
// Assign a client id up-front (server honors it) so the rendered video can be mapped
// to BOTH halves immediately — like an NLE, a split yields two clips of one source.
second.id = "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
second.gen_unit_id = unitId;
second.cut_offset_frames = baseOffset + cut;
second.frames = snapFrames(frames - cut);
second.text = "";
second.rating = "";
second.transition_to_next = s.transition_to_next || "";
second.transition_frames = s.transition_frames || null;
s.gen_unit_id = unitId;
// The split seam between the two halves is an internal HARD CUT: the original clip's
// outgoing edge now belongs to the second half. Clear ALL transition fields on the first
// half — the prompt transition (transition_to_next), the NLE video blend (video_transition),
// and its frame count — so the seam doesn't show/render a phantom (the deep clone already
// carried these onto the second half, which correctly keeps them).
s.frames = cut; s.transition_to_next = ""; s.transition_frames = null; s.video_transition = "";
// "project" frames_mode IGNORES the per-scene `frames` value (sceneEffFrames / backend
// eff_frames both fall back to the project default), so a split of a default-mode scene
// would leave the first half at full project length and render the second half as a whole
// extra scene. Pin both halves to their cut frame counts — same flip resizeScene does.
if ((s.frames_mode || "project") === "project") s.frames_mode = "timeline";
if ((second.frames_mode || "project") === "project") second.frames_mode = "timeline";
if (playsFromSourceMedia(s)) {
const srcIn = s.source_in || 0;
const totalDur = s.source_dur != null ? s.source_dur : (frames / effFps);
const secondDur = second.frames / effFps;
s.source_dur = cutSec;
second.source_in = srcIn + cutSec;
second.source_dur = Math.max(0.1, Math.min(secondDur, totalDur - cutSec));
}
arr.splice(i + 1, 0, second);
// With a pinned custom cut order, _ensureTimelineOrder appends unknown ids at the END of
// the timeline — keep the second half glued to its first half in the cut as well.
if (state.project.timeline_manually_ordered && Array.isArray(state.project.timeline_order)) {
const oi = state.project.timeline_order.indexOf(s.id);
if (oi >= 0) state.project.timeline_order.splice(oi + 1, 0, second.id);
}
// Keep the render across the cut: the second half plays the SAME source video starting
// at the first half's out-point (its in-point + first-half duration).
const r = state.sceneRenders[id];
if (r && r.media) {
delete r.durationSec; // a split is an explicit re-length: both halves revert to plan layout
r._editTok = (r._editTok || 0) + 1; // invalidate any in-flight _probeRenderDuration for this scene
state.sceneRenders[second.id] = {
media: r.media,
inSec: (r.inSec || 0) + cutSec,
renderPrompt: r.renderPrompt ? { ...r.renderPrompt } : _snapshotRenderPrompt(id),
};
}
notify(); scheduleSave();
_syncSeparatedAudioTracks();
}
// Any scene with playable content, regardless of type: an explicit "video" clip plays
// straight off media via source_in/source_dur; a generative scene plays off its OWN
// render record (state.sceneRenders[id].media + inSec, with duration driven by `frames`,
// same convention used by removeFromPlan/renderMediaLabel elsewhere in this file). Returns
// null when neither applies, so callers can treat any scene uniformly via this one shape.
function _renderRef(s) {
if (!s) return null;
const fps = sceneEffFps(s);
if (isVideoClip(s)) {
return { isVideo: true, media: null, renderPrompt: null,
baseInSec: s.source_in || 0, fps, totalSec: s.source_dur != null ? s.source_dur : sceneDurationSec(s) };
}
const r = state.sceneRenders[s.id];
if (!r || !r.media) return null;
return { isVideo: false, media: r.media, renderPrompt: r.renderPrompt || null,
baseInSec: r.inSec || 0, fps, totalSec: sceneDurationSec(s) };
}
function hasPlayableRender(s) { return _renderRef(s) !== null; }
// Auto Montage: chop an already-rendered "lead" scene into shrinking-length segments
// (trailer pacing — cuts get faster toward the end) and interleave a randomly-drawn
// segment from a pool of other rendered scenes after each one. Pure timeline construction,
// no new generation involved — every built segment reuses an existing scene's already-
// rendered media, via source_in/source_dur for an explicit video clip or via a cloned
// sceneRenders entry (media + inSec) for a generative scene's own render.
function autoMontage({ leadId, poolIds, segmentFrames, decay } = {}) {
if (!state.project) return;
const lead = scene(leadId);
const leadRef = _renderRef(lead);
if (!leadRef) return;
const pool = (poolIds || []).map((id) => scene(id)).filter((s) => s && hasPlayableRender(s));
if (!pool.length) return;
const base = Math.max(9, Math.round(segmentFrames || 100));
const dk = decay != null ? Math.min(1, Math.max(0.3, decay)) : 1;
const leadTotal = snapFrames(leadRef.totalSec * leadRef.fps);
// Lead segments stay in chronological order (one continuous shot being interrupted),
// shrinking each cycle so cutaways come faster as the take progresses.
const leadSegs = [];
let consumed = 0, i = 0;
while (consumed < leadTotal - 8) {
const len = Math.min(leadTotal - consumed, Math.max(9, Math.round(base * Math.pow(dk, i))));
leadSegs.push({ start: consumed, frames: snapFrames(len) });
consumed += len;
i++;
}
if (!leadSegs.length) return;
// Pool: chop every selected scene into its own ORDERED queue of base-length
// chunks (chronological — never reordered within a scene). Randomness only picks
// WHICH scene's queue to draw from next; each draw advances that scene's own cursor,
// so a scene's later content can never surface before its own earlier content.
const queues = pool.map((s) => {
const ref = _renderRef(s);
const total = snapFrames(ref.totalSec * ref.fps);
const chunks = [];
let c = 0;
while (c < total - 8) {
const len = Math.min(total - c, base);
if (len >= 9) chunks.push({ scene: s, ref, start: c, frames: snapFrames(len) });
c += len;
}
return { chunks, cursor: 0 };
}).filter((q) => q.chunks.length);
if (!queues.length) return;
function nextPoolChunk() {
let available = queues.filter((q) => q.cursor < q.chunks.length);
if (!available.length) {
queues.forEach((q) => { q.cursor = 0; }); // every scene exhausted — start each over from its own beginning
available = queues;
}
const q = available[Math.floor(Math.random() * available.length)];
return q.chunks[q.cursor++];
}
function buildSegment(src, ref, startFrames, lenFrames) {
const out = JSON.parse(JSON.stringify(src));
const newId = "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
out.id = newId;
out.gen_unit_id = src.id;
out.frames = lenFrames;
out.rating = "";
out.transition_to_next = "cut";
out.transition_frames = null;
const offsetSec = startFrames / ref.fps;
if (ref.isVideo) {
out.source_in = ref.baseInSec + offsetSec;
out.source_dur = lenFrames / ref.fps;
out.frames_mode = "timeline";
} else {
out.cut_offset_frames = (src.cut_offset_frames || 0) + startFrames;
state.sceneRenders[newId] = {
media: ref.media,
inSec: ref.baseInSec + offsetSec,
renderPrompt: ref.renderPrompt ? { ...ref.renderPrompt } : undefined,
};
}
return out;
}
_historyRecord();
const built = [];
leadSegs.forEach((seg) => {
built.push(buildSegment(lead, leadRef, seg.start, seg.frames));
const chunk = nextPoolChunk();
built.push(buildSegment(chunk.scene, chunk.ref, chunk.start, chunk.frames));
});
built[built.length - 1].transition_to_next = "";
state.project.scenes.push(...built);
window.Timeline?.requestAutoFit?.();
notify(); scheduleSave();
return built.length;
}
function moveScene(id, delta) {
if (!state.project) return;
_historyRecord();
const arr = state.project.scenes;
const i = arr.findIndex((s) => s.id === id);
const j = i + delta;
if (i < 0 || j < 0 || j >= arr.length) return;
[arr[i], arr[j]] = [arr[j], arr[i]];
notify(); scheduleSave();
_syncSeparatedAudioTracks();
}
// Move a scene to an absolute index (post-removal index). Used by timeline drag-reorder.
function moveSceneTo(id, toIndex) {
if (!state.project) return;
_historyRecord();
const arr = state.project.scenes;
const from = arr.findIndex((s) => s.id === id);
if (from < 0) return;
toIndex = Math.max(0, Math.min(arr.length - 1, toIndex));
if (toIndex === from) return;
const [it] = arr.splice(from, 1);
arr.splice(toIndex, 0, it);
notify(); scheduleSave();
_syncSeparatedAudioTracks();
}
// ── audio editing ─────────────────────────────────────────────────────────────
function _uid() { return "a" + Math.random().toString(36).slice(2, 9) + Date.now().toString(36); }
function isOverlayAudioTrack(t) {
return !!(t && t.kind === "overlay" && (t.media_ref || t.render_media?.filename));
}
function isSeparatedAudioTrack(t) {
return !!(t && t.kind === "separated");
}
function sceneHasEmbeddedAudio(sc) {
if (!sc || sc.excluded || sc.audio_separated) return false;
if (isVideoClip(sc)) {
const ref = sc.source?.media_ref;
if (!ref) return false;
const asset = (state.mediaBin || []).find((m) => m.id === ref);
return asset?.kind === "video";
}
if (sc.source?.type === "v2v" && sc.source?.media_ref) {
const asset = (state.mediaBin || []).find((m) => m.id === sc.source.media_ref);
if (asset?.kind === "video") return true;
}
return !!(state.sceneRenders[sc.id]?.media);
}
function separatedTrackAudioUrl(st, track) {
if (!track) return null;
if (track.pinned_bin_ref) {
const asset = (st?.mediaBin || state.mediaBin || []).find((m) => m.id === track.pinned_bin_ref);
return asset?.kind === "video" ? window.MovieEditorAPI.mediaUrl(track.pinned_bin_ref) : null;
}
const media = separatedTrackMedia(track);
if (media && st?.project?.id) return window.MovieEditorAPI.resultUrl(st.project.id, media);
if (media && state.project?.id) return window.MovieEditorAPI.resultUrl(state.project.id, media);
return null;
}
function _syncSeparatedAudioTracks() {
if (!state.project) return;
let moved = false;
for (const t of state.project.audio_tracks || []) {
if (!isSeparatedAudioTrack(t)) continue;
const sc = scene(t.scene_id);
if (!sc || sc.excluded) continue;
const off = sceneTimelineOffsetSec(t.scene_id);
if (Math.abs((t.start_sec || 0) - off) > 0.001) {
t.start_sec = off;
moved = true;
}
}
if (moved) scheduleSaveSilent();
}
function addAudioTrack(mediaId, startSec) {
if (!state.project || !mediaId) return;
_historyRecord();
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
state.project.audio_tracks = state.project.audio_tracks || [];
state.project.audio_tracks.push({
id: _uid(),
kind: "overlay",
media_ref: mediaId,
start_sec: +(startSec || 0),
source_in_sec: 0,
source_dur: asset?.duration_sec || null,
volume: 1.0,
label: (asset && asset.name) || "Overlay",
});
notify(); scheduleSave();
}
function sceneTimelineOffsetSec(sceneId) {
if (!state.project) return 0;
for (const seg of buildPreviewSegments()) {
if (seg.kind === "scene" && seg.scene.id === sceneId) return seg.offsetSec;
}
return 0;
}
function separatedTrackForScene(sceneId) {
return (state.project?.audio_tracks || []).find((t) => t.kind === "separated" && t.scene_id === sceneId);
}
function separatedTrackMedia(track) {
if (!track) return null;
if (track.pinned_media?.filename) return track.pinned_media;
const r = state.sceneRenders[track.scene_id];
return r?.media || null;
}
function separatedTrackInSec(track) {
if (!track) return 0;
if (track.pinned_in_sec != null) return track.pinned_in_sec;
if (track.source_in_sec != null) return track.source_in_sec;
const r = state.sceneRenders[track.scene_id];
const sc = scene(track.scene_id);
return (r?.inSec || 0) + (sc?.source_in || 0);
}
function separatedTrackDurSec(track) {
if (!track) return 1;
if (track.pinned_dur != null) return track.pinned_dur;
if (track.source_dur != null) return track.source_dur;
const sc = scene(track.scene_id);
return sc ? sceneDurationSec(sc) : 1;
}
function separateSceneAudio(sceneId) {
if (!state.project) return;
const sc = scene(sceneId);
if (!sc || sc.audio_separated || !sceneHasEmbeddedAudio(sc)) return;
if (separatedTrackForScene(sceneId)) return;
_historyRecord();
const idx = state.project.scenes.indexOf(sc);
const dur = sc.source_dur != null ? sc.source_dur : sceneDurationSec(sc);
const savedVol = sc.audio_volume != null ? sc.audio_volume : 1;
let inSec = sc.source_in || 0;
let pinned_media = null;
let pinned_bin_ref = null;
if (isVideoClip(sc) || (sc.source?.type === "v2v" && sc.source?.media_ref && !state.sceneRenders[sceneId]?.media)) {
pinned_bin_ref = sc.source.media_ref;
} else {
const r = state.sceneRenders[sceneId];
if (!r?.media) { alert("Generate this clip first."); return; }
inSec = clipSourceInSec(sc, r);
pinned_media = JSON.parse(JSON.stringify(r.media));
}
state.project.audio_tracks = state.project.audio_tracks || [];
state.project.audio_tracks.push({
id: _uid(),
kind: "separated",
scene_id: sceneId,
start_sec: sceneTimelineOffsetSec(sceneId),
source_in_sec: inSec,
source_dur: dur,
pinned_media,
pinned_bin_ref,
pinned_in_sec: inSec,
pinned_dur: dur,
volume: savedVol,
label: (isVideoClip(sc) ? `V${idx + 1}` : `S${idx + 1}`) + " audio",
});
sc.audio_separated = true;
sc.audio_volume = 0;
notify(); scheduleSave();
}
function audioTrackInSec(t) {
if (!t) return 0;
if (t.pinned_in_sec != null) return t.pinned_in_sec;
if (t.source_in_sec != null) return t.source_in_sec;
return 0;
}
function audioTrackDurSec(t) {
if (!t) return 0.1;
if (t.pinned_dur != null) return t.pinned_dur;
if (t.source_dur != null) return t.source_dur;
const asset = t.media_ref ? (state.mediaBin || []).find((m) => m.id === t.media_ref) : null;
if (asset?.duration_sec) return asset.duration_sec;
return 1;
}
function audioTrackSourceDurSec(t) {
if (!t) return null;
if (t.pinned_bin_ref) {
const asset = (state.mediaBin || []).find((m) => m.id === t.pinned_bin_ref);
return asset?.duration_sec ?? null;
}
if (t.media_ref) {
const asset = (state.mediaBin || []).find((m) => m.id === t.media_ref);
return asset?.duration_sec ?? null;
}
return null;
}
function audioTrackMaxDurSec(t) {
const inSec = audioTrackInSec(t);
const srcDur = audioTrackSourceDurSec(t);
if (srcDur != null) return Math.max(0.1, srcDur - inSec);
return Math.max(audioTrackDurSec(t), 86400);
}
function _applyAudioTrimFields(t, patch) {
if (patch.start_sec != null) t.start_sec = Math.max(0, +patch.start_sec);
if (patch.source_in_sec != null) t.source_in_sec = Math.max(0, +patch.source_in_sec);
if (patch.source_dur != null) t.source_dur = Math.max(0.1, +patch.source_dur);
if (isSeparatedAudioTrack(t)) {
if (patch.source_in_sec != null) t.pinned_in_sec = t.source_in_sec;
if (patch.source_dur != null) t.pinned_dur = t.source_dur;
}
}
function trimAudioTrackLeft(id, trimSec, opts) {
if (!state.project) return;
const t = (state.project.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
const trim = Math.max(0, +trimSec || 0);
if (trim < 0.02) return;
const inSec = audioTrackInSec(t);
const dur = audioTrackDurSec(t);
const start = t.start_sec || 0;
const linked = isSeparatedAudioTrack(t) && t.scene_id && scene(t.scene_id);
if (opts?.slip) {
slipAudioTrack(id, trim, opts);
return;
}
if (trim >= dur - 0.1) return;
if (!opts?.quiet) _historyRecord();
if (linked) {
_applyAudioTrimFields(t, { source_in_sec: inSec + trim, source_dur: dur - trim });
} else {
_applyAudioTrimFields(t, {
start_sec: start + trim,
source_in_sec: inSec + trim,
source_dur: dur - trim,
});
}
notify(); opts?.quiet ? scheduleSaveSilent() : scheduleSave();
}
function slipAudioTrack(id, deltaSec, opts) {
if (!state.project) return;
const t = (state.project.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
const delta = +deltaSec || 0;
if (Math.abs(delta) < 0.02) return;
const inSec = audioTrackInSec(t);
const dur = audioTrackDurSec(t);
const srcDur = audioTrackSourceDurSec(t);
let newIn = Math.max(0, inSec + delta);
if (srcDur != null) newIn = Math.min(newIn, Math.max(0, srcDur - dur));
if (Math.abs(newIn - inSec) < 0.02) return;
if (!opts?.quiet) _historyRecord();
_applyAudioTrimFields(t, { source_in_sec: newIn, source_dur: dur });
notify(); opts?.quiet ? scheduleSaveSilent() : scheduleSave();
}
function resizeAudioTrack(id, durationSec, opts) {
if (!state.project) return;
const t = (state.project.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
let dur = Math.max(0.1, +durationSec || 0);
dur = Math.min(dur, audioTrackMaxDurSec(t));
if (Math.abs(dur - audioTrackDurSec(t)) < 0.02) return;
if (!opts?.quiet) _historyRecord();
_applyAudioTrimFields(t, { source_dur: dur });
notify(); opts?.quiet ? scheduleSaveSilent() : scheduleSave();
}
function splitAudioTrack(id, timelineSec) {
if (!state.project) return;
const t = (state.project.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
const start = t.start_sec || 0;
const inSec = audioTrackInSec(t);
const dur = audioTrackDurSec(t);
const rel = (+timelineSec || 0) - start;
if (rel <= 0.05 || rel >= dur - 0.05) return;
_historyRecord();
_applyAudioTrimFields(t, { start_sec: start, source_in_sec: inSec, source_dur: rel });
const second = {
id: _uid(),
kind: t.kind || "overlay",
start_sec: start + rel,
source_in_sec: inSec + rel,
source_dur: dur - rel,
volume: t.volume != null ? t.volume : 1,
label: (t.label || "audio") + " (2)",
};
if (t.media_ref) second.media_ref = t.media_ref;
if (t.render_media) second.render_media = JSON.parse(JSON.stringify(t.render_media));
if (t.pinned_bin_ref) second.pinned_bin_ref = t.pinned_bin_ref;
if (t.pinned_media) second.pinned_media = JSON.parse(JSON.stringify(t.pinned_media));
if (isSeparatedAudioTrack(t)) {
second.pinned_in_sec = second.source_in_sec;
second.pinned_dur = second.source_dur;
}
state.project.audio_tracks.push(second);
state.selectedAudioTrackId = second.id;
state.selectedSceneId = null;
state.selectedSceneIds = [];
state.selectedOverlayId = null;
notify(); scheduleSave();
}
function selectAudioTrack(id) {
state.selectedAudioTrackId = id || null;
if (id) {
state.selectedSceneId = null;
state.selectedSceneIds = [];
state.selectedOverlayId = null;
_selectionAnchorId = null;
}
notify();
}
function updateAudioTrack(id, patch, quiet) {
const t = (state.project?.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
if (!quiet) _historyRecord();
Object.assign(t, patch);
notify(); quiet ? scheduleSaveSilent() : scheduleSave();
}
function _syncSeparatedTracksAfterRegen(recordedIds) {
for (const id of recordedIds || []) {
const t = separatedTrackForScene(id);
if (!t) continue;
const sc = scene(id);
if (!sc || sc.excluded) continue;
t.start_sec = sceneTimelineOffsetSec(id);
}
_syncSeparatedAudioTracks();
}
function removeAudioTrack(id, opts) {
if (!state.project) return;
const t = (state.project?.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
if (!opts?.skipConfirm) {
const msg = isSeparatedAudioTrack(t)
? (t.scene_id && scene(t.scene_id)
? "Remove separated audio? The clip will use embedded audio from its current video render again."
: "Remove this detached audio track?")
: "Remove this overlay audio track?";
if (!confirm(msg)) return;
}
_historyRecord();
if (isSeparatedAudioTrack(t) && t.scene_id) {
const sc = scene(t.scene_id);
if (sc) {
sc.audio_separated = false;
if (t.volume != null) sc.audio_volume = t.volume;
}
}
state.project.audio_tracks = (state.project.audio_tracks || []).filter((x) => x.id !== id);
if (state.selectedAudioTrackId === id) state.selectedAudioTrackId = null;
notify(); scheduleSave();
}
function _playheadSec() {
try { return window.Player?.getPlayhead?.() ?? 0; } catch (_) { return 0; }
}
function overlayTrack(id) {
const t = (state.project?.overlay_tracks || []).find((x) => x.id === id) || null;
return t ? normalizeOverlayTrack(t) : null;
}
function projectCanvasSize() {
return { w: state.project?.width || 768, h: state.project?.height || 512 };
}
function normalizeOverlayTrack(ov) {
if (!ov) return ov;
const cw = state.project?.width || 768;
const ch = state.project?.height || 512;
if (ov.kind === "image") {
if (ov.width_px == null && ov.scale != null) ov.width_px = Math.max(8, Math.round(+ov.scale * cw));
if (ov.width_px == null) ov.width_px = Math.max(8, Math.round(cw * 0.35));
if (ov.keep_aspect === undefined) ov.keep_aspect = true;
if (!ov.keep_aspect && ov.height_px == null) ov.height_px = ov.width_px;
}
ov.flip_h = !!ov.flip_h;
ov.flip_v = !!ov.flip_v;
return ov;
}
function normalizeOverlayLanes(project) {
if (!project) return;
project.overlay_tracks = project.overlay_tracks || [];
let lanes = project.overlay_lanes = project.overlay_lanes || [];
// Only conjure a lane to house ORPHANED tracks (an old project saved
// before lanes existed, or a lane removed out from under its tracks).
// Never create one just because nothing has asked for one yet -- the
// timeline starts with zero overlay lanes until the user explicitly
// adds one (see defaultOverlayLaneId(), the one place that creates the
// very first lane, only when an overlay is actually being added).
if (!lanes.length && project.overlay_tracks.length) {
const id = _uid();
lanes = project.overlay_lanes = [{ id, label: "Overlay 1" }];
}
if (!lanes.length) return;
const ids = new Set(lanes.map((l) => l.id));
const fallback = lanes[0].id;
project.overlay_tracks.forEach((t) => {
if (!t.lane_id || !ids.has(t.lane_id)) t.lane_id = fallback;
normalizeOverlayTrack(t);
});
}
function ensureOverlayLanes() {
if (!state.project) return [];
normalizeOverlayLanes(state.project);
return state.project.overlay_lanes;
}
function overlayLaneById(laneId) {
return ensureOverlayLanes().find((l) => l.id === laneId) || null;
}
function overlayLaneIndex(laneId) {
return ensureOverlayLanes().findIndex((l) => l.id === laneId);
}
function sortedOverlayTracks() {
if (!state.project) return [];
ensureOverlayLanes();
const tracks = [...(state.project.overlay_tracks || [])];
tracks.sort((a, b) => {
const ai = overlayLaneIndex(a.lane_id);
const bi = overlayLaneIndex(b.lane_id);
if (ai !== bi) return ai - bi;
return (a.start_sec || 0) - (b.start_sec || 0);
});
return tracks;
}
function defaultOverlayLaneId() {
let lanes = ensureOverlayLanes();
if (!lanes.length) {
// Called only from addImageOverlay/addTextOverlay, i.e. the user is
// explicitly adding their first overlay to this project -- this IS
// the "user adds it explicitly" moment the empty-by-default rule
// waits for, so create the first lane here rather than pre-seeding
// one on every project load.
const id = _uid();
lanes = state.project.overlay_lanes = [{ id, label: "Overlay 1" }];
}
return lanes[lanes.length - 1]?.id || lanes[0]?.id;
}
function addOverlayLane(opts) {
if (!state.project) return null;
_historyRecord();
ensureOverlayLanes();
const id = _uid();
const n = state.project.overlay_lanes.length + 1;
const lane = { id, label: `Overlay ${n}` };
if (opts?.belowId) {
const idx = state.project.overlay_lanes.findIndex((l) => l.id === opts.belowId);
state.project.overlay_lanes.splice(Math.max(0, idx), 0, lane);
} else {
state.project.overlay_lanes.push(lane);
}
notify(); scheduleSave();
return id;
}
function removeOverlayLane(laneId, opts) {
if (!state.project) return;
ensureOverlayLanes();
if (state.project.overlay_lanes.length <= 1) return;
const hasClips = state.project.overlay_tracks.some((t) => t.lane_id === laneId);
if (hasClips && !opts?.skipConfirm && !confirm("Remove this overlay track and all clips on it?")) return;
_historyRecord();
state.project.overlay_tracks = state.project.overlay_tracks.filter((t) => t.lane_id !== laneId);
state.project.overlay_lanes = state.project.overlay_lanes.filter((l) => l.id !== laneId);
notify(); scheduleSave();
}
function _setOverlayLane(overlayId, laneId) {
const t = overlayTrack(overlayId);
if (!t || !overlayLaneById(laneId)) return;
t.lane_id = laneId;
notify(); scheduleSave();
}
function bringOverlayToFront(overlayId) {
const lanes = ensureOverlayLanes();
if (!lanes.length) return;
_historyRecord();
_setOverlayLane(overlayId, lanes[lanes.length - 1].id);
}
function sendOverlayToBack(overlayId) {
const lanes = ensureOverlayLanes();
if (!lanes.length) return;
_historyRecord();
_setOverlayLane(overlayId, lanes[0].id);
}
function bringOverlayForward(overlayId) {
const t = overlayTrack(overlayId);
if (!t) return;
const lanes = ensureOverlayLanes();
const idx = overlayLaneIndex(t.lane_id);
if (idx < 0 || idx >= lanes.length - 1) return;
_historyRecord();
_setOverlayLane(overlayId, lanes[idx + 1].id);
}
function sendOverlayBackward(overlayId) {
const t = overlayTrack(overlayId);
if (!t) return;
const lanes = ensureOverlayLanes();
const idx = overlayLaneIndex(t.lane_id);
if (idx <= 0) return;
_historyRecord();
_setOverlayLane(overlayId, lanes[idx - 1].id);
}
function assignMediaToOverlayLane(laneId, mediaId) {
if (!state.project || !laneId || !mediaId) return;
ensureOverlayLanes();
if (!overlayLaneById(laneId)) return;
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
if (asset?.kind === "image") addImageOverlay(mediaId, undefined, laneId);
}
function selectOverlay(id) {
state.selectedOverlayId = id || null;
if (id) {
state.selectedSceneId = null;
state.selectedSceneIds = [];
state.selectedAudioTrackId = null;
_selectionAnchorId = null;
}
notify();
}
function addImageOverlay(mediaId, startSec, laneId) {
if (!state.project || !mediaId) return;
_historyRecord();
ensureOverlayLanes();
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
const lane = laneId && overlayLaneById(laneId) ? laneId : defaultOverlayLaneId();
state.project.overlay_tracks = state.project.overlay_tracks || [];
const id = _uid();
const { w: cw, h: ch } = projectCanvasSize();
state.project.overlay_tracks.push({
id,
lane_id: lane,
kind: "image",
media_ref: mediaId,
start_sec: Math.max(0, +(startSec ?? _playheadSec())),
duration_sec: 3,
x: 0.5,
y: 0.5,
width_px: Math.max(8, Math.round(cw * 0.35)),
keep_aspect: true,
flip_h: false,
flip_v: false,
opacity: 1,
label: (asset && asset.name) || "Image",
});
selectOverlay(id);
scheduleSave();
}
function addTextOverlay(text, startSec, laneId) {
if (!state.project) return;
_historyRecord();
ensureOverlayLanes();
const lane = laneId && overlayLaneById(laneId) ? laneId : defaultOverlayLaneId();
state.project.overlay_tracks = state.project.overlay_tracks || [];
const id = _uid();
state.project.overlay_tracks.push({
id,
lane_id: lane,
kind: "text",
text: String(text || "Title").trim() || "Title",
font_size: 42,
font_family: "system-ui",
color: "#ffffff",
start_sec: Math.max(0, +(startSec ?? _playheadSec())),
duration_sec: 3,
x: 0.5,
y: 0.5,
flip_h: false,
flip_v: false,
opacity: 1,
label: "Text",
});
selectOverlay(id);
scheduleSave();
}
function updateOverlayTrack(id, patch, quiet) {
const t = overlayTrack(id);
if (!t) return;
if (!quiet) _historyRecord();
Object.assign(t, patch);
notify();
quiet ? scheduleSaveSilent() : scheduleSave();
}
function removeOverlayTrack(id, opts) {
if (!state.project) return;
const t = overlayTrack(id);
if (!t) return;
if (!opts?.skipConfirm && !confirm("Remove this overlay?")) return;
_historyRecord();
state.project.overlay_tracks = (state.project.overlay_tracks || []).filter((x) => x.id !== id);
if (state.selectedOverlayId === id) state.selectedOverlayId = null;
notify(); scheduleSave();
}
function removeSelectedOverlay() {
const id = state.selectedOverlayId;
if (!id) return;
removeOverlayTrack(id, { skipConfirm: true });
}
function setSourceTrim(id, patch) {
if (!state.project) return;
const p = {};
if (patch.source_in != null) p.source_in = Math.max(0, +patch.source_in);
if (patch.source_dur !== undefined) p.source_dur = patch.source_dur == null ? null : Math.max(0.1, +patch.source_dur);
if (Object.keys(p).length) patchScene(id, p);
}
// A media library id, picked to size this scene's generation -- v5's real
// core/projects.py Scene field, not a v4 concept (v4's "drop a picture on
// the timeline" fed a resolution node the same way, through source.media_ref
// instead). api.js round-trips this already, whole-object-passthrough, so
// nothing here needs its own adapter -- it just needed a setter and a place
// to show it (see inspector.js's "Reference media" section).
function setSourceImage(id, mediaId) { patchScene(id, { source_image: mediaId || null }); }
// Media library ids, in the order they were added -- reference images for
// whatever the pipeline's own reference input wants them for (see
// app/shell/reference_wiring.js's wireReferences(), which _buildQueueInputs
// in the generate path below calls with this list at queue time).
function setReferences(id, mediaIds) {
patchScene(id, { references: Array.isArray(mediaIds) ? mediaIds.filter(Boolean) : [] });
}
const ENGINE_PRESETS = {
draft: {
label: "Fast draft",
studio_inputs: { "refiner.split_by_transitions": false },
sampler_inputs: { steps: 20 },
},
quality: {
label: "Quality",
studio_inputs: { "refiner.split_by_transitions": true },
sampler_inputs: { steps: 40 },
},
continuity: {
label: "Continuity-heavy",
guide_settings: { stack_enabled: true, auto_guides: true, identity_pins: true },
continuity_settings: { enabled: true },
sampler_inputs: { steps: 36 },
},
};
function applyEnginePreset(key) {
const preset = ENGINE_PRESETS[key];
if (!preset || !state.project) return;
_historyRecord();
const patch = {};
if (preset.studio_inputs) patch.studio_inputs = { ...(state.project.studio_inputs || {}), ...preset.studio_inputs };
if (preset.sampler_inputs) patch.sampler_inputs = { ...(state.project.sampler_inputs || {}), ...preset.sampler_inputs };
if (preset.guide_settings) patch.guide_settings = { ...(state.project.guide_settings || {}), ...preset.guide_settings };
if (preset.continuity_settings) patch.continuity_settings = { ...(state.project.continuity_settings || {}), ...preset.continuity_settings };
Object.assign(state.project, patch);
notify();
scheduleSave();
}
// ── sync scenes from preview (distribute parsed anchor/transitions back) ──────
function syncFromPreview() {
if (!state.project || !state.preview) return;
_historyRecord();
// Authoritative lossless split (verbatim text, shortcut-aware boundaries).
const v0 = state.preview.parsed_verbatim || state.preview.parsed_raw || state.preview.parsed || {};
const fullPrompt = state.preview.combined_prompt || state.project.global_prompt || "";
const v = _normalizeGlobalPromptParse(fullPrompt, v0);
const parsedScenes = v.scenes || [];
if (!parsedScenes.length) return;
const { next, ghosts: ghostsOut } = _alignScenesFromParsed(
state.project.scenes, parsedScenes, state.sceneGhosts
);
for (const sc of next) sc.transition_to_next = "";
_applyDetectedTransitions(next, null, fullPrompt);
state.project.scenes = next;
state.sceneGhosts = ghostsOut;
_pinnedGlobalPrompt = null;
syncGlobalPromptFromTimeline();
notify();
scheduleSave();
}
// Infer prompt split markers from text between verbatim scene chunks (not visual_effect).
function _inferSplitMarkersFromPrompt(scenes, fullPrompt) {
if (!fullPrompt || !scenes.length) return;
const markers = (state.transitions || []).filter((m) => m.enabled !== false && (m.trigger || m.name));
let pos = 0;
for (let i = 0; i < scenes.length; i++) {
const t = scenes[i].text || "";
if (!t) continue;
const idx = fullPrompt.indexOf(t, pos);
if (idx < 0) continue;
const endOfScene = idx + t.length;
if (i < scenes.length - 1) {
const nextT = scenes[i + 1].text || "";
const nextIdx = nextT ? fullPrompt.indexOf(nextT, endOfScene) : endOfScene;
const gapEnd = nextIdx >= 0 ? nextIdx : fullPrompt.length;
const gap = fullPrompt.slice(endOfScene, gapEnd);
for (const m of markers) {
const trig = m.trigger || m.name;
if (trig && gap.includes(trig)) {
scenes[i].transition_to_next = trig;
break;
}
}
}
pos = endOfScene;
}
}
function _applyDetectedTransitions(scenes, _transitions, fullPrompt) {
if (fullPrompt) _inferSplitMarkersFromPrompt(scenes, fullPrompt);
}
// ── preview ──────────────────────────────────────────────────────────────────
let previewTimer = null;
function refreshPreview(immediate = false) {
clearTimeout(previewTimer);
previewTimer = null;
const run = async () => {
if (!state.project) return;
try {
state.preview = await API.preview(state.project.id, false, true);
_lastPreviewKey = _promptPreviewKey(state.project);
_restorePinnedGlobalPrompt();
} catch (e) { state.preview = { parse_error: e.message }; }
notify();
};
if (immediate) return run();
previewTimer = setTimeout(run, 250);
}
// ── generation ───────────────────────────────────────────────────────────────
const GEN_ERROR_MAP = [
[/audio_vae.*no source/i, "Missing Audio VAE — add an Audio VAE loader in Models (distinct from the video VAE)."],
[/audio_latent.*no source/i, "Missing Audio latent — add an Audio Encoder node in Models (e.g. LTXVAudioEncoder)."],
[/\.vae.*no source/i, "Missing Video VAE — add a Video VAE loader in Models."],
[/\.model.*no source/i, "Missing diffusion model — add a Unet loader in Models."],
[/\.clip.*no source/i, "Missing text encoder — add a CLIP loader in Models."],
[/not installed/i, "A configured node class is not installed in ComfyUI — check Models for details."],
[/Node registry unavailable/i, "ComfyUI is offline or still starting up — wait a moment and try again."],
[/prompt_no_outputs|Prompt has no outputs/i,
"The graph reached ComfyUI without any output node. Usually the built-in pipeline is disabled for this project (Models → Enable built-in pipeline), or your custom pipeline needs a final IMAGE wired to the 🌐 Global video output."],
[/HTTP 53[0-9]|HTTP 52[24]|HTTP 502|Failed to fetch|NetworkError|Load failed/i,
"Cloudflare tunnel lost contact with the vast.ai instance. GPU work may still be running — wait, refresh, and check ComfyUI output. If it keeps failing, use vast.ai direct port access or restart the tunnel."],
];
function _isTransientTunnelError(err) {
const m = String(err?.message || err || "");
return /HTTP 53[0-9]|HTTP 52[24]|HTTP 502|Failed to fetch|NetworkError|Load failed|fetch/i.test(m);
}
async function _retryOnTunnel(fn, attempts) {
attempts = attempts || 8;
let last;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
last = e;
if (!_isTransientTunnelError(e) || i >= attempts - 1) throw e;
await _sleep(1500 + i * 500);
}
}
throw last;
}
async function _flushSaveForGenerate() {
await flushGlobalPromptApply(); // distribute any just-typed global prompt into scenes first
await flushModelsSave(); // persist exposed-control edits before building the graph
await flushSave();
if (_localDirty) {
throw new Error(
"Could not save your latest edits before generate (tunnel may have dropped). "
+ "Wait for the save indicator to clear, then try again."
);
}
}
function _sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function _friendlyGenError(raw) {
// "Generation blocked — …" already names the exact input(s); pass it through so the
// message is actionable instead of a vague "a required input has no source".
if (/Generation blocked/i.test(raw)) return raw;
for (const [pat, msg] of GEN_ERROR_MAP) if (pat.test(raw)) return msg;
return raw;
}
let pollStart = 0;
// Wall clock for the WHOLE operation the user started, as distinct from pollStart, which
// restarts on every run of a multi-run montage. The transport buttons count from the press
// to the finished video, so this has to span every run that one press kicked off.
let genWallStart = 0;
let genWallSource = null;
let genWallApprox = false;
let _interrupted = false;
// Prompt text captured when a run was queued — applied to sceneRenders on completion.
const _queuedRenderPrompts = {};
// Scene ids currently inside an in-flight generate/poll (montage runs included).
const _genInFlightIds = new Set();
// Removed from the timeline while their run was still generating — completion updates
// the ghost only, never sceneRenders, and ghosts are not pruned as "regenerated".
const _removedDuringGen = new Set();
function _markGenInFlight(ids) { (ids || []).forEach((id) => _genInFlightIds.add(id)); }
function _clearGenInFlight(ids) { (ids || []).forEach((id) => _genInFlightIds.delete(id)); }
// Ask ComfyUI to stop the current generation. GenerateBridge's own run.cancel()
// (app/shell/run.js) knows the real state-agnostic /api/jobs/.../cancel route
// and falls back to /interrupt on an older ComfyUI -- reused rather than
// duplicated here (see that file's own comment on why plain /interrupt alone
// silently does nothing for a job still waiting behind another one).
async function interrupt() {
_interrupted = true;
updateGenProgress({ msg: "Interrupting…" });
const GB = window.GenerateBridge;
if (GB) { try { await GB.cancel(); } catch (_) {} return; }
try { await API.interrupt(); } catch (_) {} // bridge not loaded — last resort
}
function _elapsed() {
const s = Math.floor((Date.now() - pollStart) / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
// A nested call (generate -> generateMontage) must keep the ORIGINAL press time, so the
// first start wins and later ones are no-ops until the clock is stopped.
function _genClockStart(source, approx) {
if (genWallStart) return;
genWallStart = Date.now();
genWallSource = source;
genWallApprox = !!approx;
}
function _genClockStop() { genWallStart = 0; genWallSource = null; genWallApprox = false; }
// Read by the transport buttons on their own 1s tick. Deliberately NOT part of `state`:
// a value that changes every second would churn every view fingerprint that hashed it,
// rebuilding whole zones once a second for a number that one button owns.
function genElapsed() {
if (!genWallStart) return null;
return { ms: Date.now() - genWallStart, source: genWallSource, approx: genWallApprox };
}
// Split the active scenes into generation runs. Each anchored scene (empty / image /
// generated_frame / mixed) is its own queue request. Only "carry" continues the
// previous run (continuous chain overlap). Mixed = solo i2v + prior-scene guides.
// True if a scene's image anchor still exists in the media bin (deleted image → fall
// back to carry / i2v guides, not a broken anchor).
function _anchorAvailable(s) {
const t = s.source && s.source.type;
if (t !== "image" && t !== "generated_frame" && t !== "mixed" && t !== "v2v" && t !== "anchor_guide") return false;
const ref = s.source.media_ref;
return !!(ref && (state.mediaBin || []).some((m) => m.id === ref));
}
function _runs() {
const active = state.project.scenes.filter((s) => !s.excluded && isGenerativeScene(s));
if (window.PipelineCaps && !window.PipelineCaps.usesChainSampler(state)) {
return active.length ? [active.map((s) => s.id)] : [];
}
const runs = [];
for (const s of active) {
const t = (s.source && s.source.type) || "empty";
// Editorial subclips always continue the current run; carry / broken anchors too.
const isCarry = isGenSubclip(s)
|| t === "carry"
|| ((t === "image" || t === "generated_frame" || t === "mixed" || t === "v2v") && !_anchorAvailable(s));
if (isCarry && runs.length) runs[runs.length - 1].push(s.id);
else runs.push([s.id]);
}
return runs;
}
// Expand selection to full generative units, then slice each chain run from the
// first to last selected scene in that run (keeps carry / overlap context).
function _expandSelection(sceneIds) {
const wanted = new Set();
for (const id of sceneIds || []) {
const sc = scene(id);
if (!sc || sc.excluded) continue;
genUnitSceneIds(genUnitId(sc)).forEach((sid) => wanted.add(sid));
}
return wanted;
}
function _runsForSceneIds(sceneIds) {
const wanted = _expandSelection(sceneIds);
if (!wanted.size) return [];
const out = [];
for (const run of _runs()) {
const hit = run.filter((id) => wanted.has(id));
if (!hit.length) continue;
const idxs = hit.map((id) => run.indexOf(id));
out.push(run.slice(Math.min(...idxs), Math.max(...idxs) + 1));
}
return out;
}
// Record a completed run's output: map each of the run's scenes to the one source
// video at its cumulative in-point, so splits/deletes later play the right portions.
function _pruneGhostsAfterRegen(recordedSceneIds, protectedGhostIds) {
const ids = new Set(recordedSceneIds || []);
const keep = new Set(protectedGhostIds || []);
// Drop ghosts superseded by this run's fresh renders (anchored after, or sharing id with, a
// recorded scene) — but never a ghost that just received its own finished media this pass.
state.sceneGhosts = (state.sceneGhosts || []).filter(
(g) => keep.has(g.id) || (!ids.has(g.afterSceneId) && !ids.has(g.id))
);
}
function _ghostDurationSec(g) {
if (!g) return 0;
if (g.durationSec != null) return g.durationSec; // frozen real length once rendered
const p = state.project;
const fps = (g.fps_mode !== "project" && g.fps != null) ? g.fps : p.frame_rate;
const frames = (g.frames_mode !== "project" && g.frames != null) ? g.frames : p.num_frames_per_scene;
return (frames || 1) / (fps || 25);
}
// Frames per scene inside one chain-sampler run (mirrors server generate effective_frames).
function _effectiveRunFrames(scenes) {
const p = state.project;
if (!p) return 65;
const trimmed = (scenes || [])
.filter((s) => s && s.frames_mode !== "project" && s.frames != null)
.map((s) => s.frames);
if (trimmed.length && trimmed.every((f) => f === trimmed[0])) return trimmed[0];
return p.num_frames_per_scene || 65;
}
// Map one chain output file onto timeline clips: inSec from scene_layout timestamps only.
// Overlap is internal to generation — preview/export split at boundaries, not (span - overlap).
function _chainRunLayout(sceneIds) {
const p = state.project;
const scenes = (sceneIds || []).map((id) => scene(id)).filter(Boolean);
const fps = p?.frame_rate || 25;
const frames = _effectiveRunFrames(scenes);
const sourceSpanSec = frames / fps;
return { frames, fps, sourceSpanSec };
}
// Chain units in a run (subclips share one layout slot).
function _chainUnitCount(sceneIds) {
let n = 0;
let lastUid = null;
for (const id of sceneIds || []) {
const sc = scene(id);
if (!sc) continue;
const uid = genUnitId(sc);
if (uid !== lastUid) { n += 1; lastUid = uid; }
}
return n;
}
function _fallbackChainPlaybackLayout(sceneIds) {
const unitCount = _chainUnitCount(sceneIds);
if (unitCount <= 1) return null;
const run = _chainRunLayout(sceneIds);
return Array.from({ length: unitCount }, (_, i) => ({
scene_index: i,
start_frame: Math.round(i * run.frames),
in_sec: Math.round(i * run.sourceSpanSec * 1e6) / 1e6,
}));
}
function _resolveSceneLayout(sceneIds, sceneLayout) {
const need = _chainUnitCount(sceneIds);
if (need <= 1) return sceneLayout;
const fallback = _fallbackChainPlaybackLayout(sceneIds);
if (!sceneLayout || !sceneLayout.length) return fallback;
if (sceneLayout.length >= need) return sceneLayout;
if (!fallback) return sceneLayout;
const out = sceneLayout.slice();
for (let i = out.length; i < need; i++) {
if (fallback[i]) out.push(fallback[i]);
}
return out;
}
function _layoutSlot(sceneLayout, chainSceneIdx) {
if (!sceneLayout || !sceneLayout.length) return null;
const byIndex = sceneLayout.find((s) => (s.scene_index ?? s.sceneIndex) === chainSceneIdx);
if (byIndex) return byIndex;
return sceneLayout[chainSceneIdx] || null;
}
function _layoutInSec(sceneLayout, chainSceneIdx, sourceSpanSec) {
const slot = _layoutSlot(sceneLayout, chainSceneIdx);
if (slot) return slot.inSec != null ? slot.inSec : (slot.in_sec != null ? slot.in_sec : 0);
return chainSceneIdx * sourceSpanSec;
}
function _recordInSec(lastChain, sourceEnd, curr, sceneLayout, chainSceneIdx, sourceSpanSec) {
if (lastChain && genUnitId(lastChain) === genUnitId(curr)) return sourceEnd;
return _layoutInSec(sceneLayout, chainSceneIdx, sourceSpanSec);
}
function _snapshotRenderPrompt(sceneId) {
const sc = scene(sceneId);
if (!sc) return null;
const root = genUnitRoot(genUnitId(sc));
const src = root?.source || sc.source || {};
return {
text: (root?.text || "").trim(),
anchor: (state.project?.anchor || "").trim(),
sourceType: src.type || "carry",
mediaRef: src.media_ref || null,
sourceSnapshotted: true,
};
}
function _snapshotRenderSource(sceneId) {
const sc = scene(sceneId);
if (!sc) return null;
const root = genUnitRoot(genUnitId(sc));
const src = root?.source || sc.source || {};
return { sourceType: src.type || "carry", mediaRef: src.media_ref || null };
}
function renderMediaLabel(mediaRef) {
if (!mediaRef) return "(none)";
const m = (state.mediaBin || []).find((x) => x.id === mediaRef);
return m?.name || mediaRef;
}
function renderPromptForScene(sceneId) {
return state.sceneRenders[sceneId]?.renderPrompt || null;
}
function renderPromptMismatch(sceneId) {
const snap = renderPromptForScene(sceneId);
if (!snap) return null;
const current = (_snapshotRenderPrompt(sceneId)?.text || "");
const rendered = (snap.text || "");
if (current === rendered) return null;
return { current, rendered, anchor: snap.anchor || "" };
}
function renderAnchorMismatch(sceneId) {
const snap = renderPromptForScene(sceneId);
if (!snap?.sourceSnapshotted) return null;
const current = _snapshotRenderSource(sceneId);
if (!current) return null;
const renderedType = snap.sourceType || "carry";
const renderedRef = snap.mediaRef || null;
if (renderedType === current.sourceType && renderedRef === current.mediaRef) return null;
return {
renderedType,
renderedRef,
renderedLabel: renderedRef ? renderMediaLabel(renderedRef) : null,
currentType: current.sourceType,
currentRef: current.mediaRef,
currentLabel: current.mediaRef ? renderMediaLabel(current.mediaRef) : null,
};
}
function renderIsStale(sceneId) {
return !!(renderPromptMismatch(sceneId) || renderAnchorMismatch(sceneId));
}
function _queueRenderPrompts(sceneIds) {
for (const id of sceneIds || []) {
const snap = _snapshotRenderPrompt(id);
if (snap) _queuedRenderPrompts[id] = snap;
}
}
// Post-render upscale (render/upscale.py): one ComfyUI job per file, then the upscaled file
// replaces the original under every scene and ghost that plays it (a chain run is one file).
const _upscaling = new Set();
const _mediaKey = (m) => `${m.type || "output"}/${m.subfolder || ""}/${m.filename}`;
function _swapRenderMedia(from, to) {
let n = 0;
const same = (m) => !!m && _mediaKey(m) === _mediaKey(from);
for (const id of Object.keys(state.sceneRenders || {})) {
const r = state.sceneRenders[id];
if (r && same(r.media)) { state.sceneRenders[id] = { ...r, media: to }; n++; }
}
state.sceneGhosts = (state.sceneGhosts || []).map((g) => (same(g.media) ? { ...g, media: to } : g));
if (n) { _validateRendersToken++; _syncEditorStateToProject(); scheduleSaveSilent(); }
return n;
}
async function upscaleMedia(media) {
const model = getEditorSetting("upscaleModel");
if (!media || !media.filename || media.subfolder === "funpack_upscaled") return; // never upscale twice
if (!model) {
set({ notice: "Upscale: pick a model in Settings ▸ Editor ▸ Upscale finished renders." });
return;
}
const key = _mediaKey(media);
const pid = state.project && state.project.id;
if (!pid || _upscaling.has(key)) return;
_upscaling.add(key);
set({ notice: `Upscaling with ${model}…` });
let notice = null; // null: leave whatever else was said meanwhile
try {
const promptId = await API.queueUpscale(media, model);
let misses = 0;
for (;;) {
await new Promise((res) => setTimeout(res, 2000));
const r = await API.upscaleResult(promptId);
if (!r) { misses = 0; continue; }
// A job neither queued nor in history is gone; a reply that fails to arrive is retried a while.
if (r.retry && ++misses < 30) continue;
if (r.retry) throw new Error("lost contact with ComfyUI");
if (r.gone && ++misses < 3) continue;
if (r.gone) throw new Error("the job left ComfyUI's queue without finishing (interrupted or ComfyUI restarted)");
if (r.error) throw new Error(r.error);
const out = { ...r.videos[0], kind: "videos" };
if (!state.project || state.project.id !== pid) notice = `Upscaled ${media.filename} in another project: it is in output/funpack_upscaled.`;
else if (!_swapRenderMedia(media, out)) notice = "Upscaled, but that render was replaced meanwhile, so nothing was swapped.";
break;
}
} catch (e) {
notice = `Upscale failed: ${e && e.message ? e.message : e}`;
} finally {
_upscaling.delete(key);
set({ notice: notice !== null ? notice : (state.notice && state.notice.startsWith("Upscaling with") ? "" : state.notice) });
}
}
function upscaleRender(sceneId) {
const r = (state.sceneRenders || {})[sceneId];
return upscaleMedia(r && r.media);
}
function isUpscaling(sceneId) {
const m = ((state.sceneRenders || {})[sceneId] || {}).media;
return !!(m && _upscaling.has(_mediaKey(m)));
}
function _recordSegment(mediaList, targetSceneIds, opts) {
if (!mediaList || !mediaList.length || !targetSceneIds || !targetSceneIds.length) return;
const completedScenes = opts?.completedScenes ?? targetSceneIds.length;
const ids = targetSceneIds.slice(0, Math.max(0, completedScenes));
if (!ids.length) return;
const primary = mediaList.find((m) => m.kind === "videos" || m.kind === "gifs") || mediaList[0];
const sceneLayout = _resolveSceneLayout(ids, opts?.sceneLayout || null);
const layout = _chainRunLayout(ids);
const sourceSpanSec = layout.sourceSpanSec;
let sourceEnd = 0;
let lastChain = null;
let chainSceneIdx = 0;
let clearedRating = false;
const recordedSceneIds = [];
// Ghosts of scenes removed mid-generation that just received their finished media in this
// pass. They must survive _pruneGhostsAfterRegen — they ARE the fresh result, not the stale
// pre-regen render the prune is meant to drop.
const completedGhostIds = [];
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
if (_removedDuringGen.has(id)) {
const ghosts = state.sceneGhosts || [];
const gi = ghosts.findIndex((g) => g.id === id);
if (gi >= 0) {
const ghost = ghosts[gi];
const sameUnit = lastChain && genUnitId(lastChain) === genUnitId(ghost);
const inSec = sameUnit ? sourceEnd : _recordInSec(lastChain, sourceEnd, ghost, sceneLayout, chainSceneIdx, sourceSpanSec);
const ghostDur = _ghostDurationSec(ghost); // freeze before any project change can shrink it
ghosts[gi] = { ...ghost, media: primary, inSec, durationSec: ghostDur, pendingGen: false };
state.sceneGhosts = ghosts;
completedGhostIds.push(id);
sourceEnd = inSec + ghostDur;
lastChain = ghost;
if (!sameUnit) chainSceneIdx += 1;
}
_removedDuringGen.delete(id);
continue;
}
const sc = scene(id); if (!sc) continue;
const sameUnit = lastChain && genUnitId(lastChain) === genUnitId(sc);
const inSec = sameUnit ? sourceEnd : _recordInSec(lastChain, sourceEnd, sc, sceneLayout, chainSceneIdx, sourceSpanSec);
const renderPrompt = _queuedRenderPrompts[id] || _snapshotRenderPrompt(id);
delete _queuedRenderPrompts[id];
const realDur = _planDurationSec(sc); // freeze the length this render was actually made at
// promptId: which ComfyUI run made it, so rating it can teach the taste key
// from THAT run's capture and nothing else.
state.sceneRenders[id] = { media: primary, inSec, renderPrompt, durationSec: realDur, promptId: opts?.promptId || null };
recordedSceneIds.push(id);
const root = genUnitRoot(genUnitId(sc));
if (root && root.rating) { root.rating = ""; clearedRating = true; }
sourceEnd = inSec + realDur;
lastChain = sc;
if (!sameUnit) chainSceneIdx += 1;
}
_pruneGhostsAfterRegen(recordedSceneIds, completedGhostIds);
_syncSeparatedTracksAfterRegen(recordedSceneIds);
// Adopt the real encoded length for standalone renders so the clip matches the file (the
// frozen length above is the plan estimate). Chains are skipped by the standalone guard.
for (const id of recordedSceneIds) _probeRenderDuration(id);
if (clearedRating) scheduleSaveSilent();
if (recordedSceneIds.length) {
_validateRendersToken++;
// Only retire THIS sweep's own stale-render notice -- an unrelated one
// currently showing (e.g. a reference that couldn't be wired into the
// very run that just finished, or an export/save confirmation) is not
// this function's to clear.
if (STALE_RENDERS_NOTICE_RE.test(state.notice)) state.notice = "";
_syncEditorStateToProject();
scheduleSaveSilent();
}
}
// The extension is what actually tells kinds apart, not which ComfyUI output
// key a file arrived under -- SaveVideo hands its file back through the same
// generic "images" list the frontend's own gallery mechanism reuses for every
// output kind (see app/shell/run.js's `handle()`), so the key alone can't
// tell a produced video from a produced image.
function _kindForFilename(name) {
const ext = String(name || "").split(".").pop().toLowerCase();
if (ext === "gif") return "gifs";
if (["mp4", "webm", "mov", "mkv"].includes(ext)) return "videos";
if (["mp3", "wav", "flac", "ogg", "m4a"].includes(ext)) return "audio";
return "images";
}
// Which scene(s) + status prefix the run GenerateBridge is currently driving
// belongs to -- set by _generateRun right before it queues, or by the
// "adopt" handler below when the run was found on reload instead of started
// by this page.
let _genRunPrefix = "Generating…";
let _genRunSceneIds = [];
let _genBridgeWired = false;
// Whether the wall clock currently running belongs to a run ADOPTED on
// reload rather than one _generateRun started. A montage/selected batch
// makes several _generateRun calls under ONE press (genWallStart spans the
// whole batch, primed once by _clockedMontage/_clockedSelected's own
// wrapper) -- stopping the clock after every individual run's terminal
// phase would kill it after run 1 of N and never restart it for the rest.
// Only an adopted run has nobody else who will ever stop it.
let _genAdoptedRun = false;
// Whether GenerateBridge has a run of THIS page's making in flight --
// dedicated to that question alone, and nothing else's. state.gen.state
// looks like it would answer this but doesn't: Export, Save to Media bin
// and Render Final all set it to "running" for their own, entirely local
// work, and asking GenerateBridge's own run.state.phase instead has a real
// gap of its own (it stays "idle" for the whole check() round trip before
// run.start() ever fires). Flipped synchronously the moment a call is
// accepted -- before any await -- so two calls fired back to back without
// an await between them still serialize correctly.
let _genRunActive = false;
// One persistent subscription translating GenerateBridge's real run state
// (app/shell/run.js -- ComfyUI's own /prompt + /ws, not a second queue of
// FunPack's own) into the state.gen shape every existing status/progress/
// clock UI already renders. Wired once; every _generateRun call after that
// just sets what the NEXT status message should say and lets this paint it.
function _wireGenerateBridge() {
if (_genBridgeWired) return;
const GB = window.GenerateBridge;
if (!GB) return;
_genBridgeWired = true;
GB.on("say", (msg) => {
if (!msg) return;
set({ gen: { state: "error", promptId: (state.gen && state.gen.promptId) || null, media: [], msg } });
});
// check()'s `notes` -- e.g. "N settings didn't reach the pipeline" -- says
// something is true of the pipeline without stopping the run. The OLD
// per-run code went out of its way to surface this (its own comment: "the
// whole report was being thrown away here"); it must not go back to being
// thrown away just because it now arrives through a different seam.
GB.on("warn", (msg) => {
if (!msg) return;
state.notice = msg;
notify();
});
GB.on("hold", (msg) => {
set({ gen: { state: "queuing", promptId: null, media: [], msg: msg || "" } });
});
GB.on("release", (runState) => {
// Only idle is worth clearing back to Ready here -- any other phase is
// already being painted by the subscribe() below, and stomping it with
// a stale "release" delivery would flash the status backwards.
if (runState.phase === "idle") set({ gen: { state: "idle", promptId: null, media: [], msg: "" } });
});
// A run this page did not start itself -- found on reload via ComfyUI's
// own /queue or /history, not anything remembered client-side (see
// app/shell/client.js). There is no prefix for it, and no scene selected
// it, so both are set from what the bridge found instead of a click. The
// clock is primed here for the same reason the old resumeRunningGeneration
// did: reattach starts blind, and without this _elapsed()/genElapsed()
// read a clock that was never started (Date.now() minus zero) instead of
// real elapsed time.
GB.on("adopt", ({ sceneId, projectId, sceneIds }) => {
_genRunPrefix = "Generation";
_genRunSceneIds = (sceneIds && sceneIds.length) ? sceneIds : (sceneId ? [sceneId] : []);
_genAdoptedRun = true;
_genRunActive = true;
pollStart = Date.now();
_genClockStart("all", true); // true: approximate — we never saw the press
if (projectId && (!state.project || state.project.id !== projectId)
&& (state.projects || []).some((p) => p.id === projectId)) {
loadProject(projectId).then(notify);
}
});
GB.subscribe((runState) => {
const { phase, promptId, progress, images, audio, error } = runState;
if (phase === "idle") return; // the resting state — nothing new to draw
// Only an ADOPTED run's terminal phase stops the clock here -- one
// _generateRun starting mid-batch's own caller (_clockedGenerate/
// Montage/Selected) already stops it in a `finally` that wraps the
// WHOLE batch, not each individual run inside it, so unconditionally
// stopping here on every terminal phase killed the clock after run 1
// of N and never restarted it for the rest. An adopted run has nobody
// else who will ever stop it (see the "adopt" handler above for why it
// needs starting here at all), so it alone is cleared once it lands.
if (phase === "done" || phase === "failed" || phase === "cancelled") {
// _genRunActive is cleared unconditionally here -- covers BOTH an
// adopted run (nothing else ever clears it) and a click-started one
// (_generateRun's own `finally` also clears it, redundantly but
// harmlessly, for the paths that never reach a terminal phase at all
// -- a check() refusal, for one, which this subscription never sees).
_genRunActive = false;
if (_genAdoptedRun) { _genAdoptedRun = false; _genClockStop(); }
}
if (phase === "queued") {
set({ gen: { state: "queuing", promptId, media: [], msg: `${_genRunPrefix}: queuing…` } });
} else if (phase === "running") {
const step = progress ? ` · sampling ${progress.value}/${progress.max}` : "";
set({
gen: {
state: "running", promptId, media: [],
step: progress ? progress.value : 0, maxStep: progress ? progress.max : 0,
msg: `${_genRunPrefix}: generating… ${_elapsed()}${step}`,
},
});
} else if (phase === "done") {
const mediaList = [...(images || []), ...(audio || [])]
.map((f) => ({ ...f, kind: _kindForFilename(f.filename) }));
if (mediaList.length && _genRunSceneIds.length) _recordSegment(mediaList, _genRunSceneIds, { promptId });
_announceQuarantine();
if (getEditorSetting("upscaleMode") === "always") upscaleMedia(mediaList.find((m) => m.kind === "videos"));
set({
gen: {
state: "done", promptId, media: mediaList,
msg: mediaList.length ? "" : "Completed but no output media found — check the ComfyUI terminal.",
},
});
} else if (phase === "failed") {
_announceQuarantine();
set({
gen: {
state: "error", promptId, media: [],
msg: error ? `ComfyUI error: ${error.message}` : "Generation failed inside ComfyUI — check the ComfyUI terminal for details.",
},
});
} else if (phase === "cancelled") {
set({ gen: { state: "idle", promptId, media: [], msg: "Interrupted." } });
}
});
}
// What a run actually sends for the prompt: the scene's own text for a
// single-scene run, or the full verbatim montage (anchor + every active
// scene's text, transitions included) for a multi-scene one -- the same
// split `generate()`/`generateMontage()` already make below. Plus
// project.video's width/height, wherever the pipeline asks for them, by
// role.input name -- same convention app/boot.js's syncVideo() uses.
//
// Reference-image wiring (assets.source_image/reference_N -- H3's own
// reference-to-video slot today) reads the FIRST scene in this run's own
// source_image/references (core/projects.py's real per-scene fields --
// api.js's getProject/saveProject already round-trip them whole-object-
// passthrough, nothing v4-specific to adapt). One run can cover several
// scenes at once (H3's whole-timeline-in-one-call shape, no chain sampler
// to split it up), and there is no per-scene reference slot to give each
// one its own set -- the run's first/anchor scene's picks stand for the
// whole run, the same way its text already does for a single-scene run
// below.
// Returns {raw, unwiredNotice} rather than setting state.notice itself --
// GB.generate() below runs check() right after this, and check()'s own
// "warn" note (app/shell/session.js's transport.warn -> the GB.on("warn")
// handler a few hundred lines up) writes the SAME state.notice field. Two
// writers, one field, whichever fires last wins; setting this one here
// let check()'s note silently clobber it before anyone ever saw it. The
// caller applies unwiredNotice AFTER GB.generate() resolves instead, so it
// wins that race on purpose (see extensive_testing round 1 finding).
// The value a `project.video` role asks for. The editor keeps these as flat project fields
// (width, height, frames per scene), the pipeline names them per node (width/target_width,
// length...); a value stored in project.video under the role's own name wins.
// `length` is the frames THIS RUN covers: the project's per scene, times the scenes in the run.
function _videoRoleValue(input, targetSceneIds) {
const p = state.project;
if (p.video && p.video[input] !== undefined) return p.video[input];
if (input === "width" || input === "target_width") return p.width;
if (input === "height" || input === "target_height") return p.height;
if (input === "frame_rate" || input === "fps") return p.frame_rate;
if (input === "length") {
// The PROJECT's length per scene, never the scene's own: a scene cropped on the timeline
// regenerates at the project length (the crop was a timeline decision; a regenerate is new).
// Story scenes, not scene rows: a split clip is one story scene in two rows.
const n = new Set((targetSceneIds || []).map((id) => scene(id)).filter(Boolean).map((sc) => genUnitId(sc))).size || 1;
return n * p.num_frames_per_scene;
}
return undefined;
}
async function _buildQueueInputs(targetSceneIds) {
const raw = {};
let unwiredNotice = null;
const GB = window.GenerateBridge;
if (!GB || !state.project) return { raw, unwiredNotice };
for (const slot of (window.PipelineState.slots() || [])) {
for (const role of (slot.roles || [])) {
if (role.at !== "project.video") continue;
// An input another node feeds takes its value from that node: sending one too is refused.
if (Array.isArray((slot.inputs || {})[role.input])) continue;
const v = _videoRoleValue(role.input, targetSceneIds);
if (v === undefined) continue;
raw[slot.id] = { ...(raw[slot.id] || {}), [role.input]: v };
}
}
const primary = scene(targetSceneIds[0]);
if (primary) {
const source = GB.slotForRole("assets.source_image");
const reservedSlotIds = [];
if (source) {
raw[source.slot.id] = { ...(raw[source.slot.id] || {}), media_id: primary.source_image || "" };
reservedSlotIds.push(source.slot.id);
}
const { overrides, unwired } = GB.wireReferences(
primary.references || [], window.PipelineState.slots() || [], reservedSlotIds);
for (const [slotId, fields] of Object.entries(overrides)) {
raw[slotId] = { ...(raw[slotId] || {}), ...fields };
}
// Two different causes look the same from here -- fewer reference
// slots than references picked, or a malformed preset pointing two
// references at the same destination -- but both are true either way:
// the reference did not make it into this run (see wireReferences's
// own comment for why the wording doesn't try to guess which).
if (unwired > 0) {
unwiredNotice = unwired === 1
? "1 reference could not be wired into this pipeline and will not be used."
: `${unwired} references could not be wired into this pipeline and will not be used.`;
}
}
// The Chat comments ride with the project and reach the enhancer for this run.
const enhancer = (window.PipelineState.slots() || []).find((s) => s.node === "FunPackEnhancePrompt");
// The prompt as TYPED (shortcuts not yet expanded): what a comment is about. The expanded
// text differs every run whenever a shortcut has several replacements.
const typed = targetSceneIds.length > 1
? buildGlobalPromptFromTimeline(state.project)
: ((primary && primary.text) || "").trim();
state.lastEnhanceTyped = typed;
const chat = state.project && state.project.editor_settings && state.project.editor_settings.enhance_chat;
const mine = Array.isArray(chat) ? chat.filter((r) => r && (r.original === undefined || r.original === typed)) : [];
if (enhancer && Array.isArray(chat) && mine.length < chat.length) {
const note = `${chat.length - mine.length} Chat comment(s) are about a different prompt and were left out of this run.`;
unwiredNotice = unwiredNotice ? `${unwiredNotice} ${note}` : note;
}
if (enhancer && mine.length) {
raw[enhancer.id] = { ...(raw[enhancer.id] || {}), chat: JSON.stringify(mine) };
}
// A run of several scenes in one prompt is one combined text: a rewrite would turn
// the cut words into prose and merge the scenes, so the enhancer sits this one out.
if (enhancer && targetSceneIds.length > 1 && (enhancer.inputs || {}).enabled === true) {
raw[enhancer.id] = { ...(raw[enhancer.id] || {}), enabled: false };
const note = "Several scenes go out as one prompt, so the prompt enhancer was skipped for this run.";
unwiredNotice = unwiredNotice ? `${unwiredNotice} ${note}` : note;
}
const found = GB.slotForRole("generation.prompt");
if (!found) return { raw, unwiredNotice };
const text = targetSceneIds.length > 1
? buildGlobalPromptFromTimeline(state.project)
: (() => {
// The scene's own words only: /api/prompt/expand puts the anchor and postfix
// around them, and sending the anchor here as well wrote it into the prompt twice.
const sc = scene(targetSceneIds[0]);
return ((sc && sc.text) || "").trim();
})();
if (text == null) return raw;
try {
const res = await fetch("/funpack/api/prompt/expand", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text, anchor: state.project.anchor || "", postfix: state.project.postfix || "",
postfix_enabled: state.project.postfix_enabled !== false, variables: state.project.variables || [],
// No per-run sampler seed is reachable from here (nothing threads a
// real generation seed through this path yet) — a fresh one per
// queue gives real variety across separate Generate clicks, same
// as app/boot.js's own queueInputs().
seed: Math.floor(Math.random() * 2 ** 31) || 1,
// A real run moves each shortcut's revolver on; every other expansion only peeks.
commit: true,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const expanded = (await res.json()).text;
raw[found.slot.id] = { ...(raw[found.slot.id] || {}), [found.role.input]: expanded };
} catch (_) {
// The literal words still queue -- with the anchor and postfix joined on, as the
// server would have -- rather than a prompt-craft feature blocking the run. Shortcuts
// and $variables are NOT applied, and the person is told so.
const p = state.project;
const literal = [(p.anchor || "").trim(), text, p.postfix_enabled !== false ? (p.postfix || "").trim() : ""]
.filter(Boolean).join(" ");
raw[found.slot.id] = { ...(raw[found.slot.id] || {}), [found.role.input]: literal };
state.notice = "Could not reach the prompt expander: this run uses your words as typed, without shortcuts or $variables.";
notify();
}
return { raw, unwiredNotice };
}
async function _pollFfmpegJob(statusFn, jobId, clipCount, busyLabel) {
let transientStreak = 0;
for (;;) {
if (_interrupted) throw new Error("Interrupted.");
try {
const st = await statusFn(jobId);
transientStreak = 0;
if (st.state === "done") return st;
if (st.state === "error") throw new Error(st.detail || `${busyLabel} failed`);
updateGenProgress({ msg: `${busyLabel} ${clipCount} clip(s)…` });
} catch (e) {
if (_isTransientTunnelError(e)) {
transientStreak++;
if (transientStreak < 120) {
updateGenProgress({
msg: `${busyLabel} ${clipCount} clip(s) · tunnel reconnect (${transientStreak})…`,
});
await _sleep(2000);
continue;
}
}
throw e;
}
await _sleep(2000);
}
}
async function _pollRenderJob(jobId, clipCount) {
return _pollFfmpegJob(
(id) => API.renderFinalStatus(state.project.id, id),
jobId,
clipCount,
"Stitching",
);
}
async function _pollExportClipsJob(jobId, clipCount) {
return _pollFfmpegJob(
(id) => API.exportClipsStatus(state.project.id, id),
jobId,
clipCount,
"Combining",
);
}
// Every refinement key a reset will wipe: the project/default key plus every non-default key
// whose shortcut fires anywhere in the prompt. Uses the scene-count-independent
// refinement_key_pool, which mirrors the backend _v2_reset_prompt_keys EXACTLY — the per-scene
// scene_refinement_keys can drop a key via its divergence fallback, which would under-report and
// let the reset silently wipe a key the user wasn't warned about. Falls back to the per-scene
// list only for an older preview payload without the pool.
function sessionResetKeys() {
const pv = state.preview || {};
const defKey = (state.project?.refinement_key || "default").trim() || "default";
const out = new Set([defKey]);
if (Array.isArray(pv.refinement_key_pool)) {
pv.refinement_key_pool.forEach((k) => { if (k) out.add(k); });
} else {
(pv.scene_refinement_keys || []).forEach((rk) => {
if (rk && !rk.uses_default) (rk.keys || []).forEach((k) => { if (k) out.add(k); });
});
}
return [...out];
}
// Toggle a pending Studio session reset — applied to the FIRST run of the next
// generation. Clicking again disarms it (in case of a mis-click). Arming asks for
// confirmation, listing every key it will wipe so non-default keys aren't lost silently.
let _resetSessionPending = false;
async function resetStudioSession() {
if (!_resetSessionPending) {
// Read the key list off a FRESH preview: the cached one can lag the prompt (debounced
// refresh, or _distributeGlobalPrompt carrying old scene_refinement_keys forward), so a
// just-added key-bound shortcut would otherwise be missing from the wipe list.
try { await refreshPreview(true); } catch (_) {}
const keys = sessionResetKeys();
const msg = `This action will reset Studio learning for keys: ${keys.join(", ")}.\n\n`
+ "It is applied on the next generation. To avoid resetting a non-default key, "
+ "remove the shortcut that activates it from the prompt.\n\nProceed?";
if (!confirm(msg)) return;
}
_resetSessionPending = !_resetSessionPending;
set({ resetSessionArmed: _resetSessionPending });
}
// Generate one run (single scene, or an explicit list of scene ids). Returns success.
// i2v-node bypass overrides for a run that contains an "Anchor as guide" scene, when the
// user has declared a custom i2v node in Editor settings (built-in pipeline needs none).
function _anchorGuideNodeOverrides(sceneIds) {
if (!getEditorSetting("anchorGuideHasI2v")) return null;
const cfg = getEditorSetting("anchorGuideBypass");
if (!cfg || !cfg.node || !cfg.input) return null;
const hasAG = (sceneIds || []).some((id) => {
const s = scene(id);
return s && s.source && s.source.type === "anchor_guide";
});
if (!hasAG) return null;
return [{ node: cfg.node, input: cfg.input, value: cfg.value }];
}
// "Reference image 2" (input_sources "prevframe") auto-fills from whatever scene rendered
// immediately before the one being generated — only meaningful for a single-scene run.
// Currently unused: v5's reference-image wiring (assets.reference_N) isn't built for
// legacy scenes yet (see _buildQueueInputs's own comment) — kept for when it is.
function _prevSceneMedia(onlyScene) {
if (!onlyScene) return null;
const scenes = (state.project?.scenes || []).filter((s) => !s.excluded);
const idx = scenes.findIndex((s) => s.id === onlyScene);
if (idx <= 0) return null;
const media = state.sceneRenders[scenes[idx - 1].id]?.media;
return media && media.filename ? { filename: media.filename, subfolder: media.subfolder || "" } : null;
}
// Generate one run through v5's real check -> queue -> terminal flow
// (GenerateBridge, wired in generate_bridge.js over app/shell/run.js and
// session.js). `resetSession` (Studio-session reset) and `extraOverrides`
// (REINS captures, the anchor-guide i2v bypass) are v4 Studio/Chain-Sampler
// machinery with no v5 module providing them yet -- said loudly rather than
// silently dropped, per the port plan's "leave placeholders for features".
async function _generateRun(sceneIds, onlyScene, prefix, resetSession, extraOverrides) {
_interrupted = false;
const GB = window.GenerateBridge;
if (!GB) {
set({ gen: { state: "error", promptId: null, media: [], msg: "Generation isn't wired up yet — reload the page." } });
return false;
}
_wireGenerateBridge();
// GenerateBridge holds exactly ONE run, and _genRunPrefix/_genRunSceneIds/
// _genAdoptedRun belong to whichever call is currently driving it. A
// second call reaching here while a first is still queued/running (the
// main Generate/Selected buttons are gated against this by `busy(st)` in
// actionbar.js, but H3 Combo Sweep's own "Run sweep" button is not) would
// silently overwrite those with ITS scene/prefix, then get refused deeper
// inside (session.js's own starting/phase guard) -- leaving the real run's
// eventual result recorded onto the WRONG scene when it finishes, and its
// clock stop possibly skipped. Refused here, loudly, before any of that
// shared state is touched, rather than corrupting it silently.
//
// Asked of _genRunActive, a flag dedicated to exactly this question --
// NOT state.gen.state (Export, Save to Media bin and Render Final also
// set that to "running" for their own, entirely local work -- reading it
// here would wrongly refuse a real sweep run with a message borrowed from
// whichever unrelated job happened to be in progress) and NOT
// GenerateBridge's own run.state.phase either (it stays "idle" for the
// whole check() round trip, before run.start() ever fires -- a real gap
// a second call could still race through). Checked and flipped in the
// same synchronous breath, before any await, so two calls fired back to
// back with nothing awaited between them still serialize correctly.
if (_genRunActive) {
console.warn(`[FunPack] "${prefix || "a generation"}" was refused: another generation is already in flight.`);
return false;
}
_genRunActive = true;
// A run started through the normal click path owns its own clock
// lifecycle (the _clockedX wrapper's `finally`, spanning the whole
// batch) — any stale flag left over from a PRIOR adopted run must not
// make this new run's terminal phase stop that batch clock early.
_genAdoptedRun = false;
if (resetSession) {
console.warn("[FunPack] a Studio session reset was requested, but v5 has no Studio session to reset yet — ignored.");
}
const overrides = [...(_anchorGuideNodeOverrides(sceneIds) || []), ...(extraOverrides || [])];
if (overrides.length) {
console.warn("[FunPack] this run asked for per-node overrides (REINS captures / the anchor-guide "
+ "i2v bypass) that v5's generate flow does not support yet -- ignored:", overrides);
}
const targetSceneIds = (sceneIds || []).filter(Boolean);
if (!targetSceneIds.length) {
_genRunActive = false; // never actually started — nothing to hold the flag for
set({ gen: { state: "error", promptId: null, media: [], msg: "No scenes to generate." } });
return false;
}
_genRunPrefix = prefix || "Generating…";
_genRunSceneIds = targetSceneIds;
_markGenInFlight(targetSceneIds);
set({ gen: { state: "queuing", promptId: null, media: [], msg: `${_genRunPrefix}: queuing…`, step: 0, maxStep: 0 } });
try {
let inputs, unwiredNotice;
try {
({ raw: inputs, unwiredNotice } = await _buildQueueInputs(targetSceneIds));
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: `Could not build the prompt: ${e.message}` } });
return false;
}
const waiting = GB.waitForTerminal();
let queued;
try {
queued = await GB.generate({
sceneId: onlyScene || targetSceneIds[0],
sceneIds: targetSceneIds,
projectId: state.project ? state.project.id : null,
inputs,
});
} catch (e) {
waiting.cancel();
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return false;
}
// Applied AFTER generate() resolves, on purpose: check() (run inside
// GB.generate(), above) can fire its own "warn" note into this same
// state.notice field -- setting this one first let that note silently
// clobber it. Setting it last means an unwired reference is always
// what the person sees, never lost to a race with an unrelated note.
if (unwiredNotice) { state.notice = unwiredNotice; notify(); }
if (!queued) {
// Refused before it ever reached run.start() (an incomplete pipeline,
// or the queue itself saying no) -- the bridge's own "say" hook
// already turned the reason into state.gen, and nothing will ever
// transition the run this was waiting on.
waiting.cancel();
return false;
}
_queueRenderPrompts(targetSceneIds);
pollStart = Date.now();
return (await waiting) === GB.DONE;
} finally {
// Redundant with the GB.subscribe terminal-phase clearing above for a
// run that actually reached one, and the only place that clears it at
// all for a run that got refused before ever transitioning (check()
// saying no, or GB.generate() throwing) -- that subscription never
// sees either of those, since nothing ever left "idle".
_genRunActive = false;
_clearGenInFlight(targetSceneIds);
}
}
// H3 combo sweep: one scene, several REINS-steering / block-repeat configs run back to
// back, each captured into a gallery labelled with the config that produced it. Every run
// is a one-off node_overrides on the BUILT graph (same mechanism as the anchor-guide i2v
// bypass above) — the project's own Engine Settings are never touched, so nothing needs
// restoring after and a crash mid-sweep leaves no residue.
// "10,11,13;seam;5" -> "funpack_sweep_10-11-13-seam-5_<short>" — VHS_VideoCombine's
// filename_prefix widget, overridden per run so the temp file itself is self-labelled
// instead of the generic funpack_preview_<uuid> every other run gets. Short unique suffix
// (not the raw uuid) because a shared prefix across runs made an earlier run's file stop
// resolving once a later run reused it — see builder.py's own comment on this prefix.
function _sweepFilenamePrefix(label) {
const safe = String(label).replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return `funpack_sweep_${safe}_${Math.random().toString(36).slice(2, 8)}`;
}
// Line format: any of "reins:strength;block", "sweep:blocks;seam|noseam;times[;laststeps]"
// and "av:penalty", joined with "|". e.g. "reins:0.15;49|sweep:40-41;noseam;1;0|av:5" or
// just "reins:0.1;49" alone. Whichever part is OMITTED is explicitly turned OFF for that
// line (not "whatever Engine Settings has") — every line fully states what it tests,
// nothing carries over from the project's own settings or the previous line.
// sweep's laststeps: positive = the final N steps (refine only, structure already
// settled); negative = the first |N| steps instead (apply while structure is still
// forming, then stop); omitted/0 = every step.
// av's penalty is the RAW attention-logit penalty h3_av_decouple takes directly (not a
// 0-1 fraction) -- see that field's own tooltip for why there's no way to pre-calibrate it.
function parseComboSweepConfig(text) {
return String(text || "").split("\n").map((l) => l.trim()).filter(Boolean).map((line) => {
let reins = null, sweep = null, av = null;
for (const seg of line.split("|").map((s) => s.trim()).filter(Boolean)) {
const m = seg.match(/^(reins|sweep|av):(.*)$/i);
if (!m) continue;
const kind = m[1].toLowerCase();
if (kind === "reins") {
const [strength, block] = m[2].split(";").map((s) => (s || "").trim());
const st = parseFloat(strength);
if (Number.isFinite(st) && block) reins = { strength: st, block };
} else if (kind === "sweep") {
const [blocks, seam, times, lastSteps] = m[2].split(";").map((s) => (s || "").trim());
if (blocks) {
sweep = {
blocks, spanLoop: seam.toLowerCase() === "seam",
times: Math.min(4, Math.max(1, parseInt(times, 10) || 1)),
lastSteps: Math.min(50, Math.max(-50, parseInt(lastSteps, 10) || 0)),
};
}
} else {
const penalty = parseFloat(m[2].trim());
if (Number.isFinite(penalty) && penalty > 0) av = penalty;
}
}
return reins || sweep || av != null ? { label: line, reins, sweep, av } : null;
}).filter(Boolean);
}
// The refinement key REINS reads/writes for — same default the rest of the app uses
// (project.refinement_key), so a sweep's slots land in the same sidecar file everything
// else on this project reads from.
function _reinsKey() {
return (state.project?.refinement_key || "default").trim() || "default";
}
// Overrides for one combo config. `seedValue` is the seed to force for this run (the
// caller picks a fresh one per line unless "same seed" is checked). `slotId`, when the
// config has a `reins` half, saves THIS run's capture under its own slot instead of the
// single overwritable "pending" — so every result in the gallery stays independently
// rateable later, not just whichever one happened to run last.
function _comboOverrides(c, seedValue, slotId) {
const ov = [
{ node: "sampler", input: "seed", value: seedValue },
{ node: "sampler", input: "h3_repr_steering", value: !!c.reins },
{ node: "sampler", input: "h3_block_repeat", value: c.sweep ? c.sweep.blocks : "" },
{ node: "sampler", input: "h3_av_decouple", value: c.av || 0 },
{ node: "vhs", input: "filename_prefix", value: _sweepFilenamePrefix(c.label) },
];
if (c.reins) {
ov.push({ node: "sampler", input: "h3_repr_steering_strength", value: c.reins.strength });
ov.push({ node: "sampler", input: "h3_repr_steering_block", value: c.reins.block });
ov.push({ node: "sampler", input: "h3_repr_capture_slot", value: slotId });
}
if (c.sweep) {
ov.push({ node: "sampler", input: "h3_block_repeat_span_loop", value: c.sweep.spanLoop });
ov.push({ node: "sampler", input: "h3_block_repeat_times", value: c.sweep.times });
ov.push({ node: "sampler", input: "h3_block_repeat_last_steps", value: c.sweep.lastSteps });
}
return ov;
}
// `sameSeed`: off (default) picks a FRESH random seed for every line, regardless of what
// the project's own Engine Settings seed is set to — a hard guarantee, not "depends on
// your settings". On, one seed is chosen once (the project's fixed seed if set, else one
// random pick) and reused for the whole sweep.
async function runComboSweep(sceneId, configText, sameSeed) {
if (!state.project || !sceneId) return;
const configs = parseComboSweepConfig(configText);
if (!configs.length) {
set({ comboSweep: { running: false, results: [], error: "No valid config lines — one per line, reins:strength;block and/or sweep:blocks;seam|noseam;times[;laststeps]." } });
return;
}
const fixedSeed = sameSeed
? (state.project.sampler_inputs?.seed ?? Math.floor(Math.random() * 0x7fffffff))
: null;
const reinsKey = _reinsKey();
const results = [];
set({ comboSweep: { running: true, results, error: null, total: configs.length, current: 0, sameSeed: !!sameSeed, seed: fixedSeed } });
for (let i = 0; i < configs.length; i++) {
const c = configs[i];
const seed = sameSeed ? fixedSeed : Math.floor(Math.random() * 0x7fffffff);
// Unique per run, not just per config — rerunning the same line must not reuse (and
// silently clobber) an earlier, still-unrated slot.
const slotId = c.reins ? `sweep_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 8)}` : null;
set({ comboSweep: { running: true, results: [...results], error: null, total: configs.length, current: i + 1, label: c.label, sameSeed: !!sameSeed, seed } });
const overrides = _comboOverrides(c, seed, slotId);
const ok = await _generateRun([sceneId], sceneId, `Sweep ${i + 1}/${configs.length}: ${c.label}`, false, overrides);
if (!ok) {
set({ comboSweep: { running: false, results, error: `Stopped at "${c.label}": ${state.gen.msg}`, total: configs.length, sameSeed: !!sameSeed, seed } });
return;
}
const media = state.sceneRenders[sceneId]?.media;
results.push({ label: c.label, media, sceneId, seed, reinsKey, slotId });
}
set({ comboSweep: { running: false, results, error: null, total: configs.length, sameSeed: !!sameSeed, seed: fixedSeed } });
}
// Rate (or discard) ANY combo-sweep result, in any order, any subset — each one's REINS
// capture lives in its own slot (see runComboSweep), independent of every other result's,
// so this is a direct commit against data already on disk. No extra generation, unlike the
// ordinary one-at-a-time Studio rating flow this deliberately does NOT reuse.
async function rateComboResult(item, ratingLabel) {
if (!item || !item.slotId) return false;
try {
const r = await API.reinsRateSlot(item.reinsKey, item.slotId, ratingLabel);
return r.outcome === "recorded";
} catch (e) {
console.error("rate-slot failed", e);
return false;
}
}
async function discardComboResult(item) {
if (!item || !item.slotId) return;
try { await API.reinsDiscardSlot(item.reinsKey, item.slotId); }
catch (e) { console.error("discard-slot failed", e); }
}
async function generate(onlyScene) {
if (!state.project) return;
// Shown BEFORE the await below, not after: without it, clicking Generate produced no
// visible change at all until _flushSaveForGenerate/_generateRun got around to their own
// first `set()` -- normally near-instant, but on a flaky connection (an ambient autosave
// still mid-retry that _generateRun then waits on) that gap could stretch to tens of
// seconds with the button looking untouched and un-disabled the whole time.
set({ gen: { state: "queuing", promptId: null, media: [], msg: "Preparing to generate…", step: 0, maxStep: 0 } });
try {
await _flushSaveForGenerate();
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return;
}
if (!onlyScene) return generateMontage();
const reset = _resetSessionPending; _resetSessionPending = false;
if (reset) state.resetSessionArmed = false;
const sc = scene(onlyScene);
const ids = sc ? genUnitSceneIds(genUnitId(sc)) : [onlyScene];
await _generateRun(ids, onlyScene, "Generating scene", reset);
}
// Generate the whole montage: one chain request per run, fired sequentially
// (one GPU at a time). Each run's first scene supplies its i2v anchor. A pending
// session reset applies to the FIRST run only.
async function generateMontage() {
if (!state.project) return;
set({ gen: { state: "queuing", promptId: null, media: [], msg: "Preparing to generate…", step: 0, maxStep: 0 } });
try {
await _flushSaveForGenerate();
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return;
}
const runs = _runs();
if (!runs.length) { set({ gen: { state: "error", promptId: null, media: [], msg: "No active scenes to generate." } }); return; }
const reset = _resetSessionPending; _resetSessionPending = false;
if (reset) state.resetSessionArmed = false;
for (let i = 0; i < runs.length; i++) {
const ok = await _generateRun(runs[i], null, `Run ${i + 1}/${runs.length}`, reset && i === 0);
if (!ok) return; // error already surfaced
}
set({ gen: { state: "done", promptId: null, media: state.gen.media, msg: `${runs.length} run(s) generated — use Render Final Video to stitch them.` } });
}
async function generateSelected() {
if (!state.project) return;
const ids = (state.selectedSceneIds || []).filter((sid) => {
const s = scene(sid);
return s && !s.excluded && isGenerativeScene(s);
});
if (!ids.length) {
set({ gen: { state: "error", promptId: null, media: [], msg: "No scenes selected." } });
return;
}
set({ gen: { state: "queuing", promptId: null, media: [], msg: "Preparing to generate…", step: 0, maxStep: 0 } });
try {
await _flushSaveForGenerate();
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return;
}
const runs = _runsForSceneIds(ids);
if (!runs.length) {
set({ gen: { state: "error", promptId: null, media: [], msg: "No generatable runs in the selection." } });
return;
}
const reset = _resetSessionPending; _resetSessionPending = false;
if (reset) state.resetSessionArmed = false;
for (let i = 0; i < runs.length; i++) {
const prefix = runs.length > 1 ? `Selected run ${i + 1}/${runs.length}` : "Generating selection";
const ok = await _generateRun(runs[i], null, prefix, reset && i === 0);
if (!ok) return;
}
const n = ids.length;
set({
gen: {
state: "done", promptId: null, media: state.gen.media,
msg: `${runs.length} run(s) generated for ${n} selected scene${n > 1 ? "s" : ""}.`,
},
});
}
// The clock is wrapped around the PUBLIC entry points rather than set inside them: every
// one of these has several early-return error paths, and a `finally` here cannot miss one.
// `await` (not `return fn()`) is what makes the finally wait for the whole montage.
async function _clockedGenerate(onlyScene) {
_genClockStart(onlyScene ? "scene" : "all");
try { return await generate(onlyScene); } finally { _genClockStop(); }
}
async function _clockedMontage() {
_genClockStart("all");
try { return await generateMontage(); } finally { _genClockStop(); }
}
async function _clockedSelected() {
_genClockStart("selected");
try { return await generateSelected(); } finally { _genClockStop(); }
}
// Local timestamp for unique export filenames: YYYYMMDD-HHMMSS.
function _stamp() {
const d = new Date(), p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
// Fetch a render and offer a Save dialog (File System Access API where available,
// else a normal download). Renders live in ComfyUI's temp dir, so persisting one
// is the user's job — this is how they do it.
async function _saveBlobAs(url, name, opts) {
opts = opts || {};
const pickTypes = opts.types || [{ description: "MP4 video", accept: { "video/mp4": [".mp4"] } }];
let blob;
try { const r = await fetch(url); if (!r.ok) throw new Error(r.statusText); blob = await r.blob(); }
catch (e) { alert("Could not fetch the file: " + e.message); return; }
if (window.showSaveFilePicker) {
try {
const h = await window.showSaveFilePicker({ suggestedName: name, types: pickTypes });
const w = await h.createWritable(); await w.write(blob); await w.close();
return;
} catch (e) { if (e && e.name === "AbortError") return; }
}
const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
}
function _exportMediaFilename(m) {
let name = (m.name || m.filename || m.id || "export").trim();
const fileExt = (m.filename || "").match(/(\.[^.]+)$/)?.[1] || "";
if (fileExt && !/\.\w+$/.test(name)) name += fileExt;
return name;
}
async function exportMediaAsset(mediaId) {
const m = (state.mediaBin || []).find((x) => x.id === mediaId);
if (!m || (m.kind !== "image" && m.kind !== "video")) {
alert("Only images and videos can be exported.");
return;
}
const name = _exportMediaFilename(m);
const types = m.kind === "video"
? [{ description: "Video", accept: { "video/mp4": [".mp4"], "video/webm": [".webm"], "video/quicktime": [".mov"] } }]
: [{ description: "Image", accept: { "image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "image/webp": [".webp"] } }];
await _saveBlobAs(API.mediaUrl(mediaId), name, { types });
}
// Source in-point for preview/export: chain segment offset + per-scene trim.
function clipSourceInSec(sc, render) {
if (!sc) return 0;
const trim = sc.source_in || 0;
if (render?.media) return trim + (render.inSec || 0);
return trim;
}
// Ordered list of rendered clips in timeline order: {media, inSec, durationSec} for
// each non-excluded scene that has a render. This is what plays/exports — so splits
// and deletes are honoured (deleted scenes simply aren't here).
function _renderClips() {
const p = state.project;
const out = [];
for (const sc of orderedTimelineScenes()) {
if (sc.excluded && !sc.removed_from_plan) continue; // removed-from-plan clips stay in the cut
const r = state.sceneRenders[sc.id];
const fps = (sc.fps_mode !== "project" && sc.fps != null ? sc.fps : p.frame_rate) || 25;
const tFrames = sc.transition_frames || 0;
const base = {
inSec: clipSourceInSec(sc, r),
durationSec: sc.source_dur != null ? sc.source_dur : sceneDurationSec(sc),
fx: sc.effects || {}, fps,
w: (sc.width != null ? sc.width : p.width) || null,
h: (sc.height != null ? sc.height : p.height) || null,
transition: sc.video_transition || "",
tdur: tFrames > 0 ? tFrames / fps : 0,
sceneId: sc.id,
volume: sc.audio_separated ? 0 : (sc.audio_volume != null ? sc.audio_volume : 1),
gap_after: Math.max(0, +(sc.gap_after_sec || 0)),
};
if (r && r.media) {
out.push({ media: r.media, ...base });
} else if (isVideoClip(sc) && sc.source?.media_ref) {
out.push({ bin_media_ref: sc.source.media_ref, ...base });
}
}
return out;
}
function _saveableSelectedSceneIds() {
return _orderedSelectedSceneIds().filter((id) => clipSaveableToMediaBin(id));
}
function _renderClipsForSceneIds(sceneIds) {
const set = new Set(sceneIds);
return _renderClips().filter((c) => set.has(c.sceneId));
}
function _plainExportClipsFromRenderClips(rc) {
return rc.map((c) => ({
filename: c.media?.filename, subfolder: c.media?.subfolder || "", type: c.media?.type || "output",
bin_media_ref: c.bin_media_ref || "",
in: +c.inSec.toFixed(3), dur: +c.durationSec.toFixed(3),
}));
}
function _combinedSelectionFilename(clipCount) {
const proj = (state.project.name || "montage").replace(/[^\w.-]+/g, "_");
return `${proj}_${clipCount}clips_${_stamp()}.mp4`;
}
// Trim one clip, or hard-cut concat multiple selected clips (no effects/transitions).
async function _stitchSelectedClips(saveableIds) {
const rc = _renderClipsForSceneIds(saveableIds);
if (!rc.length) return null;
if (rc.length === 1) {
const spec = _clipExportSpecForScene(rc[0].sceneId);
if (!spec) return null;
const out = await API.exportClip(state.project.id, spec.clip);
return { media: out.media, clips: 1, exportName: spec.name };
}
const queued = await API.exportClipsCombined(state.project.id, _plainExportClipsFromRenderClips(rc));
const r = queued?.job_id
? await _pollExportClipsJob(queued.job_id, rc.length)
: queued;
return { ...r, exportName: _combinedSelectionFilename(rc.length) };
}
function _selectedClipFocusId() {
const ids = state.selectedSceneIds?.length
? state.selectedSceneIds
: (state.selectedSceneId ? [state.selectedSceneId] : []);
if (!ids.length) return null;
if (state.selectedSceneId && ids.includes(state.selectedSceneId)) return state.selectedSceneId;
return ids[0];
}
function _hasTimelineClipSelection() {
return !!_selectedClipFocusId();
}
function _orderedSelectedSceneIds() {
const raw = state.selectedSceneIds?.length
? state.selectedSceneIds
: (state.selectedSceneId ? [state.selectedSceneId] : []);
if (!raw.length) return [];
const order = _sceneOrder();
const set = new Set(raw);
return order.filter((id) => set.has(id));
}
// Build export/import clip spec for one scene (render trim or media-bin video).
function _clipExportSpecForScene(sceneId) {
if (!state.project) return null;
const sc = scene(sceneId);
if (!sc) return null;
const r = state.sceneRenders[sceneId];
const idx = state.project.scenes.findIndex((s) => s.id === sceneId);
const proj = (state.project.name || "montage").replace(/[^\w.-]+/g, "_");
if (isVideoClip(sc) && sc.source?.media_ref && !r?.media) {
return {
sceneId,
clip: {
bin_media_ref: sc.source.media_ref,
in: sc.source_in || 0,
dur: sc.source_dur != null ? sc.source_dur : sceneDurationSec(sc),
},
name: `${proj}_video_${idx >= 0 ? idx + 1 : "x"}_${_stamp()}.mp4`,
};
}
if (!r?.media) return null;
return {
sceneId,
clip: {
filename: r.media.filename,
subfolder: r.media.subfolder || "",
type: r.media.type || "output",
in: clipSourceInSec(sc, r),
dur: sc.source_dur != null ? sc.source_dur : sceneDurationSec(sc),
},
name: `${proj}_scene${idx >= 0 ? idx + 1 : "x"}_${_stamp()}.mp4`,
};
}
function _selectedClipExportSpecs() {
return _orderedSelectedSceneIds().map((id) => _clipExportSpecForScene(id)).filter(Boolean);
}
// Build export/import clip spec for the focused selected scene (render trim or media-bin video).
function _selectedClipExportSpec() {
const id = _selectedClipFocusId();
return id ? _clipExportSpecForScene(id) : null;
}
function clipSaveableToMediaBin(sceneId) {
if (!sceneId || !state.project) return false;
const sc = scene(sceneId);
if (!sc) return false;
if (isVideoClip(sc) && sc.source?.media_ref) return true;
return !!(state.sceneRenders[sceneId]?.media);
}
// Export selected clip(s): one file; multi-select is hard-cut concat with trim only.
async function exportSelected() {
if (!state.project) return;
if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; }
const allIds = _orderedSelectedSceneIds();
const saveableIds = _saveableSelectedSceneIds();
if (!saveableIds.length) {
alert("Generate the clip first, or select a video clip with media.");
return;
}
const skipped = allIds.length - saveableIds.length;
if (skipped > 0 && !confirm(
`${skipped} selected clip(s) have no render yet and will be skipped. Export ${saveableIds.length} clip(s)?`
)) return;
if (saveableIds.length > 1 && !confirm(
`Combine ${saveableIds.length} selected clips into one file and export?\n\nTimeline trim is applied per clip. No effects, transitions, or extra audio — use Render Final for that.`
)) return;
const showProgress = saveableIds.length > 1;
if (showProgress) {
set({ gen: { state: "running", promptId: null, media: [], msg: `Exporting ${saveableIds.length} clips…` } });
}
try {
const r = await _stitchSelectedClips(saveableIds);
if (!r?.media) throw new Error("Export produced no media.");
await _saveBlobAs(API.resultUrl(state.project.id, r.media), r.exportName);
const notice = saveableIds.length === 1
? "Clip exported"
: `Exported ${saveableIds.length} clips as one file`;
set({ gen: { state: "idle", promptId: null, media: [], msg: "" }, notice });
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: "Export failed: " + _friendlyGenError(e.message) } });
}
}
// Save one clip (by scene id) straight to the Media bin — server-side copy via the same
// import-clip route saveSelectedToMediaBin uses, no browser download/re-upload round trip.
// Used by the transport bar's "Save video" button for whatever clip is under the playhead.
async function saveClipToMediaBin(sceneId) {
if (!state.project) return null;
const spec = _clipExportSpecForScene(sceneId);
if (!spec) throw new Error("Nothing to save — generate this clip first.");
try {
const res = await API.importClipToMediaBin(spec.clip, spec.name);
await loadMedia();
return res?.media || res;
} catch (e) {
throw new Error(_friendlyGenError(e.message));
}
}
async function saveSelectedToMediaBin() {
if (!state.project) return;
if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; }
const allIds = _orderedSelectedSceneIds();
const saveableIds = _saveableSelectedSceneIds();
if (!saveableIds.length) {
alert("Generate the clip first, or select a video clip with media.");
return;
}
const skipped = allIds.length - saveableIds.length;
if (skipped > 0 && !confirm(
`${skipped} selected clip(s) have no render yet and will be skipped. Save ${saveableIds.length} clip(s) to Media bin?`
)) return;
if (saveableIds.length > 1 && !confirm(
`Combine ${saveableIds.length} selected clips into one file and save to Media bin?\n\nTimeline trim is applied per clip. No effects, transitions, or extra audio — use Render Final for that.`
)) return;
const showProgress = saveableIds.length > 1;
if (showProgress) {
set({ gen: { state: "running", promptId: null, media: [], msg: `Saving ${saveableIds.length} clips to Media bin…` } });
}
try {
let clip;
let name;
if (saveableIds.length === 1) {
const spec = _clipExportSpecForScene(saveableIds[0]);
if (!spec) throw new Error("Nothing to save.");
clip = spec.clip;
name = spec.name;
} else {
const r = await _stitchSelectedClips(saveableIds);
if (!r?.media) throw new Error("Stitch produced no media.");
clip = {
filename: r.media.filename,
subfolder: r.media.subfolder || "",
type: r.media.type || "temp",
};
name = r.exportName;
}
const res = await API.importClipToMediaBin(clip, name);
await loadMedia();
const entry = res?.media || res;
const notice = saveableIds.length === 1
? `Saved to Media bin: ${entry?.name || name}`
: `Saved ${saveableIds.length} clips to Media bin as one file: ${entry?.name || name}`;
set({ gen: { state: "idle", promptId: null, media: [], msg: "" }, notice });
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: "Save to media bin failed: " + _friendlyGenError(e.message) } });
}
}
// Stitch the kept clips (in/out per clip, hard cut, video + audio) into one final file.
async function renderFinal() {
if (!state.project) return;
const rc = _renderClips();
const totalSec = previewTotalSec();
const graphicsOnly = !rc.length && hasOverlayOrAudioContent() && totalSec > 0;
if (!rc.length && !graphicsOnly) {
alert("Nothing to render yet — generate clips or add overlay/audio tracks first.");
return;
}
const confirmMsg = graphicsOnly
? `Render the overlay/audio timeline (${totalSec.toFixed(1)}s) to a final video?\n\nUses a blank canvas at project resolution (${state.project.width || 768}×${state.project.height || 512}).`
: `Stitch ${rc.length} clip(s) into one final video?\n\nThis replaces per-scene renders with a single stitched file on the timeline.`;
if (!confirm(confirmMsg)) return;
const clips = rc.map((c) => ({
filename: c.media?.filename, subfolder: c.media?.subfolder || "", type: c.media?.type || "output",
bin_media_ref: c.bin_media_ref || "",
in: +c.inSec.toFixed(3), dur: +c.durationSec.toFixed(3),
fx: c.fx, fps: c.fps, w: c.w, h: c.h, transition: c.transition, tdur: +(c.tdur || 0).toFixed(3),
volume: c.volume, scene_id: c.sceneId,
gap_after: +(c.gap_after || 0).toFixed(3),
}));
await flushSave(); // audio tracks / keep-original live on the project — render reads it from disk
set({
gen: {
state: "running",
promptId: null,
media: [],
msg: graphicsOnly ? `Rendering ${totalSec.toFixed(1)}s overlay/audio…` : `Stitching ${clips.length} clip(s)…`,
},
});
try {
const queued = await API.renderFinal(state.project.id, clips);
const r = queued?.job_id
? await _pollRenderJob(queued.job_id, clips.length || 1)
: queued;
if (!graphicsOnly) {
// Play the stitched file across the whole timeline (single source, in-point 0).
const order = state.project.scenes.filter((s) => !s.excluded);
state.sceneRenders = {};
state.sceneGhosts = [];
_syncEditorStateToProject();
let acc = 0;
for (const sc of order) {
state.sceneRenders[sc.id] = { media: r.media, inSec: acc };
const dur = sc.source_dur != null ? sc.source_dur : sceneDurationSec(sc);
acc += dur + Math.max(0, +(sc.gap_after_sec || 0));
}
}
set({
gen: {
state: "done",
promptId: null,
media: [r.media],
msg: graphicsOnly
? "Final overlay/audio video rendered — saving…"
: `Final video rendered from ${r.clips} clip(s) — saving…`,
},
});
const name = (state.project.name || "montage").replace(/[^\w.-]+/g, "_") + `_final_${_stamp()}.mp4`;
await _saveBlobAs(API.resultUrl(state.project.id, r.media), name);
if (r.warnings && r.warnings.length) set({ notice: `Rendered, but: ${r.warnings.join(" ")}` });
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: "Render failed: " + _friendlyGenError(e.message) } });
}
}
// A module that failed during a run is now off for good (until repaired): say which, once.
const _quarantineSeen = new Set();
async function _announceQuarantine() {
const PS = window.PipelineState;
if (!PS || !PS.refreshControl) return;
const control = await PS.refreshControl();
const seenKey = (id, c) => `${id}@${c.quarantine.when}`; // fail, turn back on, fail again: told again
const fresh = Object.entries(control).filter(([id, c]) => c.quarantine && !_quarantineSeen.has(seenKey(id, c)));
if (!fresh.length) return;
fresh.forEach(([id, c]) => _quarantineSeen.add(seenKey(id, c)));
const title = (id) => (PS.modulesById()[id] || {}).title || id;
state.notice = fresh.map(([id, c]) => `${title(id)} failed and is now off (${c.quarantine.reason}). `
+ `Turn it back on in Settings ▸ Modules once it is repaired.`).join(" ");
notify();
}
// ── pluggable models / exposed controls ──────────────────────────────────────
// The pipeline lives in PipelineState (one copy shared by every panel); the project keeps a
// copy of it (project.models) so a model pick and every node value travel with the project to
// a new rental. Opening a project puts ITS pipeline over the session's; a project with none
// yet inherits the session's.
let _modelsRetries = 0;
let _modelsAdopted = true; // false while the open project's pipeline is not the live one: its copy is not rewritten
let _pipelineSaveTimer = null;
async function loadModels() {
const saved = (state.project && state.project.models && state.project.models.slots) || [];
const p = (async () => {
try {
// Bounded: a hung request must not hold every later project save behind it.
const ok = await Promise.race([window.PipelineState.adopt(saved, state.project && state.project.models && state.project.models.removed,
state.project && state.project.models && state.project.models.unwired), new Promise((r) => setTimeout(() => r(false), 20000))]);
const live = window.PipelineState.slots();
// The project's pipeline is not in (ComfyUI unreachable, slow, or it refused the file): the
// project's own copy stays exactly as it was and is not rewritten from whatever is live --
// neither the default nor the previous project's. Tried again a few times.
_modelsAdopted = !!(ok && live);
const base = (state.project && state.project.models) || {};
state.models = { ...base, slots: _modelsAdopted ? JSON.parse(JSON.stringify(live)) : saved,
removed: _modelsAdopted ? window.PipelineState.removedIds() : (base.removed || []),
unwired: _modelsAdopted ? window.PipelineState.unwiredMap() : (base.unwired || {}) };
if (!_modelsAdopted && _modelsRetries++ < 3) setTimeout(loadModels, 5000);
else if (_modelsAdopted) _modelsRetries = 0;
} catch (_) { state.models = { ...((state.project && state.project.models) || {}), slots: saved }; _modelsAdopted = false; }
})();
_modelsLoad = p;
await p;
if (_modelsLoad === p) _modelsLoad = null;
if (state.project) {
state.project.models = JSON.parse(JSON.stringify(state.models || { slots: [] }));
}
if (window.PipelineCaps && !window.PipelineCaps.usesFunpackStudio(state)) {
_resetSessionPending = false;
state.resetSessionArmed = false;
}
notify();
loadImageTargets();
}
async function loadImageTargets() {
try { state.imageTargets = (await API.imageTargets(state.project?.id)).targets || []; } catch (_) { state.imageTargets = []; }
notify();
}
// A node input edited from the main editor (an "exposed" control): the pipeline's one live
// copy takes it (PipelineState validates it server-side); the project's copy follows on its own.
function setModelInput(slotId, name, value) {
return window.PipelineState.save({ inputs: { [slotId]: { [name]: value } } });
}
// v4's per-slot bypass and shared "linked input" controls have no v5 counterpart: a node is
// swapped, removed (its neighbours rewired) or wired, never silently skipped. Nothing offers them.
function setModelBypass() { console.warn("[FunPack] bypass is not part of v5 pipelines"); }
function setModelLink() { console.warn("[FunPack] linked inputs are wired in Models & Pipeline in v5"); }
async function flushModelsSave() {}
// ── media bin + libraries ─────────────────────────────────────────────────────
async function loadMedia() { try { state.mediaBin = (await API.listMedia()).media || []; } catch (_) { state.mediaBin = []; } notify(); }
// state.mediaUpload: null when idle, else { current, total, name, loaded, size } -- the
// drop zone had no way to show this at all before, so a batch of photos/a big video
// uploading over a slow connection just looked like nothing was happening.
let _uploadBatches = 0;
async function uploadMedia(files) {
const list = [...files];
const failed = [];
_uploadBatches++;
for (let i = 0; i < list.length; i++) {
const f = list[i];
// A full notify() here (once per file) is what builds the "uploading" drop zone in the
// first place -- see fpMediabrowser in view_bus.js. Progress WITHIN a file goes through
// updateMediaUploadProgress instead, which never touches the store's subscribers.
set({ mediaUpload: { current: i + 1, total: list.length, name: f.name, loaded: 0, size: f.size } });
try {
await API.uploadMedia(f, (loaded, total) => {
updateMediaUploadProgress({ loaded, size: total || f.size });
});
} catch (e) { console.error("upload failed", e); failed.push(`${f.name}: ${e && e.message ? e.message : e}`); }
}
if (--_uploadBatches === 0) state.mediaUpload = null; // a second batch drawn meanwhile keeps its bar
if (failed.length) set({ notice: `Not uploaded — ${failed.join(" · ")}` });
await loadMedia();
}
async function deleteMedia(id) {
try { await API.deleteMedia(id); } catch (_) {}
if (state.mediaPreviewId === id) state.mediaPreviewId = null;
await loadMedia();
}
async function deleteMediaMany(ids) {
const list = [...new Set(ids || [])].filter(Boolean);
if (!list.length) return;
for (const id of list) {
try { await API.deleteMedia(id); } catch (_) {}
if (state.mediaPreviewId === id) state.mediaPreviewId = null;
}
await loadMedia();
}
async function renameMedia(id, name) {
const label = String(name || "").trim();
if (!label) return;
try {
const res = await API.renameMedia(id, label);
const entry = res?.media;
if (entry) {
state.mediaBin = (state.mediaBin || []).map((m) => (m.id === id ? { ...m, ...entry } : m));
notify();
} else {
await loadMedia();
}
} catch (e) {
alert("Rename failed: " + (e.message || e));
}
}
async function loadTransitions() { try { state.transitions = (await API.transitions()).transitions || []; } catch (_) {} notify(); }
async function loadNleLibrary() {
try {
const lib = await API.nleLibrary();
state.nleEffects = lib.effects || [];
state.nleVideoTransitions = lib.video_transitions || [];
} catch (_) {
state.nleEffects = [];
state.nleVideoTransitions = [];
}
notify();
}
async function loadShortcuts() { try { const r = await API.shortcuts(); state.shortcuts = r.shortcuts || []; state.shortcutCategories = r.categories || []; } catch (_) { state.shortcuts = []; state.shortcutCategories = []; } notify(); }
async function saveShortcut(item) { try { const r = await API.saveShortcut(item); state.shortcuts = r.shortcuts || state.shortcuts; if (r.categories) state.shortcutCategories = r.categories; notify(); return true; } catch (e) { alert("Save failed: " + e.message); return false; } }
async function deleteShortcut(name) { try { const r = await API.deleteShortcut(name); state.shortcuts = r.shortcuts || []; if (r.categories) state.shortcutCategories = r.categories; notify(); } catch (e) { console.error(e); } }
async function addCategory(category, subCategory) {
try {
const r = await API.saveCategory({ category, sub_category: subCategory || "" });
if (r.categories) state.shortcutCategories = r.categories;
notify();
return true;
} catch (e) { alert("Add category failed: " + e.message); return false; }
}
async function saveTransition(item) {
try {
state.transitions = (await API.saveTransition(item)).transitions || state.transitions;
notify();
} catch (e) { alert("Save failed: " + e.message); }
}
async function deleteTransition(name) { try { state.transitions = (await API.deleteTransition(name)).transitions || []; notify(); } catch (e) { console.error(e); } }
async function importShortcuts(file, mode) {
try {
const text = await file.text();
const data = JSON.parse(text);
const r = await API.importShortcuts(data, mode);
state.shortcuts = r.shortcuts || state.shortcuts;
if (r.categories) state.shortcutCategories = r.categories;
notify();
return r.imported;
} catch (e) { alert("Import failed: " + e.message); return null; }
}
async function importTransitions(file, mode) {
try {
const text = await file.text();
const data = JSON.parse(text);
const r = await API.importTransitions(data, mode);
state.transitions = r.transitions || state.transitions; notify();
return r.imported;
} catch (e) { alert("Import failed: " + e.message); return null; }
}
async function clearShortcuts() {
try {
const r = await API.clearShortcuts();
state.shortcuts = r.shortcuts || [];
state.shortcutCategories = r.categories || [];
notify();
return true;
} catch (e) { alert("Delete-all failed: " + e.message); return false; }
}
async function clearTransitions() {
try {
const r = await API.clearTransitions();
state.transitions = r.transitions || [];
notify();
return true;
} catch (e) { alert("Delete-all failed: " + e.message); return false; }
}
// Apply a split-marker library item to the selected scene seam (generation prompt).
function applySplitMarkerToSelection(trigger) {
const s = scene(state.selectedSceneId); if (!s) return false;
patchScene(s.id, { transition_to_next: trigger }); return true;
}
function applyTransitionToSelection(trigger) { return applySplitMarkerToSelection(trigger); }
function applyNleEffect(effectId, paramValue) {
const sc = scene(state.selectedSceneId); if (!sc) return false;
const patches = {
zoom_in: () => ({
effects: {
...(sc.effects || {}),
zoom: "in",
zoom_ratio: sc.effects?.zoom_ratio ?? 0.15,
zoom_frames: sc.effects?.zoom_frames ?? 25,
zoom_start_frame: sc.effects?.zoom_start_frame ?? 0,
},
}),
zoom_out: () => ({
effects: {
...(sc.effects || {}),
zoom: "out",
zoom_ratio: sc.effects?.zoom_ratio ?? 0.15,
zoom_frames: sc.effects?.zoom_frames ?? 25,
zoom_start_frame: sc.effects?.zoom_start_frame ?? 0,
},
}),
blur: (v) => ({ effects: { ...(sc.effects || {}), blur: v } }),
fade_in: (v) => ({ effects: { ...(sc.effects || {}), fade_in: v } }),
fade_out: (v) => ({ effects: { ...(sc.effects || {}), fade_out: v } }),
// Geometry ops. The flips and fill are switches, and the Add-effect modal has no
// "off" — so applying one again turns it off. Anything else would be a setting the
// user can turn on and never turn off.
flip_h: () => ({ effects: { ...(sc.effects || {}), flip_h: !sc.effects?.flip_h } }),
flip_v: () => ({ effects: { ...(sc.effects || {}), flip_v: !sc.effects?.flip_v } }),
fill_frame: () => ({
effects: { ...(sc.effects || {}), fit: sc.effects?.fit === "fill" ? "contain" : "fill" },
}),
crop: (v) => ({
effects: { ...(sc.effects || {}), crop_inset: Math.max(0, Math.min(0.4, (+v || 0) / 100)) },
}),
// Reverse is server-side only (ffmpeg has to produce the frames backwards), so the
// preview switches to a rendered segment for this clip. Toggle, like the flips.
reverse: () => ({ effects: { ...(sc.effects || {}), reverse: !sc.effects?.reverse } }),
reset: () => ({ effects: {} }),
};
const fn = patches[effectId]; if (!fn) return false;
patchScene(sc.id, fn(paramValue)); return true;
}
function applyNleVideoTransition(transitionId, paramValue) {
const sc = scene(state.selectedSceneId); if (!sc) return false;
const item = (state.nleVideoTransitions || []).find((x) => x.id === transitionId);
if (!item) return false;
const frames = paramValue != null ? Math.round(paramValue)
: Math.round(item.param?.default || 16);
patchScene(sc.id, { video_transition: item.type || transitionId, transition_frames: frames });
return true;
}
function insertShortcutIntoSelection(trigger) {
const s = scene(state.selectedSceneId); if (!s) return false;
const t = (s.text || "").trim();
patchScene(s.id, { text: t ? `${t} ${trigger}` : trigger }); return true;
}
// True when this scene continues the current chain run (not a new run anchor).
function _continuesChainRun(s) {
if (window.PipelineCaps && !window.PipelineCaps.usesChainSampler(state)) return false;
const active = state.project.scenes.filter((sc) => !sc.excluded);
const idx = active.findIndex((sc) => sc.id === s.id);
if (idx <= 0) return false;
const t = (s.source && s.source.type) || "empty";
if (t === "empty") return false;
if (isGenSubclip(s)) return true;
if (t === "carry" || t === "mixed") return true;
if ((t === "image" || t === "generated_frame" || t === "anchor_guide") && _anchorAvailable(s)) return false;
return true;
}
function previewMedia(mediaId) {
const m = (state.mediaBin || []).find((x) => x.id === mediaId);
if (!m || (m.kind !== "image" && m.kind !== "audio" && m.kind !== "video")) return;
if (state.mediaPreviewId === mediaId) {
clearMediaPreview();
return;
}
try { window.Player?.pause?.(); } catch (_) {}
set({ mediaPreviewId: mediaId });
}
function clearMediaPreview() {
if (!state.mediaPreviewId) return;
set({ mediaPreviewId: null });
}
function assignMediaToScene(sceneId, mediaId) {
const s = scene(sceneId); if (!s) return;
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
if (asset?.kind === "video") {
if (isVideoClip(s)) {
patchScene(sceneId, {
source: { ...(s.source || {}), type: "video", media_ref: mediaId },
source_dur: asset.duration_sec || s.source_dur || null,
});
delete state.sceneRenders[sceneId];
notify(); scheduleSave();
} else if (confirm("Replace this scene with the imported video clip? Use Convert to video in the inspector to keep a backup of scene settings.")) {
_lockSceneAsVideo(sceneId, mediaId, { archive: true });
}
if (!asset.duration_sec) _probeVideoClipDuration(sceneId, mediaId);
return;
}
// Drag-drop is an explicit new anchor — use image i2v (not mixed/carry guides), but
// keep an existing anchor_guide scene in guide mode (its image is still the anchor).
const keepType = s.source?.type === "anchor_guide" ? "anchor_guide" : "image";
const patch = { source: { ...(s.source || {}), type: keepType, media_ref: mediaId } };
if ((s.source?.media_ref || null) !== mediaId) patch.guides = [];
patchScene(sceneId, patch);
}
// ── boot ─────────────────────────────────────────────────────────────────────
async function init() {
if (window.__FUNPACK_TOUR__ && window.TourSandbox) {
window.TourSandbox.patchApi?.();
window.TourSandbox.patchStore?.(window.Store);
await window.TourSandbox.bootstrap(state, notify);
notify();
document.body.classList.add("tour-mode");
setTimeout(() => window.TourGuide?.start?.(), 180);
return;
}
try { state.health = await API.health(); } catch (_) { state.health = { ok: false }; }
if (state.gen?.state === "error" && /Setup incomplete|Missing workflow template/.test(state.gen.msg || "")) {
state.gen = { state: "idle", promptId: null, media: [], msg: "" };
}
try { const t = await API.transitions(); state.transitions = t.transitions || []; } catch (_) { state.transitions = []; }
await loadNleLibrary();
await loadShortcuts();
await loadStoryMarkers();
await loadMedia();
try { state.ratingLabels = (await API.ratingLabels()).labels || []; } catch (_) { state.ratingLabels = []; }
await loadModels();
window.addEventListener("funpack-models-changed", loadModels);
// Every landed pipeline edit is the project's copy now; one writer (PipelineState), no snapshot of its own.
window.PipelineState.subscribe((slots) => {
if (!state.project || !_modelsAdopted) return;
state.models = { ...(state.models || {}), slots: JSON.parse(JSON.stringify(slots || [])),
removed: window.PipelineState.removedIds(), unwired: window.PipelineState.unwiredMap() };
// Soon, and past a Settings window's hold: a model pick must survive the reload that follows it.
_localDirty = true;
clearTimeout(_pipelineSaveTimer);
_pipelineSaveTimer = setTimeout(() => { _pipelineSaveTimer = null; flushSave(); }, 800);
});
await refreshProjectList();
// Re-attach to a generation already running in ComfyUI (UI was reloaded mid-run) is
// GenerateBridge's own job (generate_bridge.js, over app/shell/session.js's wire()) --
// it asks ComfyUI's own /queue and /history directly, not anything remembered here,
// and starts the moment its module loads. `ready` resolves once it knows whether it
// found one; the "adopt" hook (see _wireGenerateBridge) loads the right project and
// scene for it as soon as it does. Skip the welcome page in that case — there's work
// in flight.
let resumed = false;
_wireGenerateBridge();
try {
if (window.GenerateBridge) resumed = await window.GenerateBridge.ready;
} catch (_) { /* queue unreachable — boot normally */ }
notify();
// One front door: the full-screen welcome, unless a running generation was resumed.
if (resumed) return;
window.Onboarding?.maybeOpen();
}
// Backstop for a suspended save the user walks away from. Unconditional is safe:
// nobody is mid-edit in a tab that just went to the background.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") { try { flushSave(); } catch (_) {} }
});
window.Store = {
upscaleRender,
isUpscaling,
get, set, subscribe, notify, init,
scheduleSaveFromHistory, notifyHistoryState,
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
orderedTimelineScenes, patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, suspendSave, resumeSave, selectScene, addScene, removeScene, removeSelectedScenes, removeFromPlan, restoreToPlan, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene,
addVideoClip, addImageClip, convertToVideo, convertToScene, isVideoClip, isGenerativeScene,
genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds,
renderPromptForScene, renderPromptMismatch, renderAnchorMismatch, renderMediaLabel, renderIsStale,
buildPreviewSegments, previewTotalSec, audioTrackEndSec, hasOverlayOrAudioContent,
segmentDurationSec, sceneDurationSec, clipSourceInSec,
addAudioTrack, updateAudioTrack, removeAudioTrack, separateSceneAudio, separatedTrackForScene,
selectAudioTrack, trimAudioTrackLeft, slipAudioTrack, resizeAudioTrack, splitAudioTrack,
sceneHasEmbeddedAudio, separatedTrackAudioUrl,
separatedTrackMedia, separatedTrackInSec, separatedTrackDurSec, audioTrackInSec, audioTrackDurSec,
audioTrackMaxDurSec,
overlayTrack, selectOverlay, ensureOverlayLanes, sortedOverlayTracks, overlayLaneById, overlayLaneIndex,
projectCanvasSize, normalizeOverlayTrack,
addOverlayLane, removeOverlayLane, assignMediaToOverlayLane,
bringOverlayToFront, sendOverlayToBack, bringOverlayForward, sendOverlayBackward,
addImageOverlay, addTextOverlay, updateOverlayTrack, removeOverlayTrack, removeSelectedOverlay,
isOverlayAudioTrack, isSeparatedAudioTrack,
resizeScene, setSceneGapAfter, splitScene, autoMontage, hasPlayableRender, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, scenesOverridingProjectFrames, useProjectFramesEverywhere, setSourceTrim, setSourceImage, setReferences, trimSceneLeft, slipScene,
applyEnginePreset, ENGINE_PRESETS, undo, redo,
refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, globalPromptApplyPending, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate: _clockedGenerate, generateMontage: _clockedMontage, generateSelected: _clockedSelected, genElapsed, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, saveClipToMediaBin, clipSaveableToMediaBin,
runComboSweep, parseComboSweepConfig, rateComboResult, discardComboResult, interrupt, loadModels, loadImageTargets, setModelInput, setModelBypass, setModelLink, clearNotice,
projectVariables, setProjectVariables, promptTemplates, savePromptTemplate, deletePromptTemplate, applyPromptTemplate,
renamePromptTemplate, clearGlobalPrompt,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow,
loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset,
loadShortcuts, loadStoryMarkers, saveShortcut, deleteShortcut, importShortcuts, clearShortcuts, addCategory,
getEditorSettings, getEditorSetting, setEditorSetting,
loadTransitions, saveTransition, deleteTransition, importTransitions, clearTransitions,
loadNleLibrary, applyNleEffect, applyNleVideoTransition,
applySplitMarkerToSelection, applyTransitionToSelection, insertShortcutIntoSelection,
setSceneRating: (id, v) => {
patchScene(id, { rating: v || "" });
if (window.Timeline?.syncClipRatings) window.Timeline.syncClipRatings(get());
const promptId = state.sceneRenders[id]?.promptId;
const taste = window.MovieRatingPicker?.tasteOf(v); // null: a label that teaches nothing
if (promptId && taste !== null) {
const t = taste === "clear" || taste === undefined ? { rating: null, axis: null } : taste;
API.rateTaste(promptId, t.rating, t.axis).then((r) => {
if (r?.why) set({ notice: `Taste key not taught: ${r.why}.` });
}).catch(() => {}); // no taste module: rating stays a label
}
},
resetStudioSession,
};
})();