diff --git a/movie_editor/frontend/autocomplete.js b/movie_editor/frontend/autocomplete.js new file mode 100644 index 00000000..6988e81b --- /dev/null +++ b/movie_editor/frontend/autocomplete.js @@ -0,0 +1,146 @@ +// Shortcut autocomplete: as the user types in a prompt field, suggest matching +// shortcut triggers. Toggled by the "Prompt autocomplete" editor setting. +// Reusable: ShortcutAutocomplete.attach(textarea) — safe to call once per field. +(function () { + const { el } = window.dom; + const S = window.Store; + + const DELIMS = /[,\n;]/; // token boundaries — a trigger lives between delimiters + const MIN_QUERY = 2; + const MAX_ITEMS = 8; + + function enabled() { return !!S.getEditorSetting("autocomplete"); } + + // Every (trigger, shortcut) pair — a shortcut with multiple triggers is searchable by each. + function triggerIndex() { + const out = []; + (S.get().shortcuts || []).forEach((s) => { + if (s.enabled === false) return; + (s.triggers || []).forEach((t) => { + const trig = String(t || "").trim(); + if (trig) out.push({ trigger: trig, sc: s }); + }); + }); + return out; + } + + function matches(query) { + const q = query.toLowerCase(); + const scored = []; + for (const e of triggerIndex()) { + const tl = e.trigger.toLowerCase(); + const at = tl.indexOf(q); + if (at < 0) continue; + scored.push({ ...e, rank: at === 0 ? 0 : 1, pos: at }); + } + scored.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.trigger.length - b.trigger.length); + // De-dupe identical triggers (same phrase shared by multiple shortcuts). + const seen = new Set(); + const uniq = []; + for (const e of scored) { + const k = e.trigger.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); uniq.push(e); + if (uniq.length >= MAX_ITEMS) break; + } + return uniq; + } + + // The token currently under the caret: text since the last delimiter, left-trimmed. + function currentQuery(ta) { + const caret = ta.selectionStart; + if (caret == null || caret !== ta.selectionEnd) return null; + const text = ta.value.slice(0, caret); + let start = caret; + while (start > 0 && !DELIMS.test(text[start - 1])) start--; + while (start < caret && /\s/.test(text[start])) start++; // skip leading whitespace + const query = text.slice(start, caret); + if (query.trim().length < MIN_QUERY) return null; + return { start, end: caret, query }; + } + + function attach(ta) { + if (!ta || ta._acAttached) return; + ta._acAttached = true; + + let menu = null; + let items = []; + let active = -1; + let span = null; + + function close() { + if (menu) { menu.remove(); menu = null; } + items = []; active = -1; span = null; + } + + function accept(i) { + if (!span || !items[i]) return; + const trig = items[i].trigger; + const v = ta.value; + ta.value = v.slice(0, span.start) + trig + v.slice(span.end); + const caret = span.start + trig.length; + ta.setSelectionRange(caret, caret); + close(); + ta.dispatchEvent(new Event("input", { bubbles: true })); + ta.focus(); + } + + function paintActive() { + if (!menu) return; + [...menu.children].forEach((c, i) => c.classList.toggle("active", i === active)); + const cur = menu.children[active]; + if (cur) cur.scrollIntoView({ block: "nearest" }); + } + + function open(found, sp) { + close(); + span = sp; items = found; active = 0; + menu = el("div", "ac-menu"); + found.forEach((e, i) => { + const row = el("div", "ac-item" + (i === 0 ? " active" : "")); + row.append(el("span", "ac-trigger", e.trigger)); + const rep = (e.sc.replacements || [])[0]; + if (rep) row.append(el("span", "ac-prompt", rep)); + const tag = [e.sc.category, e.sc.sub_category].filter(Boolean).join(" · "); + if (tag) row.append(el("span", "ac-cat", tag)); + row.addEventListener("mousedown", (ev) => { ev.preventDefault(); accept(i); }); + row.addEventListener("mouseenter", () => { active = i; paintActive(); }); + menu.append(row); + }); + document.body.append(menu); + position(); + } + + function position() { + if (!menu) return; + const r = ta.getBoundingClientRect(); + menu.style.left = r.left + "px"; + menu.style.top = (r.bottom + 2) + "px"; + menu.style.minWidth = Math.min(r.width, 420) + "px"; + menu.style.maxWidth = Math.max(r.width, 320) + "px"; + } + + function refresh() { + if (!enabled()) { close(); return; } + const sp = currentQuery(ta); + if (!sp) { close(); return; } + const found = matches(sp.query); + if (!found.length) { close(); return; } + open(found, sp); + } + + ta.addEventListener("input", refresh); + ta.addEventListener("click", refresh); + ta.addEventListener("keydown", (e) => { + if (!menu) return; + if (e.key === "ArrowDown") { e.preventDefault(); active = (active + 1) % items.length; paintActive(); } + else if (e.key === "ArrowUp") { e.preventDefault(); active = (active - 1 + items.length) % items.length; paintActive(); } + else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); accept(active); } + else if (e.key === "Escape") { e.preventDefault(); close(); } + }); + ta.addEventListener("blur", () => setTimeout(close, 120)); + window.addEventListener("scroll", () => { if (menu) position(); }, true); + } + + window.ShortcutAutocomplete = { attach }; +})(); diff --git a/movie_editor/frontend/composer.js b/movie_editor/frontend/composer.js index d7c53205..09a24433 100644 --- a/movie_editor/frontend/composer.js +++ b/movie_editor/frontend/composer.js @@ -168,7 +168,14 @@ || (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || ""; const val = gpDraft != null ? gpDraft : live; - wrap.append(el("div", "lib-form-title", "Global prompt")); + const titleRow = el("div", "compose-head"); + titleRow.append(el("div", "lib-form-title", "Global prompt")); + 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 = "Anchor, scene texts, and split markers — one combined montage prompt for generation."; ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); }; @@ -176,10 +183,132 @@ wrap.append(el("div", "insp-hint", "Edits apply automatically and stay in sync with the timeline's per-scene prompts. Shortcuts expand at generation time.")); composeTextarea = ta; + if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta); return wrap; } let composeTextarea = null; + // 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, 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; diff --git a/movie_editor/frontend/editor_settings.js b/movie_editor/frontend/editor_settings.js new file mode 100644 index 00000000..5dbd739d --- /dev/null +++ b/movie_editor/frontend/editor_settings.js @@ -0,0 +1,48 @@ +// Editor Settings modal: per-browser editor preferences (autocomplete, anchor). +// Opened from the Settings menu. Distinct from Engine settings (which configures +// Studio / Chain Sampler behavior on the project). +(function () { + const { el } = window.dom; + const S = window.Store; + + let overlay = null; + function close() { if (overlay) { overlay.remove(); overlay = null; } } + + function toggleRow(label, hint, key) { + const row = el("div", "es-row"); + const lbl = el("label", "chk es-toggle"); + const cb = el("input"); cb.type = "checkbox"; cb.checked = !!S.getEditorSetting(key); + cb.onchange = () => S.setEditorSetting(key, cb.checked); + lbl.append(cb, el("span", null, label)); + row.append(lbl); + if (hint) row.append(el("div", "es-hint", hint)); + return row; + } + + function open() { + close(); + overlay = el("div", "modal-overlay"); + const box = el("div", "modal"); + const head = el("div", "modal-head"); + head.append(el("div", "modal-title", "Editor settings")); + const hr = el("div", "modal-head-right"); + const x = el("button", "btn ghost tiny", "✕"); x.onclick = close; + hr.append(x); head.append(hr); box.append(head); + + const content = el("div", "modal-content es-content"); + content.append(toggleRow( + "Prompt autocomplete", + "Suggest matching shortcuts while you type in the global prompt and scene prompts. Shows the trigger, its prompt, and category.", + "autocomplete")); + content.append(toggleRow( + "Use anchor", + "Text before the first split trigger is a shared anchor prepended to every scene. Turn off to make that leading text Scene 1 instead.", + "anchorEnabled")); + + box.append(content); overlay.append(box); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + document.body.append(overlay); + } + + window.EditorSettingsModal = { open, close }; +})(); diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index e179814d..72980903 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -123,6 +123,8 @@ + + diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index a5bbc9a9..cf7a4ea3 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -245,6 +245,7 @@ const ta = el("textarea"); ta.rows = 4; ta.value = root.text || ""; ta.placeholder = "Describe this scene…"; ta.dataset.k = "sc-text"; ta.oninput = () => S.patchSceneQuiet(scene.id, { text: ta.value }); body.append(field("Prompt", ta)); + if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta); renderSourceField(st, root, scene); if (PC()?.usesChainSampler(st)) { if ((root.source?.type) === "generated_frame") renderGeneratedFrameSource(st, root); @@ -493,11 +494,16 @@ } if (pv.parse_error) { const w = el("div", "pv-warn"); w.append(el("span", null, "▲")); w.append(el("span", null, "ComfyUI offline — preview paused")); box.append(w); } const parsed = pv.parsed || {}; - if (parsed.anchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); } + // Anchor disabled (editor setting): the backend still reports a leading anchor, but + // generation folds it into Scene 1 — show it that way so the preview matches. + const foldAnchor = parsed.anchor && !S.getEditorSetting("anchorEnabled"); + if (parsed.anchor && !foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); } + if (foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge", "S1")); l.append(el("span", null, parsed.anchor)); box.append(l); } + const sBase = foldAnchor ? 2 : 1; const rkeys = pv.scene_refinement_keys || []; (parsed.scenes || []).forEach((s, i) => { const l = el("div", "pv-line"); - l.append(el("span", "pv-badge", "S" + (i + 1))); + l.append(el("span", "pv-badge", "S" + (i + sBase))); // Refinement key(s) this scene will steer with (matches generation exactly) const rk = rkeys[i]; if (rk && rk.keys && rk.keys.length) { diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js index d626f88f..2856938c 100644 --- a/movie_editor/frontend/menubar.js +++ b/movie_editor/frontend/menubar.js @@ -240,6 +240,7 @@ } }, ], Settings: [ + { label: "Editor settings…", action: () => window.EditorSettingsModal.open() }, { label: "Engine settings…", disabled: !hasProject() || (!studioOn && !chainOn), action: () => window.EngineSettingsModal.open() }, { sep: true }, diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index d07f695f..3f4b86fe 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -35,6 +35,33 @@ ratingLabels: [], // FunPack Studio V2 rating options }; + // ── editor settings ───────────────────────────────────────────────────────── + // Per-browser editor preferences (not part of the project file). Govern how the + // prompt is parsed/edited, not what is generated from already-distributed scenes. + const EDITOR_SETTINGS_KEY = "funpack_editor_settings"; + const EDITOR_SETTINGS_DEFAULTS = { autocomplete: true, anchorEnabled: true }; + function _loadEditorSettings() { + try { + const raw = JSON.parse(localStorage.getItem(EDITOR_SETTINGS_KEY) || "{}"); + return { ...EDITOR_SETTINGS_DEFAULTS, ...(raw && typeof raw === "object" ? raw : {}) }; + } catch (_) { return { ...EDITOR_SETTINGS_DEFAULTS }; } + } + let _editorSettings = _loadEditorSettings(); + function getEditorSettings() { return { ..._editorSettings }; } + function getEditorSetting(key) { return _editorSettings[key]; } + function setEditorSetting(key, val) { + if (_editorSettings[key] === val) return; + _editorSettings = { ..._editorSettings, [key]: val }; + try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {} + notify(); + // Anchor toggle changes how the global prompt maps onto the timeline — re-split now. + if (key === "anchorEnabled" && state.project) { + const cur = (state.project.global_prompt + || state.preview?.display_prompt || state.preview?.combined_prompt || "").trim(); + if (cur) { _pinnedGlobalPrompt = cur; applyGlobalPromptQuiet(cur).catch(() => {}); } + } + } + const listeners = new Set(); const SAVE_DEBOUNCE_MS = 5000; // discrete edits (dropdowns, toggles) const SAVE_SILENT_DEBOUNCE_MS = 20000; // typing in fields — flushed on blur / generate @@ -446,6 +473,12 @@ if (!hasSceneText && anchor) { return { anchor: "", scenes: [{ text: anchor }], transitions: [] }; } + // Anchor disabled (editor setting): the leading pre-first-split text is Scene 1, + // not a shared anchor. Fold it in as the first scene; the split trigger that began + // the old Scene 1 becomes the seam between new Scene 1 and Scene 2 (inferred from text). + if (!_editorSettings.anchorEnabled && anchor) { + return { anchor: "", scenes: [{ text: anchor }, ...scenes], transitions }; + } return { anchor: v.anchor || "", scenes, transitions }; } @@ -3783,6 +3816,7 @@ setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow, loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset, loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, addCategory, + getEditorSettings, getEditorSetting, setEditorSetting, loadCharacters, saveCharacter, deleteCharacter, loadTransitions, saveTransition, deleteTransition, importTransitions, loadNleLibrary, applyNleEffect, applyNleVideoTransition, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 5400aaa9..939851bd 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1531,3 +1531,37 @@ body.tour-mode { overflow: hidden; } font-family: var(--font-mono); font-size: 11.5px; padding: 6px 9px; border-radius: 5px; } .composer-add-item:hover { background: var(--ink-3); color: var(--text); } +/* Compose-tab header row (title + Add shortcut) */ +.compose-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } + +/* Shortcut autocomplete dropdown (anchored under the prompt field) */ +.ac-menu { position: fixed; z-index: 9000; max-height: 240px; overflow-y: auto; + background: #16140f; border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); padding: 4px; } +.ac-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 9px; border-radius: 5px; cursor: pointer; } +.ac-item.active { background: var(--amber-soft); } +.ac-trigger { color: var(--amber-2); font-family: var(--font-mono); font-size: 12px; flex-shrink: 0; } +.ac-prompt { color: var(--muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.ac-cat { color: var(--teal); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; flex-shrink: 0; } + +/* Editor settings modal */ +.es-content { display: flex; flex-direction: column; gap: 18px; } +.es-row { display: flex; flex-direction: column; gap: 5px; } +.es-toggle { font-size: 13px; } +.es-hint { color: var(--muted); font-size: 11.5px; line-height: 1.45; padding-left: 24px; } + +/* Add-shortcut picker (Category → [Sub-category →] Shortcut) */ +.sc-picker { display: flex; flex-direction: column; gap: 10px; } +.sc-crumb { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-family: var(--font-mono); font-size: 11px; } +.sc-crumb-link { background: transparent; border: 0; color: var(--teal); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-family: inherit; font-size: inherit; } +.sc-crumb-link:hover { background: var(--ink-3); color: var(--amber-2); } +.sc-crumb-sep { color: var(--faint); } +.sc-pick-list { display: flex; flex-direction: column; gap: 4px; max-height: 52vh; overflow-y: auto; } +.sc-pick-folder, .sc-pick-item { display: flex; align-items: baseline; gap: 10px; text-align: left; + background: var(--ink-2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); + cursor: pointer; padding: 9px 12px; font-family: inherit; font-size: 12.5px; } +.sc-pick-folder:hover, .sc-pick-item:hover { border-color: var(--amber); background: var(--ink-3); } +.sc-pick-folder .sc-pick-name::before { content: "▸ "; color: var(--amber); } +.sc-pick-name { color: var(--text); flex-shrink: 0; } +.sc-pick-prompt { color: var(--muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.sc-pick-count { margin-left: auto; color: var(--faint); font-family: var(--font-mono); font-size: 10.5px; flex-shrink: 0; } +