From 70cd0152190ac4d4c772f560fb9ccf4e4ef8af7d Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:11:38 +0300 Subject: [PATCH 01/29] Easy Gen: prompt $name variables panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- easy_gen/frontend/easy.css | 23 ++++ easy_gen/frontend/easy_store.js | 15 +++ easy_gen/frontend/easy_variables.js | 166 ++++++++++++++++++++++++++++ easy_gen/frontend/index.html | 5 + 4 files changed, 209 insertions(+) create mode 100644 easy_gen/frontend/easy_variables.js diff --git a/easy_gen/frontend/easy.css b/easy_gen/frontend/easy.css index da5908a1..1c0c410f 100644 --- a/easy_gen/frontend/easy.css +++ b/easy_gen/frontend/easy.css @@ -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; } diff --git a/easy_gen/frontend/easy_store.js b/easy_gen/frontend/easy_store.js index c1deaafa..31ba2277 100644 --- a/easy_gen/frontend/easy_store.js +++ b/easy_gen/frontend/easy_store.js @@ -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, diff --git a/easy_gen/frontend/easy_variables.js b/easy_gen/frontend/easy_variables.js new file mode 100644 index 00000000..21a61263 --- /dev/null +++ b/easy_gen/frontend/easy_variables.js @@ -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(); +})(); diff --git a/easy_gen/frontend/index.html b/easy_gen/frontend/index.html index 095ad4a7..2e8b33f0 100644 --- a/easy_gen/frontend/index.html +++ b/easy_gen/frontend/index.html @@ -55,6 +55,8 @@
No project