editor: prompt $variables + global-prompt templates (Composer)

Project-scoped $name variables resolved DEAD LAST in refine_v2 — after
shortcut-expand and the transition split — so a value containing a comma or
trigger word can never move a scene cut (order: shortcuts -> splits -> vars).
Recursive (a value may reference other vars), cycle-safe (self-reference left
literal, no infinite loop), undefined -> literal $name.

- templates.resolve_variables: recursive, depth-guarded, cycle-safe resolver
- refine_v2: resolve base prompt (single-scene) + per-scene texts (post-split);
  variables threaded via studio_settings.refiner (same channel as scenes)
- builder/server/timeline: carry project.variables into the build; persist
  variables + prompt_templates on the project
- Composer: templates dropdown + Save above the prompt; collapsible Variables
  editor (Add/Remove rows) below it, with live undeclared/cycle hint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-25 12:11:44 +03:00
co-authored by Claude Opus 4.8
parent 7e053e9d60
commit e11a4e109f
8 changed files with 279 additions and 2 deletions
+23 -1
View File
@@ -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 ---
+4
View File
@@ -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
+9
View File
@@ -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 []),
+129 -1
View File
@@ -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) {
+38
View File
@@ -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,
+18
View File
@@ -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; }
+1
View File
@@ -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)
+57
View File
@@ -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: