feat(ui): Composer Story tab edits the scenes as one text cut at marker words
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
86998abb23
commit
bf480e4a15
@@ -1,17 +1,41 @@
|
||||
// ◆ 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";
|
||||
|
||||
const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }];
|
||||
const inert = (label, tone = "ghost") => c.button.sm({ label, tone, disabled: true });
|
||||
const later = (what) => c.emptyState.default({ icon: "◌", title: "Not built yet", hint: what });
|
||||
|
||||
const story = (p) => 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("💡")] }),
|
||||
c.textarea.md({ label: "Story", rows: 14, disabled: true, value: p.scenes.map((s) => s.text || "").join("\n\n") }),
|
||||
c.hint.default({ text: "Coming soon: edit all scenes as one story here." }),
|
||||
c.collapsible.default({ label: "+ Variables", body: c.hint.default({ text: "$name → text, filled in at generation." }) }),
|
||||
] });
|
||||
// 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;
|
||||
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;
|
||||
async function apply(text) {
|
||||
if (!p.project) return;
|
||||
const mine = ++asked;
|
||||
try {
|
||||
const { scenes } = await app.api.storySplit(text);
|
||||
if (mine !== asked) return; // typed more meanwhile: the newer text wins
|
||||
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(() => {});
|
||||
function sync() {
|
||||
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));
|
||||
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,
|
||||
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." }) }),
|
||||
] });
|
||||
};
|
||||
|
||||
const sheets = { story, shortcuts: () => later("Your trigger → replacement library."), cuts: () => later("Where a story splits into shots."),
|
||||
enhance: () => later("Rewrite a prompt with a language model."), chat: () => later("Talk a scene through with the enhancer.") };
|
||||
@@ -19,17 +43,19 @@ const sheets = { story, shortcuts: () => later("Your trigger → replacement lib
|
||||
export default {
|
||||
id: "composer",
|
||||
mount: "timeline.status",
|
||||
needs: ["project"],
|
||||
needs: ["project", "api"],
|
||||
setup({ host, app }) {
|
||||
let win = null;
|
||||
let win = null, owned = [];
|
||||
const cleanup = () => { owned.forEach((f) => f()); owned = []; };
|
||||
const open = () => {
|
||||
if (win) return;
|
||||
const body = c.region.stack({ gap: "sm", fill: true });
|
||||
const show = (tab) => body.set([c.tabs.underline({ label: "Composer", tabs: TABS, value: tab, onChange: show }), sheets[tab](app.project)]);
|
||||
const show = (tab) => { cleanup(); body.set([c.tabs.underline({ label: "Composer", tabs: TABS, value: tab, onChange: show }), sheets[tab](app, (off) => owned.push(off))]); };
|
||||
show("story");
|
||||
win = c.floating.window({ id: "composer", title: "Composer", subtitle: "prompt craft", body, width: 420, height: 520, x: 320, y: 90,
|
||||
onClose: () => { win = null; } });
|
||||
onClose: () => { cleanup(); win = null; } });
|
||||
};
|
||||
host.append(c.button.sm({ label: "◆ Composer", tone: "neutral", onClick: open }).node);
|
||||
return cleanup;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// The Story: every generated scene's prompt as one box, a marker word between scenes. Pure half (applyStory) here;
|
||||
// the box itself is in index.js. Scene.text stays the truth; the story is a second way to edit it.
|
||||
import { removeScene } from "../../shell/edits.js";
|
||||
import { genUnitId, isGenerative, isSubclip } from "../../shell/scenes.js";
|
||||
|
||||
const norm = (t) => String(t || "").replace(/\s+/g, " ").trim();
|
||||
const newId = () => "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
|
||||
/** The scenes the story shows: one root per generation unit (never video clips, cuts or left-out scenes), in plan order. */
|
||||
export const storyRoots = (p) => {
|
||||
const seen = new Set(), out = [];
|
||||
for (const sc of p.scenes || []) {
|
||||
if (sc.excluded || !isGenerative(sc) || isSubclip(sc) || seen.has(genUnitId(sc))) continue;
|
||||
seen.add(genUnitId(sc)); out.push(sc);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
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. */
|
||||
export function match(parts, roots) {
|
||||
const out = parts.map(() => -1), used = roots.map(() => false);
|
||||
parts.forEach((t, i) => {
|
||||
if (!t) return;
|
||||
let best = -1;
|
||||
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; }
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function applyStory(p, parts) {
|
||||
if (!parts.length) return false;
|
||||
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;
|
||||
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));
|
||||
}
|
||||
return JSON.stringify(p) !== before;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyStory, 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}`);
|
||||
|
||||
test("the story is the generated roots' texts with the marker between them; video clips and left-out scenes are not in it", () => {
|
||||
const p = proj(); p.scenes[3].excluded = true;
|
||||
assert.deepEqual(storyRoots(p).map((s) => s.id), ["a", "b"]);
|
||||
assert.equal(joinStory(p, "qcut"), "one\nqcut\ntwo");
|
||||
});
|
||||
|
||||
test("match: exact text pins a scene through a reorder; the rest take the leftovers in order", () => {
|
||||
assert.deepEqual(match(["three", "one", "new"], ["one", "two", "three"]), [2, 0, 1]);
|
||||
assert.deepEqual(match(["x", "two"], ["one", "two"]), [0, 1]);
|
||||
assert.deepEqual(match(["a", "b", "c"], ["a"]), [0, -1, -1]);
|
||||
});
|
||||
|
||||
test("editing a scene's words keeps the scene (its id, so its render and anchor)", () => {
|
||||
const p = proj();
|
||||
assert.equal(applyStory(p, ["one!", "two", "three"]), true);
|
||||
assert.deepEqual(texts(p), ["a:one!", "b:two", "v:", "c:three"]);
|
||||
});
|
||||
|
||||
test("a dropped part removes its scene (a rendered one leaves a ghost); the video clip stays", () => {
|
||||
const p = proj(); p.scene_renders.b = { media: { filename: "b.mp4" } };
|
||||
assert.equal(applyStory(p, ["one", "three"]), true);
|
||||
assert.deepEqual(texts(p), ["a:one", "v:", "c:three"]);
|
||||
assert.deepEqual(p.scene_ghosts.map((g) => g.id), ["b"]);
|
||||
});
|
||||
|
||||
test("a part nothing owns becomes a new scene at the end of the story", () => {
|
||||
const p = proj();
|
||||
assert.equal(applyStory(p, ["one", "two", "three", "four"]), true);
|
||||
assert.deepEqual(p.scenes.map((s) => s.text), ["one", "two", "", "three", "four"]);
|
||||
});
|
||||
|
||||
test("a part with new words takes over the leftover scene in place, so its render and anchor are kept", () => {
|
||||
const p = proj();
|
||||
applyStory(p, ["one", "extra", "three"]);
|
||||
assert.deepEqual(texts(p), ["a:one", "b:extra", "v:", "c:three"]);
|
||||
});
|
||||
|
||||
test("a reordered story reorders the plan; an unchanged story changes nothing", () => {
|
||||
const p = proj();
|
||||
assert.equal(applyStory(p, ["three", "one", "two"]), true);
|
||||
assert.deepEqual(p.scenes.map((s) => s.id), ["c", "a", "v", "b"]); // the video clip keeps its slot
|
||||
const q = proj();
|
||||
assert.equal(applyStory(q, ["one", "two", "three"]), false);
|
||||
});
|
||||
|
||||
test("an empty split changes nothing", () => assert.equal(applyStory(proj(), []), false));
|
||||
@@ -25,6 +25,8 @@ export const api = {
|
||||
releaseModule: (id) => call("POST", "/api/control/release", { id }),
|
||||
describeNodes: (classes) => call("GET", `/api/nodes?classes=${encodeURIComponent(classes.join(","))}`),
|
||||
system: () => call("GET", "/api/system"),
|
||||
storyMarkers: () => call("GET", "/api/story/markers"),
|
||||
storySplit: (text) => call("POST", "/api/story/split", { text }),
|
||||
packs: () => call("GET", "/api/packs"),
|
||||
pack: (action, body) => call("POST", `/api/packs/${action}`, body || {}),
|
||||
git: (action, body) => call("POST", `/api/git/${action}`, body || {}),
|
||||
|
||||
Reference in New Issue
Block a user