From 7376de0b3ed0826cea335bee768d7cb7fab2d2b8 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:43:54 +0300 Subject: [PATCH] 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 --- app/ui/composer/elements/gallery.js | 3 +- app/ui/features/composer/index.js | 16 +++++---- app/ui/features/composer/story.js | 50 +++++++++++++++++--------- app/ui/features/composer/story.test.js | 27 +++++++++++++- app/ui/features/preview/index.js | 7 ++-- app/ui/features/timeline/index.js | 1 + 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/app/ui/composer/elements/gallery.js b/app/ui/composer/elements/gallery.js index 2fa17119..d4a5df20 100644 --- a/app/ui/composer/elements/gallery.js +++ b/app/ui/composer/elements/gallery.js @@ -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(); }, }; }); diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index 78db514f..7105e0f2 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 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." }) }), ] }); diff --git a/app/ui/features/composer/story.js b/app/ui/features/composer/story.js index 65fc8a78..0e12806e 100644 --- a/app/ui/features/composer/story.js +++ b/app/ui/features/composer/story.js @@ -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(`(? 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; } diff --git a/app/ui/features/composer/story.test.js b/app/ui/features/composer/story.test.js index 66fdab74..30714369 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, 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); +}); diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js index 7129e4d1..1e7b9bff 100644 --- a/app/ui/features/preview/index.js +++ b/app/ui/features/preview/index.js @@ -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(); }; }, }; diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index 0bc9a625..ff550e62 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -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;