diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index 690429b6..75caaba6 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -81,6 +81,16 @@ + +
+
+ Plan + what will generate + +
+
+
+
@@ -128,6 +138,7 @@ + diff --git a/movie_editor/frontend/plan_strip.js b/movie_editor/frontend/plan_strip.js new file mode 100644 index 00000000..7ce6813b --- /dev/null +++ b/movie_editor/frontend/plan_strip.js @@ -0,0 +1,229 @@ +// PLAN strip — a compact board of scene RECIPE cards above the result timeline. +// Each card is one scene's plan (anchor + prompt + status); the timeline below shows the +// immutable rendered RESULT. Click a card to select it; the chevron opens a full-width +// inline editor (prompt, source/anchor, generate, remove) — "Advanced" hands off to the +// inspector for guides/effects/transitions. The plan is "what will generate"; editing it +// never touches the rendered clip below (see immutable renders). +(function () { + const { el, clear } = window.dom; + const S = window.Store; + const PC = () => window.PipelineCaps; + + const zone = document.getElementById("plan-zone"); + if (!zone) return; + const body = zone.querySelector(".plan-body"); + const collapseBtn = document.getElementById("plan-collapse"); + + let expandedId = null; // scene whose editor panel is open (one at a time) + let collapsed = localStorage.getItem("funpack_plan_collapsed") === "1"; + let _editing = false; // a plan field is focused — don't rebuild mid-edit + + const SRC = [ + ["empty", "Empty · text-to-video"], + ["image", "Image · i2v anchor"], + ["generated_frame", "From generated frame"], + ["v2v", "Video · v2v source"], + ["carry", "Carry · continue previous"], + ["mixed", "Mixed · anchor + prior guides"], + ]; + + function availableSources(st) { + if (PC() && PC().usesChainSampler(st)) return SRC; + return SRC.filter(([v]) => v === "empty" || v === "image"); + } + + function rootOf(sc) { return (S.genUnitRoot && S.genUnitRoot(S.genUnitId(sc))) || sc; } + + function statusOf(st, sc) { + const r = (st.sceneRenders || {})[sc.id]; + if (!r || !r.media) return { cls: "none", label: "not generated" }; + if (S.renderIsStale && S.renderIsStale(sc.id)) return { cls: "edited", label: "edited" }; + return { cls: "sync", label: "in sync" }; + } + + function anchorThumb(st, sc) { + const root = rootOf(sc); + const ref = root.source && root.source.media_ref; + const asset = ref ? (st.mediaBin || []).find((m) => m.id === ref) : null; + const wrap = el("div", "plan-thumb"); + if (asset && window.MovieEditorAPI) { + const img = el("img"); img.src = window.MovieEditorAPI.mediaUrl(asset.id); img.loading = "lazy"; img.alt = ""; + wrap.append(img); + } else { + const t = (root.source && root.source.type) || "empty"; + wrap.append(el("span", "plan-thumb-glyph", t === "empty" ? "T" : (t === "carry" ? "⇥" : "▢"))); + } + return wrap; + } + + function field(labelText, control) { + const l = el("label", "plan-field"); l.append(el("span", null, labelText)); l.append(control); return l; + } + + function cardEditor(st, sc) { + const root = rootOf(sc); + const ed = el("div", "plan-editor"); + const head = el("div", "plan-editor-head"); + head.append(el("span", "plan-editor-title", "Scene " + (st.project.scenes.indexOf(sc) + 1))); + const close = el("button", "btn ghost tiny", "▾ Close"); close.onclick = () => { expandedId = null; render(st); }; + head.append(close); + ed.append(head); + + const grid = el("div", "plan-editor-grid"); + + const ta = el("textarea", "plan-edit-prompt"); ta.rows = 3; ta.value = root.text || ""; + ta.placeholder = "Describe this scene…"; ta.dataset.k = "plan-prompt"; + ta.oninput = () => S.patchSceneQuiet(sc.id, { text: ta.value }); + grid.append(field("Prompt", ta)); + + const chain = PC() && PC().usesChainSampler(st); + const stored = (root.source && root.source.type) || (chain ? "carry" : "empty"); + const opts = availableSources(st); + const sel = el("select"); sel.dataset.k = "plan-src"; + opts.forEach(([v, label]) => { const o = new Option(label, v); if (v === stored) o.selected = true; sel.append(o); }); + if (!opts.some(([v]) => v === stored)) { + const o = new Option((PC() ? PC().sourceLabel(stored) : stored) + " (needs Chain Sampler)", stored); + o.selected = true; sel.append(o); + } + sel.onchange = () => { + const next = sel.value; + const patch = { source: { ...(root.source || {}), type: next } }; + if (next === "image" || next === "empty") patch.guides = []; + S.patchScene(sc.id, patch); + }; + grid.append(field("Source", sel)); + + const eff = root.source && root.source.type; + if ((eff === "image" || eff === "mixed") && window.MediaPicker) { + const pick = window.MediaPicker.create({ + value: root.source && root.source.media_ref, + mediaBin: st.mediaBin, + noneLabel: "— choose anchor image —", + onChange: (mediaRef) => { + const patch = { source: { ...(root.source || {}), type: eff, media_ref: mediaRef } }; + if ((root.source && root.source.media_ref || null) !== mediaRef) patch.guides = []; + S.patchScene(sc.id, patch); + }, + }); + grid.append(field("Anchor image", pick)); + } + ed.append(grid); + + const effFrames = S.sceneEffFrames ? S.sceneEffFrames(sc) : null; + const effFps = (S.sceneEffFps ? S.sceneEffFps(sc) : null) || 1; + const note = el("div", "plan-editor-note"); + note.textContent = effFrames + ? `Plan length ≈ ${(effFrames / effFps).toFixed(2)}s · length, guides, effects & transitions in Advanced` + : "Length, guides, effects & transitions in Advanced"; + ed.append(note); + + const actions = el("div", "plan-editor-actions"); + const gen = el("button", "btn primary tiny", "Generate this scene"); + gen.onclick = () => S.generate(sc.id); + const adv = el("button", "btn ghost tiny", "Advanced ↗"); + adv.title = "Open full scene settings (guides, effects, transitions) in the inspector"; + adv.onclick = () => { S.selectScene(sc.id); if (window.DockLayout) window.DockLayout.set({ settings: true }); }; + const rm = el("button", "btn ghost tiny danger", "Remove"); + rm.title = "Remove this scene from the plan"; + rm.onclick = () => { if (confirm("Remove this scene from the plan?")) { expandedId = null; S.removeScene(sc.id); } }; + actions.append(gen, adv, rm); + ed.append(actions); + return ed; + } + + function cardEl(st, sc, idx) { + const isSel = st.selectedSceneId === sc.id; + const isOpen = expandedId === sc.id; + const c = el("div", "plan-card" + (isSel ? " sel" : "") + (isOpen ? " open" : "")); + c.dataset.sceneId = sc.id; + c.append(anchorThumb(st, sc)); + const meta = el("div", "plan-card-meta"); + const row = el("div", "plan-card-row"); + row.append(el("span", "plan-card-no", "S" + (idx + 1))); + const st2 = statusOf(st, sc); + row.append(el("span", "plan-chip " + st2.cls, st2.label)); + meta.append(row); + const txt = (rootOf(sc).text || "").trim() || (S.isVideoClip(sc) ? "Video clip" : "(empty)"); + meta.append(el("div", "plan-card-prompt", txt)); + c.append(meta); + if (!S.isVideoClip(sc)) { + const chev = el("button", "plan-chev", isOpen ? "▾" : "▸"); + chev.title = isOpen ? "Close editor" : "Edit scene"; + chev.onclick = (e) => { e.stopPropagation(); expandedId = isOpen ? null : sc.id; S.selectScene(sc.id); render(st); }; + c.append(chev); + } + c.onclick = () => { if (st.selectedSceneId !== sc.id) S.selectScene(sc.id); }; + return c; + } + + function render(st) { + st = st || S.get(); + zone.classList.toggle("collapsed", collapsed); + if (collapseBtn) collapseBtn.textContent = collapsed ? "Show" : "Hide"; + clear(body); + if (collapsed || !st.project) return; + const scenes = st.project.scenes || []; + const rowEl = el("div", "plan-row"); + scenes.forEach((sc, i) => rowEl.append(cardEl(st, sc, i))); + const add = el("button", "plan-add", "+ Add scene"); + add.title = "Add a new scene to the plan"; + add.onclick = () => S.addScene(); + rowEl.append(add); + body.append(rowEl); + if (expandedId) { + const sc = S.scene(expandedId); + if (sc && !S.isVideoClip(sc)) body.append(cardEditor(st, sc)); + else expandedId = null; + } + } + + // ── re-render only on relevant change, and never while a plan field is being edited ── + function fp(st) { + if (!st.project) return "none"; + const p = st.project; + return JSON.stringify({ + pid: p.id, sel: st.selectedSceneId, exp: expandedId, col: collapsed, + scenes: (p.scenes || []).map((s) => { + const r = rootOf(s); + return [s.id, r.text, r.source && r.source.type, r.source && r.source.media_ref, s.excluded, S.isVideoClip(s)]; + }), + renders: (p.scenes || []).map((s) => { + const rr = (st.sceneRenders || {})[s.id]; + return [!!(rr && rr.media), S.renderIsStale && S.renderIsStale(s.id) ? 1 : 0]; + }), + media: (st.mediaBin || []).length, + }); + } + + let _lastFp = null; + function isProtectedField(a) { + if (!a || !a.dataset || !a.dataset.k || !body.contains(a)) return false; + if (a.tagName === "TEXTAREA") return true; + if (a.tagName !== "INPUT") return false; // SELECT rebuilds (reveals dependents) + const t = (a.type || "text").toLowerCase(); + return t !== "checkbox" && t !== "radio"; + } + + body.addEventListener("focusin", (e) => { if (e.target && e.target.dataset && e.target.dataset.k) _editing = true; }); + body.addEventListener("focusout", (e) => { + if (!(e.target && e.target.dataset && e.target.dataset.k)) return; + _editing = false; + setTimeout(() => { if (!_editing) { _lastFp = fp(S.get()); render(S.get()); } }, 60); + }); + + if (collapseBtn) { + collapseBtn.onclick = () => { + collapsed = !collapsed; + localStorage.setItem("funpack_plan_collapsed", collapsed ? "1" : "0"); + render(S.get()); + }; + } + + S.subscribe((st) => { + const f = fp(st); + if (f === _lastFp) return; + if (_editing && isProtectedField(document.activeElement)) return; // resyncs on blur + _lastFp = f; + render(st); + }); +})(); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 0686092b..cb5d5e09 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1414,3 +1414,41 @@ body.tour-mode { overflow: hidden; } .tour-faq-item p { margin: 8px 0 0; font-size: 12px; color: var(--muted); line-height: 1.5; } .tour-faq-empty { color: var(--muted); font-size: 12px; margin: 0; } +/* ───────────────────────── PLAN STRIP ───────────────────────── */ +.plan-strip { flex: 0 0 auto; display: flex; flex-direction: column; min-height: 0; max-height: 320px; border-top: 1px solid var(--line); } +.plan-head .plan-head-sub { font-family: var(--font-mono); font-size: 11px; color: var(--faint); margin-left: 2px; } +.plan-strip.collapsed .plan-body { display: none; } +.plan-body { display: flex; flex-direction: column; gap: 8px; padding: 8px 12px; overflow: auto; } +.plan-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 2px; align-items: stretch; } +.plan-card { + flex: 0 0 190px; display: flex; gap: 8px; align-items: flex-start; padding: 8px; + border: 1px solid var(--line); border-radius: 8px; background: var(--ink-2); cursor: pointer; transition: border-color .12s; +} +.plan-card:hover { border-color: color-mix(in srgb, var(--amber) 50%, var(--line)); } +.plan-card.sel, .plan-card.open { border-color: var(--amber); } +.plan-thumb { width: 40px; height: 40px; flex: 0 0 40px; border-radius: 6px; overflow: hidden; background: var(--ink-3); display: flex; align-items: center; justify-content: center; } +.plan-thumb img { width: 100%; height: 100%; object-fit: cover; } +.plan-thumb-glyph { font-family: var(--font-mono); font-size: 15px; color: var(--faint); } +.plan-card-meta { min-width: 0; flex: 1; } +.plan-card-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; } +.plan-card-no { font-family: var(--font-mono); font-size: 11px; color: var(--muted); } +.plan-chip { font-size: 10px; padding: 1px 6px; border-radius: 6px; white-space: nowrap; } +.plan-chip.sync { color: var(--teal); background: color-mix(in srgb, var(--teal) 14%, transparent); } +.plan-chip.edited { color: var(--amber-2); background: var(--amber-soft); } +.plan-chip.none { color: var(--faint); background: var(--ink-3); } +.plan-card-prompt { font-size: 12px; color: var(--text); line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.plan-chev { flex: 0 0 auto; background: transparent; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); cursor: pointer; padding: 2px 7px; font-size: 11px; } +.plan-chev:hover { border-color: var(--amber); color: var(--amber-2); } +.plan-add { flex: 0 0 auto; align-self: center; background: transparent; border: 1px dashed var(--line); border-radius: 8px; color: var(--muted); padding: 10px 14px; cursor: pointer; font-size: 12px; white-space: nowrap; } +.plan-add:hover { border-color: var(--amber); color: var(--amber-2); } +.plan-editor { border: 1px solid color-mix(in srgb, var(--amber) 40%, var(--line)); border-radius: 8px; background: var(--ink-2); padding: 10px; } +.plan-editor-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.plan-editor-title { font-family: var(--font-mono); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); } +.plan-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: start; } +.plan-editor-grid > .plan-field:first-child { grid-column: 1 / -1; } +.plan-field { display: flex; flex-direction: column; gap: 4px; } +.plan-field > span { font-size: 11px; color: var(--faint); } +.plan-edit-prompt { width: 100%; box-sizing: border-box; } +.plan-editor-note { font-size: 11px; color: var(--faint); margin: 8px 0; } +.plan-editor-actions { display: flex; gap: 6px; flex-wrap: wrap; } +