Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit d1739018e71ceb2073eddfff96124bbc2efc0f0e
parent bb5af7f85708fea334ee14d18ce7026ff22e43bb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 00:40:18 -0400

umtool: posts on the deck — updatePosts, the posts rows, and the posts region's preview windows

updatePosts(dir, {id: {attachTo?, hide?}}, {token}) is the one writer of a
post's attachment and visibility, through the manifest lock, the backup, the
atomic write and the stale-token guard. attachTo: null and hide: false delete
the key; an unknown id, a shape it does not take, or a result validatePosts
refuses fails the whole batch. Adding or rewording posts is not a writer's.

GET /api/report/onscreen carries the posts: each with the clip the date rule
picks (auto), the clip it rides on as saved (effective, null when hidden), its
slot in the preview's schedule, and the cut's clips for the override select.
PUT /api/report/posts is the table's save.

The preview places the posts again over a build's segments (an override saved
since the build moves them), applies an unsaved posts draft, and composes the
posts region once per postWindows window through one function,
composePostsPreview -> composeChrome({region: "posts", window, preview: true}).
A window that does not compose says why beside a working deck. The files route
serves out/<variant>/chrome/posts-preview-<segment>/ for an entry of the cut,
under the same traversal guard. `posts: false` skips the windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/chrome/files/[...path]/route.ts | 13+++++++++++--
Mumtool/app/api/report/chrome/preview/route.ts | 45+++++++++++++++++++++++++++++++++++++++------
Mumtool/app/api/report/onscreen/route.ts | 17+++++++++++++----
Aumtool/app/api/report/posts/route.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/lib/report/manifest.test.mjs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/onscreen.mjs | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mumtool/lib/report/onscreen.test.mjs | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/lib/report/serve.mjs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/serve.test.mjs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 782 insertions(+), 23 deletions(-)

