From fc53758f6f642a9aab3d2697af5d28a8ef33ce6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:41:29 +0000 Subject: [PATCH] Story: scenes are followed by id, not by their prompt text The Story box remembers which scene each part showed; an edited and moved scene keeps its render and anchor, and identical prompts stay separate scenes. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tw9fjXAUznBpkWSZm9vtPL --- app/ui/features/composer/index.js | 8 +++++--- app/ui/features/composer/story.js | 24 ++++++++++++++++++++---- app/ui/features/composer/story.test.js | 25 ++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index 9f0885f9..ffee2ec0 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -1,6 +1,6 @@ // ◆ Composer: the floating "prompt craft" window. Opens from the timeline header; its tabs are the ones v4 had. import { composer as c } from "../../composer/composer.js"; -import { applyStory, clash, joinStory } from "./story.js"; +import { applyStory, clash, joinStory, shownStory } from "./story.js"; import { shortcuts } from "./shortcuts.js"; import { cuts } from "./cuts.js"; import { enhance } from "./enhance.js"; @@ -14,7 +14,7 @@ const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: " // 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", words = ["qcut"], timer = 0, asked = 0, inFlight = Promise.resolve(); + let shown = null, marker = "qcut", words = ["qcut"], timer = 0, asked = 0, inFlight = Promise.resolve(); 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, warn = c.hint.default({ text: "" }); function apply(text) { return (inFlight = split(text)); } @@ -24,7 +24,8 @@ const story = (app, own) => { try { const { scenes } = await app.api.storySplit(text); if (mine !== asked || !p.project || p.project.id !== pid) return; // typed more meanwhile, or another project opened: not for this one - p.edit((pr) => applyStory(pr, scenes)); + p.edit((pr) => applyStory(pr, scenes, shown)); + if (p.project) shown = shownStory(p.project); } catch (err) { c.toast.warn({ text: `Could not split the story: ${err.message}` }); } } app.api.storyMarkers().then((r) => { words = r.markers || words; marker = words[0]; sync(); }).catch(() => {}); @@ -33,6 +34,7 @@ const story = (app, own) => { if (!p.project || app.dom.focusOn(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); + shown = shownStory(p.project); } own(app.on(sync)); // Generate runs what the box shows: text still waiting for its pause, or still being split, lands first. diff --git a/app/ui/features/composer/story.js b/app/ui/features/composer/story.js index 30da9bf3..2b21e18f 100644 --- a/app/ui/features/composer/story.js +++ b/app/ui/features/composer/story.js @@ -18,8 +18,12 @@ export const storyRoots = (p) => { 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. */ +const words = (t) => new Set(String(t).toLowerCase().match(/[\p{L}\p{N}]+/gu) || []); +const likeness = (a, b) => { const x = words(a), y = words(b); let n = 0; for (const w of x) if (y.has(w)) n += 1; return x.size + y.size - n ? n / (x.size + y.size - n) : 0; }; + +/** 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 closest wording among the rest (a scene that was edited AND moved), 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) => { @@ -28,11 +32,19 @@ export function match(parts, roots) { 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; } }); + const pairs = []; + parts.forEach((t, i) => { if (out[i] < 0 && t) roots.forEach((r, ri) => { if (!used[ri]) { const s = likeness(t, r); if (s >= 0.3) pairs.push({ i, ri, s }); } }); }); + pairs.sort((a, b) => b.s - a.s || Math.abs(a.ri - a.i) - Math.abs(b.ri - b.i)); + for (const { i, ri } of pairs) if (out[i] < 0 && !used[ri]) { out[i] = ri; used[ri] = 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; } +/** What the person is looking at in the Story: each shown scene's id and text. A scene is followed by its id through + * edits and moves; its words alone never decide who it is (two scenes can say the same, and a scene can be rewritten). */ +export const shownStory = (p) => storyRoots(p).map((r) => ({ id: r.id, text: r.text || "" })); + /** The first scene whose own text holds a cut word: the story would show it as two scenes. */ export const clash = (p, words) => { if (!words.length) return null; @@ -43,11 +55,15 @@ export const clash = (p, words) => { /** Make the plan say `parts` (the story split at its markers). Roots that no part owns leave the plan WITH their cuts (a * rendered one keeps a ghost); parts nothing owns become new scenes. Plan order only moves when the parts were reordered. * -> true when anything changed. */ -export function applyStory(p, parts) { +export function applyStory(p, parts, shown = null) { if (!parts.some((t) => String(t).trim())) return false; // an emptied box is not an instruction to delete everything const before = JSON.stringify(p); const roots = storyRoots(p); - const owner = match(parts.map(norm), roots.map((r) => norm(r.text))); + // Parts are matched against what the box showed (its ids), then each hit is turned back into the scene with that id. + // A shown scene that is gone from the plan (removed elsewhere meanwhile) owns nothing. + const base = shown && shown.length ? shown : shownStory(p); + const hit = match(parts.map(norm), base.map((r) => norm(r.text))); + const owner = hit.map((k) => (k < 0 ? -1 : roots.findIndex((r) => r.id === base[k].id))); const unitOf = (r) => p.scenes.filter((s) => genUnitId(s) === genUnitId(r)); roots.forEach((r, i) => { if (!owner.includes(i)) unitOf(r).sort((a, b) => (b.cut_offset_frames || 0) - (a.cut_offset_frames || 0)).forEach((s) => removeScene(p, s.id)); }); const kept = parts.map((_, i) => (owner[i] >= 0 ? roots[owner[i]] : null)); diff --git a/app/ui/features/composer/story.test.js b/app/ui/features/composer/story.test.js index 1d2548ad..e4bcfc09 100644 --- a/app/ui/features/composer/story.test.js +++ b/app/ui/features/composer/story.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { applyStory, clash, joinStory, match, storyRoots } from "./story.js"; +import { applyStory, clash, joinStory, match, shownStory, 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}`); @@ -100,3 +100,26 @@ test("a first scene typed gradually stays first (cut word added, then the second applyStory(p, ["one", "two", "three"]); assert.deepEqual(shown(p), ["one", "two", "three"]); }); + +test("scenes are followed by id: edited AND moved scenes keep their render and anchor", () => { + const p = { scenes: [{ id: "a", text: "a red fox runs through snow" }, { id: "b", text: "a blue boat drifts on a calm lake" }], scene_renders: { a: { media: { filename: "a.mp4" } }, b: { media: { filename: "b.mp4" } } }, scene_ghosts: [] }; + const shown = shownStory(p); + assert.equal(applyStory(p, ["a blue boat sails on a calm lake at dusk", "a red fox leaps through deep snow"], shown), true); + assert.deepEqual(p.scenes.map((s) => s.id), ["b", "a"]); + assert.equal(p.scenes[0].text, "a blue boat sails on a calm lake at dusk"); + assert.deepEqual(p.scene_ghosts, []); +}); + +test("two scenes with the same words stay two scenes with their own ids; a changed neighbour does not shift them", () => { + const p = { scenes: [{ id: "a", text: "same" }, { id: "b", text: "same" }, { id: "c", text: "other" }], scene_renders: {}, scene_ghosts: [] }; + assert.equal(applyStory(p, ["same", "same", "other words entirely"], shownStory(p)), true); + assert.deepEqual(p.scenes.map((s) => `${s.id}:${s.text}`), ["a:same", "b:same", "c:other words entirely"]); +}); + +test("the shown list, not the plan, decides who a part is when the plan moved meanwhile", () => { + const p = proj(); + const shown = shownStory(p); + p.scenes = [p.scenes[1], p.scenes[0], p.scenes[2], p.scenes[3]]; // reordered elsewhere + applyStory(p, ["one!", "two", "three"], shown); + assert.deepEqual(Object.fromEntries(p.scenes.map((s) => [s.id, s.text])), { a: "one!", b: "two", v: "", c: "three" }); // the ids keep their words; the order is what the box showed +});