feat(producer): true alpha output for webm, mov, and png-sequence

Extends RenderConfig.format with "png-sequence" and patches two correctness
gaps so the existing "webm" / "mov" values actually preserve the alpha
channel end-to-end.

Engine fixes:
- screenshotService.pageScreenshotCapture: drop optimizeForSpeed for PNG
  captures. The fast path uses an alpha-unaware codec that crushes real
  alpha values; kept for opaque jpeg captures where it is harmless.
- frameCapture: replace the inline setDefaultBackgroundColorOverride
  block (which fired pre-navigation and was reset by page.goto) with a
  proper initTransparentBackground() call inside initializeSession,
  after the window.__hf readiness poll. This also injects the
  html/body/[data-composition-id]{background:transparent !important}
  stylesheet so compositions with custom body / #root backgrounds do not
  defeat the override. Wired into both screenshot-mode and beginframe-mode
  branches.

Producer:
- RenderConfig.format extended to "mp4" | "webm" | "mov" | "png-sequence"
  with full JSDoc.
- Streaming encode is bypassed for png-sequence (frames go straight to
  disk). FORMAT_EXT extended.
- New Stage-5 png-sequence branch: mkdir outputPath, copy captured PNGs as
  frame_NNNNNN.png, copy audio.aac sidecar when audio is present.
- Stage-6 mux/faststart and the debug copy are wrapped in !isPngSequence.
- README.md: new "Transparent Video Output" section.

Tests:
- New fixture tests/transparency-regression/ tagged "transparency".
- New tsx script src/transparency-test.ts asserts pixel-level alpha for
  webm + png-sequence outputs. Wired as "test:transparency".
- Default "test" / "test:update" scripts pass --exclude-tags transparency
  so the golden-MP4 harness ignores the new fixture.

