From bf480e4a15e93f9393bf2d4d2881675cfe864cb0 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:31:58 +0300 Subject: [PATCH] feat(ui): Composer Story tab edits the scenes as one text cut at marker words Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/composer/index.js | 48 +++++++++++++++----- app/ui/features/composer/story.js | 61 ++++++++++++++++++++++++++ app/ui/features/composer/story.test.js | 53 ++++++++++++++++++++++ app/ui/shell/api.js | 2 + 4 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 app/ui/features/composer/story.js create mode 100644 app/ui/features/composer/story.test.js diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index b0f89815..265b02b9 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -1,17 +1,41 @@ // ◆ Composer: the floating "prompt craft" window. Opens from the timeline header; its tabs are the five v4 had. import { composer as c } from "../../composer/composer.js"; +import { applyStory, joinStory } from "./story.js"; const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }]; const inert = (label, tone = "ghost") => c.button.sm({ label, tone, disabled: true }); const later = (what) => c.emptyState.default({ icon: "◌", title: "Not built yet", hint: what }); -const story = (p) => c.region.stack({ gap: "sm", children: [ - c.toolbar.default({ items: [c.select.sm({ label: "Templates", disabled: true, options: [{ value: "", label: "Templates…" }], value: "" })], trailing: [inert("Save")] }), - c.toolbar.default({ items: [c.label.section({ text: "Story" })], trailing: [inert("+ Add shortcut"), inert("💡")] }), - c.textarea.md({ label: "Story", rows: 14, disabled: true, value: p.scenes.map((s) => s.text || "").join("\n\n") }), - c.hint.default({ text: "Coming soon: edit all scenes as one story here." }), - c.collapsible.default({ label: "+ Variables", body: c.hint.default({ text: "$name → text, filled in at generation." }) }), -] }); +// The box follows the scenes; typing in it rewrites them (after a short pause, or when focus leaves). +const story = (app, own) => { + const p = app.project; + let marker = "qcut", timer = 0, asked = 0; + const box = c.textarea.md({ label: "Story", rows: 14, value: p.project ? joinStory(p.project, marker) : "", onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => { timer = 0; apply(v); }, 700); }, onCommit: (v) => { clearTimeout(timer); timer = 0; apply(v); } }); + const area = box.node; + async function apply(text) { + if (!p.project) return; + const mine = ++asked; + try { + const { scenes } = await app.api.storySplit(text); + if (mine !== asked) return; // typed more meanwhile: the newer text wins + p.edit((pr) => applyStory(pr, scenes)); + } catch (err) { c.toast.warn({ text: `Could not split the story: ${err.message}` }); } + } + app.api.storyMarkers().then((r) => { marker = (r.markers || [marker])[0]; sync(); }).catch(() => {}); + function sync() { + if (!p.project || document.activeElement === area || timer) return; // never rewrite the box while it is being typed in + const next = joinStory(p.project, marker); + if (area.value !== next) box.setValue(next); + } + own(app.on(sync)); + return c.region.stack({ gap: "sm", children: [ + c.toolbar.default({ items: [c.select.sm({ label: "Templates", disabled: true, options: [{ value: "", label: "Templates…" }], value: "" })], trailing: [inert("Save")] }), + c.toolbar.default({ items: [c.label.section({ text: "Story" })], trailing: [inert("+ Add shortcut"), inert("💡")] }), + box, + c.hint.default({ text: `Scenes are cut at the word “${marker}”. Edits apply to the scenes as you type; the anchor is its own field.` }), + c.collapsible.default({ label: "+ Variables", body: c.hint.default({ text: "$name → text, filled in at generation." }) }), + ] }); +}; const sheets = { story, shortcuts: () => later("Your trigger → replacement library."), cuts: () => later("Where a story splits into shots."), enhance: () => later("Rewrite a prompt with a language model."), chat: () => later("Talk a scene through with the enhancer.") }; @@ -19,17 +43,19 @@ const sheets = { story, shortcuts: () => later("Your trigger → replacement lib export default { id: "composer", mount: "timeline.status", - needs: ["project"], + needs: ["project", "api"], setup({ host, app }) { - let win = null; + let win = null, owned = []; + const cleanup = () => { owned.forEach((f) => f()); owned = []; }; const open = () => { if (win) return; const body = c.region.stack({ gap: "sm", fill: true }); - const show = (tab) => body.set([c.tabs.underline({ label: "Composer", tabs: TABS, value: tab, onChange: show }), sheets[tab](app.project)]); + const show = (tab) => { cleanup(); body.set([c.tabs.underline({ label: "Composer", tabs: TABS, value: tab, onChange: show }), sheets[tab](app, (off) => owned.push(off))]); }; show("story"); win = c.floating.window({ id: "composer", title: "Composer", subtitle: "prompt craft", body, width: 420, height: 520, x: 320, y: 90, - onClose: () => { win = null; } }); + onClose: () => { cleanup(); win = null; } }); }; host.append(c.button.sm({ label: "◆ Composer", tone: "neutral", onClick: open }).node); + return cleanup; }, }; diff --git a/app/ui/features/composer/story.js b/app/ui/features/composer/story.js new file mode 100644 index 00000000..65fc8a78 --- /dev/null +++ b/app/ui/features/composer/story.js @@ -0,0 +1,61 @@ +// The Story: every generated scene's prompt as one box, a marker word between scenes. Pure half (applyStory) here; +// the box itself is in index.js. Scene.text stays the truth; the story is a second way to edit it. +import { removeScene } from "../../shell/edits.js"; +import { genUnitId, isGenerative, isSubclip } from "../../shell/scenes.js"; + +const norm = (t) => String(t || "").replace(/\s+/g, " ").trim(); +const newId = () => "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); + +/** The scenes the story shows: one root per generation unit (never video clips, cuts or left-out scenes), in plan order. */ +export const storyRoots = (p) => { + const seen = new Set(), out = []; + for (const sc of p.scenes || []) { + if (sc.excluded || !isGenerative(sc) || isSubclip(sc) || seen.has(genUnitId(sc))) continue; + seen.add(genUnitId(sc)); out.push(sc); + } + return out; +}; + +export const joinStory = (p, marker = "qcut") => storyRoots(p).map((s) => (s.text || "").trim()).join(`\n${marker}\n`); + +/** For each new part, the index of the old root that owns its content (-1: none): exact text first, nearest position + * breaking ties, then the leftovers in order. A reorder or insert keeps every unchanged scene's render and anchor. */ +export function match(parts, roots) { + const out = parts.map(() => -1), used = roots.map(() => false); + parts.forEach((t, i) => { + if (!t) return; + let best = -1; + roots.forEach((r, ri) => { if (!used[ri] && r === t && (best < 0 || Math.abs(ri - i) < Math.abs(best - i))) best = ri; }); + if (best >= 0) { out[i] = best; used[best] = true; } + }); + let at = 0; + parts.forEach((_, i) => { if (out[i] >= 0) return; while (at < roots.length && used[at]) at += 1; if (at < roots.length) { out[i] = at; used[at] = true; } }); + return out; +} + +/** Make the plan say `parts` (the story split at its markers). Roots that no part owns leave the plan (a rendered one + * keeps a ghost); parts nothing owns become new scenes. -> true when anything changed. */ +export function applyStory(p, parts) { + if (!parts.length) return false; + const before = JSON.stringify(p); + const roots = storyRoots(p); + const owner = match(parts.map(norm), roots.map((r) => norm(r.text))); + roots.forEach((r, i) => { if (!owner.includes(i)) removeScene(p, r.id); }); + const group = (r) => p.scenes.filter((s) => genUnitId(s) === genUnitId(r)); + const groups = parts.map((text, i) => { + if (owner[i] >= 0) { const r = roots[owner[i]]; if (norm(r.text) !== norm(text)) r.text = text; return group(r); } + return [{ id: newId(), text, transition_to_next: "", source: { type: "carry" }, excluded: false, frames_mode: "project", fps_mode: "project" }]; + }); + // The story's scenes take the slots the story's scenes held (video clips and left-out scenes stay where they were), in the parts' order. + const mine = new Set(groups.flat().map((s) => s.id)), keep = new Set(roots.filter((_, i) => owner.includes(i)).flatMap(group).map((s) => s.id)); + const out = [], fill = groups.flat(); + let queued = 0; + for (const s of p.scenes) { if (keep.has(s.id)) { if (queued < fill.length) out.push(fill[queued++]); } else out.push(s); } + out.push(...fill.slice(queued)); + p.scenes = out; + if (p.timeline_manually_ordered && Array.isArray(p.timeline_order)) { + const have = new Set(p.timeline_order); + p.timeline_order = p.timeline_order.filter((id) => out.some((s) => s.id === id)).concat(out.filter((s) => mine.has(s.id) && !have.has(s.id)).map((s) => s.id)); + } + return JSON.stringify(p) !== before; +} diff --git a/app/ui/features/composer/story.test.js b/app/ui/features/composer/story.test.js new file mode 100644 index 00000000..66fdab74 --- /dev/null +++ b/app/ui/features/composer/story.test.js @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyStory, joinStory, match, storyRoots } from "./story.js"; + +const proj = () => ({ scenes: [{ id: "a", text: "one" }, { id: "b", text: "two" }, { id: "v", text: "", source: { type: "video", media_ref: "m" } }, { id: "c", text: "three" }], scene_renders: {}, scene_ghosts: [] }); +const texts = (p) => p.scenes.map((s) => `${s.id}:${s.text}`); + +test("the story is the generated roots' texts with the marker between them; video clips and left-out scenes are not in it", () => { + const p = proj(); p.scenes[3].excluded = true; + assert.deepEqual(storyRoots(p).map((s) => s.id), ["a", "b"]); + assert.equal(joinStory(p, "qcut"), "one\nqcut\ntwo"); +}); + +test("match: exact text pins a scene through a reorder; the rest take the leftovers in order", () => { + assert.deepEqual(match(["three", "one", "new"], ["one", "two", "three"]), [2, 0, 1]); + assert.deepEqual(match(["x", "two"], ["one", "two"]), [0, 1]); + assert.deepEqual(match(["a", "b", "c"], ["a"]), [0, -1, -1]); +}); + +test("editing a scene's words keeps the scene (its id, so its render and anchor)", () => { + const p = proj(); + assert.equal(applyStory(p, ["one!", "two", "three"]), true); + assert.deepEqual(texts(p), ["a:one!", "b:two", "v:", "c:three"]); +}); + +test("a dropped part removes its scene (a rendered one leaves a ghost); the video clip stays", () => { + const p = proj(); p.scene_renders.b = { media: { filename: "b.mp4" } }; + assert.equal(applyStory(p, ["one", "three"]), true); + assert.deepEqual(texts(p), ["a:one", "v:", "c:three"]); + assert.deepEqual(p.scene_ghosts.map((g) => g.id), ["b"]); +}); + +test("a part nothing owns becomes a new scene at the end of the story", () => { + const p = proj(); + assert.equal(applyStory(p, ["one", "two", "three", "four"]), true); + assert.deepEqual(p.scenes.map((s) => s.text), ["one", "two", "", "three", "four"]); +}); + +test("a part with new words takes over the leftover scene in place, so its render and anchor are kept", () => { + const p = proj(); + applyStory(p, ["one", "extra", "three"]); + assert.deepEqual(texts(p), ["a:one", "b:extra", "v:", "c:three"]); +}); + +test("a reordered story reorders the plan; an unchanged story changes nothing", () => { + const p = proj(); + assert.equal(applyStory(p, ["three", "one", "two"]), true); + assert.deepEqual(p.scenes.map((s) => s.id), ["c", "a", "v", "b"]); // the video clip keeps its slot + const q = proj(); + assert.equal(applyStory(q, ["one", "two", "three"]), false); +}); + +test("an empty split changes nothing", () => assert.equal(applyStory(proj(), []), false)); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 9775ec63..f06e2f81 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -25,6 +25,8 @@ export const api = { releaseModule: (id) => call("POST", "/api/control/release", { id }), describeNodes: (classes) => call("GET", `/api/nodes?classes=${encodeURIComponent(classes.join(","))}`), system: () => call("GET", "/api/system"), + storyMarkers: () => call("GET", "/api/story/markers"), + storySplit: (text) => call("POST", "/api/story/split", { text }), packs: () => call("GET", "/api/packs"), pack: (action, body) => call("POST", `/api/packs/${action}`, body || {}), git: (action, body) => call("POST", `/api/git/${action}`, body || {}),