Easy Gen: prompt $name variables panel

Ports the Editor Composer's $variables to Easy Gen: a "$ Variables" toggle in
the header opens a collapsible name/value editor docked above the prompt bar.

Same project.variables field the Editor writes, so nothing backend-side is new —
movie_editor/server.py already forwards it on every generate and Studio resolves
it dead-last inside refine_v2 (after shortcut expansion AND after the scene
split), which is what makes a $var work inside a shortcut's replacement while a
value containing a comma or trigger word can never move a scene cut.

- easy_store: projectVariables/setProjectVariables (leading $ stripped on store)
- easy_variables.js: rows + Add/Remove, count badge, live hint for undeclared
  names and self-referencing loops (cycle detection mirrors resolve_variables)
- rows rebuild only on add/remove/project-change, so typing never loses focus

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-26 20:11:38 +03:00
co-authored by Claude
parent e29f7fea3b
commit 70cd015219
4 changed files with 209 additions and 0 deletions
+23
View File
@@ -75,6 +75,29 @@ a.brand:hover .brand-sub { color: var(--amber-2); }
.easy-generate-btn { padding: 10px 26px; font-size: 14px; }
.easy-advanced-btn { align-self: center; }
/* Prompt $name variables — collapsible strip docked above the prompt bar. */
.easy-vars {
flex: none; max-height: 38vh; overflow: auto;
padding: 12px 16px; border-top: 1px solid var(--line); background: var(--ink-1);
}
.easy-vars[hidden] { display: none; }
.easy-vars-hint { color: var(--muted); font-size: 12px; line-height: 1.5; margin-bottom: 8px; }
.easy-vars-hint.easy-var-warn { color: var(--amber); }
.easy-vars-empty { color: var(--faint); font-size: 12px; }
.easy-var-list { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.easy-var-row { display: flex; gap: 6px; align-items: center; width: 100%; }
.easy-var-dollar { color: var(--amber); font-family: var(--font-mono); opacity: .8; }
.easy-var-eq { color: var(--muted); }
.easy-var-in {
background: var(--ink-2); border: 1px solid var(--line); border-radius: var(--radius);
color: var(--text); font: 13px/1.4 var(--font-ui); padding: 6px 9px; min-width: 0;
}
.easy-var-in:focus { outline: none; border-color: var(--amber); }
.easy-var-name { flex: 0 0 22%; font-family: var(--font-mono); }
.easy-var-val { flex: 1; }
.easy-var-row .btn { flex: 0 0 auto; }
#easy-vars-btn.easy-vars-warn { color: var(--amber); border-color: var(--amber); }
.es-io-controls { display: flex; align-items: center; gap: 8px; }
.es-io-mode { background: var(--ink-2); border: 1px solid var(--line); color: var(--text);
border-radius: 6px; font-size: 12px; padding: 4px 6px; }
+15
View File
@@ -292,6 +292,20 @@
_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.
patchProjectQuiet({ variables: (Array.isArray(list) ? list : []).map((v) => ({
name: String((v && v.name) || "").replace(/^\$+/, "").trim(),
value: String((v && v.value != null) ? v.value : ""),
})) });
}
function setConditioningSlot(slotId) { patchProject({ conditioning_slot: slotId || "funpack" }); }
function setSamplerSlot(slotId) { patchProject({ sampler_slot: slotId || "funpack" }); }
function setSamplerInput(name, value) {
@@ -359,6 +373,7 @@
applyGlobalPrompt, scheduleGlobalPromptApply,
setSceneMedia, save,
patchProject, patchProjectQuiet,
projectVariables, setProjectVariables,
setConditioningSlot, setSamplerSlot,
setSamplerInput, setSamplerInputNow, unsetSamplerInput,
setStudioInput, setStudioInputNow,
+166
View File
@@ -0,0 +1,166 @@
// Easy Gen's prompt `$name` variables panel — quick replacements for full phrases,
// e.g. $vid = "High quality, high fidelity realistic video, motion blur, cinematic fog".
//
// Storage is project.variables ([{name, value}]) — the SAME project field the Editor
// writes and the same one movie_editor/server.py already hands to the builder on every
// generate ("variables": list(target.variables or [])), so nothing backend-side is new:
// templates.resolve_variables substitutes them inside refine_v2 DEAD LAST, after shortcut
// expansion and after the transition split. That order is what makes them work INSIDE
// shortcuts (a shortcut whose replacement contains $vid still resolves) while a value
// containing a comma or a trigger word can never move a scene cut.
//
// Undefined names stay literal ($foo passes through as text) and a self-referencing chain
// is left literal rather than looping — the hint line below warns about both before you burn
// a generation on it.
(function () {
const { el } = window.dom;
const S = window.Store;
const panel = document.getElementById("easy-vars");
const toggleBtn = document.getElementById("easy-vars-btn");
const promptEl = document.getElementById("easy-prompt");
if (!panel || !toggleBtn) return;
let open = false;
let hintEl = null;
let renderedProjectId = null;
function vars() {
return (S.projectVariables() || []).map((v) => ({
name: String((v && v.name) || ""),
value: String((v && v.value != null) ? v.value : ""),
}));
}
// Cycle detection over the declared variables — mirrors the safe-degrade guard in
// templates.resolve_variables, but reports the loop instead of silently leaving it literal.
function detectCycles() {
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: a cycle wins over the undeclared list. Only the visible prompt is scanned —
// a $var used solely inside a shortcut's replacement text is legitimate and stays silent.
function warning() {
const cycles = detectCycles();
if (cycles.length) {
return {
bad: true,
msg: "⚠ Variable loop: " + cycles[0].map((n) => "$" + n).join(" → ")
+ " — references itself. Rework it; it won't expand.",
};
}
const declared = new Set((S.projectVariables() || [])
.map((v) => String((v && v.name) || "").replace(/^\$+/, "").trim()).filter(Boolean));
const txt = promptEl ? promptEl.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));
if (undeclared.length) {
return {
bad: false,
msg: "Referencing " + undeclared.map((n) => "$" + n).join(", ")
+ " — never declared, passed through as plain text.",
};
}
return { bad: false, msg: "" };
}
function updateChrome() {
const st = S.get();
toggleBtn.disabled = !st.project;
const n = (S.projectVariables() || []).filter((v) => String((v && v.name) || "").trim()).length;
toggleBtn.textContent = (open ? "▾ " : "▸ ") + "$ Variables" + (n ? ` (${n})` : "");
const w = warning();
toggleBtn.classList.toggle("easy-vars-warn", !!w.bad);
if (hintEl) {
hintEl.textContent = w.msg;
hintEl.classList.toggle("easy-var-warn", !!w.bad);
hintEl.style.display = w.msg ? "" : "none";
}
}
function render() {
panel.textContent = "";
panel.hidden = !open;
if (!open) { updateChrome(); return; }
panel.append(el("div", "easy-vars-hint",
"Shorthand for full phrases. Type $name in the prompt (or inside a shortcut's replacement) "
+ "and it's swapped for the text below at generation — after shortcuts expand and after the "
+ "prompt splits into scenes, so a value can never move a scene cut."));
hintEl = el("div", "easy-vars-hint easy-var-hint");
panel.append(hintEl);
const list = el("div", "easy-var-list");
// Local working copy: every keystroke commits it to the project (debounced save), and
// only add/remove re-renders — so an input never loses focus mid-word.
const rows = vars();
const commit = () => { S.setProjectVariables(rows); updateChrome(); };
rows.forEach((v, i) => {
const row = el("div", "easy-var-row");
row.append(el("span", "easy-var-dollar", "$"));
const nm = el("input", "easy-var-in easy-var-name");
nm.value = v.name; nm.placeholder = "name";
nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); };
row.append(nm);
row.append(el("span", "easy-var-eq", "="));
const vv = el("input", "easy-var-in easy-var-val");
vv.value = v.value; vv.placeholder = "value (may reference $other)";
vv.oninput = () => { v.value = vv.value; commit(); };
row.append(vv);
const rm = el("button", "btn ghost tiny", "✕");
rm.type = "button";
rm.title = "Remove variable";
rm.onclick = () => { rows.splice(i, 1); S.setProjectVariables(rows); render(); };
row.append(rm);
list.append(row);
});
if (!rows.length) list.append(el("div", "easy-vars-empty", "No variables yet."));
const add = el("button", "btn ghost tiny easy-var-add", "+ Add variable");
add.type = "button";
add.onclick = () => { rows.push({ name: "", value: "" }); S.setProjectVariables(rows); render(); };
list.append(add);
panel.append(list);
updateChrome();
}
toggleBtn.onclick = () => {
if (!S.get().project) return;
open = !open;
render();
};
// Rebuild only when the project actually changes (a fresh set of rows); every other
// notify just refreshes the count/warning so in-progress typing is never interrupted.
S.subscribe((st) => {
const pid = st.project ? st.project.id : null;
if (pid !== renderedProjectId) {
renderedProjectId = pid;
if (open) { render(); return; }
}
updateChrome();
});
if (promptEl) promptEl.addEventListener("input", updateChrome);
updateChrome();
})();
+5
View File
@@ -55,6 +55,8 @@
<div class="easy-project-name" id="easy-project-name">No project</div>
<span class="health-chip" id="easy-health-chip"></span>
<div class="menubar-right">
<button class="btn ghost tiny" id="easy-vars-btn" type="button"
title="Prompt $name variables — shorthand for full phrases, substituted at generation">▸ $ Variables</button>
<button class="btn ghost tiny" id="easy-save-btn" type="button">Save</button>
<button class="btn ghost tiny" id="easy-export-btn" type="button"
title="Download this project as a .funpack_project.json file — e.g. to move it off a rental machine">Export…</button>
@@ -64,6 +66,8 @@
<main class="easy-preview" id="easy-preview"></main>
<section class="easy-vars" id="easy-vars" hidden></section>
<footer class="easy-bar">
<button class="btn ghost easy-upload-btn" id="easy-upload-btn" type="button"
title="Upload an image or video to guide generation">⬆ Upload</button>
@@ -98,6 +102,7 @@
<script src="autocomplete.js"></script>
<script src="project_menu.js"></script>
<script src="easy_gallery.js"></script>
<script src="easy_variables.js"></script>
<script src="log.js"></script>
<script src="preview.js"></script>
<script src="app.js"></script>