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);
});