diff --git a/umtool/app/api/report/chrome/files/[...path]/route.ts b/umtool/app/api/report/chrome/files/[...path]/route.ts @@ -1,8 +1,9 @@ import path from "node:path"; +import { selectVariant } from "umtool-report-to-video/build-video"; import { decodeProjectSegment, - deckPreviewDir, deckPreviewFile, + previewDirFor, rangeResponse, resolveReport, } from "@/lib/report/serve.mjs"; @@ -22,6 +23,11 @@ export const dynamic = "force-dynamic"; // own scan and list, and the file must resolve -- symlinks followed -- inside // that cut's out/<variant>/chrome/deck-preview/. deckPreviewFile is the rule, // and it is tested. +// +// The posts region's windows are served from the same prefix one segment +// deeper: <project>/<variant>/posts-preview-<segment>/<file…>, where <segment> +// must be an entry of this cut (previewDirFor), and the file is then confined +// to THAT window's directory by the same deckPreviewFile. const TYPES: Record<string, string> = { ".html": "text/html; charset=utf-8", @@ -50,7 +56,10 @@ export async function GET(request: Request, ctx: { params: Promise<{ path: strin const r = await resolveReport(projectId, variant); if ("error" in r) return new Response(r.error, { status: r.status }); - const file = await deckPreviewFile(deckPreviewDir(r.project.dir, r.variant), rest); + const ids = (selectVariant(r.manifest, r.variant).timeline ?? []).map((e: { id: string }) => e.id); + const where = previewDirFor(r.project.dir, r.variant, rest, ids); + if (!where) return new Response("not found", { status: 404 }); + const file = await deckPreviewFile(where.dir, where.rest); if (!file) return new Response("not found", { status: 404 }); return rangeResponse(request, { diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts @@ -1,6 +1,12 @@ -import { composeDeckPreview, normalizeDraft, scheduleForPreview } from "@/lib/report/onscreen.mjs"; -import { deckPreviewSrc, resolveReport } from "@/lib/report/serve.mjs"; -import { deckGeometry, deckLayout, deckOn, validateChrome } from "umtool-report-to-video/deck"; +import { + composeDeckPreview, + composePostsPreviews, + normalizeDraft, + normalizePostsDraft, + scheduleForPreview, +} from "@/lib/report/onscreen.mjs"; +import { deckPreviewSrc, postsPreviewSrc, resolveReport } from "@/lib/report/serve.mjs"; +import { deckGeometry, deckLayout, deckOn, postsGeometry, validateChrome } from "umtool-report-to-video/deck"; export const dynamic = "force-dynamic"; @@ -17,7 +23,15 @@ export const dynamic = "force-dynamic"; // `draft` -- unsaved rows, id → { title?, subtitle? } | null, the shape PUT // /api/report/onscreen takes -- is applied on top. // -// The client sends a project id, a variant and the draft. Never a path. +// The posts region is composed beside it, one project per window +// (postWindows: a clip that carries posts, from its first post's appearance to +// the end of their leave), each loaded in its own iframe at +// `posts.geometry` over the footage while the scrubber is inside the window. +// `postsDraft` -- unsaved overrides, the shape PUT /api/report/posts takes -- +// is applied first. A window that does not compose says why in its row; the +// deck's preview is returned either way. +// +// The client sends a project id, a variant and the drafts. Never a path. export async function POST(request: Request) { let body: Record<string, unknown>; try { @@ -36,7 +50,14 @@ export async function POST(request: Request) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); } - const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft); + let postsDraft; + try { + postsDraft = normalizePostsDraft(body.postsDraft); + } catch (e) { + return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); + } + + const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft, postsDraft); const render = (variantManifest.render ?? {}) as Record<string, unknown>; if (!deckOn(render)) { return Response.json( @@ -55,16 +76,28 @@ export async function POST(request: Request) { } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); } + // `posts: false` -- the clip bench's strip, which has no footage to lay them on. + const windows = body.posts === false ? [] : await composePostsPreviews(r.project, r.variant, schedule); + const stamp = Date.now(); return Response.json( { // `v` so the iframe reloads a recomposed preview; the files themselves // are served no-store, so its relative asset urls need none. - src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${Date.now()}`, + src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${stamp}`, variant: r.variant, geometry: deckGeometry(render), layout: deckLayout(render), schedule, + posts: { + geometry: postsGeometry(render), + windows: windows.map((w) => ({ + segment: w.segment, + from: w.from, + to: w.to, + ...(w.ok ? { src: `${postsPreviewSrc(r.project.id, r.variant, w.segment)}?v=${stamp}` } : { error: w.error }), + })), + }, }, { headers: { "cache-control": "no-store" } }, ); diff --git a/umtool/app/api/report/onscreen/route.ts b/umtool/app/api/report/onscreen/route.ts @@ -1,7 +1,6 @@ import { StaleToken, manifestToken, updateOnscreen } from "@/lib/report/manifest.mjs"; -import { deckMetas } from "@/lib/report/onscreen.mjs"; +import { postRows, scheduleForPreview } from "@/lib/report/onscreen.mjs"; import { resolveReport } from "@/lib/report/serve.mjs"; -import { selectVariant } from "umtool-report-to-video/build-video"; import { deckText, isMultiChannel, resolveDeck } from "umtool-report-to-video/deck"; export const dynamic = "force-dynamic"; @@ -26,19 +25,25 @@ const noStore = { "cache-control": "no-store" }; * shows as placeholders. Auto text comes from the archive's cue files (the * clip bench's source), so before a build it can differ from the fetched * file's metadata the build will use. + * + * `posts` rides along, for the Posts table under it: every post the manifest + * carries with the clip the date rule picks for it (`auto`), the clip it rides + * on as saved (`effective`, null when hidden), and its slot in the schedule + * the preview draws -- the build's when it still matches the cut, else the + * estimate (`postsEstimated`). `clips` is the override select's list. */ export async function GET(request: Request) { const url = new URL(request.url); const r = await resolveReport(url.searchParams.get("project") ?? "", url.searchParams.get("variant")); if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); - const cut = selectVariant(r.manifest, r.variant); + const { variantManifest: cut, metas, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant); const entries = (cut.timeline ?? []) as Entry[]; const render = cut.render ?? {}; const deck = resolveDeck(render); const provenance = cut.provenance ?? {}; const multi = isMultiChannel(entries, provenance); - const metas = await deckMetas(r.project.dir, r.manifest, entries); + const posts = postRows({ variantManifest: cut, metas, schedule }); const rows = entries.map((e, i) => { const { onscreen, ...bare } = e; return { @@ -54,6 +59,10 @@ export async function GET(request: Request) { rows, maxChars: deck.title.maxChars, multiChannel: multi, + posts: posts.posts, + clips: posts.clips, + postsShown: deck.posts.show, + postsEstimated: schedule.estimated === true, token: await manifestToken(r.project.dir), }, { headers: noStore }, diff --git a/umtool/app/api/report/posts/route.ts b/umtool/app/api/report/posts/route.ts @@ -0,0 +1,49 @@ +import { PostsRefused, StaleToken, updatePosts } from "@/lib/report/manifest.mjs"; +import { resolveReport } from "@/lib/report/serve.mjs"; + +export const dynamic = "force-dynamic"; + +// The two decisions a person makes about a post once it is in the manifest: +// which clip it rides on (`attachTo`, null for the automatic one) and whether +// it is shown (`hide`). Adding, removing or rewording posts is not here -- +// they are written into the manifest by whoever cites them. +// +// PUT is the Posts table's one save: `{ project, posts: { <id>: { attachTo?, +// hide? } }, token }`. One unknown id, one bad value, or a result the build's +// validatePosts refuses fails the WHOLE batch and nothing is written. The +// client sends a project id, never a path. Read through GET +// /api/report/onscreen, which carries the rows and the token. + +const noStore = { "cache-control": "no-store" }; + +export async function PUT(request: Request) { + let body: Record<string, unknown>; + try { + body = await request.json(); + } catch { + return Response.json({ error: "expected JSON" }, { status: 400 }); + } + + const r = await resolveReport(String(body.project ?? "")); + if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); + + try { + const res = await updatePosts( + r.project.dir, + body.posts as Record<string, { attachTo?: string | null; hide?: boolean }>, + { token: body.token === undefined ? null : String(body.token) }, + ); + return Response.json({ ok: true, posts: res.posts, token: res.token }, { headers: noStore }); + } catch (e) { + if (e instanceof StaleToken) { + return Response.json( + { error: e.message, expected: e.expected, got: e.got, stale: true }, + { status: 409 }, + ); + } + if (e instanceof PostsRefused) { + return Response.json({ error: e.message, errors: e.errors }, { status: 400 }); + } + return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); + } +} diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -30,7 +30,7 @@ import { rolesGaps, } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; -import { normalizeOnscreen, validateChrome } from "umtool-report-to-video/deck"; +import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck"; // Its own write queue, not lib/state.ts's. // @@ -552,3 +552,129 @@ export class ChromeRefused extends Error { this.errors = errors; } } + + +// --------------------------------------------------------------------------- +// POSTS: the two things a person decides about a post once an agent has put it +// in the manifest -- which clip it rides on, and whether it is shown at all. +// +// Adding, removing or rewording a post is not here. A post is a quotation of +// somebody else's words with a permalink; it is written by whoever cites it, +// into the manifest, and this writer refuses to be a second way to do that. +// --------------------------------------------------------------------------- + +const POST_PATCH_KEYS = ["attachTo", "hide"]; + +/** + * A batch of post patches, checked for shape before anything is read: post id + * → `{ attachTo?: string | null, hide?: boolean }`. `attachTo: ""` is the + * same as null (a select's "auto"). + * + * @param {unknown} posts + * @returns {Record<string, { attachTo?: string | null, hide?: boolean }>} + */ +export function normalizePostPatches(posts) { + if (!posts || typeof posts !== "object" || Array.isArray(posts)) { + throw new Error("posts must be an object of post id → { attachTo, hide }"); + } + const ids = Object.keys(posts); + if (!ids.length) throw new Error("nothing to change"); + /** @type {Record<string, { attachTo?: string | null, hide?: boolean }>} */ + const out = {}; + for (const id of ids) { + const v = /** @type {Record<string, unknown>} */ (posts)[id]; + if (!v || typeof v !== "object" || Array.isArray(v)) { + throw new Error(`${id}: a post patch must be an object with attachTo and/or hide`); + } + for (const k of Object.keys(v)) { + if (!POST_PATCH_KEYS.includes(k)) { + throw new Error(`${id}: ${k} is not something this writer changes (only attachTo and hide)`); + } + } + /** @type {{ attachTo?: string | null, hide?: boolean }} */ + const p = {}; + if ("attachTo" in v) { + const a = /** @type {Record<string, unknown>} */ (v).attachTo; + if (a === null || a === undefined || a === "") p.attachTo = null; + else if (typeof a === "string") p.attachTo = a; + else throw new Error(`${id}: attachTo must be a clip id, or null for the automatic clip`); + } + if ("hide" in v) { + const h = /** @type {Record<string, unknown>} */ (v).hide; + if (typeof h !== "boolean") throw new Error(`${id}: hide must be true or false`); + p.hide = h; + } + out[id] = p; + } + return out; +} + +/** + * Patch the `attachTo` and `hide` of any number of posts, in one write. + * + * `attachTo: null` and `hide: false` DELETE the key -- the automatic clip and + * a shown post are what an absent key already says, and a manifest read by + * humans should not carry `"hide": false` as if somebody decided it. A key the + * patch does not name is left alone. + * + * ALL OR NOTHING: an unknown post id, a shape this writer does not take, or a + * result validatePosts refuses (an attachTo naming no clip in the timeline) + * fails the whole call before anything is written. validatePosts is the + * build's own check, so a manifest this accepts is one the build accepts. + * + * @param {string} dir + * @param {Record<string, { attachTo?: string | null, hide?: boolean }>} posts + * @param {{ token?: string | null }} [opts] + * @returns {Promise<{ posts: Record<string, { attachTo: string | null, hide: boolean }>, token: string | null }>} + */ +export async function updatePosts(dir, posts, { token = null } = {}) { + const next = normalizePostPatches(posts); + const ids = Object.keys(next); + + return withManifestLock(async () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + + const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); + if (!Array.isArray(manifest.posts)) { + throw new Error("this manifest has no posts — they are added by editing the manifest, not here"); + } + const byId = new Map(manifest.posts.map((p) => [p?.id, p])); + const unknown = ids.filter((id) => !byId.has(id)); + if (unknown.length) throw new Error(`no post with id ${unknown.join(", ")} — nothing was written`); + + for (const id of ids) { + const post = byId.get(id); + const p = next[id]; + if ("attachTo" in p) { + if (p.attachTo) post.attachTo = p.attachTo; + else delete post.attachTo; + } + if ("hide" in p) { + if (p.hide) post.hide = true; + else delete post.hide; + } + } + const errors = validatePosts(manifest.posts, manifest.timeline ?? []); + if (errors.length) throw new PostsRefused(errors); + + const nextToken = await writeManifestAtomic(dir, manifest); + /** @type {Record<string, { attachTo: string | null, hide: boolean }>} */ + const saved = {}; + for (const id of ids) { + const post = byId.get(id); + saved[id] = { attachTo: post.attachTo ?? null, hide: post.hide === true }; + } + return { posts: saved, token: nextToken }; + }); +} + +/** validatePosts' sentences, thrown whole so a route can return each one. */ +export class PostsRefused extends Error { + /** @param {string[]} errors */ + constructor(errors) { + super(`posts: ${errors.join("; ")}`); + this.name = "PostsRefused"; + this.errors = errors; + } +} diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs @@ -13,11 +13,14 @@ import test from "node:test"; import { ChromeRefused, MANIFEST_NAME, + PostsRefused, StaleToken, manifestToken, + normalizePostPatches, updateChrome, updateClip, updateOnscreen, + updatePosts, } from "./manifest.mjs"; const base = () => ({ @@ -294,3 +297,96 @@ test("the writers queue: concurrent saves of different fields all land", async ( await rm(dir, { recursive: true, force: true }); } }); + + +// ---- updatePosts ------------------------------------------------------------ + +const withPosts = () => ({ + ...base(), + posts: [ + { id: "p1", platform: "bluesky", handle: "a.test", date: "2024-09-05T10:00:00Z", text: "one", url: "https://bsky.app/p/1" }, + { id: "p2", platform: "x", handle: "b", date: "2024-09-12", text: "two", url: "https://x.com/b/status/2", attachTo: "c01", hide: true }, + ], +}); +const post = (m, id) => m.posts.find((p) => p.id === id); + +test("updatePosts: attachTo and hide round trip; null and false delete the key; the rest is untouched", async () => { + const dir = await project(withPosts()); + try { + const token = await manifestToken(dir); + const res = await updatePosts(dir, { p1: { attachTo: "c02", hide: true }, p2: { attachTo: null, hide: false } }, { token }); + assert.deepEqual(res.posts, { p1: { attachTo: "c02", hide: true }, p2: { attachTo: null, hide: false } }); + assert.equal(res.token, await manifestToken(dir)); + const raw = await readRaw(dir); + assert.ok(raw.startsWith('{\n "slug"') && raw.endsWith("}\n"), "the CLI's formatting kept"); + const m = JSON.parse(raw); + assert.equal(post(m, "p1").attachTo, "c02"); + assert.equal(post(m, "p1").hide, true); + assert.ok(!("attachTo" in post(m, "p2")), "attachTo: null deletes the key"); + assert.ok(!("hide" in post(m, "p2")), "hide: false deletes the key"); + assert.equal(post(m, "p2").text, "two"); + assert.deepEqual(m.timeline, base().timeline); + assert.ok(await stat(path.join(dir, `${MANIFEST_NAME}.bak`))); + + // A key the patch does not name stays; "" is the select's "auto". + await updatePosts(dir, { p1: { hide: false } }); + assert.equal(post(await read(dir), "p1").attachTo, "c02"); + await updatePosts(dir, { p1: { attachTo: "" } }); + assert.deepEqual(post(await read(dir), "p1"), withPosts().posts[0]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updatePosts: an unknown post id refuses the WHOLE batch and writes nothing", async () => { + const dir = await project(withPosts()); + try { + const before = await readRaw(dir); + await assert.rejects(updatePosts(dir, { p1: { hide: true }, nope: { hide: true } }), /no post with id nope/); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updatePosts: an attachTo validatePosts refuses comes back as its sentence, and nothing is written", async () => { + const dir = await project(withPosts()); + try { + const before = await readRaw(dir); + // k1 is a card, not a clip: a post rides on footage. + await assert.rejects(updatePosts(dir, { p1: { hide: true }, p2: { attachTo: "k1" } }), (e) => { + assert.ok(e instanceof PostsRefused); + assert.deepEqual(e.errors, ['posts[1].attachTo "k1" is not a clip in the timeline']); + return true; + }); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updatePosts: a stale token is refused and writes nothing", async () => { + const dir = await project(withPosts()); + try { + const before = await readRaw(dir); + await assert.rejects(updatePosts(dir, { p1: { hide: true } }, { token: "1" }), (e) => e instanceof StaleToken); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updatePosts: no posts in the manifest, or a patch it does not take, is refused", async () => { + const dir = await project(); + try { + await assert.rejects(updatePosts(dir, { p1: { hide: true } }), /has no posts/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + assert.throws(() => normalizePostPatches({}), /nothing to change/); + assert.throws(() => normalizePostPatches([]), /must be an object/); + assert.throws(() => normalizePostPatches({ p1: { text: "new words" } }), /text is not something this writer changes/); + assert.throws(() => normalizePostPatches({ p1: { hide: "yes" } }), /p1: hide must be true or false/); + assert.throws(() => normalizePostPatches({ p1: { attachTo: 3 } }), /p1: attachTo must be a clip id/); + assert.deepEqual(normalizePostPatches({ p1: { attachTo: "" }, p2: {} }), { p1: { attachTo: null }, p2: {} }); +}); diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -19,7 +19,16 @@ import { mkdir, readFile, rm } from "node:fs/promises"; import path from "node:path"; import { composeChrome } from "umtool-report-to-video/compose-chrome"; import { selectVariant } from "umtool-report-to-video/build-video"; -import { deckText, estimateSchedule, normalizeOnscreen, resolveDeck } from "umtool-report-to-video/deck"; +import { + attachPosts, + clipDay, + deckText, + estimateSchedule, + normalizeOnscreen, + postSchedule, + postWindows, + resolveDeck, +} from "umtool-report-to-video/deck"; import { channelsDirFor, cuePathFor, @@ -27,7 +36,8 @@ import { manifestPath, readCues, } from "../projects/report.mjs"; -import { deckPreviewDir } from "./serve.mjs"; +import { normalizePostPatches } from "./manifest.mjs"; +import { deckPreviewDir, postsPreviewDir } from "./serve.mjs"; /** The schedule document deck.mjs defines, built or estimated. */ /** @typedef {ReturnType<typeof estimateSchedule>} DeckSchedule */ @@ -107,16 +117,25 @@ export function scheduleMatches(schedule, entries) { * subtitle override and no cue file to read keeps the build's auto subtitle, * which came from the fetched file's own metadata. * + * The posts are placed again the same way, with postSchedule over the + * build's segments: an override or a hide saved since the build moves them, + * and the build's `posts` would show where they were. + * * Without a build schedule the draft is applied to the entries and the whole * cut is estimated. * + * `postsDraft` (post id → `{attachTo?, hide?}`, the shape PUT + * /api/report/posts takes) is applied to the manifest's posts in both cases. + * * @param {{ variantManifest: Record<string, any>, built: Record<string, any> | null, * draft: Map<string, { title?: string, subtitle?: string } | null>, - * metas: Array<Record<string, any> | null> }} args + * metas: Array<Record<string, any> | null>, + * postsDraft?: Record<string, { attachTo?: string | null, hide?: boolean }> }} args * @returns {DeckSchedule} */ -export function previewSchedule({ variantManifest, built, draft, metas }) { +export function previewSchedule({ variantManifest, built, draft, metas, postsDraft = {} }) { const entries = variantManifest.timeline ?? []; + const posts = applyPostsDraft(variantManifest.posts ?? [], postsDraft); const patched = (e) => { if (!draft.has(e.id)) return e; const v = draft.get(e.id); @@ -128,20 +147,134 @@ export function previewSchedule({ variantManifest, built, draft, metas }) { const render = variantManifest.render ?? {}; const deck = resolveDeck(render); const provenance = variantManifest.provenance ?? {}; + const patchedEntries = entries.map(patched); + const { posts: _builtPosts, ...rest } = built; + const placed = deck.posts.show + ? postSchedule({ + posts, + entries: patchedEntries, + metas, + segments: built.segments, + D: built.transition, + total: built.total, + render, + }) + : []; + const round = (v) => Math.round(v * 1000) / 1000; return { - ...built, + ...rest, segments: built.segments.map((s, i) => { - const e = patched(entries[i]); + const e = patchedEntries[i]; const meta = metas[i] ?? null; const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel); const keepBuilt = e.type === "clip" && e.onscreen?.subtitle === undefined && !meta; return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle }; }), + ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), }; } - return estimateSchedule({ ...variantManifest, timeline: entries.map(patched) }, { metas }); + return estimateSchedule({ ...variantManifest, posts, timeline: entries.map(patched) }, { metas }); +} + +/** + * The manifest's posts with unsaved patches on top, by updatePosts' rule: + * `attachTo: null` and `hide: false` remove the key. Pure; ids the draft names + * that are not posts are ignored here (the writer is what refuses them). + * + * @param {Array<Record<string, any>>} posts + * @param {Record<string, { attachTo?: string | null, hide?: boolean }>} draft + */ +export function applyPostsDraft(posts, draft = {}) { + if (!draft || !Object.keys(draft).length) return posts; + return posts.map((p) => { + const d = draft[p.id]; + if (!d) return p; + const out = { ...p }; + if ("attachTo" in d) { + if (d.attachTo) out.attachTo = d.attachTo; + else delete out.attachTo; + } + if ("hide" in d) { + if (d.hide) out.hide = true; + else delete out.hide; + } + return out; + }); +} + +/** + * A posts draft from the client, normalised the writer's way; an absent or + * empty draft is `{}`. + * + * @param {unknown} draft + */ +export function normalizePostsDraft(draft) { + if (draft === undefined || draft === null) return {}; + if (typeof draft === "object" && !Array.isArray(draft) && !Object.keys(draft).length) return {}; + return normalizePostPatches(draft); +} + +/** + * What a person reads off a clip in a "rides on" select: the deck title when + * there is one, else the quote, else the stream's own title. + * + * @param {Record<string, any>} entry + * @param {Record<string, any> | null} meta + */ +export function clipLabel(entry, meta) { + return String(entry.onscreen?.title ?? entry.quote ?? entry.title ?? meta?.title ?? "").trim(); +} + +/** + * The posts table's rows, for one cut. Pure. + * + * Every post the manifest carries, hidden or not, with: + * - `auto`: the clip the date rule picks with no override and not hidden -- + * attachPosts on the post stripped of `attachTo` and `hide`, so a hidden + * post still says where it WOULD ride; + * - `effective`: where it rides as saved (null when hidden); + * - `timing`: its slot in `schedule.posts` when a schedule places it. + * + * `clips` is every clip of the cut, in order, for the override select. + * + * @param {{ variantManifest: Record<string, any>, metas: Array<Record<string, any> | null>, + * schedule?: Record<string, any> | null }} args + */ +export function postRows({ variantManifest, metas, schedule = null }) { + const entries = variantManifest.timeline ?? []; + const posts = Array.isArray(variantManifest.posts) ? variantManifest.posts : []; + const labelOf = new Map(entries.map((e, i) => [e.id, clipLabel(e, metas[i] ?? null)])); + const bare = posts.map(({ attachTo: _a, hide: _h, ...p }) => p); + const auto = new Map(attachPosts({ posts: bare, entries, metas }).map((a) => [a.id, a])); + const effective = new Map(attachPosts({ posts, entries, metas }).map((a) => [a.id, a])); + const timing = new Map((schedule?.posts ?? []).map((p) => [p.id, p])); + const where = (a) => (a ? { entryId: a.entryId, rule: a.rule, clipDay: a.clipDay, label: labelOf.get(a.entryId) ?? "" } : null); + return { + posts: posts.map((p) => { + const t = timing.get(p.id); + return { + id: p.id, + platform: p.platform, + author: p.author ?? "", + handle: p.handle ?? "", + date: p.date, + text: p.text, + url: p.url, + attachTo: p.attachTo ?? null, + hide: p.hide === true, + auto: where(auto.get(p.id)), + effective: p.hide ? null : where(effective.get(p.id)), + timing: t ? { segment: t.segment, slot: t.slot, of: t.of, appear: t.appear, out: t.out } : null, + }; + }), + clips: entries + .map((e, i) => ({ e, i })) + .filter(({ e }) => e.type === "clip") + .map(({ e, i }) => ({ id: e.id, label: labelOf.get(e.id) ?? "", day: clipDay(e, metas[i] ?? null) })), + }; } + /** out/<variant>/schedule.json when it is the deck's, else null. */ async function readBuiltSchedule(dir, variant) { try { @@ -161,14 +294,14 @@ async function readBuiltSchedule(dir, variant) { * @param {string} variant * @param {Map<string, { title?: string, subtitle?: string } | null>} draft */ -export async function scheduleForPreview(project, manifest, variant, draft = new Map()) { +export async function scheduleForPreview(project, manifest, variant, draft = new Map(), postsDraft = {}) { const variantManifest = selectVariant(manifest, variant); const entries = variantManifest.timeline ?? []; const [built, metas] = await Promise.all([ readBuiltSchedule(project.dir, variant), deckMetas(project.dir, manifest, entries), ]); - return { variantManifest, schedule: previewSchedule({ variantManifest, built, draft, metas }) }; + return { variantManifest, metas, schedule: previewSchedule({ variantManifest, built, draft, metas, postsDraft }) }; } // compose-chrome writes one directory per cut. Two requests composing into it @@ -221,6 +354,67 @@ export async function composeDeckPreview(project, variant, schedule) { } /** + * Compose the PREVIEW of the posts region for one window. No render. + * + * The posts region is compose-chrome's (`region: "posts"`, one project per + * window under out/<variant>/chrome/posts-preview-<segment>/), and this is the + * ONE place umtool calls it: if its arguments change, they change here. + * `compose` is injectable for the unit test; the routes never pass it. + * + * @param {{ dir: string }} project + * @param {string} variant + * @param {Record<string, any>} schedule + * @param {{ segment: string, from: number, to: number }} window + * @param {{ compose?: (args: Record<string, unknown>) => Promise<any> }} [opts] + */ +export async function composePostsPreview(project, variant, schedule, window, { compose = composeChrome } = {}) { + const outDir = path.join(project.dir, "out", variant); + const want = postsPreviewDir(project.dir, variant, window.segment); + return serialised(want, async () => { + /** @type {Record<string, unknown>} */ + const args = { + manifestPath: manifestPath(project.dir), + outDir, + variant, + region: "posts", + window: { segment: window.segment, from: window.from, to: window.to }, + schedule, + preview: true, + }; + const r = await compose(/** @type {any} */ (args)); + if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) { + throw new Error(`compose-chrome wrote the posts preview to ${r.projDir}, not ${want}`); + } + return r; + }); +} + +/** + * Every posts window of a schedule, composed for the preview. A window that + * fails says why beside it rather than failing the deck's preview: the deck + * is drawn either way, and a posts region that is not there yet is a fact + * about the pipeline, not about this cut. + * + * @param {{ dir: string }} project + * @param {string} variant + * @param {Record<string, any>} schedule + * @param {{ compose?: (args: Record<string, unknown>) => Promise<any> }} [opts] + * @returns {Promise<Array<{ segment: string, from: number, to: number, ok: boolean, error?: string }>>} + */ +export async function composePostsPreviews(project, variant, schedule, opts = {}) { + const out = []; + for (const w of postWindows(schedule)) { + try { + await composePostsPreview(project, variant, schedule, w, opts); + out.push({ ...w, ok: true }); + } catch (e) { + out.push({ ...w, ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + return out; +} + +/** * A true still of the deck at `t`: the composition, screenshotted by the * render browser, as PNG bytes. Composed into the preview project (never the * build's), into a scratch file that is removed once read. diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -2,10 +2,23 @@ // // Run with: pnpm test:scripts import assert from "node:assert/strict"; +import path from "node:path"; import test from "node:test"; import { estimateSchedule } from "umtool-report-to-video/deck"; -import { normalizeDraft, previewSchedule, scheduleMatches, stillTimeOf } from "./onscreen.mjs"; +import { postWindows } from "umtool-report-to-video/deck"; +import { + applyPostsDraft, + clipLabel, + composePostsPreview, + composePostsPreviews, + normalizeDraft, + normalizePostsDraft, + postRows, + previewSchedule, + scheduleMatches, + stillTimeOf, +} from "./onscreen.mjs"; const cut = () => ({ slug: "t", @@ -103,3 +116,131 @@ test("stillTimeOf: the middle of an entry's segment", () => { assert.equal(stillTimeOf(built(), "c01"), 9.45); assert.equal(stillTimeOf(built(), "nope"), null); }); + + +// ---- posts -------------------------------------------------------------------- + +// c01's record is from Sep 3, c02's from Sep 10 (metas above). +const POSTS = [ + { id: "p1", platform: "bluesky", handle: "a.test", date: "2024-09-05T10:00:00Z", text: "one", url: "https://bsky.app/p/1" }, + { id: "p2", platform: "x", handle: "b", date: "2024-09-12", text: "two", url: "https://x.com/b/status/2" }, + { id: "p3", platform: "bluesky", handle: "a.test", date: "2024-08-01", text: "older than every clip", url: "https://bsky.app/p/3" }, +]; +const withPosts = (posts = POSTS) => ({ ...cut(), posts: posts.map((p) => ({ ...p })) }); + +test("a matching build schedule: posts are placed again over the build's segments, from the manifest NOW", () => { + // The build placed them somewhere else; a hide saved since must not show. + const b = { ...built(), posts: [{ id: "p2", segment: "c01", slot: 0, of: 1, appear: 1, out: [2, 3] }] }; + const m = withPosts(); + m.posts[1].attachTo = "c01"; + const s = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas }); + const by = Object.fromEntries(s.posts.map((p) => [p.id, p])); + // c01 leaves at c02's start (13.9) and carries p3, p1 and p2, oldest first. + assert.deepEqual(s.posts.filter((p) => p.segment === "c01").map((p) => p.id), ["p3", "p1", "p2"]); + assert.equal(by.p2.appear, 11.9); + assert.deepEqual(by.p2.out, [13.9, 14.4]); + assert.equal(by.p3.appear, 7.9); + + // A draft unhides nothing and moves p2 back to its own date's clip. + const d = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas, postsDraft: { p2: { attachTo: null } } }); + assert.equal(d.posts.find((p) => p.id === "p2").segment, "c02"); + // c02 is the last segment: p2 leaves 0.3 s before the end. + assert.deepEqual(d.posts.find((p) => p.id === "p2").out, [25.6, 25.9]); + assert.deepEqual(postWindows(d).map((w) => w.segment), ["c01", "c02"]); + + // All hidden: no posts key, as a cut without them. + const none = previewSchedule({ + variantManifest: m, built: b, draft: new Map(), metas, + postsDraft: { p1: { hide: true }, p2: { hide: true }, p3: { hide: true } }, + }); + assert.ok(!("posts" in none)); +}); + +test("no build schedule: the estimate places the posts with the draft applied", () => { + const s = previewSchedule({ variantManifest: withPosts(), built: null, draft: new Map(), metas, postsDraft: { p3: { hide: true } } }); + assert.equal(s.estimated, true); + assert.deepEqual(s.posts.map((p) => [p.id, p.segment]), [["p1", "c01"], ["p2", "c02"]]); +}); + +test("applyPostsDraft: the writer's rule, on a copy", () => { + const posts = withPosts().posts; + posts[0].hide = true; + const out = applyPostsDraft(posts, { p1: { hide: false, attachTo: "c02" }, p2: { attachTo: null }, zz: { hide: true } }); + assert.ok(!("hide" in out[0])); + assert.equal(out[0].attachTo, "c02"); + assert.ok(!("attachTo" in out[1])); + assert.equal(posts[0].hide, true, "the input is not mutated"); + assert.equal(applyPostsDraft(posts, {}), posts); + assert.deepEqual(normalizePostsDraft(undefined), {}); + assert.deepEqual(normalizePostsDraft({}), {}); + assert.throws(() => normalizePostsDraft({ p1: { text: "x" } }), /only attachTo and hide/); +}); + +test("postRows: the automatic clip, the effective one, and the slot in the schedule", () => { + const m = withPosts(); + m.posts[0].attachTo = "c02"; // p1 overridden + m.posts[1].hide = true; // p2 hidden + const schedule = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas }); + const { posts, clips } = postRows({ variantManifest: m, metas, schedule }); + const by = Object.fromEntries(posts.map((p) => [p.id, p])); + + assert.deepEqual(by.p1.auto, { entryId: "c01", rule: "date", clipDay: "2024-09-03", label: "Stream one" }); + assert.equal(by.p1.effective.entryId, "c02"); + assert.equal(by.p1.effective.rule, "attachTo"); + assert.equal(by.p1.attachTo, "c02"); + assert.equal(by.p1.effective.label, "Saved", "a clip's label is its deck title when it has one"); + + // Hidden: it still says where it WOULD ride, and rides nowhere. + assert.equal(by.p2.hide, true); + assert.equal(by.p2.auto.entryId, "c02"); + assert.equal(by.p2.effective, null); + assert.equal(by.p2.timing, null); + + assert.equal(by.p3.auto.rule, "first"); + assert.deepEqual(by.p3.timing, { segment: "c01", slot: 0, of: 1, appear: 11.9, out: [13.9, 14.4] }); + assert.deepEqual(clips, [ + { id: "c01", label: "Stream one", day: "2024-09-03" }, + { id: "c02", label: "Saved", day: "2024-09-10" }, + ]); + assert.deepEqual(postRows({ variantManifest: cut(), metas }).posts, []); +}); + +test("clipLabel: deck title, then the quote, then the stream's title", () => { + assert.equal(clipLabel({ onscreen: { title: "T" }, quote: "q" }, { title: "m" }), "T"); + assert.equal(clipLabel({ quote: " q " }, { title: "m" }), "q"); + assert.equal(clipLabel({}, { title: "m" }), "m"); + assert.equal(clipLabel({}, null), ""); +}); + +test("composePostsPreview: ONE call per window, region posts, preview, into posts-preview-<segment>", async () => { + const calls = []; + const project = { dir: "/proj" }; + const compose = async (args) => { + calls.push(args); + return { projDir: path.join(args.outDir, "chrome", `posts-preview-${args.window.segment}`) }; + }; + const schedule = previewSchedule({ variantManifest: withPosts(), built: built(), draft: new Map(), metas }); + const res = await composePostsPreviews(project, "sourced", schedule, { compose }); + assert.deepEqual(res.map((w) => [w.segment, w.ok]), [["c01", true], ["c02", true]]); + assert.deepEqual(calls[0], { + manifestPath: path.join("/proj", "video.manifest.json"), + outDir: path.join("/proj", "out", "sourced"), + variant: "sourced", + region: "posts", + window: postWindows(schedule)[0], + schedule, + preview: true, + }); + + // A composition written anywhere else is an error, said beside its window. + const wrong = async () => ({ projDir: "/elsewhere" }); + await assert.rejects( + composePostsPreview(project, "sourced", schedule, postWindows(schedule)[0], { compose: wrong }), + /not \/proj\/out\/sourced\/chrome\/posts-preview-c01/, + ); + const failing = async () => { + throw new Error("unknown chrome region: posts"); + }; + const failed = await composePostsPreviews(project, "sourced", schedule, { compose: failing }); + assert.deepEqual(failed.map((w) => [w.ok, w.error]), [[false, "unknown chrome region: posts"], [false, "unknown chrome region: posts"]]); +}); diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs @@ -312,3 +312,51 @@ export async function deckPreviewFile(dir, segments) { if (!st?.isFile()) return null; return { abs: realAbs, size: st.size }; } + +// --------------------------------------------------------------------------- +// The posts region's preview compositions: one per window, each its own +// project under out/<variant>/chrome/posts-preview-<segment>/ (compose-chrome +// names it; onscreen.mjs checks the name). Served by the same files route +// under one more path segment, `posts-preview-<segment>`, and confined by the +// same deckPreviewFile once the directory is chosen. +// --------------------------------------------------------------------------- + +const POSTS_PREVIEW_PREFIX = "posts-preview-"; + +/** The preview project of the posts window on one segment. */ +export const postsPreviewDir = (projectDir, variant, segment) => + path.join(projectDir, "out", variant, "chrome", `${POSTS_PREVIEW_PREFIX}${segment}`); + +/** The iframe src for one posts window's preview composition. */ +export const postsPreviewSrc = (projectId, variant, segment) => + `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/${POSTS_PREVIEW_PREFIX}${encodeURIComponent(segment)}/index.html`; + +/** + * Which preview directory a files request is for, and the segments left to + * resolve inside it. + * + * `posts-preview-<segment>/…` is a posts window's project when `<segment>` is + * one of `segmentIds` -- the cut's own entry ids, which the caller reads from + * the manifest, so a name the client made up is not a directory this serves. + * Anything else is a file of the deck's preview, as it always was (the deck's + * project holds `index.html`, `assets/` and `hyperframes.json`, nothing named + * like this). The per-segment rules (no `..`, no slash) are deckPreviewFile's + * and still apply to everything after. + * + * @param {string} projectDir + * @param {string} variant + * @param {string[]} rest the url segments after `<project>/<variant>/` + * @param {Iterable<string>} segmentIds + * @returns {{ dir: string, rest: string[] } | null} + */ +export function previewDirFor(projectDir, variant, rest, segmentIds) { + if (!Array.isArray(rest) || !rest.length) return null; + const head = rest[0]; + if (typeof head === "string" && head.startsWith(POSTS_PREVIEW_PREFIX)) { + const seg = head.slice(POSTS_PREVIEW_PREFIX.length); + if (!seg || /[\/\\\0]/.test(seg) || seg === "." || seg === "..") return null; + if (!new Set(segmentIds).has(seg)) return null; + return { dir: postsPreviewDir(projectDir, variant, seg), rest: rest.slice(1) }; + } + return { dir: deckPreviewDir(projectDir, variant), rest }; +} diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs @@ -15,6 +15,9 @@ import { deckPreviewFile, deckPreviewSrc, encodeProjectSegment, + postsPreviewDir, + postsPreviewSrc, + previewDirFor, rangeResponse, } from "./serve.mjs"; @@ -183,3 +186,54 @@ async function realDir(p) { const { realpath } = await import("node:fs/promises"); return realpath(p); } + + +test("previewDirFor: posts-preview-<segment> is a window's project only for an entry of the cut", () => { + const ids = ["c01", "c02", "k1"]; + assert.deepEqual(previewDirFor("/p", "sourced", ["posts-preview-c02", "index.html"], ids), { + dir: postsPreviewDir("/p", "sourced", "c02"), + rest: ["index.html"], + }); + assert.equal(postsPreviewDir("/p", "sourced", "c02"), path.join("/p", "out", "sourced", "chrome", "posts-preview-c02")); + // Everything else is the deck's preview, as before. + assert.deepEqual(previewDirFor("/p", "sourced", ["assets", "gsap.min.js"], ids), { + dir: deckPreviewDir("/p", "sourced"), + rest: ["assets", "gsap.min.js"], + }); + // A name the client made up, or one that walks, is no directory at all. + for (const head of ["posts-preview-nope", "posts-preview-", "posts-preview-..", "posts-preview-.", "posts-preview-a/b"]) { + assert.equal(previewDirFor("/p", "sourced", [head, "index.html"], [...ids, "..", ".", "a/b"]), null, head); + } + assert.equal(previewDirFor("/p", "sourced", [], ids), null); + // The src the preview route hands out names the same directory. + assert.match(postsPreviewSrc("reports/x", "sourced", "c02"), /\/sourced\/posts-preview-c02\/index\.html$/); +}); + +test("deckPreviewFile under a posts window: confined to THAT window's directory", async () => { + const root = await mkdtemp(path.join(tmpdir(), "umtool-posts-preview-")); + try { + const win = postsPreviewDir(root, "sourced", "c02"); + await mkdir(path.join(win, "assets"), { recursive: true }); + await writeFile(path.join(win, "index.html"), "<html>posts</html>"); + await writeFile(path.join(win, "assets", "qr.png"), "png"); + await mkdir(deckPreviewDir(root, "sourced"), { recursive: true }); + await writeFile(path.join(deckPreviewDir(root, "sourced"), "index.html"), "<html>deck</html>"); + + const at = (rest) => { + const w = previewDirFor(root, "sourced", rest, ["c02"]); + return w ? deckPreviewFile(w.dir, w.rest) : null; + }; + assert.equal((await at(["posts-preview-c02", "index.html"]))?.abs, path.join(await realpathOf(win), "index.html")); + assert.ok(await at(["posts-preview-c02", "assets", "qr.png"])); + // The window's dir is no way up to the deck's, or out. + assert.equal(await at(["posts-preview-c02", "..", "deck-preview", "index.html"]), null); + assert.equal(await at(["posts-preview-c02"]), null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function realpathOf(p) { + const { realpath } = await import("node:fs/promises"); + return realpath(p); +}