fix(ui): Story keeps plan order and removes whole cut units, refuses an emptied box, warns on cut-word clashes; preview catches short clips' ends, resets on project open, releases videos

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 22:43:54 +03:00
co-authored by Claude Sonnet 5.5
parent 7fe4d12312
commit 7376de0b3e
6 changed files with 76 additions and 28 deletions
+2 -1
View File
@@ -288,6 +288,7 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
current = next || null;
currentKind = nextKind;
currentFile = current ? file : null;
if (mediaEl && mediaEl.tagName === "VIDEO") { mediaEl.removeAttribute("src"); mediaEl.load(); } // a video left behind must give its connection back
mediaEl = null;
stage.replaceChildren();
if (!current) {
@@ -339,6 +340,6 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
canvas.getContext("2d").drawImage(mediaEl, 0, 0);
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
},
destroy: () => node.remove(),
destroy: () => { show(null); node.remove(); },
};
});
+9 -7
View File
@@ -1,6 +1,6 @@
// ◆ 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";
import { applyStory, clash, joinStory } from "./story.js";
import { shortcuts } from "./shortcuts.js";
import { cuts } from "./cuts.js";
import { enhance } from "./enhance.js";
@@ -12,29 +12,31 @@ const later = (what) => c.emptyState.default({ icon: "◌", title: "Not built ye
// 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;
let marker = "qcut", words = ["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;
const area = box.node, warn = c.hint.default({ text: "" });
async function apply(text) {
if (!p.project) return;
const mine = ++asked;
const mine = ++asked, pid = p.project.id;
try {
const { scenes } = await app.api.storySplit(text);
if (mine !== asked) return; // typed more meanwhile: the newer text wins
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));
} 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(() => {});
app.api.storyMarkers().then((r) => { words = r.markers || words; marker = words[0]; sync(); }).catch(() => {});
function sync() {
warn.setText(p.project && clash(p.project, words) ? `A scene's own text contains a cut word (${words.join(", ")}), so the story shows it as two scenes. Remove the word from that scene.` : "");
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));
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,
box, warn,
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." }) }),
] });
+33 -17
View File
@@ -33,29 +33,45 @@ export function match(parts, roots) {
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. */
/** 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;
const re = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${words.map((w) => w.trim().split(/\s+/).map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")).join("|")})(?![\\p{L}\\p{N}_])`, "iu");
return storyRoots(p).find((s) => re.test(s.text || "")) || null;
};
/** 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) {
if (!parts.length) return false;
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)));
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;
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));
kept.forEach((r, i) => { if (r && norm(r.text) !== norm(parts[i])) unitOf(r).forEach((s) => { if (!isSubclip(s)) s.text = parts[i]; }); }); // a cut's first piece shares the prompt
const made = new Map();
parts.forEach((text, i) => { if (!kept[i]) made.set(i, { id: newId(), text, transition_to_next: "", source: { type: "carry" }, excluded: false, frames_mode: "project", fps_mode: "project" }); });
const owned = owner.filter((o) => o >= 0);
if (owned.some((o, k) => k && o < owned[k - 1])) { // the parts were reordered: whole units move, into the slots the story's scenes held
const fill = parts.flatMap((_, i) => (kept[i] ? unitOf(kept[i]) : [made.get(i)]));
const keep = new Set(kept.filter(Boolean).flatMap(unitOf).map((s) => s.id));
const out = []; let n = 0;
for (const s of p.scenes) { if (keep.has(s.id)) { if (n < fill.length) out.push(fill[n++]); } else out.push(s); }
p.scenes = out.concat(fill.slice(n)).filter((s, k, all) => all.indexOf(s) === k);
} else {
parts.forEach((_, i) => { // new scenes go right after the previous part's last scene
if (!made.has(i)) return;
const prev = parts.slice(0, i).map((__, k) => k).reverse().find((k) => kept[k] || made.has(k));
const anchor = prev === undefined ? -1 : p.scenes.indexOf(kept[prev] ? unitOf(kept[prev]).pop() : made.get(prev));
p.scenes.splice(anchor + 1, 0, made.get(i));
});
}
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));
p.timeline_order = p.timeline_order.filter((id) => p.scenes.some((s) => s.id === id)).concat([...made.values()].filter((s) => !have.has(s.id)).map((s) => s.id));
}
return JSON.stringify(p) !== before;
}
+26 -1
View File
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyStory, joinStory, match, storyRoots } from "./story.js";
import { applyStory, clash, 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}`);
@@ -51,3 +51,28 @@ test("a reordered story reorders the plan; an unchanged story changes nothing",
});
test("an empty split changes nothing", () => assert.equal(applyStory(proj(), []), false));
test("an emptied box does not delete the scenes", () => {
const p = proj(), before = JSON.stringify(p);
assert.equal(applyStory(p, [""]), false);
assert.equal(JSON.stringify(p), before);
});
test("editing one scene leaves the plan order alone, even with a montage or a cut unit interleaved", () => {
const p = { scenes: [{ id: "A", text: "alpha" }, { id: "B", text: "beta" }, { id: "a1", gen_unit_id: "A", cut_offset_frames: 40, text: "" }, { id: "b1", gen_unit_id: "B", cut_offset_frames: 40, text: "" }, { id: "a2", gen_unit_id: "A", cut_offset_frames: 0, text: "alpha" }], scene_renders: {}, scene_ghosts: [] };
applyStory(p, ["alpha!", "beta"]);
assert.deepEqual(p.scenes.map((s) => s.id), ["A", "B", "a1", "b1", "a2"]);
assert.equal(p.scenes.find((s) => s.id === "a2").text, "alpha!", "the first piece shares the prompt");
});
test("dropping a cut scene's paragraph removes the scene with its cuts instead of promoting a cut", () => {
const p = { scenes: [{ id: "A", text: "alpha" }, { id: "A2", gen_unit_id: "A", cut_offset_frames: 40, text: "" }, { id: "B", text: "beta" }], scene_renders: {}, scene_ghosts: [] };
assert.equal(applyStory(p, ["beta"]), true);
assert.deepEqual(p.scenes.map((s) => s.id), ["B"]);
});
test("clash finds a scene whose own text contains a cut word", () => {
const p = proj(); p.scenes[1].text = "a quick qcut to b";
assert.equal(clash(p, ["qcut"]).id, "b");
assert.equal(clash(p, ["other"]), null);
});
+5 -2
View File
@@ -65,6 +65,7 @@ export default {
}
scrub.input.max = String(total() || 1);
scrub.setValue(head.at);
scrub.input.style.setProperty("--fill", `${Math.min(100, (head.at / (total() || 1)) * 100)}%`); // the slider drew its fill for 0..1
time.setText(timecode(head.at, open() && open().frame_rate));
play.node.firstChild.textContent = playing ? "⏸" : "▶"; play.node.title = playing ? "Pause" : "Play";
if (playing && viewer.element) viewer.element.paused && viewer.element.play().catch(() => {});
@@ -74,7 +75,8 @@ export default {
const walk = setInterval(() => {
if (!playing) return;
const v = viewer.element;
if (!(seg && v && !v.paused)) head.set(head.at + 0.04);
if (seg && v && !v.paused) onTime(v); // timeupdate is too slow to catch a short clip's end
else head.set(head.at + 0.04);
if (head.at >= total()) { pause(); head.set(Math.min(head.at, total())); }
}, 40);
function start() { if (!total()) return; if (head.at >= total() - 0.02) head.set(0); playing = true; sync(); }
@@ -98,8 +100,9 @@ export default {
else if (what === "play.toggle") (playing ? pause() : start());
else if (what === "play.start") start();
else if (what === "play.pause") pause();
else if (what === "open") { pause(); head.set(0); } // another project: nothing carries over
else sync();
})];
return () => { clearInterval(walk); off.forEach((f) => f()); };
return () => { clearInterval(walk); off.forEach((f) => f()); viewer.destroy(); };
},
};
+1
View File
@@ -60,6 +60,7 @@ const tools = {
const put = inPlace(host);
const btn = (label, onClick, disabled, title) => c.button.sm({ label, onClick, disabled, title, tone: "ghost" }).node;
const split = () => { // the selected clip, at the playhead if it is over it, else in the middle
if (!p.project) return;
const seg = segments(p.project).find((s) => s.id === sel.focus && s.kind === "scene");
if (!seg) return;
const inside = head.at > seg.start && head.at < seg.start + seg.dur;