diff --git a/movie_editor/README.md b/movie_editor/README.md index 6a033c8a..85785764 100644 --- a/movie_editor/README.md +++ b/movie_editor/README.md @@ -62,6 +62,27 @@ Nothing to start separately — the editor loads with ComfyUI: | `FUNPACK_MOVIE_TEMPLATE` | `backend/templates/ltxav_chain.api.json` | workflow template | | `FUNPACK_COMFY_URL` | auto (detected from the running server) | override only if loopback self-calls need a different address | +## Interface ("Cutting Room") + +A classic NLE three-zone layout with a top menu bar: + +``` + ┌ menu bar — File · Edit · View · Generate · FunPack ─────────────┐ + ├───────────────┬────────────────────────────────────────────────┤ + │ Media Browser │ Preview (program monitor) │ + │ projects │ │ + │ media bin ├────────────────────────────────────────────────┤ + │ (drag-drop) │ Inspector — selected clip OR project + split │ + ├───────────────┴────────────────────────────────────────────────┤ + │ Timeline — clips with transition seams; click a clip to edit it │ + └──────────────────────────────────────────────────────────────────┘ +``` + +Editing happens in the **Inspector**: select a clip in the timeline to edit its prompt, +source (Empty/Image), transition, and exclude flag; deselect (Generate ▸ Project Settings) +to edit the anchor and global seed/frames/fps. Panel borders are draggable. The media bin +drag-drop and Render/Export are stubbed for later phases. + ## How the timeline maps to Studio Studio splits one prompt into scenes by transition triggers; `segments[0]` (text before the diff --git a/movie_editor/frontend/app.js b/movie_editor/frontend/app.js new file mode 100644 index 00000000..037c45b4 --- /dev/null +++ b/movie_editor/frontend/app.js @@ -0,0 +1,31 @@ +// Entry: wire splitters, boot the store (view modules self-mount on load). +(function () { + function makeSplitter(splitterId, targetVar, axis, min, max, invert) { + const sp = document.getElementById(splitterId); + if (!sp) return; + let dragging = false; + const root = document.documentElement; + sp.addEventListener("pointerdown", (e) => { + dragging = true; sp.setPointerCapture(e.pointerId); + document.body.classList.add("dragging"); + }); + sp.addEventListener("pointermove", (e) => { + if (!dragging) return; + let v = axis === "x" + ? (invert ? window.innerWidth - e.clientX : e.clientX) + : (invert ? window.innerHeight - e.clientY : e.clientY); + v = Math.max(min, Math.min(max(), v)); + root.style.setProperty(targetVar, v + "px"); + }); + sp.addEventListener("pointerup", (e) => { + dragging = false; try { sp.releasePointerCapture(e.pointerId); } catch (_) {} + document.body.classList.remove("dragging"); + }); + } + + window.addEventListener("DOMContentLoaded", () => { + makeSplitter("split-left", "--media-w", "x", 200, () => window.innerWidth * 0.5, false); + makeSplitter("split-bottom", "--timeline-h", "y", 160, () => window.innerHeight * 0.6, true); + window.Store.init(); + }); +})(); diff --git a/movie_editor/frontend/dom.js b/movie_editor/frontend/dom.js new file mode 100644 index 00000000..4ed74bc1 --- /dev/null +++ b/movie_editor/frontend/dom.js @@ -0,0 +1,20 @@ +// Tiny DOM helpers shared by view modules. +(function () { + function el(tag, cls, txt) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (txt != null) e.textContent = txt; + return e; + } + function $(sel) { return document.querySelector(sel); } + function clear(node) { while (node && node.firstChild) node.removeChild(node.firstChild); return node; } + function icon(name) { + // minimal inline glyphs (keeps us font-icon-free) + const map = { + empty: "▦", image: "◐", frame: "⛶", play: "▶", add: "+", + del: "✕", up: "‹", down: "›", warn: "▲", dot: "●", film: "🎞", + }; + return map[name] || "•"; + } + window.dom = { el, $, clear, icon }; +})(); diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index 5fadd5f3..4bd54e35 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -3,67 +3,73 @@ - FunPack Movie Editor + FunPack · Cutting Room + + + -
-
🎬 FunPack Movie Editor V1
-
- - - - + +
+ -
-
-
- - -
- - + +
+
+ + + +
+ + +
+
+
+ Preview + +
+
-
+
+
Inspector
+
+
+
+ -
- - - - -
+
-
-

Timeline

