Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
918 lines
45 KiB
JavaScript
918 lines
45 KiB
JavaScript
// 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 = " |