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

@ -2,6 +2,12 @@
## Unreleased
- **`shots record --source web` films at device pixels.** Chromium's screencast always sent frames at the page's CSS
size (430x932 for a 430x932 @2x frame), so a 1080x1920 preview was a 2.5x upscale. The recorder now takes
`Page.captureScreenshot` frames with the clip scaled to the frame's dpr (860x1864 there; long side capped at 1920,
the tallest store video), keeps 3 requests in flight (~45-55 frames/s) and joins them at their real timings as
before. It warns when the first frame is smaller than asked or the rate drops under 20 frames/s.
## 0.8.20 · 2026-10-20
- **Tooling: the desktop updater signatures of the Linux and Windows installers are made on the VDS.** The release

View file

@ -239,7 +239,7 @@ export const TOOLS: ToolDef[] = [
description: 'Write <renders>/index.html: one self-contained page with every language, size, scene, variant and app preview, store check issues highlighted. Open it in a browser to review before uploading.',
props: { lang }, args: i => opt('lang', i.lang) },
{ name: 'shots_record', title: 'Record app or gameplay clips', command: ['shots', 'record'], write: true, network: false, idempotent: true,
description: 'App or gameplay clips for app previews from shots.video.record: web build (Chromium screencast), iOS Simulator (simctl recordVideo), Android emulator / device (screenrecord, never booted) or a folder listing. Local files only.',
description: 'App or gameplay clips for app previews from shots.video.record: web build (Chromium frames at device pixels), iOS Simulator (simctl recordVideo), Android emulator / device (screenrecord, never booted) or a folder listing. Local files only.',
props: { lang, only: { type: 'string', description: 'Comma-separated clip names' }, source },
args: i => [...opt('lang', i.lang), ...opt('only', i.only), ...opt('source', i.source)] },
{ name: 'shots_video', title: 'Cut app previews', command: ['shots', 'video'], write: true, network: false, idempotent: true,

View file

@ -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 ----

View file

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

View file

@ -147,7 +147,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `shots jpeg` | local | JPEG q92 copies of the Play renders (about 4x faster uploads) |
| `shots check [--variants] [--json]` | local, read-only | store rules: sizes, alpha, format, bytes, counts, overflow, fallback / missing glyphs, videos, folder frames |
| `shots gallery` | local | `<renders>/index.html` review page |
| `shots record [--source ...]` | local | app or gameplay clips: web (Chromium screencast), simulator, emulator, folder |
| `shots record [--source ...]` | local | app or gameplay clips: web (Chromium frames at device pixels), simulator, emulator, folder |
| `shots video [--target iphone,ipad,youtube]` | local | app previews + promo video with captions, poster, checks |
</details>

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 85 | 2026-10 | `shots record --source web` filmed at the page's CSS size: Chromium's screencast ignores the deviceScaleFactor (maxWidth only shrinks), so a 1080x1920 preview of a 430x932 @2x page was a 2.5x upscale. | `Page.captureScreenshot` with `clip.scale` = dpr (long side capped at 1920), 3 requests in flight, timestamped frames joined by ffmpeg; a warning when the first frame is smaller than asked or the rate is under 20 frames/s. `recordSize` / `captureShortfall` in `cli/shots/devices.ts`, `tests/video.test.ts`. |
| 80 | 2026-10 | A game's hero logo and titles sat against the screenshot edges, a game with a macOS version had no Mac screenshots, and the contact sheet timed out at a flat 30 s with 9 sizes. | Templates keep 7 % / 5 % safe margins and `shots check` fails text or logo boxes under 6 % / 4 % (`marginViolations`, report.json `boxes`); `apple.mac` adds the `mac` size (APP_DESKTOP, macOS version); `sheetTimeoutMs` scales with images, sizes and load. `tests/mac-landscape.test.ts`, `tests/browser/landscape.browser.ts`. |
| 78 | 2026-10 | `storebound api <write> --yes` with stdout piped or redirected printed a dry run and exited 0 (twice); the owner took it as sent. | `decide` (`cli/api/gate.ts`) refuses with `NOT_A_TTY`: exit 1, "refused: writes through `storebound api` need an interactive terminal ... Nothing was sent." on stderr, the dedicated command named; without `--yes` still a dry run. `tests/api.test.ts`. |
| 77 | 2026-10 | A Mac App Store version was rejected under Guideline 2.3.10: its What's New came from release notes shared with Google Play ("Google Play Games", "Android"); storebound wrote it without a word. Play's Metadata policy is the mirror case. | `cli/lib/cross-store.ts`: other platforms per locale, field and line (App Store text: Android, Google Play (Games), Play Store, Galaxy Store, Amazon Appstore, Steam, Microsoft Store, AppGallery, localized forms; Play text: iOS, iPhone, iPad, App Store, Apple Arcade, Game Center, TestFlight). Every text write refuses (apple / play listing, play release / upload notes, listing set, whats-new check / apply, the desktop editor and storebound edit); check / validate blockers; scan `other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). `listing.allowPlatformMentions` for the owner's functional mentions; whats-new splits `[apple]` / `[play]` lines. `tests/cross-store.test.ts`. |

View file

@ -34,7 +34,9 @@ other platforms' UI. Play promo video: YouTube URL, public or unlisted, ads off,
or `url` / `args` / `extras` (simulator / emulator), `waitFor`, `delayMs`. The source and its settings come from
`shots.capture` (or `record.source`). `storebound shots record --dry` shows the plan, then
`storebound shots record` writes `store/video/clips/<lang>/NN-<name>.mp4` (30 fps H.264, no audio).
- web: Chromium screencast at 2x the frame size, filming starts when `waitFor` is true.
- web: JPEG frames at the frame's device pixels (`Page.captureScreenshot`, long side capped at 1920, the tallest
store video), 3 requests in flight (~45-55 frames/s), joined at their real timings; filming starts when `waitFor`
is true. It warns when frames come out smaller than asked (an upscale) or under 20 frames/s.
- ios-sim: `simctl io recordVideo`, stopped after `seconds`; boots (and later shuts down) the simulator if needed;
the first launch after a cold boot gets `ios.warmupMs`. A still ending is padded with the last frame.
- android-emu: `screenrecord` on the running emulator / device (never booted, wiped or rebooted).

View file

@ -1038,3 +1038,14 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `cli/shots/weights.ts` (templateWeights, fetchFaces, weightIssues), `cli/shots/palette.ts` (bgIssue in
config validation, bgCss, contrastIssues, coverCrop); report.json `weights` / `contrast`; `tests/shots-look.test.ts`,
`tests/browser/palette-bg.browser.ts`.
## 85. Web app previews at CSS size (blurry 1080x1920 promo)
- **Symptom:** `shots record --source web` clips of a 430x932 @2x page came out 430x932, so a 1080x1920 preview was a
2.5x upscale. Chromium's `Page.startScreencast` sends frames at the viewport's CSS size whatever the deviceScaleFactor
(`maxWidth` / `maxHeight` only shrink); forcing scale 2 through `Emulation.setDeviceMetricsOverride` breaks the layout
(innerWidth 980).
- **Do:** update storebound; the recorder takes `Page.captureScreenshot` frames with `clip.scale` = the frame's dpr
(long side capped at 1920), 3 requests in flight. Re-record old web clips. If it still warns that frames are
smaller than asked, point `STOREBOUND_CHROME` at a current Chromium or record with `--source folder`.
- **Guard:** `recordSize` / `captureShortfall` in `cli/shots/devices.ts` (first frame size checked per clip, plus a
frames-per-second warning under 20); `tests/video.test.ts`.

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([]);