- + +
+
+ Timeline +
-
+
+
- -
+ + - + + + + - + + + diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js new file mode 100644 index 00000000..8f0b587d --- /dev/null +++ b/movie_editor/frontend/inspector.js @@ -0,0 +1,113 @@ +// Right-bottom zone: context inspector (selected scene OR project) + split preview. +(function () { + const { el, clear } = window.dom; + const S = window.Store; + const body = document.getElementById("inspector-body"); + const title = document.getElementById("inspector-title"); + + const SRC = [["empty", "Empty · text-to-video"], ["image", "Image · i2v anchor"], ["generated_frame", "From generated frame"]]; + + function transitionSelect(value, onChange) { + const sel = el("select"); + const none = el("option", null, "— none —"); none.value = ""; sel.append(none); + (S.get().transitions || []).forEach((t) => { + const name = t.trigger || t.name || t.key; if (!name) return; + const o = el("option", null, t.visual_effect && t.visual_effect !== "none" ? `${name} (${t.visual_effect})` : name); + o.value = name; if (name === value) o.selected = true; sel.append(o); + }); + if (value && ![...sel.options].some((o) => o.value === value)) { + const o = el("option", null, value); o.value = value; o.selected = true; sel.append(o); + } + sel.onchange = () => onChange(sel.value); + return sel; + } + + function field(labelText, control) { + const l = el("label", "field"); l.append(el("span", null, labelText)); l.append(control); return l; + } + + function numberField(labelText, value, on) { + const i = el("input"); i.type = "number"; i.value = value; + i.oninput = () => on(parseInt(i.value || "0", 10)); + return field(labelText, i); + } + + function renderScene(st, scene) { + title.textContent = `Scene · ${st.project.scenes.indexOf(scene) + 1}`; + const tag = el("div", "insp-tag"); tag.textContent = "Clip properties"; body.append(tag); + + const ta = el("textarea"); ta.rows = 5; ta.value = scene.text || ""; ta.placeholder = "Describe this scene…"; + ta.oninput = () => S.patchScene(scene.id, { text: ta.value }); + body.append(field("Prompt", ta)); + + const src = el("select"); + SRC.forEach(([v, label]) => { const o = el("option", null, label); o.value = v; if ((scene.source?.type) === v) o.selected = true; src.append(o); }); + src.onchange = () => S.patchScene(scene.id, { source: { ...(scene.source || {}), type: src.value } }); + body.append(field("Source (acts in Phase 2)", src)); + + body.append(field("Transition to next scene", transitionSelect(scene.transition_to_next || "", + (v) => S.patchScene(scene.id, { transition_to_next: v })))); + + const row = el("div", "insp-block"); + const chk = el("label", "chk"); const cb = el("input"); cb.type = "checkbox"; cb.checked = !!scene.excluded; + cb.onchange = () => S.patchScene(scene.id, { excluded: cb.checked }); + chk.append(cb); chk.append(el("span", null, "Exclude from full generation")); + row.append(chk); body.append(row); + + const actions = el("div", "insp-block"); + const gen = el("button", "btn primary", "Generate this scene"); gen.onclick = () => S.generate(scene.id); + const del = el("button", "btn danger", "Delete"); del.style.marginLeft = "8px"; del.onclick = () => S.removeScene(scene.id); + actions.append(gen); actions.append(del); body.append(actions); + } + + function renderProject(st) { + const p = st.project; + title.textContent = "Project"; + const tag = el("div", "insp-tag"); tag.textContent = "Global settings"; body.append(tag); + + const name = el("input"); name.value = p.name || ""; + name.oninput = () => S.patchProject({ name: name.value }); + body.append(field("Project name", name)); + + const anchor = el("textarea"); anchor.rows = 2; anchor.value = p.anchor || ""; + anchor.placeholder = "Character / world description prepended to every scene"; + anchor.oninput = () => S.patchProject({ 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.patchProject({ seed: v }))); + row1.append(numberField("Frames / scene", p.num_frames_per_scene, (v) => S.patchProject({ num_frames_per_scene: v }))); + body.append(row1); + const row2 = el("div", "fields-row"); + row2.append(numberField("FPS", p.frame_rate, (v) => S.patchProject({ frame_rate: v }))); + row2.append(numberField("Max scenes", p.max_scenes, (v) => S.patchProject({ max_scenes: v }))); + body.append(row2); + } + + function renderSplit(st) { + const pv = st.preview; if (!pv) return; + const wrap = el("div", "insp-block"); + const tag = el("div", "insp-tag"); tag.textContent = "Split preview"; wrap.append(tag); + const box = el("div", "split-pv"); + if (pv.warning) { const w = el("div", "pv-warn"); w.append(el("span", null, "▲")); w.append(el("span", null, pv.warning)); box.append(w); } + 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); } + (parsed.scenes || []).forEach((s, i) => { const l = el("div", "pv-line"); l.append(el("span", "pv-badge", "S" + (i + 1))); l.append(el("span", null, s.text || "(empty)")); box.append(l); }); + const raw = el("details", "pv-raw"); raw.append(el("summary", null, "combined prompt")); raw.append(el("pre", null, pv.combined_prompt || "")); box.append(raw); + wrap.append(box); body.append(wrap); + } + + function render(st) { + clear(body); + if (!st.project) { title.textContent = "Inspector"; body.append(el("div", "pj-meta", "No project open.")); return; } + const scene = st.selectedSceneId ? S.scene(st.selectedSceneId) : null; + if (scene) renderScene(st, scene); else renderProject(st); + renderSplit(st); + } + + S.subscribe(render); +})(); diff --git a/movie_editor/frontend/mediabrowser.js b/movie_editor/frontend/mediabrowser.js new file mode 100644 index 00000000..89bd235e --- /dev/null +++ b/movie_editor/frontend/mediabrowser.js @@ -0,0 +1,46 @@ +// Left zone: projects list + media bin (drag-drop placeholder for Phase 3). +(function () { + const { el, clear } = window.dom; + const S = window.Store; + const body = document.getElementById("media-body"); + + function render(st) { + clear(body); + + // Projects + const sec = el("div", "mb-section"); + const head = el("div", "mb-section-title"); + head.append(el("span", null, "Projects")); + const add = el("button", "btn ghost tiny", "+ New"); + add.onclick = () => S.newProject(prompt("Project name:", "Untitled montage")); + head.append(add); + sec.append(head); + (st.projects || []).forEach((p) => { + const row = el("div", "project-row" + (st.project && st.project.id === p.id ? " active" : "")); + row.onclick = () => S.loadProject(p.id); + row.append(el("span", "pj-name", p.name)); + row.append(el("span", "pj-meta", `${p.scene_count}▦`)); + sec.append(row); + }); + if (!(st.projects || []).length) sec.append(el("div", "pj-meta", "No projects yet.")); + body.append(sec); + + // Media bin (placeholder) + const msec = el("div", "mb-section"); + const mhead = el("div", "mb-section-title"); + mhead.append(el("span", null, "Media Bin")); + mhead.append(el("span", "soon", "soon")); + msec.append(mhead); + const bin = el("div", "mediabin"); + bin.append(el("div", "big", "🎞")); + bin.append(el("div", null, "Drop images & clips here")); + bin.append(el("div", "pj-meta", "Assign to scenes as i2v anchors")); + // drag affordance (visual only for now) + ["dragenter", "dragover"].forEach((ev) => bin.addEventListener(ev, (e) => { e.preventDefault(); bin.classList.add("drag"); })); + ["dragleave", "drop"].forEach((ev) => bin.addEventListener(ev, (e) => { e.preventDefault(); bin.classList.remove("drag"); })); + msec.append(bin); + body.append(msec); + } + + S.subscribe(render); +})(); diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js new file mode 100644 index 00000000..0f632f2a --- /dev/null +++ b/movie_editor/frontend/menubar.js @@ -0,0 +1,106 @@ +// Top menu bar: File / Edit / View / Generate / FunPack + status chips. +(function () { + const { el, clear } = window.dom; + const S = window.Store; + const menusEl = document.getElementById("menus"); + const veil = document.getElementById("menu-veil"); + let openName = null; + + function sel() { return S.get().selectedSceneId; } + function hasProject() { return !!S.get().project; } + + function menuSpec() { + const st = S.get(); + const recent = (st.projects || []).slice(0, 8).map((p) => ({ + label: p.name, hint: `${p.scene_count}▦`, action: () => S.loadProject(p.id), + })); + return { + File: [ + { label: "New Project", hint: "⌘N", action: () => S.newProject(prompt("Project name:", "Untitled montage")) }, + { sep: true }, + { menulabel: "Open recent" }, + ...(recent.length ? recent : [{ label: "No projects", disabled: true }]), + { sep: true }, + { label: "Import Media…", soon: true, disabled: true }, + { label: "Delete Current Project", danger: true, disabled: !hasProject(), + action: () => { const p = S.get().project; if (p && confirm(`Delete "${p.name}"?`)) S.deleteProject(p.id); } }, + ], + Edit: [ + { label: "Add Scene", hint: "+", disabled: !hasProject(), action: () => S.addScene() }, + { label: "Delete Scene", disabled: !sel(), action: () => S.removeScene(sel()) }, + { sep: true }, + { label: "Move Scene Left", disabled: !sel(), action: () => S.moveScene(sel(), -1) }, + { label: "Move Scene Right", disabled: !sel(), action: () => S.moveScene(sel(), 1) }, + { sep: true }, + { label: "Toggle Exclude", disabled: !sel(), action: () => { const s = S.scene(sel()); if (s) S.patchScene(s.id, { excluded: !s.excluded }); } }, + ], + View: [ + { label: "Refresh Preview", action: () => S.refreshPreview() }, + { sep: true }, + { label: "Reset Layout", action: () => { const r = document.documentElement; r.style.removeProperty("--media-w"); r.style.removeProperty("--timeline-h"); } }, + ], + Generate: [ + { label: "Generate Whole Montage", hint: "▶", disabled: !hasProject(), action: () => S.generate(null) }, + { label: "Generate Selected Scene", disabled: !sel(), action: () => S.generate(sel()) }, + { sep: true }, + { label: "Project Settings", disabled: !hasProject(), action: () => S.selectScene(null) }, + { label: "Render / Export…", soon: true, disabled: true }, + ], + FunPack: [ + { menulabel: "Libraries (in ComfyUI Studio)" }, + { label: "Open ComfyUI", hint: "↗", action: () => window.open("/", "_blank") }, + { sep: true }, + { label: st.health?.template_exists ? "Workflow template: ready" : "Workflow template: missing", disabled: true }, + { label: st.health?.ok ? `Connected · ${st.health.comfy_url || ""}` : "ComfyUI not reachable", disabled: true }, + ], + }; + } + + function closeAll() { openName = null; veil.hidden = true; render(); } + + function openMenu(name) { openName = name; veil.hidden = false; render(); } + + function render() { + const spec = menuSpec(); + clear(menusEl); + Object.keys(spec).forEach((name) => { + const wrap = el("div", "menu" + (openName === name ? " open" : "")); + const btn = el("button", "menu-btn", name); + btn.onclick = (e) => { e.stopPropagation(); openName === name ? closeAll() : openMenu(name); }; + btn.onmouseenter = () => { if (openName && openName !== name) openMenu(name); }; + wrap.append(btn); + if (openName === name) { + const pop = el("div", "menu-pop"); + spec[name].forEach((item) => { + if (item.sep) return pop.append(el("div", "menu-sep")); + if (item.menulabel) return pop.append(el("div", "menu-label", item.menulabel)); + const mi = el("div", "menu-item" + (item.disabled ? " disabled" : "") + (item.danger ? " danger" : "")); + mi.append(el("span", null, item.label + (item.soon ? " ⋯" : ""))); + if (item.hint) mi.append(el("span", "hint", item.hint)); + if (!item.disabled && item.action) mi.onclick = () => { closeAll(); item.action(); }; + pop.append(mi); + }); + wrap.append(pop); + } + menusEl.append(wrap); + }); + } + + function renderChips(st) { + const hc = document.getElementById("health-chip"); + const ok = st.health?.ok; + hc.className = "health-chip " + (ok ? "ok" : "bad"); + clear(hc); + hc.append(el("span", "led")); + hc.append(el("span", null, ok ? "ComfyUI live" : "offline")); + const sc = document.getElementById("save-chip"); + sc.className = "save-chip" + (st.saving ? " dirty" : ""); + sc.textContent = st.saving ? "saving…" : (st.project ? "saved" : ""); + } + + veil.addEventListener("click", closeAll); + window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeAll(); }); + + S.subscribe((st) => { render(); renderChips(st); }); + render(); +})(); diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index bfc8becf..f4b6f9bb 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -1,79 +1,47 @@ -// Generate + poll status + render result media. +// Right-top zone: the program monitor (result video) + generation readout. (function () { + const { el, clear } = window.dom; + const S = window.Store; const API = window.MovieEditorAPI; - let polling = null; + const body = document.getElementById("preview-body"); + const statusEl = document.getElementById("preview-status"); - const $ = (id) => document.getElementById(id); - const el = (tag, cls, txt) => { - const e = document.createElement(tag); - if (cls) e.className = cls; - if (txt != null) e.textContent = txt; - return e; - }; + function render(st) { + clear(body); + const gen = st.gen || { state: "idle", media: [] }; - function status(msg, kind) { - const box = $("player"); - box.replaceChildren(); - box.append(el("div", "player-status " + (kind || ""), msg)); - } - - function renderMedia(projectId, media) { - const box = $("player"); - box.replaceChildren(); - if (!media.length) { - box.append(el("div", "player-status warn", "Completed, but no video/image output found in history.")); - return; - } - for (const m of media) { - const url = API.resultUrl(projectId, m); - const fmt = (m.format || "").toLowerCase(); - const isVideo = m.kind !== "images" || fmt.includes("mp4") || fmt.includes("webm") || - /\.(mp4|webm|mov)$/i.test(m.filename); + if (gen.media && gen.media.length) { + const m = gen.media[0]; + const url = API.resultUrl(st.project.id, m); + const isVideo = m.kind !== "images" || /\.(mp4|webm|mov)$/i.test(m.filename) || + (m.format || "").toLowerCase().includes("mp4"); if (isVideo && !/\.gif$/i.test(m.filename)) { - const v = el("video"); v.src = url; v.controls = true; v.autoplay = true; v.loop = true; - v.className = "result-media"; box.append(v); + const v = el("video", "result-media"); v.src = url; v.controls = true; v.autoplay = true; v.loop = true; + body.append(v); } else { - const img = el("img"); img.src = url; img.className = "result-media"; box.append(img); + const img = el("img", "result-media"); img.src = url; body.append(img); } - const cap = el("div", "result-cap", m.filename); - const a = el("a", "ghost", "open"); a.href = url; a.target = "_blank"; - cap.append(document.createTextNode(" ")); cap.append(a); - box.append(cap); + body.append(el("div", "result-cap", m.filename)); + } else { + const empty = el("div", "program-empty"); + empty.append(el("div", "reel", "🎬")); + empty.append(el("div", null, st.project ? "No render yet" : "Open or create a project")); + empty.append(el("div", "pj-meta", "Generate from the menu or timeline")); + body.append(empty); } - } - async function poll(projectId, promptId) { - clearInterval(polling); - polling = setInterval(async () => { - try { - const s = await API.status(projectId, promptId); - if (s.state === "completed") { - clearInterval(polling); - renderMedia(projectId, s.media); - } else { - status(`Generating… (${s.state})`, ""); - } - } catch (e) { - clearInterval(polling); - status("Status error: " + e.message, "warn"); - } - }, 2000); - } - - async function generate(projectId, onlyScene) { - status(onlyScene ? "Queuing scene…" : "Queuing montage…", ""); - try { - const r = await API.generate(projectId, onlyScene); - if (!r.prompt_id) { - status("Queued but no prompt id returned.", "warn"); - return; - } - status("Queued. Waiting for ComfyUI…", ""); - poll(projectId, r.prompt_id); - } catch (e) { - status("Generate failed: " + e.message, "warn"); + // readout overlay + if (["queuing", "running", "pending", "error"].includes(gen.state)) { + const ro = el("div", "gen-readout" + (gen.state === "error" ? " error" : "")); + ro.append(el("span", "pulse")); + ro.append(el("span", null, gen.msg || gen.state)); + body.append(ro); } + + // header status + clear(statusEl); + if (gen.promptId) statusEl.append(el("span", null, gen.state.toUpperCase())); } - window.MoviePlayer = { generate }; + S.subscribe(render); })(); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js new file mode 100644 index 00000000..87b5f3fa --- /dev/null +++ b/movie_editor/frontend/store.js @@ -0,0 +1,172 @@ +// Central state + data actions. Views subscribe and re-render their zone. +(function () { + const API = window.MovieEditorAPI; + + const state = { + projects: [], // [{id,name,scene_count,updated_at}] + project: null, // full Project + selectedSceneId: null, + transitions: [], // library [{trigger/name, visual_effect}] + health: null, // {ok, comfy_url, template_exists} + preview: null, // {combined_prompt, parsed, warning, parse_error} + gen: { state: "idle", promptId: null, media: [], msg: "" }, + saving: false, + }; + + const listeners = new Set(); + let saveTimer = null; + + function notify() { listeners.forEach((fn) => { try { fn(state); } catch (e) { console.error(e); } }); } + function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } + function set(patch) { Object.assign(state, patch); notify(); } + function get() { return state; } + + // ── project lifecycle ────────────────────────────────────────────────────── + async function refreshProjectList() { + const { projects } = await API.listProjects(); + state.projects = projects; notify(); + } + + async function loadProject(id) { + state.project = await API.getProject(id); + state.selectedSceneId = state.project.scenes[0]?.id || null; + state.gen = { state: "idle", promptId: null, media: [], msg: "" }; + notify(); + refreshPreview(); + } + + async function newProject(name) { + const p = await API.createProject(name || "Untitled montage"); + await refreshProjectList(); + await loadProject(p.id); + } + + async function deleteProject(id) { + await API.deleteProject(id); + state.project = null; state.selectedSceneId = null; + await refreshProjectList(); + if (state.projects[0]) await loadProject(state.projects[0].id); + else notify(); + } + + // ── editing ──────────────────────────────────────────────────────────────── + function scheduleSave() { + state.saving = true; notify(); + clearTimeout(saveTimer); + saveTimer = setTimeout(commit, 550); + } + + async function commit() { + if (!state.project) return; + try { + state.project = await API.saveProject(state.project.id, state.project); + state.saving = false; + // keep selection valid + if (!state.project.scenes.some((s) => s.id === state.selectedSceneId)) + state.selectedSceneId = state.project.scenes[0]?.id || null; + notify(); + refreshProjectList(); + refreshPreview(); + } catch (e) { + state.saving = false; notify(); + console.error("save failed", e); + } + } + + function patchProject(patch) { if (!state.project) return; Object.assign(state.project, patch); notify(); scheduleSave(); } + + function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; } + + function patchScene(id, patch) { + const s = scene(id); if (!s) return; + Object.assign(s, patch); notify(); scheduleSave(); + } + + function selectScene(id) { state.selectedSceneId = id; notify(); } + + function addScene() { + if (!state.project) return; + const s = { text: "", transition_to_next: "", source: { type: "empty" }, excluded: false }; + state.project.scenes.push(s); + notify(); scheduleSave(); // server assigns id; reselect after commit + } + + function removeScene(id) { + if (!state.project) return; + state.project.scenes = state.project.scenes.filter((s) => s.id !== id); + if (state.selectedSceneId === id) state.selectedSceneId = state.project.scenes[0]?.id || null; + notify(); scheduleSave(); + } + + function moveScene(id, delta) { + if (!state.project) return; + const arr = state.project.scenes; + const i = arr.findIndex((s) => s.id === id); + const j = i + delta; + if (i < 0 || j < 0 || j >= arr.length) return; + [arr[i], arr[j]] = [arr[j], arr[i]]; + notify(); scheduleSave(); + } + + // ── preview ────────────────────────────────────────────────────────────────── + let previewTimer = null; + function refreshPreview() { + clearTimeout(previewTimer); + previewTimer = setTimeout(async () => { + if (!state.project) return; + try { state.preview = await API.preview(state.project.id, false); } + catch (e) { state.preview = { parse_error: e.message }; } + notify(); + }, 250); + } + + // ── generation ─────────────────────────────────────────────────────────────── + let pollTimer = null; + async function generate(onlyScene) { + if (!state.project) return; + set({ gen: { state: "queuing", promptId: null, media: [], msg: onlyScene ? "Queuing scene…" : "Queuing montage…" } }); + try { + const r = await API.generate(state.project.id, onlyScene); + if (!r.prompt_id) { set({ gen: { ...state.gen, state: "error", msg: "No prompt id returned." } }); return; } + set({ gen: { state: "running", promptId: r.prompt_id, media: [], msg: "Generating…" } }); + poll(r.prompt_id); + } catch (e) { + set({ gen: { state: "error", promptId: null, media: [], msg: e.message } }); + } + } + + function poll(promptId) { + clearInterval(pollTimer); + pollTimer = setInterval(async () => { + try { + const s = await API.status(state.project.id, promptId); + if (s.state === "completed") { + clearInterval(pollTimer); + set({ gen: { state: "done", promptId, media: s.media, msg: s.media.length ? "" : "Completed (no media found)." } }); + } else { + set({ gen: { ...state.gen, state: s.state, msg: `Generating… (${s.state})` } }); + } + } catch (e) { + clearInterval(pollTimer); + set({ gen: { ...state.gen, state: "error", msg: e.message } }); + } + }, 2000); + } + + // ── boot ───────────────────────────────────────────────────────────────────── + async function init() { + try { state.health = await API.health(); } catch (_) { state.health = { ok: false }; } + try { const t = await API.transitions(); state.transitions = t.transitions || []; } catch (_) { state.transitions = []; } + await refreshProjectList(); + if (state.projects[0]) await loadProject(state.projects[0].id); + else await newProject("My first montage"); + notify(); + } + + window.Store = { + get, set, subscribe, init, + refreshProjectList, loadProject, newProject, deleteProject, + patchProject, patchScene, selectScene, addScene, removeScene, moveScene, scene, + refreshPreview, generate, + }; +})(); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index bfde484f..1e35739f 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1,97 +1,248 @@ +/* ════════════════════════════════════════════════════════════════════ + FunPack · Cutting Room — cinematic dark editing suite + ════════════════════════════════════════════════════════════════════ */ :root { - --bg: #15171c; - --panel: #1d2027; - --panel-2: #23262f; - --line: #2e323c; - --text: #e6e8ec; - --muted: #8c93a1; - --accent: #6ea8fe; - --accent-2: #4b6bdb; - --danger: #e0566b; - --warn: #e6b450; + /* warm near-black surface stack */ + --ink-0: #0c0b09; /* app bg */ + --ink-1: #131210; /* zones */ + --ink-2: #1a1814; /* heads / raised */ + --ink-3: #221f19; /* hover */ + --line: #2a261e; + --line-2:#37322833; + --text: #ece7db; + --muted: #918a7a; + --faint: #645e51; + /* film-marker amber + cool teal selection */ + --amber: #f3a93c; + --amber-2:#ffc36b; + --amber-soft: rgba(243,169,60,.14); + --teal: #57d0c6; + --teal-soft: rgba(87,208,198,.14); + --danger:#e8607a; + --good: #79c479; + + --media-w: 300px; + --timeline-h: 262px; + --menubar-h: 42px; + + --font-display: "Bricolage Grotesque", "Trebuchet MS", sans-serif; + --font-ui: "IBM Plex Sans", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; + --radius: 9px; + --shadow: 0 10px 30px -12px rgba(0,0,0,.7); } + * { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } body { - margin: 0; background: var(--bg); color: var(--text); - font: 14px/1.45 -apple-system, Segoe UI, Roboto, sans-serif; + background: + radial-gradient(1200px 600px at 80% -10%, #1b1813 0%, transparent 60%), + var(--ink-0); + color: var(--text); + font: 14px/1.45 var(--font-ui); + overflow: hidden; + -webkit-font-smoothing: antialiased; } -button { - background: var(--panel-2); color: var(--text); border: 1px solid var(--line); - border-radius: 6px; padding: 5px 10px; cursor: pointer; +body.dragging { cursor: col-resize; user-select: none; } + +/* film grain overlay */ +.grain { + position: fixed; inset: 0; pointer-events: none; z-index: 9000; opacity: .035; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + mix-blend-mode: overlay; } -button:hover { border-color: var(--accent); } -button.primary { background: var(--accent-2); border-color: var(--accent-2); font-weight: 600; } -button.primary:hover { background: var(--accent); } -button.ghost { background: transparent; padding: 3px 7px; font-size: 12px; } -button.danger, .danger { color: var(--danger); } -select, input, textarea { - background: var(--panel-2); color: var(--text); border: 1px solid var(--line); - border-radius: 6px; padding: 5px 8px; font: inherit; + +button, input, select, textarea { font-family: inherit; color: var(--text); } +::selection { background: var(--amber-soft); } + +/* scrollbars */ +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-thumb { background: #2f2b22; border-radius: 8px; border: 2px solid var(--ink-1); } +*::-webkit-scrollbar-thumb:hover { background: #3d382c; } +*::-webkit-scrollbar-track { background: transparent; } + +/* ───────────────────────── MENU BAR ───────────────────────── */ +.menubar { + height: var(--menubar-h); display: flex; align-items: center; gap: 18px; + padding: 0 14px; background: linear-gradient(#16140f, #110f0c); + border-bottom: 1px solid var(--line); position: relative; z-index: 200; } -textarea { width: 100%; resize: vertical; } -input[type=number] { width: 80px; } +.brand { font-family: var(--font-display); font-weight: 800; letter-spacing: .2px; font-size: 16px; display: flex; align-items: center; gap: 8px; } +.brand-mark { color: var(--amber); font-size: 12px; transform: translateY(-1px); filter: drop-shadow(0 0 6px var(--amber-soft)); } +.brand-sub { color: var(--faint); font-weight: 500; font-size: 12px; letter-spacing: 2px; text-transform: uppercase; } -.topbar { - display: flex; align-items: center; gap: 16px; padding: 10px 16px; - background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 5; +.menus { display: flex; gap: 2px; } +.menu { position: relative; } +.menu-btn { + background: transparent; border: 0; padding: 6px 11px; border-radius: 7px; + font-size: 13.5px; color: var(--text); cursor: pointer; letter-spacing: .2px; } -.brand { font-weight: 700; } -.tag { font-size: 11px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; } -.topbar-projects { display: flex; gap: 8px; align-items: center; } -.topbar-actions { margin-left: auto; display: flex; gap: 12px; align-items: center; } -.health { font-size: 12px; color: var(--muted); } - -.layout { display: grid; grid-template-columns: 1fr 380px; gap: 16px; padding: 16px; align-items: start; } -.editor { min-width: 0; } - -.block-label, .anchor-row label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px; } -.anchor-block { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px; } -.anchor-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; } -.anchor-row label { margin: 0; } - -.settings-bar { display: flex; gap: 16px; flex-wrap: wrap; margin: 12px 0; } -.settings-bar label { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; } - -.rail-header { display: flex; align-items: center; justify-content: space-between; } -.rail-header h2 { font-size: 15px; margin: 8px 0; } -.rail { display: flex; gap: 0; overflow-x: auto; padding-bottom: 10px; align-items: stretch; } - -.scene-card { - position: relative; flex: 0 0 260px; background: var(--panel); border: 1px solid var(--line); - border-radius: 10px; padding: 10px; margin-right: 28px; display: flex; flex-direction: column; gap: 8px; +.menu.open .menu-btn, .menu-btn:hover { background: var(--ink-3); } +.menu-pop { + position: absolute; top: calc(100% + 6px); left: 0; min-width: 230px; + background: #1b1813; border: 1px solid var(--line); border-radius: 10px; + box-shadow: var(--shadow); padding: 6px; z-index: 300; + animation: pop .12s ease; transform-origin: top left; } -.scene-card.excluded { opacity: 0.45; } -.scene-head { display: flex; align-items: center; gap: 8px; } -.scene-num { font-weight: 600; font-size: 13px; } -.src-select { margin-left: auto; font-size: 12px; } -.scene-text { flex: 1; } -.scene-ctrl { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } -.chk { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 3px; } - -.seam { - position: absolute; right: -28px; top: 50%; transform: translateY(-50%); - display: flex; flex-direction: column; align-items: center; gap: 2px; width: 28px; +@keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(.98); } } +.menu-item { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 7px 10px; border-radius: 7px; cursor: pointer; font-size: 13px; white-space: nowrap; } -.seam-label { color: var(--accent); font-weight: 700; } -.seam .transition-select { width: 110px; font-size: 11px; position: absolute; top: 22px; left: -41px; } +.menu-item:hover { background: var(--amber-soft); color: var(--amber-2); } +.menu-item.disabled { color: var(--faint); pointer-events: none; } +.menu-item .hint { color: var(--faint); font-family: var(--font-mono); font-size: 11px; } +.menu-item:hover .hint { color: var(--amber); } +.menu-sep { height: 1px; background: var(--line); margin: 5px 6px; } +.menu-label { padding: 4px 10px 3px; font-size: 10.5px; letter-spacing: 1.4px; text-transform: uppercase; color: var(--faint); } +.menu-veil { position: fixed; inset: 0; z-index: 150; } -.empty-hint { color: var(--muted); padding: 20px; } - -.sidebar { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 70px; } -.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px; } -.panel-head { display: flex; justify-content: space-between; align-items: center; } -.panel-head h3 { margin: 0 0 8px; font-size: 14px; } - -.preview { font-size: 13px; display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow: auto; } -.pv-anchor, .pv-scene { display: flex; gap: 8px; align-items: baseline; } -.pv-badge { - font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--accent); - border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; flex: 0 0 auto; +.menubar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } +.health-chip, .save-chip { + font-family: var(--font-mono); font-size: 11px; color: var(--muted); + display: flex; align-items: center; gap: 6px; padding: 3px 9px; border: 1px solid var(--line); border-radius: 20px; } -.pv-raw pre { white-space: pre-wrap; background: var(--panel-2); padding: 8px; border-radius: 6px; font-size: 12px; } -.warn { color: var(--warn); font-size: 12px; } +.health-chip .led { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); } +.health-chip.ok .led { background: var(--good); box-shadow: 0 0 8px var(--good); } +.health-chip.bad .led { background: var(--danger); box-shadow: 0 0 8px var(--danger); } +.save-chip { border-color: transparent; color: var(--faint); } +.save-chip.dirty { color: var(--amber); } -.player { min-height: 120px; } -.player-status { color: var(--muted); padding: 16px 0; text-align: center; } -.result-media { width: 100%; border-radius: 8px; background: #000; } -.result-cap { font-size: 11px; color: var(--muted); margin: 4px 0 12px; } +/* ───────────────────────── WORKSPACE GRID ───────────────────────── */ +.workspace { height: calc(100% - var(--menubar-h)); display: flex; flex-direction: column; } +.row-main { flex: 1; display: flex; min-height: 0; } +.right-stack { flex: 1; display: flex; flex-direction: column; min-width: 0; } + +.zone { background: var(--ink-1); display: flex; flex-direction: column; min-height: 0; min-width: 0; } +.media { width: var(--media-w); border-right: 1px solid var(--line); } +.preview { flex: 1; border-bottom: 1px solid var(--line); } +.inspector { height: 340px; min-height: 120px; } +.timeline { height: var(--timeline-h); border-top: 1px solid var(--line); } + +.zone-head { + height: 34px; flex: 0 0 34px; display: flex; align-items: center; gap: 10px; + padding: 0 12px; background: linear-gradient(#1a1813, #161410); + border-bottom: 1px solid var(--line); +} +.zone-title { + font-family: var(--font-mono); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; color: var(--muted); +} +.zone-title::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 9px; border-radius: 1px; background: var(--amber); transform: translateY(-1px); box-shadow: 0 0 7px var(--amber-soft); } +.zone-head-right { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--faint); display: flex; gap: 10px; align-items: center; } +.zone-body { flex: 1; overflow: auto; padding: 12px; min-height: 0; } + +/* splitters */ +.splitter { background: transparent; position: relative; flex: 0 0 auto; } +.splitter::after { content: ""; position: absolute; background: var(--line); transition: background .15s; } +.splitter:hover::after { background: var(--amber); } +.splitter-v { width: 7px; cursor: col-resize; } +.splitter-v::after { inset: 0 3px; } +.splitter-h { height: 7px; cursor: row-resize; } +.splitter-h::after { inset: 3px 0; } + +/* ───────────────────────── BUTTONS / FIELDS ───────────────────────── */ +.btn { background: var(--ink-2); border: 1px solid var(--line); border-radius: 7px; padding: 6px 11px; cursor: pointer; font-size: 13px; transition: border-color .12s, background .12s; } +.btn:hover { border-color: var(--amber); } +.btn.primary { background: linear-gradient(#f3a93c, #e0972a); color: #1a1206; border-color: #f3a93c; font-weight: 700; box-shadow: 0 4px 14px -6px var(--amber); } +.btn.primary:hover { filter: brightness(1.08); } +.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); padding: 4px 8px; font-size: 12px; } +.btn.ghost:hover { color: var(--amber-2); background: var(--ink-3); } +.btn.danger:hover { border-color: var(--danger); color: var(--danger); } +.btn.tiny { padding: 2px 7px; font-size: 12px; } + +label.field { display: block; margin-bottom: 12px; } +label.field > span { display: block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1px; text-transform: uppercase; color: var(--faint); margin-bottom: 5px; } +input, select, textarea { + width: 100%; background: var(--ink-0); border: 1px solid var(--line); border-radius: 7px; + padding: 7px 9px; font-size: 13px; outline: none; transition: border-color .12s, box-shadow .12s; +} +input:focus, select:focus, textarea:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); } +textarea { resize: vertical; line-height: 1.5; } +.fields-row { display: flex; gap: 10px; } +.fields-row > label.field { flex: 1; } +input[type=number] { font-family: var(--font-mono); } + +/* ───────────────────────── MEDIA BROWSER ───────────────────────── */ +.mb-section { margin-bottom: 18px; } +.mb-section-title { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--faint); margin-bottom: 8px; } +.project-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; border: 1px solid transparent; } +.project-row:hover { background: var(--ink-2); } +.project-row.active { background: var(--amber-soft); border-color: #f3a93c44; } +.project-row .pj-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.project-row .pj-meta { font-family: var(--font-mono); font-size: 11px; color: var(--faint); } +.mediabin { + border: 1.5px dashed var(--line); border-radius: var(--radius); padding: 26px 14px; text-align: center; + color: var(--faint); font-size: 12.5px; transition: border-color .15s, background .15s; +} +.mediabin.drag { border-color: var(--teal); background: var(--teal-soft); color: var(--teal); } +.mediabin .big { font-size: 26px; opacity: .5; margin-bottom: 8px; } +.soon { font-family: var(--font-mono); font-size: 10px; letter-spacing: 1px; color: var(--faint); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; } + +/* ───────────────────────── PREVIEW / PROGRAM MONITOR ───────────────────────── */ +.program { padding: 0; background: #060606; display: flex; align-items: center; justify-content: center; position: relative; } +.program::after { content: ""; position: absolute; inset: 0; pointer-events: none; box-shadow: inset 0 0 140px 10px rgba(0,0,0,.65); } +.program .result-media { max-width: 100%; max-height: 100%; display: block; } +.program-empty { text-align: center; color: var(--faint); } +.program-empty .reel { font-size: 40px; opacity: .35; margin-bottom: 10px; } +.gen-readout { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); font-family: var(--font-mono); font-size: 12px; color: var(--amber-2); background: #0009; border: 1px solid var(--line); padding: 5px 12px; border-radius: 20px; display: flex; align-items: center; gap: 8px; } +.gen-readout .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); animation: pulse 1s infinite; } +.gen-readout.error { color: var(--danger); } +.gen-readout.error .pulse { background: var(--danger); animation: none; } +@keyframes pulse { 50% { opacity: .25; } } +.result-cap { position: absolute; top: 10px; left: 12px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); background: #0008; padding: 3px 8px; border-radius: 6px; } + +/* ───────────────────────── INSPECTOR ───────────────────────── */ +.insp-tag { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1px; color: var(--amber); text-transform: uppercase; } +.insp-block { margin-bottom: 16px; } +.chk { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); cursor: pointer; } +.chk input { width: auto; } +.split-pv { background: var(--ink-0); border: 1px solid var(--line); border-radius: 8px; padding: 10px; } +.pv-line { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; font-size: 12.5px; } +.pv-badge { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; color: var(--teal); border: 1px solid #57d0c655; border-radius: 4px; padding: 1px 5px; flex: 0 0 auto; } +.pv-badge.anchor { color: var(--amber); border-color: #f3a93c55; } +.pv-warn { color: var(--amber-2); font-size: 12px; display: flex; gap: 7px; margin-bottom: 8px; } +.pv-raw { margin-top: 8px; } +.pv-raw summary { cursor: pointer; color: var(--faint); font-size: 11px; font-family: var(--font-mono); } +.pv-raw pre { white-space: pre-wrap; background: var(--ink-2); border-radius: 6px; padding: 8px; font-size: 11.5px; font-family: var(--font-mono); color: var(--muted); margin: 6px 0 0; } + +/* ───────────────────────── TIMELINE ───────────────────────── */ +.timeline .zone-body { padding: 0; display: flex; flex-direction: column; } +.tl-ruler { height: 22px; flex: 0 0 22px; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding-left: 12px; gap: 0; background: repeating-linear-gradient(90deg, transparent, transparent 39px, var(--line-2) 39px, var(--line-2) 40px); } +.tl-track { flex: 1; display: flex; align-items: stretch; gap: 0; padding: 14px 12px; overflow-x: auto; overflow-y: hidden; } +.clip { + position: relative; flex: 0 0 188px; background: linear-gradient(#1f1c16, #191611); + border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; cursor: pointer; + display: flex; flex-direction: column; gap: 6px; transition: transform .1s, border-color .12s, box-shadow .12s; +} +.clip::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; border-radius: 10px 0 0 10px; background: var(--amber); opacity: .55; } +.clip:hover { transform: translateY(-2px); border-color: #4a4334; } +.clip.selected { border-color: var(--teal); box-shadow: 0 0 0 2px var(--teal-soft), var(--shadow); } +.clip.selected::before { background: var(--teal); opacity: 1; } +.clip.excluded { opacity: .42; } +.clip.excluded::before { background: var(--faint); } +.clip-top { display: flex; align-items: center; gap: 7px; } +.clip-no { font-family: var(--font-mono); font-size: 11px; color: var(--amber); font-weight: 600; } +.clip-src { margin-left: auto; font-size: 12px; color: var(--faint); } +.clip-text { font-size: 12px; line-height: 1.4; color: var(--text); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; min-height: 50px; } +.clip-text.empty { color: var(--faint); font-style: italic; } +.clip-actions { display: flex; gap: 2px; opacity: 0; transition: opacity .12s; } +.clip:hover .clip-actions { opacity: 1; } +.clip-foot { display: flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 10px; color: var(--faint); } + +.seam { flex: 0 0 96px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; padding: 0 6px; } +.seam .seam-line { width: 100%; height: 1px; background: linear-gradient(90deg, transparent, var(--line), transparent); } +.seam select { font-size: 11px; padding: 4px 6px; background: var(--ink-2); text-align: center; } +.seam .seam-cap { font-family: var(--font-mono); font-size: 9px; letter-spacing: 1px; text-transform: uppercase; color: var(--faint); } +.tl-add { flex: 0 0 120px; align-self: center; margin-left: 4px; border: 1.5px dashed var(--line); background: transparent; color: var(--muted); border-radius: 10px; height: 100%; max-height: 132px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; transition: border-color .15s, color .15s; } +.tl-add:hover { border-color: var(--amber); color: var(--amber-2); } +.tl-add .plus { font-size: 22px; } + +.empty-stage { color: var(--faint); padding: 30px; text-align: center; width: 100%; } + +/* staggered zone reveal on load */ +.zone { animation: rise .5s both; } +.media { animation-delay: .02s; } +.preview { animation-delay: .08s; } +.inspector { animation-delay: .12s; } +.timeline { animation-delay: .16s; } +@keyframes rise { from { opacity: 0; transform: translateY(6px); } } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index c04dcd79..42a3e89b 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -1,244 +1,79 @@ -// Movie Editor controller: project state, editable timeline, live preview. +// Bottom zone: the clip track. Selection drives the inspector; transitions sit +// in the seams between clips. Editing happens in the inspector, not here. (function () { - const API = window.MovieEditorAPI; - let project = null; // current Project object - let transitions = []; // [{trigger/name, visual_effect}] - let saveTimer = null; + const { el, clear } = window.dom; + const S = window.Store; + const body = document.getElementById("timeline-body"); + const meta = document.getElementById("timeline-meta"); - const $ = (id) => document.getElementById(id); - const el = (tag, cls, txt) => { - const e = document.createElement(tag); - if (cls) e.className = cls; - if (txt != null) e.textContent = txt; - return e; - }; + const SRC_ICON = { empty: "▦", image: "◐", generated_frame: "⛶" }; - function transitionOptions(selected) { - const sel = el("select", "transition-select"); - const none = el("option", null, "— none —"); - none.value = ""; - sel.append(none); - for (const t of transitions) { - const name = t.trigger || t.name || t.key; - if (!name) continue; - const o = el("option", null, t.visual_effect && t.visual_effect !== "none" - ? `${name} (${t.visual_effect})` : name); - o.value = name; - if (name === selected) o.selected = true; - sel.append(o); - } - if (selected && !transitions.some((t) => (t.trigger || t.name) === selected)) { - const o = el("option", null, selected); - o.value = selected; o.selected = true; sel.append(o); - } + function transitionSelect(value, onChange) { + const sel = el("select"); + const none = el("option", null, "—"); none.value = ""; sel.append(none); + (S.get().transitions || []).forEach((t) => { + const name = t.trigger || t.name || t.key; if (!name) return; + const o = el("option", null, name); o.value = name; if (name === value) o.selected = true; sel.append(o); + }); + if (value && ![...sel.options].some((o) => o.value === value)) { const o = el("option", null, value); o.value = value; o.selected = true; sel.append(o); } + sel.onchange = (e) => { e.stopPropagation(); onChange(sel.value); }; + sel.onclick = (e) => e.stopPropagation(); return sel; } - function scheduleSave() { - clearTimeout(saveTimer); - saveTimer = setTimeout(saveAndPreview, 600); + function clipEl(st, scene, index) { + const clip = el("div", "clip" + (scene.id === st.selectedSceneId ? " selected" : "") + (scene.excluded ? " excluded" : "")); + clip.onclick = () => S.selectScene(scene.id); + + const top = el("div", "clip-top"); + top.append(el("span", "clip-no", String(index + 1).padStart(2, "0"))); + top.append(el("span", "clip-src", SRC_ICON[scene.source?.type] || "▦")); + const actions = el("div", "clip-actions"); + const mk = (label, cls, fn) => { const b = el("button", "btn ghost tiny" + (cls ? " " + cls : ""), label); b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; }; + actions.append(mk("‹", "", () => S.moveScene(scene.id, -1))); + actions.append(mk("›", "", () => S.moveScene(scene.id, 1))); + actions.append(mk("✕", "danger", () => S.removeScene(scene.id))); + top.append(actions); + clip.append(top); + + const text = el("div", "clip-text" + (scene.text ? "" : " empty"), scene.text || "empty scene"); + clip.append(text); + + const foot = el("div", "clip-foot"); + foot.append(el("span", null, scene.excluded ? "excluded" : (scene.source?.type || "empty"))); + clip.append(foot); + return clip; } - async function saveAndPreview() { - if (!project) return; - try { - project = await API.saveProject(project.id, project); - await refreshPreview(); - } catch (e) { - $("preview").textContent = "Save failed: " + e.message; - } + function seamEl(scene) { + const seam = el("div", "seam"); + seam.append(el("div", "seam-line")); + seam.append(transitionSelect(scene.transition_to_next || "", (v) => S.patchScene(scene.id, { transition_to_next: v }))); + seam.append(el("div", "seam-cap", "transition")); + return seam; } - async function refreshPreview() { - if (!project) return; - const box = $("preview"); - box.textContent = "…"; - try { - const r = await API.preview(project.id, false); - box.replaceChildren(); - if (r.warning) { - box.append(el("div", "warn", "⚠ " + r.warning)); - } - if (r.parse_error) { - box.append(el("div", "warn", "⚠ " + r.parse_error + " (ComfyUI not reachable — editing still works)")); - } - const parsed = r.parsed || {}; - if (parsed.anchor) { - const a = el("div", "pv-anchor"); - a.append(el("span", "pv-badge", "anchor")); - a.append(el("span", null, parsed.anchor)); - box.append(a); - } - (parsed.scenes || []).forEach((s, i) => { - const row = el("div", "pv-scene"); - row.append(el("span", "pv-badge", "scene " + (i + 1))); - row.append(el("span", null, s.text || "(empty)")); - box.append(row); - }); - const cp = el("details", "pv-raw"); - cp.append(el("summary", null, "combined prompt")); - cp.append(el("pre", null, r.combined_prompt || "")); - box.append(cp); - } catch (e) { - box.textContent = "Preview unavailable: " + e.message; - } - } + function render(st) { + clear(body); clear(meta); + if (!st.project) { body.append(el("div", "empty-stage", "Open a project to start cutting.")); return; } - function sceneCard(scene, index) { - const card = el("div", "scene-card" + (scene.excluded ? " excluded" : "")); - - const head = el("div", "scene-head"); - head.append(el("span", "scene-num", "Scene " + (index + 1))); - const srcSel = el("select", "src-select"); - [["empty", "Empty (t2v)"], ["image", "Image (i2v)"], ["generated_frame", "From frame"]].forEach(([v, label]) => { - const o = el("option", null, label); o.value = v; - if ((scene.source && scene.source.type) === v) o.selected = true; - srcSel.append(o); + body.append(el("div", "tl-ruler")); + const track = el("div", "tl-track"); + const scenes = st.project.scenes || []; + scenes.forEach((scene, i) => { + track.append(clipEl(st, scene, i)); + if (i < scenes.length - 1) track.append(seamEl(scene)); }); - srcSel.title = "How this scene's latent is created (acts in Phase 2)"; - srcSel.onchange = () => { scene.source = scene.source || {}; scene.source.type = srcSel.value; scheduleSave(); }; - head.append(srcSel); - card.append(head); + const add = el("button", "tl-add"); + add.append(el("span", "plus", "+")); + add.append(el("span", null, "Add scene")); + add.onclick = () => S.addScene(); + track.append(add); + body.append(track); - const ta = el("textarea", "scene-text"); - ta.rows = 3; ta.value = scene.text || ""; - ta.placeholder = "Describe this scene…"; - ta.oninput = () => { scene.text = ta.value; scheduleSave(); }; - card.append(ta); - - const ctrl = el("div", "scene-ctrl"); - const exc = el("label", "chk"); - const cb = el("input"); cb.type = "checkbox"; cb.checked = !!scene.excluded; - cb.onchange = () => { scene.excluded = cb.checked; render(); scheduleSave(); }; - exc.append(cb); exc.append(document.createTextNode(" exclude")); - ctrl.append(exc); - - const solo = el("button", "ghost", "Generate only this"); - solo.onclick = () => window.MoviePlayer.generate(project.id, scene.id); - ctrl.append(solo); - - const up = el("button", "ghost", "↑"); up.onclick = () => move(index, -1); - const down = el("button", "ghost", "↓"); down.onclick = () => move(index, 1); - const del = el("button", "ghost danger", "✕"); del.onclick = () => removeScene(index); - ctrl.append(up); ctrl.append(down); ctrl.append(del); - card.append(ctrl); - - // transition to next scene (seam), shown between cards - if (index < project.scenes.length - 1) { - const seam = el("div", "seam"); - seam.append(el("span", "seam-label", "→")); - const sel = transitionOptions(scene.transition_to_next || ""); - sel.onchange = () => { scene.transition_to_next = sel.value; scheduleSave(); }; - seam.append(sel); - card.append(seam); - } - return card; + const active = scenes.filter((s) => !s.excluded).length; + meta.append(el("span", null, `${scenes.length} clips · ${active} active`)); } - function move(index, delta) { - const j = index + delta; - if (j < 0 || j >= project.scenes.length) return; - const s = project.scenes; - [s[index], s[j]] = [s[j], s[index]]; - render(); scheduleSave(); - } - - function removeScene(index) { - project.scenes.splice(index, 1); - render(); scheduleSave(); - } - - function addScene() { - project.scenes.push({ text: "", transition_to_next: "", source: { type: "empty" }, excluded: false }); - render(); scheduleSave(); - } - - function render() { - if (!project) return; - $("project-name").value = project.name || ""; - $("anchor").value = project.anchor || ""; - $("seed").value = project.seed; - $("frames").value = project.num_frames_per_scene; - $("fps").value = project.frame_rate; - $("max-scenes").value = project.max_scenes; - - const intro = $("intro-transition"); - const newIntro = transitionOptions(project.intro_transition || ""); - newIntro.id = "intro-transition"; - newIntro.onchange = () => { project.intro_transition = newIntro.value; scheduleSave(); }; - intro.replaceWith(newIntro); - - const rail = $("rail"); - rail.replaceChildren(); - project.scenes.forEach((s, i) => rail.append(sceneCard(s, i))); - if (project.scenes.length === 0) { - rail.append(el("div", "empty-hint", "No scenes yet — add one to start your timeline.")); - } - } - - async function loadProject(id) { - project = await API.getProject(id); - render(); - await refreshPreview(); - } - - async function refreshProjectList(selectId) { - const { projects } = await API.listProjects(); - const sel = $("project-select"); - sel.replaceChildren(); - for (const p of projects) { - const o = el("option", null, `${p.name} (${p.scene_count})`); - o.value = p.id; sel.append(o); - } - if (selectId) sel.value = selectId; - if (sel.value) await loadProject(sel.value); - } - - function bindGlobals() { - $("project-select").onchange = (e) => loadProject(e.target.value); - $("new-project").onclick = async () => { - const p = await API.createProject("Untitled"); - await refreshProjectList(p.id); - }; - $("delete-project").onclick = async () => { - if (!project) return; - if (!confirm(`Delete project "${project.name}"?`)) return; - await API.deleteProject(project.id); - project = null; - await refreshProjectList(); - }; - $("project-name").oninput = (e) => { project.name = e.target.value; scheduleSave(); }; - $("anchor").oninput = (e) => { project.anchor = e.target.value; scheduleSave(); }; - $("seed").oninput = (e) => { project.seed = parseInt(e.target.value || "0", 10); scheduleSave(); }; - $("frames").oninput = (e) => { project.num_frames_per_scene = parseInt(e.target.value || "1", 10); scheduleSave(); }; - $("fps").oninput = (e) => { project.frame_rate = parseInt(e.target.value || "1", 10); scheduleSave(); }; - $("max-scenes").oninput = (e) => { project.max_scenes = parseInt(e.target.value || "1", 10); scheduleSave(); }; - $("add-scene").onclick = addScene; - $("refresh-preview").onclick = refreshPreview; - $("generate-all").onclick = () => project && window.MoviePlayer.generate(project.id, null); - } - - async function init() { - bindGlobals(); - try { - const h = await API.health(); - $("health").textContent = "ComfyUI: " + h.comfy_url; - } catch (_) { $("health").textContent = "sidecar only"; } - try { - const t = await API.transitions(); - transitions = t.transitions || []; - } catch (_) { transitions = []; } - try { - await refreshProjectList(); - const sel = $("project-select"); - if (!sel.value) { - const p = await API.createProject("My first montage"); - await refreshProjectList(p.id); - } - } catch (e) { - $("preview").textContent = "Backend not reachable: " + e.message; - } - } - - window.MovieEditor = { init, getProject: () => project }; + S.subscribe(render); })();