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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjXAUznBpkWSZm9vtPL
This commit is contained in:
Claude
2026-11-03 13:41:29 +00:00
parent 33c21c0bc3
commit fc53758f6f
3 changed files with 49 additions and 8 deletions
+5 -3
View File
@@ -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.
+20 -4
View File
@@ -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));
+24 -1
View File
@@ -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
});