shots record (web): frames at device pixels through Page.captureScreenshot (clip scale = dpr, long side capped at 1920, 3 requests in flight, midpoint timestamps into the ffmpeg concat); Chromium's screencast stayed at CSS size. Warnings for frames smaller than asked and under 20 frames/s; record-web browser test, pitfall 85

This commit is contained in:
CRTkafa 2026-10-21 03:59:39 +07:00
parent 2d7cf6ec2b
commit dda21d2570
10 changed files with 144 additions and 29 deletions

View file

@ -0,0 +1,51 @@
// `shots record --source web` against a local animated page: the clip comes out at the frame's device pixels (CSS size
// x dpr), not at CSS size (Chromium's screencast ignores the deviceScaleFactor), 30 fps H.264 of the asked length.
// Skips when no Chromium can launch or ffmpeg is missing. Runs the CLI in a child process, like a real run.
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig } from '../../cli/lib/config';
import { BASE } from '../helpers/store-mock';
const CLI = join(import.meta.dir, '..', '..', 'cli', 'index.ts');
const server = Bun.serve({
port: 0, hostname: '127.0.0.1',
fetch: () => new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0"><canvas id="c" style="width:100vw;height:100vh;display:block"></canvas><script>
const c = document.getElementById('c'); c.width = innerWidth * devicePixelRatio; c.height = innerHeight * devicePixelRatio;
const g = c.getContext('2d'); let n = 0;
(function tick() { g.fillStyle = 'hsl(' + (n++ * 7 % 360) + ',70%,50%)'; g.fillRect(0, 0, c.width, c.height); window.ready = n > 5; requestAnimationFrame(tick); })();
</script></body></html>`, { headers: { 'content-type': 'text/html' } }),
});
afterAll(() => server.stop(true));
const canLaunch = spawnSync('bun', ['-e', `const { launch } = await import(${JSON.stringify(join(import.meta.dir, '..', '..', 'cli', 'shots', 'browser.ts'))}); await launch(${JSON.stringify(join(import.meta.dir, '..', '..'))}, { headless: true }); process.exit(0);`], { timeout: 60_000 }).status === 0;
const hasFfmpeg = spawnSync('ffmpeg', ['-version']).status === 0;
if (!canLaunch || !hasFfmpeg) console.warn('record-web browser test skipped: needs a Chromium (bunx playwright install chromium) and ffmpeg');
describe.skipIf(!canLaunch || !hasFfmpeg)('shots record (web)', () => {
test('clip at device pixels, 30 fps, asked length', async () => {
const root = mkdtempSync(join(tmpdir(), 'sb-rec-'));
try {
mkdirSync(join(root, 'store/shots'), { recursive: true });
writeFileSync(join(root, 'store/shots/copy.json'), '{}');
const cfg: ShipConfig = { ...BASE, languages: [{ id: 'en' }], shots: { copy: 'store/shots/copy.json', frames: 'store/shots/frames', out: 'store/renders',
scenes: [{ id: 'hero', palette: 'fire', frames: [{ src: 'hero.png' }] }], phone: { w: 200, h: 400, dpr: 2 },
capture: { source: 'web', baseUrl: `http://127.0.0.1:${server.port}`, shots: { home: { path: '/' } } },
video: { clips: 'store/video/clips', record: { clips: { play: { seconds: 2, path: '/', waitFor: 'window.ready', delayMs: 50 } } } } } } as ShipConfig;
writeFileSync(join(root, 'storebound.config.json'), JSON.stringify(cfg));
const r = Bun.spawn(['bun', CLI, 'shots', 'record'], { cwd: root, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } });
const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited]);
if (code !== 0) console.error(out, err);
expect(code).toBe(0);
expect(err).not.toContain('asked for');
const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,r_frame_rate:format=duration', '-of', 'json', join(root, 'store/video/clips/en/01-play.mp4')]).stdout.toString();
const j = JSON.parse(probe) as { streams: { width: number; height: number; r_frame_rate: string }[]; format: { duration: string } };
expect(j.streams[0]).toMatchObject({ width: 400, height: 800, r_frame_rate: '30/1' });
expect(Number(j.format.duration)).toBeCloseTo(2, 1);
} finally { rmSync(root, { recursive: true, force: true }); }
}, 120_000);
});

View file

@ -5,7 +5,7 @@ import { setUpToDate } from '../cli/apple/asc';
import { mimeType, previewAttrs, previewFiles, previewSets } from '../cli/apple/previews';
import { type ShipConfig, validateConfig } from '../cli/lib/config';
import { listingBody } from '../cli/play/listing';
import { adbPullArgs, adbRecordArgs, adbRmArgs, clipName, concatArgs, concatList, recordSize, simRecordArgs, trimArgs, validateRecord } from '../cli/shots/devices';
import { adbPullArgs, adbRecordArgs, adbRmArgs, captureShortfall, clipName, concatArgs, concatList, RECORD_MAX_SIDE, recordSize, simRecordArgs, trimArgs, validateRecord } from '../cli/shots/devices';
import { clipsToRecord } from '../cli/shots/record';
import { checkVideo, parseProbe, type ProbeInfo } from '../cli/shots/spec';
import { clipFiles, defaultTargets, planClips, posterArgs, timeCode, videoArgs } from '../cli/shots/video';
@ -116,9 +116,23 @@ describe('record', () => {
expect(a.slice(3, 12)).toEqual(['-f', 'concat', '-safe', '0', '-i', 'l.txt', '-t', '6.000', '-vf']);
expect(a.at(-1)).toBe('o.mp4');
});
test('record size: 2x, capped, even', () => {
expect(recordSize({ w: 440, h: 902 })).toEqual({ width: 780, height: 1600 });
expect(recordSize({ w: 301, h: 401 })).toEqual({ width: 602, height: 802 });
test('record size: device pixels (clip scale = dpr), long side capped', () => {
// a 430x932 @2x context: the screencast gave 430x932, captureScreenshot with scale 2 gives 860x1864
expect(recordSize({ w: 430, h: 932, dpr: 2 })).toEqual({ width: 860, height: 1864, scale: 2 });
expect(recordSize({ w: 301, h: 401, dpr: 2 })).toEqual({ width: 602, height: 802, scale: 2 });
expect(recordSize({ w: 360, h: 640, dpr: 1 })).toEqual({ width: 360, height: 640, scale: 1 });
// default 3x phone (2706 px tall) and the 13" iPad: capped at the tallest store video, never below the frame's CSS size
const phone = recordSize({ w: 440, h: 902, dpr: 3 });
expect(phone.height).toBe(RECORD_MAX_SIDE);
expect(phone.width).toBe(937);
expect(phone.scale).toBeCloseTo(1920 / 902, 6);
expect(recordSize({ w: 1032, h: 1352, dpr: 2 })).toMatchObject({ width: 1466, height: 1920 });
expect(recordSize({ w: 440, h: 902 }).scale).toBe(2);
});
test('capture guardrail: frames smaller than requested', () => {
expect(captureShortfall({ w: 860, h: 1864 }, { width: 860, height: 1864 })).toBeNull();
expect(captureShortfall({ w: 937, h: 1919 }, { width: 937, height: 1920 })).toBeNull();
expect(captureShortfall({ w: 430, h: 932 }, { width: 860, height: 1864 })).toContain('frames are 430x932, asked for 860x1864');
});
test('config checks', () => {
expect(validateRecord({ clips: { a: { seconds: 8, path: '/' } } }, cap)).toEqual([]);