mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
feat(video): add Microsoft Store trailer variant
This commit is contained in:
parent
b556cd8108
commit
f273a75e6f
5 changed files with 482 additions and 60 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue