opencut_ui: Composer revamp pt.1 — Compose tab, drop Characters, declutter Properties

- Composer button moves next to the Assets/Preview/Properties tabs (was in the
  menu bar).
- Remove Characters entirely from the UI (Composer tab + the scene inspector's
  character chips). Backend character code left dormant.
- New "Compose" tab hosts the Global prompt (moved out of the inspector); stays
  in sync with the timeline, never clobbers active typing.
- Properties decluttered: drop Video transition, Blend length, Generation split
  marker and "Pause before next clip" from a scene, and "Split before scene 1"
  from the project — these are driven by the global prompt and timeline.
- Swap Import/Export arrows in Shortcuts & Splits (Import ↓ "to me", Export ↑).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-19 15:39:39 +03:00
co-authored by Claude Opus 4.8
parent 499a68abde
commit 98a76a94f1
5 changed files with 54 additions and 259 deletions
+47 -108
View File
@@ -1,19 +1,16 @@
// Composer: the single home for prompt-craft tools — Characters, Shortcuts, Split markers.
// Lives in a draggable FloatingWindow, toggled by the "Composer" button in the menu bar.
// (Moved out of the Media Browser so the left dock is just media; these belong together.)
// Composer: the home for prompt-craft tools — Compose (global prompt), Shortcuts, Split markers.
// Lives in a draggable FloatingWindow, toggled by the "Composer" button next to the panel tabs.
(function () {
const { el, clear } = window.dom;
const S = window.Store;
const API = window.MovieEditorAPI;
let win = null; // FloatingWindow instance (lazy)
let tab = "Characters";
let q = { Characters: "", Shortcuts: "", Splits: "" };
let editCharacter = null; // character being edited ({} = new), or null
let tab = "Compose";
let q = { Shortcuts: "", Splits: "" };
let gpDraft = null; // in-progress global-prompt text (Compose tab), or null
let _modal = null;
function rerender() { if (win && win.isOpen()) render(); }
// ── generic editor modal (shortcut / split forms) ──────────────────────────────
function closeModal() { if (_modal) { _modal.remove(); _modal = null; } }
function openModal(title, build) {
@@ -59,12 +56,28 @@
inp.oninput = () => { q[key] = inp.value; render(); };
return inp;
}
function mediaRefPicker(st, value, onChange, opts) {
return window.MediaPicker.create({
value, mediaBin: st.mediaBin, onChange, compact: true,
startOpen: !!(opts && opts.startOpen),
});
// ── compose (global prompt) ─────────────────────────────────────────────────────
// The whole montage as one editable prompt. Edits debounce into the timeline (which
// re-splits into scenes); external timeline edits flow back unless we're mid-type.
function composeTab(st) {
const wrap = el("div", "bin compose-bin");
const pv = st.preview;
const live = st.project.global_prompt
|| (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || "";
const val = gpDraft != null ? gpDraft : live;
wrap.append(el("div", "lib-form-title", "Global prompt"));
const ta = el("textarea", "lib-in compose-ta"); ta.rows = 14; ta.value = val;
ta.placeholder = "Anchor, scene texts, and split markers — one combined montage prompt for generation.";
ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); };
wrap.append(ta);
wrap.append(el("div", "insp-hint",
"Edits apply automatically and stay in sync with the timeline's per-scene prompts. Shortcuts expand at generation time."));
composeTextarea = ta;
return wrap;
}
let composeTextarea = null;
// ── shortcuts ─────────────────────────────────────────────────────────────────
function openShortcutEditor(item) {
@@ -113,7 +126,7 @@
wrap.append(searchRow("Shortcuts", "Filter shortcuts…"));
const toolbar = el("div", "bin-toolbar");
const addBtn = el("button", "btn ghost tiny", "+ Add"); addBtn.onclick = () => openShortcutEditor({});
const expBtn = el("a", "btn ghost tiny", "↓ Export");
const expBtn = el("a", "btn ghost tiny", "↑ Export");
expBtn.href = API.exportShortcutsUrl(); expBtn.download = "funpack_shortcuts.json"; expBtn.title = "Download shortcuts as JSON";
const impFile = el("input"); impFile.type = "file"; impFile.accept = ".json"; impFile.style.display = "none";
impFile.onchange = async () => {
@@ -121,7 +134,7 @@
const n = await S.importShortcuts(impFile.files[0]); impFile.value = "";
if (n != null) alert(`Imported ${n} shortcut(s).`);
};
const impBtn = el("button", "btn ghost tiny", "↑ Import"); impBtn.title = "Import shortcuts from JSON";
const impBtn = el("button", "btn ghost tiny", "↓ Import"); impBtn.title = "Import shortcuts from JSON";
impBtn.onclick = () => impFile.click();
toolbar.append(addBtn, expBtn, impBtn, impFile); wrap.append(toolbar);
@@ -182,7 +195,7 @@
wrap.append(searchRow("Splits", "Filter split markers…"));
const toolbar = el("div", "bin-toolbar");
const addBtn = el("button", "btn ghost tiny", "+ Add"); addBtn.onclick = () => openSplitMarkerEditor({});
const expBtn = el("a", "btn ghost tiny", "↓ Export");
const expBtn = el("a", "btn ghost tiny", "↑ Export");
expBtn.href = API.exportTransitionsUrl(); expBtn.download = "funpack_promptsplit.json"; expBtn.title = "Download split markers as JSON";
const impFile = el("input"); impFile.type = "file"; impFile.accept = ".json"; impFile.style.display = "none";
impFile.onchange = async () => {
@@ -190,7 +203,7 @@
const n = await S.importTransitions(impFile.files[0]); impFile.value = "";
if (n != null) alert(`Imported ${n} split marker(s).`);
};
const impBtn = el("button", "btn ghost tiny", "↑ Import"); impBtn.title = "Import split markers from JSON";
const impBtn = el("button", "btn ghost tiny", "↓ Import"); impBtn.title = "Import split markers from JSON";
impBtn.onclick = () => impFile.click();
toolbar.append(addBtn, expBtn, impBtn, impFile); wrap.append(toolbar);
@@ -217,108 +230,26 @@
return wrap;
}
// ── characters ──────────────────────────────────────────────────────────────────
function characterForm(st, item) {
const isNew = !item.id;
const box = el("div", "lib-form");
box.append(el("div", "lib-form-title", isNew ? "New character" : `Edit “${item.name || "character"}”`));
const name = el("input", "lib-in"); name.placeholder = "Name"; name.value = item.name || "";
const appearance = el("textarea", "lib-in"); appearance.rows = 2; appearance.placeholder = "Face, hair, eyes, skin…"; appearance.value = item.appearance || "";
const body = el("textarea", "lib-in"); body.rows = 2; body.placeholder = "Build, height, proportions…"; body.value = item.body || "";
const wardrobe = el("textarea", "lib-in"); wardrobe.rows = 2; wardrobe.placeholder = "Default outfit or style…"; wardrobe.value = item.wardrobe || "";
const always = el("textarea", "lib-in"); always.rows = 2; always.placeholder = "Tags always included in this character's prompt"; always.value = item.always_include || "";
const never = el("textarea", "lib-in"); never.rows = 2; never.placeholder = "Appended to negative when this character is on a scene"; never.value = item.never_include || "";
let faceRef = item.face_ref || null, bodyRef = item.body_ref || null, detailRef = item.detail_ref || null;
box.append(
labeled("Name", name), labeled("Appearance", appearance), labeled("Body", body),
labeled("Wardrobe", wardrobe), labeled("Always include", always), labeled("Never include", never),
labeled("Face ref (identity pin)", mediaRefPicker(st, faceRef, (v) => { faceRef = v; }, { startOpen: true })),
labeled("Body ref", mediaRefPicker(st, bodyRef, (v) => { bodyRef = v; }, { startOpen: true })),
labeled("Detail ref", mediaRefPicker(st, detailRef, (v) => { detailRef = v; }, { startOpen: true })),
);
const actions = el("div", "lib-form-actions");
const save = el("button", "btn primary tiny", "Save");
save.onclick = async () => {
const nm = name.value.trim();
if (!nm) { alert("Character name is required."); return; }
await S.saveCharacter({
id: item.id || undefined, original_id: item.id || undefined, name: nm,
appearance: appearance.value, body: body.value, wardrobe: wardrobe.value,
always_include: always.value, never_include: never.value,
face_ref: faceRef, body_ref: bodyRef, detail_ref: detailRef,
});
editCharacter = null; render();
};
const cancel = el("button", "btn ghost tiny", "Cancel");
cancel.onclick = () => { editCharacter = null; render(); };
actions.append(save, cancel); box.append(actions);
return box;
}
function charactersTab(st) {
if (editCharacter) return characterForm(st, editCharacter);
const wrap = el("div", "bin");
wrap.append(searchRow("Characters", "Filter characters…"));
const hint = el("div", "pj-meta char-hint");
hint.textContent = st.selectedSceneId
? "Click a character to add/remove it on the selected scene."
: "Select a scene, then click characters to assign them.";
wrap.append(hint);
const head = el("div", "lib-head");
const add = el("button", "btn ghost tiny", "+ New"); add.onclick = () => { editCharacter = {}; render(); };
head.append(add); wrap.append(head);
const assigned = st.selectedSceneId ? new Set(S.sceneCharacterIds(st.selectedSceneId)) : null;
const list = el("div", "char-list");
const items = filtered(st.characters || [], q.Characters, (c) =>
`${c.name} ${c.appearance || ""} ${c.body || ""} ${c.wardrobe || ""}`);
items.forEach((c) => {
const onScene = assigned && assigned.has(c.id);
const row = el("div", "char-row" + (onScene ? " on-scene" : ""));
const prev = el("div", "char-thumb");
if (c.face_ref) { const img = el("img"); img.src = API.mediaUrl(c.face_ref); img.loading = "lazy"; prev.append(img); }
else prev.append(el("span", null, "◎"));
row.append(prev);
const info = el("div", "char-info");
info.append(el("div", "char-name", c.name || "(unnamed)"));
const sub = [c.appearance, c.wardrobe].filter(Boolean).join(" · ");
if (sub) info.append(el("div", "char-sub", sub));
row.append(info);
if (onScene) row.append(el("span", "char-badge", "on scene"));
row.onclick = () => {
if (st.selectedSceneId) S.toggleSceneCharacter(st.selectedSceneId, c.id);
else { editCharacter = { ...c }; render(); }
};
const edit = el("button", "btn ghost tiny", "✎"); edit.title = "Edit character";
edit.onclick = (e) => { e.stopPropagation(); editCharacter = { ...c }; render(); };
const del = el("button", "btn ghost tiny danger", "✕"); del.title = "Delete character";
del.onclick = async (e) => { e.stopPropagation(); if (confirm(`Delete character “${c.name}”?`)) await S.deleteCharacter(c.id); };
const actions = el("div", "char-actions"); actions.append(edit, del); row.append(actions);
list.append(row);
});
if (!items.length) list.append(el("div", "pj-meta", (st.characters || []).length ? "No match." : "No characters yet — create one."));
wrap.append(list);
return wrap;
}
// ── window + tabs ────────────────────────────────────────────────────────────────
const TABS = ["Characters", "Shortcuts", "Splits"];
const TABS = ["Compose", "Shortcuts", "Splits"];
function render() {
if (!win) return;
const st = S.get();
composeTextarea = null;
const root = clear(win.body);
const shell = el("div", "composer-shell");
const tabs = el("div", "bin-tabs composer-tabs");
TABS.forEach((name) => {
const b = el("button", "bin-tab" + (tab === name ? " active" : ""), name);
b.title = name === "Splits" ? "Split markers (generation prompt)" : name;
b.onclick = () => { if (tab === name) return; tab = name; editCharacter = null; closeModal(); render(); };
b.title = name === "Splits" ? "Split markers (generation prompt)"
: name === "Compose" ? "Global prompt — the whole montage" : name;
b.onclick = () => { if (tab === name) return; tab = name; closeModal(); render(); };
tabs.append(b);
});
shell.append(tabs);
const scroll = el("div", "composer-scroll");
scroll.append(
tab === "Characters" ? charactersTab(st)
tab === "Compose" ? composeTab(st)
: tab === "Shortcuts" ? shortcutsTab(st)
: splitMarkersTab(st),
);
@@ -344,19 +275,27 @@
function registerButton(btn) { _btn = btn; btn.onclick = toggle; _syncButton(); }
function _syncButton() { if (_btn) _btn.classList.toggle("on", isOpen()); }
// Re-render on relevant store changes (characters/shortcuts/splits/selection).
// Re-render on relevant store changes (shortcuts / splits / project switch).
let lastFp = null;
S.subscribe((st) => {
if (!isOpen()) return;
const fp = JSON.stringify({
c: st.characters?.length, s: st.shortcuts?.length, t: st.transitions?.length,
sel: st.selectedSceneId, pid: st.project?.id,
s: st.shortcuts?.length, t: st.transitions?.length, pid: st.project?.id,
});
if (fp === lastFp) return;
lastFp = fp;
render();
});
// Keep the Compose textarea in sync with timeline-driven global-prompt changes,
// but never clobber what the user is actively typing.
window.addEventListener("funpack-global-prompt-updated", (e) => {
if (tab !== "Compose" || !composeTextarea) return;
if (document.activeElement === composeTextarea) return;
gpDraft = null;
composeTextarea.value = (e.detail && e.detail.text) || "";
});
window.Composer = { toggle, isOpen, registerButton };
// Self-wire the header button (present in index.html before this script runs).
+3 -3
View File
@@ -48,9 +48,6 @@
<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">
<button class="composer-btn" id="composer-btn" type="button" title="Open the Composer — characters, shortcuts, split markers">
<span class="composer-dot">◆</span> Composer
</button>
<span class="save-chip" id="save-chip"></span>
<span class="health-chip" id="health-chip"></span>
</div>
@@ -92,6 +89,9 @@
<div class="zone-head-right">
<span id="timeline-meta"></span>
<nav class="dock-tabs" id="dock-tabs" aria-label="Workspace panels"></nav>
<button class="composer-btn" id="composer-btn" type="button" title="Open the Composer — global prompt, shortcuts, split markers">
<span class="composer-dot">◆</span> Composer
</button>
</div>
</div>
<div class="zone-body" id="timeline-body"></div>
+1 -147
View File
@@ -14,28 +14,6 @@
["mixed", "Mixed · Img2Video anchor + prior guides"],
];
function splitMarkerSelect(value, onChange, opts) {
opts = opts || {};
const sel = el("select");
const noneLabel = opts.noneLabel || "— default cut —";
const none = el("option", null, noneLabel); 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 = () => onChange(sel.value);
sel.title = opts.title || "Prompt marker: how Studio splits the montage before the next scene when generating";
return sel;
}
// Legacy alias used in a few call sites
function transitionSelect(value, onChange) {
return splitMarkerSelect(value, onChange);
}
function field(labelText, control) {
const l = el("label", "field"); l.append(el("span", null, labelText)); l.append(control); return l;
}
@@ -213,7 +191,6 @@
const ref = scene.source?.media_ref;
const asset = ref ? (st.mediaBin || []).find((m) => m.id === ref) : null;
body.append(field("Source", el("span", null, asset?.name || ref || "From last render")));
renderOutgoingSeam(st, scene);
foldSection("More editing", false, (more) => {
const fx = scene.effects || {};
@@ -291,9 +268,7 @@
(lenDrift
? `Rendered ≈ ${shownDur.toFixed(2)}s · plan ${planDur.toFixed(2)}s — Regenerate to apply`
: `Duration ≈ ${planDur.toFixed(2)}s`)
+ ` · trim on timeline · generation splits: global prompt or Outgoing seam below`));
renderOutgoingSeam(st, scene);
+ ` · trim on timeline · generation splits live in the global prompt (Composer)`));
const actions = el("div", "insp-block");
const genBtn = el("button", "btn primary", "Generate this scene");
@@ -302,7 +277,6 @@
body.append(actions);
foldSection("More editing", false, (more) => {
if (PC()?.usesFunpackStudio(st)) renderSceneCharacters(st, scene, more);
const fxTag = el("div", "insp-tag"); fxTag.textContent = "Video effects"; more.append(fxTag);
const fx = scene.effects || {};
@@ -415,87 +389,6 @@
});
}
const VIDEO_TRANSITIONS = [
["", "Hard cut"],
["crossfade", "Dissolve"],
["fadeblack", "Fade through black"],
["wipeleft", "Wipe left"],
["wiperight", "Wipe right"],
];
function renderOutgoingSeam(st, scene) {
const p = st.project;
const fps = scene.fps || p.frame_rate || 24;
const type = (scene.video_transition || "").trim();
const frames = scene.transition_frames > 0 ? scene.transition_frames : 16;
const tag = el("div", "insp-tag"); tag.textContent = "Outgoing seam"; body.append(tag);
body.append(el("div", "insp-hint",
"Video blend on the cut to the next clip — drag the colored edge on the timeline to set length."));
const typeSel = el("select");
VIDEO_TRANSITIONS.forEach(([v, lbl]) => {
const o = el("option", null, lbl); o.value = v;
if (v === type) o.selected = true;
typeSel.append(o);
});
typeSel.onchange = () => {
const next = typeSel.value;
if (!next) S.patchScene(scene.id, { video_transition: "", transition_frames: null });
else S.patchScene(scene.id, { video_transition: next, transition_frames: frames });
};
body.append(field("Video transition", typeSel));
const fr = el("input"); fr.type = "number"; fr.min = "1"; fr.max = "120"; fr.step = "1";
fr.dataset.k = "sc-blend-frames";
fr.value = type ? frames : "";
fr.disabled = !type;
fr.placeholder = type ? "" : "—";
fr.oninput = () => {
if (!type) return;
S.patchScene(scene.id, { transition_frames: Math.max(1, parseInt(fr.value, 10) || 16) });
};
body.append(field(`Blend length (frames · ${fps} fps)`, fr));
body.append(field("Generation split marker", splitMarkerSelect(scene.transition_to_next || "",
(v) => S.patchScene(scene.id, { transition_to_next: v }),
{ noneLabel: "— default cut —",
title: "Prompt marker before the next scene when Studio splits the montage (not the video blend)" })));
const gapIn = el("input");
gapIn.type = "number";
gapIn.min = "0";
gapIn.step = "0.05";
gapIn.value = scene.gap_after_sec || 0;
gapIn.oninput = () => S.patchSceneQuiet(scene.id, {
gap_after_sec: Math.max(0, parseFloat(gapIn.value || "0")),
});
body.append(field("Pause before next clip (s)", gapIn));
body.append(el("div", "insp-hint",
"Drag a clip right on the timeline to open space before it. Drag back flush with the previous clip to remove the pause."));
}
function renderSceneCharacters(st, scene, parent) {
parent = parent || body;
const ids = S.sceneCharacterIds(scene.id);
const tag = el("div", "insp-tag"); tag.textContent = "Characters"; parent.append(tag);
const chips = el("div", "char-chips");
if (!ids.length) {
chips.append(el("span", "char-chip empty", "None — assign in Characters bin"));
} else {
ids.forEach((cid) => {
const c = (st.characters || []).find((x) => x.id === cid);
const chip = el("span", "char-chip");
chip.textContent = c?.name || cid;
const rm = el("button", "char-chip-rm", "✕");
rm.onclick = () => S.toggleSceneCharacter(scene.id, cid);
chip.append(rm);
chips.append(chip);
});
}
parent.append(chips);
}
function slotLabelFor(st, slotId) {
if (!slotId || slotId === "funpack") return null;
const slot = (st.models?.slots || []).find((s) => s.id === slotId);
@@ -552,9 +445,6 @@
anchor.placeholder = "World / setting context prepended to every scene";
anchor.oninput = () => S.patchProjectQuiet({ anchor: anchor.value });
body.append(field("Anchor", anchor));
body.append(field("Split before scene 1 (generation prompt)", splitMarkerSelect(p.intro_transition || "",
(v) => S.patchProject({ intro_transition: v }),
{ noneLabel: "— default cut —", title: "Prompt marker between anchor and scene 1 when Studio splits a long montage" })));
const neg = el("textarea"); neg.rows = 2; neg.value = p.negative_prompt || ""; neg.dataset.k = "pj-neg";
neg.placeholder = "What to avoid in every scene";
neg.oninput = () => S.patchProjectQuiet({ negative_prompt: neg.value });
@@ -785,31 +675,6 @@
body.append(wrap);
}
// Pinned global-prompt editor (always on top). Edits debounce into the timeline via store.
let gpDraft = null;
let gpProjectId = null;
function renderGlobalPrompt(st) {
if (st.project.id !== gpProjectId) { gpProjectId = st.project.id; gpDraft = null; }
const pv = st.preview;
const live = st.project.global_prompt
|| (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt))
|| "";
const dirty = gpDraft != null && gpDraft !== live;
const val = gpDraft != null ? gpDraft : live;
const sec = el("div", "insp-global");
sec.append(el("div", "insp-global-title", "Global prompt"));
const ta = el("textarea", "insp-global-ta"); ta.rows = 3; ta.value = val; ta.dataset.k = "global-prompt";
ta.placeholder = "Anchor, scene texts, and split markers — one combined montage prompt for generation.";
ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); };
sec.append(ta);
sec.append(el("div", "insp-hint", dirty
? "Applying to timeline…"
: "Edits apply automatically. Stays in sync with per-scene prompts. Per-seam split markers: Outgoing seam in the scene inspector."));
body.append(sec);
}
function renderSwitch(st, scene) {
const sw = el("div", "insp-switch");
const mk = (label, active, on, disabled) => {
@@ -955,7 +820,6 @@
if (!st.project) { title.textContent = "Inspector"; body.append(el("div", "pj-meta", "No project open.")); return; }
const ov = st.selectedOverlayId ? S.overlayTrack(st.selectedOverlayId) : null;
const scene = !ov && st.selectedSceneId ? S.scene(st.selectedSceneId) : null;
renderGlobalPrompt(st);
renderSwitch(st, scene);
renderEngineStrip(st);
if (ov) renderOverlayInspector(st, ov);
@@ -992,16 +856,6 @@
}, 60);
});
window.addEventListener("funpack-invalidate-global-prompt", () => { gpDraft = null; });
window.addEventListener("funpack-global-prompt-updated", (e) => {
if (_editing && document.activeElement?.dataset?.k === "global-prompt") return;
gpDraft = null;
const ta = body.querySelector('[data-k="global-prompt"]');
if (ta && e.detail?.text != null) ta.value = e.detail.text;
const hint = body.querySelector(".insp-global .insp-hint");
if (hint) hint.textContent = "Edits apply automatically. Stays in sync with per-scene prompts. Per-seam split markers: Outgoing seam in the scene inspector.";
});
if (window.ViewBus) window.ViewBus.subscribeInspector(render);
else S.subscribe(render);
})();
+2
View File
@@ -1513,4 +1513,6 @@ body.tour-mode { overflow: hidden; }
.composer-shell { display: flex; flex-direction: column; width: 100%; min-height: 0; }
.composer-tabs { padding: 10px 12px 0; flex: 0 0 auto; }
.composer-scroll { flex: 1; min-height: 0; overflow: auto; padding: 12px; }
.compose-bin { display: flex; flex-direction: column; gap: 8px; height: 100%; }
.compose-ta { flex: 1; min-height: 220px; resize: none; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55; }
+1 -1
View File
@@ -448,7 +448,7 @@
id: "inspector-scene",
title: "Scene inspector",
target: "#inspector-body",
body: "Edit the prompt, source type (Empty, Image, Carry), transitions, and characters for the selected scene. Deselect all clips to edit project anchor, seed, and global timing.",
body: "Edit the prompt and source type (Empty, Image, Carry) for the selected scene. Deselect all clips to edit project anchor, seed, and global timing. The whole-montage global prompt lives in the Composer.",
pad: 8,
},
{