diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a641e8..4e4f3a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ ## [Unreleased] +## [3.2.1] - 2026-07-06 + +### Added +- **Shortcut Ideas bulb** (πŸ’‘ next to prompt fields) is now a habit-aware co-pilot: alongside + the existing missing-category browser, it surfaces shortcuts you've historically paired with + what's already in the prompt, and what usually follows the previous scene in your own + projects β€” mined on demand from saved project text, no new persistent state. +- **Media gallery: one-click continuity pin** β€” a πŸ“Œ hover action on image cards sets/unsets + the project's Continuity identity pin directly from the Assets gallery, without a trip to + Engine settings. +- **Shortcut revolver** β€” an opt-in no-repeat cycling mode for multi-replacement shortcuts + (sequential or shuffled). A shortcut with several replacements now draws through all of them + before repeating, instead of a seeded random pick that could repeat early. +- **Guide blur** (`alg_blur_guides`) now stands alone from the sampler's anchor ALG: it works + with `alg_enabled` off, and has its own strength / sigma-threshold controls independent of + the anchor's. +- **Best-FaceID identity transfer** (experimental, Engine β–Έ Experimental) β€” tags the existing + Continuity "Identity pin" guide with the source-phase RoPE rotation Best-FaceID-style + identity LoRAs were trained on. A native port on the Scene Chain Sampler, no external + custom-node dependency; the LoRA itself loads the normal way via Models. + +### Changed +- Models & Pipeline "+ New node" search collapses to a compact summary once a node is picked, + instead of leaving the full results list open above the Values/Wiring panel. +- Prompt autocomplete no longer caps suggestions at 8 β€” a prefix shared by many shortcuts now + shows every match (the menu already scrolls). + +### Fixed +- **Preview stability**: the preview-segment ffmpeg re-encode ran synchronously inside the + request handler, freezing ComfyUI's event loop and stalling every other streaming response β€” + now backgrounded with per-key locking. Ghost clips (and deep-trimmed lone clips) get proper + segments instead of deep-seeking the raw chain file. Segment URLs encode the clip duration so + re-trimming a clip can't serve an hour-old cached segment. Pooled preview videos default to + `preload=metadata` (only the current clip and its neighbours preload fully), so a full + timeline no longer starves the browser's connection pool mid-playback. + ## [3.2.0] - 2026-07-05 ### Added diff --git a/conditioning.py b/conditioning.py index d16165fb..3cf89fc4 100644 --- a/conditioning.py +++ b/conditioning.py @@ -663,11 +663,13 @@ def _expand_with_map(text): try: from .templates import (load_shortcut_db, shortcut_list, shortcut_key, _shortcut_replacements, _shortcut_trigger_pattern, - normalize_refinement_key) + normalize_refinement_key, load_revolver, + revolver_next_replacement) except ImportError: from templates import (load_shortcut_db, shortcut_list, shortcut_key, _shortcut_replacements, _shortcut_trigger_pattern, - normalize_refinement_key) + normalize_refinement_key, load_revolver, + revolver_next_replacement) import random from hashlib import md5 @@ -678,7 +680,7 @@ def _expand_with_map(text): except Exception: return text, whole candidates = [] - for shortcut in (data.get("shortcuts", {}) or {}).values(): + for db_key, shortcut in (data.get("shortcuts", {}) or {}).items(): if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)): continue reps = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", []))) @@ -689,14 +691,23 @@ def _expand_with_map(text): for trig in shortcut_list(shortcut.get("triggers", [])): pat = _shortcut_trigger_pattern(trig) if pat: - candidates.append((trig, pat, reps, sc_key, sc_name)) + candidates.append((trig, pat, reps, sc_key, sc_name, str(db_key))) if not candidates: return text, whole candidates.sort(key=lambda it: len(shortcut_key(it[0])), reverse=True) - combined = "|".join(f"(?P{pat})" for i, (_, pat, _, _, _) in enumerate(candidates)) + combined = "|".join(f"(?P{pat})" for i, (_, pat, _, _, _, _) in enumerate(candidates)) if not combined: return text, whole rng = random.Random(int(md5(text.encode("utf-8")).hexdigest()[:12], 16)) + # Shortcut revolver: peek the same stored cycle generation will draw from (never saved + # here) so split boundaries are found in the text generation will actually see. + try: + _revolver = load_revolver() + except Exception: + _revolver = {"enabled": False, "random": False, "state": {}} + _rev_on = bool(_revolver.get("enabled")) + _rev_state = _revolver.get("state") if isinstance(_revolver.get("state"), dict) else {} + _rev_random = bool(_revolver.get("random")) try: pattern = re.compile(combined, re.IGNORECASE | re.UNICODE) except re.error: @@ -711,9 +722,13 @@ def _expand_with_map(text): parts.append(lit); exp_pos += len(lit) rep = None pc_key, pc_name = "", "" - for i, (_, _, reps, sc_key, sc_name) in enumerate(candidates): + for i, (_, _, reps, sc_key, sc_name, db_key) in enumerate(candidates): if m.group(f"t{i}") is not None: - rep = rng.choice(reps); pc_key, pc_name = sc_key, sc_name; break + if _rev_on and len(reps) > 1: + rep = revolver_next_replacement(_rev_state, db_key, reps, _rev_random) + else: + rep = rng.choice(reps) + pc_key, pc_name = sc_key, sc_name; break if rep is None: rep = m.group(0) pieces.append({"exp_start": exp_pos, "exp_end": exp_pos + len(rep), @@ -6661,13 +6676,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): def _v2_prompt_key(self, prompt): return re.sub(r"\s+", " ", str(prompt or "").strip()) - def _v2_apply_global_shortcuts(self, text, seed=0): + def _v2_apply_global_shortcuts(self, text, seed=0, revolver_commit=False): try: try: from .templates import apply_prompt_shortcuts except ImportError: from templates import apply_prompt_shortcuts - expanded, applied = apply_prompt_shortcuts(text, seed=seed) + expanded, applied = apply_prompt_shortcuts(text, seed=seed, revolver_commit=revolver_commit) except Exception as error: return str(text or ""), f"Shortcuts: unavailable ({error}).", [] if not applied: @@ -11729,8 +11744,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): # seeds to build N variant conditionings (Studio owns conditioning production). _raw_positive_prompt = str(positive_prompt or "") _raw_intent_prompt = str(user_intent_prompt or "") - positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(positive_prompt, seed=seed) + # revolver_commit only on the real positive prompt: the one draw per run that advances + # the shortcut revolver's cycle. The intent prompt peeks, and does so FIRST β€” if it + # echoes the positive prompt's triggers, both then resolve to the same chamber (the + # same alignment the shared seed used to give them). user_intent_prompt, intent_shortcut_status, intent_shortcut_applied = self._v2_apply_global_shortcuts(user_intent_prompt, seed=seed) + positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts( + positive_prompt, seed=seed, revolver_commit=True) if intent_shortcut_applied: shortcut_status = ( f"{shortcut_status}\nIntent {intent_shortcut_status}" @@ -12864,14 +12884,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): return (cond_i, m) try: - base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0] + try: + from .templates import revolver_enabled as _revolver_enabled + except ImportError: + from templates import revolver_enabled as _revolver_enabled + revolver_on = bool(_revolver_enabled()) except Exception: - base_expansion = str(raw_prompt or "") + revolver_on = False out = [tagged(base_entry, 0, base_prompt)] - cache = {base_expansion: base_entry} + if revolver_on: + # Shortcut revolver: each variant draw below COMMITS and advances the shared + # cycle, so N variants sweep N consecutive chambers (no repeats until the pool + # exhausts). Re-expanding the base here would both burn a chamber and collide + # with variant 1's draw β€” skip seeding the dedupe cache with it. + base_expansion = str(raw_prompt or "") + cache = {} + else: + try: + base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0] + except Exception: + base_expansion = str(raw_prompt or "") + cache = {base_expansion: base_entry} for i in range(1, n): try: - exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i)[0] + exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i, + revolver_commit=True)[0] except Exception: exp_i = base_expansion entry = cache.get(exp_i) @@ -14387,6 +14424,23 @@ class FunPackStudio: }, } + @classmethod + def IS_CHANGED(cls, **kwargs): + # Shortcut revolver advances persistent per-shortcut state on every run, so a + # fixed-seed re-run must not be served from ComfyUI's cache while it's enabled β€” + # the next Generate is supposed to draw the next replacement. Normal input-hash + # caching applies when the revolver is off. + try: + try: + from .templates import revolver_enabled + except ImportError: + from templates import revolver_enabled + if revolver_enabled(): + return float("nan") + except Exception: + pass + return "" + @staticmethod def _is_output_connected(prompt, unique_id, output_index): return FunPackVideoRefiner()._is_output_connected(prompt, unique_id, output_index) diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index 8728e88f..6c2f342b 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -285,6 +285,20 @@ def delete_shortcut(name: str) -> dict: return {"shortcuts": si(), "categories": _shortcut_categories()} +def revolver_settings() -> dict: + """Shortcut-revolver settings (enabled + random order). State lives server-side next to + the shortcut DB so previews and generation share one cycle.""" + return _funpack_attr("templates", "revolver_settings")() + + +def set_revolver_settings(payload: dict) -> dict: + fn = _funpack_attr("templates", "set_revolver_settings") + return fn( + enabled=payload.get("enabled"), + random_order=payload.get("random"), + ) + + def save_category(payload: dict) -> dict: """Add a category (and optionally a sub-category under it) to the managed list.""" add = _funpack_attr("templates", "add_shortcut_category") @@ -305,6 +319,70 @@ def delete_transition(name: str) -> dict: return {"transitions": ti()} +def suggestion_stats() -> dict: + """Mine the user's OWN prompting habits from all saved projects: which shortcuts + land in the same scene (pairs) and which shot follows which (follows, in timeline + order). Scene texts keep triggers verbatim (editor prompt model), so this is a + plain text scan. Derived on demand from project files β€” no learned state stored. + Stats are keyed by shortcut `key` (the library's stable id).""" + import re + + from . import projects as _projects + + si, *_ = _library_fns() + pats: list[tuple[str, Any]] = [] + for item in si(): + if item.get("enabled") is False: + continue + key = str(item.get("key") or item.get("name") or "") + if not key: + continue + for trig in item.get("triggers") or []: + t = str(trig or "").strip().lower() + if t: + pats.append((key, re.compile(r"(? dict: return _funpack_attr("templates", "load_shortcut_db")() diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 34d20623..43d17252 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -101,8 +101,8 @@ def _anchor_media_ref(scene: Scene) -> Optional[str]: return None -def _identity_pin_guide(media_ref: str, strength: float) -> dict: - return { +def _identity_pin_guide(media_ref: str, strength: float, *, is_pin: bool = True) -> dict: + entry = { "enabled": True, "source": "image", "media_ref": media_ref, @@ -110,12 +110,19 @@ def _identity_pin_guide(media_ref: str, strength: float) -> dict: "apply_at": 0, "strength": max(0.25, min(0.5, float(strength))), } + if is_pin: + # Marks this entry (not prior-scene/mid-scene/template guides) as the one eligible + # for the sampler's identity_transfer source-phase RoPE tag β€” see samplers.py. + entry["identity_pin"] = True + return entry def _prior_scene_guide(prior: Scene, strength: float) -> Optional[dict]: ref = _anchor_media_ref(prior) if ref: - return _identity_pin_guide(ref, strength) + # Reuses the same image-guide shape as the project identity pin, but this is the + # PRIOR SCENE's own anchor (motion/layout continuity) β€” never the identity_pin tag. + return _identity_pin_guide(ref, strength, is_pin=False) return { "enabled": True, "source": "template", diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 06946454..f8788889 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -82,12 +82,15 @@ importTransitions: (data, mode) => j("POST", API(`/library/transitions/import?mode=${mode || "merge"}`), data), clearTransitions: () => j("POST", API("/library/transitions/clear"), {}), shortcuts: () => j("GET", API("/library/shortcuts")), + suggestionStats: () => j("GET", API("/library/suggestion_stats")), saveCategory: (payload) => j("POST", API("/library/categories"), payload), saveShortcut: (item) => j("POST", API("/library/shortcuts"), item), deleteShortcut: (name) => j("DELETE", API(`/library/shortcuts/${encodeURIComponent(name)}`)), exportShortcutsUrl: () => API("/library/shortcuts/export"), importShortcuts: (data, mode) => j("POST", API(`/library/shortcuts/import?mode=${mode || "merge"}`), data), clearShortcuts: () => j("POST", API("/library/shortcuts/clear"), {}), + revolverSettings: () => j("GET", API("/library/revolver")), + setRevolverSettings: (payload) => j("POST", API("/library/revolver"), payload), // FunPack file manager (Composer β–Έ Files) listFiles: () => j("GET", API("/files")), @@ -182,6 +185,10 @@ type: m.type || "output", render_in: String(spec.renderIn != null ? spec.renderIn : 0), }); + // dur is in the URL for two reasons: ghosts have no scene server-side (the trim + // window must travel in the query), and it versions the browser cache β€” segments + // are cached for an hour, so a timeline trim must produce a different URL. + if (spec.dur != null) q.set("dur", String(spec.dur)); u += "?" + q.toString(); } return u; diff --git a/movie_editor/frontend/autocomplete.js b/movie_editor/frontend/autocomplete.js index 93fa3b6b..abcb2b74 100644 --- a/movie_editor/frontend/autocomplete.js +++ b/movie_editor/frontend/autocomplete.js @@ -7,7 +7,6 @@ const DELIMS = /[,\n;]/; // token boundaries β€” a trigger lives between delimiters const MIN_QUERY = 2; - const MAX_ITEMS = 8; function enabled() { return !!S.getEditorSetting("autocomplete"); } @@ -25,7 +24,9 @@ } // Triggers whose text contains `query` (substring, so "@golden" matches "golden"), - // prefix matches ranked first, identical triggers de-duped. + // prefix matches ranked first, identical triggers de-duped. Unbounded β€” the menu itself + // scrolls (max-height in CSS), so a prefix shared by many shortcuts (e.g. 15 "lazy_*" + // triggers) shows every one of them instead of silently hiding the tail. function matchTriggers(query) { const q = query.toLowerCase(); const scored = []; @@ -42,7 +43,6 @@ const k = e.trigger.toLowerCase(); if (seen.has(k)) continue; seen.add(k); uniq.push(e); - if (uniq.length >= MAX_ITEMS) break; } return uniq; } 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..cdb72094 100644 --- a/movie_editor/frontend/editor_settings.js +++ b/movie_editor/frontend/editor_settings.js @@ -141,6 +141,55 @@ return wrap; } + // Shortcut revolver: server-side library setting (not per-browser) β€” the no-repeat cycle + // is shared by previews and generation, so the toggle lives with the shortcut DB. + function revolverSection() { + const wrap = el("div", "es-section"); + wrap.append(el("div", "es-section-title", "Shortcut revolver")); + wrap.append(el("div", "es-hint", + "For shortcuts with several replacements: remember which replacement fired and don’t reuse it " + + "until the WHOLE set has fired. Off = seeded random pick (may repeat). Saved with your shortcut " + + "library β€” applies to every browser and to generation.")); + + const main = el("label", "chk es-toggle"); + const mainCb = el("input"); mainCb.type = "checkbox"; mainCb.disabled = true; + main.append(mainCb, el("span", null, "Shortcut revolver")); + wrap.append(main); + + const sub = el("div", "es-subfields"); + const rand = el("label", "chk es-toggle"); + const randCb = el("input"); randCb.type = "checkbox"; randCb.disabled = true; + rand.append(randCb, el("span", null, "Random")); + sub.append(rand); + sub.append(el("div", "es-hint", + "Unchecked: replacements fire in order β€” first, second, … last, then the cycle restarts. " + + "Checked: any order, but still no repeats until the set is exhausted. Changing either " + + "option restarts all cycles.")); + wrap.append(sub); + + const apply = (s) => { + mainCb.checked = !!(s && s.enabled); + randCb.checked = !!(s && s.random); + mainCb.disabled = false; + randCb.disabled = !mainCb.checked; + sub.style.display = mainCb.checked ? "" : "none"; + }; + const save = () => { + mainCb.disabled = randCb.disabled = true; + API.setRevolverSettings({ enabled: mainCb.checked, random: randCb.checked }) + .then(apply) + .catch(() => { API.revolverSettings().then(apply).catch(() => {}); }); + }; + mainCb.onchange = save; + randCb.onchange = save; + + sub.style.display = "none"; + API.revolverSettings().then(apply).catch(() => { + wrap.append(el("div", "es-hint", "Could not load revolver settings.")); + }); + return wrap; + } + function toggleRow(label, hint, key) { const row = el("div", "es-row"); const lbl = el("label", "chk es-toggle"); @@ -158,10 +207,15 @@ "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.", "anchorEnabled")); + content.append(revolverSection()); content.append(anchorGuideSection()); body.append(content); } @@ -169,7 +223,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 revolver random replacements cycle", iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)", icon: '', mount, diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index 0b445b39..6eea3300 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -173,10 +173,16 @@ { name: "joyai_audio_memory", label: "Paired audio memory", kind: "bool", default: false, dependsOn: "joyai_memory" }, { name: "v2a_grad_scale", label: "Videoβ†’audio coupling", kind: "float", default: 1.0, min: 0.0, max: 4.0, step: 0.25, dependsOn: "joyai_audio_memory" }, { name: "alg_blur_guides", label: "Blur i2v guides and JoyAI memory", kind: "bool", default: false }, + { name: "alg_guide_blur_strength", label: "Guide blur strength", kind: "float", default: 2.0, min: 1.0, max: 4.0, step: 0.1, dependsOn: "alg_blur_guides" }, + { name: "alg_guide_blur_sigma_threshold", label: "Guide blur sigma threshold", kind: "float", default: 0.975, min: 0.5, max: 0.999, step: 0.005, dependsOn: "alg_blur_guides" }, { name: "bounded_attention_enabled", label: "Bounded attention (multi-subject)", kind: "bool", default: false }, { name: "dynashift", label: "DynaShift (steer off bad gens)", kind: "bool", default: false }, { name: "dynashift_strength", label: "DynaShift strength", kind: "float", default: 0.3, min: 0.05, max: 1.0, step: 0.05, dependsOn: "dynashift" }, { name: "dynashift_threshold", label: "DynaShift match threshold", kind: "float", default: 0.6, min: 0.3, max: 0.95, step: 0.05, dependsOn: "dynashift" }, + { name: "identity_transfer_enabled", label: "Best-FaceID compatibility", kind: "bool", default: false, + hint: "Tags Continuity's Identity pin guide (Engine β†’ Continuity) with the source-phase RoPE rotation Best-FaceID-style identity LoRAs were trained on. Load the LoRA itself the normal way β€” Models β†’ add a LoRA loader onto the model path. No effect without an Identity pin image set." }, + { name: "identity_transfer_phase", label: "Source-phase id", kind: "float", default: 2.0, min: 0.0, max: 8.0, step: 0.5, dependsOn: "identity_transfer_enabled", + hint: "Matches the LoRA's training convention (ltx-trainer used 2)." }, ]; const SAMPLER_KNOB_MAP = Object.fromEntries(SAMPLER_KNOBS.map((k) => [k.name, k])); @@ -263,7 +269,7 @@ S.setSamplerInput(k.name, v); }; } - parentGroup.append(field(k.label + (forced ? " (auto)" : ""), ctrl)); + parentGroup.append(field(k.label + (forced ? " (auto)" : ""), ctrl, k.hint)); } function renderKnobList(parentGroup, st, names) { @@ -281,7 +287,7 @@ chain_timing: ["frame_overlap", "transition_duration", "use_same_seed"], chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "output_guidance", "output_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold"], chain_decode: ["decode_noise_scale", "decode_timestep", "decode_tile_size"], - chain_experimental: ["mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "bounded_attention_enabled"], + chain_experimental: ["mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "identity_transfer_phase"], }; function countChainView(p, id) { 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..b2d04cf2 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -288,7 +288,29 @@ 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