diff --git a/movie_editor/frontend/composer.js b/movie_editor/frontend/composer.js
index d6d52d85..189f8ef0 100644
--- a/movie_editor/frontend/composer.js
+++ b/movie_editor/frontend/composer.js
@@ -213,6 +213,7 @@
"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);
+ 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());
diff --git a/movie_editor/frontend/editor_settings.js b/movie_editor/frontend/editor_settings.js
index 3074aef7..b77102a8 100644
--- a/movie_editor/frontend/editor_settings.js
+++ b/movie_editor/frontend/editor_settings.js
@@ -158,6 +158,10 @@
"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(
+ "Shortcut ideas",
+ "A π‘ next to prompt fields lights up when shortcut categories in your library (camera moves, sub-actionsβ¦) aren't used in the prompt. Click it for insertable ideas β it never pops up on its own.",
+ "suggestions"));
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.",
@@ -169,7 +173,7 @@
window.SettingsWindow.register({
id: "editor", group: "", title: "Editor",
subtitle: "Per-browser preferences β they apply to this browser, not the project.",
- keywords: "autocomplete anchor prompt shortcuts i2v bypass guide preferences",
+ keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences",
iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)",
icon: ' ',
mount,
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 47061c3b..5e6f27cc 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -130,6 +130,7 @@
+
diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js
index aab48a90..f0711a0f 100644
--- a/movie_editor/frontend/inspector.js
+++ b/movie_editor/frontend/inspector.js
@@ -288,7 +288,12 @@
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));
+ // Ideas bulb floats OUTSIDE the label β a button inside a becomes its
+ // implicit control (double-fired clicks, stolen focus target).
+ const promptWrap = el("div", "sugg-fieldwrap");
+ promptWrap.append(field("Prompt", ta));
+ if (window.ShortcutSuggest) promptWrap.append(window.ShortcutSuggest.bulb(ta));
+ body.append(promptWrap);
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
renderSourceField(st, root, scene);
if (PC()?.usesChainSampler(st)) {
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index 69901e6b..39d7ff77 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -39,7 +39,7 @@
// 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,
+ autocomplete: true, suggestions: true, anchorEnabled: true,
// "Anchor as guide" i2v bypass: declare a custom i2v node so it can be forced
// to a state (value-only) on anchor_guide runs. anchorGuideBypass = {node, input, value}.
anchorGuideHasI2v: false, anchorGuideBypass: null,
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index a8642aa8..6dcc3da5 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -1561,6 +1561,25 @@ body.tour-mode { overflow: hidden; }
.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; }
+/* Shortcut ideas bulb + popover (quiet nudge β lights up when categories go unused) */
+.sugg-bulb { padding: 0 4px; font-size: 12px; line-height: 1; opacity: .35; filter: grayscale(1); transition: opacity .3s, filter .3s; }
+.sugg-bulb.on { opacity: 1; filter: none; text-shadow: 0 0 7px rgba(255, 196, 87, .55); }
+.sugg-fieldwrap { position: relative; }
+.sugg-fieldwrap .sugg-bulb { position: absolute; top: -3px; right: 0; }
+.sugg-pop { position: fixed; z-index: 99999; width: 330px; max-height: 280px; overflow-y: auto;
+ background: #16140f; border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); padding: 6px; }
+.sugg-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 4px 6px; }
+.sugg-title { color: var(--muted); font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; }
+.sugg-list { display: flex; flex-direction: column; }
+.sugg-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 9px; border-radius: 5px; cursor: pointer; }
+.sugg-item:hover { background: var(--amber-soft); }
+.sugg-empty { color: var(--muted); font-size: 11.5px; padding: 6px 9px; }
+.sugg-cat-head { display: flex; align-items: center; gap: 7px; padding: 7px 4px 2px; }
+.sugg-cat-head:first-child { padding-top: 2px; }
+.sugg-cat-name { color: var(--teal); font-family: var(--font-mono); font-size: 10px; letter-spacing: .6px; text-transform: uppercase; }
+.sugg-cat-count { color: var(--muted); font-size: 9.5px; font-family: var(--font-mono); background: var(--ink-3); border-radius: 8px; padding: 0 6px; }
+.sugg-more { margin-left: auto; font-size: 10px; padding: 0 4px; color: var(--muted); }
+
/* Editor settings modal */
.es-content { display: flex; flex-direction: column; gap: 18px; }
.es-row { display: flex; flex-direction: column; gap: 5px; }
diff --git a/movie_editor/frontend/suggestions.js b/movie_editor/frontend/suggestions.js
new file mode 100644
index 00000000..3d651893
--- /dev/null
+++ b/movie_editor/frontend/suggestions.js
@@ -0,0 +1,223 @@
+// Shortcut suggestions ("Ideas" bulb): a quiet nudge next to prompt fields that lights
+// up when whole shortcut categories (camera moves, sub-actions, β¦) exist in the library
+// but are absent from the current prompt β easy to forget, and the video comes out plain.
+// Never opens on its own: click the bulb for a handful of insertable ideas, β³ rerolls.
+// Toggled by the "Shortcut ideas" editor setting. Reusable: ShortcutSuggest.bulb(textarea).
+(function () {
+ const { el } = window.dom;
+ const S = window.Store;
+
+ const MAX_CHIPS = 3; // random samples shown per category (before "+N more")
+
+ function enabled() { return !!S.getEditorSetting("suggestions"); }
+
+ function libraryShortcuts() {
+ return (S.get().shortcuts || []).filter((s) => s.enabled !== false && (s.triggers || []).length);
+ }
+
+ // Does `text` contain `trigger` as a whole token (word boundaries on both sides)?
+ // Triggers may contain spaces, so this is a boundary-checked substring scan.
+ function containsTrigger(textLc, trigger) {
+ const t = trigger.toLowerCase();
+ if (!t) return false;
+ let at = textLc.indexOf(t);
+ const isWord = (ch) => /[a-z0-9_]/.test(ch || "");
+ while (at >= 0) {
+ const before = textLc[at - 1];
+ const after = textLc[at + t.length];
+ if (!isWord(before) && !isWord(after)) return true;
+ at = textLc.indexOf(t, at + 1);
+ }
+ return false;
+ }
+
+ // Group the library by category and split into covered (some trigger appears in the
+ // prompt) vs missing. The missing groups are the "you forgot about these" pool.
+ function analyze(text) {
+ const textLc = String(text || "").toLowerCase();
+ const groups = new Map(); // category -> { used: bool, items: [shortcut] }
+ for (const sc of libraryShortcuts()) {
+ const cat = sc.category || "other";
+ if (!groups.has(cat)) groups.set(cat, { used: false, items: [] });
+ const g = groups.get(cat);
+ g.items.push(sc);
+ if (!g.used && (sc.triggers || []).some((t) => containsTrigger(textLc, String(t || "").trim()))) {
+ g.used = true;
+ }
+ }
+ const missing = [...groups.entries()].filter(([, g]) => !g.used)
+ .map(([cat, g]) => ({ cat, items: g.items }));
+ return { missing, total: groups.size };
+ }
+
+ function shuffled(arr) {
+ const a = arr.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+ }
+
+ // Insert at the caret (replacing any selection), padded with spaces against
+ // neighboring words, and fire `input` so the field's normal handlers persist it.
+ function insertIntoTextarea(ta, text) {
+ const v = ta.value;
+ let s = ta.selectionStart, e = ta.selectionEnd;
+ if (s == null) { s = e = v.length; }
+ const before = v.slice(0, s), after = v.slice(e);
+ const lead = before && !/\s$/.test(before) ? " " : "";
+ const trail = !/^(\s|[,;])/.test(after) ? " " : "";
+ const ins = lead + text + trail;
+ ta.value = before + ins + after;
+ const caret = s + ins.length;
+ ta.setSelectionRange(caret, caret);
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
+ ta.focus();
+ }
+
+ function bulb(ta) {
+ const btn = el("button", "btn ghost tiny sugg-bulb", "π‘");
+ btn.type = "button";
+ if (!enabled()) { btn.style.display = "none"; return btn; }
+
+ let pop = null;
+ let unlisten = null; // detaches the document-level dismiss listeners
+ function closePop() {
+ if (pop) { pop.remove(); pop = null; }
+ if (unlisten) { unlisten(); unlisten = null; }
+ }
+
+ function refresh() {
+ const { missing } = analyze(ta.value);
+ const n = missing.length;
+ btn.classList.toggle("on", n > 0 && !!ta.value.trim());
+ btn.title = n > 0
+ ? `Ideas β ${n} shortcut ${n === 1 ? "category" : "categories"} this prompt isn't using`
+ : "Ideas β this prompt already touches every shortcut category";
+ }
+
+ function position() {
+ if (!pop) return;
+ const r = btn.getBoundingClientRect();
+ pop.style.top = (r.bottom + 4) + "px";
+ pop.style.left = Math.max(8, Math.min(r.left, window.innerWidth - 340)) + "px";
+ }
+
+ function chipRow(sc) {
+ const trig = String((sc.triggers || [])[0] || "").trim();
+ if (!trig) return null;
+ const row = el("div", "sugg-item");
+ row.append(el("span", "ac-trigger", trig));
+ const rep = (sc.replacements || [])[0];
+ if (rep) row.append(el("span", "ac-prompt", rep));
+ if (sc.sub_category) row.append(el("span", "ac-cat", sc.sub_category));
+ row.title = rep || trig;
+ // mousedown (not click) so the textarea keeps focus context, same as autocomplete.
+ row.addEventListener("mousedown", (ev) => {
+ ev.preventDefault();
+ insertIntoTextarea(ta, trig);
+ closePop();
+ refresh();
+ });
+ return row;
+ }
+
+ function openPop() {
+ closePop();
+ document.querySelectorAll(".sugg-pop").forEach((p) => p.remove());
+ // Never open off an invisible/detached bulb β the popover would strand at (0,0).
+ const br = btn.getBoundingClientRect();
+ if (!btn.isConnected || (!br.width && !br.height)) return;
+
+ const { missing } = analyze(ta.value);
+ pop = el("div", "sugg-pop");
+
+ const head = el("div", "sugg-head");
+ head.append(el("span", "sugg-title", missing.length
+ ? "Categories not in this prompt yet"
+ : "All categories covered β"));
+ const reroll = el("button", "btn ghost tiny", "β³");
+ reroll.title = "Shuffle the samples";
+ reroll.onclick = (ev) => { ev.stopPropagation(); fill(); };
+ head.append(reroll);
+ pop.append(head);
+
+ const list = el("div", "sugg-list");
+ pop.append(list);
+
+ // Category-grouped browser: every missing category is listed (so you SEE what
+ // whole families you're skipping), with a few random samples under each and a
+ // "+N more" toggle to browse that category in full.
+ const expanded = new Set();
+ function fill() {
+ list.replaceChildren();
+ if (!missing.length) {
+ list.append(el("div", "sugg-empty",
+ "Nothing left to suggest β every shortcut category already appears here."));
+ return;
+ }
+ const cats = missing.slice().sort((a, b) => a.cat.localeCompare(b.cat));
+ for (const m of cats) {
+ const headRow = el("div", "sugg-cat-head");
+ headRow.append(el("span", "sugg-cat-name", m.cat));
+ headRow.append(el("span", "sugg-cat-count", String(m.items.length)));
+ const showAll = expanded.has(m.cat);
+ if (m.items.length > MAX_CHIPS) {
+ const more = el("button", "btn ghost tiny sugg-more",
+ showAll ? "less" : `+${m.items.length - MAX_CHIPS} more`);
+ more.onclick = (ev) => {
+ ev.stopPropagation();
+ showAll ? expanded.delete(m.cat) : expanded.add(m.cat);
+ fill();
+ };
+ headRow.append(more);
+ }
+ list.append(headRow);
+ const items = showAll ? m.items : shuffled(m.items).slice(0, MAX_CHIPS);
+ for (const sc of items) {
+ const row = chipRow(sc);
+ if (row) list.append(row);
+ }
+ }
+ }
+ fill();
+
+ document.body.append(pop);
+ position();
+ // Dismiss on any outside press or Escape; reposition while scrolling.
+ // Every close path funnels through closePop, which also detaches these.
+ const onDown = (ev) => { if (pop && !pop.contains(ev.target) && ev.target !== btn) closePop(); };
+ const onKey = (ev) => { if (ev.key === "Escape") closePop(); };
+ const onScroll = () => position();
+ document.addEventListener("mousedown", onDown, true);
+ document.addEventListener("keydown", onKey, true);
+ window.addEventListener("scroll", onScroll, true);
+ unlisten = () => {
+ document.removeEventListener("mousedown", onDown, true);
+ document.removeEventListener("keydown", onKey, true);
+ window.removeEventListener("scroll", onScroll, true);
+ };
+ }
+
+ btn.onclick = (ev) => { ev.preventDefault(); if (pop) closePop(); else openPop(); };
+
+ // Keep the glow honest while typing β cheap scan, debounced.
+ let timer = null;
+ ta.addEventListener("input", () => {
+ clearTimeout(timer);
+ timer = setTimeout(refresh, 400);
+ });
+ // Callers create the bulb before appending the field to the DOM β defer the first scan.
+ setTimeout(refresh, 0);
+ // A panel re-render can remove the bulb while its popover is open; close it (and
+ // stop watching) when the bulb detaches, same pattern as the autocomplete menu.
+ const detachObs = new MutationObserver(() => {
+ if (!btn.isConnected) { closePop(); detachObs.disconnect(); }
+ });
+ setTimeout(() => { if (btn.isConnected) detachObs.observe(document.body, { childList: true, subtree: true }); }, 0);
+ return btn;
+ }
+
+ window.ShortcutSuggest = { bulb };
+})();