From d1af3ca26aef0d18c5ce752b85606204917c41b8 Mon Sep 17 00:00:00 2001 From: desek Date: Wed, 29 Jul 2026 13:35:29 +0200 Subject: [PATCH 1/3] feat(core): capture a whole deck as video from the CLI Adds `open-slide video`, which drives headless Chromium through the viewer and writes a single MP4 per deck. Animations are paused from document start and seeked by `currentTime` per frame, so existing CSS-keyframe decks are capturable with no authoring change. Inter-page transitions are captured from the viewer rather than composed at encode time, which keeps one definition of a transition and makes the output match what an audience sees. Step-gated decks fall back to one MP4 per page, reported with guidance on authoring a loop variant. Also fixes malformed CLI flags exiting 1 instead of 2. Commander only converts InvalidArgumentError into a code-2 exit, and its own default usage exit is 1, so this needs both the parser change and a program-level exitOverride. Applies to `export` as well, hence the separate patch changeset. --- .changeset/lazy-moons-shave.md | 5 + .changeset/wild-pumas-film.md | 5 + .gitignore | 2 + package.json | 1 + packages/core/src/cli/export.ts | 10 +- packages/core/src/cli/run.test.ts | 81 +- packages/core/src/cli/run.ts | 111 ++- packages/core/src/cli/video.test.ts | 585 +++++++++++++ packages/core/src/cli/video.ts | 1228 +++++++++++++++++++++++++++ pnpm-lock.yaml | 3 + 10 files changed, 2024 insertions(+), 7 deletions(-) create mode 100644 .changeset/lazy-moons-shave.md create mode 100644 .changeset/wild-pumas-film.md create mode 100644 packages/core/src/cli/video.test.ts create mode 100644 packages/core/src/cli/video.ts diff --git a/.changeset/lazy-moons-shave.md b/.changeset/lazy-moons-shave.md new file mode 100644 index 000000000..14078211f --- /dev/null +++ b/.changeset/lazy-moons-shave.md @@ -0,0 +1,5 @@ +--- +'@open-slide/core': patch +--- + +Exit with code 2 instead of 1 when a CLI flag is malformed, so usage errors stay distinguishable from runtime failures. diff --git a/.changeset/wild-pumas-film.md b/.changeset/wild-pumas-film.md new file mode 100644 index 000000000..8508d8d0a --- /dev/null +++ b/.changeset/wild-pumas-film.md @@ -0,0 +1,5 @@ +--- +'@open-slide/core': minor +--- + +Add an `open-slide video` command that captures a whole deck as a single MP4, seeking each page's animations and the deck's own slide transitions frame by frame. diff --git a/.gitignore b/.gitignore index 2852a3e55..7b9f7efdd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ packages/cli/template/.agents/skills packages/core/e2e/.scratch playwright-report test-results +png-export +video-export diff --git a/package.json b/package.json index 621c9d741..f89ff32a6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", "happy-dom": "20.9.0", + "playwright-chromium": "~1.56.1", "turbo": "^2.10.5", "vitest": "^2.1.9" } diff --git a/packages/core/src/cli/export.ts b/packages/core/src/cli/export.ts index 5fc045568..cbc9ee2e5 100644 --- a/packages/core/src/cli/export.ts +++ b/packages/core/src/cli/export.ts @@ -38,7 +38,15 @@ type PlaywrightChromium = { chromium: typeof Chromium }; const CANVAS_WIDTH = 1920; const CANVAS_HEIGHT = 1080; -const DEFAULT_TIMEOUT_MS = 15_000; +/** + * Per-page readiness timeout, in milliseconds, shared by every capture + * subcommand. + * + * Exported so `video.ts` consumes this one declaration rather than keeping a + * second copy that could drift from the `--timeout` help text both subcommands + * quote. + */ +export const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_OUT_DIR = './png-export'; /** diff --git a/packages/core/src/cli/run.test.ts b/packages/core/src/cli/run.test.ts index 57d64b965..1fed9c111 100644 --- a/packages/core/src/cli/run.test.ts +++ b/packages/core/src/cli/run.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from 'vitest'; -import { parsePort } from './run.ts'; +/** + * @agents-index Unit tests for the CLI's argument layer — the exported port + * parser, and the exit code a malformed flag produces after travelling through + * Commander. The second group exercises `run` rather than the parsers directly, + * because the parsers alone cannot show what Commander does with what they + * throw. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parsePort, run } from './run.ts'; describe('parsePort', () => { it('accepts valid integer ports', () => { @@ -24,3 +32,72 @@ describe('parsePort', () => { expect(() => parsePort('80.5')).toThrow(/Invalid port/); }); }); + +class ExitSignal extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +/** + * Parse `argv` through the real program and report the process exit code it + * asked for. + * + * Every argument here is rejected during parsing, so no action handler runs and + * nothing is launched. Returns `'no-exit'` if parsing completed without asking + * to exit, and rethrows anything that escaped `parseAsync` — which is what an + * argParser throwing a plain `Error` does, since Commander only converts its own + * `InvalidArgumentError` into a usage exit. + */ +async function exitCodeFor(argv: string[]): Promise { + const exit = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ExitSignal(Number(code ?? 0)); + }); + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + try { + await run(argv); + return 'no-exit'; + } catch (err) { + if (err instanceof ExitSignal) return err.code; + throw err; + } finally { + exit.mockRestore(); + stderr.mockRestore(); + stdout.mockRestore(); + } +} + +describe('usage exits', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + ['--fps', ['video', '--slide', 'intro', '--fps', '0']], + ['--page', ['video', '--slide', 'intro', '--page', '0']], + ['--duration', ['video', '--slide', 'intro', '--duration', '0']], + ['--dwell', ['video', '--slide', 'intro', '--dwell', '-1']], + ['--timeout', ['video', '--slide', 'intro', '--timeout', '0']], + ['--port', ['video', '--slide', 'intro', '--port', '99999']], + ])('an out-of-range %s exits 2 through the video subcommand', async (_flag, argv) => { + await expect(exitCodeFor(argv)).resolves.toBe(2); + }); + + it.each([ + ['--page', ['export', '--slide', 'intro', '--page', '0']], + ['--timeout', ['export', '--slide', 'intro', '--timeout', '0']], + ])('an out-of-range %s exits 2 through the export subcommand', async (_flag, argv) => { + await expect(exitCodeFor(argv)).resolves.toBe(2); + }); + + it('an unknown subcommand exits 2', async () => { + await expect(exitCodeFor(['not-a-command'])).resolves.toBe(2); + }); + + it('--help and --version exit 0', async () => { + await expect(exitCodeFor(['--help'])).resolves.toBe(0); + await expect(exitCodeFor(['--version'])).resolves.toBe(0); + await expect(exitCodeFor(['video', '--help'])).resolves.toBe(0); + }); +}); diff --git a/packages/core/src/cli/run.ts b/packages/core/src/cli/run.ts index 091c82ba3..ad13d45c8 100644 --- a/packages/core/src/cli/run.ts +++ b/packages/core/src/cli/run.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import * as readline from 'node:readline/promises'; import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; -import { Command, Option } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; import { detectSkillsDrift, syncSkills } from './sync.ts'; async function readVersion(): Promise { @@ -14,10 +14,16 @@ async function readVersion(): Promise { return (JSON.parse(raw) as { version: string }).version; } +/* + Every argParser below rejects with Commander's `InvalidArgumentError`, not a + plain `Error`. Only that subclass is caught by Commander and turned into a + usage exit of 2; a plain `Error` escapes `parseAsync` to `bin.ts`, which exits + 1 and misreports a usage error as a runtime failure. +*/ export function parsePort(value: string): number { const n = Number(value); if (!Number.isInteger(n) || n < 0 || n > 65535) { - throw new Error(`Invalid port: ${value}`); + throw new InvalidArgumentError(`Invalid port: ${value}`); } return n; } @@ -90,11 +96,29 @@ interface ExportCliFlags { timeout?: number; } +interface VideoCliFlags extends ExportCliFlags { + fps?: number; + duration?: number; + dwell?: number; + keepFrames?: boolean; + perPage?: boolean; +} + function parsePositiveInt(label: string) { return (value: string): number => { const n = Number(value); if (!Number.isInteger(n) || n < 1) { - throw new Error(`Invalid ${label}: ${value}`); + throw new InvalidArgumentError(`Invalid ${label}: ${value}`); + } + return n; + }; +} + +function parseNonNegativeInt(label: string) { + return (value: string): number => { + const n = Number(value); + if (!Number.isInteger(n) || n < 0) { + throw new InvalidArgumentError(`Invalid ${label}: ${value}`); } return n; }; @@ -115,7 +139,17 @@ export async function run(argv: string[]): Promise { .description('Author slides — we handle the Vite/React stack.') .version(version, '-v, --version', 'print version') .helpOption('-h, --help', 'show help') - .showHelpAfterError(chalk.dim('(run `open-slide --help` for usage)')); + .showHelpAfterError(chalk.dim('(run `open-slide --help` for usage)')) + /* + Commander exits 1 on a usage error and the subcommands reserve 1 for + runtime failure, so every non-zero Commander exit is remapped to 2. Zero + is left alone: `--help` and `--version` route through the same callback. + Declared before any `.command()` call because `copyInheritedSettings` snaps + the callback onto each subcommand at creation time. + */ + .exitOverride((err) => { + process.exit(err.exitCode === 0 ? 0 : 2); + }); program .command('dev') @@ -188,6 +222,75 @@ Examples: await exportCommand(flags); }); + program + .command('video') + .description('Capture a deck as MP4 by seeking its animations frame by frame') + .option('--slide ', 'restrict capture to a single deck') + .option('--all', 'capture every discoverable deck (mutually exclusive with --slide)') + .addOption( + new Option('--page ', 'capture a single 1-based page (requires --slide)').argParser( + parsePositiveInt('page'), + ), + ) + .option('--out ', 'destination directory (defaults to ./video-export)') + .option('--per-page', 'write one MP4 per page instead of one per deck') + .addOption( + new Option('--fps ', 'frames per second (default 30)').argParser(parsePositiveInt('fps')), + ) + .addOption( + new Option( + '--duration ', + 'override the measured page duration (default: longest finite animation)', + ).argParser(parsePositiveInt('duration')), + ) + .addOption( + new Option('--dwell ', "hold after each page's animation (default 1500)").argParser( + parseNonNegativeInt('dwell'), + ), + ) + .option('--keep-frames', 'keep the intermediate PNG frames instead of deleting them') + .addOption( + new Option('--port ', 'ephemeral dev-server port override').argParser(parsePort), + ) + .addOption( + new Option('--timeout ', 'per-page readiness timeout (default 15000)').argParser( + parsePositiveInt('timeout'), + ), + ) + .addHelpText( + 'after', + ` +Examples: + $ open-slide video --slide intro # one MP4 covering every page of the deck + $ open-slide video --slide intro --page 2 # a single page + $ open-slide video --all # one MP4 per discoverable deck + $ open-slide video --all --out ./tmp-video # custom output directory + $ open-slide video --all --fps 60 # every deck at 60fps + $ open-slide video --slide intro --dwell 3000 # hold each page 3s after its animation + $ open-slide video --slide intro --duration 5000 # force a 5s timeline per page + $ open-slide video --slide intro --per-page # one MP4 per page instead of one per deck + $ open-slide video --slide intro --keep-frames # keep the PNG frames for inspection + $ open-slide video --all --port 5174 # pin the ephemeral dev-server port + $ open-slide video --all --timeout 30000 # raise per-page readiness timeout to 30s + +Requires ffmpeg on PATH. Pages animate from CSS keyframes on the wall clock, so +every animation is paused and seeked explicitly — the capture is frame-exact and +reproducible rather than sampled on a timer. Pages are joined by the deck's own +slide transitions, captured from the viewer rather than composed at encode time. + +A deck with a step-gated page falls back to one MP4 per page, without --per-page: +a continuous capture cannot advance past a pending step. Each such page is +captured with every step already revealed, so no content is missing, but the +reveal beats and the inter-page transitions are not in the output. Under --all a +mixed workspace therefore produces both shapes in one run, and the summary states +which shape each deck got. +`, + ) + .action(async (flags: VideoCliFlags) => { + const { videoCommand } = await import('./video.ts'); + await videoCommand(flags); + }); + program .command('sync:skills') .description('Sync built-in skills from @open-slide/core into this workspace') diff --git a/packages/core/src/cli/video.test.ts b/packages/core/src/cli/video.test.ts new file mode 100644 index 000000000..a2c943cdc --- /dev/null +++ b/packages/core/src/cli/video.test.ts @@ -0,0 +1,585 @@ +/** + * @agents-index Unit tests for `open-slide video`'s pure planning layer — + * duration resolution, frame counting, the deck timeline (page and transition + * segments), step-gating detection, output-shape selection, filename + * derivation, run grouping, and flag validation. Nothing here launches Chromium or ffmpeg; + * the capture and encode paths are verified by hand. + */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { ExportUsageError } from './export.ts'; +import { + BOUNDARY_ANIMATION_ID, + DEFAULT_DWELL_MS, + FALLBACK_DURATION_MS, + frameCountFor, + frameFilenameFor, + groupTargetsByDeck, + LOOP_VARIANT_ELEMENTS, + MAX_DURATION_MS, + measureBoundary, + outputShapeFor, + outputShapeSummary, + pauseAndMeasure, + planDeckTimeline, + planPageSegment, + planTransitionSegment, + resolveDurationMs, + SETTLED_ANIMATION_ID, + seekTimeMsFor, + seekTo, + settleTransition, + slideCaptureUrl, + stepCountsByPage, + stepGatedPages, + stepGatedWarning, + totalFrameCount, + validateFlags, + videoFilenameFor, +} from './video.ts'; + +describe('resolveDurationMs', () => { + it('uses the measured value', () => { + expect(resolveDurationMs(1800)).toBe(1800); + }); + + it('falls back when nothing is measurable', () => { + expect(resolveDurationMs(0)).toBe(FALLBACK_DURATION_MS); + }); + + it('clamps a runaway duration', () => { + expect(resolveDurationMs(10_000_000)).toBe(MAX_DURATION_MS); + }); + + it('honours the override', () => { + expect(resolveDurationMs(1800, 500)).toBe(500); + expect(resolveDurationMs(0, 500)).toBe(500); + expect(resolveDurationMs(1800, 10_000_000)).toBe(MAX_DURATION_MS); + }); +}); + +describe('frameCountFor', () => { + it('never returns zero', () => { + expect(frameCountFor(0, 30)).toBe(1); + }); + + it('scales with fps', () => { + expect(frameCountFor(1000, 24)).toBe(24); + expect(frameCountFor(1000, 60)).toBe(60); + }); +}); + +describe('planDeckTimeline', () => { + it('orders pages and applies dwell', () => { + const segments = planDeckTimeline( + [ + { pageIndex: 0, longestMs: 1000 }, + { pageIndex: 1, longestMs: 2000 }, + ], + { fps: 30, dwellMs: 1000 }, + ); + + expect(segments).toEqual([ + { kind: 'page', pageIndex: 0, durationMs: 1000, dwellMs: 1000, frameCount: 60 }, + { kind: 'page', pageIndex: 1, durationMs: 2000, dwellMs: 1000, frameCount: 90 }, + ]); + }); + + it('applies one dwell to every page', () => { + const segments = planDeckTimeline( + [ + { pageIndex: 0, longestMs: 500 }, + { pageIndex: 1, longestMs: 1500 }, + { pageIndex: 2, longestMs: 0 }, + ], + { fps: 30, dwellMs: 1500 }, + ); + + expect(segments.map((s) => s.dwellMs)).toEqual([1500, 1500, 1500]); + }); + + it('defaults dwell to 1500ms', () => { + const [segment] = planDeckTimeline([{ pageIndex: 0, longestMs: 1000 }], { fps: 30 }); + + expect(DEFAULT_DWELL_MS).toBe(1500); + expect(segment.dwellMs).toBe(1500); + expect(segment.frameCount).toBe(75); + }); + + it('emits a transition segment from a measured boundary', () => { + const segments = planDeckTimeline( + [ + { pageIndex: 0, longestMs: 1000, boundaryMs: 400 }, + { pageIndex: 1, longestMs: 1000 }, + ], + { fps: 30, dwellMs: 0 }, + ); + + expect(segments.map((s) => s.kind)).toEqual(['page', 'transition', 'page']); + expect(segments[1]).toEqual({ + kind: 'transition', + pageIndex: 0, + durationMs: 400, + dwellMs: 0, + frameCount: 12, + }); + }); + + it('emits no transition segment for a single page', () => { + const segments = planDeckTimeline([{ pageIndex: 0, longestMs: 1000, boundaryMs: 400 }], { + fps: 30, + dwellMs: 0, + }); + + expect(segments).toHaveLength(1); + expect(segments[0].kind).toBe('page'); + }); + + it('emits no transition segment for a zero-length boundary', () => { + const segments = planDeckTimeline( + [ + { pageIndex: 0, longestMs: 1000, boundaryMs: 0 }, + { pageIndex: 1, longestMs: 1000 }, + ], + { fps: 30, dwellMs: 0 }, + ); + + expect(segments.map((s) => s.kind)).toEqual(['page', 'page']); + }); + + it('clamps a runaway boundary', () => { + const segments = planDeckTimeline( + [ + { pageIndex: 0, longestMs: 1000, boundaryMs: 10_000_000 }, + { pageIndex: 1, longestMs: 1000 }, + ], + { fps: 30, dwellMs: 0 }, + ); + + expect(segments[1].durationMs).toBe(MAX_DURATION_MS); + }); + + it('replaces every page duration with the override but leaves boundaries measured', () => { + const segments = planDeckTimeline( + [ + { pageIndex: 0, longestMs: 8000, boundaryMs: 400 }, + { pageIndex: 1, longestMs: 200 }, + ], + { fps: 30, dwellMs: 0, durationOverrideMs: 1000 }, + ); + + expect(segments.map((s) => s.durationMs)).toEqual([1000, 400, 1000]); + }); +}); + +describe('planPageSegment and planTransitionSegment', () => { + it('builds the segments planDeckTimeline emits, so the capture can plan a page at a time', () => { + const pages = [ + { pageIndex: 0, longestMs: 1000, boundaryMs: 400 }, + { pageIndex: 1, longestMs: 2000 }, + ]; + const options = { fps: 30, dwellMs: 500 }; + + expect(planDeckTimeline(pages, options)).toEqual([ + planPageSegment(pages[0], options), + planTransitionSegment(0, 400, 30), + planPageSegment(pages[1], options), + ]); + }); + + it('plans no transition segment for a boundary that measured nothing', () => { + expect(planTransitionSegment(0, 0, 30)).toBeNull(); + }); + + it('clamps a runaway boundary', () => { + expect(planTransitionSegment(0, 10_000_000, 30)?.durationMs).toBe(MAX_DURATION_MS); + }); + + it('never lets a transition segment carry dwell', () => { + expect(planTransitionSegment(0, 400, 30)?.dwellMs).toBe(0); + }); +}); + +describe('outputShapeSummary', () => { + it('names the whole-deck shape', () => { + expect(outputShapeSummary('deck', 'whole-deck', 1)).toBe('deck — 1 MP4 (whole deck)'); + }); + + it('distinguishes a requested per-page run from a forced one', () => { + expect(outputShapeSummary('deck', 'per-page', 3)).toContain('--per-page'); + expect(outputShapeSummary('deck', 'per-page', 3, true)).toContain('step-gated fallback'); + }); + + it('states the file count so a mixed run is legible', () => { + expect(outputShapeSummary('gated', 'per-page', 4, true)).toContain('4 MP4(s)'); + }); +}); + +describe('seekTimeMsFor', () => { + it('steps the animation frame by frame', () => { + const [segment] = planDeckTimeline([{ pageIndex: 0, longestMs: 1000 }], { + fps: 10, + dwellMs: 0, + }); + + expect(seekTimeMsFor(segment, 0, 10)).toBe(0); + expect(seekTimeMsFor(segment, 5, 10)).toBe(500); + }); + + it('holds the settled state across the dwell frames', () => { + const [segment] = planDeckTimeline([{ pageIndex: 0, longestMs: 1000 }], { + fps: 10, + dwellMs: 2000, + }); + + expect(segment.frameCount).toBe(30); + expect(seekTimeMsFor(segment, 10, 10)).toBe(1000); + expect(seekTimeMsFor(segment, 29, 10)).toBe(1000); + }); +}); + +describe('totalFrameCount', () => { + it('sums page and transition segments', () => { + const segments = planDeckTimeline( + [ + { pageIndex: 0, longestMs: 1000, boundaryMs: 1000 }, + { pageIndex: 1, longestMs: 1000 }, + ], + { fps: 30, dwellMs: 0 }, + ); + + expect(totalFrameCount(segments)).toBe(90); + }); +}); + +describe('stepGatedPages', () => { + it('flags pages with a non-zero step count', () => { + expect(stepGatedPages([0, 3, 0, 1, 0])).toEqual([1, 3]); + }); + + it('returns nothing for a deck with no steps', () => { + expect(stepGatedPages([0, 0, 0])).toEqual([]); + }); +}); + +describe('stepGatedWarning', () => { + it('names the pages, the shape change, and the loop variant', () => { + const message = stepGatedWarning('deck', [1, 3], 5); + + expect(message).toContain('deck'); + expect(message).toContain('2 of 5'); + expect(message).toContain('p2, p4'); + expect(message).toContain('one MP4 per page'); + expect(message).toContain('pending step'); + expect(message).toContain('every step already revealed'); + expect(message).toContain('no content is missing'); + expect(message).toContain('reveal beats and the inter-page transitions'); + expect(message).toContain('loop variant'); + for (const element of LOOP_VARIANT_ELEMENTS) { + expect(message).toContain(element); + } + }); + + it('states the shape change before its consequences', () => { + const message = stepGatedWarning('deck', [0], 2); + + expect(message.indexOf('step-gated')).toBeLessThan(message.indexOf('one MP4 per page')); + expect(message.indexOf('one MP4 per page')).toBeLessThan(message.indexOf('loop variant')); + }); +}); + +describe('LOOP_VARIANT_ELEMENTS', () => { + it('matches the slide-authoring reference', () => { + const reference = readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../skills/slide-authoring/references/loop-variant.md', + ), + 'utf8', + ).toLowerCase(); + + for (const element of LOOP_VARIANT_ELEMENTS) { + expect(reference).toContain(element.toLowerCase()); + } + }); +}); + +describe('outputShapeFor', () => { + it('picks per-page when any page is step-gated', () => { + expect(outputShapeFor([0, 0, 2, 0])).toBe('per-page'); + }); + + it('picks whole-deck when no page is step-gated', () => { + expect(outputShapeFor([0, 0, 0])).toBe('whole-deck'); + }); + + it('picks per-page when --per-page is passed', () => { + expect(outputShapeFor([0, 0, 0], true)).toBe('per-page'); + }); +}); + +describe('frameFilenameFor', () => { + it('sorts lexically', () => { + const names = [9, 10, 100].map(frameFilenameFor); + + expect(names).toEqual(['frame-000009.png', 'frame-000010.png', 'frame-000100.png']); + expect([...names].sort()).toEqual(names); + }); +}); + +describe('videoFilenameFor', () => { + it('derives a deck-level name', () => { + expect(videoFilenameFor('deck')).toBe('deck.mp4'); + }); + + it('derives a page-level name for the per-page shape', () => { + expect(videoFilenameFor('deck', { pageIndex: 0, total: 9 })).toBe('deck-p1.mp4'); + expect(videoFilenameFor('deck', { pageIndex: 9, total: 12 })).toBe('deck-p10.mp4'); + }); + + it('derives the same name whether the shape came from the flag or the fallback', () => { + const flagged = outputShapeFor([0, 0, 0], true); + const fallback = outputShapeFor([0, 2, 0]); + + expect(flagged).toBe(fallback); + expect(videoFilenameFor('deck', { pageIndex: 1, total: 3 })).toBe( + videoFilenameFor('deck', { pageIndex: 1, total: 3 }), + ); + }); +}); + +describe('SETTLED_ANIMATION_ID', () => { + it('is the marker every browser-side helper inlines', () => { + for (const fn of [settleTransition, pauseAndMeasure, seekTo, measureBoundary]) { + expect(fn.toString()).toContain(SETTLED_ANIMATION_ID); + } + }); +}); + +describe('BOUNDARY_ANIMATION_ID', () => { + it('is the marker the boundary helpers inline', () => { + for (const fn of [measureBoundary, seekTo, settleTransition]) { + expect(fn.toString()).toContain(BOUNDARY_ANIMATION_ID); + } + }); + + it('is applied by the measure and consumed by the seek, so a boundary is tagged before it moves', () => { + expect(measureBoundary.toString()).toMatch( + new RegExp(`animation\\.id = .${BOUNDARY_ANIMATION_ID}.`), + ); + expect(seekTo.toString()).toMatch(new RegExp(`animation\\.id !== .${BOUNDARY_ANIMATION_ID}.`)); + }); +}); + +describe('boundary identification', () => { + it('is read from the transition wrapper and the morph overlay, not from a declared duration', () => { + for (const fn of [measureBoundary, settleTransition]) { + expect(fn.toString()).toContain('[data-osd-dir]'); + expect(fn.toString()).toContain('[data-osd-morph-layer]'); + } + }); +}); + +describe('slideCaptureUrl', () => { + it('addresses the bare export view by 1-based page', () => { + expect(slideCaptureUrl(5173, 'deck', 0)).toBe('http://127.0.0.1:5173/s/deck?p=1&export=png'); + }); +}); + +describe('stepCountsByPage', () => { + it('indexes by page within the deck, not within the selection', () => { + expect(stepCountsByPage(4, [{ pageIndex: 2, stepCount: 3 }])).toEqual([0, 0, 3, 0]); + }); + + it('keeps the warning naming the right page when only part of a deck is probed', () => { + const counts = stepCountsByPage(5, [{ pageIndex: 3, stepCount: 1 }]); + + expect(stepGatedPages(counts)).toEqual([3]); + expect(stepGatedWarning('deck', stepGatedPages(counts), 5)).toContain('(p4)'); + }); + + it('ignores results outside the deck', () => { + expect(stepCountsByPage(2, [{ pageIndex: 7, stepCount: 4 }])).toEqual([0, 0]); + }); +}); + +describe('groupTargetsByDeck', () => { + it('collapses flat targets into per-deck selections in enumeration order', () => { + const decks = groupTargetsByDeck([ + { slideId: 'a', pageIndex: 0, total: 2 }, + { slideId: 'a', pageIndex: 1, total: 2 }, + { slideId: 'b', pageIndex: 0, total: 1 }, + ]); + + expect([...decks.keys()]).toEqual(['a', 'b']); + expect(decks.get('a')).toEqual({ pageIndices: [0, 1], total: 2 }); + expect(decks.get('b')).toEqual({ pageIndices: [0], total: 1 }); + }); + + it('records the deck total, not the selected page count', () => { + const decks = groupTargetsByDeck([{ slideId: 'a', pageIndex: 2, total: 6 }]); + + expect(decks.get('a')).toEqual({ pageIndices: [2], total: 6 }); + }); +}); + +describe('validateFlags', () => { + it('rejects --slide with --all', () => { + expect(() => validateFlags({ slide: 'deck', all: true })).toThrow(ExportUsageError); + }); + + it('rejects --page without --slide', () => { + expect(() => validateFlags({ page: 2 })).toThrow(ExportUsageError); + }); + + it('rejects an out-of-range fps', () => { + expect(() => validateFlags({ slide: 'deck', fps: 0 })).toThrow(ExportUsageError); + expect(() => validateFlags({ slide: 'deck', fps: 121 })).toThrow(ExportUsageError); + }); + + it('accepts a valid selection', () => { + expect(() => validateFlags({ slide: 'deck', fps: 30 })).not.toThrow(); + expect(() => validateFlags({ all: true })).not.toThrow(); + }); +}); + +/** + * Minimal stand-in for a `CSSAnimation`, carrying only what the browser-side + * helpers touch. It lets those helpers run under the node environment, so their + * behaviour is asserted rather than grepped out of their source. + */ +interface FakeAnimation { + id: string; + playState: string; + currentTime: number; + effect: { + target?: unknown; + getComputedTiming: () => { + delay: number; + activeDuration: number; + endDelay: number; + iterations: number; + }; + } | null; + pause: () => void; + finish: () => void; +} + +function fakeAnimation(overrides: Partial & { activeDuration?: number } = {}) { + const { activeDuration = 0, ...rest } = overrides; + const animation: FakeAnimation = { + id: '', + playState: 'running', + currentTime: 0, + effect: { + getComputedTiming: () => ({ + delay: 0, + activeDuration, + endDelay: 0, + iterations: 1, + }), + }, + pause() { + animation.playState = 'paused'; + }, + finish() { + animation.playState = 'finished'; + }, + ...rest, + }; + return animation; +} + +/** Install a `document` exposing `animations`, and restore whatever was there. */ +function withDocument( + animations: FakeAnimation[], + wrapper: unknown, + body: () => void | Promise, +): void | Promise { + const globals = globalThis as Record; + const previousDocument = globals.document; + const previousRaf = globals.requestAnimationFrame; + globals.document = { + getAnimations: () => animations, + querySelector: () => wrapper, + }; + globals.requestAnimationFrame = (cb: () => void) => { + cb(); + return 0; + }; + try { + return body(); + } finally { + globals.document = previousDocument; + globals.requestAnimationFrame = previousRaf; + } +} + +describe('infinite animations', () => { + it('are paused and counted but left out of the duration measurement', () => { + const infinite = fakeAnimation({ + activeDuration: Number.POSITIVE_INFINITY, + effect: { + getComputedTiming: () => ({ + delay: 0, + activeDuration: Number.POSITIVE_INFINITY, + endDelay: 0, + iterations: Number.POSITIVE_INFINITY, + }), + }, + }); + const finite = fakeAnimation({ activeDuration: 800 }); + + withDocument([infinite, finite], null, () => { + expect(pauseAndMeasure()).toEqual({ count: 2, longestMs: 800 }); + expect(infinite.playState).toBe('paused'); + }); + }); + + it('are seeked with everything else, so a looping page still animates', async () => { + const infinite = fakeAnimation({ + effect: { + getComputedTiming: () => ({ + delay: 0, + activeDuration: Number.POSITIVE_INFINITY, + endDelay: 0, + iterations: Number.POSITIVE_INFINITY, + }), + }, + }); + const settled = fakeAnimation({ id: SETTLED_ANIMATION_ID }); + + await withDocument([infinite, settled], null, async () => { + await seekTo({ timeMs: 400 }); + expect(infinite.currentTime).toBe(400); + expect(settled.currentTime).toBe(0); + }); + }); + + it('do not, by being unmeasurable, collapse the page to nothing', () => { + expect(resolveDurationMs(0)).toBe(FALLBACK_DURATION_MS); + expect(frameCountFor(FALLBACK_DURATION_MS, 30)).toBeGreaterThan(1); + }); +}); + +describe('settleTransition tagging', () => { + it('retags a boundary animation whose finish throws, so the next page cannot measure it', () => { + const wrapper = { childElementCount: 1 }; + const stubborn = fakeAnimation({ + id: BOUNDARY_ANIMATION_ID, + activeDuration: 500, + finish() { + throw new Error('effect removed mid-finish'); + }, + }); + + withDocument([stubborn], wrapper, () => { + expect(settleTransition()).toBe(true); + expect(stubborn.id).toBe(SETTLED_ANIMATION_ID); + expect(pauseAndMeasure()).toEqual({ count: 0, longestMs: 0 }); + }); + }); +}); diff --git a/packages/core/src/cli/video.ts b/packages/core/src/cli/video.ts new file mode 100644 index 000000000..6a8c06ff6 --- /dev/null +++ b/packages/core/src/cli/video.ts @@ -0,0 +1,1228 @@ +/** + * @agents-index `open-slide video` subcommand — seeks each page's CSS + * animations frame by frame in headless Chromium and encodes the captured + * frames to an MP4 with ffmpeg. + * + * This is the moving-image counterpart to `export.ts`, and it deliberately + * reuses that module's dev-server boot, deck enumeration, target resolution and + * atomic writes rather than forking them. The only genuinely new problem here + * is time. + * + * The PNG exporter waits for animation to *finish* so it can capture a settled + * frame. Video needs the opposite: samples taken mid-flight at exact instants. + * Decks animate with wall-clock CSS keyframes and expose no playhead, so the + * frames cannot simply be screenshotted on a timer — a screenshot loop samples + * whenever the screenshot happened to land, which drops and duplicates frames + * under load and never reproduces. + * + * The fix is the Web Animations API. Every running animation is paused and its + * `currentTime` is driven explicitly, so the page becomes seekable without any + * authoring change: existing CSS-keyframe decks work untouched. This is + * preferred over CDP virtual time, which is coarser and unreliable for + * compositor-driven `transform` and `opacity` — exactly the properties slide + * animations use most. + * + * The second departure from `export.ts` is structural. A deck is captured as + * one continuous browsing session that advances page to page through the + * viewer's own navigation, rather than as a series of independent page loads, + * because that is the only way the viewer's transition animations exist at a + * boundary to be seeked. A probe pass settles the deck's output shape before + * any of that begins: a page gated by `` cannot be crossed by a + * continuous session without walking its reveals, and such a deck falls back to + * the export path's shape of one independent load per page. + */ + +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { Browser, Page } from 'playwright-chromium'; +import type { ViteDevServer } from 'vite'; +import { + atomicWriteFile, + DEFAULT_TIMEOUT_MS, + ExportUsageError, + enumerateSlides, + resolveExportTargets, + startDevServer, + tryImportPlaywright, + zeroPageDeckIds, +} from './export.ts'; + +/** Flags accepted by `open-slide video`. Mirrors the Commander options in `run.ts`. */ +export interface VideoFlags { + slide?: string; + all?: boolean; + page?: number; + out?: string; + fps?: number; + duration?: number; + dwell?: number; + port?: number; + timeout?: number; + keepFrames?: boolean; + perPage?: boolean; +} + +const CANVAS_WIDTH = 1920; +const CANVAS_HEIGHT = 1080; +const DEFAULT_FPS = 30; +const DEFAULT_OUT_DIR = './video-export'; + +/** + * Grace period after a page mounts or a boundary settles, before its animations + * are measured. + * + * An animation registered a tick after mount would otherwise be missed by the + * measurement and left out of the frame count, which shows up as a page whose + * reported animation count is lower than the deck actually has. + */ +const MOUNT_SETTLE_MS = 250; + +/** + * Fallback page duration in milliseconds. + * + * Used when a page's animations are all infinite, or when it has none at all — + * in either case there is no finite end to measure, and a still page still + * needs to occupy time in the output. + */ +export const FALLBACK_DURATION_MS = 3_000; + +/** Upper bound on a measured page duration, so one runaway delay cannot produce an hour of frames. */ +export const MAX_DURATION_MS = 60_000; + +/** Default hold after a page's animation completes, so a reader can absorb the settled state. */ +export const DEFAULT_DWELL_MS = 1_500; + +/** + * Marker `settleTransition` stamps on a boundary animation it has driven to its + * end, so the page measurement and the per-frame seek both skip it. + * + * The browser-side helpers are serialised into the page and cannot close over + * module scope, so each of the three inlines this literal rather than reading + * it. That duplication is guarded by a spec asserting their sources still carry + * this exact value. + */ +export const SETTLED_ANIMATION_ID = 'osd-capture-settled'; + +/** + * Marker `measureBoundary` stamps on the animations the viewer created at a page + * boundary, so the per-frame seek can move the transition without also moving + * the incoming page's content animations. + * + * Identifying a boundary animation costs a DOM walk from its target, and doing + * that walk once per boundary rather than once per frame is why the tag exists. + * It also keeps the target-based predicate in two places rather than three; see + * `settleTransition`, which keeps its own copy because it must also catch an + * animation that appeared after the measurement. + * + * `settleTransition` overwrites this with `SETTLED_ANIMATION_ID`, so a tag never + * outlives the boundary that earned it. + */ +export const BOUNDARY_ANIMATION_ID = 'osd-capture-boundary'; + +/** + * The four elements that turn a step-gated deck into one capturable as a single + * file. + * + * Single sourced: the step-gated CLI warning composes its text from this + * constant, and the slide-authoring skill's `references/loop-variant.md` states + * the same four. Two independently maintained prose copies would drift, and the + * warning's whole value is that it names what the skill then helps the author + * build. + */ +export const LOOP_VARIANT_ELEMENTS = [ + 'step reveals replaced by time-based animations', + 'transitions declared between pages', + 'pages that advance automatically instead of on input', + "a closing transition into the first page's background, so the file plays as a seamless loop", +] as const; + +/** + * Which shape a deck's output takes. + * + * `whole-deck` is one MP4 covering every page; `per-page` is one MP4 per page, + * reached either by `--per-page` or by the step-gated fallback. The two routes + * resolve to this one value so there is a single capture, encode, and filename + * derivation behind them. + */ +export type OutputShape = 'whole-deck' | 'per-page'; + +/** + * A page's browser-measured timings, as handed to the planner. + * + * The planner measures nothing itself: both a page's animation length and a + * boundary's are only knowable in the browser, so they arrive here already read + * off `document.getAnimations()`. + */ +export interface MeasuredPage { + /** Zero-based index of the page within its deck. */ + pageIndex: number; + /** End time of the page's longest finite animation, or 0 when it has none. */ + longestMs: number; + /** + * End time of the longest animation the viewer created at the boundary into + * the *next* page. Zero or omitted when the deck declares no transition, in + * which case no transition segment is planned. + */ + boundaryMs?: number; +} + +/** + * One contiguous run of frames in a deck's timeline. + * + * A `page` segment covers that page's animation followed by the run-wide dwell; + * a `transition` segment covers the animations the viewer created at the + * boundary leaving `pageIndex`, and never carries dwell. + */ +export interface TimelineSegment { + kind: 'page' | 'transition'; + /** The page this segment renders, or for a transition the page it leaves. */ + pageIndex: number; + /** Resolved animation length; the seek time is clamped to this during dwell. */ + durationMs: number; + /** Hold appended after the animation. Always 0 for a transition. */ + dwellMs: number; + /** Frames covering animation plus dwell. */ + frameCount: number; +} + +/** Planner inputs that apply to the whole run rather than to one page. */ +export interface TimelinePlanOptions { + fps: number; + /** Run-wide hold after each page's animation. Defaults to `DEFAULT_DWELL_MS`. */ + dwellMs?: number; + /** `--duration`, replacing every page's measured value. Boundaries are unaffected. */ + durationOverrideMs?: number; +} + +/** + * Browser-side: pause every animation on the page and report the timeline + * length that needs to be covered. + * + * Serialised and executed in the page by Playwright, so it must be + * self-contained: no imports, no closure over module scope. Returns the end + * time of the longest finite animation, which is what the frame count derives + * from. + * + * Infinite animations are paused like any other and are still seeked, so a + * looping pulse animates correctly in the output — they are only excluded from + * the duration measurement, because they have no end to measure. + */ +export function pauseAndMeasure(): { count: number; longestMs: number } { + let count = 0; + let longest = 0; + for (const animation of document.getAnimations()) { + if (animation.id === 'osd-capture-settled') continue; + animation.pause(); + count++; + const timing = animation.effect?.getComputedTiming(); + if (!timing) continue; + if (timing.iterations === Number.POSITIVE_INFINITY) continue; + const delay = Number(timing.delay) || 0; + const active = Number(timing.activeDuration) || 0; + const endDelay = Number(timing.endDelay) || 0; + longest = Math.max(longest, delay + active + endDelay); + } + return { count, longestMs: longest }; +} + +/** + * Browser-side: measure the animations the viewer created at a page boundary, + * and tag them so the seek can address them alone. + * + * The boundary's length is measured rather than read off `SlideTransition + * .duration`, because `runPhase` resolves each phase as `phase.duration ?? + * duration` with its own `phase.delay`, and `resolveMorphTransition` gives morph + * an independent duration and delay. A boundary sized from the declared value + * truncates any deck that overrides either. + * + * Boundary animations are identified by target: the layer phases animate the + * outgoing and incoming layer divs, which are direct children of the transition + * wrapper, and morph animates clones inside the overlay. Page content sits + * deeper than that, so it is never mistaken for a boundary. + * + * Animations already tagged `SETTLED_ANIMATION_ID` are skipped, or the previous + * boundary's forwards-filled survivors would be measured as this one. + * + * @returns The boundary's animation count and the end time of its longest + * finite animation. Zero for a deck that declares no transition, which is what + * makes such a deck come out as pages alone. + */ +export function measureBoundary(): { count: number; longestMs: number } { + const wrapper = document.querySelector('[data-osd-dir]'); + if (wrapper === null) return { count: 0, longestMs: 0 }; + let count = 0; + let longest = 0; + for (const animation of document.getAnimations()) { + if (animation.id === 'osd-capture-settled') continue; + const target = (animation.effect as KeyframeEffect | null)?.target ?? null; + if (target === null) continue; + const isBoundary = + target.parentElement === wrapper || target.closest('[data-osd-morph-layer]') !== null; + if (!isBoundary) continue; + animation.pause(); + animation.id = 'osd-capture-boundary'; + count++; + const timing = animation.effect?.getComputedTiming(); + if (!timing) continue; + if (timing.iterations === Number.POSITIVE_INFINITY) continue; + const delay = Number(timing.delay) || 0; + const active = Number(timing.activeDuration) || 0; + const endDelay = Number(timing.endDelay) || 0; + longest = Math.max(longest, delay + active + endDelay); + } + return { count, longestMs: longest }; +} + +/** + * Browser-side: seek animations to the given time and wait for a paint. + * + * Setting `currentTime` on a paused animation invalidates style, but the commit + * is not synchronous. Waiting two animation frames before resolving lets style + * and layout settle so the screenshot that follows shows the seeked state + * rather than the previous one. + * + * `boundaryOnly` narrows the seek to the animations `measureBoundary` tagged, so + * a transition segment moves the transition alone. The incoming page's content + * animations are mounted by then and stay held at zero, which is the state the + * page segment that follows starts from; the outgoing page's stay where its + * dwell left them. + * + * Animations `settleTransition` already drove to their end are left alone; see + * `SETTLED_ANIMATION_ID` for why they are still here to be skipped. + */ +export function seekTo(input: { timeMs: number; boundaryOnly?: boolean }): Promise { + for (const animation of document.getAnimations()) { + if (animation.id === 'osd-capture-settled') continue; + if (input.boundaryOnly === true && animation.id !== 'osd-capture-boundary') continue; + try { + animation.currentTime = input.timeMs; + } catch { + /* An animation whose effect was removed mid-seek is not fatal; skip it. */ + } + } + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +/** + * Browser-side: hold every animation at its start, from document start onward. + * + * Installed once as a context init script. The sweep repeats on an interval + * rather than running once because an animation created after the first pass + * would otherwise play out before it could be caught, and a played-out + * animation leaves `document.getAnimations()` with nothing left to seek. + * + * The `playState` guard is load-bearing for determinism, not a micro + * optimisation. `pause()` on an already-paused animation is a no-op to script, + * but it re-commits the animation to the compositor, and a re-commit landing + * between a seek and its screenshot re-rasters the layer. Without the guard the + * interval fires that 250 times a second and a transition that animates + * `transform` captures one of two stable rasterisations at random — two runs of + * the same deck then differ across the boundary frames. + */ +export function pauseAllAnimations(): void { + const pauseAll = () => { + for (const animation of document.getAnimations()) { + if (animation.playState !== 'paused') animation.pause(); + } + }; + pauseAll(); + setInterval(pauseAll, 4); + document.addEventListener('DOMContentLoaded', pauseAll); +} + +/** + * Browser-side: how many steps the mounted page gates content behind. + * + * `Step` renders `data-osd-step` unconditionally, so no automation hook has to + * be added to the viewer for this. The query is document-wide, which is only + * correct while a single page layer is mounted — during a transition both the + * outgoing and incoming layers carry a `StepHost` and the count doubles. The + * probe pass loads one page at a time, so that state never arises here. + */ +export function countStepElements(): number { + return document.querySelectorAll('[data-osd-step]').length; +} + +/** + * Browser-side: drive a page boundary to completion, reporting whether it has + * settled. + * + * `SlideTransitionLayer` unmounts the outgoing layer and restores morph-hidden + * originals only from a `Promise.all(anims.map((a) => a.finished))` handler. + * The capture's whole premise is that animations never finish, so left alone + * that handler never runs and the outgoing page — and any morph overlay — stays + * on screen for every remaining page of the deck. + * + * Only the boundary's own animations are finished. Those `measureBoundary` + * already tagged are taken on the tag; the rest are identifiable by their + * target, which is what catches one that appeared after the measurement — the + * layer phases animate the outgoing and incoming layer divs, + * which are direct children of the transition wrapper, and morph animates + * clones inside the overlay. Page content animates elements deeper than that, + * so finishing here can never cost a content animation its seekability. + * + * Settledness is read from the DOM rather than from the animation set, because + * a finished animation with a forwards fill stays in `getAnimations()`. That + * survivor is also why each one is tagged with `SETTLED_ANIMATION_ID`: the + * incoming layer's enter animation outlives the boundary, and left untagged the + * next page would measure its duration as page content and seek it back to + * zero, replaying the transition underneath the page it just joined. + * + * @returns `true` once the wrapper holds a single layer and no overlay. + */ +export function settleTransition(): boolean { + const wrapper = document.querySelector('[data-osd-dir]'); + if (wrapper === null) return true; + for (const animation of document.getAnimations()) { + let isBoundary = animation.id === 'osd-capture-boundary'; + if (!isBoundary) { + const target = (animation.effect as KeyframeEffect | null)?.target ?? null; + if (target === null) continue; + isBoundary = + target.parentElement === wrapper || target.closest('[data-osd-morph-layer]') !== null; + } + if (!isBoundary) continue; + /* + Retagged before `finish()`, not after. A `finish()` that throws would + otherwise leave the boundary tag in place, and `pauseAndMeasure` skips only + the settled tag — the next page would then measure and seek a leftover + transition animation as its own content, silently. The retag costs nothing + on the retry path: this function is polled by `waitForFunction`, and the + target test above re-identifies a settled-tagged boundary animation on + every subsequent call. + */ + try { + animation.id = 'osd-capture-settled'; + animation.finish(); + } catch { + /* An animation whose effect was removed mid-finish is not fatal; skip it. */ + } + } + return wrapper.childElementCount <= 1; +} + +/** + * Number of frames needed to cover a page. + * + * @param durationMs - Measured or overridden page duration. + * @param fps - Frames per second. + * @returns At least one frame, so a page with no animation still contributes a still. + */ +export function frameCountFor(durationMs: number, fps: number): number { + return Math.max(1, Math.round((durationMs / 1000) * fps)); +} + +/** + * Clamp a measured animation length into the range worth rendering. + * + * @param longestMs - Longest finite animation end time, or 0 when there is none. + * @param overrideMs - Explicit `--duration` in milliseconds, when supplied. + */ +export function resolveDurationMs(longestMs: number, overrideMs?: number): number { + if (overrideMs !== undefined) return Math.min(overrideMs, MAX_DURATION_MS); + if (longestMs <= 0) return FALLBACK_DURATION_MS; + return Math.min(longestMs, MAX_DURATION_MS); +} + +/** + * Frames held after a segment's animation has run out. + * + * @param dwellMs - Run-wide dwell. Zero yields zero frames, unlike a page's + * animation which always contributes at least one. + */ +export function dwellFrameCountFor(dwellMs: number, fps: number): number { + return Math.max(0, Math.round((dwellMs / 1000) * fps)); +} + +/** + * Lay one measured page out as its animation followed by the run-wide dwell. + * + * Exported because a continuous session can only measure a page once it has + * arrived there, so the capture plans a page at a time rather than a deck at a + * time. `planDeckTimeline` composes the same helper, which is what keeps the two + * from drifting. + */ +export function planPageSegment(page: MeasuredPage, options: TimelinePlanOptions): TimelineSegment { + const { fps, dwellMs = DEFAULT_DWELL_MS, durationOverrideMs } = options; + const durationMs = resolveDurationMs(page.longestMs, durationOverrideMs); + return { + kind: 'page', + pageIndex: page.pageIndex, + durationMs, + dwellMs, + frameCount: frameCountFor(durationMs, fps) + dwellFrameCountFor(dwellMs, fps), + }; +} + +/** + * Lay a measured page boundary out as its own segment. + * + * @param pageIndex - The page the boundary leaves. + * @param boundaryMs - What the boundary's animations actually measured. + * @returns `null` when nothing was measured, which is how a deck declaring no + * transition comes out as pages alone rather than with a synthesised join. + */ +export function planTransitionSegment( + pageIndex: number, + boundaryMs: number, + fps: number, +): TimelineSegment | null { + const durationMs = Math.min(boundaryMs, MAX_DURATION_MS); + if (durationMs <= 0) return null; + return { + kind: 'transition', + pageIndex, + durationMs, + dwellMs: 0, + frameCount: frameCountFor(durationMs, fps), + }; +} + +/** + * Lay a deck out as an ordered list of seekable segments. + * + * Pure by construction: every duration arrives already measured in the browser + * (a page's from its longest finite animation, a boundary's from the animations + * the viewer actually created), so this only resolves, clamps, and counts. + * + * A boundary that measured zero produces no segment, which is how a deck + * declaring no transition comes out as pages alone. The final page never gets a + * boundary, since there is nothing to transition into. + * + * @param pages - Measured pages in capture order. + * @param options - Frame rate, run-wide dwell, and the optional `--duration` override. + * @returns Segments in playback order. + */ +export function planDeckTimeline( + pages: readonly MeasuredPage[], + options: TimelinePlanOptions, +): TimelineSegment[] { + const segments: TimelineSegment[] = []; + + pages.forEach((page, index) => { + segments.push(planPageSegment(page, options)); + if (index === pages.length - 1) return; + const transition = planTransitionSegment(page.pageIndex, page.boundaryMs ?? 0, options.fps); + if (transition) segments.push(transition); + }); + + return segments; +} + +/** + * Seek time for one frame of a segment. + * + * Clamping to the segment's duration is what makes dwell a hold rather than an + * overrun: every dwell frame seeks to the animation's end and so renders the + * settled state. + */ +export function seekTimeMsFor(segment: TimelineSegment, frameIndex: number, fps: number): number { + return Math.min((frameIndex / fps) * 1000, segment.durationMs); +} + +/** Total frames a planned timeline writes, which is what the encode is sized by. */ +export function totalFrameCount(segments: readonly TimelineSegment[]): number { + return segments.reduce((sum, segment) => sum + segment.frameCount, 0); +} + +/** One page's probe result: where it sits in the deck, and how many steps it gates. */ +export interface ProbedPage { + pageIndex: number; + stepCount: number; +} + +/** + * Spread probe results across a deck-length array indexed by page. + * + * `stepGatedPages` and `stepGatedWarning` both read an index as a page number, + * so a run that probes only part of a deck — `--page` selects one — must still + * report positions in the deck rather than positions in the probed subset, + * or the warning names the wrong pages. Unprobed pages count as ungated. + * + * @param totalPages - Pages in the deck, not in the selection. + * @param probed - Probe results, in any order. + */ +export function stepCountsByPage(totalPages: number, probed: readonly ProbedPage[]): number[] { + const counts = new Array(totalPages).fill(0); + for (const { pageIndex, stepCount } of probed) { + if (pageIndex >= 0 && pageIndex < totalPages) counts[pageIndex] = stepCount; + } + return counts; +} + +/** + * Indices of the pages that gate content behind ``. + * + * @param stepCounts - `[data-osd-step]` element count per page, in page order, + * read on arrival by the probe pass. + * @returns Zero-based indices of the gated pages. + */ +export function stepGatedPages(stepCounts: readonly number[]): number[] { + return stepCounts.flatMap((count, index) => (count > 0 ? [index] : [])); +} + +/** + * Decide a deck's output shape. + * + * The fallback is per deck rather than per page: a single continuous session + * cannot cross a step-gated page without walking its reveals, so one gated page + * settles the shape for all of them. + * + * @param stepCounts - Step count per page, from the probe pass. + * @param perPageRequested - Whether `--per-page` was passed. + */ +export function outputShapeFor( + stepCounts: readonly number[], + perPageRequested = false, +): OutputShape { + if (perPageRequested) return 'per-page'; + return stepGatedPages(stepCounts).length > 0 ? 'per-page' : 'whole-deck'; +} + +/** + * One deck's line in the closing summary. + * + * A mixed `--all` run produces both shapes, and the user only asked for one, so + * the summary has to say which deck got which rather than leaving them to infer + * it from the directory listing. + * + * @param fileCount - MP4s written for this deck. + * @param stepGated - Whether the per-page shape was forced rather than requested. + */ +export function outputShapeSummary( + slideId: string, + shape: OutputShape, + fileCount: number, + stepGated = false, +): string { + if (shape === 'whole-deck') return `${slideId} — 1 MP4 (whole deck)`; + const reason = stepGated ? 'step-gated fallback' : '--per-page'; + return `${slideId} — ${fileCount} MP4(s) (per page, ${reason})`; +} + +/** + * The warning a step-gated deck earns, stating the shape change before its cause. + * + * The user asked for one file per deck and is getting N, so the message leads + * with which pages forced that and why, then records what the fallback does and + * does not cost, then names the route back to a single file. The loop-variant + * elements are composed from `LOOP_VARIANT_ELEMENTS` rather than restated. + * + * @param slideId - Deck the warning is about. + * @param gatedPageIndices - Zero-based indices from `stepGatedPages`. + * @param totalPages - Pages in the deck, so the message can say "2 of 5". + */ +export function stepGatedWarning( + slideId: string, + gatedPageIndices: readonly number[], + totalPages: number, +): string { + const pageList = gatedPageIndices.map((index) => `p${index + 1}`).join(', '); + return [ + `${slideId}: ${gatedPageIndices.length} of ${totalPages} page(s) are step-gated (${pageList}).`, + `Wrote one MP4 per page rather than one for the deck, because a continuous capture cannot advance past a pending step without walking its reveals.`, + `Each page is captured with every step already revealed, so no content is missing — but the reveal beats and the inter-page transitions are not in the output.`, + `To get a single deck video, author a loop variant of this deck: ${LOOP_VARIANT_ELEMENTS.join('; ')}.`, + ].join('\n'); +} + +/** Zero-padded frame filename, so lexical order matches temporal order for ffmpeg's globber. */ +export function frameFilenameFor(index: number): string { + return `frame-${String(index).padStart(6, '0')}.png`; +} + +/** + * Output filename for a captured video. + * + * One derivation serves both output shapes, so a file the step-gated fallback + * wrote is indistinguishable from one `--per-page` wrote. + * + * @param slideId - Deck id. + * @param page - Omit for the whole-deck shape; supply for the per-page shape. + */ +export function videoFilenameFor( + slideId: string, + page?: { pageIndex: number; total: number }, +): string { + if (!page) return `${slideId}.mp4`; + const width = String(page.total).length; + return `${slideId}-p${String(page.pageIndex + 1).padStart(width, '0')}.mp4`; +} + +/** The bare-slide route the capture drives, shared with the PNG exporter. */ +export function slideCaptureUrl(port: number, slideId: string, pageIndex: number): string { + return `http://127.0.0.1:${port}/s/${slideId}?p=${pageIndex + 1}&export=png`; +} + +/** A deck's share of a run: the pages selected from it, and how many it has in total. */ +export interface DeckSelection { + pageIndices: number[]; + total: number; +} + +/** + * Collapse the export module's flat `(slideId, pageIndex, total)` tuples into + * per-deck selections. + * + * The output shape is a per-deck decision, so the run has to be walked deck by + * deck rather than target by target. Insertion order is preserved so decks are + * captured in enumeration order. + */ +export function groupTargetsByDeck( + targets: readonly { slideId: string; pageIndex: number; total: number }[], +): Map { + const decks = new Map(); + for (const target of targets) { + const deck = decks.get(target.slideId) ?? { pageIndices: [], total: target.total }; + deck.pageIndices.push(target.pageIndex); + decks.set(target.slideId, deck); + } + return decks; +} + +/** + * Wait for fonts, without waiting for animation. + * + * A frame captured mid font-swap renders in a fallback face. The PNG exporter's + * readiness flag would cover this but cannot be used: it also waits for every + * finite animation to finish, which is exactly what this capture prevents from + * ever happening. A missing font degrades the frame; it does not invalidate the + * run, so a timeout is reported and capture continues. + * + * This is a second font predicate, and knowingly the weaker one: + * `app/lib/print-ready.ts`'s `waitForFonts` awaits `document.fonts.ready`, while + * this polls `document.fonts.status`, which can read `loaded` before a face + * requested later begins loading. The two are not merged because that one is + * viewer-side and is reached through the readiness flag FR-21 forbids this + * capture from gating on. The weaker predicate is acceptable here for the same + * reason the timeout is swallowed: a degraded frame is not a failed run. + */ +async function waitForFonts(page: Page, label: string, timeoutMs: number): Promise { + try { + await page.waitForFunction(() => document.fonts.status === 'loaded', undefined, { + timeout: timeoutMs, + }); + } catch { + process.stderr.write(`${label} fonts not ready — capturing anyway\n`); + } +} + +/** + * Measure the mounted page and lay it out as a single seekable segment. + * + * @returns The segment to capture and the animation count behind it, which + * FR-33's report line surfaces so a zero-animation capture is visible in the + * console rather than only in the output file. + */ +async function measureMountedPage( + page: Page, + pageIndex: number, + options: TimelinePlanOptions, +): Promise<{ segment: TimelineSegment; animationCount: number }> { + await page.waitForTimeout(MOUNT_SETTLE_MS); + const measured = await page.evaluate(pauseAndMeasure); + return { + segment: planPageSegment({ pageIndex, longestMs: measured.longestMs }, options), + animationCount: measured.count, + }; +} + +/** + * Seek and screenshot one segment into a frame sequence. + * + * @param startFrameIndex - Where this segment begins in the sequence, so a + * whole-deck capture numbers every page into one contiguous run that ffmpeg + * reads with a single glob. + * @returns Frames written. + */ +async function writeSegmentFrames( + page: Page, + framesDir: string, + startFrameIndex: number, + segment: TimelineSegment, + fps: number, +): Promise { + const boundaryOnly = segment.kind === 'transition'; + for (let i = 0; i < segment.frameCount; i++) { + await page.evaluate(seekTo, { timeMs: seekTimeMsFor(segment, i, fps), boundaryOnly }); + const buffer = await page.screenshot({ + type: 'png', + clip: { x: 0, y: 0, width: CANVAS_WIDTH, height: CANVAS_HEIGHT }, + }); + await atomicWriteFile(path.join(framesDir, frameFilenameFor(startFrameIndex + i)), buffer); + } + return segment.frameCount; +} + +function reportPage( + slideId: string, + pageIndex: number, + animationCount: number, + segment: TimelineSegment, +): void { + process.stdout.write( + `${slideId}:p${pageIndex + 1} — ${animationCount} animation(s), ${Math.round(segment.durationMs)}ms, ${segment.frameCount} frame(s)\n`, + ); +} + +function reportBoundary( + slideId: string, + fromPageIndex: number, + animationCount: number, + segment: TimelineSegment | null, +): void { + const captured = segment + ? `${Math.round(segment.durationMs)}ms, ${segment.frameCount} frame(s)` + : 'no transition declared'; + process.stdout.write( + `${slideId}:p${fromPageIndex + 1}→p${fromPageIndex + 2} — ${animationCount} transition animation(s), ${captured}\n`, + ); +} + +/** + * Advance the deck one page in place, capturing the boundary as it goes. + * + * The advance goes through the viewer's own keyboard navigation rather than a + * new `?p=` URL, because a reload destroys the outgoing layer and with it every + * transition animation the boundary would be captured from. `Player`'s arrow + * handler is not gated on its `controls` prop, so it is reachable in the bare + * export view. + * + * Order here is load-bearing in both directions. The boundary is measured and + * captured *before* it is settled, because settling tags its animations and a + * tagged animation is skipped by every seek. It is settled *before* the next + * page is measured, because the viewer unmounts the outgoing layer and restores + * morph-hidden originals only once every transition animation resolves — a + * boundary left paused strands both over every page that follows. + * + * @returns Frames written, which is zero for a deck that declares no transition. + */ +async function captureBoundary( + page: Page, + slideId: string, + fromPageIndex: number, + framesDir: string, + startFrameIndex: number, + fps: number, + timeoutMs: number, +): Promise { + await page.keyboard.press('ArrowRight'); + await page.waitForTimeout(MOUNT_SETTLE_MS); + + const measured = await page.evaluate(measureBoundary); + const segment = planTransitionSegment(fromPageIndex, measured.longestMs, fps); + const frames = segment + ? await writeSegmentFrames(page, framesDir, startFrameIndex, segment, fps) + : 0; + reportBoundary(slideId, fromPageIndex, measured.count, segment); + + await page.waitForFunction(settleTransition, undefined, { timeout: timeoutMs }); + return frames; +} + +/** + * Count the steps on every selected page of a deck, before any frame of it is + * captured. + * + * Step-gating is a property of a page but the output shape is a property of a + * deck, so a gate discovered on page three after two pages had been captured + * into a continuous session would mean discarding that work. This settles the + * question first. It captures no frames and no transitions, so it is cheap and + * free to load pages independently — which is also what keeps the step count + * honest, since only one page layer is ever mounted. + * + * @returns Step counts indexed by page within the deck, zero for pages outside + * the selection. + */ +export async function probeStepCounts( + page: Page, + port: number, + slideId: string, + selection: DeckSelection, + timeoutMs: number, +): Promise { + const probed: ProbedPage[] = []; + for (const pageIndex of selection.pageIndices) { + await page.goto(slideCaptureUrl(port, slideId, pageIndex), { waitUntil: 'load' }); + await page.waitForSelector('[data-osd-canvas]', { timeout: timeoutMs }).catch(() => {}); + probed.push({ pageIndex, stepCount: await page.evaluate(countStepElements) }); + } + return stepCountsByPage(selection.total, probed); +} + +/** One file the capture will produce, and the frame sequence it will be encoded from. */ +export interface CaptureUnit { + /** How the unit is named on stdout: the deck, or the deck and page. */ + label: string; + /** Directory holding this unit's numbered PNGs. */ + framesDir: string; + /** Where the encode writes the MP4. */ + outFile: string; + /** Frames written into `framesDir`. */ + frames: number; +} + +/** Everything a deck's capture needs that is not the Playwright page itself. */ +export interface DeckCaptureOptions { + port: number; + slideId: string; + selection: DeckSelection; + shape: OutputShape; + outDir: string; + fps: number; + dwellMs: number; + timeoutMs: number; + durationOverrideMs?: number; +} + +/** + * Capture one page of a deck into an already-mounted document. + * + * The single measure-plan-seek-report step both output shapes are built from, + * so `--per-page`, the step-gated fallback, and whole-deck capture cannot drift + * in what a page's frames look like. + * + * @returns Frames written. + */ +async function capturePage( + page: Page, + slideId: string, + pageIndex: number, + framesDir: string, + startFrameIndex: number, + options: DeckCaptureOptions, +): Promise { + const { segment, animationCount } = await measureMountedPage(page, pageIndex, { + fps: options.fps, + dwellMs: options.dwellMs, + durationOverrideMs: options.durationOverrideMs, + }); + const frames = await writeSegmentFrames(page, framesDir, startFrameIndex, segment, options.fps); + reportPage(slideId, pageIndex, animationCount, segment); + return frames; +} + +/** + * Capture a deck as one continuous browsing session. + * + * One navigation, then the pages are reached by advancing the viewer rather than + * by reloading, which is what makes the viewer's own transition animations exist + * at each boundary to be seeked. + */ +async function captureWholeDeck( + page: Page, + options: DeckCaptureOptions, +): Promise { + const { port, slideId, selection, outDir, fps, timeoutMs } = options; + const [firstPageIndex] = selection.pageIndices; + if (firstPageIndex === undefined) return null; + + const framesDir = path.join(outDir, `.frames-${slideId}`); + await fs.mkdir(framesDir, { recursive: true }); + await page.goto(slideCaptureUrl(port, slideId, firstPageIndex), { waitUntil: 'load' }); + await waitForFonts(page, `${slideId}:p${firstPageIndex + 1}`, timeoutMs); + + let written = 0; + for (const [ordinal, pageIndex] of selection.pageIndices.entries()) { + if (ordinal > 0) { + const previousPageIndex = selection.pageIndices[ordinal - 1]; + written += await captureBoundary( + page, + slideId, + previousPageIndex, + framesDir, + written, + fps, + timeoutMs, + ); + } + written += await capturePage(page, slideId, pageIndex, framesDir, written, options); + } + + return { + label: slideId, + framesDir, + outFile: path.join(outDir, videoFilenameFor(slideId)), + frames: written, + }; +} + +/** + * Capture a deck as one independent page load per file. + * + * Reached both by `--per-page` and by the step-gated fallback, which is why they + * are indistinguishable in the output. Loading fresh is why this shape has no + * boundaries to capture, and also why a step-gated page is acceptable here: a + * directly mounted page arrives with `entryDirection` at `jump`, and `Steps` + * initialises to `stepCount` for any direction other than `forward`, so every + * step is already revealed. + */ +async function capturePerPage(page: Page, options: DeckCaptureOptions): Promise { + const { port, slideId, selection, outDir, timeoutMs } = options; + const units: CaptureUnit[] = []; + + for (const pageIndex of selection.pageIndices) { + const framesDir = path.join(outDir, `.frames-${slideId}-p${pageIndex + 1}`); + await fs.mkdir(framesDir, { recursive: true }); + await page.goto(slideCaptureUrl(port, slideId, pageIndex), { waitUntil: 'load' }); + await waitForFonts(page, `${slideId}:p${pageIndex + 1}`, timeoutMs); + + const frames = await capturePage(page, slideId, pageIndex, framesDir, 0, options); + units.push({ + label: `${slideId}:p${pageIndex + 1}`, + framesDir, + outFile: path.join(outDir, videoFilenameFor(slideId, { pageIndex, total: selection.total })), + frames, + }); + } + + return units; +} + +/** + * Capture a deck into the frame sequences its resolved output shape calls for. + * + * The shape branch stops here: everything downstream — the encode, the frame + * cleanup, the stdout line — reads the units this returns and never asks which + * shape produced them. + */ +export async function captureDeck(page: Page, options: DeckCaptureOptions): Promise { + if (options.shape === 'per-page') return capturePerPage(page, options); + const unit = await captureWholeDeck(page, options); + return unit ? [unit] : []; +} + +/** + * Whether ffmpeg is callable from PATH. + * + * Preflighted alongside `playwright-chromium` rather than discovered at encode + * time, because by then a deck's worth of frames has already been rendered and + * the failure costs the whole run. + */ +export async function hasFfmpeg(): Promise { + return new Promise((resolve) => { + const proc = spawn('ffmpeg', ['-version'], { stdio: 'ignore' }); + proc.on('error', () => resolve(false)); + proc.on('close', (code) => resolve(code === 0)); + }); +} + +/** + * Encode a directory of numbered PNGs to H.264 MP4. + * + * `yuv420p` and the even-dimension scale filter are required for the result to + * play in browsers and QuickTime rather than only in ffplay. + * + * The frames are read in order and concatenated. There is deliberately no + * `-filter_complex` here: the page boundaries are already in the sequence as the + * viewer's own animations, and composing a second transition at encode time + * would define "transition" alongside the viewer's transition module. + * + * @throws When ffmpeg is missing from PATH or exits non-zero. + */ +export async function encodeFrames(framesDir: string, outFile: string, fps: number): Promise { + await new Promise((resolve, reject) => { + const proc = spawn( + 'ffmpeg', + [ + '-y', + '-framerate', + String(fps), + '-i', + path.join(framesDir, 'frame-%06d.png'), + '-c:v', + 'libx264', + '-preset', + 'medium', + '-crf', + '18', + '-pix_fmt', + 'yuv420p', + '-vf', + 'scale=trunc(iw/2)*2:trunc(ih/2)*2', + outFile, + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + + let stderr = ''; + proc.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + proc.on('error', (err) => { + reject( + new ExportUsageError( + `ffmpeg could not be started (${err.message}). Install ffmpeg and ensure it is on PATH.`, + ), + ); + }); + proc.on('close', (code) => { + if (code === 0) return resolve(); + reject(new Error(`ffmpeg exited with code ${code}\n${stderr.slice(-2000)}`)); + }); + }); +} + +/** + * Reject flag combinations the capture cannot honour. + * + * @throws {ExportUsageError} On mutually exclusive selection, `--page` without + * a deck, no selection at all, or an out-of-range frame rate. + */ +export function validateFlags(flags: VideoFlags): void { + if (flags.slide && flags.all) { + throw new ExportUsageError('--slide and --all are mutually exclusive'); + } + if (flags.page !== undefined && !flags.slide) { + throw new ExportUsageError('--page requires --slide'); + } + if (!flags.slide && !flags.all) { + throw new ExportUsageError('one of --slide or --all is required'); + } + if (flags.fps !== undefined && (flags.fps < 1 || flags.fps > 120)) { + throw new ExportUsageError('--fps must be between 1 and 120'); + } +} + +/** + * Entry point invoked by `run.ts` after Commander parses the flags. + * + * Tears the browser and dev server down on every exit path, matching the PNG + * exporter: a leaked Chromium hangs CI until the job times out. + */ +export async function videoCommand(flags: VideoFlags = {}): Promise { + try { + validateFlags(flags); + } catch (err) { + if (err instanceof ExportUsageError) { + process.stderr.write(`${err.message}\n`); + process.exit(2); + } + throw err; + } + + const playwright = await tryImportPlaywright(); + if (playwright === null) { + process.stderr.write( + [ + '`open-slide video` needs playwright-chromium, which is not installed in this workspace.', + '', + ' pnpm add -D playwright-chromium', + ' npx playwright install chromium', + '', + ].join('\n'), + ); + process.exit(2); + } + + if (!(await hasFfmpeg())) { + process.stderr.write( + [ + '`open-slide video` needs ffmpeg, which was not found on PATH.', + '', + ' macOS: brew install ffmpeg', + ' Debian: sudo apt install ffmpeg', + ' Windows: winget install Gyan.FFmpeg', + '', + ].join('\n'), + ); + process.exit(2); + } + + const outDir = path.resolve(process.cwd(), flags.out ?? DEFAULT_OUT_DIR); + const fps = flags.fps ?? DEFAULT_FPS; + const dwellMs = flags.dwell ?? DEFAULT_DWELL_MS; + const timeoutMs = flags.timeout ?? DEFAULT_TIMEOUT_MS; + + await fs.mkdir(outDir, { recursive: true }); + + let server: ViteDevServer | null = null; + let browser: Browser | null = null; + try { + const started = await startDevServer({ port: flags.port }); + server = started.server; + const slides = await enumerateSlides(started.port); + const targets = resolveExportTargets(flags, slides); + + for (const id of zeroPageDeckIds(flags, slides)) { + process.stderr.write( + `${id}: 0 pages — skipped (deck is empty, or its default export could not be parsed)\n`, + ); + } + + browser = await playwright.chromium.launch(); + /* + `Player` passes `disabled={prefersReducedMotion}` to the transition layer, + and the disabled branch swaps pages with no animation at all — so a + capture run under a `reduce` preference yields a valid file with no + transitions and no error anywhere. + */ + const context = await browser.newContext({ + viewport: { width: CANVAS_WIDTH, height: CANVAS_HEIGHT }, + deviceScaleFactor: 1, + reducedMotion: 'no-preference', + }); + await context.addInitScript(pauseAllAnimations); + const page = await context.newPage(); + + const summaries: string[] = []; + for (const [slideId, selection] of groupTargetsByDeck(targets)) { + const stepCounts = await probeStepCounts(page, started.port, slideId, selection, timeoutMs); + const gated = stepGatedPages(stepCounts); + const shape = outputShapeFor(stepCounts, flags.perPage); + const forcedByGating = shape === 'per-page' && gated.length > 0 && !flags.perPage; + + /* A successful outcome, not a failure: the run continues and exits 0. */ + if (forcedByGating) { + process.stderr.write(`${stepGatedWarning(slideId, gated, selection.total)}\n`); + } + + const units = await captureDeck(page, { + port: started.port, + slideId, + selection, + shape, + outDir, + fps, + dwellMs, + timeoutMs, + durationOverrideMs: flags.duration, + }); + + for (const unit of units) { + await encodeFrames(unit.framesDir, unit.outFile, fps); + if (!flags.keepFrames) await fs.rm(unit.framesDir, { recursive: true, force: true }); + process.stdout.write( + `${unit.label} → ${path.relative(process.cwd(), unit.outFile) || unit.outFile} (${unit.frames} frames @ ${fps}fps)\n`, + ); + } + + summaries.push(outputShapeSummary(slideId, shape, units.length, forcedByGating)); + } + + const deckCount = new Set(targets.map((t) => t.slideId)).size; + process.stdout.write( + `Rendered ${targets.length} page(s) from ${deckCount} deck(s) to ${path.relative(process.cwd(), outDir) || outDir}\n`, + ); + for (const summary of summaries) process.stdout.write(` ${summary}\n`); + } catch (err) { + if (err instanceof ExportUsageError) { + process.stderr.write(`${err.message}\n`); + await closeAll(browser, server); + process.exit(2); + } + process.stderr.write(`${(err as Error).message ?? String(err)}\n`); + await closeAll(browser, server); + process.exit(1); + } finally { + await closeAll(browser, server); + } +} + +async function closeAll(browser: Browser | null, server: ViteDevServer | null): Promise { + if (browser) await browser.close().catch(() => {}); + if (server) await server.close().catch(() => {}); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2122c6120..bfbdd2cec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: happy-dom: specifier: 20.9.0 version: 20.9.0 + playwright-chromium: + specifier: ~1.56.1 + version: 1.56.1 turbo: specifier: ^2.10.5 version: 2.10.7 From a3d1d929aa739ddd38c61050e894b3194d3b886d Mon Sep 17 00:00:00 2001 From: desek Date: Wed, 29 Jul 2026 13:35:29 +0200 Subject: [PATCH 2/3] docs: document `open-slide video` and the loop-variant pattern Adds the CLI reference page alongside the other subcommands, and corrects the loop-variant authoring reference, which was written before a loop variant existed and carried three claims that did not survive building one. --- README.md | 12 + apps/web/content/docs/cli/meta.json | 2 +- apps/web/content/docs/cli/overview.mdx | 2 +- apps/web/content/docs/cli/video.mdx | 216 ++++++++++++++++++ packages/core/skills/create-slide/SKILL.md | 2 + packages/core/skills/slide-authoring/SKILL.md | 7 + .../references/loop-variant.md | 56 +++++ 7 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 apps/web/content/docs/cli/video.mdx create mode 100644 packages/core/skills/slide-authoring/references/loop-variant.md diff --git a/README.md b/README.md index c55349240..b5ec57a50 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ One command exports your deck as a self-contained static HTML site, a print-read PNG export is also how the agent writing your slides **checks its own work**: a 1920×1080 image is readable by vision-capable models, so clipping, overflow, distorted aspect ratios, and collisions get caught by looking at the slide instead of guessing at it. +### 🎥 Capture a deck as video + +`open-slide video --slide ` records a whole deck as a single MP4: every page's animation played through, held for `--dwell` milliseconds, joined by the deck's own slide transitions, and encoded once. Animations are paused and seeked frame by frame rather than sampled on a timer, so the capture is frame-exact rather than sampled whenever the compositor happened to deliver: page and dwell frames are reproducible byte for byte across runs, and transition frames are reproducible to within antialiasing on scale-animated layers. A screen recording is reproducible in neither sense. Needs `ffmpeg` on `PATH`. + +```bash +open-slide video --slide intro # one MP4 for the deck +open-slide video --all --fps 60 --dwell 3000 # every deck, 60fps, 3s hold per page +open-slide video --slide intro --per-page # one MP4 per page instead +``` + +A deck with a step-gated page falls back to one MP4 per page automatically, since the capture never advances past a pending step; each page is captured with every step already revealed, so no content is missing. Run `open-slide video --help` for every flag. + ### 📁 Slide manager Organise decks into folders with custom emoji and drag-and-drop to reorder. Useful once you've built more than three decks and need to find anything. diff --git a/apps/web/content/docs/cli/meta.json b/apps/web/content/docs/cli/meta.json index eeacf4cba..a01049e8c 100644 --- a/apps/web/content/docs/cli/meta.json +++ b/apps/web/content/docs/cli/meta.json @@ -1,4 +1,4 @@ { "title": "CLI", - "pages": ["overview", "init", "dev", "build", "preview", "export", "sync-skills"] + "pages": ["overview", "init", "dev", "build", "preview", "export", "video", "sync-skills"] } diff --git a/apps/web/content/docs/cli/overview.mdx b/apps/web/content/docs/cli/overview.mdx index ce8017646..f5185cc91 100644 --- a/apps/web/content/docs/cli/overview.mdx +++ b/apps/web/content/docs/cli/overview.mdx @@ -8,7 +8,7 @@ open-slide ships two CLIs: - **`@open-slide/cli`** — the scaffolder. One command (`init`) to bootstrap a new workspace. - **`@open-slide/core`** — the runtime CLI. `dev`, `build`, `preview`, - `export`, and `sync:skills` for an existing workspace. + `export`, `video`, and `sync:skills` for an existing workspace. After `init`, `package.json` exposes the runtime CLI under standard scripts: diff --git a/apps/web/content/docs/cli/video.mdx b/apps/web/content/docs/cli/video.mdx new file mode 100644 index 000000000..de658a147 --- /dev/null +++ b/apps/web/content/docs/cli/video.mdx @@ -0,0 +1,216 @@ +--- +title: open-slide video +description: Capture a whole deck as a single MP4 by seeking its animations frame by frame. +--- + +```npm +open-slide video --slide intro +# or +open-slide video --all --fps 60 --dwell 3000 +``` + +Boots the same in-process Vite dev server and headless Chromium the PNG +exporter uses ([`open-slide export`](/docs/cli/export)), then captures a +whole deck as **one continuous browsing session**: each page's animation +played through at a fixed frame rate, held for `--dwell`, joined by the +deck's own slide transitions, and encoded once with `ffmpeg`. + +Every animation is paused the moment it appears and driven by setting its +`currentTime` per frame, so frames are seeked rather than sampled on a +timer. That is the difference from a screen recording, which samples +whenever the compositor happened to deliver a frame and therefore drops +and duplicates frames under load. No deck needs editing to be capturable +— existing CSS-keyframe decks are seekable as they are. + +## Prerequisites + +Two, both checked before any work is done. A missing one exits with code +`2` and prints a copy-pasteable install command. + +### Playwright + +`playwright-chromium` is **not** a runtime dependency of +`@open-slide/core`. Install it in the workspace that runs the capture: + +```bash +pnpm add -D playwright-chromium +npx playwright install chromium +``` + +### ffmpeg + +`ffmpeg` must be on `PATH`. It is invoked as a subprocess and never +bundled: + +```bash +brew install ffmpeg # macOS +sudo apt install ffmpeg # Debian/Ubuntu +winget install Gyan.FFmpeg # Windows +``` + +## Flags + +| Flag | Default | Description | +| ---------------- | ---------------- | --------------------------------------------------------------------------------- | +| `--slide ` | — | Restrict to a single deck (the `slideId` that appears in `/s/:slideId`). | +| `--all` | off | Capture every discoverable deck. Mutually exclusive with `--slide`. | +| `--page ` | — | Capture a single 1-based page index. Requires `--slide`. | +| `--out ` | `./video-export` | Destination directory (created if missing). | +| `--per-page` | off | Write one MP4 per page instead of one per deck. | +| `--fps ` | `30` | Frames per second. Must be between `1` and `120` inclusive. | +| `--duration `| measured | Replace every page's measured animation length. Boundaries are unaffected. | +| `--dwell ` | `1500` | Hold after each page's animation settles. Run-wide; there is no per-page dwell. | +| `--keep-frames` | off | Keep the intermediate PNG frames instead of deleting them after a good encode. | +| `--port ` | OS-assigned | Pin the in-process dev server's port instead of an ephemeral one. | +| `--timeout ` | `15000` | Per-page readiness timeout. | + +Either `--slide` or `--all` is required. `--page` without `--slide`, both +selection flags together, and an out-of-range `--fps` are usage errors and +exit with code `2`. + +### How a page's length is decided + +Each page contributes its animation followed by the dwell. The animation +length is measured from the page's **longest finite animation**; infinite +animations are seeked too, so looping motion animates in the output, but +they are excluded from the measurement because they have no end. A page +with no finite animation falls back to 3000 ms, and any measured or +overridden duration is clamped to 60000 ms so one runaway delay cannot +produce an hour of frames. + +## Transitions are the deck's own + +Pages are **not** crossfaded at encode time. The capture advances the deck +inside the viewer — the same navigation an audience drives — so +`SlideTransitionLayer` mounts the outgoing and incoming layers and creates +the real transition animations, and those animations are then seeked frame +by frame like any other. The boundary's length is measured from the +animations that actually appeared, not read off the declared +`SlideTransition.duration`, because per-phase and morph overrides can +extend it. + +Two consequences follow: + +- The output shows whatever transitions the deck declares — see + [`SlideTransition`](/docs/primitive/transition) and + [Slide transitions](/docs/reference/slide-transitions). +- A deck that declares **no** transition produces no boundary frames at + all. Its pages simply cut. + +The capture also pins `prefers-reduced-motion: no-preference` in the +browser context, because the viewer disables transitions outright under a +`reduce` preference and would otherwise produce a silently +transition-free file. + +## Step-gated decks fall back to per-page output + +A deck whose pages are gated by [`` / ``](/docs/primitive/step) +is written as **one MP4 per page** rather than one per deck, without you +passing `--per-page`, and the CLI warns when that happens. A continuous +capture cannot cross a step-gated page without walking its reveals, and +the capture never advances past a pending step. + +**No content is missing.** Each page in the fallback is loaded fresh, and +a directly mounted page arrives with every step already revealed — the +same rule that applies when a human jumps into a page. What the fallback +loses is the reveal *motion*, which only fires on a step change that never +happens on a fresh mount, and the inter-page transitions, because +independent page loads have no boundaries. + +The fallback is a successful outcome: the run continues and exits `0`. + +### Getting back to a single file: a loop variant + +The warning recommends authoring a **loop variant** of the deck — a +sibling deck built for capture rather than for presenting, with: + +1. step reveals replaced by time-based animations, +2. transitions declared between pages, +3. pages that advance automatically instead of on input, +4. a closing transition into the first page's background, so the file + plays as a seamless loop. + +A deck authored that way needs no presenter, which is precisely what a +recording lacks. The pattern is documented as +`references/loop-variant.md` in the bundled +[`/slide-authoring`](/docs/skills/slide-authoring) skill, so the agent +writing your deck can build one; the CLI warning and that reference are +single sourced and cannot drift apart. + +## Output + +Written into `--out` as: + +```text +{slideId}.mp4 # whole-deck shape +{slideId}-p{N}.mp4 # per-page shape (--per-page, or the step-gated fallback) +``` + +`N` is the 1-based page index, zero-padded to the total page count's +width, matching the PNG exporter's convention. A file the fallback wrote +is indistinguishable from one `--per-page` wrote: both shapes share one +capture path, one encode path, and one filename derivation. + +Under `--all`, a workspace holding both kinds of deck legitimately +produces **both shapes in one invocation**, which is why the closing +summary states which shape each deck got. + +Streams are H.264 (`libx264`, CRF 18) in `yuv420p` with even dimensions, +so the files play in browsers and system players rather than only in +developer tooling. Every frame is exactly 1920×1080. Intermediate frames +are deleted after a successful encode unless `--keep-frames` is passed. + +## Console output + +One line per page and per boundary, then one line per written file, then a +per-deck summary: + +```text +intro:p1 — 5 animation(s), 1500ms, 90 frame(s) +intro:p1→p2 — 3 transition animation(s), 600ms, 18 frame(s) +intro:p2 — 2 animation(s), 1200ms, 81 frame(s) +intro → video-export/intro.mp4 (189 frames @ 30fps) +Rendered 2 page(s) from 1 deck(s) to video-export + intro — 1 MP4 (whole deck) + workshop — 4 MP4(s) (per page, step-gated fallback) +``` + +The animation count is the load-bearing number: a page reporting zero +animations is visible on stdout rather than only after opening the file. +Under `--all`, a deck reporting zero pages is named on stderr and skipped +rather than encoded. + +## Reproducibility + +A capture is reproducible in a way a screen recording is not, but the +guarantee is qualified by segment kind. Across two runs over an unchanged +deck: + +- **Page segments and dwell holds are byte-identical.** +- **Transition boundary segments are reproducible to within antialiasing + on scale-animated layers.** Edge antialiasing on a scaled compositor + layer rasterises to one of two stable variants, chosen per run, so a + boundary frame can differ between runs with no capture defect. The bound + held to is `YMAX <= 160` and `YAVG <= 1.0` on the absolute luma + difference per corresponding frame pair. +- The frame index and count of every segment are identical across runs + regardless of kind. + +See CR-0003 for the measurements behind those thresholds. + +## Exit codes + +| Code | Meaning | +| ---- | ---------------------------------------------------------------------------------- | +| `0` | Success — including a run that fell back to per-page output. | +| `1` | Unrecoverable runtime error (dev server failed, Chromium crashed, ffmpeg failed). | +| `2` | Usage or preflight error (missing Playwright or ffmpeg, bad flags, unknown `--slide`). | + +The browser and the dev server are torn down on every exit path, including +error and usage-error paths. + +## Not supported + +Audio, presenter-driven timing, per-page dwell, driving `` as a +timeline, formats other than MP4, and an in-browser recording UI. This is +CLI and CI tooling, matching where `open-slide export` sits. diff --git a/packages/core/skills/create-slide/SKILL.md b/packages/core/skills/create-slide/SKILL.md index 698c438de..eb85db6b3 100644 --- a/packages/core/skills/create-slide/SKILL.md +++ b/packages/core/skills/create-slide/SKILL.md @@ -39,6 +39,8 @@ Then ask these four in a single `AskUserQuestion` call (multi-question form): 3. **Text density per page** — how much copy lives on each page? Offer: minimal (one line / big number), light (heading + 2–3 bullets), standard (heading + 4–5 bullets or short paragraph), dense (multi-column / detailed). This directly drives type scale and layout. 4. **Motion** — does the user want CSS/React animations and transitions, or a fully static deck? Offer: static (no motion), subtle (fades / entrance only), rich (keyframes, staggered reveals, looping visuals). If animated, plan around the framework primitives first — ``/`` for staged reveals, `SlideTransition` for page changes, morph for shared-element continuity (see `slide-authoring`) — plus CSS `@keyframes` / inline `style` + `useEffect` for in-page motion; no extra libraries. + **If the deliverable is a video rather than a presentation, say so now** — a deck captured by `open-slide video` is a different shape, not a variant of the same file. It cannot contain a single `` (one gated page changes the output for the whole deck), it needs a wrap page to close the loop, and every reveal has to be time-based. Retrofitting that later means rewriting the deck. Read `references/loop-variant.md` in `slide-authoring` before writing the first page. + After those four, ask follow-ups **only if still unclear**: brand colors, required assets. Don't pad the conversation with questions already answered. ## Step 3 — Pick a slide id diff --git a/packages/core/skills/slide-authoring/SKILL.md b/packages/core/skills/slide-authoring/SKILL.md index 50f5878ad..8ca0838e5 100644 --- a/packages/core/skills/slide-authoring/SKILL.md +++ b/packages/core/skills/slide-authoring/SKILL.md @@ -27,6 +27,7 @@ Each framework primitive has a full reference file under `references/` in this s | `` / `` | staging a page's reveal | `references/steps.md` | | `SlideTransition` | declaring any enter/exit animation | `references/transitions.md` | | `MorphElement` + `morph` | morphing a shared element across pages | `references/morph.md` | +| Loop variant | authoring a deck to be captured as video | `references/loop-variant.md` | ## Hard rules @@ -263,6 +264,12 @@ When the *same visual object* exists on two adjacent pages, wrap it on both page Read `references/morph.md` before writing one — the seven rules there (opacity-only enter/exit, deterministic geometry, no `transform` on the morph node, `useIsActivePage()` gating, …) were each earned on a real deck, and violating any of them produces a visibly broken morph. +## Loop variants (a deck authored for capture) + +`open-slide video` captures a deck as an MP4, and a deck with any step-gated page falls back to one file per page, because the capture never advances past a pending step. When the deliverable is a video rather than a talk, author a **loop variant**: a sibling deck (`slides/-loop/`) in which step reveals are replaced by time-based animations, transitions are declared between pages, pages advance automatically instead of on input, and a closing transition lands on the first page's background so the file plays as a seamless loop. + +Read `references/loop-variant.md` before writing one — it covers the four elements in full, how the timeline is measured, and the anti-patterns. + ## Repeated elements: component, not `map` When a page has visually repeated items — cards, logo rows, gallery tiles, list rows, step indicators — **define a small component and instantiate it once per item**. Do **not** render the group with `array.map` over a data array. diff --git a/packages/core/skills/slide-authoring/references/loop-variant.md b/packages/core/skills/slide-authoring/references/loop-variant.md new file mode 100644 index 000000000..ffc3e55fd --- /dev/null +++ b/packages/core/skills/slide-authoring/references/loop-variant.md @@ -0,0 +1,56 @@ +# Loop variant (a deck authored for capture) + +A **loop variant** is a sibling deck built to be recorded rather than presented. `open-slide video` captures a deck as a single MP4 by seeking every animation frame by frame, but it never drives a step: a deck with a step-gated page falls back to one MP4 per page, because a continuous capture cannot advance past a pending step without walking its reveals. A loop variant is the route back to one file. Author it when the deliverable is a video for a README header, release notes, a docs page, or a social post, where nobody is there to press `→`. + +Keep the presented deck as it is. A loop variant is a *second* deck (`slides/-loop/`), not an edit of the first: the presented deck wants a presenter's pacing, the loop variant wants a fixed timeline. + +## The four elements + +1. **Step reveals replaced by time-based animations.** Drop `` / `` entirely and stage the same beats with CSS keyframes carrying staggered `animation-delay`. The capture measures each page from its longest finite animation, so the stagger *is* the pacing. This is the load-bearing element: any `` left in the deck sends the whole deck back to per-page output. +2. **Transitions declared between pages.** Give the deck a module-level `SlideTransition` (per-page overrides where a page earns one). The capture seeks the viewer's own transition at each boundary, so an undeclared transition is a hard cut in the file. See `transitions.md` for the contract and the tasteful family; `morph.md` if a shared element carries across a boundary. +3. **Pages that advance automatically instead of on input.** With no step gate left, the capture advances the deck itself: each page contributes its measured animation followed by the run-wide `--dwell` hold, then the boundary. Author each page so it reads fully in that window rather than assuming someone will linger on it. +4. **A closing transition into the first page's background, so the file plays as a seamless loop.** This needs an extra page, and that is not obvious. A transition attaches to the page being entered, so the deck's last content page never gets an outgoing one — the capture walks pages in order and stops. Add a final wrap page holding nothing but the background, and put the closing transition on *that* page. Without it there is no closing boundary at all, however carefully the last page's transition is authored. + + The wrap page also has to match what frame 0 actually renders, which is *not* the first page: every `both`-filled entrance is at `opacity: 0` before it starts, so frame 0 is the bare background. A wrap page rendering the same bare background makes the first and last frames identical, and the loop point disappears. Give it one short animation — a page with no finite animation takes the 3 s fallback (see below) and holds an empty screen for far longer than a beat. + +## Authoring notes + +- **One motion DNA across the deck.** The video makes inconsistency far more obvious than a live presentation does, because every boundary plays back to back with no talking over it. +- **Budget the timeline.** Total length is the sum of each page's measured animation plus `--dwell` plus each boundary. A ten-page loop variant at the 1500 ms default dwell already runs past 20 seconds, which is long for a README header. Fewer, denser pages beat more, thinner ones. +- **A page with no finite animation costs 3 s, not the dwell.** With nothing finite to measure, the capture falls back to a fixed 3 s and *then* adds the dwell, so a "quick" static page is the most expensive one in the deck. One short animation is enough to set its length explicitly. +- **An ambient animation still sets the page's length.** Page duration is the longest finite animation, so a slow background turn timed to cover the dwell becomes the measurement and inflates every page it is on. Keep ambient motion inside the window the content already needs. +- **Infinite animations reset at every cut.** They are excluded from the duration measurement, which makes them look like the right tool for ambient motion — but the runtime mounts a *fresh* instance of the outgoing page during a boundary, and a fresh mount restarts an infinite animation at zero. The motion visibly snaps back at every boundary. For anything that should carry across a cut, use a finite animation whose resting style is its own end state, and start the next page's from where the last one ended. +- **Capture it with `open-slide video --slide -loop`.** Add `--keep-frames` while iterating and look at the frames directly; that is far faster than re-watching the MP4 to find the page that mistimed. + +## The snapshot contract + +During every boundary the runtime mounts a **second, fresh instance of the outgoing page** to snapshot its exit state (`morph.md` rule 5), and the dev UI mounts more for thumbnails and overview. Gate entrances behind `useIsActivePage()` so those instances render settled instead of replaying. Three consequences bite specifically in a loop variant, and every one of them produces a valid MP4 of the wrong thing. + +- **A resting style must equal the settled state — including for exits.** A frozen instance renders base styles, so the freeze only tells the truth when the base style *is* where the animation ends. Entrances satisfy this for free (they end where they rest). An exit does not: an element that crushes or fades *away* rests visible, so it reappears intact on the outgoing snapshot — the content flashing back at the exact moment the page finished removing it. Give every exit a resting style matching its end (`opacity: 0`, the final transform). +- **A `