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:
co-authored by
Claude
parent
e29f7fea3b
commit
70cd015219
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user