diff --git a/demos/showcase.config.mjs b/demos/showcase.config.mjs index 0569891..acae520 100644 --- a/demos/showcase.config.mjs +++ b/demos/showcase.config.mjs @@ -10,10 +10,11 @@ export default defineConfig({ height: 1080, fps: 30, browser: 'chromium', - // 2x DPI capture — page renders at 3840×2160, export downscales with lanczos. - // Requires --force-device-scale-factor flag (auto-applied by record.ts) so the - // CDP screencast actually delivers 4K JPEGs (without the flag, screencast caps - // at viewport CSS pixels regardless of DPR). + // 2x DPI capture — page keeps the normal 1920px desktop layout while the + // recorder captures a 3840×2160 source. + // Requires --force-device-scale-factor flag (auto-applied by record.ts) so + // the CDP screencast actually delivers 4K JPEGs (without the flag, + // screencast caps at viewport CSS pixels regardless of DPR). deviceScaleFactor: 2, // EXPERIMENT (feat/jpeg-stitch): capture all frames as high-quality JPEGs // and stitch in post with libx264 — bypasses the engine's VP8 encoder. @@ -31,6 +32,10 @@ export default defineConfig({ // Pin the encoder so preview re-export matches pipeline output exactly. // Without this, preview defaults to GPU (videotoolbox watercolor risk). encoder: 'cpu', + // Keep the 1920px-wide desktop layout during recording, but export a 4K + // master so the 2x capture is not downscaled back to 1080p. + outputWidth: 3840, + outputHeight: 2160, transition: { type: 'shader', shader: 'crosswarp', durationMs: 1200 }, // speedRamp: { gapSpeed: 2.0, minGapMs: 600 }, // disabled for now — conflicts with transitions // formats: ['gif'], // too long for GIF — use argo clip for scene-level GIFs @@ -39,7 +44,12 @@ export default defineConfig({ src: 'assets/logo-watermark.png', position: 'bottom-right', opacity: 0.16, - margin: 26, + // 4K master: scale up so the watermark stays visually-relative to a + // 1080p master, and bump margin proportionally. The PNG is sampled with + // bicubic during scale — for cleanest output, replace with a 2× asset + // when one is available. + scale: 2, + margin: 52, }, sharpen: true, // Frame: wrap the recording in a styled frame with padding, rounded corners, diff --git a/src/camera-move.ts b/src/camera-move.ts index 62a8b39..584c9f3 100644 --- a/src/camera-move.ts +++ b/src/camera-move.ts @@ -270,17 +270,17 @@ export function shiftCameraMoves(moves: CameraMove[], offsetMs: number): CameraM } /** - * Scale camera move coordinates by deviceScaleFactor. - * During recording, bounding boxes are in CSS pixels but the video is - * captured at scaled resolution. Coordinates need to match the video frame. + * Scale camera move coordinates from CSS layout pixels to output-frame pixels. + * During recording, bounding boxes are measured in CSS pixels; export may + * downscale, preserve, or upscale relative to that layout. */ -export function scaleCameraMoves(moves: CameraMove[], deviceScaleFactor: number): CameraMove[] { - if (deviceScaleFactor <= 1) return moves; +export function scaleCameraMoves(moves: CameraMove[], scaleX: number, scaleY: number = scaleX): CameraMove[] { + if (scaleX === 1 && scaleY === 1) return moves; return moves.map((m) => ({ ...m, - x: Math.round(m.x * deviceScaleFactor), - y: Math.round(m.y * deviceScaleFactor), - w: Math.round(m.w * deviceScaleFactor), - h: Math.round(m.h * deviceScaleFactor), + x: Math.round(m.x * scaleX), + y: Math.round(m.y * scaleY), + w: Math.round(m.w * scaleX), + h: Math.round(m.h * scaleY), })); } diff --git a/src/cdp-screencast.ts b/src/cdp-screencast.ts index 52043a1..f55fa8c 100644 --- a/src/cdp-screencast.ts +++ b/src/cdp-screencast.ts @@ -202,8 +202,11 @@ export async function startCdpScreencast( try { await cdp.detach(); } catch { /* best-effort */ } if (frames.length === 0) { - console.warn('Warning: cdp-screencast: no frames captured — output mp4 not created.'); - return; + throw new Error( + `cdp-screencast captured 0 frames at ${options.size.width}x${options.size.height}; ` + + `output mp4 was not created. Try re-running with ARGO_CDP_DIRECT=0 to fall back ` + + `to Playwright's onFrame recorder and compare behavior.`, + ); } const concatPath = join(framesDir, 'concat.txt'); diff --git a/src/cli.ts b/src/cli.ts index abc17e4..2646712 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ import { Command, Option } from 'commander'; import { basename } from 'node:path'; import { createRequire } from 'node:module'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { loadConfig, resolveDemoConfigPath, type ArgoConfig, type BrowserEngine } from './config.js'; +import { loadConfig, resolveDemoConfigPath, resolveExportSize, type ArgoConfig, type BrowserEngine } from './config.js'; import { record } from './record.js'; import { generateClips } from './tts/generate.js'; import { exportVideo } from './export.js'; @@ -26,7 +26,7 @@ import { import { generateChapterMetadata } from './chapters.js'; import { generateSrt, generateVtt } from './subtitles.js'; import { applySpeedRampToTimeline, type Segment, type SceneSpeedMap } from './speed-ramp.js'; -import { shiftCameraMoves, type CameraMove } from './camera-move.js'; +import { scaleCameraMoves, shiftCameraMoves, type CameraMove } from './camera-move.js'; import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs, type FreezeSpec } from './freeze.js'; import { renderShaderTransitions } from './transitions/shader-render.js'; import type { Placement } from './tts/align.js'; @@ -134,6 +134,7 @@ export function createProgram(): Command { validateDemoName(demo); const configPath = program.opts().config; const config = await loadConfigForDemo(demo, configPath); + const exportSize = resolveExportSize(config); const demoDir = `.argo/${demo}`; const timingPath = `${demoDir}/.timing.json`; const manifestPath = `${config.demosDir}/${demo}.scenes.json`; @@ -225,8 +226,8 @@ export function createProgram(): Command { demoName: demo, manifestPath, placements: placements ?? [], - videoWidth: config.video.width, - videoHeight: config.video.height, + videoWidth: exportSize.width, + videoHeight: exportSize.height, deviceScaleFactor: config.video.deviceScaleFactor, }); @@ -248,8 +249,8 @@ export function createProgram(): Command { videoPath: videoFile, boundaries, shader: shaderTransition.shader, - width: config.video?.width ?? 1280, - height: config.video?.height ?? 720, + width: exportSize.width, + height: exportSize.height, fps: config.video?.fps ?? 30, cacheDir: `.argo/${demo}/shaders`, }); @@ -267,8 +268,8 @@ export function createProgram(): Command { preset: config.export.preset, crf: config.export.crf, fps: config.video.fps, - outputWidth: config.video.width, - outputHeight: config.video.height, + outputWidth: exportSize.width, + outputHeight: exportSize.height, deviceScaleFactor: config.video.deviceScaleFactor, thumbnailPath: config.export.thumbnailPath, chapterMetadataPath, @@ -287,8 +288,9 @@ export function createProgram(): Command { if (existsSync(cameraMovesPath)) { let moves: CameraMove[] = JSON.parse(readFileSync(cameraMovesPath, 'utf-8')); if (headTrimMs && headTrimMs > 0) moves = shiftCameraMoves(moves, headTrimMs); - // Coords stay at CSS pixels — export's zoompan filter operates on - // already-downscaled output-dim frames. See pipeline.ts comment. + const scaleX = exportSize.width / config.video.width; + const scaleY = exportSize.height / config.video.height; + moves = scaleCameraMoves(moves, scaleX, scaleY); return moves; } } catch { /* optional */ } @@ -430,6 +432,7 @@ export function createProgram(): Command { .action(async (demo: string | undefined, cmdOpts: { port?: number }) => { const configPath = program.opts().config; const config = await loadConfigForDemo(demo, configPath); + const exportSize = resolveExportSize(config); if (!demo) { // Dashboard mode — list all demos @@ -442,8 +445,10 @@ export function createProgram(): Command { preset: config.export.preset, crf: config.export.crf, fps: config.video.fps, - outputWidth: config.video.width, - outputHeight: config.video.height, + captureWidth: config.video.width, + captureHeight: config.video.height, + outputWidth: exportSize.width, + outputHeight: exportSize.height, deviceScaleFactor: config.video.deviceScaleFactor, thumbnailPath: config.export.thumbnailPath, formats: config.export.formats, @@ -479,8 +484,10 @@ export function createProgram(): Command { preset: config.export.preset, crf: config.export.crf, fps: config.video.fps, - outputWidth: config.video.width, - outputHeight: config.video.height, + captureWidth: config.video.width, + captureHeight: config.video.height, + outputWidth: exportSize.width, + outputHeight: exportSize.height, deviceScaleFactor: config.video.deviceScaleFactor, thumbnailPath: config.export.thumbnailPath, formats: config.export.formats, diff --git a/src/config.ts b/src/config.ts index d8466a4..2cd27ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -101,8 +101,15 @@ export interface WatermarkConfig { position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; /** Opacity (0.0 to 1.0). Default: 0.7. */ opacity?: number; - /** Margin from edges in pixels. Default: 20. */ + /** Margin from edges in pixels. Default: 20. Note: this is in OUTPUT pixels — + * at 4K output you may want to roughly double the value used at 1080p. */ margin?: number; + /** Scale factor applied to the watermark image before overlay. Default: 1. + * Useful when exporting at higher resolutions (e.g. set to 2 when going from + * 1080p to 4K so the logo stays the same relative size on screen). The + * upstream PNG is resampled with bicubic — keep originals at 2× the target + * on-screen size for cleanest scaling. */ + scale?: number; } export type BackgroundType = 'solid' | 'gradient' | 'image' | 'auto'; @@ -145,6 +152,10 @@ export interface VariantConfig { export interface ExportConfig { preset: string; crf: number; + /** Final output width. Defaults to `video.width` when omitted. */ + outputWidth?: number; + /** Final output height. Defaults to `video.height` when omitted. */ + outputHeight?: number; thumbnailPath?: string; formats?: Array<'1:1' | '9:16' | 'gif'>; /** Scene transition applied between scenes during export. */ @@ -242,6 +253,13 @@ export function defineConfig(userConfig: UserConfig): ArgoConfig { }; } +export function resolveExportSize(config: Pick): { width: number; height: number } { + return { + width: config.export.outputWidth ?? config.video.width, + height: config.export.outputHeight ?? config.video.height, + }; +} + export function demosProject(options: { baseURL: string; demosDir?: string; diff --git a/src/export.ts b/src/export.ts index a7b7b4b..61f3380 100644 --- a/src/export.ts +++ b/src/export.ts @@ -504,10 +504,21 @@ export async function exportVideo(options: ExportOptions): Promise { }; const posExpr = positionMap[wmPosition]; - // Build watermark filter chain - const wmRef = `${wmInputIdx}:v`; + // Build watermark filter chain. When `scale` is set, resample the + // watermark BEFORE applying opacity so the alpha channel is honored + // through the scale (`scale` defaults to bicubic sampling). Required + // when exporting at higher resolutions than the source PNG was + // designed for — at 4K, a 200×60 logo built for 1080p would otherwise + // render at 200×60 on a 3840-wide canvas, visually half the size. + let wmRef: string = `${wmInputIdx}:v`; + const wmScale = watermark.scale; + if (typeof wmScale === 'number' && wmScale > 0 && wmScale !== 1) { + filterParts.push( + `[${wmRef}]scale=iw*${wmScale}:ih*${wmScale}:flags=bicubic[wmscaled]`, + ); + wmRef = 'wmscaled'; + } const needsOpacity = wmOpacity < 1.0; - if (needsOpacity) { filterParts.push(`[${wmRef}]colorchannelmixer=aa=${wmOpacity}[wm]`); } diff --git a/src/fixtures.ts b/src/fixtures.ts index 61f8271..75adc8d 100644 --- a/src/fixtures.ts +++ b/src/fixtures.ts @@ -81,12 +81,21 @@ export const test = base.extend<{ narration: NarrationTimeline }>({ const durations = loadSceneDurations(); const timeline = new NarrationTimeline(durations); timeline.start(); + let useError: unknown; + let closeError: unknown; + let flushError: unknown; try { await use(timeline); + } catch (err) { + useError = err; } finally { // Stop the screencast (if any) before flushing — the page is still // alive at this point because Playwright tears down fixtures bottom-up. - await timeline._closeRecording(); + try { + await timeline._closeRecording(); + } catch (err) { + closeError = err; + } // Restore original env to avoid leaking across tests in the same worker if (autoDiscovered) { @@ -98,8 +107,16 @@ export const test = base.extend<{ narration: NarrationTimeline }>({ const outputPath = argoDir ? `${argoDir}/.timing.json` : `narration-${testInfo.title}.json`; - await timeline.flush(outputPath); + try { + await timeline.flush(outputPath); + } catch (err) { + flushError = err; + } } + + if (useError) throw useError; + if (closeError) throw closeError; + if (flushError) throw flushError; }, }); diff --git a/src/narration.ts b/src/narration.ts index f34fac2..4b265a6 100644 --- a/src/narration.ts +++ b/src/narration.ts @@ -351,18 +351,26 @@ export class NarrationTimeline { // Stop CDP screencast first so no more frames arrive after we've sealed // the stream-encode pipeline. Then pad/flush ffmpeg and wait for it to // close — order matters: we want the full set of CDP frames in the mp4. + const errors: string[] = []; try { await stop(); } catch (err) { - console.warn(`Warning: failed to stop screencast: ${(err as Error).message}`); + const message = `failed to stop screencast: ${(err as Error).message}`; + console.warn(`Warning: ${message}`); + errors.push(message); } if (cleanup) { try { await cleanup(); } catch (err) { - console.warn(`Warning: failed to finalize stream-encode: ${(err as Error).message}`); + const message = `failed to finalize stream-encode: ${(err as Error).message}`; + console.warn(`Warning: ${message}`); + errors.push(message); } } + if (errors.length > 0) { + throw new Error(`Recording teardown failed: ${errors.join('; ')}`); + } } /** diff --git a/src/pipeline.ts b/src/pipeline.ts index ff65b7c..ef906f6 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -10,14 +10,14 @@ import { generateSrt, generateVtt } from './subtitles.js'; import { generateChapterMetadata } from './chapters.js'; import { buildSceneReport, formatSceneReport } from './report.js'; import { applySpeedRampToTimeline, type SceneSpeedMap } from './speed-ramp.js'; -import { shiftCameraMoves, type CameraMove } from './camera-move.js'; +import { scaleCameraMoves, shiftCameraMoves, type CameraMove } from './camera-move.js'; import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs, type FreezeSpec, } from './freeze.js'; -import type { ArgoConfig } from './config.js'; +import { resolveExportSize, type ArgoConfig } from './config.js'; import { getVideoDurationMs } from './media.js'; import { buildOverlayPngsForImport } from './overlays/render-to-png.js'; import { renderShaderTransitions } from './transitions/shader-render.js'; @@ -100,6 +100,8 @@ export async function runPipeline( checkFfmpeg(); + const exportSize = resolveExportSize(config); + const argoDir = join('.argo', demoName); mkdirSync(argoDir, { recursive: true }); @@ -301,8 +303,8 @@ export async function runPipeline( demoName, manifestPath, placements: finalPlacements, - videoWidth: config.video.width, - videoHeight: config.video.height, + videoWidth: exportSize.width, + videoHeight: exportSize.height, deviceScaleFactor: config.video.deviceScaleFactor, }); @@ -315,8 +317,8 @@ export async function runPipeline( preset: config.export.preset, crf: config.export.crf, fps: config.video.fps, - outputWidth: config.video.width, - outputHeight: config.video.height, + outputWidth: exportSize.width, + outputHeight: exportSize.height, deviceScaleFactor: config.video.deviceScaleFactor, thumbnailPath: config.export.thumbnailPath, chapterMetadataPath, @@ -340,8 +342,8 @@ export async function runPipeline( // Pre-render frame PNG for faster encoding if (config.export.frame) { const framePngPath = join(argoDir, 'frame.png'); - const outW = config.video.width; - const outH = config.video.height; + const outW = exportSize.width; + const outH = exportSize.height; const pngResult = generateFramePng(framePngPath, outW, outH, config.export.frame); if (pngResult) { exportOptions.framePngPath = pngResult; @@ -353,11 +355,14 @@ export async function runPipeline( if (tailPadMs !== undefined) exportOptions.tailPadMs = tailPadMs; if (headTrimMs > 0) exportOptions.headTrimMs = headTrimMs; - // Apply camera moves — shift for head trim. Coords stay at CSS pixels; the - // export's zoompan filter operates on already-downscaled output-dim frames - // (see export.ts: vFilters lanczos downscale runs before cameraMoves). + // Apply camera moves — shift for head trim, then scale from CSS layout + // coordinates to the final export dimensions when those differ. if (cameraMoves.length > 0) { - exportOptions.cameraMoves = shiftCameraMoves(cameraMoves, headTrimMs); + let moves = shiftCameraMoves(cameraMoves, headTrimMs); + const scaleX = exportSize.width / config.video.width; + const scaleY = exportSize.height / config.video.height; + moves = scaleCameraMoves(moves, scaleX, scaleY); + exportOptions.cameraMoves = moves; } // Pre-render shader transition frames when transition type is 'shader' @@ -372,8 +377,8 @@ export async function runPipeline( videoPath, boundaries, shader: shaderTransition.shader, - width: config.video?.width ?? 1280, - height: config.video?.height ?? 720, + width: exportSize.width, + height: exportSize.height, fps: config.video?.fps ?? 30, cacheDir: join(argoDir, 'shaders'), }); diff --git a/src/preview.ts b/src/preview.ts index 7aea07c..d38b63c 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -21,7 +21,7 @@ import { generateSrt, generateVtt } from './subtitles.js'; import { generateChapterMetadata } from './chapters.js'; import { exportVideo, checkFfmpeg } from './export.js'; import { applySpeedRampToTimeline } from './speed-ramp.js'; -import { shiftCameraMoves, type CameraMove } from './camera-move.js'; +import { scaleCameraMoves, shiftCameraMoves, type CameraMove } from './camera-move.js'; import { generateFramePng } from './frame.js'; import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs, type FreezeSpec } from './freeze.js'; import { buildOverlayPngsForImport, isImportedVideo, type RenderedOverlayPng } from './overlays/render-to-png.js'; @@ -34,6 +34,8 @@ export interface PreviewExportConfig { preset?: string; crf?: number; fps?: number; + captureWidth?: number; + captureHeight?: number; outputWidth?: number; outputHeight?: number; deviceScaleFactor?: number; @@ -1020,8 +1022,11 @@ export async function startPreviewServer(options: PreviewOptions): Promise<{ url if (existsSync(cameraMovesPath)) { let moves: CameraMove[] = JSON.parse(readFileSync(cameraMovesPath, 'utf-8')); if (headTrimMs > 0) moves = shiftCameraMoves(moves, headTrimMs); - // Coords stay at CSS pixels — export's zoompan filter operates on - // already-downscaled output-dim frames. See pipeline.ts comment. + const captureW = ec?.captureWidth ?? ec?.outputWidth ?? 1920; + const captureH = ec?.captureHeight ?? ec?.outputHeight ?? 1080; + const outW = ec?.outputWidth ?? captureW; + const outH = ec?.outputHeight ?? captureH; + moves = scaleCameraMoves(moves, outW / captureW, outH / captureH); if (moves.length > 0) cameraMoves = moves; } } catch { /* optional */ } diff --git a/src/record.ts b/src/record.ts index 7499c1f..c206aca 100644 --- a/src/record.ts +++ b/src/record.ts @@ -297,11 +297,20 @@ export async function record(demoName: string, options: RecordOptions): Promise< } // narration.startRecording() wrote the screencast directly to videoPath. - // If it's missing, the demo never called startRecording() — fail loudly. + // If it's missing, surface a mode-specific hint. In jpeg-stitch mode the + // demo may have called startRecording() successfully, but the recorder + // can still fail during finalization (zero CDP frames, ffmpeg concat + // failure, etc.). if (!existsSync(videoPath)) { + const hint = useJpegStitch + ? `The demo completed, but captureMode: 'jpeg-stitch' did not produce ${videoPath}. ` + + `This usually means the CDP/ffmpeg recorder failed during finalization, not that ` + + `startRecording() was never called. Re-run and inspect the Playwright error output ` + + `for a cdp-screencast or stream-encode failure.` + : `Ensure the demo calls 'await narration.startRecording(page)' before the first ` + + `'narration.mark()'.`; reject(new Error( - `No screencast recording found at ${videoPath}. ` + - `Ensure the demo calls 'await narration.startRecording(page)' before the first 'narration.mark()'.` + `No screencast recording found at ${videoPath}. ${hint}` )); return; } diff --git a/tests/camera-move.test.ts b/tests/camera-move.test.ts index 83cf0db..d81837a 100644 --- a/tests/camera-move.test.ts +++ b/tests/camera-move.test.ts @@ -210,4 +210,15 @@ describe('scaleCameraMoves', () => { const result = scaleCameraMoves(moves, 1); expect(result).toBe(moves); }); + + it('supports non-uniform output scaling', () => { + const moves: CameraMove[] = [ + { startMs: 1000, durationMs: 400, x: 100, y: 200, w: 300, h: 400 }, + ]; + const scaled = scaleCameraMoves(moves, 2, 1.5); + expect(scaled[0].x).toBe(200); + expect(scaled[0].y).toBe(300); + expect(scaled[0].w).toBe(600); + expect(scaled[0].h).toBe(600); + }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 7632f0d..f3b4b45 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -3,6 +3,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../src/config.js', () => ({ loadConfig: vi.fn(), resolveDemoConfigPath: vi.fn().mockResolvedValue(undefined), + resolveExportSize: vi.fn((config: any) => ({ + width: config.export?.outputWidth ?? config.video.width, + height: config.export?.outputHeight ?? config.video.height, + })), })); vi.mock('../src/record.js', () => ({ diff --git a/tests/config.test.ts b/tests/config.test.ts index f7ad0ff..4495592 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -6,6 +6,7 @@ import { defineConfig, loadConfig, demosProject, + resolveExportSize, type ArgoConfig, type TTSEngine, } from '../src/config.js'; @@ -64,6 +65,13 @@ describe('defineConfig', () => { expect(config.export.crf).toBe(23); }); + it('allows export output size overrides', () => { + const config = defineConfig({ export: { outputWidth: 3840, outputHeight: 2160 } }); + expect(config.export.outputWidth).toBe(3840); + expect(config.export.outputHeight).toBe(2160); + expect(resolveExportSize(config)).toEqual({ width: 3840, height: 2160 }); + }); + it('preserves a custom TTS engine', () => { const engine: TTSEngine = { generate: async (_text, _options) => Buffer.from('audio'), diff --git a/tests/narration.test.ts b/tests/narration.test.ts index 70b9fd2..2ce4bf0 100644 --- a/tests/narration.test.ts +++ b/tests/narration.test.ts @@ -251,3 +251,33 @@ describe('sceneDuration', () => { expect(timeline.sceneDuration('missing')).toBe(5000); }); }); + +describe('_closeRecording', () => { + it('throws when stream finalization fails', async () => { + const timeline = new NarrationTimeline(); + const stop = vi.fn().mockResolvedValue(undefined); + const cleanup = vi.fn().mockRejectedValue(new Error('concat exited 1')); + (timeline as unknown as { _screencastStop: unknown })._screencastStop = stop; + (timeline as unknown as { _streamCleanup: unknown })._streamCleanup = cleanup; + + await expect(timeline._closeRecording()).rejects.toThrow( + 'Recording teardown failed: failed to finalize stream-encode: concat exited 1', + ); + expect(stop).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('surfaces both stop and cleanup failures', async () => { + const timeline = new NarrationTimeline(); + const stop = vi.fn().mockRejectedValue(new Error('stop failed')); + const cleanup = vi.fn().mockRejectedValue(new Error('cleanup failed')); + (timeline as unknown as { _screencastStop: unknown })._screencastStop = stop; + (timeline as unknown as { _streamCleanup: unknown })._streamCleanup = cleanup; + + await expect(timeline._closeRecording()).rejects.toThrow( + 'Recording teardown failed: failed to stop screencast: stop failed; failed to finalize stream-encode: cleanup failed', + ); + expect(stop).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index d00d009..6db8370 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -163,6 +163,29 @@ describe('runPipeline', () => { })); }); + it('uses export output size overrides and scales camera moves to match', async () => { + writeFileSync(join(ARGO_DIR, '.timing.camera-moves.json'), JSON.stringify([ + { startMs: 1200, durationMs: 400, x: 100, y: 120, w: 300, h: 240, scale: 1.4 }, + ])); + + const config = { + ...defaultConfig, + export: { ...defaultConfig.export, outputWidth: 3840, outputHeight: 2160 }, + video: { ...defaultConfig.video, deviceScaleFactor: 2 }, + }; + + await runPipeline(DEMO_NAME, config); + + expect(mockedExportVideo).toHaveBeenCalledWith(expect.objectContaining({ + outputWidth: 3840, + outputHeight: 2160, + deviceScaleFactor: 2, + cameraMoves: [ + expect.objectContaining({ x: 200, y: 240, w: 600, h: 480 }), + ], + })); + }); + it('writes scene durations metadata for recording-time pacing', async () => { mockedGenerateClips.mockResolvedValue([ { scene: 'intro', clipPath: join(ARGO_DIR, 'clips', 'intro.wav'), durationMs: 1200 }, diff --git a/tests/record.test.ts b/tests/record.test.ts index c040b00..e50b347 100644 --- a/tests/record.test.ts +++ b/tests/record.test.ts @@ -183,6 +183,26 @@ describe('record', () => { expect(existsSync(stalePath)).toBe(false); }); + it('reports jpeg-stitch finalization failures without blaming startRecording', async () => { + execFileMock.mockImplementation((_cmd, _args, options, callback) => { + const argoOutputDir = options.env.ARGO_OUTPUT_DIR as string; + mkdirSync(resolve(tempDir, argoOutputDir), { recursive: true }); + writeFileSync(resolve(tempDir, argoOutputDir, '.timing.json'), '{}'); + callback(null, '', ''); + return {} as never; + }); + + await expect(record('demo', { + demosDir: 'custom-demos', + baseURL: 'http://localhost:4321', + video: { width: 1280, height: 720 }, + browser: 'chromium', + captureMode: 'jpeg-stitch', + })).rejects.toThrow( + `captureMode: 'jpeg-stitch' did not produce ${join('.argo', 'demo', 'video.mp4')}`, + ); + }); + it('normalizes deviceScaleFactor and scales the screencast size env accordingly', async () => { mockSubprocessSuccess();