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 `