From b05fafff15b030f49332ec5ffa8f392d4896f7c2 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:19:35 +0300 Subject: [PATCH] Movie Editor: reorganize engine settings into Studio/Chain cards Split Project settings into Output, Prompt, and Engine bands with collapsible FunPack Studio and Chain Sampler cards. Group chain knobs by timing, continuity, guidance, decode, and experimental; add transition_duration, use_same_seed, and decode_tile_size. Show engine context strip in scene view, non-default badges, and locked carry hint for multi-scene runs. --- movie_editor/frontend/inspector.js | 367 ++++++++++++++++++++++------- movie_editor/frontend/styles.css | 27 +++ 2 files changed, 304 insertions(+), 90 deletions(-) diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 10f0a45f..4f137389 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -218,23 +218,82 @@ actions.append(gen); actions.append(del); body.append(actions); } + // ── collapsible engine settings (Studio / Chain cards) ─────────────────────── + const FOLD_KEY = "funpack_insp_fold_"; + function foldOpen(id, def) { + const v = localStorage.getItem(FOLD_KEY + id); + return v === null ? def : v === "1"; + } + function setFoldOpen(id, open) { localStorage.setItem(FOLD_KEY + id, open ? "1" : "0"); } + + function collapsibleSection(parent, id, title, defaultOpen) { + const det = el("details", "insp-fold"); + det.open = foldOpen(id, defaultOpen); + det.addEventListener("toggle", () => setFoldOpen(id, det.open)); + det.append(el("summary", "insp-fold-sum", title)); + const inner = el("div", "insp-fold-body"); + det.append(inner); + parent.append(det); + return inner; + } + + function engineCard(parent, id, title, defaultOpen) { + const card = el("div", "insp-engine"); + const head = el("button", "insp-engine-head"); + head.type = "button"; + let open = foldOpen(id, defaultOpen); + const chev = el("span", "insp-engine-chev", open ? "▾" : "▸"); + const ttl = el("span", "insp-engine-title", title); + const badge = el("span", "insp-engine-badge"); + badge.hidden = true; + head.append(chev, ttl, badge); + const cardBody = el("div", "insp-engine-body"); + cardBody.hidden = !open; + head.onclick = () => { + open = !open; + cardBody.hidden = !open; + chev.textContent = open ? "▾" : "▸"; + setFoldOpen(id, open); + }; + card.append(head, cardBody); + parent.append(card); + return { body: cardBody, setBadge: (n) => { badge.textContent = n > 0 ? `${n} changed` : ""; badge.hidden = n <= 0; } }; + } + + function slotLabelFor(st, slotId) { + if (!slotId || slotId === "funpack") return null; + const slot = (st.models?.slots || []).find((s) => s.id === slotId); + return slot ? (slot.label || slot.node_class || slot.id) : slotId; + } + + function activeSceneCount(p) { + return (p.scenes || []).filter((s) => !s.excluded).length; + } + + function renderEngineStrip(st) { + const p = st.project; + const studioOn = !p.conditioning_slot || p.conditioning_slot === "funpack"; + const chainOn = !p.sampler_slot || p.sampler_slot === "funpack"; + const parts = []; + parts.push(studioOn ? "FunPack Studio" : (slotLabelFor(st, p.conditioning_slot) || p.conditioning_slot)); + parts.push(chainOn ? "Chain Sampler" : (slotLabelFor(st, p.sampler_slot) || p.sampler_slot)); + const strip = el("div", "insp-engine-strip"); + strip.append(el("span", "insp-engine-strip-txt", "Generating with " + parts.join(" + "))); + const btn = el("button", "btn ghost tiny", "Engine settings →"); + btn.onclick = () => S.selectScene(null); + strip.append(btn); + body.append(strip); + } + function renderProject(st) { const p = st.project; title.textContent = "Project"; - const tag = el("div", "insp-tag"); tag.textContent = "Global settings"; body.append(tag); + const outTag = el("div", "insp-tag"); outTag.textContent = "Output"; body.append(outTag); const name = el("input"); name.value = p.name || ""; name.dataset.k = "pj-name"; name.oninput = () => S.patchProjectQuiet({ name: name.value }); body.append(field("Project name", name)); - const anchor = el("textarea"); anchor.rows = 2; anchor.value = p.anchor || ""; anchor.dataset.k = "pj-anchor"; - anchor.placeholder = "Character / world description prepended to every scene"; - anchor.oninput = () => S.patchProjectQuiet({ anchor: anchor.value }); - body.append(field("Anchor", anchor)); - - body.append(field("Opening transition (anchor → scene 1)", transitionSelect(p.intro_transition || "", - (v) => S.patchProject({ intro_transition: v })))); - const row1 = el("div", "fields-row"); row1.append(numberField("Seed", p.seed, (v) => S.patchProjectQuiet({ seed: v }), "pj-seed")); row1.append(numberField("Frames / scene", p.num_frames_per_scene, (v) => S.patchProjectQuiet({ num_frames_per_scene: v }), "pj-frames")); @@ -247,128 +306,255 @@ row3.append(numberField("Width", p.width != null ? p.width : 768, (v) => S.patchProjectQuiet({ width: v }), "pj-w")); row3.append(numberField("Height", p.height != null ? p.height : 512, (v) => S.patchProjectQuiet({ height: v }), "pj-h")); body.append(row3); - body.append(el("div", "insp-hint", "Link FPS / Frames / Width / Height to node inputs in Models → Linked inputs (set Source = Project …).")); + body.append(el("div", "insp-hint", "Link FPS / Frames / Width / Height in Models → Linked inputs (Source = Project …).")); + const promptTag = el("div", "insp-tag"); promptTag.textContent = "Prompt"; body.append(promptTag); + const anchor = el("textarea"); anchor.rows = 2; anchor.value = p.anchor || ""; anchor.dataset.k = "pj-anchor"; + anchor.placeholder = "Character / world description prepended to every scene"; + anchor.oninput = () => S.patchProjectQuiet({ anchor: anchor.value }); + body.append(field("Anchor", anchor)); + body.append(field("Opening transition (anchor → scene 1)", transitionSelect(p.intro_transition || "", + (v) => S.patchProject({ intro_transition: v })))); const neg = el("textarea"); neg.rows = 2; neg.value = p.negative_prompt || ""; neg.dataset.k = "pj-neg"; neg.placeholder = "What to avoid in every scene"; neg.oninput = () => S.patchProjectQuiet({ negative_prompt: neg.value }); body.append(field("Negative prompt", neg)); const slots = (st.models?.slots || []); - - // Conditioning slot selector + its settings panel - const condTag = el("div", "insp-tag"); condTag.textContent = "Conditioning"; body.append(condTag); - const condSel = el("select"); - [["funpack", "FunPack Studio (built-in)"], ...slots.map((s) => [s.id, s.label || s.node_class || s.id])] + const engTag = el("div", "insp-tag"); engTag.textContent = "Engine"; body.append(engTag); + const engRow = el("div", "fields-row insp-engine-pickers"); + const condSel = el("select"); condSel.dataset.k = "pj-cond"; + [["funpack", "FunPack Studio"], ...slots.map((s) => [s.id, s.label || s.node_class || s.id])] .forEach(([v, lbl]) => { const o = new Option(lbl, v); if ((p.conditioning_slot || "funpack") === v) o.selected = true; condSel.append(o); }); condSel.onchange = () => S.setConditioningSlot(condSel.value); - body.append(field("Node", condSel)); - if (!p.conditioning_slot || p.conditioning_slot === "funpack") renderStudioSettings(st); - - // Sampler slot selector + its settings panel - const sampTag = el("div", "insp-tag"); sampTag.textContent = "Sampler"; body.append(sampTag); - const sampSel = el("select"); - [["funpack", "FunPack Chain Sampler (built-in)"], ...slots.map((s) => [s.id, s.label || s.node_class || s.id])] + engRow.append(field("Conditioning", condSel)); + const sampSel = el("select"); sampSel.dataset.k = "pj-samp"; + [["funpack", "FunPack Chain Sampler"], ...slots.map((s) => [s.id, s.label || s.node_class || s.id])] .forEach(([v, lbl]) => { const o = new Option(lbl, v); if ((p.sampler_slot || "funpack") === v) o.selected = true; sampSel.append(o); }); sampSel.onchange = () => S.setSamplerSlot(sampSel.value); - body.append(field("Node", sampSel)); - if (!p.sampler_slot || p.sampler_slot === "funpack") renderSamplerSettings(st); + engRow.append(field("Sampler", sampSel)); + body.append(engRow); + + const studioOn = !p.conditioning_slot || p.conditioning_slot === "funpack"; + const chainOn = !p.sampler_slot || p.sampler_slot === "funpack"; + if (!studioOn) { + body.append(el("div", "insp-hint", "Custom conditioning node — wire and tune it in Models. FunPack Studio settings are hidden.")); + } else { + renderStudioCard(st); + } + if (!chainOn) { + body.append(el("div", "insp-hint", "Custom sampler node — wire and tune it in Models. Chain Sampler settings are hidden.")); + } else { + renderChainCard(st); + } } - // Studio refiner flags (live in studio_settings.refiner). Booleans the editor exposes. - const STUDIO_REFINER_FLAGS = [ + const STUDIO_REFINER_ESSENTIALS = [ { name: "vision_conditioning", label: "Vision conditioning", default: true }, { name: "reference_injection", label: "Reference injection", default: false }, { name: "prompt_repair", label: "Prompt repair", default: true }, ]; + const STUDIO_REFINER_ADVANCED = [ + { name: "value_guidance", label: "Value guidance", kind: "bool", default: true }, + { name: "steer_mode", label: "Steer mode", kind: "combo", choices: ["relative", "absolute", "both"], default: "relative" }, + { name: "absolute_strength", label: "Absolute strength", kind: "float", default: 0.6, min: 0, max: 1, step: 0.05, + dependsOn: "steer_mode", dependsVals: ["absolute", "both"] }, + { name: "temporal_style", label: "Temporal style", kind: "combo", + choices: ["natural", "auto", "accelerate", "decelerate", "loop", "freeze"], default: "natural" }, + { name: "split_transition_placement", label: "Transition placement", kind: "combo", + choices: ["start", "end", "silent"], default: "start" }, + ]; - function renderStudioSettings(st) { - const p = st.project; + function parseStudioSettings(p) { const si = p.studio_inputs || {}; - let curSettings = {}; - try { curSettings = JSON.parse(si.studio_settings || "{}"); } catch (_) {} - const samplers = curSettings.samplers || null; + let cur = {}; + try { cur = JSON.parse(si.studio_settings || "{}"); } catch (_) {} + const rf = (cur.refiner && typeof cur.refiner === "object") ? cur.refiner : {}; + return { si, cur, rf }; + } - // Studio refiner toggles - const rfTag = el("div", "insp-tag sp-sub"); rfTag.textContent = "Studio · refiner"; body.append(rfTag); - const rf = (curSettings.refiner && typeof curSettings.refiner === "object") ? curSettings.refiner : {}; - STUDIO_REFINER_FLAGS.forEach((f) => { + function countStudioChanges(p) { + const { rf } = parseStudioSettings(p); + let n = 0; + [...STUDIO_REFINER_ESSENTIALS, ...STUDIO_REFINER_ADVANCED].forEach((f) => { const cur = rf[f.name] != null ? rf[f.name] : f.default; - const ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!cur; ctrl.style.width = "auto"; - ctrl.dataset.k = "rf-" + f.name; - ctrl.onchange = () => { - const next = { ...curSettings, refiner: { ...rf, [f.name]: ctrl.checked } }; - S.setStudioInputNow("studio_settings", JSON.stringify(next)); - }; - body.append(field(f.label, ctrl)); + if (cur !== f.default) n++; }); + return n; + } - const tag = el("div", "insp-tag sp-sub"); tag.textContent = "Studio · sampler algorithm"; body.append(tag); + function persistStudioRefiner(patch, now) { + const { cur } = parseStudioSettings(S.get().project); + const rf = (cur.refiner && typeof cur.refiner === "object") ? cur.refiner : {}; + const next = JSON.stringify({ ...cur, refiner: { ...rf, ...patch } }); + if (now) S.setStudioInputNow("studio_settings", next); + else S.setStudioInput("studio_settings", next); + } - function persist(updatedSamplers, quiet) { - const next = JSON.stringify({ ...curSettings, samplers: updatedSamplers }); + function renderStudioRefinerBool(parent, rf, f) { + const cur = rf[f.name] != null ? rf[f.name] : f.default; + const ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!cur; ctrl.style.width = "auto"; + ctrl.dataset.k = "rf-" + f.name; + ctrl.onchange = () => persistStudioRefiner({ [f.name]: ctrl.checked }, true); + parent.append(field(f.label, ctrl)); + } + + function renderStudioRefinerField(parent, rf, f) { + if (f.dependsOn) { + const depVal = rf[f.dependsOn] != null ? rf[f.dependsOn] : STUDIO_REFINER_ADVANCED.find((x) => x.name === f.dependsOn)?.default; + if (!(f.dependsVals || []).includes(depVal)) return; + } + const val = rf[f.name] != null ? rf[f.name] : f.default; + let ctrl; + if (f.kind === "bool") { + ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!val; ctrl.style.width = "auto"; + ctrl.dataset.k = "rf-" + f.name; + ctrl.onchange = () => persistStudioRefiner({ [f.name]: ctrl.checked }, true); + } else if (f.kind === "combo") { + ctrl = el("select"); ctrl.dataset.k = "rf-" + f.name; + (f.choices || []).forEach((c) => { const o = new Option(c, c); if (c === val) o.selected = true; ctrl.append(o); }); + ctrl.onchange = () => persistStudioRefiner({ [f.name]: ctrl.value }, true); + } else { + ctrl = el("input"); ctrl.type = "number"; + if (f.step != null) ctrl.step = String(f.step); + if (f.min != null) ctrl.min = String(f.min); + if (f.max != null) ctrl.max = String(f.max); + ctrl.value = val; ctrl.dataset.k = "rf-" + f.name; + ctrl.oninput = () => persistStudioRefiner({ [f.name]: parseFloat(ctrl.value || "0") }, false); + } + parent.append(field(f.label, ctrl)); + } + + function renderStudioCard(st) { + const p = st.project; + const { cur: curSettings, rf } = parseStudioSettings(p); + const samplers = curSettings.samplers || null; + const card = engineCard(body, "studio_card", "FunPack Studio", true); + card.setBadge(countStudioChanges(p)); + + const ess = collapsibleSection(card.body, "studio_ess", "Essentials", true); + STUDIO_REFINER_ESSENTIALS.forEach((f) => renderStudioRefinerBool(ess, rf, f)); + + const refSec = collapsibleSection(card.body, "studio_ref", "Refinement", false); + STUDIO_REFINER_ADVANCED.forEach((f) => renderStudioRefinerField(refSec, rf, f)); + + const sampSec = collapsibleSection(card.body, "studio_samp", "Sampler algorithm", false); + function persistSamplers(updatedSamplers, quiet) { + const { cur } = parseStudioSettings(S.get().project); + const next = JSON.stringify({ ...cur, samplers: updatedSamplers }); if (quiet) S.setStudioInput("studio_settings", next); else S.setStudioInputNow("studio_settings", next); } - try { - window.SamplerPanel.render(body, samplers, - (s) => persist(s, true), - (s) => persist(s, false)); + window.SamplerPanel.render(sampSec, samplers, + (s) => persistSamplers(s, true), + (s) => persistSamplers(s, false)); } catch (e) { const err = el("div", "insp-hint"); err.style.color = "var(--err,#e55)"; err.textContent = "Studio sampler panel failed to render: " + e.message; - body.append(err); + sampSec.append(err); } + + card.body.append(el("div", "insp-hint", + "Scene text and transitions come from the timeline. Advisor, LoRA, batch training, and adjustments remain in the ComfyUI Studio popup on the graph.")); } - // Key ChainSampler widget inputs exposed as simple form controls. const SAMPLER_KNOBS = [ - { name: "cfg", label: "CFG", kind: "float", default: 1.0, min: 0, max: 20, step: 0.1 }, - { name: "frame_overlap", label: "Frame overlap", kind: "int", default: 16, min: 0, max: 97, step: 1 }, - { name: "carry_i2v_guides", label: "Carry i2v guides", kind: "bool", default: false }, - { name: "mid_scene_guide", label: "Mid-scene guide", kind: "bool", default: false }, - { name: "mid_scene_guide_strength", label: "Guide strength", kind: "float", default: 0.25, min: 0, max: 1, step: 0.05, dependsOn: "mid_scene_guide" }, - { name: "embed_guidance", label: "Embed guidance", kind: "bool", default: false }, - { name: "embed_guidance_source", label: "Embed mode", kind: "combo", choices: ["relative", "absolute"], default: "relative", dependsOn: "embed_guidance" }, - { name: "embed_guidance_strength", label: "Embed strength", kind: "float", default: 0.02, min: 0, max: 0.5, step: 0.005, dependsOn: "embed_guidance" }, - { name: "decode_noise_scale", label: "Decode noise scale", kind: "float", default: 0.0, min: 0, max: 1, step: 0.01 }, - { name: "decode_timestep", label: "Decode timestep", kind: "float", default: 0.05, min: 0, max: 1, step: 0.01 }, + { name: "frame_overlap", label: "Frame overlap", kind: "int", default: 16, min: 0, max: 512, step: 8 }, + { name: "transition_duration", label: "Transition duration", kind: "int", default: 16, min: 0, max: 128, step: 2 }, + { name: "use_same_seed", label: "Same seed per scene", kind: "bool", default: false }, + { name: "carry_i2v_guides", label: "Carry i2v guides", kind: "bool", default: false, lockMulti: true }, + { name: "cfg", label: "CFG", kind: "float", default: 1.0, min: 0, max: 20, step: 0.1 }, + { name: "embed_guidance", label: "Embed guidance", kind: "bool", default: false }, + { name: "embed_guidance_source", label: "Embed mode", kind: "combo", choices: ["relative", "absolute"], default: "relative", dependsOn: "embed_guidance" }, + { name: "embed_guidance_strength", label: "Embed strength", kind: "float", default: 0.02, min: 0.005, max: 0.1, step: 0.005, dependsOn: "embed_guidance" }, + { name: "decode_noise_scale", label: "Decode noise scale", kind: "float", default: 0.0, min: 0, max: 1, step: 0.01 }, + { name: "decode_timestep", label: "Decode timestep", kind: "float", default: 0.05, min: 0, max: 1, step: 0.01 }, + { name: "decode_tile_size", label: "Decode tile size", kind: "int", default: 0, min: 0, max: 4096, step: 64 }, + { name: "mid_scene_guide", label: "Mid-scene guide", kind: "bool", default: false }, + { name: "mid_scene_guide_strength", label: "Guide strength", kind: "float", default: 0.25, min: 0.25, max: 0.5, step: 0.05, dependsOn: "mid_scene_guide" }, ]; + const CHAIN_SECTIONS = [ + { id: "chain_timing", title: "Timing", defaultOpen: true, knobs: ["frame_overlap", "transition_duration", "use_same_seed"] }, + { id: "chain_cont", title: "Continuity", defaultOpen: true, knobs: ["carry_i2v_guides"] }, + { id: "chain_guid", title: "Guidance", defaultOpen: false, knobs: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength"] }, + { id: "chain_dec", title: "Decode", defaultOpen: false, knobs: ["decode_noise_scale", "decode_timestep", "decode_tile_size"] }, + { id: "chain_exp", title: "Experimental", defaultOpen: false, knobs: ["mid_scene_guide", "mid_scene_guide_strength"] }, + ]; + const SAMPLER_KNOB_MAP = Object.fromEntries(SAMPLER_KNOBS.map((k) => [k.name, k])); - function renderSamplerSettings(st) { + function countChainChanges(p) { + const si = p.sampler_inputs || {}; + let n = 0; + SAMPLER_KNOBS.forEach((k) => { + if (si[k.name] != null && si[k.name] !== k.default) n++; + }); + return n; + } + + function knobVisible(k, si) { + if (k.dependsOn) { + const depVal = si[k.dependsOn] != null ? si[k.dependsOn] : SAMPLER_KNOB_MAP[k.dependsOn]?.default; + if (!depVal) return false; + } + return true; + } + + function renderSamplerKnob(parent, st, k, si, multiScene) { + if (!knobVisible(k, si)) return; + const val = si[k.name] != null ? si[k.name] : k.default; + const forced = k.lockMulti && multiScene; + let ctrl; + if (k.kind === "bool") { + ctrl = el("input"); ctrl.type = "checkbox"; + ctrl.checked = forced ? true : !!val; + ctrl.style.width = "auto"; + ctrl.dataset.k = "si-" + k.name; + if (forced) { + ctrl.disabled = true; + ctrl.title = "Auto-enabled for multi-scene montages at generation time"; + } else { + ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.checked); + } + } else if (k.kind === "combo") { + ctrl = el("select"); ctrl.dataset.k = "si-" + k.name; + (k.choices || []).forEach((c) => { const o = el("option", null, c); o.value = c; if (c === val) o.selected = true; ctrl.append(o); }); + ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.value); + } else { + ctrl = el("input"); ctrl.type = "number"; + if (k.step != null) ctrl.step = String(k.step); + if (k.min != null) ctrl.min = String(k.min); + if (k.max != null) ctrl.max = String(k.max); + ctrl.value = val; ctrl.dataset.k = "si-" + k.name; + ctrl.oninput = () => { + const v = k.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0"); + S.setSamplerInput(k.name, v); + }; + } + const row = field(k.label + (forced ? " (auto)" : ""), ctrl); + parent.append(row); + } + + function renderChainCard(st) { const p = st.project; const si = p.sampler_inputs || {}; - const tag = el("div", "insp-tag"); tag.textContent = "Sampler settings"; body.append(tag); + const multiScene = activeSceneCount(p) > 1; + const card = engineCard(body, "chain_card", "Chain Sampler", true); + card.setBadge(countChainChanges(p)); - SAMPLER_KNOBS.forEach((k) => { - const dep = k.dependsOn; - if (dep) { - const depVal = si[dep] != null ? si[dep] : SAMPLER_KNOBS.find((x) => x.name === dep)?.default; - if (!depVal) return; + CHAIN_SECTIONS.forEach((sec) => { + const inner = collapsibleSection(card.body, sec.id, sec.title, sec.defaultOpen); + sec.knobs.forEach((name) => { + const k = SAMPLER_KNOB_MAP[name]; + if (k) renderSamplerKnob(inner, st, k, si, multiScene); + }); + if (sec.id === "chain_cont" && multiScene) { + inner.append(el("div", "insp-hint", "Carry i2v guides is forced on when generating 2+ active scenes.")); } - const val = si[k.name] != null ? si[k.name] : k.default; - let ctrl; - if (k.kind === "bool") { - ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!val; ctrl.style.width = "auto"; - ctrl.dataset.k = "si-" + k.name; - ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.checked); - } else if (k.kind === "combo") { - ctrl = el("select"); ctrl.dataset.k = "si-" + k.name; - (k.choices || []).forEach((c) => { const o = el("option", null, c); o.value = c; if (c === val) o.selected = true; ctrl.append(o); }); - ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.value); - } else { - ctrl = el("input"); ctrl.type = "number"; - if (k.step != null) ctrl.step = String(k.step); - if (k.min != null) ctrl.min = String(k.min); - if (k.max != null) ctrl.max = String(k.max); - ctrl.value = val; ctrl.dataset.k = "si-" + k.name; - ctrl.oninput = () => { - const v = k.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0"); - S.setSamplerInput(k.name, v); - }; + if (sec.id === "chain_timing") { + inner.append(el("div", "insp-hint", + "Transition duration controls in-decode boundary fades (from transition library). Post-render crossfades are per-clip in the scene inspector.")); } - body.append(field(k.label, ctrl)); }); } @@ -526,7 +712,8 @@ const scene = st.selectedSceneId ? S.scene(st.selectedSceneId) : null; renderGlobalPrompt(st); renderSwitch(st, scene); - if (scene) renderScene(st, scene); else renderProject(st); + if (scene) { renderEngineStrip(st); renderScene(st, scene); } + else renderProject(st); renderExposed(st); renderSplit(st); body.scrollTop = scrollTop; // restore so editing doesn't jump to the top diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 753604b6..e838af4b 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -350,6 +350,33 @@ input[type=number] { font-family: var(--font-mono); } .insp-seg.active { background: var(--amber-soft); color: var(--amber-2); } .insp-seg:disabled { opacity: .4; cursor: default; } +/* engine context strip (scene view → jump to project engine settings) */ +.insp-engine-strip { display: flex; align-items: center; justify-content: space-between; gap: 8px; + margin-bottom: 12px; padding: 7px 10px; background: var(--ink-2); border: 1px solid var(--line); border-radius: 8px; } +.insp-engine-strip-txt { font-size: 11.5px; color: var(--muted); } + +/* collapsible Studio / Chain cards */ +.insp-engine { margin: 10px 0 14px; border: 1px solid var(--line); border-radius: 9px; background: var(--ink-2); overflow: hidden; } +.insp-engine-head { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 10px; + background: linear-gradient(var(--ink-3, #1e1b15), var(--ink-2)); border: none; cursor: pointer; + font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .8px; text-transform: uppercase; color: var(--amber); text-align: left; } +.insp-engine-head:hover { color: var(--amber-2); } +.insp-engine-chev { font-size: 11px; color: var(--faint); flex: 0 0 auto; } +.insp-engine-title { flex: 1 1 auto; } +.insp-engine-badge { flex: 0 0 auto; font-size: 9px; letter-spacing: .4px; color: var(--teal); + border: 1px solid var(--teal-soft, #57d0c655); border-radius: 4px; padding: 1px 6px; text-transform: none; } +.insp-engine-body { padding: 4px 10px 10px; } +.insp-engine-pickers { margin-bottom: 4px; } + +.insp-fold { margin: 6px 0; border: 1px solid var(--line-2); border-radius: 7px; background: var(--ink-0); } +.insp-fold > summary.insp-fold-sum { cursor: pointer; padding: 6px 9px; font-family: var(--font-mono); + font-size: 10px; letter-spacing: .6px; text-transform: uppercase; color: var(--muted); list-style: none; user-select: none; } +.insp-fold > summary.insp-fold-sum::-webkit-details-marker { display: none; } +.insp-fold > summary.insp-fold-sum::before { content: "▸ "; color: var(--faint); } +.insp-fold[open] > summary.insp-fold-sum::before { content: "▾ "; } +.insp-fold-body { padding: 2px 9px 8px; } +.insp-fold-body .insp-hint { margin: 4px 0 0; } + /* read-only length/fps value when inheriting */ .len-readout { font-family: var(--font-mono); font-size: 12px; color: var(--muted); padding: 6px 8px; background: var(--ink-0); border: 1px dashed var(--line); border-radius: 7px; }