Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion .github/workflows/windows-render.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

@'
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Issue 574 reused video regression</title>
<style>
html,
body {
margin: 0;
padding: 0;
background: #000;
}

#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
}

video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="root"
data-start="0"
data-duration="12"
data-width="1920"
data-height="1080"
>
<video id="video1" src="1.mp4" data-start="0" muted data-duration="4" data-track-index="0" data-media-start="0"></video>
<video id="video2" src="1.mp4" data-start="4" muted data-duration="4" data-track-index="0" data-media-start="4"></video>
<video id="video3" src="1.mp4" data-start="8" muted data-duration="4" data-track-index="0" data-media-start="8"></video>
</div>
<script>
window.__timelines = window.__timelines || {};
</script>
</body>
</html>
'@ | 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

Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type {
HfMediaElement,
HfTransitionMeta,
CaptureOptions,
CaptureVideoMetadataHint,
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
Expand Down
51 changes: 48 additions & 3 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import type {
CaptureOptions,
CaptureVideoMetadataHint,
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
Expand Down Expand Up @@ -221,6 +222,44 @@ async function pollPageExpression(
return Boolean(await page.evaluate(expression));
}

async function applyVideoMetadataHints(
page: Page,
hints: readonly CaptureVideoMetadataHint[] | undefined,
): Promise<void> {
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<void> {
const { page, serverUrl } = session;

Expand Down Expand Up @@ -290,11 +329,14 @@ export async function initializeSession(session: CaptureSession): Promise<void>
);
}

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,
Expand Down Expand Up @@ -382,9 +424,12 @@ export async function initializeSession(session: CaptureSession): Promise<void>
);
}

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);
Expand Down
23 changes: 17 additions & 6 deletions packages/engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<video>` element is then only
* needed for layout (`getBoundingClientRect` / `offsetWidth`), which works
* at `readyState=0`. Without this, codecs that headless Chromium can't
* decode (HEVC on Linux `headless-shell`) cause a fatal timeout even
* though we never asked the browser to play the video.
* Use for videos whose frames are supplied out-of-band, including standard
* FFmpeg frame injection and native HDR extraction. Pair with
* `videoMetadataHints` for any skipped video whose CSS layout may depend on
* intrinsic media dimensions.
*/
skipReadinessVideoIds?: readonly string[];
}

export interface CaptureVideoMetadataHint {
id: string;
width: number;
height: number;
}

export interface CaptureResult {
frameIndex: number;
time: number;
Expand Down
31 changes: 31 additions & 0 deletions packages/producer/src/services/renderOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { CompiledComposition } from "./htmlCompiler.js";
import {
applyRenderModeHints,
buildMissingFrameRetryBatches,
collectVideoMetadataHints,
collectVideoReadinessSkipIds,
createCaptureCalibrationConfig,
estimateMeasuredCaptureCostMultiplier,
estimateCaptureCostMultiplier,
Expand Down Expand Up @@ -260,6 +262,35 @@ describe("applyRenderModeHints", () => {
});
});

describe("collectVideoReadinessSkipIds", () => {
it("skips native metadata waits for every injected video with dimensions", () => {
expect(
collectVideoReadinessSkipIds(new Set(["hdr-video"]), [
{ videoId: "video1", metadata: { width: 1920, height: 1080 } },
{ videoId: "video2", metadata: { width: 1920, height: 1080 } },
{ videoId: "video3", metadata: { width: 1920, height: 1080 } },
{ videoId: "hdr-video", metadata: { width: 1920, height: 1080 } },
{ videoId: "bad-metadata", metadata: { width: 0, height: 0 } },
]),
).toEqual(["hdr-video", "video1", "video2", "video3"]);
});
});

describe("collectVideoMetadataHints", () => {
it("passes extracted video dimensions to capture sessions", () => {
expect(
collectVideoMetadataHints([
{ videoId: "video2", metadata: { width: 1080, height: 1920, durationSeconds: 4 } },
{ videoId: "video1", metadata: { width: 1920, height: 1080, durationSeconds: 12 } },
{ videoId: "bad-metadata", metadata: { width: 0, height: 1080, durationSeconds: 1 } },
]),
).toEqual([
{ id: "video1", width: 1920, height: 1080 },
{ id: "video2", width: 1080, height: 1920 },
]);
});
});

describe("resolveRenderWorkerCount", () => {
const cfg = { ...createConfig(), coresPerWorker: 100 };
const audio = {
Expand Down
Loading
Loading