feat(video): add Microsoft Store trailer variant

This commit is contained in:
Johannes Millan 2026-05-11 16:46:04 +02:00
parent b556cd8108
commit f273a75e6f
5 changed files with 482 additions and 60 deletions

View file

@ -7,14 +7,17 @@ Playwright-driven generation of the marketing gif/video for the landing page and
```bash
npm run video # tight default (~17s) → dist/video/reel*.{mp4,webm,gif}
npm run video:full # full variant (~21s) → dist/video/reel-full*.{...}
npm run video:ms-store # 16:9 Store trailer → dist/video/reel-ms-store.mp4 + thumbnail
# under the hood
npm run video:capture # Playwright records to .tmp/video/recordings/
npm run video:capture # Playwright records to .tmp/video/recordings/<variant>/
npm run video:build # ffmpeg → dist/video/, picks the most recent webm
npm run video:open # opens an autoplay browser preview, skips in CI
```
`REEL_VARIANT=<name>` switches the spec branch and adds a filename suffix so multiple variants coexist in `dist/video/`. `full` is the only one wired up so far; add more by branching on `isFull`-style flags in the spec.
`REEL_VARIANT=<name>` switches the spec branch and adds a filename suffix so multiple variants coexist in `dist/video/`. `full` uses the longer choreography. `ms-store` reuses the tight choreography but captures at 1920×1080 and builds only the Microsoft Store trailer assets.
Variant recordings are isolated under `.tmp/video/recordings/<variant>/` (`default`, `full`, `ms-store`, …) so `video:build` can't accidentally reuse a recording from a different aspect ratio.
`gifsicle` is optional — the build script falls back to ffmpeg's gif if it's missing. With it installed, you also get `reel-optimized.gif` (~30% smaller).
@ -23,10 +26,10 @@ npm run video:open # opens an autoplay browser preview, skips in CI
| File | Responsibility |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `playwright.store-video.config.ts` | Single chromium project, `video: 'off'` at project level (the fixture handles `recordVideo` itself because `browser.newContext()` doesn't inherit `use.video`). |
| `store-video/fixture.ts` | Custom context with `recordVideo` enabled at 1024×1024 / DPR 2. Reuses the screenshot pipeline's seed builder. Init scripts handle: cursor highlight ring, dialog/snack/tooltip/mention suppression, app zoom. |
| `store-video/fixture.ts` | Custom context with `recordVideo` enabled at 1024×1024 / DPR 2, or 1920×1080 / DPR 1 for `REEL_VARIANT=ms-store`. Reuses the screenshot pipeline's seed builder. Init scripts handle: cursor highlight ring, dialog/snack/tooltip/mention suppression, app zoom. |
| `store-video/overlays.ts` | DOM-injected overlay primitives: `showOverlay`, `showCaption`, `showIntegrationsCard`, `showEndCard`, `cutToScene`, `fadeTransition`, `loopBoundary`, `attachDragGhost`, `smoothMouseMove`. Plus inline brand SVGs in the `LOGOS` constant. |
| `store-video/scenarios/reel.spec.ts` | Six-beat choreography. `REEL_VARIANT=full` triggers the optional "No account. No tracking." beat and relaxes hold timings. |
| `store-video/build-video.ts` | Picks the most recent `.webm` under `.tmp/video/recordings/`, applies the trim sidecar (cuts the seed-import lead-in), produces mp4/webm/gif via ffmpeg, optionally `gifsicle`-optimizes. |
| `store-video/build-video.ts` | Picks the most recent `.webm` under `.tmp/video/recordings/`, applies the trim sidecar (cuts the seed-import lead-in), produces mp4/webm/gif via ffmpeg, optionally `gifsicle`-optimizes. For `ms-store`, produces a 1920×1080 H.264/AAC MP4 and PNG thumbnail. |
| `store-video/open-video.ts` | Opens an autoplay browser preview after `npm run video`. Prefers mp4, respects `REEL_VARIANT`, seeks slightly past the black first frame for preview only, and skips auto-open in CI. |
## Beat structure (current)
@ -93,6 +96,18 @@ The main reel uses two transition styles:
**Output cadence.** Playwright's recorder emits 25fps webm in this pipeline. `build-video.ts` keeps MP4, WebM, and GIF at 25fps to avoid duplicate/drop-frame judder during fades and cursor movement.
**Microsoft Store variant.** `npm run video:ms-store` sets `REEL_VARIANT=ms-store`, records at 1920×1080, and emits:
- `dist/video/reel-ms-store.mp4` — H.264 High Profile, yuv420p, 50 Mbps target, BT.709 color tags, closed GOP, 2 B-frames, fast-start MP4, plus AAC-LC stereo at 48 kHz with a 384 kbps encoder target.
- `dist/video/reel-ms-store-thumbnail.png` — 1920×1080 PNG frame from 1.2s into the finished trailer.
This variant follows Microsoft Partner Center's app trailer requirements: MP4/MOV, 1920×1080 video, PNG thumbnail at 1920×1080, title under 255 chars, and no age-rating bumper inside the trailer. The build validates the generated MP4/PNG with `ffprobe` and fails on wrong size, codec, profile, scan type, color tags, missing audio, or an over-2GB file.
Optional Store env vars:
- `MS_STORE_AUDIO_SOURCE=path/to/audio.ext` loops a real audio bed under the trailer. Without it, the trailer gets silent AAC-LC stereo; ffmpeg's native AAC encoder reports very low probed bitrate for pure silence even with a 384 kbps encoder target, so the build prints a warning.
- `MS_STORE_THUMBNAIL_AT_SECONDS=2.4` chooses a different thumbnail frame from the finished MP4.
**Build script picks most-recent webm.** No need to clean `.tmp/video/recordings/` between runs. Old webms accumulate but only the most recent `.mtime` is built into outputs.
**Variant filename suffix.** `build-video.ts` reads `process.env.REEL_VARIANT` and appends `-${variant}` to all output filenames when set. `npm run video:full` works because env vars propagate through `npm run`.
@ -111,7 +126,7 @@ The main reel uses two transition styles:
## Open polish ideas (not yet shipped)
- **Theme + locale matrix.** Fixture supports both via `test.use({ theme, locale })`. Add Playwright projects per variant; matrix run produces `reel-en-dark.gif`, `reel-en-light.gif`, etc. Mirrors the screenshot pipeline pattern.
- **Aspect ratio variants.** 16:9 (1920×1080) for landing-page hero, 9:16 (1080×1920) for mobile social. Different `VIDEO_SIZE` per matrix entry.
- **Aspect ratio variants.** 9:16 (1080×1920) for mobile social. Different `VIDEO_SIZE` per matrix entry.
- **5-second social cut.** `npm run video:short` produces beats {1, 3, 5}. Trivial extension of the variant flag.
- **Drop-slot highlight.** Brief CSS pulse on the schedule slot the dragged task lands in — gives beat 2 a closing punctuation.
- **Brand-color flash on logo entrance.** Currently logos are flat brand colors. Could flash bright then settle.

View file

@ -3,6 +3,8 @@
* - reel.mp4 1024×1024, 25fps, H.264 yuv420p landing-page fallback
* - reel.webm 1024×1024, 25fps, VP9 landing-page primary
* - reel.gif 1024 wide, 25fps, two-pass palette README embed
* - reel-ms-store.mp4 / reel-ms-store-thumbnail.png when
* REEL_VARIANT=ms-store. These are 1920×1080 Partner Center trailer assets.
*
* Inputs: the most recent `.webm` under `.tmp/video/recordings/` (where the
* fixture's `recordVideo` setting writes). Outputs: `dist/video/`.
@ -17,9 +19,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const RECORDINGS_DIR = path.join(REPO_ROOT, '.tmp', 'video', 'recordings');
const TRIM_SIDECAR_PATH = path.join(RECORDINGS_DIR, '_latest-trim.json');
const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video');
const RECORDINGS_ROOT_DIR = path.join(REPO_ROOT, '.tmp', 'video', 'recordings');
/**
* Variant suffix applied to output filenames. `REEL_VARIANT=full` produces
@ -29,29 +29,98 @@ const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video');
*/
const VARIANT = process.env.REEL_VARIANT ?? '';
const SUFFIX = VARIANT ? `-${VARIANT}` : '';
const IS_MS_STORE = VARIANT === 'ms-store';
const variantDirName = (VARIANT || 'default').replace(/[^a-z0-9_-]+/gi, '-');
const RECORDINGS_DIR = path.join(RECORDINGS_ROOT_DIR, variantDirName);
const TRIM_SIDECAR_PATH = path.join(RECORDINGS_DIR, '_latest-trim.json');
const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video');
// Playwright's recorder currently emits 25fps VP8 webm. Keeping all derived
// formats on that cadence avoids duplicate/drop-frame judder in fades.
const OUTPUT_FPS = 25;
const MS_STORE_WIDTH = 1920;
const MS_STORE_HEIGHT = 1080;
const MS_STORE_GOP = Math.round(OUTPUT_FPS / 2);
const MS_STORE_VIDEO_BITRATE = 50_000_000;
const MS_STORE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
const MS_STORE_AUDIO_BITRATE_WARNING_THRESHOLD = 320_000;
type TrimSidecar = {
offsetMs?: unknown;
recordedAtMs?: unknown;
variant?: unknown;
recordingSize?: unknown;
};
type MediaStream = {
codec_type?: string;
codec_name?: string;
codec_tag_string?: string;
profile?: string;
width?: number;
height?: number;
pix_fmt?: string;
field_order?: string;
r_frame_rate?: string;
avg_frame_rate?: string;
bit_rate?: string;
sample_rate?: string;
channels?: number;
channel_layout?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
has_b_frames?: number;
};
type MediaFormat = {
duration?: string;
size?: string;
bit_rate?: string;
format_name?: string;
};
type MediaProbe = {
streams?: MediaStream[];
format?: MediaFormat;
};
const readNonNegativeNumberEnv = (name: string, fallback: number): number => {
const raw = process.env[name];
if (raw === undefined) return fallback;
const value = Number(raw);
if (Number.isFinite(value) && value >= 0) return value;
throw new Error(`${name} must be a non-negative number, got ${JSON.stringify(raw)}`);
};
const MS_STORE_THUMBNAIL_AT_SECONDS = readNonNegativeNumberEnv(
'MS_STORE_THUMBNAIL_AT_SECONDS',
1.2,
);
const readTrimSidecar = (): TrimSidecar | null => {
if (!fs.existsSync(TRIM_SIDECAR_PATH)) return null;
try {
return JSON.parse(fs.readFileSync(TRIM_SIDECAR_PATH, 'utf8')) as TrimSidecar;
} catch {
return null;
}
};
/**
* Read the trim offset (seconds) from the sidecar the fixture writes when
* `markBeatsStart()` is called. Falls back to 0 (no trim) if missing or
* malformed. `VIDEO_TRIM_OVERRIDE` env var wins for manual overrides.
*/
const readTrimSeconds = (): number => {
const readTrimSeconds = (sidecar: TrimSidecar | null): number => {
const override = process.env.VIDEO_TRIM_OVERRIDE;
if (override !== undefined) {
const n = Number(override);
if (Number.isFinite(n) && n >= 0) return n;
}
if (!fs.existsSync(TRIM_SIDECAR_PATH)) return 0;
try {
const { offsetMs } = JSON.parse(fs.readFileSync(TRIM_SIDECAR_PATH, 'utf8'));
if (typeof offsetMs === 'number' && Number.isFinite(offsetMs) && offsetMs >= 0) {
return offsetMs / 1000;
}
} catch {
/* fall through */
const offsetMs = sidecar?.offsetMs;
if (typeof offsetMs === 'number' && Number.isFinite(offsetMs) && offsetMs >= 0) {
return offsetMs / 1000;
}
return 0;
};
@ -61,16 +130,12 @@ const findMostRecentWebm = (root: string): string => {
throw new Error(`${root} does not exist. Run \`npm run video:capture\` first.`);
}
const candidates: { file: string; mtime: number }[] = [];
const walk = (dir: string): void => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) walk(p);
else if (entry.isFile() && entry.name.endsWith('.webm')) {
candidates.push({ file: p, mtime: fs.statSync(p).mtimeMs });
}
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const p = path.join(root, entry.name);
if (entry.isFile() && entry.name.endsWith('.webm')) {
candidates.push({ file: p, mtime: fs.statSync(p).mtimeMs });
}
};
walk(root);
}
if (candidates.length === 0) {
throw new Error(`No .webm files under ${root}. Did the capture run produce a video?`);
}
@ -85,23 +150,325 @@ const run = (cmd: string, args: string[]): void => {
};
const has = (cmd: string): boolean => {
// Probe by `which` rather than running the command: ffmpeg and gifsicle
// disagree on `-version` vs `--version` flags, and either may exit non-zero
// for harmless reasons. `which` only cares whether the binary is on PATH.
const r = spawnSync('which', [cmd], { stdio: 'ignore' });
const probe = process.platform === 'win32' ? 'where' : 'which';
const r = spawnSync(probe, [cmd], { stdio: 'ignore' });
return r.status === 0;
};
const probeMedia = (file: string): MediaProbe => {
const r = spawnSync(
'ffprobe',
['-v', 'error', '-show_streams', '-show_format', '-of', 'json', file],
{ encoding: 'utf8' },
);
if (r.error) throw r.error;
if (r.status !== 0) {
const detail = r.stderr ? `: ${r.stderr.trim()}` : '';
throw new Error(`ffprobe exited with status ${r.status}${detail}`);
}
return JSON.parse(r.stdout) as MediaProbe;
};
const streamOfType = (probe: MediaProbe, type: string): MediaStream | undefined =>
probe.streams?.find((stream) => stream.codec_type === type);
const asNumber = (value: string | number | undefined): number | null => {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
};
const relative = (file: string): string => path.relative(REPO_ROOT, file);
const logOutputs = (): void => {
console.log('[video] outputs:');
for (const f of fs.readdirSync(OUT_DIR)) {
const full = path.join(OUT_DIR, f);
if (!fs.statSync(full).isFile()) continue;
const kb = (fs.statSync(full).size / 1024).toFixed(0);
console.log(` ${relative(full)} ${kb} KB`);
}
};
const validateMsStoreSource = (
src: string,
sidecar: TrimSidecar | null,
trimSeconds: number,
): void => {
if (process.env.VIDEO_TRIM_OVERRIDE === undefined && trimSeconds === 0) {
throw new Error(
`Missing valid trim sidecar for ms-store recording at ${relative(
TRIM_SIDECAR_PATH,
)}. Run \`npm run video:ms-store\` or set VIDEO_TRIM_OVERRIDE.`,
);
}
const sidecarVariant = sidecar?.variant;
if (
sidecarVariant !== undefined &&
sidecarVariant !== 'ms-store' &&
sidecarVariant !== VARIANT
) {
throw new Error(
`Trim sidecar variant ${JSON.stringify(
sidecarVariant,
)} does not match REEL_VARIANT=${JSON.stringify(VARIANT)}.`,
);
}
const probe = probeMedia(src);
const video = streamOfType(probe, 'video');
if (!video) throw new Error(`No video stream found in ${relative(src)}.`);
if (video.width !== MS_STORE_WIDTH || video.height !== MS_STORE_HEIGHT) {
throw new Error(
`Expected ms-store source to be ${MS_STORE_WIDTH}x${MS_STORE_HEIGHT}, got ${
video.width ?? 'unknown'
}x${video.height ?? 'unknown'} from ${relative(src)}. Run \`npm run video:ms-store\` before building.`,
);
}
};
const msStoreVideoFilter = (trimFilter: string): string =>
[
`${trimFilter}fps=${OUTPUT_FPS}`,
`scale=${MS_STORE_WIDTH}:${MS_STORE_HEIGHT}:force_original_aspect_ratio=decrease:flags=lanczos:out_color_matrix=bt709`,
`pad=${MS_STORE_WIDTH}:${MS_STORE_HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=black`,
'setsar=1',
'format=yuv420p',
].join(',');
const msStoreThumbnailFilter = (): string =>
[
`scale=${MS_STORE_WIDTH}:${MS_STORE_HEIGHT}:force_original_aspect_ratio=decrease:flags=lanczos`,
`pad=${MS_STORE_WIDTH}:${MS_STORE_HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=black`,
'setsar=1',
].join(',');
const msStoreAudioInputArgs = (): string[] => {
const audioSource = process.env.MS_STORE_AUDIO_SOURCE;
if (!audioSource) {
return ['-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=48000'];
}
const resolved = path.resolve(REPO_ROOT, audioSource);
if (!fs.existsSync(resolved)) {
throw new Error(`MS_STORE_AUDIO_SOURCE does not exist: ${resolved}`);
}
return ['-stream_loop', '-1', '-i', resolved];
};
const buildMsStoreAssets = (
src: string,
trimFilter: string,
mp4: string,
thumbnail: string,
): void => {
console.log('[video] -> ms-store mp4');
run('ffmpeg', [
'-y',
'-i',
src,
...msStoreAudioInputArgs(),
'-map',
'0:v:0',
'-map',
'1:a:0',
'-c:v',
'libx264',
'-preset',
'slow',
'-profile:v',
'high',
'-pix_fmt',
'yuv420p',
'-vf',
msStoreVideoFilter(trimFilter),
'-color_primaries',
'bt709',
'-color_trc',
'bt709',
'-colorspace',
'bt709',
'-b:v',
'50M',
'-minrate',
'50M',
'-maxrate',
'50M',
'-bufsize',
'100M',
'-bf',
'2',
'-g',
String(MS_STORE_GOP),
'-keyint_min',
String(MS_STORE_GOP),
'-sc_threshold',
'0',
'-flags',
'+cgop',
'-x264-params',
'cabac=1:open-gop=0:nal-hrd=cbr:filler=1',
'-c:a',
'aac',
'-profile:a',
'aac_low',
'-b:a',
'384k',
'-ar',
'48000',
'-ac',
'2',
'-shortest',
'-movflags',
'+faststart',
'-use_editlist',
'0',
mp4,
]);
console.log('[video] -> ms-store thumbnail');
run('ffmpeg', [
'-y',
'-ss',
String(MS_STORE_THUMBNAIL_AT_SECONDS),
'-i',
mp4,
'-frames:v',
'1',
'-update',
'1',
'-vf',
msStoreThumbnailFilter(),
thumbnail,
]);
};
const validateMsStoreOutputs = (mp4: string, thumbnail: string): void => {
const failures: string[] = [];
const warnings: string[] = [];
const mp4Probe = probeMedia(mp4);
const video = streamOfType(mp4Probe, 'video');
const audio = streamOfType(mp4Probe, 'audio');
const format = mp4Probe.format;
const size = asNumber(format?.size);
const videoBitRate = asNumber(video?.bit_rate);
const audioBitRate = asNumber(audio?.bit_rate);
if (!format?.format_name?.includes('mp4')) failures.push('container is not MP4');
if (size != null && size > MS_STORE_MAX_BYTES) {
failures.push(`file is ${(size / 1024 / 1024).toFixed(1)} MB, over 2 GB`);
}
if (!video) {
failures.push('missing video stream');
} else {
if (video.codec_name !== 'h264') failures.push(`video codec is ${video.codec_name}`);
if (video.codec_tag_string !== 'avc1') {
failures.push(`video codec tag is ${video.codec_tag_string}`);
}
if (video.profile !== 'High') failures.push(`H.264 profile is ${video.profile}`);
if (video.width !== MS_STORE_WIDTH || video.height !== MS_STORE_HEIGHT) {
failures.push(`video is ${video.width}x${video.height}, expected 1920x1080`);
}
if (video.pix_fmt !== 'yuv420p') failures.push(`pixel format is ${video.pix_fmt}`);
if (video.field_order !== 'progressive') {
failures.push(`field order is ${video.field_order}`);
}
if (video.avg_frame_rate !== `${OUTPUT_FPS}/1`) {
failures.push(`average frame rate is ${video.avg_frame_rate}`);
}
if (video.has_b_frames !== 2) {
failures.push(`B-frame metadata reports ${video.has_b_frames}`);
}
if (
video.color_space !== 'bt709' ||
video.color_transfer !== 'bt709' ||
video.color_primaries !== 'bt709'
) {
failures.push(
`color tags are ${video.color_space}/${video.color_transfer}/${video.color_primaries}`,
);
}
if (videoBitRate != null && videoBitRate < MS_STORE_VIDEO_BITRATE * 0.9) {
failures.push(`video bitrate is ${(videoBitRate / 1_000_000).toFixed(1)} Mbps`);
}
}
if (!audio) {
failures.push('missing audio stream');
} else {
if (audio.codec_name !== 'aac') failures.push(`audio codec is ${audio.codec_name}`);
if (audio.profile !== 'LC') failures.push(`AAC profile is ${audio.profile}`);
if (audio.sample_rate !== '48000') {
failures.push(`audio sample rate is ${audio.sample_rate}`);
}
if (audio.channels !== 2) failures.push(`audio channel count is ${audio.channels}`);
if (audio.channel_layout !== 'stereo') {
failures.push(`audio channel layout is ${audio.channel_layout}`);
}
if (
!process.env.MS_STORE_AUDIO_SOURCE &&
audioBitRate != null &&
audioBitRate < MS_STORE_AUDIO_BITRATE_WARNING_THRESHOLD
) {
warnings.push(
`silent AAC stream probes at ${(audioBitRate / 1000).toFixed(
1,
)} kbps despite the 384 kbps encoder target; set MS_STORE_AUDIO_SOURCE to a real audio bed if Partner Center rejects silent audio metadata.`,
);
}
}
const thumbnailProbe = probeMedia(thumbnail);
const thumbnailVideo = streamOfType(thumbnailProbe, 'video');
if (!thumbnailVideo) {
failures.push('thumbnail is missing image stream');
} else {
if (thumbnailVideo.codec_name !== 'png') {
failures.push(`thumbnail codec is ${thumbnailVideo.codec_name}`);
}
if (
thumbnailVideo.width !== MS_STORE_WIDTH ||
thumbnailVideo.height !== MS_STORE_HEIGHT
) {
failures.push(
`thumbnail is ${thumbnailVideo.width}x${thumbnailVideo.height}, expected 1920x1080`,
);
}
}
for (const warning of warnings) {
console.warn(`[video] warning: ${warning}`);
}
if (failures.length > 0) {
throw new Error(
`Microsoft Store trailer validation failed:\n${failures
.map((failure) => ` - ${failure}`)
.join('\n')}`,
);
}
console.log('[video] ms-store validation passed.');
};
const main = (): void => {
if (!has('ffmpeg')) {
throw new Error('ffmpeg not found in PATH — install ffmpeg first.');
}
if (IS_MS_STORE && !has('ffprobe')) {
throw new Error('ffprobe not found in PATH — install ffprobe first.');
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const src = findMostRecentWebm(RECORDINGS_DIR);
console.log(`[video] source: ${path.relative(REPO_ROOT, src)}`);
console.log(`[video] source: ${relative(src)}`);
const trimSeconds = readTrimSeconds();
const sidecar = readTrimSidecar();
const trimSeconds = readTrimSeconds(sidecar);
if (IS_MS_STORE) {
validateMsStoreSource(src, sidecar, trimSeconds);
}
// Trim in the filter graph so ffmpeg decodes to the exact trim point for
// every output. Seeking before `-i` is faster, but VP8 keyframes can be
// sparse enough to drop the opening beat from the generated reel.
@ -114,10 +481,18 @@ const main = (): void => {
}
const mp4 = path.join(OUT_DIR, `reel${SUFFIX}.mp4`);
const thumbnail = path.join(OUT_DIR, `reel${SUFFIX}-thumbnail.png`);
const webm = path.join(OUT_DIR, `reel${SUFFIX}.webm`);
const palette = path.join(OUT_DIR, `.palette${SUFFIX}.png`);
const gif = path.join(OUT_DIR, `reel${SUFFIX}.gif`);
if (IS_MS_STORE) {
buildMsStoreAssets(src, trimFilter, mp4, thumbnail);
validateMsStoreOutputs(mp4, thumbnail);
logOutputs();
return;
}
// 1. mp4 — Playwright records VFR; force CFR for predictable playback.
console.log('[video] -> mp4');
run('ffmpeg', [
@ -200,13 +575,7 @@ const main = (): void => {
);
}
console.log('[video] outputs:');
for (const f of fs.readdirSync(OUT_DIR)) {
const full = path.join(OUT_DIR, f);
if (!fs.statSync(full).isFile()) continue;
const kb = (fs.statSync(full).size / 1024).toFixed(0);
console.log(` ${path.relative(REPO_ROOT, full)} ${kb} KB`);
}
logOutputs();
};
main();

View file

@ -4,8 +4,10 @@
* with `recordVideo` enabled, since `browser.newContext()` does not inherit
* the project's `use.video` setting.
*
* Recording lands in `.tmp/video/recordings/<random>.webm` after the page
* closes; `build-video.ts` picks the most recent one.
* Recording lands in a variant directory such as
* `.tmp/video/recordings/default/<random>.webm` or
* `.tmp/video/recordings/ms-store/<random>.webm` after the page closes;
* `build-video.ts` picks the most recent one for the same variant.
*
* Trim handling: the recording necessarily includes ~16s of seed-import
* navigation before the choreographed beats begin. The fixture timestamps the
@ -30,16 +32,42 @@ import { waitForAppReady } from '../utils/waits';
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const SEED_DIR = path.join(REPO_ROOT, '.tmp', 'video-seeds');
const RECORDING_DIR = path.join(REPO_ROOT, '.tmp', 'video', 'recordings');
const VARIANT = process.env.REEL_VARIANT ?? '';
const RECORDINGS_DIR = path.join(REPO_ROOT, '.tmp', 'video', 'recordings');
const variantDirName = (VARIANT || 'default').replace(/[^a-z0-9_-]+/gi, '-');
const RECORDING_DIR = path.join(RECORDINGS_DIR, variantDirName);
const TRIM_SIDECAR_PATH = path.join(RECORDING_DIR, '_latest-trim.json');
type VideoProfile = {
size: { width: number; height: number };
deviceScaleFactor: number;
};
const getVideoProfile = (): VideoProfile => {
if (process.env.REEL_VARIANT === 'ms-store') {
return {
// Microsoft Store trailers must be exactly 1920x1080. Keep the backing
// surface at 1x here; 2x would render a 3840x2160 page before recording.
size: { width: 1920, height: 1080 },
deviceScaleFactor: 1,
};
}
return {
// Square 1024x1024 plays well on social embeds and matches the rhythm of
// the GitHub README. DPR 2 renders the page at 2x physical pixels, then
// Playwright downsamples into 1024x1024 for sharper text on the gif.
size: { width: 1024, height: 1024 },
deviceScaleFactor: 2,
};
};
// Viewport == recording size, otherwise Playwright pads the smaller axis with
// gray. Square 1024×1024 plays well on social embeds and matches the rhythm
// of the GitHub README. deviceScaleFactor 2 renders the page at 2× physical
// pixels (2048×2048) which Playwright then downsamples into 1024×1024 —
// sharp text on the gif. Cost: ~2× CPU/memory during capture.
const VIDEO_SIZE = { width: 1024, height: 1024 } as const;
const DEVICE_SCALE_FACTOR = 2;
// gray. The profile is selected by REEL_VARIANT so the choreography can be
// reused for square README assets and 16:9 Store trailers.
const VIDEO_PROFILE = getVideoProfile();
const VIDEO_SIZE = VIDEO_PROFILE.size;
const DEVICE_SCALE_FACTOR = VIDEO_PROFILE.deviceScaleFactor;
type VideoFixtures = {
locale: Locale;
@ -199,14 +227,12 @@ export const test = base.extend<VideoFixtures>({
visibility: hidden !important;
opacity: 0 !important;
}
/* Hide every Material dialog that would modal over the reel
reminders, scheduling confirmation, anything else. The reel
doesn't legitimately need any modal, and per-selector :has()
rules kept missing variants. Targeting the dialog container
class catches them all. focus-mode-overlay is its own element,
not a mat-dialog, so it's unaffected. */
.cdk-overlay-pane:has(.mat-mdc-dialog-container),
.cdk-overlay-pane:has(mat-dialog-container) {
/* Hide Material dialogs that would modal over the actual reel, but
keep the import encryption warning actionable during the trimmed
pre-roll seed import. focus-mode-overlay is its own element, not a
mat-dialog, so it's unaffected. */
.cdk-overlay-pane:has(.mat-mdc-dialog-container):not(:has(dialog-import-encryption-warning)),
.cdk-overlay-pane:has(mat-dialog-container):not(:has(dialog-import-encryption-warning)) {
display: none !important;
}
/* Hide every Material snack bar beat 1's task-add and beat 4's
@ -292,7 +318,16 @@ export const test = base.extend<VideoFixtures>({
fs.mkdirSync(RECORDING_DIR, { recursive: true });
fs.writeFileSync(
TRIM_SIDECAR_PATH,
JSON.stringify({ offsetMs, recordedAtMs: beatsMs }, null, 2),
JSON.stringify(
{
offsetMs,
recordedAtMs: beatsMs,
variant: VARIANT || 'default',
recordingSize: VIDEO_SIZE,
},
null,
2,
),
);
} catch (err) {
console.warn(`[video] failed to write trim sidecar: ${(err as Error).message}`);

View file

@ -49,8 +49,10 @@ const writePreview = (videoPath: string): string => {
' place-items: center;',
' }',
' video {',
' width: min(100vw, 100vh);',
' height: min(100vw, 100vh);',
' max-width: 100vw;',
' max-height: 100vh;',
' width: auto;',
' height: auto;',
' background: #000;',
' }',
' </style>',

View file

@ -84,7 +84,8 @@
"video:capture": "npx playwright test --config e2e/playwright.store-video.config.ts",
"video:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/build-video.ts",
"video:open": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/open-video.ts",
"video:full": "REEL_VARIANT=full npm run video",
"video:full": "cross-env REEL_VARIANT=full npm run video",
"video:ms-store": "cross-env REEL_VARIANT=ms-store npm run video",
"electron": "NODE_ENV=PROD electron .",
"electron:build": "node ./tools/build-wayland-idle-helper.js && tsc -p electron/tsconfig.electron.json && node electron/scripts/bundle-preload.js",
"electron:verify-asar": "node tools/verify-electron-requires.js .tmp/app-builds/linux-unpacked/resources/app.asar",