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:
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);
+}