Movie Editor UI: 'Cutting Room' three-zone NLE shell

Full frontend redesign into a real movie-editor layout:
- menu bar (File/Edit/View/Generate/FunPack) with dropdowns + status chips
- left Media Browser (projects + media bin drag-drop placeholder)
- right Preview/program monitor + context Inspector (clip props or project
  settings + live split preview)
- bottom Timeline track: clips with transition seams, click-to-select drives
  the inspector, reorder/exclude/delete
- draggable panel splitters; cinematic dark theme (warm near-black, amber clip
  accent, teal selection, film grain), Bricolage Grotesque + IBM Plex Sans/Mono

Architecture: central pub/sub store (store.js) + per-zone view modules; all
backend wiring unchanged (same /funpack/movie/api). Editing moved from cards
into the inspector. Render/Export and media drag-drop are stubbed for later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-06 13:52:09 +03:00
co-authored by Claude Opus 4.8
parent 7b4162c0be
commit a593ae7be7
11 changed files with 894 additions and 425 deletions
+21
View File
@@ -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
+31
View File
@@ -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();
});
})();
+20
View File
@@ -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 };
})();
+54 -48
View File
@@ -3,67 +3,73 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunPack Movie Editor</title>
<title>FunPack · Cutting Room</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand">🎬 FunPack Movie Editor <span class="tag">V1</span></div>
<div class="topbar-projects">
<select id="project-select" title="Open project"></select>
<button id="new-project">New</button>
<input id="project-name" placeholder="Project name" />
<button id="delete-project" class="danger" title="Delete project">Delete</button>
<!-- ───────────────────────── MENU BAR ───────────────────────── -->
<nav class="menubar" id="menubar">
<div class="brand"><span class="brand-mark">◉</span> FunPack <span class="brand-sub">Cutting Room</span></div>
<div class="menus" id="menus"><!-- populated by menubar.js --></div>
<div class="menubar-right">
<span class="save-chip" id="save-chip"></span>
<span class="health-chip" id="health-chip"></span>
</div>
<div class="topbar-actions">
<button id="generate-all" class="primary">Generate</button>
<span id="health" class="health"></span>
</div>
</header>
</nav>
<main class="layout">
<section class="editor">
<div class="anchor-block">
<label class="block-label">Anchor — prepended to every scene (character/world)</label>
<textarea id="anchor" rows="2" placeholder="e.g. a young woman with red hair, cinematic lighting"></textarea>
<div class="anchor-row">
<label>Opening transition (anchor → scene 1)</label>
<select id="intro-transition"></select>
<!-- ───────────────────────── WORKSPACE ───────────────────────── -->
<div class="workspace" id="workspace">
<div class="row-main">
<!-- LEFT: media browser -->
<aside class="zone media" id="media-zone">
<div class="zone-head"><span class="zone-title">Media Browser</span></div>
<div class="zone-body" id="media-body"></div>
</aside>
<div class="splitter splitter-v" id="split-left" title="Drag to resize"></div>
<!-- RIGHT: preview + inspector -->
<section class="right-stack">
<div class="zone preview" id="preview-zone">
<div class="zone-head">
<span class="zone-title">Preview</span>
<span class="zone-head-right" id="preview-status"></span>
</div>
<div class="zone-body program" id="preview-body"></div>
</div>
</div>
<div class="zone inspector" id="inspector-zone">
<div class="zone-head"><span class="zone-title" id="inspector-title">Inspector</span></div>
<div class="zone-body" id="inspector-body"></div>
</div>
</section>
</div>
<div class="settings-bar">
<label>Seed <input id="seed" type="number" min="0" /></label>
<label>Frames/scene <input id="frames" type="number" min="1" step="8" /></label>
<label>FPS <input id="fps" type="number" min="1" /></label>
<label>Max scenes <input id="max-scenes" type="number" min="1" /></label>
</div>
<div class="splitter splitter-h" id="split-bottom" title="Drag to resize"></div>
<div class="rail-header">
<h2>Timeline</h2>
<button id="add-scene">+ Add scene</button>
<!-- BOTTOM: timeline -->
<section class="zone timeline" id="timeline-zone">
<div class="zone-head">
<span class="zone-title">Timeline</span>
<span class="zone-head-right" id="timeline-meta"></span>
</div>
<div id="rail" class="rail"></div>
<div class="zone-body" id="timeline-body"></div>
</section>
</div>
<aside class="sidebar">
<div class="panel">
<div class="panel-head">
<h3>Split preview</h3>
<button id="refresh-preview" class="ghost">↻</button>
</div>
<div id="preview" class="preview"></div>
</div>
<div class="panel">
<div class="panel-head"><h3>Result</h3></div>
<div id="player" class="player"></div>
</div>
</aside>
</main>
<div class="grain" aria-hidden="true"></div>
<div class="menu-veil" id="menu-veil" hidden></div>
<script src="api.js"></script>
<script src="timeline.js"></script>
<script src="dom.js"></script>
<script src="store.js"></script>
<script src="menubar.js"></script>
<script src="mediabrowser.js"></script>
<script src="player.js"></script>
<script>window.MovieEditor.init();</script>
<script src="inspector.js"></script>
<script src="timeline.js"></script>
<script src="app.js"></script>
</body>
</html>
+113
View File
@@ -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);
})();
+46
View File
@@ -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);
})();
+106
View File
@@ -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();
})();
+34 -66
View File
@@ -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);
})();
+172
View File
@@ -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,
};
})();
+234 -83
View File
@@ -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); } }
+63 -228
View File
@@ -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);
})();