// Tests for deck.mjs and the deck half of attribution.mjs -- the pure core the // build, the composition and umtool all read. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; import { attributionLine, attributionParts, deckSubtitle, formatDeckDate } from "./attribution.mjs"; import { assertChrome, chromeCacheKey, DECK_DEFAULTS, deckChoreography, deckGeometry, deckOn, deckQrUrl, deckSchedule, deckText, estimatedDuration, estimateSchedule, even, frameCount, hyperframesCommand, HYPERFRAMES_PKG_DEFAULT, isMultiChannel, normalizeOnscreen, pipSegments, pipXs, resolveDeck, scheduleFrom, validateChrome, } from "./deck.mjs"; const CHROME = { engine: "hyperframes", layout: "deck", deck: {} }; const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, chrome: CHROME }; const PROV = { siteOrigin: "https://example.pages.dev", channelSlug: "chan", channel: "Chan" }; test("deckOn: only the hyperframes deck", () => { assert.equal(deckOn(RENDER), true); assert.equal(deckOn({}), false); assert.equal(deckOn({ chromeEngine: "hyperframes" }), false); assert.equal(deckOn({ chrome: { engine: "hyperframes", layout: "band" } }), false); }); test("resolveDeck merges one level deep", () => { const d = resolveDeck({ chrome: { ...CHROME, deck: { height: 200, pip: { size: 8 } } } }); assert.equal(d.height, 200); assert.deepEqual(d.pip, { spacing: "even", size: 8, activeSize: 12 }); assert.deepEqual(d.motion, DECK_DEFAULTS.motion); }); test("deckGeometry: the ferret numbers", () => { const g = deckGeometry(RENDER); assert.deepEqual(g.footage, { x: 173, y: 2, width: 1574, height: 886 }); assert.deepEqual(g.deck, { x: 0, y: 890, width: 1920, height: 190 }); assert.equal(even(885.375), 886); }); test("validateChrome: defaults are valid; refusals are sentences", () => { assert.deepEqual(validateChrome(CHROME, RENDER), []); assert.deepEqual(validateChrome(undefined, RENDER), []); const bad = (chrome, render = RENDER) => validateChrome(chrome, render); assert.match(bad({ ...CHROME, engine: "ffmpeg" })[0], /engine/); assert.match(bad(CHROME, { ...RENDER, rail: {} })[0], /rail/); assert.match(bad(CHROME, { ...RENDER, chromeEngine: "hyperframes" })[0], /chromeEngine/); assert.match(bad({ ...CHROME, deck: { footageScle: 0.8 } })[0], /footageScle is not a deck setting/); assert.match(bad({ ...CHROME, deck: { height: 50 } })[0], /height/); assert.match(bad({ ...CHROME, deck: { pip: { size: 10, activeSize: 6 } } })[0], /activeSize/); assert.match(bad({ ...CHROME, deck: { subtitle: { parts: ["date", "date"] } } })[0], /parts/); assert.deepEqual(bad({ ...CHROME, deck: { subtitle: { parts: ["clock", "title"] } } }), []); assert.match(bad({ ...CHROME, deck: { qr: { size: 180 } } })[0], /does not fit a 190px deck/); assert.match(bad({ ...CHROME, deck: { motion: { in: 5 } } })[0], /motion.in/); // The footage must fit above the deck: 0.9 of 1080 is 972 > 890. assert.match(bad({ ...CHROME, deck: { footageScale: 0.9 } })[0], /at most 0.824/); assert.throws(() => assertChrome({ ...CHROME, layout: "x" }, RENDER), /render.chrome: .*layout/); }); test("normalizeOnscreen trims, drops empties, refuses odd shapes", () => { assert.deepEqual(normalizeOnscreen({ title: " A ", subtitle: "" }), { title: "A" }); assert.equal(normalizeOnscreen({ title: " ", subtitle: "" }), null); assert.equal(normalizeOnscreen(null), null); assert.throws(() => normalizeOnscreen({ heading: "x" }), /onscreen.heading/); assert.throws(() => normalizeOnscreen({ title: "a\nb" }), /one line/); assert.throws(() => normalizeOnscreen({ title: 3 }), /string/); }); test("pipXs: even, a single pip, by time", () => { assert.deepEqual(pipXs(0, 0, 100), []); assert.deepEqual(pipXs(1, 0, 100), [50]); assert.deepEqual(pipXs(3, 0, 100), [0, 50, 100]); const sched = { total: 10, segments: [{ start: 0, duration: 2 }, { start: 2, duration: 8 }] }; assert.deepEqual(pipXs(2, 0, 100, "time", sched), [10, 60]); assert.throws(() => pipXs(3, 0, 100, "time", sched), /one segment per pip/); }); test("scheduleFrom is segmentOffsets' arithmetic", () => { // The same sum the crossfaded concat performs: each join overlaps by D. const durs = [10, 5, 7]; assert.deepEqual(scheduleFrom(durs, 0.5), { starts: [0, 9.5, 14], total: 21 }); assert.deepEqual(scheduleFrom(durs, 0), { starts: [0, 10, 15], total: 22 }); assert.deepEqual(scheduleFrom([], 0.5), { starts: [], total: 0 }); }); test("estimatedDuration: play window, cut with lead-in, card and image seconds", () => { assert.equal(estimatedDuration({ type: "clip", start: 10, end: 25 }), 15); assert.equal( estimatedDuration({ type: "clip", start: 10, end: 25, cutStart: 12, cutEnd: 20 }), 8.4, ); assert.equal(estimatedDuration({ type: "card", seconds: 6 }), 6); assert.equal(estimatedDuration({ type: "image" }), 4); }); test("attributionParts rebuilds attributionLine exactly", () => { const entry = { cite: 3725, start: 3700 }; const meta = { title: "Stream 馃幃 !discord", uploadDate: "20260814", channel: "Chan" }; const p = attributionParts(entry, meta, PROV); assert.deepEqual(p, { channel: "Chan", title: "Stream", date: "2026-08-14", at: 3725 }); assert.equal(attributionLine(entry, meta, PROV), "Chan 路 Stream 路 2026-08-14 @ 1:02:05"); }); test("deckSubtitle: auto, multi-channel, explicit parts, iso, empties", () => { const p = { channel: "Chan", title: "Stream", date: "2026-08-14", at: 3725 }; assert.equal(formatDeckDate("2026-08-14"), "Aug 14, 2026"); assert.equal(formatDeckDate("2026-02-31"), "2026-02-31"); assert.equal(deckSubtitle(p), "Stream 路 Aug 14, 2026"); assert.equal(deckSubtitle(p, {}, true), "Chan 路 Stream 路 Aug 14, 2026"); assert.equal(deckSubtitle(p, { dateFormat: "iso" }), "Stream 路 2026-08-14"); assert.equal(deckSubtitle(p, { parts: ["date", "clock"] }), "Aug 14, 2026 路 1:02:05"); assert.equal(deckSubtitle({ ...p, title: "" }), "Aug 14, 2026"); }); test("deckText: onscreen overrides, clip/image/card fallbacks", () => { const deck = resolveDeck(RENDER); const meta = { title: "Stream", uploadDate: "20260814" }; const clip = { id: "c1", type: "clip", video: "v", start: 1, end: 9 }; assert.deepEqual(deckText(clip, meta, PROV, deck, false), { title: "", subtitle: "Stream 路 Aug 14, 2026" }); assert.deepEqual( deckText({ ...clip, onscreen: { title: "T", subtitle: "S" } }, meta, PROV, deck, false), { title: "T", subtitle: "S" }, ); assert.deepEqual( deckText({ id: "i", type: "image", title: "Poster", date: "2026-01-02" }, null, PROV, deck, true), { title: "", subtitle: "Poster 路 Jan 2, 2026" }, ); assert.deepEqual( deckText({ id: "k", type: "card", heading: "H", sub: "S" }, null, PROV, deck, false), { title: "H", subtitle: "S" }, ); }); test("deckQrUrl: citeUrl wins, derived at the start, images only when cited, cards never", () => { assert.equal(deckQrUrl({ type: "clip", citeUrl: "https://x/y" }, PROV), "https://x/y"); assert.equal( deckQrUrl({ type: "clip", video: "abc", start: 61.9 }, PROV), "https://example.pages.dev/?v=chan%2Fabc&t=61", ); assert.equal(deckQrUrl({ type: "image" }, PROV), null); assert.equal(deckQrUrl({ type: "image", citeUrl: "https://z" }, PROV), "https://z"); assert.equal(deckQrUrl({ type: "card" }, PROV), null); }); test("isMultiChannel counts clip channels only", () => { const clips = [{ type: "clip" }, { type: "clip", channel: "chan" }, { type: "card" }]; assert.equal(isMultiChannel(clips, PROV), false); assert.equal(isMultiChannel([...clips, { type: "clip", channel: "mirror" }], PROV), true); }); const TIMELINE = [ { id: "t0", type: "card", seconds: 5, heading: "Title" }, { id: "c1", type: "clip", video: "a", start: 0, end: 10, citeUrl: "https://q/1" }, { id: "c2", type: "clip", video: "b", start: 0, end: 6, onscreen: { title: "Two" } }, { id: "c3", type: "clip", video: "c", start: 0, end: 8 }, { id: "src", type: "card", seconds: 5 }, ]; test("deckSchedule / estimateSchedule: the schedule.json shape", () => { const s = estimateSchedule({ render: RENDER, provenance: PROV, timeline: TIMELINE }); assert.equal(s.version, 1); assert.equal(s.kind, "deck"); assert.equal(s.estimated, true); assert.equal(s.transition, 0.5); assert.equal(s.total, 32); assert.deepEqual(s.segments.map((x) => x.start), [0, 4.5, 14, 19.5, 27]); assert.deepEqual(s.segments.map((x) => x.hideDeck), [true, false, false, false, true]); assert.equal(s.segments[1].qrUrl, "https://q/1"); assert.equal(s.segments[2].title, "Two"); assert.equal(s.segments[0].qrUrl, null); // A real build passes probed durations and gets estimated: false. const b = deckSchedule({ entries: TIMELINE, durs: [5, 10, 6, 8, 5], D: 0.5, render: RENDER, provenance: PROV }); assert.equal(b.estimated, false); assert.equal(b.total, s.total); // qr.show false clears every code. const noQr = estimateSchedule({ render: { ...RENDER, chrome: { ...CHROME, deck: { qr: { show: false } } } }, provenance: PROV, timeline: TIMELINE, }); assert.ok(noQr.segments.every((x) => x.qrUrl === null)); assert.deepEqual(pipSegments(s).map((x) => x.id), ["c1", "c2", "c3"]); }); test("deckChoreography: handovers centred on the mid-dissolve; slides over cards", () => { const s = estimateSchedule({ render: RENDER, provenance: PROV, timeline: TIMELINE }); const { handovers, visibility } = deckChoreography(s, RENDER); assert.deepEqual(handovers.map((h) => [h.from, h.to]), [["c1", "c2"], ["c2", "c3"]]); const h = handovers[0]; assert.equal(h.m, 14.25); assert.deepEqual(h.out, [13.95, 14.25]); assert.deepEqual(h.in, [14.25, 14.7]); assert.deepEqual(h.sub.map((v) => Number(v.toFixed(3))), [14.33, 14.78]); assert.deepEqual(h.qr, [14.125, 14.375]); assert.deepEqual(h.pip, [13.9, 14.6]); assert.deepEqual(visibility, [ { i: 0, hide: true, at: [0, 0] }, { i: 1, hide: false, at: [4.5, 5] }, { i: 4, hide: true, at: [27, 27.5] }, ]); // A hard cut: m is the cut itself, and the slide finishes by it. const hard = estimateSchedule({ render: { ...RENDER, transition: 0 }, provenance: PROV, timeline: TIMELINE }); const c = deckChoreography(hard, { ...RENDER, transition: 0 }); assert.equal(c.handovers[0].m, hard.segments[2].start); assert.deepEqual(c.visibility[2].at, [hard.segments[4].start - 0.45, hard.segments[4].start]); }); test("chromeCacheKey is stable and sensitive", () => { const base = { html: "