Verified locally on macOS arm64: typecheck clean across engine + producer,
producer renderOrchestrator vitest 10/10, transparency-test passes for
both webm and png-sequence with end-to-end pixel assertions.
This commit is contained in:
Youssef Toufik
2026-04-27 20:33:45 +01:00
parent 2935be6bf6
commit fe017b48c7
8 changed files with 537 additions and 97 deletions
+38 -11
View File
@@ -20,7 +20,12 @@ import {
resolveHeadlessShellPath,
type CaptureMode,
} from "./browserManager.js";
import { beginFrameCapture, getCdpSession, pageScreenshotCapture } from "./screenshotService.js";
import {
beginFrameCapture,
getCdpSession,
pageScreenshotCapture,
initTransparentBackground,
} from "./screenshotService.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import type {
CaptureOptions,
@@ -78,7 +83,11 @@ export async function createCaptureSession(
): Promise<CaptureSession> {
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
// Determine capture mode before building args — BeginFrame flags only apply on Linux
// Determine capture mode before building args — BeginFrame flags only apply on Linux.
// BeginFrame's compositor does not preserve alpha; callers that pass
// `options.format === "png"` for transparent capture should also set
// `config.forceScreenshot = true` (the producer's renderOrchestrator does this
// automatically when `RenderConfig.format` is an alpha-capable value).
const headlessShell = resolveHeadlessShellPath(config);
const isLinux = process.platform === "linux";
const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
@@ -144,15 +153,12 @@ export async function createCaptureSession(
};
await page.setViewport(viewport);
// For PNG capture (used by WebM/transparency), make the page background transparent
// so Chrome's screenshot captures alpha channel data. Must use the same CDP session
// that the screenshot service uses (getCdpSession caches per page).
if (options.format === "png") {
const cdp = await getCdpSession(page);
await cdp.send("Emulation.setDefaultBackgroundColorOverride", {
color: { r: 0, g: 0, b: 0, a: 0 },
});
}
// Transparent-background setup is intentionally NOT done here. Chrome resets
// the default-background-color override on navigation, and the
// `[data-composition-id]{background:transparent}` stylesheet that
// `initTransparentBackground` injects must land in a real `document.head`.
// See `initializeSession()` below — it calls `initTransparentBackground` for
// PNG captures after `page.goto(...)` and the `window.__hf` readiness poll.
return {
browser,
@@ -303,6 +309,17 @@ export async function initializeSession(session: CaptureSession): Promise<void>
await page.evaluate(`document.fonts?.ready`);
// For PNG captures, force the page background fully transparent so the
// captured screenshots carry a real alpha channel. Must run AFTER
// navigation (Chrome resets the override on every goto) and AFTER the
// page is loaded (the injected stylesheet needs a real document.head).
// The override is overridden by `body { background: ... }` and
// `#root { background: ... }` rules — the helper handles that with a
// `[data-composition-id]{background:transparent !important}` injection.
if (session.options.format === "png") {
await initTransparentBackground(session.page);
}
session.isInitialized = true;
return;
}
@@ -388,6 +405,16 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// Set base frame time ticks past warmup range
session.beginFrameTimeTicks = (warmupTicks + 10) * session.beginFrameIntervalMs;
// For PNG captures, inject the transparent-background override + stylesheet
// (see the screenshot-mode branch above for the rationale). BeginFrame mode
// does not actually preserve alpha through its compositor — callers that
// need transparent output should set `forceScreenshot: true` so this branch
// is bypassed entirely. The call is left here as defense-in-depth for any
// future BeginFrame alpha support.
if (session.options.format === "png") {
await initTransparentBackground(session.page);
}
session.isInitialized = true;
}
@@ -119,16 +119,22 @@ export async function beginFrameCapture(
/**
* Capture a screenshot using standard Page.captureScreenshot CDP call.
* Fallback for environments where BeginFrame is unavailable (macOS, Windows).
*
* For `format: "png"` captures we disable Chrome's `optimizeForSpeed` fast
* path. The fast path uses a zero-alpha-aware codec that crushes real alpha
* values to 0 or 255 (verified empirically; CDP docs don't document this) —
* exactly the same caveat called out on `captureScreenshotWithAlpha` /
* `captureAlphaPng`. Keeping the fast path for opaque jpeg captures is fine.
*/
export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise<Buffer> {
const client = await getCdpSession(page);
const format = options.format === "png" ? "png" : "jpeg";
const isPng = options.format === "png";
const result = await client.send("Page.captureScreenshot", {
format,
quality: format === "jpeg" ? (options.quality ?? 80) : undefined,
format: isPng ? "png" : "jpeg",
quality: isPng ? undefined : (options.quality ?? 80),
fromSurface: true,
captureBeyondViewport: false,
optimizeForSpeed: true,
optimizeForSpeed: !isPng,
});
return Buffer.from(result.data, "base64");
}
+62 -12
View File
@@ -47,22 +47,72 @@ await startServer({ port: 8080 });
`RenderConfig` controls the render pipeline:
| Option | Default | Description |
| ------------ | ------------ | -------------------------------------------------- |
| `inputPath` | — | Path to the HTML composition |
| `outputPath` | — | Output video file path |
| `width` | 1920 | Frame width in pixels |
| `height` | 1080 | Frame height in pixels |
| `fps` | 30 | Frames per second (24, 30, or 60) |
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
| Option | Default | Description |
| ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `inputPath` | — | Path to the HTML composition |
| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"`) |
| `width` | 1920 | Frame width in pixels |
| `height` | 1080 | Frame height in pixels |
| `fps` | 30 | Frames per second (24, 30, or 60) |
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, or `"png-sequence"`. See [Transparent Video Output](#transparent-video-output) below. |
## Transparent Video Output
The producer can render HTML compositions to formats that carry a **true alpha channel** — not chroma key. The same composition that renders an opaque MP4 renders a layerable overlay when you set `format`.
| `format` | Codec / pixel format | Alpha | Audio | Use case |
| ----------------- | --------------------------------- | ----------------------- | ------------------- | --------------------------------------------------------------------------------------- |
| `"mp4"` (default) | H.264 (yuv420p) or H.265 + HDR10 | No | AAC | Streaming, social, default deliverable |
| `"webm"` | VP9 + yuva420p | **True alpha** | Opus | Web playback as overlay (`<video>` over background); supported in Chrome, Edge, Firefox |
| `"mov"` | ProRes 4444 + yuva444p10le | **True alpha + 10-bit** | AAC | Editor ingest (Premiere, Final Cut Pro, DaVinci Resolve) |
| `"png-sequence"` | Numbered RGBA PNGs in a directory | **Lossless alpha** | Sidecar `audio.aac` | After Effects / Nuke / Fusion, or pipelines that post-process frames before encoding |
### Example
```typescript
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";
const job = createRenderJob({
inputPath: "./my-composition.html",
outputPath: "./output.webm", // or a directory for "png-sequence"
width: 1080,
height: 1920,
fps: 30,
format: "webm", // "mp4" | "webm" | "mov" | "png-sequence"
});
await executeRenderJob(job);
```
### What "transparent background" means here
The producer captures Chrome screenshots with the page background forced transparent (`html, body, [data-composition-id] { background: transparent !important }`) and the CDP default background override set to RGBA 0,0,0,0. The captured PNGs carry a real alpha channel and that channel is preserved end-to-end:
- VP9 (`webm`) is encoded with `-pix_fmt yuva420p`, `-auto-alt-ref 0`, and `alpha_mode=1` metadata.
- ProRes 4444 (`mov`) is encoded with `-pix_fmt yuva444p10le`.
- PNG sequences are written without re-encoding (zero-padded `frame_NNNNNN.png`).
This is not chroma keying. There is no green/blue background to remove and no "key" tolerance to tune — pixels that were transparent in the browser are transparent in the output.
### Caveats
- **Linux + alpha forces screenshot capture.** Chrome's BeginFrame compositor (the default deterministic capture path on Linux headless-shell) does not preserve alpha; the orchestrator falls back to `Page.captureScreenshot`, which is slower per frame. macOS and Windows already use screenshot mode by default, so they are unaffected.
- **HDR + alpha is not supported.** Setting `hdr: true` together with an alpha-capable format logs a warning and falls back to SDR. Use `format: "mp4"` for HDR10 output.
- **`png-sequence` does not produce a single muxed file.** When the composition contains audio elements, an `audio.aac` sidecar is written alongside the PNGs in `outputPath`.
- **Safari + WebM alpha is incomplete.** For broad browser playback of an alpha video, ship `format: "mov"` to your editor and re-encode for the codec your distribution target supports.
### Authoring transparent compositions
Don't paint a fullscreen background in your HTML. The default body background is overridden to transparent automatically — any `body { background: ... }`, `#root { background: ... }`, or `[data-composition-id] { background: ... }` rule is force-overridden during alpha rendering. Backgrounds on inner elements (cards, scenes, components) are kept.
## How it works
1. **Serve** — spins up a local file server for the HTML composition
2. **Capture** — opens the page in headless Chrome, seeks frame-by-frame via `HeadlessExperimental.beginFrame`, captures screenshots
3. **Encode** — pipes frames through FFmpeg (with GPU encoder detection and chunked concat)
4. **Mix** — extracts `<audio>` elements and mixes them into the final video
5. **Finalize** — applies faststart for streaming-friendly MP4
2. **Capture** — opens the page in headless Chrome, seeks frame-by-frame via `HeadlessExperimental.beginFrame` (or `Page.captureScreenshot` for transparent / non-Linux renders), captures screenshots
3. **Encode** — pipes frames through FFmpeg (with GPU encoder detection and chunked concat). Skipped for `format: "png-sequence"`.
4. **Mix** — extracts `<audio>` elements and mixes them into the final video. For `png-sequence`, audio is written as an `audio.aac` sidecar.
5. **Finalize** — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and `png-sequence`)
## Documentation
+3 -2
View File
@@ -38,8 +38,9 @@
"check:runtime-conformance": "tsx src/runtime-conformance.ts",
"benchmark": "tsx src/benchmark.ts",
"bench:hdr": "tsx src/benchmark.ts --tags hdr",
"test": "tsx src/regression-harness.ts",
"test:update": "tsx src/regression-harness.ts --update",
"test": "tsx src/regression-harness.ts --exclude-tags transparency",
"test:update": "tsx src/regression-harness.ts --update --exclude-tags transparency",
"test:transparency": "tsx src/transparency-test.ts",
"docker:build:test": "docker build -f ../../Dockerfile.test -t hyperframes-producer:test ../..",
"docker:test": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test",
"docker:test:update": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --update",
@@ -207,8 +207,40 @@ export type RenderStatus =
export interface RenderConfig {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
/** Output container format. WebM uses VP9+alpha, MOV uses ProRes 4444+alpha for transparency. */
format?: "mp4" | "webm" | "mov";
/**
* Output container format. Defaults to `"mp4"`; existing renders are
* unaffected unless this field is set explicitly.
*
* - `"mp4"`: H.264 (or H.265 + HDR10 when `hdr: true`). Opaque. The
* default streaming/social deliverable. Faststart is applied so the
* `moov` atom sits at the file start and the file plays from a
* partial download.
* - `"webm"`: VP9 + `yuva420p` pixel format → **true alpha channel**, no
* chroma key. Plays in Chrome, Edge, and Firefox; Safari support for
* alpha-WebM is incomplete. Use this when the output should drop
* straight into a `<video>` over a colored background on the web.
* Audio is muxed as Opus.
* - `"mov"`: ProRes 4444 + `yuva444p10le` → **true alpha channel +
* 10-bit color**. Sized for editor ingest (Premiere, Final Cut Pro,
* DaVinci Resolve), not direct web playback. Audio is muxed as AAC.
* - `"png-sequence"`: a directory of zero-padded RGBA PNGs
* (`frame_000001.png` …). Lossless alpha, largest on disk, no muxed
* audio (an `audio.aac` sidecar is written alongside the PNGs when
* the composition has audio elements). Use for After Effects / Nuke
* / Fusion ingest, or when frames need post-processing before
* encoding. `outputPath` is treated as a directory; it is created if
* it doesn't exist.
*
* Alpha output (`"webm"`, `"mov"`, `"png-sequence"`) automatically
* forces screenshot capture (Chrome's BeginFrame compositor does not
* preserve alpha on Linux headless-shell) and disables HDR — HDR +
* alpha is not a supported combination, a warning is logged and HDR
* falls back to SDR. The transparent-background CSS is injected by
* the engine's `initTransparentBackground` helper, so authors should
* not paint a fullscreen `body` / `#root` background in their
* compositions when targeting alpha output.
*/
format?: "mp4" | "webm" | "mov" | "png-sequence";
workers?: number;
useGpu?: boolean;
debug?: boolean;
@@ -997,17 +1029,22 @@ export async function executeRenderJob(
};
const perfOutputPath = join(workDir, "perf-summary.json");
const cfg = { ...(job.config.producerConfig ?? resolveConfig()) };
const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm" | "mov";
const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm" | "mov" | "png-sequence";
const isWebm = outputFormat === "webm";
const isMov = outputFormat === "mov";
const needsAlpha = isWebm || isMov;
const isPngSequence = outputFormat === "png-sequence";
const needsAlpha = isWebm || isMov || isPngSequence;
// Transparency requires screenshot mode — beginFrame doesn't support alpha channel
if (needsAlpha) {
cfg.forceScreenshot = true;
}
const enableChunkedEncode = cfg.enableChunkedEncode;
const chunkedEncodeSize = cfg.chunkSizeFrames;
const enableStreamingEncode = cfg.enableStreamingEncode;
// Streaming encode pipes captured frames through ffmpeg's stdin to produce
// a single video file. png-sequence has no encoded video output — frames go
// straight to disk — so the streaming branch is bypassed regardless of the
// engine config flag.
const enableStreamingEncode = cfg.enableStreamingEncode && !isPngSequence;
// Periodic memory sampler — surfaces peak RSS/heap so the benchmark harness
// can detect memory regressions (e.g. unbounded image-cache growth) that
@@ -1494,7 +1531,8 @@ export async function executeRenderJob(
}
if (effectiveHdr && outputFormat !== "mp4") {
log.warn(
`[Render] HDR source detected but format is ${outputFormat} — falling back to SDR. Use --format mp4 for HDR10 output.`,
`[Render] HDR source detected but format is "${outputFormat}" — falling back to SDR. ` +
`HDR + alpha is not supported. Use --format mp4 for HDR10 output.`,
);
effectiveHdr = undefined;
}
@@ -1571,7 +1609,16 @@ export async function executeRenderJob(
const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
// png-sequence is "no container" — outputPath is treated as a directory and
// the encode/mux/faststart stages are skipped entirely. The empty extension
// keeps `videoOnlyPath` (which is constructed below) sensible even though
// it will not be written.
const FORMAT_EXT: Record<string, string> = {
mp4: ".mp4",
webm: ".webm",
mov: ".mov",
"png-sequence": "",
};
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
const videoOnlyPath = join(workDir, `video-only${videoExt}`);
// Only use the HDR encoder preset when there's HDR content to pass through —
@@ -1581,7 +1628,12 @@ export async function executeRenderJob(
const nativeHdrIds = new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
const hasHdrContent = effectiveHdr && nativeHdrIds.size > 0;
const encoderHdr = hasHdrContent ? effectiveHdr : undefined;
const preset = getEncoderPreset(job.config.quality, outputFormat, encoderHdr);
// png-sequence has no encoder, but the rest of the orchestrator still
// reads `preset.quality` for `effectiveQuality` and `preset.codec` for
// unrelated bookkeeping. Fall back to the mp4 preset shape — its values
// are never written to ffmpeg in the png-sequence path.
const presetFormat: "mp4" | "webm" | "mov" = isPngSequence ? "mp4" : outputFormat;
const preset = getEncoderPreset(job.config.quality, presetFormat, encoderHdr);
// CLI overrides (--crf, --video-bitrate) flow through job.config and must
// win over the preset-derived defaults. The CLI enforces mutual exclusivity
@@ -2535,47 +2587,80 @@ export async function executeRenderJob(
perfStages.captureMs = Date.now() - stage4Start;
// ── Stage 5: Encode ─────────────────────────────────────────────────
const stage5Start = Date.now();
updateJobStatus(job, "encoding", "Encoding video", 75, onProgress);
const frameExt = needsAlpha ? "png" : "jpg";
const framePattern = `frame_%06d.${frameExt}`;
const encoderOpts = {
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
pixelFormat: preset.pixelFormat,
useGpu: job.config.useGpu,
hdr: preset.hdr,
};
const encodeResult = enableChunkedEncode
? await encodeFramesChunkedConcat(
framesDir,
framePattern,
videoOnlyPath,
encoderOpts,
chunkedEncodeSize,
abortSignal,
)
: await encodeFramesFromDir(
framesDir,
framePattern,
videoOnlyPath,
encoderOpts,
abortSignal,
if (isPngSequence) {
// ── Stage 5 (png-sequence): copy captured PNGs to outputDir ──────
// No encoder, no mux, no faststart — captured frames already carry
// alpha and are the deliverable. We rename to `frame_NNNNNN.png`
// (zero-padded) so consumers (After Effects, Nuke, Fusion, ffmpeg
// image2 demuxer) can globbed-import without surprises.
const stage5Start = Date.now();
updateJobStatus(job, "encoding", "Writing PNG sequence", 75, onProgress);
if (!existsSync(outputPath)) mkdirSync(outputPath, { recursive: true });
const captured = readdirSync(framesDir)
.filter((name) => name.endsWith(".png"))
.sort();
if (captured.length === 0) {
throw new Error(
`[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`,
);
assertNotAborted();
}
captured.forEach((name, i) => {
const dst = join(outputPath, `frame_${String(i + 1).padStart(6, "0")}.png`);
copyFileSync(join(framesDir, name), dst);
});
if (hasAudio && existsSync(audioOutputPath)) {
// Sidecar audio for callers that need to re-mux later. png-sequence
// has no container of its own, so this is the only place audio
// can land alongside the frames.
copyFileSync(audioOutputPath, join(outputPath, "audio.aac"));
log.info(
`[Render] png-sequence: audio.aac sidecar written to ${outputPath}/audio.aac`,
);
}
perfStages.encodeMs = Date.now() - stage5Start;
} else {
// ── Stage 5: Encode ───────────────────────────────────────────────
const stage5Start = Date.now();
updateJobStatus(job, "encoding", "Encoding video", 75, onProgress);
if (!encodeResult.success) {
throw new Error(`Encoding failed: ${encodeResult.error}`);
const frameExt = needsAlpha ? "png" : "jpg";
const framePattern = `frame_%06d.${frameExt}`;
const encoderOpts = {
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
pixelFormat: preset.pixelFormat,
useGpu: job.config.useGpu,
hdr: preset.hdr,
};
const encodeResult = enableChunkedEncode
? await encodeFramesChunkedConcat(
framesDir,
framePattern,
videoOnlyPath,
encoderOpts,
chunkedEncodeSize,
abortSignal,
)
: await encodeFramesFromDir(
framesDir,
framePattern,
videoOnlyPath,
encoderOpts,
abortSignal,
);
assertNotAborted();
if (!encodeResult.success) {
throw new Error(`Encoding failed: ${encodeResult.error}`);
}
perfStages.encodeMs = Date.now() - stage5Start;
}
perfStages.encodeMs = Date.now() - stage5Start;
}
} finally {
// Defensive cleanup: if the streaming encoder branch threw before
@@ -2609,30 +2694,34 @@ export async function executeRenderJob(
fileServer = null;
// ── Stage 6: Assemble ───────────────────────────────────────────────
const stage6Start = Date.now();
updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress);
// Skipped for png-sequence — there is no encoded video to mux/faststart.
// The frames were copied directly to outputPath in Stage 5.
if (!isPngSequence) {
const stage6Start = Date.now();
updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress);
if (hasAudio) {
const muxResult = await muxVideoWithAudio(
videoOnlyPath,
audioOutputPath,
outputPath,
abortSignal,
);
assertNotAborted();
if (!muxResult.success) {
throw new Error(`Audio muxing failed: ${muxResult.error}`);
}
} else {
const faststartResult = await applyFaststart(videoOnlyPath, outputPath, abortSignal);
assertNotAborted();
if (!faststartResult.success) {
throw new Error(`Faststart failed: ${faststartResult.error}`);
if (hasAudio) {
const muxResult = await muxVideoWithAudio(
videoOnlyPath,
audioOutputPath,
outputPath,
abortSignal,
);
assertNotAborted();
if (!muxResult.success) {
throw new Error(`Audio muxing failed: ${muxResult.error}`);
}
} else {
const faststartResult = await applyFaststart(videoOnlyPath, outputPath, abortSignal);
assertNotAborted();
if (!faststartResult.success) {
throw new Error(`Faststart failed: ${faststartResult.error}`);
}
}
perfStages.assembleMs = Date.now() - stage6Start;
}
perfStages.assembleMs = Date.now() - stage6Start;
// ── Complete ─────────────────────────────────────────────────────────
job.outputPath = outputPath;
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
@@ -2681,8 +2770,11 @@ export async function executeRenderJob(
// ── Cleanup ─────────────────────────────────────────────────────────
if (job.config.debug) {
// Copy output MP4 into debug dir for easy access
if (existsSync(outputPath)) {
// Copy output MP4 (or single-file alpha output) into the debug dir for
// easy access. Skipped for png-sequence: outputPath is a directory, not
// a single file — the captured frames already live in `framesDir` under
// workDir during a debug run anyway.
if (!isPngSequence && existsSync(outputPath)) {
const debugOutput = join(workDir, `output${videoExt}`);
copyFileSync(outputPath, debugOutput);
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Transparency Regression Test
*
* Exercises the alpha-output pipelines (webm + png-sequence) end-to-end
* against `tests/transparency-regression/`. Asserts that:
*
* 1. Pixels that were transparent in the browser stay transparent in the
* output (alpha = 0).
* 2. Pixels covered by the opaque red `.card` element stay fully opaque
* (alpha = 255) and keep their red color.
*
* This is intentionally NOT wired into `regression-harness.ts` the harness
* compares each fixture against a golden MP4, but transparency requires a
* different validation strategy (pixel inspection of the alpha channel). Run
* this script via `bun run --filter @hyperframes/producer test:transparency`
* or directly via `tsx src/transparency-test.ts` from this package.
*/
import { strict as assert } from "node:assert";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { decodePng, runFfmpeg } from "@hyperframes/engine";
import { createRenderJob, executeRenderJob } from "./services/renderOrchestrator.js";
const moduleDir = dirname(fileURLToPath(import.meta.url));
const FIXTURE_DIR = resolve(moduleDir, "../tests/transparency-regression");
const FIXTURE_SRC = join(FIXTURE_DIR, "src");
const WIDTH = 200;
const HEIGHT = 200;
const FPS = 30;
const TRANSPARENT_X = 10; // expected fully transparent
const TRANSPARENT_Y = 10;
const OPAQUE_X = 100; // inside the 50150 red card
const OPAQUE_Y = 100;
function pixelOffset(x: number, y: number, width: number): number {
return (y * width + x) * 4;
}
function assertAlphaPixel(
png: { data: Uint8Array; width: number; height: number },
x: number,
y: number,
expectAlpha: "transparent" | "opaque-red",
label: string,
): void {
assert.equal(png.width, WIDTH, `${label}: width mismatch`);
assert.equal(png.height, HEIGHT, `${label}: height mismatch`);
const off = pixelOffset(x, y, png.width);
const r = png.data[off + 0];
const g = png.data[off + 1];
const b = png.data[off + 2];
const a = png.data[off + 3];
if (expectAlpha === "transparent") {
assert.equal(
a,
0,
`${label}: pixel (${x},${y}) expected fully transparent (alpha=0), got rgba(${r},${g},${b},${a})`,
);
} else {
assert.equal(
a,
255,
`${label}: pixel (${x},${y}) expected fully opaque (alpha=255), got rgba(${r},${g},${b},${a})`,
);
assert.ok(
typeof r === "number" && r >= 240,
`${label}: pixel (${x},${y}) expected red >= 240, got rgba(${r},${g},${b},${a})`,
);
assert.ok(
typeof g === "number" && g <= 30,
`${label}: pixel (${x},${y}) expected green <= 30, got rgba(${r},${g},${b},${a})`,
);
assert.ok(
typeof b === "number" && b <= 30,
`${label}: pixel (${x},${y}) expected blue <= 30, got rgba(${r},${g},${b},${a})`,
);
}
}
async function extractFirstFrameFromWebm(webmPath: string, outPng: string): Promise<void> {
// VP9 alpha is encoded as a separate intra-frame stream inside the WebM,
// and ffmpeg's default decoder path silently discards it. Forcing the
// libvpx-vp9 decoder via `-c:v libvpx-vp9` BEFORE `-i` is what engages
// the alpha-aware decode — without it the captured transparent pixels
// come out opaque even when the file was correctly encoded as yuva420p.
// `-update 1` permits writing a single PNG (no `%d` pattern in the path)
// and silences the otherwise-noisy ffmpeg warning.
const result = await runFfmpeg(
[
"-y",
"-c:v",
"libvpx-vp9",
"-i",
webmPath,
"-frames:v",
"1",
"-pix_fmt",
"rgba",
"-update",
"1",
outPng,
],
{ timeout: 60_000 },
);
if (!result.success) {
throw new Error(
`ffmpeg failed extracting frame 0 from ${webmPath}: ${result.stderr.slice(-400)}`,
);
}
}
async function runWebmCheck(workRoot: string): Promise<void> {
console.log("\n[webm] rendering transparency-regression …");
const outDir = join(workRoot, "webm");
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, "out.webm");
const job = createRenderJob({
fps: FPS,
quality: "draft",
format: "webm",
});
await executeRenderJob(job, FIXTURE_SRC, outPath);
assert.equal(job.status, "complete", `webm render did not complete: status=${job.status}`);
assert.ok(existsSync(outPath), `webm output not written to ${outPath}`);
const size = (await import("node:fs")).statSync(outPath).size;
assert.ok(size > 0, `webm output ${outPath} is empty`);
console.log(`[webm] rendered ${outPath} (${size} bytes)`);
const framePng = join(outDir, "frame-0.png");
await extractFirstFrameFromWebm(outPath, framePng);
const decoded = decodePng(readFileSync(framePng));
assertAlphaPixel(decoded, TRANSPARENT_X, TRANSPARENT_Y, "transparent", "webm");
assertAlphaPixel(decoded, OPAQUE_X, OPAQUE_Y, "opaque-red", "webm");
console.log("[webm] PASS — transparent + opaque-red pixels verified");
}
async function runPngSequenceCheck(workRoot: string): Promise<void> {
console.log("\n[png-sequence] rendering transparency-regression …");
const outDir = join(workRoot, "pngs");
// executeRenderJob mkdirs outputPath itself; deliberately leave it absent.
const job = createRenderJob({
fps: FPS,
quality: "draft",
format: "png-sequence",
});
await executeRenderJob(job, FIXTURE_SRC, outDir);
assert.equal(
job.status,
"complete",
`png-sequence render did not complete: status=${job.status}`,
);
assert.ok(existsSync(outDir), `png-sequence output dir missing: ${outDir}`);
const frames = readdirSync(outDir)
.filter((name) => name.startsWith("frame_") && name.endsWith(".png"))
.sort();
assert.equal(
frames.length,
FPS, // 1 second at 30fps = 30 frames
`png-sequence expected ${FPS} frames, got ${frames.length}: ${frames.join(",")}`,
);
assert.equal(frames[0], "frame_000001.png", "first frame should be frame_000001.png");
assert.equal(
frames[frames.length - 1],
`frame_${String(FPS).padStart(6, "0")}.png`,
`last frame should be frame_${String(FPS).padStart(6, "0")}.png`,
);
console.log(`[png-sequence] wrote ${frames.length} frames to ${outDir}`);
const firstFrame = frames[0];
if (!firstFrame) throw new Error("png-sequence: first frame missing");
const decoded = decodePng(readFileSync(join(outDir, firstFrame)));
assertAlphaPixel(decoded, TRANSPARENT_X, TRANSPARENT_Y, "transparent", "png-sequence");
assertAlphaPixel(decoded, OPAQUE_X, OPAQUE_Y, "opaque-red", "png-sequence");
console.log("[png-sequence] PASS — transparent + opaque-red pixels verified");
}
async function main(): Promise<void> {
if (!existsSync(FIXTURE_SRC)) {
throw new Error(`Fixture missing: ${FIXTURE_SRC}`);
}
const workRoot = join(tmpdir(), `hf-transparency-${process.pid}-${Date.now()}`);
mkdirSync(workRoot, { recursive: true });
const keepWork = process.env.KEEP_TEMP === "1";
console.log(`work dir: ${workRoot}${keepWork ? " (KEEP_TEMP=1)" : ""}`);
try {
await runWebmCheck(workRoot);
await runPngSequenceCheck(workRoot);
console.log("\nAll transparency assertions passed.");
} finally {
if (!keepWork) {
try {
rmSync(workRoot, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
}
}
main().catch((err) => {
console.error("\nTransparency regression test FAILED:");
console.error(err);
process.exitCode = 1;
});
@@ -0,0 +1,8 @@
{
"name": "Transparency Regression",
"description": "Asserts that webm + png-sequence outputs preserve a real alpha channel end-to-end. Exercised by `tsx src/transparency-test.ts`, NOT by the standard regression harness (which compares against a golden MP4).",
"tags": ["transparency", "alpha", "smoke"],
"renderConfig": {
"fps": 30
}
}
@@ -0,0 +1,41 @@
<!doctype html>
<html>
<head>
<style>
html,
body {
margin: 0;
padding: 0;
}
/*
Intentionally NO body / #root background — the alpha pipeline forces
the page background fully transparent. We DO set a background on
.card to verify that *inner* element backgrounds survive the
force-transparent override.
*/
.card {
position: absolute;
left: 50px;
top: 50px;
width: 100px;
height: 100px;
background: rgb(255, 0, 0);
}
</style>
</head>
<body
data-composition-id="transparency-regression"
data-duration="1"
data-width="200"
data-height="200"
>
<div class="card"></div>
<script>
// Minimal seek protocol — the composition is static.
window.__hf = {
duration: 1,
seek: () => {},
};
</script>
</body>
</html>