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:
co-authored by
Claude Sonnet 5.5
parent
7fe4d12312
commit
7376de0b3e
@@ -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(); },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user