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:
parent
2d7cf6ec2b
commit
dda21d2570
10 changed files with 144 additions and 29 deletions
|
|
@ -207,7 +207,7 @@ export const trimArgs = (inp: string, out: string, start: number, seconds: numbe
|
|||
|
||||
export interface ScreencastFrame { file: string; t: number }
|
||||
|
||||
/** ffmpeg concat-demuxer list for screencast frames (Chromium sends a frame only when the page changes): each frame lasts
|
||||
/** ffmpeg concat-demuxer list for timestamped frames (`t` in seconds, frames come at an uneven rate): each frame lasts
|
||||
* until the next one; the last one fills up to `seconds`. Pure. */
|
||||
export function concatList(frames: ScreencastFrame[], seconds: number): string {
|
||||
const t0 = frames[0]?.t ?? 0;
|
||||
|
|
@ -226,11 +226,25 @@ export const concatArgs = (list: string, out: string, seconds: number, fps = 30)
|
|||
['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-t', seconds.toFixed(3),
|
||||
'-vf', `fps=${fps},scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '16', '-an', '-movflags', '+faststart', out];
|
||||
|
||||
/** Screencast size for a frame: the frame at 2x (capped at 1600 px on the long side), even numbers. */
|
||||
export function recordSize(d: { w: number; h: number }): { width: number; height: number } {
|
||||
const k = Math.min(2, 1600 / Math.max(d.w, d.h));
|
||||
const even = (n: number) => Math.max(2, Math.round(n * k / 2) * 2);
|
||||
return { width: even(d.w), height: even(d.h) };
|
||||
/** Long side cap of a web recording: the tallest store video (886 x 1920 iPhone preview, 1080 x 1920 Play promo), so
|
||||
* `shots video` only scales down; a 3x phone (2706 px) or a 13" iPad (2752 px) would cost capture speed for pixels
|
||||
* every output throws away. */
|
||||
export const RECORD_MAX_SIDE = 1920;
|
||||
|
||||
/** Web capture for a frame: CDP `Page.captureScreenshot` with clip = the viewport in CSS px and `scale` = the frame's
|
||||
* dpr (capped at RECORD_MAX_SIDE on the long side). Chromium returns CSS size x clip.scale whatever the context's
|
||||
* deviceScaleFactor (checked on Chromium 153); its screencast stays at CSS size (maxWidth only shrinks), which is why
|
||||
* the recorder does not use it. width / height = the expected image size. Pure. */
|
||||
export function recordSize(d: { w: number; h: number; dpr?: number }): { width: number; height: number; scale: number } {
|
||||
const scale = Math.min(d.dpr ?? 2, RECORD_MAX_SIDE / Math.max(d.w, d.h));
|
||||
return { width: Math.round(d.w * scale), height: Math.round(d.h * scale), scale };
|
||||
}
|
||||
|
||||
/** Guardrail: a first frame smaller than requested means the browser ignored the capture scale (the clip would be an
|
||||
* upscale in `shots video`). Message or null. Pure. */
|
||||
export function captureShortfall(got: { w: number; h: number }, want: { width: number; height: number }): string | null {
|
||||
if (got.w >= want.width - 2 && got.h >= want.height - 2) return null;
|
||||
return `frames are ${got.w}x${got.h}, asked for ${want.width}x${want.height}: this browser ignores the capture scale, so the clip is upscaled later (set STOREBOUND_CHROME to a current Chromium or record with --source folder)`;
|
||||
}
|
||||
|
||||
// ---- frames ----
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
// Config: shots.video.record { source?, device?, clips: { <name>: { seconds, path | url | args | extras, waitFor?, delayMs? } } };
|
||||
// the URL / simulator / emulator settings are the ones of shots.capture. Out: <shots.video.clips>/<lang>/NN-<name>.mp4
|
||||
// (NN = config order, so `shots video` joins them in that order): H.264, 30 fps, no audio.
|
||||
// web Chromium screencast of the page through Playwright (JPEG frames at up to 2x the frame size, joined by
|
||||
// your ffmpeg; no Playwright ffmpeg download needed). Filming starts when waitFor turns true (+ delayMs).
|
||||
// web JPEG frames of the page in Chromium through Playwright (CDP Page.captureScreenshot at the frame's device
|
||||
// pixels, long side capped at 1920; 3 requests in flight, ~45-55 frames/s on an M1 Pro), each stamped
|
||||
// with the middle of its request and joined by your ffmpeg at their real durations (no Playwright ffmpeg
|
||||
// download needed). Not the screencast: it stays at CSS size. Filming starts when waitFor turns true (+ delayMs).
|
||||
// ios-sim `xcrun simctl io <udid> recordVideo` while the app runs (launch args with -AppleLanguages, deep link),
|
||||
// stopped with SIGINT after `seconds`. Boots the simulator if needed (shut down again afterwards unless
|
||||
// --keep-booted), status bar 9:41, one warm-up launch after a cold boot (shots.capture.ios.warmupMs).
|
||||
|
|
@ -13,7 +15,8 @@
|
|||
// folder your own clips (screen recorder, engine capture, OBS): lists <clips>/<lang>/ or <clips>/ with durations.
|
||||
// Make the app or game run hands-free for this (an autoplay / attract mode switch) or record yourself playing with
|
||||
// --source folder. Apple: previews must show footage captured from the app itself.
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { ClipSpec, VideoRecord } from '../lib/config';
|
||||
|
|
@ -23,9 +26,10 @@ import { done, log, step, warn } from '../lib/log';
|
|||
import { launch } from './browser';
|
||||
import { adbPath, localeOf, requireTool, sh } from './capture';
|
||||
import { ADB_REMOTE_CLIP, adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, adbPullArgs, adbRecordArgs, adbRmArgs, adbStartArgs, adbStateArgs,
|
||||
adbTarget, clipName, fill, pickSimulator, recordSize, simBootArgs, simBootStatusArgs, simLaunchArgs, simOpenUrlArgs, simRecordArgs, simShutdownArgs,
|
||||
adbTarget, captureShortfall, clipName, fill, pickSimulator, recordSize, simBootArgs, simBootStatusArgs, simLaunchArgs, simOpenUrlArgs, simRecordArgs, simShutdownArgs,
|
||||
simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, trimArgs, validateRecord, concatArgs, concatList, type ScreencastFrame } from './devices';
|
||||
import { frameDims } from './lib';
|
||||
import { imageInfo } from './spec';
|
||||
import { CLIP_RE, clipFiles } from './video';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
|
|
@ -34,6 +38,9 @@ export function clipsToRecord(r: VideoRecord, only: string[] = []): [number, str
|
|||
return Object.entries(r.clips ?? {}).map(([n, c], i) => [i, n, c] as [number, string, ClipSpec]).filter(([, n]) => !only.length || only.includes(n));
|
||||
}
|
||||
|
||||
/** Capture requests kept in flight for a web clip. */
|
||||
const IN_FLIGHT = 3;
|
||||
|
||||
const probeDuration = (file: string): number => {
|
||||
const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file]);
|
||||
return Number(r.stdout?.toString().trim()) || 0;
|
||||
|
|
@ -109,27 +116,36 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (cl.waitFor) await page.waitForFunction(cl.waitFor, null, { timeout: 120000, polling: 50 });
|
||||
for (const js of cl.eval ?? []) await page.evaluate(js);
|
||||
await page.waitForTimeout(cl.delayMs ?? 1000);
|
||||
// Chromium's screencast (what Playwright's own recorder uses), straight to JPEG frames + system ffmpeg
|
||||
// captureScreenshot, not the screencast (its frames stay at CSS size); a few requests in flight keep the
|
||||
// rate near the page's own (one at a time reaches ~20 frames/s)
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const clip = { x: 0, y: 0, width: d.w, height: d.h, scale: size.scale };
|
||||
const frames: ScreencastFrame[] = [];
|
||||
let n = 0;
|
||||
cdp.on('Page.screencastFrame', (f: { data: string; sessionId: number; metadata: { timestamp: number } }) => {
|
||||
const file = join(dir, `f${String(++n).padStart(5, '0')}.jpg`);
|
||||
writeFileSync(file, Buffer.from(f.data, 'base64'));
|
||||
frames.push({ file, t: f.metadata.timestamp });
|
||||
cdp.send('Page.screencastFrameAck', { sessionId: f.sessionId }).catch(() => {});
|
||||
});
|
||||
await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 92, maxWidth: size.width, maxHeight: size.height, everyNthFrame: 1 });
|
||||
await page.waitForTimeout(cl.seconds * 1000 + 300);
|
||||
await cdp.send('Page.stopScreencast');
|
||||
const end = performance.now() + cl.seconds * 1000 + 300;
|
||||
const grab = async () => {
|
||||
while (performance.now() < end) {
|
||||
const t0 = performance.now();
|
||||
const r = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 92, optimizeForSpeed: true, clip }) as { data: string };
|
||||
const file = join(dir, `f${String(++n).padStart(5, '0')}.jpg`);
|
||||
frames.push({ file, t: (t0 + performance.now()) / 2000 });
|
||||
await writeFile(file, Buffer.from(r.data, 'base64'));
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: IN_FLIGHT }, grab));
|
||||
await context.close();
|
||||
if (frames.length < 2) throw new Error(`the screencast delivered ${frames.length} frames (is the page animating?)`);
|
||||
if (frames.length < 2) throw new Error(`the capture delivered ${frames.length} frames`);
|
||||
frames.sort((x, y) => x.t - y.t);
|
||||
const short = captureShortfall(imageInfo(readFileSync(frames[0]!.file)), size);
|
||||
if (short) warn('shots', `${lang}/${name}: ${short}`);
|
||||
const rate = frames.length / (frames.at(-1)!.t - frames[0]!.t || 1);
|
||||
if (rate < 20) warn('shots', `${lang}/${name}: only ${rate.toFixed(0)} frames/s captured (the clip will stutter): close heavy apps, or record with --source folder`);
|
||||
const list = join(dir, 'frames.txt');
|
||||
writeFileSync(list, concatList(frames, cl.seconds));
|
||||
sh('ffmpeg', concatArgs(list, outFor(lang, idx, name), cl.seconds), { timeout: 600_000 });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
made++;
|
||||
progress(`${lang}/${clipName(idx, name)} (${cl.seconds} s, ${frames.length} frames)`);
|
||||
progress(`${lang}/${clipName(idx, name)} (${cl.seconds} s, ${size.width}x${size.height}, ${frames.length} frames, ${rate.toFixed(0)}/s)`);
|
||||
} catch (e) { failed++; i++; warn('shots', `${lang}/${name}: ${(e as Error).message.slice(0, 300)}`); await context.close().catch(() => {}); }
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue