Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions demos/showcase.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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,
Expand Down
18 changes: 9 additions & 9 deletions src/camera-move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));
}
7 changes: 5 additions & 2 deletions src/cdp-screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
35 changes: 21 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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,
});

Expand All @@ -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`,
});
Expand All @@ -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,
Expand All @@ -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 */ }
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -242,6 +253,13 @@ export function defineConfig(userConfig: UserConfig): ArgoConfig {
};
}

export function resolveExportSize(config: Pick<ArgoConfig, 'video' | 'export'>): { 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;
Expand Down
17 changes: 14 additions & 3 deletions src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,21 @@ export async function exportVideo(options: ExportOptions): Promise<string> {
};
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]`);
}
Expand Down
21 changes: 19 additions & 2 deletions src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
},
});

Expand Down
12 changes: 10 additions & 2 deletions src/narration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('; ')}`);
}
}

/**
Expand Down
Loading
Loading