", assets: [["b.png", "2"], ["a.ttf", "1"]], fps: 30, frames: 900, version: "hyperframes@0.8.24" }; const k = chromeCacheKey(base); assert.match(k, /^[0-9a-f]{64}$/); assert.equal(chromeCacheKey({ ...base, assets: [["a.ttf", "1"], ["b.png", "2"]] }), k); for (const change of [{ html: "

" }, { fps: 60 }, { frames: 901 }, { version: "hyperframes@0.8.25" }, { assets: [["a.ttf", "1"], ["b.png", "3"]] }]) { assert.notEqual(chromeCacheKey({ ...base, ...change }), k); } assert.equal(frameCount(351.2, 30), 10536); }); test("hyperframesCommand: pinned by default, overridable", () => { assert.deepEqual(hyperframesCommand({}), { cmd: "npx", args: ["--yes", HYPERFRAMES_PKG_DEFAULT], version: HYPERFRAMES_PKG_DEFAULT }); 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, shiftedFootage, validatePosts } from "./deck.mjs"; // The first posts release's settings, which the timing tests below were written for. const POSTS2 = { ...RENDER, chrome: { ...CHROME, deck: { posts: { seconds: 2, hold: 0, shift: false } } } }; 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: POSTS2 }); // 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: POSTS2 }); 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: POSTS2 }); 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("postSchedule at the clip's start: manifest order, the whole clip shared", () => { 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 AT_START = { ...RENDER, chrome: { ...CHROME, deck: { posts: { at: "start", hold: 0, shift: false } } } }; // Listed newest first: a popup at the start keeps that order. const posts = [POST("w", "2026-01-01", { attachTo: "c2", platform: "web", url: "https://example.org/a" }), POST("x", "2024-10-19", { attachTo: "c2" }), POST("y", "2024-01-01", { attachTo: "c1" })]; const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: AT_START }); // c2 runs 10 (after its dissolve) to 21 (its leave): 11 s, two posts, 5.5 s each. assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.appear, p.out]), [ ["w", 0, 10, [21, 21.5]], ["x", 1, 15.5, [21, 21.5]], ]); // The first clip has no incoming dissolve: its post is up from 0. assert.deepEqual(s.filter((p) => p.segment === "c1").map((p) => [p.id, p.appear]), [["y", 0]]); assert.equal(s.find((p) => p.id === "w").platform, "web"); }); test("posts: at, shotMaxHeight, variant and the web platform are validated", () => { const chrome = (posts) => validateChrome({ ...CHROME, deck: { posts } }, RENDER); assert.deepEqual(chrome({ at: "start", shotMaxHeight: 800 }), []); assert.deepEqual(chrome({ shotMaxHeight: null }), []); assert.match(chrome({ at: "middle" }).join(), /posts\.at must be "end" or "start"/); assert.match(chrome({ shotMaxHeight: 40 }).join(), /shotMaxHeight must be null or a whole number/); assert.deepEqual(validatePosts([POST("a", "2026-01-01", { platform: "web", url: "https://example.org/a", variant: "full" })]), []); assert.match(validatePosts([POST("a", "2026-01-01", { variant: "short" })]).join(), /variant must be "sourced" or "full"/); }); 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 (no shift), at the frame's edge (shift)", () => { assert.deepEqual(postsGeometry(POSTS2), { 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, shift: false } } } }; assert.deepEqual(postsGeometry(left), { x: 183, y: 12, width: 500, height: 866 }); // Shift on (the default): the column moves to the frame's right edge. assert.deepEqual(postsGeometry(RENDER), { x: 1920 - 24 - 600, y: 26, width: 600, height: 838 }); }); 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), []); }); test("posts fit: a deck without posts is never refused for the column; drawn posts are", () => { // 1280脳720 at 0.5: a 640 px footage box, narrower than the default column. const narrow = { width: 1280, height: 720, fps: 30, transition: 0.5, chrome: { ...CHROME, deck: { footageScale: 0.5 } } }; assert.deepEqual(validateChrome(narrow.chrome, narrow), []); // Posts that will be drawn there are refused, with the column's sentence. assert.match(validatePosts([POST("p", "2024-01-01")], CLIPS, narrow).join(" "), /does not fit the 640px footage box/); // Hidden, switched off, or no render to check against: nothing to fit. assert.deepEqual(validatePosts([POST("p", "2024-01-01", { hide: true })], CLIPS, narrow), []); const off = { ...narrow, chrome: { ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } } }; assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS, off), []); assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS), []); // A deck that SETS posts is still held to the fit by validateChrome. assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { width: 600 } } }, narrow).join(" "), /does not fit/); // ...unless it switches them off. assert.deepEqual(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } }, narrow), []); }); test("posts hold: a carrying clip is held on its last frame, and every start after it moves", () => { // CLIPS: c1 (2024-09-05), c2 (2024-09-29 by record), k1 card, c3 (2025-12-08). const posts = [POST("a", "2024-10-19"), POST("z", "2026-01-22")]; const durs = [10, 12, 4, 4]; const base = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS }); const held = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts }); assert.equal("hold" in base.segments[1], false); assert.deepEqual(held.segments.map((x) => x.hold ?? 0), [0, 2.5, 0, 2.5]); assert.deepEqual(held.segments.map((x) => x.duration), [10, 14.5, 4, 6.5]); assert.deepEqual(held.segments.map((x) => x.start), [0, 9.5, 23.5, 27]); assert.equal(held.total, base.total + 5); // Four seconds each by default: c2's one post is up for its last 4 s, hold included. const a = held.posts.find((p) => p.id === "a"); assert.deepEqual([a.segment, a.appear, a.out], ["c2", 19.5, [23.5, 24]]); // hold 0: nothing held, the schedule's lengths are the segments'. const none = { ...RENDER, chrome: { ...CHROME, deck: { posts: { hold: 0 } } } }; const h0 = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: none, provenance: PROV, metas: METAS, posts }); assert.deepEqual(h0.segments.map((x) => x.duration), durs); }); test("posts shift: the footage moves aside from the column while a clip's posts are up", () => { const to = shiftedFootage(RENDER); // 86 % of the 1574脳886 box, 24 px from the left, centred above the deck. assert.deepEqual(to, { x: 24, y: 64, width: 1354, height: 762 }); // Room: the column at the frame edge starts at 1296; the footage ends at 1378. assert.equal(postsGeometry(RENDER).x - (to.x + to.width), -82); const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left" } } } }; assert.equal(shiftedFootage(left).x, 1920 - 24 - 1354); assert.equal(shiftedFootage(POSTS2), null); const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27")]; const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts }); assert.deepEqual(s.moves, [{ segment: "c2", at: 15.5, segmentAt: 6, seconds: 0.6, from: { x: 173, y: 2, width: 1574, height: 886 }, to, }]); assert.equal("moves" in deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: POSTS2, provenance: PROV, metas: METAS, posts }), false); // Validation of the new keys. assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { shift: false, hold: 0 } } }, RENDER), []); assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { scale: 0.2 } } } }, RENDER)[0], /shift.scale/); assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { speed: 1 } } } }, RENDER)[0], /speed is not a deck setting/); assert.match(validateChrome({ ...CHROME, deck: { posts: { hold: 20 } } }, RENDER)[0], /hold/); }); import { pageDuration } from "./deck.mjs"; import { deckHtml } from "./chrome-deck.mjs"; test("pageDuration: whole frames, floored to 4 decimals, so HyperFrames' ceil(duration 脳 fps) is the build's frame count", () => { // On the grid: written as before. for (const v of [14.4, 7.9, 356.7, 7, 0.5, 14.2]) assert.equal(pageDuration(v, 30), v); // 434 frames is 14.4667 s, and a schedule writes 14.467: both read as 434 frames, never 435. for (const v of [14.467, 14.4667, 434 / 30]) { const d = pageDuration(v, 30); assert.equal(d, 14.4666); assert.equal(Math.ceil(d * 30), 434); assert.equal(frameCount(d, 30), frameCount(v, 30)); } assert.equal(pageDuration(14.767, 30), 14.7666); assert.equal(Math.ceil(pageDuration(14.633, 30) * 30), 439); assert.equal(pageDuration(10.04, 25), 10.04); }); test("the deck page states pageDuration of the cut, not its millisecond total", () => { const timeline = [ { id: "a", type: "clip", video: "v", start: 0, end: 7.0667 }, { id: "b", type: "clip", video: "v", start: 10, end: 17.9 }, ]; const R = { ...RENDER, palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" } }; const s = deckSchedule({ entries: timeline, durs: [212 / 30, 7.9], D: 0.5, render: R, provenance: PROV }); assert.equal(s.total, 14.467); assert.match(deckHtml(s, R), /data-composition-id="deck" data-start="0" data-duration="14\.4666"/); }); test("posts[].shot: a picture relative to the manifest, inside its folder; carried onto the placed post", () => { assert.deepEqual(validatePosts([POST("p1", "2024-10-19", { shot: "shots/p1.png" })], CLIPS), []); assert.deepEqual(validatePosts([POST("p1", "2024-10-19", { shot: null })], CLIPS), []); for (const [shot, re] of [ ["/abs/p1.png", /shot must be relative to the manifest and inside its folder/], ["../p1.png", /shot must be relative to the manifest and inside its folder/], ["shots/../../p1.png", /inside its folder/], ["shots/p1.gif", /shot must be a \.png, \.jpg, \.jpeg, \.webp picture/], ["", /shot must be a path to the post's screenshot/], ]) { assert.match(validatePosts([POST("p1", "2024-10-19", { shot })], CLIPS).join(" | "), re, shot); } const segs = [{ id: "c1", start: 0, duration: 10 }, { id: "c2", start: 9.5, duration: 10 }]; const placed = postSchedule({ posts: [POST("p1", "2024-10-19", { shot: "shots/p1.png" }), POST("p2", "2024-10-20")], entries: CLIPS.slice(0, 2), segments: segs, D: 0.5, total: 19.5, render: RENDER, }); assert.equal(placed.find((p) => p.id === "p1").shot, "shots/p1.png"); assert.equal("shot" in placed.find((p) => p.id === "p2"), false, "a post without one places as it always did"); }); test("qr.links: \"site\" by default, \"original\" accepted, anything else refused", () => { assert.equal(DECK_DEFAULTS.qr.links, "site"); assert.deepEqual(resolveDeck({ chrome: { ...CHROME, deck: { qr: { size: 120 } } } }).qr, { show: true, size: 120, links: "site" }); assert.deepEqual(validateChrome({ ...CHROME, deck: { qr: { links: "original" } } }, RENDER), []); assert.match(validateChrome({ ...CHROME, deck: { qr: { links: "x" } } }, RENDER)[0], /qr\.links must be "site" or "original"/); });