diff --git a/.github/workflows/windows-render.yml b/.github/workflows/windows-render.yml
index ceaf6ecda..0a7db07d6 100644
--- a/.github/workflows/windows-render.yml
+++ b/.github/workflows/windows-render.yml
@@ -201,12 +201,121 @@ jobs:
Write-Host "canary.mp4 ok: ${width}x${height} @ $fps, ${duration}s"
+ - name: Scaffold issue #574 reused-video regression
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $project = "$env:RUNNER_TEMP\issue-574-reused-video"
+ New-Item -ItemType Directory -Force -Path $project | Out-Null
+ cd $project
+
+ ffmpeg -y `
+ -f lavfi -i "testsrc2=size=1920x1080:rate=30:duration=12" `
+ -f lavfi -i "sine=frequency=880:sample_rate=48000:duration=12" `
+ -c:v libx264 `
+ -pix_fmt yuv420p `
+ -r 30 `
+ -g 250 `
+ -keyint_min 250 `
+ -c:a aac `
+ -shortest `
+ 1.mp4
+
+ @'
+
+
+
+
+ Issue 574 reused video regression
+
+
+
+
+
+
+
+
+
+
+
+ '@ | Set-Content -Path index.html -Encoding utf8
+
+ - name: Render issue #574 reused-video regression
+ shell: pwsh
+ env:
+ PRODUCER_PLAYER_READY_TIMEOUT_MS: "15000"
+ run: |
+ cd "$env:RUNNER_TEMP\issue-574-reused-video"
+ node "$env:GITHUB_WORKSPACE\packages\cli\dist\cli.js" render `
+ --fps 30 `
+ --quality standard `
+ --workers 1 `
+ --output renders\issue-574.mp4
+
+ - name: Verify issue #574 rendered MP4
+ shell: pwsh
+ run: |
+ $mp4 = "$env:RUNNER_TEMP\issue-574-reused-video\renders\issue-574.mp4"
+ if (-not (Test-Path $mp4)) { throw "issue-574.mp4 not produced" }
+
+ $probe = ffprobe -v error -select_streams v:0 `
+ -show_entries stream=width,height,r_frame_rate -show_entries format=duration `
+ -of default=noprint_wrappers=1 $mp4
+ Write-Host $probe
+
+ $width = ($probe | Select-String '^width=(.+)$').Matches.Groups[1].Value
+ $height = ($probe | Select-String '^height=(.+)$').Matches.Groups[1].Value
+ $fps = ($probe | Select-String '^r_frame_rate=(.+)$').Matches.Groups[1].Value
+ $duration = [double]($probe | Select-String '^duration=(.+)$').Matches.Groups[1].Value
+
+ if ([int]$width -ne 1920) { throw "expected 1920 width, got $width" }
+ if ([int]$height -ne 1080) { throw "expected 1080 height, got $height" }
+ if ($fps -ne "30/1") { throw "expected 30fps, got $fps" }
+ if ($duration -lt 11.5 -or $duration -gt 12.5) { throw "expected ~12s duration, got $duration" }
+
+ Write-Host "issue-574.mp4 ok: ${width}x${height} @ $fps, ${duration}s"
+
- name: Upload rendered MP4 artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-render-${{ github.run_id }}
- path: ${{ runner.temp }}/windows-canary/canary/renders/canary.mp4
+ path: |
+ ${{ runner.temp }}/windows-canary/canary/renders/canary.mp4
+ ${{ runner.temp }}/issue-574-reused-video/renders/issue-574.mp4
if-no-files-found: error
retention-days: 7
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index 0bfc00bcc..d2dff7f6f 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -36,6 +36,7 @@ export type {
HfMediaElement,
HfTransitionMeta,
CaptureOptions,
+ CaptureVideoMetadataHint,
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts
index 296a2fda1..6571ca7e5 100644
--- a/packages/engine/src/services/frameCapture.ts
+++ b/packages/engine/src/services/frameCapture.ts
@@ -29,6 +29,7 @@ import {
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import type {
CaptureOptions,
+ CaptureVideoMetadataHint,
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
@@ -221,6 +222,44 @@ async function pollPageExpression(
return Boolean(await page.evaluate(expression));
}
+async function applyVideoMetadataHints(
+ page: Page,
+ hints: readonly CaptureVideoMetadataHint[] | undefined,
+): Promise {
+ if (!hints || hints.length === 0) return;
+
+ await page.evaluate(
+ (metadataHints: CaptureVideoMetadataHint[]) => {
+ for (const hint of metadataHints) {
+ if (
+ !hint.id ||
+ !Number.isFinite(hint.width) ||
+ !Number.isFinite(hint.height) ||
+ hint.width <= 0 ||
+ hint.height <= 0
+ ) {
+ continue;
+ }
+
+ const video = document.getElementById(hint.id) as HTMLVideoElement | null;
+ if (!video) continue;
+
+ if (!video.hasAttribute("width")) video.setAttribute("width", String(hint.width));
+ if (!video.hasAttribute("height")) video.setAttribute("height", String(hint.height));
+
+ const computed = window.getComputedStyle(video);
+ if (
+ !video.style.aspectRatio &&
+ (!computed.aspectRatio || computed.aspectRatio === "auto")
+ ) {
+ video.style.aspectRatio = `${hint.width} / ${hint.height}`;
+ }
+ }
+ },
+ [...hints],
+ );
+}
+
export async function initializeSession(session: CaptureSession): Promise {
const { page, serverUrl } = session;
@@ -290,11 +329,14 @@ export async function initializeSession(session: CaptureSession): Promise
);
}
+ await applyVideoMetadataHints(page, session.options.videoMetadataHints);
+
// Wait for all video elements to have loaded metadata (dimensions + duration)
// Without this, frame 0 captures videos at their 300x150 default size.
// skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
- // sources) whose frames come from ffmpeg out-of-band — Chromium may not be
- // able to decode them at all (e.g. HEVC on Linux headless-shell).
+ // sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
+ // supply intrinsic dimensions for skipped videos whose layout depends on
+ // aspect ratio, while Chromium may still fail to decode/load metadata.
const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
const videosReady = await pollPageExpression(
page,
@@ -382,9 +424,12 @@ export async function initializeSession(session: CaptureSession): Promise
);
}
+ await applyVideoMetadataHints(page, session.options.videoMetadataHints);
+
// Wait for all video elements to have loaded metadata (dimensions + duration).
// Without this, frame 0 captures videos at their 300x150 default size.
- // See screenshot-mode comment above for why skipReadinessVideoIds exists.
+ // See screenshot-mode comment above for why skipReadinessVideoIds and
+ // videoMetadataHints are paired.
const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
const videoDeadline =
Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts
index 81920d270..efb7f94e3 100644
--- a/packages/engine/src/types.ts
+++ b/packages/engine/src/types.ts
@@ -85,20 +85,31 @@ export interface CaptureOptions {
format?: "jpeg" | "png";
quality?: number;
deviceScaleFactor?: number;
+ /**
+ * FFmpeg-probed intrinsic dimensions for videos whose frames are injected
+ * out-of-band. Applied before the readiness wait so layout that depends on
+ * video aspect ratio (e.g. `height:auto`) stays stable even if Chromium never
+ * loads native metadata.
+ */
+ videoMetadataHints?: readonly CaptureVideoMetadataHint[];
/**
* Video element IDs to exclude from the in-page readiness check that waits
* for `video.readyState >= 1` before capture starts.
*
- * Use for videos whose frames are supplied out-of-band (e.g. native HDR
- * frame extraction via ffmpeg). The DOM `