Files
olivv-cs-ComfyUI-FunPack/app/legacy/composer.js
T

918 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Composer: the home for prompt-craft tools — Story (every scene as one text), Shortcuts, Cuts.
// Lives in a draggable FloatingWindow, toggled by the "Composer" button next to the panel tabs.
(function () {
const { el, clear } = window.dom;
const S = window.Store;
const API = window.MovieEditorAPI;
let win = null; // FloatingWindow instance (lazy)
let tab = "Story";
let q = { Shortcuts: "" };
let gpDraft = null; // in-progress global-prompt text (Compose tab), or null
let _modal = null;
// ── generic editor modal (shortcut / split forms) ──────────────────────────────
function closeModal() { if (_modal) { _modal.remove(); _modal = null; } }
function openModal(title, build) {
closeModal();
const overlay = el("div", "modal-overlay");
const box = el("div", "modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", title));
const hr = el("div", "modal-head-right");
const x = el("button", "btn ghost tiny", "✕"); x.onclick = closeModal;
hr.append(x); head.append(hr); box.append(head);
const content = el("div", "modal-content");
build(content, closeModal);
box.append(content); overlay.append(box);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
document.body.append(overlay);
overlay.style.zIndex = _stackZ(0);
_modal = overlay;
}
// z-index helper: keep Composer modals above the floating window (~4000+); `bump`
// stacks a secondary modal above the primary editor modal beneath it.
function _stackZ(bump) {
const winZ = win && win.root ? (parseInt(win.root.style.zIndex || "4000", 10) || 4000) : 4000;
const base = _modal ? (parseInt(_modal.style.zIndex || winZ, 10) || winZ) : winZ;
return base + 5 + (bump || 0);
}
// A small modal that STACKS above the current editor modal without closing it —
// used for the quick "Add category / sub-category" prompts.
function openStackModal(title, build) {
const overlay = el("div", "modal-overlay");
const box = el("div", "modal modal-mini");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", title));
box.append(head);
const content = el("div", "modal-content");
const close = () => overlay.remove();
build(content, close);
box.append(content); overlay.append(box);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
overlay.style.zIndex = _stackZ(10);
setTimeout(() => { const i = box.querySelector("input, select"); if (i) i.focus(); }, 0);
return close;
}
function promptNewCategory(onAdded) {
openStackModal("Add category", (content, close) => {
const box = el("div", "lib-form lib-form-modal");
const name = el("input", "lib-in"); name.placeholder = "Category name";
box.append(labeled("Name", name));
const actions = el("div", "lib-form-actions");
const ok = el("button", "btn primary tiny", "OK");
ok.onclick = async () => {
const v = name.value.trim(); if (!v) { name.focus(); return; }
if (await S.addCategory(v)) { close(); if (onAdded) onAdded(v); }
};
const cancel = el("button", "btn ghost tiny", "Cancel"); cancel.onclick = close;
name.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); ok.click(); } };
actions.append(ok, cancel); box.append(actions);
content.append(box);
});
}
function promptNewSubCategory(presetCategory, onAdded) {
const cats = (S.get().shortcutCategories || []);
if (!presetCategory && !cats.length) { alert("Add a category first."); return; }
openStackModal("Add sub-category", (content, close) => {
const box = el("div", "lib-form lib-form-modal");
let parentSel = null;
if (!presetCategory) {
parentSel = selectFrom(cats.map((c) => c.name), cats[0] && cats[0].name);
box.append(labeled("Category", parentSel));
}
const name = el("input", "lib-in"); name.placeholder = "Sub-category name";
box.append(labeled("Name", name));
const actions = el("div", "lib-form-actions");
const ok = el("button", "btn primary tiny", "OK");
ok.onclick = async () => {
const parent = presetCategory || (parentSel && parentSel.value) || "";
const v = name.value.trim();
if (!parent) { alert("Pick a category first."); return; }
if (!v) { name.focus(); return; }
if (await S.addCategory(parent, v)) { close(); if (onAdded) onAdded(parent, v); }
};
const cancel = el("button", "btn ghost tiny", "Cancel"); cancel.onclick = close;
name.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); ok.click(); } };
actions.append(ok, cancel); box.append(actions);
content.append(box);
});
}
function optionEl(value, label, selected) {
const o = el("option", null, label); o.value = value; if (selected) o.selected = true; return o;
}
// Ask whether an import should Merge into the current library or Replace it
// wholesale. Calls onPick("merge" | "replace") — or never, if cancelled.
function chooseImportMode(kind, onPick) {
openStackModal(`Import ${kind}`, (content, close) => {
const box = el("div", "lib-form lib-form-modal");
box.append(el("div", "insp-hint",
`Merge keeps your current ${kind} and adds the imported ones (a matching name overwrites). `
+ `Replace deletes every current ${kind} first, then loads the file.`));
const actions = el("div", "lib-form-actions");
const merge = el("button", "btn primary tiny", "Merge");
merge.onclick = () => { close(); onPick("merge"); };
const replace = el("button", "btn danger tiny", "Replace all");
replace.onclick = () => { close(); onPick("replace"); };
const cancel = el("button", "btn ghost tiny", "Cancel"); cancel.onclick = close;
actions.append(merge, replace, cancel); box.append(actions);
content.append(box);
});
}
// A "+ Add ▾" split button with a small dropdown of [label, action] entries.
function addDropdown(entries) {
const wrap = el("div", "composer-add");
const btn = el("button", "btn ghost tiny", "+ Add ▾");
const panel = el("div", "composer-add-menu"); panel.hidden = true;
entries.forEach(([label, fn]) => {
const b = el("button", "composer-add-item", label);
b.onclick = () => { panel.hidden = true; fn(); };
panel.append(b);
});
btn.onclick = (e) => {
e.stopPropagation();
const opening = panel.hidden;
panel.hidden = !opening;
if (opening) {
const off = (ev) => { if (!wrap.contains(ev.target)) { panel.hidden = true; document.removeEventListener("mousedown", off, true); } };
setTimeout(() => document.addEventListener("mousedown", off, true), 0);
}
};
wrap.append(btn, panel);
return wrap;
}
// ── shared field helpers ──────────────────────────────────────────────────────
// Triggers: one-per-line OR comma-separated. Replacements: one-per-line ONLY (prose
// phrases often contain commas; comma-splitting tore one phrase into bogus variants).
const splitTriggers = (v) => String(v || "").split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
const splitReplacements = (v) => String(v || "").split(/\n+/).map((s) => s.trim()).filter(Boolean);
function labeled(label, ctrl) {
const l = el("label", "lib-field"); l.append(el("span", null, label)); l.append(ctrl); return l;
}
function checkRow(label, checked) {
const row = el("label", "chk"); const cb = el("input"); cb.type = "checkbox"; cb.checked = checked;
row.append(cb); row.append(el("span", null, label)); row._cb = cb; return row;
}
function selectFrom(opts, value) {
const sel = el("select", "lib-in");
opts.forEach((o) => { const op = el("option", null, o); op.value = o; if (o === value) op.selected = true; sel.append(op); });
return sel;
}
function filtered(arr, query, textOf) {
const s = (query || "").trim().toLowerCase();
return s ? arr.filter((x) => textOf(x).toLowerCase().includes(s)) : arr;
}
// Typing must never trigger a full render() — that replaces this very input and drops
// focus after each character. onChange rebuilds only the filtered list instead.
function searchRow(key, placeholder, onChange) {
const inp = el("input", "lib-search"); inp.type = "text"; inp.placeholder = placeholder; inp.value = q[key] || "";
inp.oninput = () => { q[key] = inp.value; (onChange || render)(); };
return inp;
}
// ── compose (global prompt) ─────────────────────────────────────────────────────
// The whole montage as one editable prompt. Edits debounce into the timeline (which
// re-splits into scenes); external timeline edits flow back unless we're mid-type.
function composeTab(st) {
const wrap = el("div", "bin compose-bin");
const pv = st.preview;
// No project open (welcome screen): the library tabs still work, but there is no
// global prompt to edit — dereferencing st.project here crashed the whole editor.
const live = (st.project && st.project.global_prompt)
|| (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || "";
// The draft only stands in for the scenes while its split is still on its way; after
// that the scenes are the truth, so a tab/project/cut-word change never shows old text.
const val = gpDraft != null && S.globalPromptApplyPending() ? 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", "Story"));
const addSc = el("button", "btn ghost tiny", "+ Add shortcut");
addSc.title = "Insert a shortcut trigger — browse by category";
addSc.onclick = () => openShortcutPicker((trig) => insertIntoCompose(trig));
titleRow.append(addSc);
wrap.append(titleRow);
const ta = el("textarea", "lib-in compose-ta"); ta.rows = 14; ta.value = val;
ta.placeholder = st.project
? "Every scene, one after another. Type a cut word to start a new scene."
: "Open a project to write its story.";
ta.disabled = !st.project;
ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); updateVarHint(); };
wrap.append(ta);
wrap.append(el("div", "insp-hint",
"Edits apply to the scenes as you type, and scene edits show here. The anchor is its own field. Shortcuts expand at generation time."));
timedHintEl = el("div", "insp-hint compose-var-hint");
wrap.append(timedHintEl);
composeTextarea = ta;
updateTimedHint();
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
if (st.project && window.ShortcutSuggest) titleRow.append(window.ShortcutSuggest.bulb(ta));
// Variables ($name) — collapsible, same toggle pattern as Settings ▸ Built-in pipeline.
wrap.append(composeVariables());
// Segmented detailing targets — only while the Chain Sampler feature is enabled
// (Engine ▸ Chain Sampler ▸ Experimental), so prompt-craft and "what gets detailed"
// live side by side without adding a permanently-visible field.
const detailBlock = composeDetailTargets(st);
if (detailBlock) wrap.append(detailBlock);
return wrap;
}
// ── segmented detailing targets (shown only when the feature is on) ─────────────
function composeDetailTargets(st) {
const p = st.project;
if (!p) return null;
const PC = window.PipelineCaps;
if (PC && PC.usesChainSampler && !PC.usesChainSampler(st)) return null;
const si = p.sampler_inputs || {};
if (!si.segmented_detailing) return null;
const box = el("div", "compose-detail");
const row = el("div", "compose-head");
row.append(el("div", "lib-form-title", "Segmented detailing"));
box.append(row);
const input = el("input", "lib-in");
input.type = "text";
input.placeholder = "hands, feet";
input.value = si.detail_targets != null ? String(si.detail_targets) : "hands";
// Quiet while typing (no repaint under the caret), commit on blur/Enter —
// same contract as the Engine Settings text knob, same store key, so the
// two fields can never disagree.
input.oninput = () => S.setSamplerInput("detail_targets", input.value);
input.onchange = () => S.setSamplerInputNow("detail_targets", input.value);
box.append(input);
box.append(el("div", "insp-hint",
"Comma-separated regions to detail (CLIPSeg text queries — plain words work best). Strength and the upsampler model live in Engine ▸ Chain Sampler ▸ Experimental."));
return box;
}
let composeTextarea = null;
let varsOpen = false; // Variables panel expanded?
let timedHintEl = null; // timed phrases `[walks left@2.0-3.5]` found in the prompt
let varHintEl = null; // undeclared / cycle hint line under the prompt
// ── templates bar (above the global prompt) ─────────────────────────────────────
const TPL_NONE = "__none__";
function composeTemplatesBar() {
const bar = el("div", "compose-templates");
const tpls = S.promptTemplates() || [];
// Which template the prompt came from. Without it the select forgot its own answer the
// instant it was given, so there was no state to leave — and so no way to leave it.
const active = S.get().project?.active_prompt_template || "";
const known = tpls.some((t) => t && t.name === active);
const sel = el("select", "lib-in compose-tpl-select");
const ph = new Option("Templates…", ""); ph.disabled = true; sel.append(ph);
sel.append(new Option("— None (clear all prompts) —", TPL_NONE));
tpls.forEach((t) => {
const preview = String(t.prompt || "").replace(/\s+/g, " ").slice(0, 48);
const o = new Option(`${t.name}${preview ? " — " + preview : ""}`, t.name);
if (t.name === active) o.selected = true;
sel.append(o);
});
if (!known) ph.selected = true;
sel.title = "Apply a saved set of scenes + its variables";
sel.onchange = () => {
const n = sel.value;
if (!n) return;
if (n === TPL_NONE) {
if (!confirm(
"Clear the Story and stop using the current template?\n\n"
+ "Empties the anchor and every scene's text. The postfix and "
+ "variables are separate settings and are left alone. Undo restores it."
)) { render(); return; }
S.clearGlobalPrompt();
render();
return;
}
S.applyPromptTemplate(n).then(render);
};
bar.append(sel);
// Saving under an existing name overwrites it, so pre-filling the applied template's
// name makes "update this template" the default gesture rather than a hidden trick.
const save = el("button", "btn ghost tiny", known ? "Update…" : "Save");
save.title = known
? `Overwrite "${active}" with the current prompt + variables, or save under a new name`
: "Save the current global prompt + variables as a template";
save.onclick = () => {
const name = (window.prompt("Template name:", known ? active : "") || "").trim();
if (!name) return;
S.savePromptTemplate(name).then(render);
};
bar.append(save);
// Rename and delete act on the applied template. They are the two operations the bar
// never offered: a template could only be replaced by saving over its own name, and
// nothing anywhere called the store's deletePromptTemplate.
if (known) {
const ren = el("button", "btn ghost tiny", "✎");
ren.title = `Rename "${active}"`;
ren.onclick = () => {
const next = (window.prompt("Rename template:", active) || "").trim();
if (!next || next === active) return;
if (!S.renamePromptTemplate(active, next)) {
alert(`A template called "${next}" already exists.`);
return;
}
render();
};
bar.append(ren);
const del = el("button", "btn ghost tiny danger", "×");
del.title = `Delete "${active}"`;
del.onclick = () => {
if (!confirm(`Delete the template "${active}"?\n\nThe prompt itself stays as it is.`)) return;
S.deletePromptTemplate(active);
render();
};
bar.append(del);
}
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.
// Grow a value textarea to fit its text (measured after a reset to "auto", or it can only
// ever get taller). Deferred once on mount because scrollHeight reads 0 before layout.
function autoGrowVar(ta) {
const fit = () => {
ta.style.height = "auto";
// scrollHeight covers content + padding; under border-box the borders must be added
// back or every line is clipped by ~2px and the textarea scrolls anyway.
const cs = getComputedStyle(ta);
const border = cs.boxSizing === "border-box"
? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)
: -(parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom));
ta.style.height = (ta.scrollHeight + (border || 0)) + "px";
};
fit();
if (!ta.dataset.grown) { ta.dataset.grown = "1"; setTimeout(fit, 0); }
}
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", "="));
// Values are usually long phrases, so this grows with its content — a one-line input
// makes editing "High quality, high fidelity realistic video, …" a horizontal-
// scrolling guessing game.
const vv = el("textarea", "lib-in compose-var-val");
vv.rows = 1;
vv.value = v.value; vv.placeholder = "value (may reference $other)";
vv.oninput = () => { v.value = vv.value; autoGrowVar(vv); commit(); updateVarHint(); };
row.append(vv);
autoGrowVar(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;
}
// Timed phrases: `[phrase@t0-t1]` / `[phrase:w@t0-t1]`, seconds from the scene start. Mirrors
// h3_token_weights._TIMED — the phrase is only allowed to shape the video inside its window.
const TIMED_RE = /(?<!\\)\[([^\[\]]*?)(?::\s*(-?\d+(?:\.\d+)?))?\s*@\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\]/g;
function updateTimedHint() {
if (!timedHintEl) return;
const txt = composeTextarea ? composeTextarea.value : "";
const found = [];
for (const m of txt.matchAll(TIMED_RE)) {
const t0 = parseFloat(m[3]), t1 = parseFloat(m[4]);
const phrase = m[1].trim();
if (!phrase) continue;
found.push({ phrase, t0, t1, w: m[2] ? parseFloat(m[2]) : 1, bad: !(t1 > t0) });
}
// (phrase:1.5) — a weight with no window. Mirrors h3_token_weights._WEIGHTED.
const weighted = [...txt.matchAll(/(?<!\\)\(([^():]*?):\s*(-?\d+(?:\.\d+)?)\s*\)|(?<!\\)\[([^\[\]@]*?):\s*(-?\d+(?:\.\d+)?)\s*\]/g)]
.map((m) => ({ phrase: (m[1] ?? m[3]).trim(), w: parseFloat(m[2] ?? m[4]) })).filter((f) => f.phrase);
const PC = window.PipelineCaps;
const h3 = !!(PC && PC.isH3 && PC.isH3(S.get()));
let msg = "";
let warn = false;
if (found.length || weighted.length) {
const bad = found.filter((f) => f.bad);
if (!h3) { msg = "⏱ Timed and weighted phrases are MiniMax H3 only — the words stay, the markup is dropped."; warn = true; }
else if (bad.length) { msg = `⚠ "${bad[0].phrase}" ends before it starts (${bad[0].t0}-${bad[0].t1}s) — window ignored.`; warn = true; }
else {
const parts = found.map((f) => `${f.phrase} ${f.t0}-${f.t1}s${f.w !== 1 ? ` ×${f.w}` : ""}`)
.concat(weighted.map((f) => `${f.phrase} ×${f.w}`));
msg = "⏱ " + parts.join(" · ")
+ (found.length ? " — a window only shapes the picture inside it; keep the subject outside the brackets." : "");
}
}
timedHintEl.textContent = msg;
timedHintEl.classList.toggle("compose-var-warn", warn);
timedHintEl.style.display = msg ? "" : "none";
}
// Live hint under the prompt: cycle warning takes priority, else list $vars used but not declared.
function updateVarHint() {
updateTimedHint();
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) {
const ta = composeTextarea;
if (!ta) return;
const v = ta.value;
let s = ta.selectionStart, e = ta.selectionEnd;
if (s == null) { s = e = v.length; }
// Pad with a space when butting up against existing words.
const before = v.slice(0, s), after = v.slice(e);
const lead = before && !/\s$/.test(before) ? " " : "";
const trail = after && !/^\s/.test(after) ? " " : "";
const ins = lead + text + trail;
ta.value = before + ins + after;
const caret = s + ins.length;
ta.focus(); ta.setSelectionRange(caret, caret);
gpDraft = ta.value;
S.scheduleGlobalPromptApply(ta.value);
}
// ── shortcut picker (browse by Category → [Sub-category →] Shortcut) ────────────
// Builds the grouped tree from the shortcut library + managed category list, then
// drills down. Direct (no sub-category) shortcuts sit beside the sub-category folders.
function buildShortcutTree() {
const lc = (s) => String(s || "").toLowerCase();
const cats = (S.get().shortcutCategories || []).map((c) => ({ name: c.name, subs: (c.sub_categories || []).slice() }));
const find = (name) => cats.find((c) => lc(c.name) === lc(name));
const tree = new Map(); // category name → { subs: Map<sub, [sc]>, direct: [sc] }
const ensure = (name) => {
if (!tree.has(name)) tree.set(name, { subs: new Map(), direct: [] });
return tree.get(name);
};
cats.forEach((c) => { const n = ensure(c.name); c.subs.forEach((s) => n.subs.set(s, [])); });
const UNCAT = "Uncategorized";
(S.get().shortcuts || []).forEach((sc) => {
if (sc.enabled === false) return;
const cat = (sc.category || "").trim();
if (!cat) { ensure(UNCAT).direct.push(sc); return; }
const node = ensure(cat);
const sub = (sc.sub_category || "").trim();
if (!sub) { node.direct.push(sc); return; }
// Match sub-category case-insensitively to its managed name, else create.
const known = find(cat) && find(cat).subs.find((s) => lc(s) === lc(sub));
const key = known || sub;
if (!node.subs.has(key)) node.subs.set(key, []);
node.subs.get(key).push(sc);
});
// Drop empty categories that have no shortcuts at all (managed-but-unused).
for (const [name, node] of [...tree]) {
const hasAny = node.direct.length || [...node.subs.values()].some((a) => a.length);
if (!hasAny) tree.delete(name);
}
return tree;
}
function openShortcutPicker(onPick) {
const tree = buildShortcutTree();
if (!tree.size) { alert("No shortcuts yet. Add some in the Shortcuts tab."); return; }
let path = []; // [] | [category] | [category, sub]
openModal("Add shortcut", (content, close) => {
const box = el("div", "sc-picker");
function pickShortcut(sc) {
const trig = (sc.triggers || [])[0] || sc.name;
if (trig) onPick(trig);
close();
}
function shortcutRow(sc) {
const r = el("button", "sc-pick-item");
r.append(el("span", "sc-pick-name", (sc.triggers || [])[0] || sc.name));
const rep = (sc.replacements || [])[0];
if (rep) r.append(el("span", "sc-pick-prompt", rep));
r.onclick = () => pickShortcut(sc);
return r;
}
function paint() {
clear(box);
const crumb = el("div", "sc-crumb");
const home = el("button", "sc-crumb-link", "Categories");
home.onclick = () => { path = []; paint(); };
crumb.append(home);
path.forEach((p, i) => {
crumb.append(el("span", "sc-crumb-sep", "›"));
const link = el("button", "sc-crumb-link", p);
link.onclick = () => { path = path.slice(0, i + 1); paint(); };
crumb.append(link);
});
box.append(crumb);
const list = el("div", "sc-pick-list");
if (path.length === 0) {
[...tree.keys()].forEach((name) => {
const node = tree.get(name);
const count = node.direct.length + [...node.subs.values()].reduce((n, a) => n + a.length, 0);
const b = el("button", "sc-pick-folder");
b.append(el("span", "sc-pick-name", name));
b.append(el("span", "sc-pick-count", String(count)));
b.onclick = () => { path = [name]; paint(); };
list.append(b);
});
} else if (path.length === 1) {
const node = tree.get(path[0]);
[...node.subs.entries()].forEach(([sub, arr]) => {
if (!arr.length) return;
const b = el("button", "sc-pick-folder");
b.append(el("span", "sc-pick-name", sub));
b.append(el("span", "sc-pick-count", String(arr.length)));
b.onclick = () => { path = [path[0], sub]; paint(); };
list.append(b);
});
node.direct.forEach((sc) => list.append(shortcutRow(sc)));
} else {
const node = tree.get(path[0]);
(node.subs.get(path[1]) || []).forEach((sc) => list.append(shortcutRow(sc)));
}
box.append(list);
}
paint();
content.append(box);
});
}
// ── shortcuts ─────────────────────────────────────────────────────────────────
function openShortcutEditor(item) {
const isNew = !item.name;
openModal(isNew ? "New shortcut" : `Edit “${item.name}”`, (content, close) => {
const box = el("div", "lib-form lib-form-modal");
const name = el("input", "lib-in"); name.placeholder = "Name"; name.value = item.name || "";
const trig = el("textarea", "lib-in"); trig.rows = 3; trig.placeholder = "Triggers — one per line or comma-separated"; trig.value = (item.triggers || []).join("\n");
const reps = el("textarea", "lib-in"); reps.rows = 4; reps.placeholder = "Replacement(s) — one per line; multiple = random pick"; reps.value = (item.replacements || []).join("\n");
const en = checkRow("Enabled", item.enabled !== false);
box.append(labeled("Name", name), labeled("Triggers", trig), labeled("Replacements", reps), en);
// Grouping for the Composer: managed category + nested sub-category, chosen from
// dropdowns. Each dropdown offers the existing entries plus "+ Add new…".
const grouping = { category: item.category || "", sub_category: item.sub_category || "" };
const catRow = el("div", "fields-row");
box.append(catRow);
const ADD = "add";
function paintGrouping() {
clear(catRow);
const cats = S.get().shortcutCategories || [];
const lc = (s) => String(s || "").toLowerCase();
const catNames = cats.map((c) => c.name);
if (grouping.category && !catNames.some((n) => lc(n) === lc(grouping.category))) catNames.push(grouping.category);
const catSel = el("select", "lib-in");
catSel.append(optionEl("", "— none —", !grouping.category));
catNames.forEach((n) => catSel.append(optionEl(n, n, lc(n) === lc(grouping.category))));
catSel.append(optionEl(ADD, "+ Add new category…"));
catSel.onchange = () => {
if (catSel.value === ADD) { catSel.value = grouping.category; promptNewCategory((nm) => { grouping.category = nm; grouping.sub_category = ""; paintGrouping(); }); return; }
grouping.category = catSel.value; grouping.sub_category = ""; paintGrouping();
};
catRow.append(labeled("Category", catSel));
const entry = cats.find((c) => lc(c.name) === lc(grouping.category));
const subs = entry ? entry.sub_categories.slice() : [];
if (grouping.sub_category && !subs.some((s) => lc(s) === lc(grouping.sub_category))) subs.push(grouping.sub_category);
const subSel = el("select", "lib-in");
subSel.append(optionEl("", "— none —", !grouping.sub_category));
subs.forEach((s) => subSel.append(optionEl(s, s, lc(s) === lc(grouping.sub_category))));
subSel.append(optionEl(ADD, "+ Add new sub-category…"));
subSel.disabled = !grouping.category;
subSel.onchange = () => {
if (subSel.value === ADD) { subSel.value = grouping.sub_category; promptNewSubCategory(grouping.category, (p, s) => { grouping.category = p; grouping.sub_category = s; paintGrouping(); }); return; }
grouping.sub_category = subSel.value;
};
catRow.append(labeled("Sub-category", subSel));
}
paintGrouping();
const actions = el("div", "lib-form-actions");
const save = el("button", "btn primary tiny", "Save");
save.onclick = async () => {
const triggers = splitTriggers(trig.value);
if (!triggers.length) { alert("At least one trigger is required."); return; }
// A refused save (a name clash) keeps the editor open: closing it would throw away
// what was typed.
if (!await S.saveShortcut({
name: name.value.trim() || triggers[0], triggers,
replacements: splitReplacements(reps.value), enabled: en._cb.checked,
category: grouping.category, sub_category: grouping.sub_category,
original_name: item.name || undefined,
})) return;
close(); render();
};
const cancel = el("button", "btn ghost tiny", "Cancel"); cancel.onclick = close;
actions.append(save, cancel); box.append(actions);
content.append(box);
});
}
function shortcutsTab(st) {
const wrap = el("div", "bin");
wrap.append(searchRow("Shortcuts", "Filter shortcuts…", () => fillShortcutList()));
const toolbar = el("div", "bin-toolbar");
const addBtn = addDropdown([
["Shortcut", () => openShortcutEditor({})],
["Category", () => promptNewCategory()],
["Sub-category", () => promptNewSubCategory("")],
]);
const expBtn = el("a", "btn ghost tiny", "↑ Export");
expBtn.href = API.exportShortcutsUrl(); expBtn.download = "funpack_shortcuts.json"; expBtn.title = "Download shortcuts as JSON";
const impFile = el("input"); impFile.type = "file"; impFile.accept = ".json"; impFile.style.display = "none";
impFile.onchange = () => {
const file = impFile.files[0]; impFile.value = "";
if (!file) return;
chooseImportMode("shortcuts", async (mode) => {
const n = await S.importShortcuts(file, mode);
if (n != null) alert(`Imported ${n} shortcut(s)${mode === "replace" ? " (replaced existing)" : ""}.`);
});
};
const impBtn = el("button", "btn ghost tiny", "↓ Import"); impBtn.title = "Import shortcuts from JSON";
impBtn.onclick = () => impFile.click();
const delAll = el("button", "btn danger tiny", "✕ Delete all");
delAll.title = "Delete every shortcut and category";
delAll.onclick = async () => {
if (!(st.shortcuts || []).length) { alert("No shortcuts to delete."); return; }
if (confirm("Delete ALL shortcuts and categories? This cannot be undone.")) await S.clearShortcuts();
};
toolbar.append(addBtn, expBtn, impBtn, delAll, impFile); wrap.append(toolbar);
const list = el("div", "lib-list");
fillShortcutList = () => {
clear(list);
const items = filtered(st.shortcuts || [], q.Shortcuts, (s) => `${s.name} ${s.category || ""} ${s.sub_category || ""} ${(s.triggers || []).join(" ")} ${(s.replacements || []).join(" ")}`);
items.forEach((s) => {
const trig = (s.triggers || [])[0] || s.name;
const row = el("div", "lib-row");
const main = el("div", "lib-main");
const nameLine = el("div", "lib-name", s.name + (s.enabled === false ? " (off)" : ""));
const catLabel = [s.category, s.sub_category].filter(Boolean).join(" · ");
if (catLabel) nameLine.append(el("span", "lib-cat-tag", catLabel));
main.append(nameLine);
const rep = (s.replacements || []).join(" / ");
if (rep) main.append(el("div", "lib-sub", "→ " + rep));
row.append(main);
const ins = el("button", "btn ghost tiny", "insert"); ins.title = "Append to selected scene's prompt";
ins.onclick = () => { if (!S.insertShortcutIntoSelection(trig)) alert("Select a scene first."); };
const edit = el("button", "ic-btn", "✎"); edit.title = "Edit shortcut"; edit.onclick = () => openShortcutEditor(s);
const del = el("button", "ic-btn danger", "✕"); del.title = "Delete shortcut";
del.onclick = () => { if (confirm(`Delete shortcut "${s.name}"?`)) S.deleteShortcut(s.name); };
row.append(ins, edit, del); list.append(row);
});
if (!items.length) list.append(el("div", "pj-meta", (st.shortcuts || []).length ? "No match." : "No shortcuts yet."));
};
fillShortcutList();
wrap.append(list);
return wrap;
}
// Rebuilds just the Shortcuts/Splits list rows for the current filter text — assigned by
// the tab builders so searchRow can refresh results without a focus-dropping full render.
let fillShortcutList = () => {};
// ── cut words (what splits the Story into scenes) ───────────────────────────────
// core/story.py owns the list. The FIRST word is the one the app writes when it lays
// scenes out as a story; every word cuts when typed. Only typed words cut -- a shortcut
// that expands to one never moves a boundary.
function splitMarkersTab() {
const wrap = el("div", "bin");
wrap.append(el("div", "lib-form-title", "Cut words"));
wrap.append(el("div", "insp-hint",
"Type one of these words in the Story to start a new scene. The first is the one written between scenes for you. A scene whose own text contains a cut word is split there the next time the Story is edited, so pick words you will not otherwise write."));
const list = el("div", "lib-list");
wrap.append(list);
let words = null;
const save = async (next) => {
try { words = (await API.saveStoryMarkers(next)).markers; await S.loadStoryMarkers(); S.syncGlobalPromptFromTimeline(); }
catch (e) { alert(e.message); }
paint();
};
const paint = () => {
clear(list);
(words || []).forEach((w, i) => {
const row = el("div", "lib-row");
const main = el("div", "lib-main");
main.append(el("div", "lib-name", w), ...(i ? [] : [el("div", "lib-sub", "written between scenes")]));
row.append(main);
if (i) { const up = el("button", "ic-btn", "↑"); up.title = "Make this the written word"; up.onclick = () => save([w, ...words.filter((x) => x !== w)]); row.append(up); }
const del = el("button", "ic-btn danger", "✕"); del.title = "Remove";
del.onclick = () => save(words.filter((x) => x !== w));
row.append(del); list.append(row);
});
};
const add = el("input", "lib-in"); add.placeholder = "New cut word, then Enter";
add.onkeydown = (e) => {
if (e.key !== "Enter" || !add.value.trim()) return;
const v = add.value.trim();
const re = new RegExp("(?<!\\w)" + v.split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") + "(?!\\w)", "i");
const hit = ((S.get().project || {}).scenes || []).find((sc) => re.test(sc.text || ""));
if (hit) { alert(`"${v}" already appears in a scene's text, which would split that scene. Pick another word.`); return; }
add.value = ""; save([...words, v]);
};
wrap.append(add);
API.storyMarkers().then((r) => { words = r.markers; paint(); }).catch((e) => list.append(el("div", "pj-meta", e.message)));
return wrap;
}
// ── window + tabs ────────────────────────────────────────────────────────────────
const TABS = ["Story", "Shortcuts", "Cuts", "Enhance", "Chat"];
function render() {
if (!win) return;
const st = S.get();
composeTextarea = null;
const root = clear(win.body);
const shell = el("div", "composer-shell");
const tabs = el("div", "bin-tabs composer-tabs");
TABS.forEach((name) => {
const b = el("button", "bin-tab" + (tab === name ? " active" : ""), name);
b.title = name === "Cuts" ? "The words that cut the story into scenes"
: name === "Story" ? "Every scene as one text"
: name === "Enhance" ? "A language model rewrites the prompt before it is encoded"
: name === "Chat" ? "Comment on the last rewrite"
: name;
b.onclick = () => {
if (tab === name) return;
tab = name; closeModal();
render();
};
tabs.append(b);
});
shell.append(tabs);
const scroll = el("div", "composer-scroll");
scroll.append(
tab === "Story" ? composeTab(st)
: tab === "Shortcuts" ? shortcutsTab(st)
: tab === "Cuts" ? splitMarkersTab()
: paneFromEnhancer(),
);
shell.append(scroll);
root.append(shell);
}
// Enhance / Chat live in enhancer_ui.js; they repaint themselves through render().
function paneFromEnhancer() {
const ui = window.EnhancerUI;
if (!ui) return el("div", "insp-hint", "The enhancer panel did not load.");
const pane = tab === "Chat" ? ui.chatTab : ui.enhanceTab;
// `ready` fetches the defaults and the last run once, then repaints.
if (!paneFromEnhancer.ready) { paneFromEnhancer.ready = true; ui.ready().then(render); }
return pane(render);
}
function ensureWin() {
if (win) return win;
win = window.FloatingWindow.create({
id: "composer", title: "Composer", subtitle: "prompt craft",
width: 520, height: 560, minWidth: 380, minHeight: 320,
onOpen: render,
onClose: () => { closeModal(); _syncButton(); },
});
return win;
}
function toggle() { ensureWin().toggle(); _syncButton(); }
function isOpen() { return !!(win && win.isOpen()); }
let _btn = null;
function registerButton(btn) { _btn = btn; btn.onclick = toggle; _syncButton(); }
function _syncButton() { if (_btn) _btn.classList.toggle("on", isOpen()); }
// Re-render on relevant store changes (shortcuts / splits / project switch).
// ── panes hosted somewhere other than the Composer window ──────────────────
// The setup wizard shows Shortcuts and Splits as full screens of its own, so the flow
// stays one continuous surface instead of spawning a floating Composer over it. Same
// builders, same editors, same store — only the container differs.
const PANE_BUILDERS = { shortcuts: shortcutsTab, splits: splitMarkersTab };
const _panes = new Set(); // { kind, host }
function mountPane(kind, host) {
const build = PANE_BUILDERS[kind];
if (!build) return () => {};
const entry = { kind, host };
_panes.add(entry);
paintPane(entry);
return () => { _panes.delete(entry); closeModal(); };
}
function paintPane(entry) {
clear(entry.host);
entry.host.append(PANE_BUILDERS[entry.kind](S.get()));
}
let lastFp = null;
S.subscribe((st) => {
if (!isOpen() && !_panes.size) return;
const fp = JSON.stringify({
s: st.shortcuts?.length, t: st.transitions?.length, pid: st.project?.id,
c: (st.shortcutCategories || []).reduce((n, x) => n + 1 + (x.sub_categories?.length || 0), 0),
// Segmented detailing block visibility: toggling it in Engine Settings must
// repaint an open Composer or the targets field appears/disappears only on the
// next unrelated repaint ("store right, UI stale"). The targets TEXT is not
// fingerprinted — typing goes through quiet patches and the field itself is
// the source of those edits.
sd: !!(st.project?.sampler_inputs?.segmented_detailing),
});
if (fp === lastFp) return;
lastFp = fp;
if (isOpen()) render();
_panes.forEach(paintPane);
});
// Keep the Compose textarea in sync with timeline-driven global-prompt changes,
// but never clobber what the user is actively typing.
window.addEventListener("funpack-global-prompt-updated", (e) => {
if (tab !== "Story" || !composeTextarea) { if (!(S.globalPromptApplyPending && S.globalPromptApplyPending())) gpDraft = null; return; }
if (document.activeElement === composeTextarea) return;
// Never rewind the draft while the user's newest text is still debouncing/mid-parse —
// the event would carry the previous apply's text and eat their last keystrokes.
if (S.globalPromptApplyPending && S.globalPromptApplyPending()) return;
gpDraft = null;
composeTextarea.value = (e.detail && e.detail.text) || "";
});
window.Composer = { toggle, isOpen, registerButton, mountPane };
// Self-wire the header button (present in index.html before this script runs).
const headerBtn = document.getElementById("composer-btn");
if (headerBtn) registerButton(headerBtn);
})();