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:
co-authored by
Claude Opus 4.8
parent
499a68abde
commit
98a76a94f1
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user