diff --git a/conditioning.py b/conditioning.py index 3cdc2fa7..3a8fd207 100644 --- a/conditioning.py +++ b/conditioning.py @@ -11718,8 +11718,16 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): steer_mode="relative", absolute_strength=0.6, _seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None, batch_variants=1, guess_mode=False, guess_direction="up", guess_range=1.0, - guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None): + guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None, + variables=None): seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff) + # Project-scoped `$name` variables, resolved DEAD LAST (after shortcut-expand + split) + # so they can never affect scene-cut detection. Empty/None = no-op. + try: + from .templates import resolve_variables as _resolve_variables + except ImportError: + from templates import resolve_variables as _resolve_variables + _prompt_variables = variables or None encode_cache = {} linked_refinement_key = str(refinement_key_input or "").strip() if linked_refinement_key: @@ -12084,6 +12092,12 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): prompt_to_encode = f"{base}, {', '.join(additions)}" if base else ", ".join(additions) repair_status = repair_status.replace("suggestion(s)", "phrase(s)").replace("suggested", "added") + # Resolve $name variables into the base prompt before encoding (single-scene path + + # the modified_positive output). The multi-scene per-scene texts are resolved after the + # split below; this keeps the order shortcuts -> split -> variables intact. + if _prompt_variables: + prompt_to_encode = _resolve_variables(prompt_to_encode, _prompt_variables)[0] + cond, meta, encode_status, conditioning_owner = self._v2_conditioning_source( clip, prompt_to_encode, @@ -12301,6 +12315,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): segments = [(anchor_exp, None)] + \ [(s.get("expanded", ""), s.get("effect")) for s in canon_scenes] split_scene_texts, split_scene_effects = self._v2_transition_scene_texts(segments) + # Variables resolve here — AFTER shortcut-expand and the transition split — + # so a variable whose value contains a comma or a trigger word can never + # change the scene count. Per-scene texts are what the chain sampler encodes. + if _prompt_variables and split_scene_texts: + split_scene_texts = [ + _resolve_variables(t, _prompt_variables)[0] for t in split_scene_texts + ] scene_refinement_keys = [set(s.get("keys") or set()) for s in canon_scenes] current_scene_seeds = self._v2_scene_seed_values(seed, len(split_scene_texts), _scene_seeds) current_scene_seed_source = ( @@ -14448,6 +14469,7 @@ class FunPackStudio: _scene_seeds=scene_seed_memory, movie_editor_scene_ratings=_me_scene_ratings, scene_segments=(rf.get("scenes") if isinstance(rf.get("scenes"), dict) else None), + variables=(rf.get("variables") if isinstance(rf.get("variables"), (list, dict)) else None), ) # --- Conditioning Adjust --- diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index a70b4a78..2476e247 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -319,6 +319,10 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No # (first run after the user clicks "Reset Studio session"); explicit so it's never # left on from a previous run. _rf["reset_session"] = bool(params.get("reset_session")) + # Project `$name` variables — Studio resolves them dead-last (after split), per scene. + _vars = params.get("variables") + if isinstance(_vars, (list, dict)) and _vars: + _rf["variables"] = _vars if _me_scene_ratings: _rf["movie_editor_scene_ratings"] = _me_scene_ratings _ss["refiner"] = _rf diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 16f371ab..34d20623 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -566,6 +566,13 @@ class Project: # corresponding slot == "funpack"). Keys match ComfyUI widget/input names exactly. studio_inputs: dict = field(default_factory=dict) sampler_inputs: dict = field(default_factory=dict) + # Prompt `$name` variables — a project-scoped find/replace layer resolved at generation AFTER + # shortcut-expand and the transition split (so they never affect scene cuts). Ordered list of + # {"name": str, "value": str}. Shortcuts may reference variables; resolution is recursive. + variables: list = field(default_factory=list) + # Saved global-prompt templates: [{"name": str, "prompt": str, "variables": [...]}]. Selecting + # one in the Composer applies its prompt + variables; loaded with the project (no Load button). + prompt_templates: list = field(default_factory=list) # Refinement key for this project's runs. Feeds the FunPackRefinementKeyLoader (Studio / # Chain Sampler / SaveRefinementLatent). "default" = the keyless/default store. Shortcuts # bound to a non-default key layer their own per-scene training on top of this. @@ -629,6 +636,8 @@ class Project: sampler_slot=str(d.get("sampler_slot", "funpack")), studio_inputs=dict(d.get("studio_inputs") or {}), sampler_inputs=dict(d.get("sampler_inputs") or {}), + variables=list(d.get("variables") or []), + prompt_templates=list(d.get("prompt_templates") or []), refinement_key=str(d.get("refinement_key") or "default"), keep_original_audio=bool(d.get("keep_original_audio", True)), audio_tracks=list(d.get("audio_tracks") or []), diff --git a/movie_editor/frontend/composer.js b/movie_editor/frontend/composer.js index cb20a974..2fd85d87 100644 --- a/movie_editor/frontend/composer.js +++ b/movie_editor/frontend/composer.js @@ -187,6 +187,9 @@ || (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || ""; const val = gpDraft != null ? gpDraft : live; + // Templates: pick one to apply (prompt + variables), Save to snapshot the current state. + wrap.append(composeTemplatesBar()); + const titleRow = el("div", "compose-head"); titleRow.append(el("div", "lib-form-title", "Global prompt")); const addSc = el("button", "btn ghost tiny", "+ Add shortcut"); @@ -197,15 +200,140 @@ const ta = el("textarea", "lib-in compose-ta"); ta.rows = 14; ta.value = val; ta.placeholder = "Anchor, scene texts, and split markers — one combined montage prompt for generation."; - ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); }; + ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); updateVarHint(); }; wrap.append(ta); wrap.append(el("div", "insp-hint", "Edits apply automatically and stay in sync with the timeline's per-scene prompts. Shortcuts expand at generation time.")); composeTextarea = ta; if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta); + + // Variables ($name) — collapsible, same toggle pattern as Settings ▸ Built-in pipeline. + wrap.append(composeVariables()); return wrap; } let composeTextarea = null; + let varsOpen = false; // Variables panel expanded? + let varHintEl = null; // undeclared / cycle hint line under the prompt + + // ── templates bar (above the global prompt) ───────────────────────────────────── + function composeTemplatesBar() { + const bar = el("div", "compose-templates"); + const sel = el("select", "lib-in compose-tpl-select"); + const ph = new Option("Templates…", ""); ph.disabled = true; ph.selected = true; sel.append(ph); + (S.promptTemplates() || []).forEach((t) => { + const preview = String(t.prompt || "").replace(/\s+/g, " ").slice(0, 48); + const o = new Option(`${t.name}${preview ? " — " + preview : ""}`, t.name); + sel.append(o); + }); + sel.title = "Apply a saved global prompt + its variables"; + sel.onchange = () => { const n = sel.value; if (n) S.applyPromptTemplate(n).then(render); }; + bar.append(sel); + const save = el("button", "btn ghost tiny", "Save"); + save.title = "Save the current global prompt + variables as a template"; + save.onclick = () => { + const name = (window.prompt("Template name:") || "").trim(); + if (!name) return; + const txt = composeTextarea ? composeTextarea.value : (S.get().project.global_prompt || ""); + S.savePromptTemplate(name, txt); + render(); + }; + bar.append(save); + return bar; + } + + // ── variables panel (below the global prompt) ─────────────────────────────────── + // The list IS the editor: editable name/value rows + Add/Remove. Resolution happens at + // generation, after shortcuts + split (Studio), so nothing here touches the timeline. + function composeVariables() { + const box = el("div", "compose-vars"); + const toggle = el("button", "btn ghost tiny composer-var-toggle", + (varsOpen ? "▾ " : "▸ ") + "Variables"); + toggle.title = "Project $name variables — substituted into the prompt at generation"; + toggle.onclick = () => { varsOpen = !varsOpen; render(); }; + box.append(toggle); + + varHintEl = el("div", "insp-hint compose-var-hint"); + box.append(varHintEl); + + if (varsOpen) { + const list = el("div", "compose-var-list"); + // Local working copy; committed (persisted) on every edit, re-rendered only on add/remove. + const vars = (S.projectVariables() || []).map((v) => ({ + name: String((v && v.name) || ""), value: String((v && v.value != null) ? v.value : ""), + })); + const commit = () => S.setProjectVariables(vars); + vars.forEach((v, i) => { + const row = el("div", "compose-var-row"); + row.append(el("span", "compose-var-dollar", "$")); + const nm = el("input", "lib-in compose-var-name"); + nm.value = v.name; nm.placeholder = "name"; + nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); updateVarHint(); }; + row.append(nm); + row.append(el("span", "compose-var-eq", "=")); + const vv = el("input", "lib-in compose-var-val"); + vv.value = v.value; vv.placeholder = "value (may reference $other)"; + vv.oninput = () => { v.value = vv.value; commit(); updateVarHint(); }; + row.append(vv); + const rm = el("button", "btn ghost tiny", "✕"); + rm.title = "Remove variable"; + rm.onclick = () => { vars.splice(i, 1); commit(); render(); }; + row.append(rm); + list.append(row); + }); + const add = el("button", "btn ghost tiny", "+ Add variable"); + add.onclick = () => { vars.push({ name: "", value: "" }); commit(); render(); }; + list.append(add); + box.append(list); + } + // Defer so composeTextarea is assigned before the first scan. + setTimeout(updateVarHint, 0); + return box; + } + + // Detect $name cycles among the declared variables (the "yell"); mirrors the safe-degrade + // guard in templates.resolve_variables but reports the loop instead of silently leaving it. + function detectVarCycles() { + const map = {}; + (S.projectVariables() || []).forEach((v) => { + const k = String((v && v.name) || "").replace(/^\$+/, "").trim(); + if (k) map[k] = String((v && v.value) || ""); + }); + const refs = (n) => (String(map[n] || "").match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || []) + .map((m) => m.slice(1)).filter((r) => r in map); + const color = {}, path = [], cycles = []; + const dfs = (n) => { + color[n] = 1; path.push(n); + for (const r of refs(n)) { + if (color[r] === 1) cycles.push(path.slice(path.indexOf(r)).concat(r)); + else if (!color[r]) dfs(r); + } + path.pop(); color[n] = 2; + }; + Object.keys(map).forEach((n) => { if (!color[n]) dfs(n); }); + return cycles; + } + + // Live hint under the prompt: cycle warning takes priority, else list $vars used but not declared. + function updateVarHint() { + if (!varHintEl) return; + const declared = new Set((S.projectVariables() || []) + .map((v) => String((v && v.name) || "").replace(/^\$+/, "").trim()).filter(Boolean)); + const txt = composeTextarea ? composeTextarea.value : ""; + const used = new Set((txt.match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((m) => m.slice(1))); + const undeclared = [...used].filter((n) => !declared.has(n)); + const cycles = detectVarCycles(); + let msg = ""; + if (cycles.length) { + msg = "⚠ Variable loop: " + cycles[0].map((n) => "$" + n).join(" → ") + + " — references itself. Rework it; it won't expand."; + } else if (undeclared.length) { + msg = "Referencing " + undeclared.map((n) => "$" + n).join(", ") + + " — never declared, passed as plain text."; + } + varHintEl.textContent = msg; + varHintEl.classList.toggle("compose-var-warn", !!cycles.length); + varHintEl.style.display = msg ? "" : "none"; + } // Insert text at the compose textarea's caret (or append), then re-sync the timeline. function insertIntoCompose(text) { diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 37408ce7..f54440e9 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -898,6 +898,43 @@ 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) || []; } + function savePromptTemplate(name, promptText) { + if (!state.project) return; + const nm = String(name || "").trim(); + if (!nm) return; + const snapshot = { + name: nm, + prompt: String(promptText != null ? promptText : (state.project.global_prompt || "")), + 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 }); + } + function deletePromptTemplate(name) { + if (!state.project) return; + patchProject({ prompt_templates: (state.project.prompt_templates || []).filter((t) => t && t.name !== name) }); + } + async function applyPromptTemplate(name) { + if (!state.project) return; + const tpl = (state.project.prompt_templates || []).find((t) => t && t.name === name); + if (!tpl) return; + // Restore the saved variables first, then distribute the prompt (re-splits the timeline). + patchProjectQuiet({ variables: JSON.parse(JSON.stringify(tpl.variables || [])) }); + await applyGlobalPromptQuiet(tpl.prompt || ""); + } + 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) || ""; } @@ -3867,6 +3904,7 @@ resizeScene, setSceneGapAfter, splitScene, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, setSourceTrim, trimSceneLeft, slipScene, applyEnginePreset, ENGINE_PRESETS, undo, redo, refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate, generateMontage, generateSelected, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink, clearNotice, + projectVariables, setProjectVariables, promptTemplates, savePromptTemplate, deletePromptTemplate, applyPromptTemplate, setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow, loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset, loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, clearShortcuts, addCategory, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 2dee0570..90aaccac 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1160,6 +1160,24 @@ body.tl-spacing { cursor: grabbing; user-select: none; } .composer-preview { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; } .composer-preview:empty { display: none; } +/* Global-prompt templates bar (above the prompt) */ +.compose-templates { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } +.compose-templates .compose-tpl-select { flex: 1; min-width: 0; } +.compose-templates .btn { flex: 0 0 auto; } + +/* Variables panel (below the prompt) */ +.compose-vars { margin-top: 8px; } +.composer-var-toggle { padding-left: 2px; } +.compose-var-hint { margin-top: 4px; } +.compose-var-hint.compose-var-warn { color: var(--amber); } +.compose-var-list { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; } +.compose-var-row { display: flex; gap: 6px; align-items: center; } +.compose-var-dollar { color: var(--amber); font-family: var(--font-mono); opacity: 0.8; } +.compose-var-eq { color: var(--muted); } +.compose-var-row .compose-var-name { flex: 0 0 30%; min-width: 0; } +.compose-var-row .compose-var-val { flex: 1; min-width: 0; } +.compose-var-row .btn { flex: 0 0 auto; } + /* config validation banner */ /* Pipeline requirements panel */ .req-panel { margin-bottom: 18px; } diff --git a/movie_editor/server.py b/movie_editor/server.py index c563ff11..239e2825 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -1523,6 +1523,7 @@ if web is not None and PromptServer is not None: # generation prompt stays clean — no injected `scene N` delimiters. "scene_segments": build_generation_scene_segments(target), "sampler_inputs": sampler_inputs, + "variables": list(target.variables or []), "reset_session": reset_session, "refinement_key": (target.refinement_key or "default"), }, media=(media_pack or {}).get("primary") if media_pack else None) diff --git a/templates.py b/templates.py index d7ac36e3..f7261147 100644 --- a/templates.py +++ b/templates.py @@ -770,6 +770,63 @@ def _cleanup_removed_phrases(text): return text +# ── prompt variables ($name) ──────────────────────────────────────────────────── +# Variables are a project-scoped find/replace layer resolved DEAD LAST — after shortcut +# expansion AND after the transition split — so they can never create or move a scene cut. +# A `$name` token is replaced with the variable's text; the text may itself reference other +# variables (recursive), undefined names are left as literal `$name`, and a variable that +# references itself anywhere in its own chain is a cycle (left literal, never expanded). +_VARIABLE_TOKEN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)") +_VARIABLE_MAX_DEPTH = 64 + + +def _normalize_variables(variables): + """Accept either {name: text} or [{"name":, "value":}] and return an ordered-safe dict. + Names are stripped of a leading `$` and surrounding whitespace; later entries win.""" + out = {} + if isinstance(variables, dict): + items = list(variables.items()) + elif isinstance(variables, list): + items = [ + (v.get("name"), v.get("value", v.get("content", v.get("text", "")))) + for v in variables if isinstance(v, dict) + ] + else: + items = [] + for name, val in items: + key = str(name if name is not None else "").lstrip("$").strip() + if key: + out[key] = str(val if val is not None else "") + return out + + +def resolve_variables(text, variables): + """Substitute `$name` tokens in `text` from `variables`. Recursive, cycle-safe (a name in + its own expansion chain is left literal), depth-guarded, undefined -> literal `$name`. + Returns (resolved_text, sorted list of undefined names referenced).""" + var_map = _normalize_variables(variables) + if not var_map: + return str(text or ""), [] + undefined = set() + + def _expand(s, stack, depth): + if depth > _VARIABLE_MAX_DEPTH: + return s + + def _repl(m): + name = m.group(1) + if name not in var_map: + undefined.add(name) + return m.group(0) # undefined -> leave literal + if name in stack: + return m.group(0) # cycle -> leave literal, do not recurse + return _expand(var_map[name], stack | {name}, depth + 1) + + return _VARIABLE_TOKEN.sub(_repl, s) + + return _expand(str(text or ""), frozenset(), 0), sorted(undefined) + + def apply_prompt_shortcuts(text, seed=0, shortcut_db=None): original = str(text or "") if not original: