Archilyzer · Source

archilyzer

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

commit 28f93a41529d5335fa42f3b97ee9ce285c4e7d35
parent bb5af7f85708fea334ee14d18ce7026ff22e43bb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 00:49:55 -0400

Merge deck/posts-p2 (posts slice P2) — umtool: updatePosts, the posts rows on GET /api/report/onscreen, PUT /api/report/posts, the preview composing posts windows, the files route serving them, the Posts table (auto clip, override, hide) and the deck.posts settings group; e2e 93/93; reviewed

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/components/projects/ClipBench.tsx | 2+-
Mumtool/components/projects/OnscreenSection.tsx | 474+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/e2e/fixtures/make-fixture.mjs | 38+++++++++++++++++++++++++++++++++++++-
Aumtool/e2e/onscreen-posts.spec.ts | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 1547 insertions(+), 36 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/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -436,7 +436,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { let live = true; void (async () => { const [pv, table] = await Promise.all([ - composePreview(data.project, null), + composePreview(data.project, null, {}, { posts: false }), fetch(`/api/report/onscreen?project=${encodeURIComponent(data.project)}`, { cache: "no-store" }) .then((r) => (r.ok ? r.json() : null)) .catch(() => null) as Promise<{ diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -60,7 +60,19 @@ export type DeckSchedule = { multiChannel: boolean; segments: DeckSegment[]; }; -export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; schedule: DeckSchedule }; +export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] }; +/** One posts window's preview composition: `src` when it composed, `error` when it did not. */ +export type PostsWindow = { segment: string; from: number; to: number; src?: string; error?: string }; +export type DeckPreviewDoc = { + src: string; + variant: string; + geometry: DeckGeometry; + schedule: DeckSchedule & { posts?: PostSlot[] }; + /** The posts region: where it sits in the frame, and one composition per window. */ + posts?: { geometry: Rect; windows: PostsWindow[] }; +}; +/** An unsaved change to a post, as PUT /api/report/posts takes it. */ +export type PostPatch = { attachTo?: string | null; hide?: boolean }; /** What each segment's panel should say right now, by entry id. */ export type DeckTexts = Record<string, { title: string; subtitle: string }>; export type Onscreen = { title?: string; subtitle?: string }; @@ -83,16 +95,21 @@ export const onscreenValue = (d: { title: string; subtitle: string }): Onscreen return o.title || o.subtitle ? o : null; }; -/** Ask for a fresh preview composition. One door, used by both callers. */ +/** + * Ask for a fresh preview composition. One door, used by both callers. + * `postsDraft` is the Posts table's unsaved overrides; `posts: false` skips + * composing the posts windows (the bench's strip has no footage to put them on). + */ export async function composePreview( project: string, variant: string | null, draft: Record<string, Onscreen | null> = {}, + { postsDraft = {}, posts = true }: { postsDraft?: Record<string, PostPatch>; posts?: boolean } = {}, ): Promise<DeckPreviewDoc | { error: string }> { const r = await fetch("/api/report/chrome/preview", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ project, variant: variant || undefined, draft }), + body: JSON.stringify({ project, variant: variant || undefined, draft, postsDraft, ...(posts ? {} : { posts: false }) }), }); const j = (await r.json().catch(() => ({}))) as Record<string, unknown>; if (!r.ok) return { error: String(j.error ?? r.status) }; @@ -244,6 +261,114 @@ export function NeutralFrame({ geometry: g, label = "footage" }: { geometry: Dec ); } +/** The posts window the scrubber is inside, if any. Windows of one cut do not overlap in practice; the first wins. */ +export const postsWindowAt = (windows: PostsWindow[] | undefined, t: number): PostsWindow | null => + (windows ?? []).find((w) => t >= w.from && t <= w.to) ?? null; + +// --------------------------------------------------------------------------- +// PostsOverlay: one posts window's composition, at the posts region's rect +// inside the 16:9 frame, while the scrubber is in that window. +// +// The same contract as the deck's frame, with its own message names: the page +// is loaded with `?preview=1`, posts `{type: "posts:ready"}` when it can be +// seeked, and takes `{type: "deck:seek", t}` in the CUT's clock -- the +// window's composition knows where it starts. Laid out at its own pixel size +// and scaled, so its text is measured at the width the render measures it. +// Same origin, not sandboxed, for the reason DeckFrame's header gives. +// --------------------------------------------------------------------------- +export function PostsOverlay({ + win, + geometry, + frame: g, + t, +}: { + win: PostsWindow; + geometry: Rect; + frame: DeckGeometry; + t: number; +}) { + const box = useRef<HTMLDivElement | null>(null); + const el = useRef<HTMLIFrameElement | null>(null); + const [width, setWidth] = useState(0); + const [readySrc, setReadySrc] = useState<string | null>(null); + const src = win.src ? `${win.src}${win.src.includes("?") ? "&" : "?"}preview=1` : null; + const pct = (n: number, of: number) => `${(n / of) * 100}%`; + + useEffect(() => { + const node = box.current; + if (!node) return; + const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width)); + ro.observe(node); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + const onMsg = (e: MessageEvent) => { + if (e.source !== el.current?.contentWindow || e.origin !== window.location.origin) return; + if ((e.data as { type?: string } | null)?.type === "posts:ready") setReadySrc(src); + }; + window.addEventListener("message", onMsg); + return () => window.removeEventListener("message", onMsg); + }, [src]); + + const live = !!src && readySrc === src; + useEffect(() => { + if (live) el.current?.contentWindow?.postMessage({ type: "deck:seek", t }, window.location.origin); + }, [live, t]); + + const scale = width > 0 ? width / geometry.width : 0; + return ( + <div + ref={box} + data-testid="onscreen-posts-preview" + data-segment={win.segment} + data-posts-ready={live ? "1" : "0"} + className="pointer-events-none absolute" + style={{ + left: pct(geometry.x, g.W), + top: pct(geometry.y, g.H), + width: pct(geometry.width, g.W), + height: pct(geometry.height, g.H), + }} + > + {src ? ( + <iframe + ref={el} + key={src} + src={src} + title="posts preview" + data-testid="onscreen-posts-preview-iframe" + tabIndex={-1} + aria-hidden + style={{ + position: "absolute", + left: 0, + top: 0, + width: geometry.width, + height: geometry.height, + transform: `scale(${scale})`, + transformOrigin: "0 0", + border: 0, + background: "transparent", + colorScheme: "normal", + visibility: scale > 0 ? "visible" : "hidden", + }} + /> + ) : ( + <div + data-testid="onscreen-posts-preview-error" + className="absolute inset-0 flex items-start justify-center border border-dashed border-[var(--color-dirty)] p-2 text-center text-[11px] text-[var(--color-dirty)]" + title={win.error} + > + <span className="rounded bg-black/70 px-1.5 py-0.5"> + posts on {win.segment}: not composed — {win.error} + </span> + </div> + )} + </div> + ); +} + // --------------------------------------------------------------------------- // The settings form: every key of render.chrome.deck, flattened. // --------------------------------------------------------------------------- @@ -258,6 +383,7 @@ type DeckSettings = { qr: { show: boolean; size: number }; overCards: string; motion: { out: number; in: number; pip: number }; + posts: { show: boolean; seconds: number; position: string; width: number; qrSize: number; maxLines: number; inset: number }; }; type Field = @@ -307,6 +433,18 @@ const GROUPS: { name: string; fields: Field[] }[] = [ ], }, { + name: "posts", + fields: [ + { key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" }, + { key: "posts.seconds", label: "seconds", kind: "num", step: 0.5, hint: "s, 0.5–10: each post alone before the next stacks on" }, + { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the corner of the footage the column hangs from" }, + { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900, inside the footage" }, + { key: "posts.qrSize", label: "qr", kind: "num", hint: "px, 80–200, at most half the card" }, + { key: "posts.maxLines", label: "lines", kind: "int", hint: "2–14: longer posts end in an ellipsis" }, + { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the footage's edges" }, + ], + }, + { name: "motion", fields: [ { key: "motion.out", label: "out", kind: "num", step: 0.05, hint: "s, 0–2: the old title wiping out" }, @@ -378,6 +516,24 @@ type Row = { auto: { title: string; subtitle: string }; }; type Draft = { title: string; subtitle: string }; +type PostWhere = { entryId: string; rule: "attachTo" | "date" | "first"; clipDay: string | null; label: string }; +type PostRow = { + id: string; + platform: string; + author: string; + handle: string; + date: string; + text: string; + url: string; + attachTo: string | null; + hide: boolean; + auto: PostWhere | null; + effective: PostWhere | null; + timing: { segment: string; slot: number; of: number; appear: number; out: [number, number] } | null; +}; +type ClipOption = { id: string; label: string; day: string | null }; +/** A post's row as the form holds it: `attachTo` "" is the automatic clip. */ +type PostDraft = { attachTo: string; hide: boolean }; type JobView = { id: string; state: "running" | "done" | "failed"; @@ -390,6 +546,17 @@ type JobView = { const draftOf = (o: Onscreen | null): Draft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" }); const sameDraft = (a: Draft, b: Draft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim(); +const postDraftOf = (p: PostRow): PostDraft => ({ attachTo: p.attachTo ?? "", hide: p.hide }); +const samePost = (a: PostDraft, b: PostDraft) => a.attachTo === b.attachTo && a.hide === b.hide; +/** Only what changed, in the writer's shape. */ +const postPatch = (d: PostDraft, saved: PostDraft): PostPatch => { + const p: PostPatch = {}; + if (d.attachTo !== saved.attachTo) p.attachTo = d.attachTo || null; + if (d.hide !== saved.hide) p.hide = d.hide; + return p; +}; +const clipText = (id: string, label: string) => (label ? `${id} — ${label}` : id); +const postDay = (iso: string) => String(iso).slice(0, 10); const STALE = "the manifest changed since you opened this — reload the saved values (your edits are kept) and save again, or your edit would overwrite whatever was written"; @@ -428,6 +595,16 @@ export default function OnscreenSection({ }, [rows]); const [maxChars, setMaxChars] = useState(48); const [drafts, setDrafts] = useState<Record<string, Draft>>({}); + // The Posts table: the manifest's posts as saved, the clips one can ride on, + // and the unsaved overrides. Saved through the same token as everything else. + const [posts, setPosts] = useState<PostRow[]>([]); + const postsRef = useRef<PostRow[]>([]); + useEffect(() => { + postsRef.current = posts; + }, [posts]); + const [clips, setClips] = useState<ClipOption[]>([]); + const [postsShown, setPostsShown] = useState(true); + const [postDrafts, setPostDrafts] = useState<Record<string, PostDraft>>({}); const [note, setNote] = useState<string | null>(null); const [stale, setStale] = useState(false); const [busy, setBusy] = useState<string | null>(null); @@ -484,12 +661,36 @@ export default function OnscreenSection({ const loadRows = useCallback( async (keep: boolean) => { const r = await fetch(`/api/report/onscreen?${q}`, { cache: "no-store" }); - const j = (await r.json()) as { rows: Row[]; maxChars: number; token: string | null; error?: string }; + const j = (await r.json()) as { + rows: Row[]; + maxChars: number; + token: string | null; + posts?: PostRow[]; + clips?: ClipOption[]; + postsShown?: boolean; + error?: string; + }; if (!r.ok) { setLoadError(String(j.error ?? r.status)); return; } const before = new Map(rowsRef.current.map((row) => [row.id, draftOf(row.onscreen)])); + const postsBefore = new Map(postsRef.current.map((p) => [p.id, postDraftOf(p)])); + const nextPosts = j.posts ?? []; + setPosts(nextPosts); + setClips(j.clips ?? []); + setPostsShown(j.postsShown !== false); + // The titles' rule, for posts: only a real edit survives a reload. + setPostDrafts((prev) => { + const next: Record<string, PostDraft> = {}; + for (const p of nextPosts) { + const saved = postDraftOf(p); + const old = prev[p.id]; + const was = postsBefore.get(p.id); + next[p.id] = keep && old && was && !samePost(old, was) ? old : saved; + } + return next; + }); token.current = j.token; setRows(j.rows); setMaxChars(j.maxChars); @@ -508,10 +709,10 @@ export default function OnscreenSection({ ); const recompose = useCallback( - async (draft: Record<string, Onscreen | null> = {}) => { + async (draft: Record<string, Onscreen | null> = {}, postsDraft: Record<string, PostPatch> = {}) => { setComposing(true); setPreviewError(null); - const res = await composePreview(project, variant || null, draft); + const res = await composePreview(project, variant || null, draft, { postsDraft }); setComposing(false); if ("error" in res) { setPreviewError(res.error); @@ -627,6 +828,14 @@ export default function OnscreenSection({ () => Object.fromEntries(dirtyIds.map((id) => [id, onscreenValue(drafts[id])])) as Record<string, Onscreen | null>, [dirtyIds, drafts], ); + const dirtyPostIds = posts.filter((p) => postDrafts[p.id] && !samePost(postDrafts[p.id], postDraftOf(p))).map((p) => p.id); + const postsDraftMap = useCallback( + () => + Object.fromEntries( + posts.filter((p) => dirtyPostIds.includes(p.id)).map((p) => [p.id, postPatch(postDrafts[p.id], postDraftOf(p))]), + ) as Record<string, PostPatch>, + [posts, dirtyPostIds, postDrafts], + ); const saveSettings = useCallback(async () => { if (!doc) return; @@ -641,9 +850,9 @@ export default function OnscreenSection({ // The auto subtitles follow subtitle.parts and dateFormat; the counter // follows title.maxChars. Unsaved table edits ride through. await loadRows(true); - await recompose(draftMap()); + await recompose(draftMap(), postsDraftMap()); setNote("settings saved — the preview is recomposed with them"); - }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap]); + }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap, postsDraftMap]); const saveRows = useCallback( () => @@ -682,6 +891,39 @@ export default function OnscreenSection({ [queued, dirtyIds, project, draftMap], ); + /** The Posts table's one save: only the posts that changed, only the keys that changed. */ + const savePosts = useCallback( + () => + queued(async () => { + if (!dirtyPostIds.length) return; + setBusy("saving…"); + setNote(null); + const r = await fetch("/api/report/posts", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project, posts: postsDraftMap(), token: token.current }), + }); + const j = (await r.json()) as Record<string, unknown>; + setBusy(null); + if (!r.ok) { + if (j.stale) { + setStale(true); + setNote(STALE); + } else setNote(`could not save: ${String(j.error ?? r.status)}`); + return; + } + token.current = String(j.token ?? ""); + setStale(false); + const n = Object.keys((j.posts ?? {}) as object).length; + // The effective clip and the timing are the server's to say: read them + // back, keeping any title edits, and redraw the windows. + await loadRows(true); + if (doc?.deckOn) await recompose(draftMap()); + setNote(`saved ${n} post${n === 1 ? "" : "s"}`); + }), + [queued, dirtyPostIds, project, postsDraftMap, loadRows, recompose, draftMap, doc], + ); + const reloadSaved = useCallback(async () => { await loadChrome(); await loadRows(true); @@ -754,6 +996,7 @@ export default function OnscreenSection({ const schedule = preview?.schedule ?? null; const current = schedule ? segmentAt(schedule, t) : null; + const postsWin = preview?.posts ? postsWindowAt(preview.posts.windows, t) : null; const backdropId = current && segmentOf.has(current.id) ? current.id : null; // The backdrop follows the scrubber: the built segment of whichever clip is @@ -924,6 +1167,173 @@ export default function OnscreenSection({ </div> ); + // ---- the posts ------------------------------------------------------------ + const clipById = new Map(clips.map((c) => [c.id, c])); + /** Where a post rides as DRAFTED: hidden, the chosen clip, or the automatic one. */ + const ridesOn = (p: PostRow, d: PostDraft): { id: string; label: string; how: "auto" | "chosen" } | null => { + if (d.hide) return null; + const chosen = d.attachTo ? clipById.get(d.attachTo) : null; + if (chosen) return { id: chosen.id, label: chosen.label, how: "chosen" }; + return p.auto ? { id: p.auto.entryId, label: p.auto.label, how: "auto" } : null; + }; + const autoText = (p: PostRow) => + p.auto ? `auto: ${clipText(p.auto.entryId, p.auto.label)}` : "auto: no clip in this cut"; + + const postsBlock = posts.length > 0 && ( + <div className="space-y-1.5" data-testid="onscreen-posts"> + <div className="flex flex-wrap items-center gap-2"> + <span className="micro">posts — {posts.length}</span> + <span className="text-[11px] text-[var(--color-dim)]"> + {postsShown + ? "each rides on the clip recorded most closely before it, and stacks on at that clip's end" + : "posts are switched off in the settings (posts → show); nothing below is drawn"} + </span> + <button + type="button" + data-testid="onscreen-posts-save" + className={`${buttonVariants({ variant: "primary", size: "sm" })} ml-auto`} + disabled={!!busy || dirtyPostIds.length === 0} + onClick={() => void savePosts()} + > + {dirtyPostIds.length ? `save ${dirtyPostIds.length} post${dirtyPostIds.length === 1 ? "" : "s"}` : "saved"} + </button> + {dirtyPostIds.length > 0 && ( + <button + type="button" + data-testid="onscreen-posts-discard" + className={buttonVariants({ size: "sm" })} + disabled={!!busy} + onClick={() => setPostDrafts(Object.fromEntries(posts.map((p) => [p.id, postDraftOf(p)])))} + > + discard + </button> + )} + </div> + <div className="overflow-x-auto"> + <table data-testid="onscreen-posts-table" className="w-full border-collapse text-[12px]"> + <thead> + <tr className="micro text-left"> + <th className="w-40 px-1.5 py-0.5">post</th> + <th className="px-1.5 py-0.5">words</th> + <th className="w-72 px-1.5 py-0.5">rides on</th> + <th className="w-12 px-1.5 py-0.5 text-center">hide</th> + </tr> + </thead> + <tbody> + {posts.map((p) => { + const d = postDrafts[p.id] ?? postDraftOf(p); + const dirty = !samePost(d, postDraftOf(p)); + const on = ridesOn(p, d); + const set = (patch: Partial<PostDraft>) => + setPostDrafts((prev) => ({ ...prev, [p.id]: { ...(prev[p.id] ?? postDraftOf(p)), ...patch } })); + const inWindow = postsWin && on && postsWin.segment === on.id; + return ( + <tr + key={p.id} + data-testid="onscreen-post-row" + data-post={p.id} + data-dirty={dirty ? "1" : "0"} + data-hidden={d.hide ? "1" : "0"} + data-effective={on?.id ?? ""} + data-current={inWindow ? "1" : "0"} + className={`border-t border-[var(--color-line)] align-top ${d.hide ? "opacity-50" : ""} ${inWindow ? "bg-[var(--color-panel-2)]" : ""}`} + > + <td className="px-1.5 py-1"> + <div className="num font-mono text-[11px] text-[var(--color-text)]" title={p.date}> + {postDay(p.date)} + {dirty && <span className="ml-1 text-[var(--color-dirty)]" title="unsaved">●</span>} + </div> + <div className="text-[11px] text-[var(--color-dim)]"> + <span className={badgeVariants({ size: "sm" })} data-testid="onscreen-post-platform"> + {p.platform} + </span>{" "} + <span data-testid="onscreen-post-handle">@{p.handle || p.author}</span> + </div> + <a + href={p.url} + target="_blank" + rel="noreferrer noopener" + data-testid="onscreen-post-link" + className="text-[11px] text-[var(--color-sel)] hover:underline" + > + open the post ↗ + </a> + </td> + <td className="px-1.5 py-1"> + <p + data-testid="onscreen-post-text" + className="line-clamp-3 whitespace-pre-line text-[12px] leading-snug text-[var(--color-text)]" + title={p.text} + > + {p.text} + </p> + </td> + <td className="px-1.5 py-1"> + <select + data-testid="onscreen-post-attach" + value={d.attachTo} + disabled={d.hide} + onChange={(e) => set({ attachTo: e.target.value })} + className={`${input} w-full text-[11px]`} + title="The clip this post rides on: automatic (by date), or any clip of the cut" + > + <option value="" data-testid="onscreen-post-auto"> + {autoText(p)} + </option> + {clips.map((c) => ( + <option key={c.id} value={c.id}> + {clipText(c.id, c.label)} + </option> + ))} + </select> + <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[11px] text-[var(--color-dim)]"> + {on ? ( + <> + <span data-testid="onscreen-post-effective"> + {on.how === "auto" ? "automatic" : "chosen"}:{" "} + <span className="font-mono text-[var(--color-text)]">{on.id}</span> + {on.how === "auto" && p.auto?.clipDay ? ` (${p.auto.clipDay})` : ""} + </span> + {!dirty && p.timing && schedule && ( + <button + type="button" + data-testid="onscreen-post-jump" + className="num font-mono text-[var(--color-sel)] hover:underline" + onClick={() => setT(Math.min(schedule.total, Math.round((p.timing!.appear + 0.25) * 1000) / 1000))} + title="show this post in the preview" + > + at {clock(p.timing.appear)} + </button> + )} + </> + ) : ( + <span data-testid="onscreen-post-effective">hidden — not in the cut</span> + )} + </div> + </td> + <td className="px-1.5 py-1 text-center"> + <input + type="checkbox" + data-testid="onscreen-post-hide" + checked={d.hide} + onChange={(e) => set({ hide: e.target.checked })} + aria-label={`hide post ${p.id}`} + /> + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + {dirtyPostIds.length > 0 && doc.deckOn && ( + <p className="text-[11px] text-[var(--color-dirty)]"> + the preview shows saved posts; recompose to see the unsaved {dirtyPostIds.length === 1 ? "change" : "changes"} + </p> + )} + </div> + ); + return ( <section data-testid="onscreen-section" @@ -1008,6 +1418,15 @@ export default function OnscreenSection({ ) : ( <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> )} + {postsWin && preview.posts && ( + <PostsOverlay + key={`${postsWin.segment}:${postsWin.src ?? "none"}`} + win={postsWin} + geometry={preview.posts.geometry} + frame={preview.geometry} + t={t} + /> + )} </DeckFrame> ) : ( <div className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)]"> @@ -1045,6 +1464,28 @@ export default function OnscreenSection({ /> ))} </div> + {(preview?.posts?.windows.length ?? 0) > 0 && ( + // Where the posts are up: one mark per window, a click to + // the moment its last card has stacked on. + <div className="relative h-1.5 w-full" data-testid="onscreen-posts-windows"> + {preview!.posts!.windows.map((w) => ( + <button + key={w.segment} + type="button" + data-posts-window={w.segment} + title={`posts on ${w.segment}${w.error ? " · not composed" : ""}`} + onClick={() => setT(Math.round(((w.from + w.to) / 2) * 1000) / 1000)} + className={`absolute top-0 h-full rounded-sm ${ + postsWin?.segment === w.segment ? "bg-[var(--color-sel)]" : w.error ? "bg-[var(--color-dirty)]" : "bg-[var(--color-meter)]" + }`} + style={{ + left: `${(w.from / schedule.total) * 100}%`, + width: `${Math.max(0.4, ((w.to - w.from) / schedule.total) * 100)}%`, + }} + /> + ))} + </div> + )} <div className="flex items-center gap-2"> <input type="range" @@ -1086,8 +1527,8 @@ export default function OnscreenSection({ data-testid="onscreen-recompose" className={buttonVariants({ size: "sm" })} disabled={composing} - onClick={() => void recompose(draftMap())} - title="Compose the preview again from the manifest, with the table's unsaved rows on top" + onClick={() => void recompose(draftMap(), postsDraftMap())} + title="Compose the preview again from the manifest, with the tables' unsaved rows on top" > {composing ? "recomposing…" : "recompose"} </button> @@ -1302,7 +1743,10 @@ export default function OnscreenSection({ never uses the panel should not carry a nineteen-row form, and one that will can have its titles written before the switch. */} {on ? ( - words + <> + {words} + {postsBlock} + </> ) : ( <details data-testid="onscreen-words-folded"> <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]"> @@ -1312,6 +1756,14 @@ export default function OnscreenSection({ <div className="mt-1.5">{words}</div> </details> )} + {!on && postsBlock && ( + <details data-testid="onscreen-posts-folded"> + <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]"> + posts — {posts.length}; drawn only when the panel is on + </summary> + <div className="mt-1.5">{postsBlock}</div> + </details> + )} </section> ); } diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1506,6 +1506,41 @@ const ONSCREEN_BUILD = writeProject( ]), ); +// onscreen-posts-fixture: the Posts table writes here (onscreen-posts.spec.ts). +// The clips carry their own `date`, so which clip a post rides on is the +// date rule's answer and not the cue file's one shared upload day: +// p-early Aug 1 older than every clip -> c01 ("first") +// p-mid Sep 5 after c01's Sep 3 -> c01 ("date") +// p-late Sep 12 after c02's Sep 10 -> c02 ("date") +// Never built, so the posts' timing is the estimate's. +const ONSCREEN_POSTS = writeProject( + "onscreen-posts-fixture", + { + ...deckManifest("onscreen-posts-fixture", "The On-screen Posts Fixture", [ + { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because", date: "2024-09-03" }, + { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence", date: "2024-09-10" }, + { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" }, + ]), + posts: [ + { + id: "p-early", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", + date: "2024-08-01T12:00:00.000Z", text: "Older than every clip in the cut.", + url: "https://bsky.app/profile/fixture.example/post/early", + }, + { + id: "p-mid", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", + date: "2024-09-05T09:30:00.000Z", text: "Two days after the first clip.\nA second line.", + url: "https://bsky.app/profile/fixture.example/post/mid", + }, + { + id: "p-late", platform: "x", author: "Fixture Author", handle: "fixture", + date: "2024-09-12T18:00:00.000Z", text: "Two days after the second clip.", + url: "https://x.com/fixture/status/1", + }, + ], + }, +); + mkdirSync(path.join(reports, "bike-fixture"), { recursive: true }); writeFileSync( path.join(reports, "bike-fixture", "sweep-report.md"), @@ -1545,7 +1580,7 @@ ff([ // intermediates and are excluded by name. mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true }); -for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD]) { +for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD, ONSCREEN_POSTS]) { mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true }); copyFileSync( path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"), @@ -1666,5 +1701,6 @@ console.log(` editor-fetch-{,many-,reuse-}fixture (nothing cached — console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longform-edit-fixture, dash-fixture`); console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`); console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen-build-fixture (built with the deck)`); +console.log(` onscreen-posts-fixture (writable, deck on, three posts, unbuilt)`); console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`); console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`); diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts @@ -0,0 +1,264 @@ +import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { postWindows } from "umtool-report-to-video/deck"; + +// --------------------------------------------------------------------------- +// POSTS on the on-screen deck, as umtool edits them: the Posts table under the +// titles, its override and hide, the writer behind it, and the posts region +// in the live preview. +// +// One project, onscreen-posts-fixture (make-fixture.mjs): two dated clips and +// a card, three posts whose dates put them on c01 ("first"), c01 ("date") and +// c02 ("date"). Never built, so every timing is the estimate's. Each test puts +// the posts back to automatic and shown through the route before it starts. +// +// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`, slice +// P1 of plans/deck-posts.md). When it is there, the preview test asserts the +// window's page reports `posts:ready`; when it is not, the route says so per +// window and the test asserts that refusal instead, and annotates the run. +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.join(HERE, "..", ".e2e-song"); +const PROJECT = "reports/onscreen-posts-fixture"; +const DIR = path.join(FIXTURE, "reports", "onscreen-posts-fixture"); +const IDS = ["p-early", "p-mid", "p-late"]; + +type Post = { id: string; attachTo?: string | null; hide?: boolean }; +type Manifest = { render: Record<string, unknown>; posts: Post[] }; +const readManifest = (): Manifest => JSON.parse(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")); +const post = (id: string) => readManifest().posts.find((p) => p.id === id)!; +const enc = encodeURIComponent; + +type PostRow = { + id: string; + attachTo: string | null; + hide: boolean; + auto: { entryId: string; rule: string; label: string } | null; + effective: { entryId: string; rule: string } | null; + timing: { segment: string; appear: number } | null; +}; + +async function token(request: APIRequestContext): Promise<string> { + const j = (await (await request.get(`/api/report/chrome?project=${enc(PROJECT)}`)).json()) as { token: string }; + return j.token; +} + +async function putPosts(request: APIRequestContext, posts: Record<string, { attachTo?: string | null; hide?: boolean }>) { + const r = await request.put("/api/report/posts", { data: { project: PROJECT, posts, token: await token(request) } }); + expect(r.ok(), await r.text()).toBeTruthy(); +} + +async function rows(request: APIRequestContext): Promise<Record<string, PostRow>> { + const j = (await (await request.get(`/api/report/onscreen?project=${enc(PROJECT)}`)).json()) as { posts: PostRow[] }; + return Object.fromEntries(j.posts.map((p) => [p.id, p])); +} + +const reset = (request: APIRequestContext) => + putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }]))); + +async function openSection(page: Page) { + await page.goto(`/browse/${PROJECT}`); + await expect(page.getByTestId("onscreen-section")).toHaveAttribute("data-onscreen", "on"); + await expect(page.getByTestId("onscreen-post-row")).toHaveCount(3); +} + +const postRow = (page: Page, id: string) => page.locator(`[data-testid="onscreen-post-row"][data-post="${id}"]`); + +test.beforeEach(async ({ request }) => { + test.setTimeout(120_000); + test.skip( + !readManifest().render.fontRegular, + "the deck refuses to draw without render.fontRegular/fontBold, and make-fixture.mjs found no font on this machine", + ); + await reset(request); +}); + +test("the posts table shows each post with the clip the date rule puts it on", async ({ page, request }) => { + // The route first: the automatic clip and the effective one, and a slot in + // the estimated schedule. + const r = await rows(request); + expect(r["p-early"].auto).toMatchObject({ entryId: "c01", rule: "first", label: "and because" }); + expect(r["p-mid"].auto).toMatchObject({ entryId: "c01", rule: "date" }); + expect(r["p-late"].auto).toMatchObject({ entryId: "c02", rule: "date", label: "another whole sentence" }); + for (const id of IDS) expect(r[id].effective?.entryId).toBe(r[id].auto?.entryId); + expect(r["p-late"].timing?.segment).toBe("c02"); + + await openSection(page); + const late = postRow(page, "p-late"); + await expect(late).toHaveAttribute("data-effective", "c02"); + await expect(late.getByTestId("onscreen-post-platform")).toHaveText("x"); + await expect(late.getByTestId("onscreen-post-handle")).toHaveText("@fixture"); + await expect(late.getByTestId("onscreen-post-link")).toHaveAttribute("href", "https://x.com/fixture/status/1"); + await expect(late.getByTestId("onscreen-post-text")).toHaveText("Two days after the second clip."); + await expect(late.getByTestId("onscreen-post-auto")).toHaveText("auto: c02 — another whole sentence"); + await expect(late.getByTestId("onscreen-post-attach")).toHaveValue(""); + await expect(late.getByTestId("onscreen-post-effective")).toContainText("automatic: c02 (2024-09-10)"); + await expect(postRow(page, "p-early")).toHaveAttribute("data-effective", "c01"); + await expect(postRow(page, "p-early").getByTestId("onscreen-post-auto")).toHaveText("auto: c01 — and because"); + await expect(page.getByTestId("onscreen-posts-save")).toBeDisabled(); +}); + +test("an override saves attachTo and moves the effective clip; auto deletes the key", async ({ page, request }) => { + await openSection(page); + const mid = postRow(page, "p-mid"); + await mid.getByTestId("onscreen-post-attach").selectOption("c02"); + await expect(mid).toHaveAttribute("data-dirty", "1"); + await expect(mid).toHaveAttribute("data-effective", "c02"); + await expect(mid.getByTestId("onscreen-post-effective")).toContainText("chosen: c02"); + // Unsaved: nothing written yet. + expect("attachTo" in post("p-mid")).toBe(false); + + const put = page.waitForRequest((q) => q.url().includes("/api/report/posts") && q.method() === "PUT"); + await page.getByTestId("onscreen-posts-save").click(); + // Only the post that changed, only the key that changed. + expect((await put).postDataJSON().posts).toEqual({ "p-mid": { attachTo: "c02" } }); + await expect.poll(() => post("p-mid").attachTo).toBe("c02"); + await expect(mid).toHaveAttribute("data-dirty", "0"); + const r = await rows(request); + expect(r["p-mid"].effective).toMatchObject({ entryId: "c02", rule: "attachTo" }); + // The automatic answer is still the date rule's. + expect(r["p-mid"].auto?.entryId).toBe("c01"); + expect(r["p-mid"].timing?.segment).toBe("c02"); + + // Back to auto: the key goes, it is not left as null. + await page.reload(); + await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02"); + await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption(""); + await page.getByTestId("onscreen-posts-save").click(); + await expect.poll(() => "attachTo" in post("p-mid")).toBe(false); + await expect(postRow(page, "p-mid")).toHaveAttribute("data-effective", "c01"); +}); + +test("hide persists across a reload, and showing it again deletes the key", async ({ page, request }) => { + await openSection(page); + const early = postRow(page, "p-early"); + await early.getByTestId("onscreen-post-hide").check(); + await expect(early).toHaveAttribute("data-hidden", "1"); + await expect(early.getByTestId("onscreen-post-attach")).toBeDisabled(); + await page.getByTestId("onscreen-posts-save").click(); + await expect.poll(() => post("p-early").hide).toBe(true); + + const r = await rows(request); + expect(r["p-early"].effective).toBeNull(); + // It still says where it WOULD ride. + expect(r["p-early"].auto?.entryId).toBe("c01"); + expect(r["p-early"].timing).toBeNull(); + + await page.reload(); + await expect(postRow(page, "p-early").getByTestId("onscreen-post-hide")).toBeChecked(); + await expect(postRow(page, "p-early")).toHaveAttribute("data-dirty", "0"); + await expect(postRow(page, "p-early").getByTestId("onscreen-post-effective")).toContainText("hidden"); + + await postRow(page, "p-early").getByTestId("onscreen-post-hide").uncheck(); + await page.getByTestId("onscreen-posts-save").click(); + await expect.poll(() => "hide" in post("p-early")).toBe(false); +}); + +test("a stale token is a 409; reloading keeps the edit and shows the other writer's", async ({ page, request }) => { + await openSection(page); + + // Somebody else writes after this page read its token. + await putPosts(request, { "p-late": { hide: true } }); + + await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption("c02"); + const refused = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT"); + await page.getByTestId("onscreen-posts-save").click(); + expect((await refused).status()).toBe(409); + await expect(page.getByTestId("onscreen-note")).toContainText("the manifest changed since you opened this"); + expect("attachTo" in post("p-mid")).toBe(false); + expect(post("p-late").hide).toBe(true); + + await page.getByTestId("onscreen-reload").click(); + await expect(page.getByTestId("onscreen-reload")).toHaveCount(0); + // The unsaved override survives... + await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02"); + await expect(postRow(page, "p-mid")).toHaveAttribute("data-dirty", "1"); + // ...and a post nobody touched here shows what the other writer saved, + // rather than reading as an edit the next save would quietly revert. + await expect(postRow(page, "p-late").getByTestId("onscreen-post-hide")).toBeChecked(); + await expect(postRow(page, "p-late")).toHaveAttribute("data-dirty", "0"); + + const saved = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT"); + await page.getByTestId("onscreen-posts-save").click(); + expect((await saved).status()).toBe(200); + await expect.poll(() => post("p-mid").attachTo).toBe("c02"); + expect(post("p-late").hide).toBe(true); +}); + +test("the writer refuses an unknown post and a clip that is not one, and writes nothing", async ({ request }) => { + const before = readFileSync(path.join(DIR, "video.manifest.json"), "utf8"); + const unknown = await request.put("/api/report/posts", { + data: { project: PROJECT, posts: { "p-mid": { hide: true }, nope: { hide: true } }, token: await token(request) }, + }); + expect(unknown.status()).toBe(400); + expect(((await unknown.json()) as { error: string }).error).toContain("no post with id nope"); + const card = await request.put("/api/report/posts", { + data: { project: PROJECT, posts: { "p-mid": { attachTo: "k01" } }, token: await token(request) }, + }); + expect(card.status()).toBe(400); + expect(((await card.json()) as { errors: string[] }).errors).toEqual([ + 'posts[1].attachTo "k01" is not a clip in the timeline', + ]); + expect(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")).toBe(before); +}); + +test("the preview composes the posts region per window and overlays it while the scrubber is inside", async ({ + page, + request, +}) => { + // The route: one window per clip that carries posts, the deck's own + // postWindows over the schedule it returns, at postsGeometry. + const pv = (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })).json()) as { + schedule: Parameters<typeof postWindows>[0] & { total: number }; + posts: { + geometry: { x: number; y: number; width: number; height: number }; + windows: { segment: string; from: number; to: number; src?: string; error?: string }[]; + }; + }; + expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule)); + expect(pv.posts.windows.map((w) => w.segment)).toEqual(["c01", "c02"]); + expect(pv.posts.geometry.width).toBe(600); + const composed = pv.posts.windows.every((w) => w.src); + if (!composed) { + // compose-chrome has no posts region on this branch yet (P1). The route + // made the call and says why it failed, per window, beside a working deck. + test.info().annotations.push({ type: "pending", description: "posts region not in compose-chrome yet (P1)" }); + for (const w of pv.posts.windows) expect(w.error).toMatch(/posts/); + } + + // The bench's strip asks for no posts windows at all. + const bench = (await ( + await request.post("/api/report/chrome/preview", { data: { project: PROJECT, posts: false } }) + ).json()) as { posts: { windows: unknown[] } }; + expect(bench.posts.windows).toEqual([]); + + await openSection(page); + await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); + await expect(page.locator("[data-posts-window]")).toHaveCount(2); + // Outside every window: nothing over the footage. k01 starts after the last. + await page.locator('[data-seg-jump="k01"]').click(); + await expect(page.getByTestId("onscreen-current")).toHaveText("k01"); + await expect(page.getByTestId("onscreen-posts-preview")).toHaveCount(0); + + await page.locator('[data-posts-window="c02"]').click(); + const overlay = page.getByTestId("onscreen-posts-preview"); + await expect(overlay).toHaveAttribute("data-segment", "c02"); + await expect(postRow(page, "p-late")).toHaveAttribute("data-current", "1"); + await expect(postRow(page, "p-mid")).toHaveAttribute("data-current", "0"); + if (composed) { + await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 }); + await expect(page.getByTestId("onscreen-posts-preview-iframe")).toHaveAttribute("src", /posts-preview-c02\/index\.html/); + } else { + await expect(overlay.getByTestId("onscreen-posts-preview-error")).toContainText("posts on c02: not composed"); + } + + // The overlay sits at the posts rect inside the 16:9 frame. + const frame = (await page.getByTestId("onscreen-preview").boundingBox())!; + const box = (await overlay.boundingBox())!; + const W = 1920; + expect(Math.abs((box.x - frame.x) / frame.width - pv.posts.geometry.x / W)).toBeLessThan(0.01); + expect(Math.abs(box.width / frame.width - pv.posts.geometry.width / W)).toBeLessThan(0.01); +}); 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); +}