From 6eab8d9632007c563ac2d2e9244a4f668ca4a2f5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:39:54 +0300 Subject: [PATCH] Movie Editor: Effects dropdown on the timeline toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '✨ Effects ▾' on the selected clip: pick an effect/transition (zoom in/out, blur, fade in/out, crossfade/fade-to-black/wipe → next), enter its value (strength/seconds/frames) where relevant, and Add. Writes onto the scene, so the inspector's per-scene controls remain the single source of truth and reflect whatever is added here. Co-Authored-By: Claude Opus 4.8 --- movie_editor/frontend/styles.css | 10 +++++ movie_editor/frontend/timeline.js | 64 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index e91d3802..d61953b3 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -377,6 +377,16 @@ input[type=number] { font-family: var(--font-mono); } body.tl-reordering { cursor: grabbing; user-select: none; } .tl-dropline { position: absolute; top: 6px; bottom: 6px; width: 2px; background: var(--amber, #e0a23a); box-shadow: 0 0 6px 1px rgba(224,162,58,.8); z-index: 60; pointer-events: none; } +.tl-dd { position: relative; display: inline-block; } +.tl-dd-panel { position: absolute; top: calc(100% + 4px); left: 0; z-index: 200; min-width: 200px; + background: #1b1812; border: 1px solid var(--line); border-radius: 8px; padding: 8px; + display: flex; flex-direction: column; gap: 7px; box-shadow: 0 8px 22px rgba(0,0,0,.55); } +.tl-dd-panel[hidden] { display: none; } +.tl-dd-head { font-size: 11px; color: var(--muted, #9a8f78); text-transform: uppercase; letter-spacing: .04em; } +.tl-dd-sel { width: 100%; } +.tl-dd-val { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; } +.tl-dd-val[hidden] { display: none; } +.tl-dd-val input { width: 78px; } .clip.selected { border-color: var(--teal); box-shadow: inset 0 0 0 1px var(--teal-soft); z-index: 3; } .clip.selected::before { background: var(--teal); opacity: 1; } .clip.excluded { opacity: .42; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index d7d36edc..203d2848 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -231,6 +231,69 @@ return seam; } + // ── effects / transitions menu (timeline toolbar) ─────────────────────────────── + // Each writes onto the selected scene, so the inspector's per-scene controls stay the + // single source of truth and reflect whatever is added here. + const FX_DEFS = [ + { id: "zoom_in", label: "Zoom in (crop)", apply: (sc) => ({ effects: { ...(sc.effects || {}), zoom: "in" } }) }, + { id: "zoom_out", label: "Zoom out (pad)", apply: (sc) => ({ effects: { ...(sc.effects || {}), zoom: "out" } }) }, + { id: "blur", label: "Gaussian blur", val: { label: "Strength (0–1)", def: 0.3, min: 0, max: 1, step: 0.05 }, apply: (sc, v) => ({ effects: { ...(sc.effects || {}), blur: v } }) }, + { id: "fade_in", label: "Fade in", val: { label: "Seconds", def: 0.5, min: 0, max: 10, step: 0.1 }, apply: (sc, v) => ({ effects: { ...(sc.effects || {}), fade_in: v } }) }, + { id: "fade_out", label: "Fade out", val: { label: "Seconds", def: 0.5, min: 0, max: 10, step: 0.1 }, apply: (sc, v) => ({ effects: { ...(sc.effects || {}), fade_out: v } }) }, + { id: "crossfade", label: "Crossfade → next", val: { label: "Frames", def: 16, min: 1, max: 120, step: 1 }, apply: (sc, v) => ({ video_transition: "crossfade", transition_frames: Math.round(v) }) }, + { id: "fadeblack", label: "Fade to black → next", val: { label: "Frames", def: 16, min: 1, max: 120, step: 1 }, apply: (sc, v) => ({ video_transition: "fadeblack", transition_frames: Math.round(v) }) }, + { id: "wipeleft", label: "Wipe left → next", val: { label: "Frames", def: 16, min: 1, max: 120, step: 1 }, apply: (sc, v) => ({ video_transition: "wipeleft", transition_frames: Math.round(v) }) }, + { id: "wiperight", label: "Wipe right → next", val: { label: "Frames", def: 16, min: 1, max: 120, step: 1 }, apply: (sc, v) => ({ video_transition: "wiperight", transition_frames: Math.round(v) }) }, + ]; + + function effectsDropdown(st) { + const wrap = el("div", "tl-dd"); + const hasSel = !!st.selectedSceneId; + const btn = el("button", "btn ghost tiny", "✨ Effects ▾"); + btn.disabled = !hasSel; + btn.title = hasSel ? "Add a video effect or transition to the selected clip" : "Select a clip first"; + wrap.append(btn); + + const panel = el("div", "tl-dd-panel"); panel.hidden = true; + panel.append(el("div", "tl-dd-head", "Add to selected clip")); + const sel = el("select", "tl-dd-sel"); + FX_DEFS.forEach((d) => sel.append(new Option(d.label, d.id))); + const valRow = el("label", "tl-dd-val"); + const valLbl = el("span", null, ""); const valIn = el("input"); valIn.type = "number"; + valRow.append(valLbl, valIn); + const addBtn = el("button", "btn primary tiny", "Add"); + const sync = () => { + const d = FX_DEFS.find((x) => x.id === sel.value); + if (d && d.val) { + valRow.hidden = false; valLbl.textContent = d.val.label; + valIn.min = d.val.min; valIn.max = d.val.max; valIn.step = d.val.step; + if (valIn.value === "") valIn.value = d.val.def; + } else valRow.hidden = true; + }; + sel.onchange = () => { valIn.value = ""; sync(); }; + addBtn.onclick = () => { + const d = FX_DEFS.find((x) => x.id === sel.value); if (!d) return; + const sc = S.scene(st.selectedSceneId); if (!sc) { alert("Select a clip first."); return; } + const v = d.val ? parseFloat(valIn.value || d.val.def) : null; + S.patchScene(sc.id, d.apply(sc, v)); + panel.hidden = true; + }; + panel.append(sel, valRow, addBtn); + sync(); + wrap.append(panel); + + btn.onclick = (e) => { + e.stopPropagation(); + const show = panel.hidden; + panel.hidden = !show; + if (show) { + const off = (ev) => { if (!wrap.contains(ev.target)) { panel.hidden = true; document.removeEventListener("mousedown", off, true); } }; + setTimeout(() => document.addEventListener("mousedown", off, true), 0); + } + }; + return wrap; + } + // ── toolbar ───────────────────────────────────────────────────────────────────── function toolbar(st, p, totalSec) { const bar = el("div", "tl-toolbar"); @@ -254,6 +317,7 @@ exp.disabled = !(hasSel && hasRender(st, st.selectedSceneId)); exp.onclick = () => S.exportSelected(); bar.append(split); bar.append(del); bar.append(exp); + bar.append(effectsDropdown(st)); // Scene rating — only when FunPack Studio is the conditioning provider AND the // selected clip has a render. Rates that clip's own scene (cut-aware by scene id);