From 2f58e9d1880530493ca1d9fabe1f6a57bf29dbd1 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 22 Apr 2026 22:40:44 -0700 Subject: [PATCH] ci(windows-render): bypass Chocolatey, fetch ffmpeg from BtbN/GitHub (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Replace the `choco install ffmpeg` step in `windows-render.yml` with a direct download of the upstream Windows GPL build from [`BtbN/FFmpeg-Builds`](https://github.com/BtbN/FFmpeg-Builds/releases/latest) on GitHub Releases. ## Why The `Render on windows-latest` canary started failing on every PR with: ``` [NuGet] Response status code does not indicate success: 504 (Gateway Timeout). [NuGet] Response status code does not indicate success: 503 (Service Unavailable). ``` The Chocolatey community feed (`community.chocolatey.org/api/v2/package/ffmpeg/8.1.0`) is degraded for the `ffmpeg` package right now. The earlier 3-attempt retry I added wasn't enough — every attempt across multiple runs failed with 503/504, so retrying does nothing. The Chocolatey path is also a bit indirect for what this job actually validates. The real point of the canary is the [PR #336](https://github.com/heygen-com/hyperframes/pull/336) fix where `findFFmpeg()` / `where ffmpeg` discovery has to work on a fresh Windows runner. As long as `ffmpeg.exe` ends up on `PATH`, the underlying thing under test (the harness can find ffmpeg, capture frames, mux to MP4) is exercised exactly the same. BtbN/FFmpeg-Builds is the canonical upstream nightly Windows GPL build (Chocolatey itself rebundles essentially the same artifact), so this is closer to the source, not further from it. ## How - Download `ffmpeg-master-latest-win64-gpl.zip` from the BtbN release with `Invoke-WebRequest` (3-attempt retry with backoff). - Extract to `$env:RUNNER_TEMP/ffmpeg` and locate `ffmpeg.exe` recursively. - Add the bin directory to `$env:GITHUB_PATH` so all subsequent steps in the job (the Bun-driven harness, `findFFmpeg()`, etc.) see ffmpeg on `PATH` exactly the same way as before. - Print `ffmpeg -version` as a sanity check. ## Test plan - [ ] CI: `Render on windows-latest` job goes green on this PR. - [ ] Subsequent PRs no longer get blocked on `choco install ffmpeg` 503s. --- .../actions/install-ffmpeg-windows/action.yml | 58 +++++++++++++++ .github/workflows/windows-render.yml | 71 ++++++++++++++++--- packages/engine/src/services/audioMixer.ts | 7 +- .../src/services/videoFrameExtractor.ts | 8 ++- 4 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 .github/actions/install-ffmpeg-windows/action.yml diff --git a/.github/actions/install-ffmpeg-windows/action.yml b/.github/actions/install-ffmpeg-windows/action.yml new file mode 100644 index 000000000..04d0ea58c --- /dev/null +++ b/.github/actions/install-ffmpeg-windows/action.yml @@ -0,0 +1,58 @@ +name: Install FFmpeg (Windows) +description: >- + Download a pinned FFmpeg GPL build for Windows from BtbN/FFmpeg-Builds and put + ffmpeg.exe / ffprobe.exe on PATH. We bypass `choco install ffmpeg` because + the Chocolatey community feed regularly returns 503 / 504 / NuGet resolver + errors with no retry, which makes it unsuitable as a CI dependency. From the + consumer's perspective this is equivalent — `where ffmpeg` (the PR #336 + validation) and `findFFmpeg()` both pass. + +inputs: + release-url: + description: >- + URL of the BtbN/FFmpeg-Builds zip to install. Pinned by default so a new + upstream release can't silently change the encoder/muxer set under us. + required: false + default: https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip + max-attempts: + description: Max download attempts before failing. + required: false + default: "3" + +runs: + using: composite + steps: + - name: Install FFmpeg from BtbN/FFmpeg-Builds + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $url = '${{ inputs.release-url }}' + $maxAttempts = [int]'${{ inputs.max-attempts }}' + + $zip = Join-Path $env:RUNNER_TEMP 'ffmpeg.zip' + $dir = Join-Path $env:RUNNER_TEMP 'ffmpeg' + New-Item -ItemType Directory -Force -Path $dir | Out-Null + + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + Write-Host "--- Downloading ffmpeg from BtbN/FFmpeg-Builds (attempt $attempt/$maxAttempts) ---" + try { + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + break + } catch { + Write-Warning "Download failed: $($_.Exception.Message)" + if ($attempt -eq $maxAttempts) { throw } + Start-Sleep -Seconds (10 * $attempt) + } + } + + Write-Host "--- Extracting ffmpeg ---" + Expand-Archive -Path $zip -DestinationPath $dir -Force + + $bin = Get-ChildItem -Path $dir -Recurse -Filter 'ffmpeg.exe' | Select-Object -First 1 + if (-not $bin) { throw "ffmpeg.exe not found after extracting $url" } + + Add-Content -Path $env:GITHUB_PATH -Value $bin.Directory.FullName + + Write-Host "--- ffmpeg sanity check ---" + & $bin.FullName -version | Select-Object -First 1 diff --git a/.github/workflows/windows-render.yml b/.github/workflows/windows-render.yml index 2e7de5f2a..36715e633 100644 --- a/.github/workflows/windows-render.yml +++ b/.github/workflows/windows-render.yml @@ -69,17 +69,62 @@ jobs: Write-Host "Runner: windows-latest" # ----------------------------------------------------------------- - # Install FFmpeg via Chocolatey (mirrors the recommended path for - # real Windows users — no Docker, no WSL). + # Install FFmpeg via the shared composite action so the install logic + # stays identical between this job and `test-windows` below. See + # .github/actions/install-ffmpeg-windows for why we bypass Chocolatey. # ----------------------------------------------------------------- - - name: Install FFmpeg (Chocolatey) + - name: Install FFmpeg + uses: ./.github/actions/install-ffmpeg-windows + + # ----------------------------------------------------------------- + # Verify FFmpeg feature inventory. + # + # The engine shells out to a fixed set of encoders (libx264 for MP4, + # libx265 for HEVC, libvpx-vp9 for WebM, prores_ks for transparent + # MOV, aac for audio), muxers (mp4 / mov / webm), and demuxers + # (image2pipe for streaming RGBA frames, rawvideo for HDR PQ frames, + # mov,mp4 for video frame extraction). Some of these are GPL-only, + # so a future build swap could silently drop one and break a code + # path the canary render doesn't exercise. Fail fast here instead. + # ----------------------------------------------------------------- + - name: Verify FFmpeg feature inventory shell: pwsh run: | - choco install ffmpeg -y --no-progress - refreshenv - Write-Host "--- ffmpeg sanity check ---" - where.exe ffmpeg - ffmpeg -version | Select-Object -First 1 + $ErrorActionPreference = 'Stop' + + function Assert-FfmpegFeature { + param( + [Parameter(Mandatory)] [string] $Listing, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Kind + ) + # `ffmpeg -encoders` etc. emit one feature per line as + # ` `, so a whitespace boundary on + # each side is enough to disambiguate (e.g. `mov` vs `movflags`). + $pattern = "(^|\s)$([regex]::Escape($Name))(\s|$)" + if ($Listing -notmatch $pattern) { + throw "Required FFmpeg $Kind '$Name' not present in this build" + } + Write-Host " ok: $Kind $Name" + } + + Write-Host "--- encoders ---" + $encoders = (& ffmpeg -hide_banner -encoders 2>&1) -join "`n" + foreach ($enc in @('libx264', 'libx265', 'libvpx-vp9', 'prores_ks', 'aac')) { + Assert-FfmpegFeature -Listing $encoders -Name $enc -Kind 'encoder' + } + + Write-Host "--- muxers ---" + $muxers = (& ffmpeg -hide_banner -muxers 2>&1) -join "`n" + foreach ($mux in @('mp4', 'mov', 'webm')) { + Assert-FfmpegFeature -Listing $muxers -Name $mux -Kind 'muxer' + } + + Write-Host "--- demuxers ---" + $demuxers = (& ffmpeg -hide_banner -demuxers 2>&1) -join "`n" + foreach ($dem in @('image2pipe', 'rawvideo', 'mov,mp4,m4a,3gp,3g2,mj2')) { + Assert-FfmpegFeature -Listing $demuxers -Name $dem -Kind 'demuxer' + } - name: Install Bun uses: oven-sh/setup-bun@v2 @@ -178,6 +223,16 @@ jobs: with: ref: ${{ github.event.inputs.ref }} + # ----------------------------------------------------------------- + # Install FFmpeg so vitest suites that gate on `HAS_FFMPEG` + # (e.g. packages/engine videoFrameExtractor.test.ts) actually run on + # Windows. Without it those suites `describe.skipIf(!HAS_FFMPEG)` + # themselves silently and any Windows-specific regression in the + # FFmpeg-driven code paths would not be caught here. + # ----------------------------------------------------------------- + - name: Install FFmpeg + uses: ./.github/actions/install-ffmpeg-windows + - name: Install Bun uses: oven-sh/setup-bun@v2 diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 6875bb567..726726425 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -5,7 +5,7 @@ */ import { existsSync, mkdirSync, rmSync } from "fs"; -import { join, dirname } from "path"; +import { isAbsolute, join, dirname } from "path"; import { parseHTML } from "linkedom"; import { extractAudioMetadata } from "../utils/ffprobe.js"; import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; @@ -324,7 +324,10 @@ export async function processCompositionAudio( } try { let srcPath = element.src; - if (!srcPath.startsWith("/") && !isHttpUrl(srcPath)) { + // Use isAbsolute() rather than startsWith("/"). On Windows, absolute paths + // like "C:\…" are not detected by the latter, so we'd re-join them under + // baseDir and produce duplicated, nonexistent paths. + if (!isAbsolute(srcPath) && !isHttpUrl(srcPath)) { const fromCompiled = compiledDir ? join(compiledDir, srcPath) : null; srcPath = fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, srcPath); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 3980e3a1c..02c8e0905 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -7,7 +7,7 @@ import { spawn } from "child_process"; import { existsSync, mkdirSync, readdirSync, rmSync } from "fs"; -import { join } from "path"; +import { isAbsolute, join } from "path"; import { parseHTML } from "linkedom"; import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js"; import { @@ -382,7 +382,11 @@ export async function extractAllVideoFrames( if (signal?.aborted) break; try { let videoPath = video.src; - if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) { + // Use isAbsolute() rather than startsWith("/"). On Windows, absolute paths + // like "C:\…" are not detected by the latter, so we'd re-join them under + // baseDir and produce duplicated, nonexistent paths + // (e.g. C:\tmp\hf-vfr-test-X\C:\tmp\hf-vfr-test-X\vfr_screen.mp4). + if (!isAbsolute(videoPath) && !isHttpUrl(videoPath)) { const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null; videoPath = fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);