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:
co-authored by
Claude Opus 4.8
parent
7b4162c0be
commit
a593ae7be7
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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); } }
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user