Archilyzer · Source

archilyzer

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

commit bb5af7f85708fea334ee14d18ce7026ff22e43bb
parent 03254fc0f64ed30e94747fd3148e60383b0a65b1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 00:29:19 -0400

report-to-video: posts on the deck — the contract and the pure core

plans/deck-posts.md fixes the rulings: a post rides on the clip whose
recording most closely precedes it (attachTo overrides, hide leaves it out),
a clip's posts stack from its end one every `seconds` and leave in the
transition, and the card carries the date, the words and the post's QR.

deck.mjs gains the deck.posts settings (validated with the rest),
validatePosts, clipDay, attachPosts, postSchedule, postWindows and
postsGeometry; deckSchedule carries `posts` only when there are some, so a
cut without them writes the schedule it always did.

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

Diffstat:
Aplans/deck-posts.md | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/deck.mjs | 212++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/report-to-video/deck.test.mjs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 383 insertions(+), 1 deletion(-)

diff --git a/plans/deck-posts.md b/plans/deck-posts.md @@ -0,0 +1,62 @@ +# Posts on the on-screen deck + +A report cut that wears the deck (`plans/onscreen-deck.md`) can carry written statements — a +Bluesky or X post — as cards over the footage. A post is not a segment: it rides on a clip, +appears near the end of it, and leaves in the transition to the next segment. + +## Rulings + +- **Attachment.** A post rides on the clip whose recording most closely PRECEDES it: the clip + with the latest day on or before the post's day (a clip's day is its own `date`, else its + record's upload date); ties go to the later clip in the cut; a post older than every clip goes + on the first. `attachTo: "<clip id>"` overrides. `hide: true` leaves a post out. +- **Timing.** A clip's posts, oldest first, stack: post j of k appears at A − seconds·(k − j), + where A is the start of the outgoing transition (the next segment's start under a crossfade, + 0.3 s before a hard cut, 0.3 s before the end of the cut). Each has `seconds` (default 2) alone + before the next stacks on; the last has the clip's final `seconds`. A clip too short for that + shares what it has after its incoming dissolve evenly. All of them leave together over the + transition. +- **Card.** The post's date, its words (clamped to `maxLines`, ellipsis), the author's handle and + platform, and a QR of the post's own permalink (`url`). The deck's palette and fonts. +- **Where.** A column inside the footage box, `inset` from its top and from the chosen side + (`position`, default top-right), `width` wide. Cards stack top-down; when a new card would + overflow the column, the oldest slide up and out. + +## Manifest + +```jsonc +"posts": [ + { "id": "bs-3msydljwjis2a", "platform": "bluesky", "author": "Pirate Software", + "handle": "piratesoftware.live", "date": "2026-08-13T19:05:26.424Z", + "text": "We just signed off on 51 page document …", + "url": "https://bsky.app/profile/piratesoftware.live/post/3msydljwjis2a", + "attachTo": null, "hide": false } ], +"render": { "chrome": { "engine": "hyperframes", "layout": "deck", + "deck": { "posts": { "show": true, "seconds": 2, "position": "top-right", "width": 600, + "qrSize": 120, "maxLines": 7, "inset": 24 } } } } +``` + +`validatePosts(posts, timeline)` and the `deck.posts` branch of `validateChrome` are the one +validator (unknown keys refused). Posts are drawn only under the deck; without it they are data +for the report and nothing in a build reads them. + +## Core (in `umtool/report-to-video/deck.mjs`, pure) + +| Export | → | +|---|---| +| `validatePosts(posts, timeline)` | sentences | +| `clipDay(entry, meta)` | `YYYY-MM-DD` or null | +| `attachPosts({posts, entries, metas})` | `[{id, entryId, rule: "attachTo"\|"date"\|"first", clipDay}]` | +| `postSchedule({posts, entries, metas, segments, D, total, render})` | `[{id, segment, slot, of, appear, out:[a,b], date, text, author, handle, platform, url, qrUrl}]` | +| `postWindows(schedule)` | `[{segment, from, to}]` — one render window per carrying clip | +| `postsGeometry(render)` | `{x, y, width, height}` in the frame | +| `deckSchedule({…, posts})` / `estimateSchedule(manifest)` | the schedule gains `posts` (only when there are some) | + +## Slices + +| Slice | What | Owns | +|---|---|---| +| P0 | This file; the core and its tests | `deck.mjs`, `deck.test.mjs` | +| P1 | The posts region and its overlay: `chrome-posts.mjs` (card HTML, stack/slide choreography, `?still`, `?preview=1`), `composeChrome({region:"posts", window})` per `postWindows` entry with its own cache, the build passing `manifest.posts` to `writeChromeSchedule` and `validatePosts` at build start, the overlay of each window at its `from` in the crossfade concat, `applyChrome`, `--chrome-only` and `--chrome-preview`; verify-build; docs | `report-to-video/*` | +| P2 | umtool: `updatePosts(dir, {[id]: {attachTo?, hide?}}, {token})`, the On-screen section lists posts with their automatic clip and an override + hide, the preview shows the posts region in its windows; e2e | `umtool/lib`, `umtool/app/api/report`, `umtool/components`, `umtool/e2e` | +| P3 | First application: the ferret report's posts (report section + manifest) and a rebuild | outside the repo | diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -36,6 +36,9 @@ export const DECK_DEFAULTS = Object.freeze({ qr: Object.freeze({ show: true, size: 150 }), overCards: "hide", // | "show" motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }), + // The manifest's `posts`, drawn as cards over the footage at the end of the + // clip each one is attached to. position | "top-left". + posts: Object.freeze({ show: true, seconds: 2, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24 }), }); /** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */ @@ -164,6 +167,21 @@ export function validateChrome(chrome, render = {}) { errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`); } }); + sub("posts", ["show", "seconds", "position", "width", "qrSize", "maxLines", "inset"], (p) => { + if (p.show !== undefined && typeof p.show !== "boolean") errors.push(`${w}.posts.show must be true or false`); + if (p.seconds !== undefined && !numIn(p.seconds, 0.5, 10)) errors.push(`${w}.posts.seconds must be from 0.5 to 10`); + if (p.position !== undefined && !["top-right", "top-left"].includes(p.position)) { + errors.push(`${w}.posts.position must be "top-right" or "top-left"`); + } + if (p.width !== undefined && !(Number.isInteger(p.width) && numIn(p.width, 320, 900))) { + errors.push(`${w}.posts.width must be a whole number of pixels from 320 to 900`); + } + if (p.qrSize !== undefined && !numIn(p.qrSize, 80, 200)) errors.push(`${w}.posts.qrSize must be from 80 to 200`); + if (p.maxLines !== undefined && !(Number.isInteger(p.maxLines) && numIn(p.maxLines, 2, 14))) { + errors.push(`${w}.posts.maxLines must be a whole number from 2 to 14`); + } + if (p.inset !== undefined && !numIn(p.inset, 0, 80)) errors.push(`${w}.posts.inset must be from 0 to 80`); + }); sub("motion", ["out", "in", "pip"], (m) => { for (const k of ["out", "in"]) { if (m[k] !== undefined && !numIn(m[k], 0, 2)) errors.push(`${w}.motion.${k} must be from 0 to 2 seconds`); @@ -175,6 +193,11 @@ export function validateChrome(chrome, render = {}) { // make the setting lie about what was drawn. if (!errors.length) { const g = deckGeometry({ ...render, chrome }); + const pp = resolveDeck({ chrome }).posts; + if (pp.width + 2 * pp.inset > g.footage.width) { + errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`); + } + if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`); const room = g.H - g.deck.height; if (g.footage.height > room) { const max = Math.floor((room / g.H) * 1000) / 1000; @@ -431,11 +454,15 @@ export function deckQrUrl(entry, provenance = {}) { * end: number, title: string, subtitle: string, qrUrl: string|null, * hideDeck: boolean }> }} */ -export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false }) { +export function deckSchedule({ + entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [], +}) { const deck = resolveDeck(render); const { starts, total } = scheduleFrom(durs, D); const multiChannel = isMultiChannel(entries, provenance); const round = (v) => Math.round(v * 1000) / 1000; + const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: durs[i] })); + const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : []; return { version: 1, kind: "deck", @@ -458,6 +485,9 @@ export function deckSchedule({ entries, durs, D, render, provenance = {}, metas hideDeck: hidesDeck(e, deck), }; }), + // Present only when there are posts to draw, so a cut without them writes + // the schedule it always did. + ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), }; } @@ -467,6 +497,7 @@ export function estimateSchedule(manifest, { metas = [], noXfade = false } = {}) const entries = manifest.timeline ?? []; return deckSchedule({ entries, + posts: manifest.posts ?? [], durs: entries.map((e) => estimatedDuration(e, render)), D: transitionOf(render, { noXfade }), render, @@ -540,6 +571,185 @@ export function pipSegments(schedule) { } // --------------------------------------------------------------------------- +// Posts: written statements (a Bluesky or X post) drawn as cards over the +// footage. A post is not a segment -- it has no footage of its own -- so it +// rides on a clip: the clip whose recording most closely PRECEDES it, unless +// the post names one with `attachTo`. A clip's posts appear at the end of it, +// one every `posts.seconds`, stacking, so the last is up for the clip's last +// `seconds`; all of them leave in the transition to the next segment. +// --------------------------------------------------------------------------- + +export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]); +const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide"]; + +/** + * Every reason the manifest's `posts` cannot be built, as sentences. `timeline` + * is the whole manifest's: an `attachTo` must name one of its clips. + * + * @returns {string[]} + */ +export function validatePosts(posts, timeline = []) { + if (posts === undefined || posts === null) return []; + if (!Array.isArray(posts)) return ["posts must be a list"]; + const errors = []; + const clips = new Set(timeline.filter((e) => e.type === "clip").map((e) => e.id)); + const seen = new Set(); + posts.forEach((p, i) => { + const w = `posts[${i}]`; + if (!isObj(p)) { errors.push(`${w} must be an object`); return; } + unknownKeys(p, POST_KEYS, w, errors); + if (typeof p.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(p.id)) { + errors.push(`${w}.id must be letters, digits, dashes or underscores`); + } else if (seen.has(p.id)) errors.push(`${w}.id ${p.id} is used twice`); + else seen.add(p.id); + if (!POST_PLATFORMS.includes(p.platform)) errors.push(`${w}.platform must be ${POST_PLATFORMS.join(" or ")}`); + if (typeof p.date !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(p.date) || Number.isNaN(Date.parse(p.date))) { + errors.push(`${w}.date must be an ISO date or date-time`); + } + if (typeof p.text !== "string" || !p.text.trim()) errors.push(`${w}.text must be the post's words`); + else if (p.text.length > 3000) errors.push(`${w}.text is ${p.text.length} characters (at most 3000)`); + if (typeof p.url !== "string" || !/^https:\/\/\S+$/.test(p.url)) errors.push(`${w}.url must be the post's https link`); + for (const k of ["author", "handle"]) { + if (p[k] !== undefined && typeof p[k] !== "string") errors.push(`${w}.${k} must be a string`); + } + if (p.attachTo !== undefined && p.attachTo !== null && !clips.has(p.attachTo)) { + errors.push(`${w}.attachTo ${JSON.stringify(p.attachTo)} is not a clip in the timeline`); + } + if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`); + }); + return errors; +} + +/** The day a clip's recording is dated by: the clip's own `date`, else its record's upload date. */ +export function clipDay(entry, meta) { + if (entry.date) return entry.date; + const d = String(meta?.uploadDate ?? ""); + return /^\d{8}$/.test(d) ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : null; +} + +/** + * Which clip each post rides on. A post names its clip with `attachTo`, or + * takes the clip with the LATEST day on or before its own (ties: the later in + * the cut); a post older than every clip goes on the first. Hidden posts, and + * posts in a cut with no clips, are not placed. + * + * @returns {Array<{ id: string, entryId: string, rule: "attachTo"|"date"|"first", clipDay: string|null }>} + */ +export function attachPosts({ posts = [], entries = [], metas = [] }) { + const clips = entries + .map((e, i) => ({ e, i, day: e.type === "clip" ? clipDay(e, metas[i]) : null })) + .filter((c) => c.e.type === "clip"); + if (!clips.length) return []; + const out = []; + for (const p of posts) { + if (p.hide) continue; + if (p.attachTo) { + const c = clips.find((x) => x.e.id === p.attachTo); + if (c) { out.push({ id: p.id, entryId: c.e.id, rule: "attachTo", clipDay: c.day }); continue; } + // Not in this cut (a variant left it out): fall through to the date rule. + } + const day = String(p.date).slice(0, 10); + let best = null; + for (const c of clips) { + if (!c.day || c.day > day) continue; + if (!best || c.day > best.day || (c.day === best.day && c.i > best.i)) best = c; + } + out.push(best + ? { id: p.id, entryId: best.e.id, rule: "date", clipDay: best.day } + : { id: p.id, entryId: clips[0].e.id, rule: "first", clipDay: clips[0].day }); + } + return out; +} + +/** + * When each placed post is on screen, in the cut's clock. + * + * A clip's posts (oldest first) share an anchor A: the start of the outgoing + * transition -- the next segment's start with a crossfade, 0.3 s before the cut + * on a hard cut, 0.3 s before the end on the last segment. Post j of k appears + * at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone + * before the next stacks on and the last has the final seconds. A clip too + * short for that shares what it has after its incoming dissolve evenly. They + * all leave together over `out`. + * + * @returns {Array<{ id, segment, slot, of, appear, out: [number, number], date, text, + * author, handle, platform, url, qrUrl }>} + */ +export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render }) { + const settings = resolveDeck(render).posts; + const byId = new Map(posts.map((p) => [p.id, p])); + const groups = new Map(); + for (const a of attachPosts({ posts, entries, metas })) { + if (!groups.has(a.entryId)) groups.set(a.entryId, []); + groups.get(a.entryId).push(byId.get(a.id)); + } + const out = []; + segments.forEach((seg, i) => { + const group = groups.get(seg.id); + if (!group) return; + group.sort((x, y) => Date.parse(x.date) - Date.parse(y.date)); + const last = i === segments.length - 1; + const leave = last + ? [total - 0.3, total] + : D > 0 + ? [segments[i + 1].start, segments[i + 1].start + D] + : [segments[i + 1].start - 0.3, segments[i + 1].start]; + const A = leave[0]; + const from = seg.start + (i > 0 ? D : 0); + const k = group.length; + const step = Math.min(settings.seconds, Math.max(0, A - from) / k); + group.forEach((p, j) => { + out.push({ + id: p.id, + segment: seg.id, + slot: j, + of: k, + appear: A - step * (k - j), + out: leave, + date: p.date, + text: p.text, + author: p.author ?? "", + handle: p.handle ?? "", + platform: p.platform, + url: p.url, + qrUrl: p.url, + }); + }); + }); + return out; +} + +/** + * The windows the posts region is rendered for: one per clip that carries + * posts, from its first post's appearance to the end of its leave. Frames are + * only made for these seconds; the overlay places each at its `from`. + */ +export function postWindows(schedule) { + const by = new Map(); + for (const p of schedule.posts ?? []) { + const w = by.get(p.segment) ?? { segment: p.segment, from: Infinity, to: -Infinity }; + w.from = Math.min(w.from, p.appear); + w.to = Math.max(w.to, p.out[1]); + by.set(p.segment, w); + } + return [...by.values()].sort((a, b) => a.from - b.from); +} + +/** + * Where the posts region sits in the frame: a column inside the footage box, + * `inset` from its top and from the chosen side, `width` wide and the box's + * height less the insets. Cards stack top-down inside it. + */ +export function postsGeometry(render) { + const { footage: f } = deckGeometry(render); + const p = resolveDeck(render).posts; + const width = even(p.width); + const height = even(f.height - 2 * p.inset); + const x = p.position === "top-left" ? f.x + p.inset : f.x + f.width - p.inset - width; + return { x: Math.round(x), y: Math.round(f.y + p.inset), width, height }; +} + +// --------------------------------------------------------------------------- // The render cache and the renderer command. // --------------------------------------------------------------------------- diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs @@ -239,3 +239,113 @@ test("hyperframesCommand: pinned by default, overridable", () => { assert.equal(hyperframesCommand({ HYPERFRAMES_PKG: "hyperframes@1.0.0" }).version, "hyperframes@1.0.0"); assert.deepEqual(hyperframesCommand({ HYPERFRAMES_BIN: "/stub" }), { cmd: "/stub", args: [], version: "bin:/stub" }); }); + +// ---- posts ----------------------------------------------------------------- +import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, validatePosts } from "./deck.mjs"; + +const POST = (id, date, extra = {}) => ({ + id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, ...extra, +}); +const CLIPS = [ + { id: "c1", type: "clip", video: "a", start: 0, end: 10, date: "2024-09-05" }, + { id: "c2", type: "clip", video: "b", start: 0, end: 12 }, // dated by its record + { id: "k1", type: "card", seconds: 4 }, + { id: "c3", type: "clip", video: "c", start: 0, end: 4, date: "2025-12-08" }, +]; +const METAS = [null, { uploadDate: "20240929" }, null, null]; + +test("clipDay: the clip's own date wins over its record's upload date", () => { + assert.equal(clipDay({ date: "2025-04-01" }, { uploadDate: "20260815" }), "2025-04-01"); + assert.equal(clipDay({}, { uploadDate: "20240929" }), "2024-09-29"); + assert.equal(clipDay({}, null), null); +}); + +test("attachPosts: the latest clip on or before the post; ties go later; attachTo wins; hidden skipped", () => { + const posts = [ + POST("p1", "2024-10-19T16:53:06Z"), // after c2 (09-29), before c3 + POST("p2", "2024-09-05T23:00:00Z"), // same day as c1 + POST("p3", "2020-01-01"), // before every clip + POST("p4", "2026-02-09", { attachTo: "c1" }), + POST("p5", "2026-02-09", { hide: true }), + POST("p6", "2026-01-22"), + ]; + assert.deepEqual(attachPosts({ posts, entries: CLIPS, metas: METAS }), [ + { id: "p1", entryId: "c2", rule: "date", clipDay: "2024-09-29" }, + { id: "p2", entryId: "c1", rule: "date", clipDay: "2024-09-05" }, + { id: "p3", entryId: "c1", rule: "first", clipDay: "2024-09-05" }, + { id: "p4", entryId: "c1", rule: "attachTo", clipDay: "2024-09-05" }, + { id: "p6", entryId: "c3", rule: "date", clipDay: "2025-12-08" }, + ]); + // Two clips on one day: the later in the cut. + const twin = [{ ...CLIPS[0] }, { ...CLIPS[0], id: "c1b" }]; + assert.equal(attachPosts({ posts: [POST("q", "2024-09-06")], entries: twin })[0].entryId, "c1b"); + assert.deepEqual(attachPosts({ posts: [POST("q", "2024-09-06")], entries: [{ id: "k", type: "card" }] }), []); +}); + +test("postSchedule: stacked from the end, one every `seconds`, leaving in the transition", () => { + const segments = [ + { id: "c1", start: 0, duration: 10 }, + { id: "c2", start: 9.5, duration: 12 }, + { id: "k1", start: 21, duration: 4 }, + { id: "c3", start: 24.5, duration: 4 }, + ]; + const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("z", "2026-01-22")]; + const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER }); + // c2 carries a and b; its leave is the dissolve into k1 at 21. + assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.of, p.appear, p.out]), [ + ["a", 0, 2, 17, [21, 21.5]], + ["b", 1, 2, 19, [21, 21.5]], + ]); + // The last segment leaves 0.3 s before the end; a 4 s clip with 3.2 s after + // its incoming dissolve still gives its one post the full 2 s. + const z = s.find((p) => p.id === "z"); + assert.deepEqual([z.segment, z.appear, z.out], ["c3", 26.2, [28.2, 28.5]]); + assert.equal(z.qrUrl, z.url); + // A hard cut: the leave ends AT the cut. + const h = postSchedule({ posts, entries: CLIPS, metas: METAS, segments: [ + { id: "c1", start: 0, duration: 10 }, { id: "c2", start: 10, duration: 12 }, + { id: "k1", start: 22, duration: 4 }, { id: "c3", start: 26, duration: 4 }, + ], D: 0, total: 30, render: RENDER }); + assert.deepEqual(h.find((p) => p.id === "b").out, [21.7, 22]); + // Too short for k × seconds: what is left after the incoming dissolve is shared. + const many = ["m1", "m2", "m3", "m4"].map((id) => POST(id, "2026-01-01")); + const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER }); + assert.deepEqual(sq.map((p) => Number(p.appear.toFixed(3))), [25, 25.8, 26.6, 27.4]); + // Windows: one per carrying clip, first appearance to the end of the leave. + assert.deepEqual(postWindows({ posts: s }), [ + { segment: "c2", from: 17, to: 21.5 }, + { segment: "c3", from: 26.2, to: 28.5 }, + ]); +}); + +test("deckSchedule carries posts only when there are some; posts.show false drops them", () => { + const timeline = CLIPS; + const base = estimateSchedule({ render: RENDER, provenance: PROV, timeline }); + assert.equal("posts" in base, false); + const withPosts = estimateSchedule({ render: RENDER, provenance: PROV, timeline, posts: [POST("p", "2026-01-01")] }); + assert.equal(withPosts.posts.length, 1); + assert.equal(withPosts.posts[0].segment, "c3"); + const off = { ...RENDER, chrome: { ...CHROME, deck: { posts: { show: false } } } }; + assert.equal("posts" in estimateSchedule({ render: off, provenance: PROV, timeline, posts: [POST("p", "2026-01-01")] }), false); +}); + +test("postsGeometry: a column inside the footage box", () => { + assert.deepEqual(postsGeometry(RENDER), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 }); + const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10 } } } }; + assert.deepEqual(postsGeometry(left), { x: 183, y: 12, width: 500, height: 866 }); +}); + +test("validatePosts and the posts settings refuse in sentences", () => { + assert.deepEqual(validatePosts(undefined), []); + assert.deepEqual(validatePosts([POST("p1", "2024-10-19T16:53:06.587Z")], CLIPS), []); + const errs = validatePosts([ + POST("p1", "yesterday"), POST("p1", "2024-01-01", { platform: "mastodon", url: "http://x", attachTo: "k1", extra: 1 }), + ], CLIPS); + for (const re of [/date/, /used twice/, /platform/, /https link/, /attachTo "k1" is not a clip/, /extra is not a deck setting/]) { + assert.ok(errs.some((e) => re.test(e)), `missing ${re}: ${errs.join(" | ")}`); + } + assert.match(validateChrome({ ...CHROME, deck: { posts: { width: 2000 } } }, RENDER)[0], /posts.width/); + assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { width: 900, inset: 80 } } }, RENDER).join(" "), /does not fit the 960px footage box/); + assert.match(validateChrome({ ...CHROME, deck: { posts: { position: "middle" } } }, RENDER)[0], /position/); + assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { show: false } } }, RENDER), []); +});