From dda21d257069bd85cb97a7a66a2698ea594cd906 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 21 Oct 2026 03:59:39 +0700 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++++ cli/mcp-tools.ts | 2 +- cli/shots/devices.ts | 26 +++++++++++---- cli/shots/record.ts | 48 ++++++++++++++++++--------- docs/commands.md | 2 +- docs/pitfalls.md | 1 + skills/app-previews/SKILL.md | 4 ++- skills/store-pitfalls/SKILL.md | 11 +++++++ tests/browser/record-web.browser.ts | 51 +++++++++++++++++++++++++++++ tests/video.test.ts | 22 ++++++++++--- 10 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 tests/browser/record-web.browser.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d92dfe5..1792059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index d637967..962ac6e 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -239,7 +239,7 @@ export const TOOLS: ToolDef[] = [ description: 'Write /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, diff --git a/cli/shots/devices.ts b/cli/shots/devices.ts index 3b2fab0..fecf941 100644 --- a/cli/shots/devices.ts +++ b/cli/shots/devices.ts @@ -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 ---- diff --git a/cli/shots/record.ts b/cli/shots/record.ts index 0ac1b11..68d74ee 100644 --- a/cli/shots/record.ts +++ b/cli/shots/record.ts @@ -3,8 +3,10 @@ // Config: shots.video.record { source?, device?, clips: { : { seconds, path | url | args | extras, waitFor?, delayMs? } } }; // the URL / simulator / emulator settings are the ones of shots.capture. Out: //NN-.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 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 // or / 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 { 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(); } diff --git a/docs/commands.md b/docs/commands.md index 7f51fe7..fa5d1ed 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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 | `/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 | diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 27cccd0..42ad0d1 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -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 --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`. | diff --git a/skills/app-previews/SKILL.md b/skills/app-previews/SKILL.md index eb360b1..6409f55 100644 --- a/skills/app-previews/SKILL.md +++ b/skills/app-previews/SKILL.md @@ -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//NN-.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). diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 531540e..3bd982c 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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`. diff --git a/tests/browser/record-web.browser.ts b/tests/browser/record-web.browser.ts new file mode 100644 index 0000000..60a129f --- /dev/null +++ b/tests/browser/record-web.browser.ts @@ -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(` +`, { 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); +}); diff --git a/tests/video.test.ts b/tests/video.test.ts index 7d45527..5c06607 100644 --- a/tests/video.test.ts +++ b/tests/video.test.ts @@ -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([]);