Files
olivv-cs-ComfyUI-FunPack/easy_gen/frontend/easy_store.js
T
digital-garbageandClaude 8e9c0479a8 Variables: own window in Easy Gen, auto-growing value fields in both UIs
Follow-up to the previous commit, per review:
- the panel is no longer docked into Easy Gen's main screen — the "$ Variables"
  button now sits in the prompt bar (next to Upload/Gallery, not pinned to the
  header) and opens a modal window, same shell as the Gallery picker
- value fields are auto-growing textareas instead of one-line inputs, in the
  Cutting Room's Composer as well: a long phrase was impossible to edit in a
  single-line field. Height is measured from scrollHeight with the border-box
  borders added back, re-fit once after layout (scrollHeight reads 0 before it)
- fully-empty rows are dropped on persist, so an accidental "Add variable" click
  never sticks to the project (the row stays on screen until it's filled in —
  the open window keeps its own working copy for exactly this reason)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 20:18:15 +03:00

386 lines
17 KiB
JavaScript

// Minimal state container for Easy Gen — NOT the Editor's store.js (that's a
// 4000+ line timeline/composer/overlay engine tightly coupled to the full NLE
// DOM). This is a small pub-sub wrapper around the same /funpack/movie/api/*
// endpoints, but it DOES support the same one-prompt-splits-into-N-scenes
// flow as the Editor's Composer — the user only ever sees one textarea, the
// resulting scenes are never surfaced in any per-scene UI, and Generate always
// runs the whole project as one chain (no scene_ids filter), so N scenes come
// back as one stitched video with no manual cut/trim ability, by design.
//
// Exposed as window.Store (same global name the Editor uses) purely so
// reused sections — models.js, autocomplete.js, and the built-in "Refinement
// & Taste" / "Updates & ComfyUI" sections baked into settings_window.js —
// work unmodified: they all read window.Store.get().project/.shortcuts/etc.
// and call window.Store.subscribe(fn) without needing to know which app they're in.
(function () {
const API = window.MovieEditorAPI;
const state = {
project: null,
health: null,
resetSessionArmed: false,
shortcuts: [], // prompt shortcut library (for autocomplete)
transitions: [], // transition trigger library (for split-marker inference)
mediaBin: [], // uploaded assets [{id,name,kind,...}] — feeds engine_settings.js's MediaPicker
};
const listeners = new Set();
function get() { return state; }
function notify() { listeners.forEach((fn) => { try { fn(state); } catch (_) {} }); }
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function set(patch) {
Object.assign(state, patch);
notify();
}
async function refreshHealth() {
try { set({ health: await API.health() }); } catch (_) { set({ health: { ok: false } }); }
}
// ── per-browser editor settings ──────────────────────────────────────────
// Same localStorage key/shape as movie_editor/frontend/store.js so toggling
// "Prompt autocomplete" or "Use anchor" is shared between the Editor and
// Easy Gen on the same browser (matches the Editor's own cross-entry-point
// behavior — these are per-browser prompt-editing prefs, not project data).
const EDITOR_SETTINGS_KEY = "funpack_editor_settings";
const EDITOR_SETTINGS_DEFAULTS = { autocomplete: true, anchorEnabled: true };
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 getEditorSetting(key) { return _editorSettings[key]; }
function setEditorSetting(key, val) {
if (_editorSettings[key] === val) return;
_editorSettings = { ..._editorSettings, [key]: val };
try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {}
notify();
if (key === "anchorEnabled" && state.project && (state.project.global_prompt || "").trim()) {
applyGlobalPrompt(state.project.global_prompt);
}
}
async function loadLibraries() {
try { const r = await API.shortcuts(); set({ shortcuts: r.shortcuts || [] }); } catch (_) {}
try { const r = await API.transitions(); set({ transitions: r.transitions || r || [] }); } catch (_) {}
}
async function loadMedia() {
try { set({ mediaBin: (await API.listMedia()).media || [] }); } catch (_) { set({ mediaBin: [] }); }
}
// Import/export the same shortcut/transition JSON libraries the ComfyUI node graph
// and the Movie Editor read and write (one shared FunPack library, no Easy Gen copy).
// mode: "merge" (overlay onto the existing library) | "replace" (wipe first).
async function importShortcuts(file, mode) {
const data = JSON.parse(await file.text());
const r = await API.importShortcuts(data, mode);
set({ shortcuts: r.shortcuts || state.shortcuts });
return r.imported;
}
async function importTransitions(file, mode) {
const data = JSON.parse(await file.text());
const r = await API.importTransitions(data, mode);
set({ transitions: r.transitions || state.transitions });
return r.imported;
}
function emptyScene() {
return { text: "", source: { type: "empty" } };
}
async function listProjects() {
return (await API.listProjects()).projects || [];
}
async function createProject(name) {
const p = await API.createProject(name || "Untitled");
p.scenes = [emptyScene()];
const saved = await API.saveProject(p.id, p);
set({ project: saved });
return saved;
}
// Fallback for projects saved before the global-prompt split existed, or
// edited scene-by-scene in the Editor without ever using its Composer:
// reconstruct a verbatim prompt from anchor + scene texts + transition
// markers so the textarea shows something sensible instead of going blank.
function buildPromptFallback(p) {
const parts = [];
const anchor = (p.anchor || "").trim();
if (anchor) parts.push(anchor);
(p.scenes || []).forEach((s, i) => {
if (i > 0) parts.push((p.scenes[i - 1].transition_to_next || "cut").trim());
const t = (s.text || "").trim();
if (t) parts.push(t);
});
return parts.filter(Boolean).join(" ");
}
async function loadProject(id) {
const p = await API.getProject(id);
if (!p.scenes || !p.scenes.length) p.scenes = [emptyScene()];
if (!(p.global_prompt || "").trim()) p.global_prompt = buildPromptFallback(p);
set({ project: p });
return p;
}
async function deleteProject(id) {
await API.deleteProject(id);
if (state.project && state.project.id === id) set({ project: null });
}
// ── global prompt → N scenes (mirrors the Editor's Composer Apply, minus
// the timeline/ghost/history machinery Easy Gen has no use for) ──────────
// Ported from movie_editor/frontend/store.js's _normalizeGlobalPromptParse:
// folds the parser's "anchor" back into Scene 1 when the anchor setting is off.
function _normalizeGlobalPromptParse(trimmed, v) {
v = v || {};
const text = String(trimmed || "").trim();
const anchor = String(v.anchor || "").trim();
const scenes = Array.isArray(v.scenes) ? v.scenes : [];
const hasSceneText = scenes.some((s) => String(s.text || "").trim());
if (!hasSceneText && text) return { anchor: "", scenes: [{ text }] };
if (!hasSceneText && anchor) return { anchor: "", scenes: [{ text: anchor }] };
if (!_editorSettings.anchorEnabled && anchor) {
return { anchor: "", scenes: [{ text: anchor }, ...scenes] };
}
return { anchor: v.anchor || "", scenes };
}
// Ported from movie_editor/frontend/store.js's _inferSplitMarkersFromPrompt:
// finds which transition trigger (if any) sits in the raw gap text between
// two consecutive verbatim scene chunks, so the chain sampler gets the same
// seam behavior ("cut", etc.) the Editor would have produced.
function _inferSplitMarkers(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;
}
}
// Sequence guard: if the user keeps typing while a parse is in flight, an
// older (now-stale) response must not overwrite scenes built from newer text.
let _applySeq = 0;
// Splits `text` into project.anchor + project.scenes via the same backend
// parser the Editor's Composer uses (POST /projects/{id}/parse, lossless —
// no shortcut expansion, no scene UI). Scene 0 keeps whatever upload/guide
// it already had; new scenes 1..N-1 default to "carry" (continue the chain),
// matching how the Editor treats anchorless continuation scenes.
async function applyGlobalPrompt(text) {
if (!state.project) return false;
const trimmed = String(text || "").trim();
state.project.global_prompt = trimmed;
if (!trimmed) {
const prevSource = state.project.scenes[0]?.source || { type: "empty" };
state.project.scenes = [{ text: "", source: prevSource }];
state.project.anchor = "";
notify();
return true;
}
const seq = ++_applySeq;
let res;
try { res = await API.parsePrompt(state.project.id, trimmed); }
catch (e) { console.warn("Prompt parse failed:", e); return false; }
if (seq !== _applySeq) return false; // superseded by a newer keystroke
if (!state.project || state.project.global_prompt !== trimmed) return false; // project changed underneath us
const v = _normalizeGlobalPromptParse(trimmed, res.parsed_verbatim || res.parsed_raw || res.parsed || {});
if (!(v.scenes || []).length) return false;
const prevSource = state.project.scenes[0]?.source || { type: "empty" };
state.project.anchor = v.anchor || "";
state.project.scenes = v.scenes.map((s, i) => ({
text: String(s.text || "").trim(),
transition_to_next: "",
source: i === 0 ? prevSource : { type: "carry" },
}));
_inferSplitMarkers(state.project.scenes, trimmed);
notify();
return true;
}
let _applyTimer = null;
function scheduleGlobalPromptApply(text) {
if (_applyTimer) clearTimeout(_applyTimer);
_applyTimer = setTimeout(() => { applyGlobalPrompt(text).then(() => save().catch(() => {})); }, 500);
}
// kind: "image" | "video" | null (null clears the upload → pure t2v).
function setSceneMedia(mediaId, kind) {
if (!state.project) return;
state.project.scenes[0].source = mediaId
? { type: kind === "video" ? "v2v" : "image", media_ref: mediaId }
: { type: "empty" };
notify();
}
// Two overlapping save() calls (e.g. a debounced auto-save from an earlier
// edit still in flight when a later action calls save() directly) can land
// on the server out of order — whichever PUT is written to disk last wins,
// even if it was the older/now-stale one, silently reverting a newer edit.
// Serializing through one chain means a save only starts once the previous
// one has FULLY completed, and reads state.project live at that point, so
// it always picks up whatever changed while it waited.
let saveQueue = Promise.resolve();
function save() {
const run = saveQueue.then(async () => {
if (!state.project) return null;
// Models & Pipeline (models.js) saves bypass/expose/widget edits straight to the
// server via its own PUT /projects/{id}/models call — it never touches state.project
// here. Without refetching first, this save would send project.models as it was when
// the project was last loaded, silently reverting whatever was just changed there
// (same class of bug the Editor's store.js fixed by syncing state.models into
// state.project.models before every autosave commit).
try { state.project.models = await API.getModels(state.project.id); } catch (_) {}
const saved = await API.saveProject(state.project.id, state.project);
set({ project: saved });
return saved;
});
saveQueue = run.catch(() => {}); // keep the queue alive even if this save failed
return run;
}
// ── Engine settings (Studio / Chain Sampler / continuity) ──────────────────
// Minimal re-implementation of movie_editor/frontend/store.js's project-patch
// helpers, just enough for the shared engine_settings.js (loaded via the same
// frontend-fallback mechanism as autocomplete.js/suggestions.js) to run
// unmodified against Easy Gen's project. No undo/history, no global-prompt
// resync — Easy Gen's scenes come from the split parser, not manual editing.
let _engineSaveTimer = null;
function _scheduleEngineSave(ms) {
if (_engineSaveTimer) clearTimeout(_engineSaveTimer);
_engineSaveTimer = setTimeout(() => { _engineSaveTimer = null; save().catch(() => {}); }, ms);
}
function patchProject(patch) {
if (!state.project) return;
Object.assign(state.project, patch);
notify();
save().catch(() => {});
}
function patchProjectQuiet(patch) {
if (!state.project) return;
Object.assign(state.project, patch);
notify();
_scheduleEngineSave(600);
}
// ── prompt variables ($name) ───────────────────────────────────────────────
// Project-scoped find/replace, stored in the same project.variables field the Editor
// uses; the generate endpoint already forwards it to the builder, and Studio resolves
// it dead-last (after shortcut expansion + the scene split) inside refine_v2.
function projectVariables() { return (state.project && state.project.variables) || []; }
function setProjectVariables(list) {
if (!state.project) return;
// A leading $ is stripped so "$vid" and "vid" declare the same variable. Fully-empty
// rows are dropped on persist (the UI keeps showing them while they're being filled in),
// so an accidental "Add variable" click never sticks to the project.
patchProjectQuiet({ variables: (Array.isArray(list) ? list : []).map((v) => ({
name: String((v && v.name) || "").replace(/^\$+/, "").trim(),
value: String((v && v.value != null) ? v.value : ""),
})).filter((v) => v.name || v.value) });
}
function setConditioningSlot(slotId) { patchProject({ conditioning_slot: slotId || "funpack" }); }
function setSamplerSlot(slotId) { patchProject({ sampler_slot: slotId || "funpack" }); }
function setSamplerInput(name, value) {
if (!state.project) return;
patchProjectQuiet({ sampler_inputs: { ...(state.project.sampler_inputs || {}), [name]: value } });
}
function setSamplerInputNow(name, value) {
if (!state.project) return;
patchProject({ sampler_inputs: { ...(state.project.sampler_inputs || {}), [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;
patchProjectQuiet({ studio_inputs: { ...(state.project.studio_inputs || {}), [name]: value } });
}
function setStudioInputNow(name, value) {
if (!state.project) return;
patchProject({ studio_inputs: { ...(state.project.studio_inputs || {}), [name]: value } });
}
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;
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 };
patchProject(patch);
}
function resetStudioSession() {
set({ resetSessionArmed: !state.resetSessionArmed });
}
// Consumed by the Generate flow: reset_session applies to the NEXT run only.
function takeResetSessionFlag() {
const armed = state.resetSessionArmed;
if (armed) set({ resetSessionArmed: false });
return armed;
}
window.Store = {
get, subscribe, set,
refreshHealth,
getEditorSetting, setEditorSetting,
loadLibraries, importShortcuts, importTransitions,
loadMedia,
listProjects, createProject, loadProject, deleteProject,
applyGlobalPrompt, scheduleGlobalPromptApply,
setSceneMedia, save,
patchProject, patchProjectQuiet,
projectVariables, setProjectVariables,
setConditioningSlot, setSamplerSlot,
setSamplerInput, setSamplerInputNow, unsetSamplerInput,
setStudioInput, setStudioInputNow,
ENGINE_PRESETS, applyEnginePreset,
resetStudioSession, takeResetSessionFlag,
};
})();