Movie Editor: add Plan strip (scene recipes above the result timeline)

New plan_strip.js band (#plan-zone) above the timeline: compact recipe cards
(anchor thumb + prompt + status: not generated / in sync / edited), click to
select, chevron opens a full-width inline editor (prompt, source, anchor picker,
Generate this scene, Remove, Advanced -> inspector). Collapsible. Placed above
#split-bottom so the timeline splitter / --timeline-h math is untouched; it's a
bounded, additive band (dock flex:1 absorbs it).

Plan = 'what will generate'; the timeline below is the immutable rendered result.
Edits are quiet/debounced and the band won't rebuild while a field is focused
(no input disruption). Selection syncs with the timeline.

Phase 1 / additive: the inspector's per-scene panel is intentionally kept as the
full editor + fallback (the plan can be Hidden) — retiring/merging it and removing
ghosts are the next steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-17 14:00:46 +03:00
co-authored by Claude Opus 4.8
parent 4fb7e66759
commit af07b7bb25
3 changed files with 278 additions and 0 deletions
+11
View File
@@ -81,6 +81,16 @@
</div>
</section>
<!-- PLAN: scene recipes (what will generate) — the timeline below is the result -->
<section class="zone plan-strip" id="plan-zone">
<div class="zone-head plan-head">
<span class="zone-title">Plan</span>
<span class="plan-head-sub">what will generate</span>
<span class="zone-head-right"><button class="btn ghost tiny" id="plan-collapse" type="button">Hide</button></span>
</div>
<div class="zone-body plan-body" id="plan-body"></div>
</section>
<div class="splitter splitter-h" id="split-bottom" title="Drag to resize"></div>
<!-- BOTTOM: timeline -->
@@ -128,6 +138,7 @@
<script src="rating_picker.js"></script>
<script src="waveform.js"></script>
<script src="timeline.js"></script>
<script src="plan_strip.js"></script>
<script src="tour.js"></script>
<script src="app.js"></script>
</body>
+229
View File
@@ -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);
});
})();
+38
View File
@@ -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; }