Files
hyperframes/.github/workflows/windows-render.yml
T
Miguel Ángel 042ec2ebd0 fix(studio): restore overscan and publish the zoom viewport (#5151)
* fix(studio): restore the timeline overscan budget

* test(studio): align window witnesses with the restored budget

* fix(studio): publish the viewport when a zoom anchor lands

* fix(studio): publish the viewport when a zoom-out ends at its laid-out scale

An eased zoom-out that ends at the scale already laid out only moves the scroll position,
so the timeline drew from the old position until the next scroll event. The zoom input
now publishes that scroll through the same callback the zoom anchor uses.

* fix(studio): a pan at the laid-out scale publishes its scroll before paint

The same-scale ending of a zoom ran outside React, so its scroll publish rendered on the
next task, after the browser painted the old window. It now flushes like the scale change
beside it, and its test pans at the laid-out scale instead of changing it.

* test(studio): a same-scale pan must render its scroll before paint

Reads the render window outside act, so unwrapping the flush fails it, and records each
published scroll at call time instead of reading the live element afterwards.
2026-10-08 08:37:55 +00:00

562 lines
24 KiB
YAML

name: Windows render verification
permissions:
contents: read
# Suppress hyperframes CLI telemetry from HeyGen's own CI runs.
# External users' CI continues to emit telemetry unless they set this themselves.
env:
HYPERFRAMES_NO_TELEMETRY: "1"
# Manually triggered smoke test that renders a HyperFrames composition on a
# real Windows runner. Proves the PR #336 `where ffmpeg` fix actually works
# end-to-end: FFmpeg is discovered natively on Windows, Chrome is installed
# and launched, frames are captured, and an MP4 is produced — without Docker
# or WSL.
on:
merge_group:
types: [checks_requested]
pull_request:
# `edited` is required so the workflow re-fires when a PR's base ref is
# set back to `main` after a Graphite stack restack momentarily flips
# the base off of `main`. Without it, `pull_request` triggers are not
# re-evaluated on `base_ref_changed`, leaving required checks skipped
# for that head SHA forever.
types: [opened, synchronize, reopened, edited]
branches: [main]
push:
branches: [main]
workflow_dispatch:
inputs:
ref:
description: "Git ref to render (branch / tag / SHA)."
required: false
default: ""
concurrency:
group: windows-render-${{ github.ref }}
# A body or title edit queues behind running checks instead of cancelling them.
cancel-in-progress: ${{ github.event.action != 'edited' || github.event.changes.base != null }}
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the workflow on transient listFiles timeouts
# before the Windows render jobs even start.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
# HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's
# base.sha, which goes stale and would count the base's newer commits as the PR's changes.
- name: Find the pull request's current base
id: base
if: github.event_name == 'pull_request'
run: |
git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; }
sha="$(git rev-parse --verify HEAD^1)"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4
id: filter
with:
token: ""
base: ${{ steps.base.outputs.sha }}
# A file counts only if it matches every pattern. Player and Studio `src/` run in a browser
# and no code reads a package README, so a diff confined to them skips Windows.
predicate-quantifier: every
filters: |
code:
- "{packages/**,scripts/**,package.json,bun.lock,.github/workflows/windows-render.yml}"
- "!packages/player/src/**"
- "!packages/studio/src/**"
- "!packages/*/README.md"
preflight:
name: Preflight (lint + format)
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ inputs.ref || github.sha }}
- uses: ./.github/actions/preflight
render-windows:
name: Render on windows-latest
needs: [changes, preflight]
# A skipped required check passes, so a failed change detection or preflight must fail it.
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch') }}
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Require change detection and the preflight
if: needs.changes.result != 'success' || needs.preflight.result != 'success'
shell: bash
run: |
echo "::error::changes=${{ needs.changes.result }} preflight=${{ needs.preflight.result }}; this required check cannot pass."
exit 1
- name: Disable Windows Defender real-time monitoring
shell: pwsh
continue-on-error: true
run: Set-MpPreference -DisableRealtimeMonitoring $true
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ inputs.ref || github.sha }}
lfs: true
- name: Show platform info
shell: pwsh
run: |
Write-Host "OS: $([System.Environment]::OSVersion.VersionString)"
Write-Host "PowerShell: $($PSVersionTable.PSVersion)"
Write-Host "Runner: windows-latest"
# -----------------------------------------------------------------
# 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
uses: ./.github/actions/install-ffmpeg-windows
- name: Set FFMPEG_BIN for bun install
shell: pwsh
run: |
$path = (Get-Command ffmpeg.exe).Source
"FFMPEG_BIN=$path" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# -----------------------------------------------------------------
# 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: |
$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
# `<flags> <name> <description>`, 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@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Install dependencies
shell: pwsh
# Force the pre-1.3 "hoisted" linker on Windows. Bun 1.3.x's default
# "isolated" linker creates nested workspace junctions that don't
# materialize reliably on Windows GHA runners — manifests as
# `Cannot find package 'esbuild'` walking up from
# `packages/producer/build.mjs` and `EPERM stat` on
# `packages/producer/node_modules/@fontsource/*` from generators.
# See oven-sh/bun#23615, #18354, #10146. Linux CI keeps isolated;
# the lockfile is linker-agnostic so this flag is safe with
# --frozen-lockfile.
run: bun install --frozen-lockfile --linker=hoisted
# The CLI bundle consumes Engine and Producer from source, but copies the
# built Studio and Player artifacts. Build only that dependency chain;
# cloud adapters, SDK playground, and other publish-only packages are not
# exercised by this Windows render smoke test.
- name: Build render prerequisites
shell: pwsh
run: |
bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
bun run --cwd packages/core build
bun run --filter @hyperframes/player build
bun run --filter @hyperframes/studio build
bun run --filter @hyperframes/cli build
# -----------------------------------------------------------------
# Prove the PR #336 fix: hyperframes doctor exercises findFFmpeg()
# and whichBinary() — both must pass on Windows without workarounds.
# -----------------------------------------------------------------
- name: hyperframes doctor (verifies `where ffmpeg` fix)
shell: pwsh
run: node packages/cli/dist/cli.js doctor
- name: Scaffold canary composition
shell: pwsh
# init's --skip-skills flag is neutered (see init.ts); opt out of the
# GitHub skills check via this env so the canary scaffold stays offline.
env:
HYPERFRAMES_SKIP_SKILLS: "1"
run: |
New-Item -ItemType Directory -Force -Path "$env:RUNNER_TEMP\windows-canary" | Out-Null
cd "$env:RUNNER_TEMP\windows-canary"
node "$env:GITHUB_WORKSPACE\packages\cli\dist\cli.js" init canary --example blank --non-interactive --skip-skills
$fixtures = "$env:GITHUB_WORKSPACE\.github\workflows\fixtures"
Copy-Item "$fixtures\windows-canary.html" "canary\index.html" -Force
- name: Render canary composition
shell: pwsh
run: |
cd "$env:RUNNER_TEMP\windows-canary\canary"
node "$env:GITHUB_WORKSPACE\packages\cli\dist\cli.js" render `
--fps 30 `
--quality draft `
--workers 2 `
--output renders\canary.mp4
- name: Verify rendered MP4
shell: pwsh
run: |
$mp4 = "$env:RUNNER_TEMP\windows-canary\canary\renders\canary.mp4"
if (-not (Test-Path $mp4)) { throw "canary.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
# Parse probe output
$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 7.5 -or $duration -gt 8.5) { throw "expected ~8s duration, got $duration" }
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
${{ runner.temp }}/issue-574-reused-video/renders/issue-574.mp4
if-no-files-found: error
retention-days: 7
# -------------------------------------------------------------------
# Unit-test suites on Windows. Mirrors the Linux `test` job in ci.yml
# so we catch Windows-specific regressions (path separators, shell
# invocations, CRLF, file URLs, etc.) in existing vitest suites.
# The producer package is skipped because its tests require Docker /
# Linux-only tooling (Dockerfile.test, LFS golden MP4 baselines).
# The aws-lambda package is skipped because it targets the AWS Lambda
# Linux runtime exclusively (`@sparticuz/chromium` is Linux-only; the
# ZIP layout is for `/var/task` on AL2023). Its handler.test.ts also
# trips a bun-on-Windows workspace-symlink quirk reading the
# producer's transitive `hono` dep.
# -------------------------------------------------------------------
test-windows-lane:
name: "Tests on windows-latest: ${{ matrix.lane }}"
needs: [changes, preflight]
if: needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
# One suite group per runner: Studio's halves and the process-heavy Engine and CLI
# suites never share a runner, and no lane waits for another group's tests.
- lane: studio-1
studio_shard: 1/2
- lane: studio-2
studio_shard: 2/2
- lane: core
packages: --filter @hyperframes/core --filter @hyperframes/player --filter @hyperframes/shader-transitions
- lane: engine-cli
packages: --filter @hyperframes/engine --filter @hyperframes/cli
runtime_contract: true
steps:
- name: Disable Windows Defender real-time monitoring
shell: pwsh
continue-on-error: true
run: Set-MpPreference -DisableRealtimeMonitoring $true
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ inputs.ref || github.sha }}
# Only the engine-cli lane reads an LFS file (engine's ffprobe test parses a producer HDR PNG).
lfs: ${{ matrix.lane == 'engine-cli' }}
# -----------------------------------------------------------------
# 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: Set FFMPEG_BIN for bun install
shell: pwsh
run: |
$path = (Get-Command ffmpeg.exe).Source
"FFMPEG_BIN=$path" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Install Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Install dependencies
shell: pwsh
# Force the pre-1.3 "hoisted" linker on Windows. Bun 1.3.x's default
# "isolated" linker creates nested workspace junctions that don't
# materialize reliably on Windows GHA runners — manifests as
# `Cannot find package 'esbuild'` walking up from
# `packages/producer/build.mjs` and `EPERM stat` on
# `packages/producer/node_modules/@fontsource/*` from generators.
# See oven-sh/bun#23615, #18354, #10146. Linux CI keeps isolated;
# the lockfile is linker-agnostic so this flag is safe with
# --frozen-lockfile.
run: bun install --frozen-lockfile --linker=hoisted
# Vitest loads these workspace packages through their Node dist exports.
# No tested package needs the full publish build used by `bun run build`.
- name: Build test prerequisites
shell: pwsh
run: |
bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
bun run --cwd packages/core build
- name: Run Studio shard ${{ matrix.studio_shard }}
if: matrix.studio_shard
shell: pwsh
run: bun run --cwd packages/studio test -- --shard=${{ matrix.studio_shard }} --maxWorkers=2
- name: Run ${{ matrix.lane }} package tests
if: matrix.packages
shell: pwsh
# Each package starts its own Vitest pool. Bound every pool so concurrent
# package scripts cannot oversubscribe the hosted Windows runner.
run: bun run ${{ matrix.packages }} test -- --maxWorkers=2
- name: Verify waveform cache writes on Windows
if: matrix.lane == 'engine-cli'
shell: pwsh
run: bun run --cwd packages/studio-server test -- src/helpers/waveform.test.ts src/helpers/waveform.file-race.test.ts --maxWorkers=2
- name: Verify producer lint entry reads on Windows
if: matrix.lane == 'engine-cli'
shell: pwsh
run: bunx vitest run packages/producer/src/services/hyperframeLint.file-race.test.ts --maxWorkers=2
- name: Verify Windows frame staging
if: matrix.lane == 'engine-cli'
shell: pwsh
run: bunx vitest run packages/producer/src/services/windowsFrameStaging.test.ts --maxWorkers=2
- name: Verify catalog file operations on Windows
if: matrix.lane == 'engine-cli'
shell: pwsh
run: bunx vitest run scripts/catalog/catalog-payload-assets.file-race.test.ts scripts/catalog/catalog-payload-assets.cache-race.test.ts --maxWorkers=2
- name: Run runtime contract test
if: matrix.runtime_contract
shell: pwsh
run: bun run --filter "@hyperframes/core" test:hyperframe-runtime-ci
# Preserve the existing required check name while the lanes stay
# independently visible and rerunnable.
test-windows:
name: Tests on windows-latest
needs: [changes, test-windows-lane]
if: always() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
actions: read
steps:
# On a PR, a cancelled lane passes only when a newer run of this workflow
# exists for the branch (concurrency cancelled this one). Push runs, a manual
# cancel of the latest run and any API error still fail.
- name: Require all Windows test lanes
if: needs.test-windows-lane.result != 'success'
env:
GH_TOKEN: ${{ github.token }}
LANE_RESULT: ${{ needs.test-windows-lane.result }}
BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
if [ "$LANE_RESULT" = "cancelled" ] && [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
api="repos/$GITHUB_REPOSITORY/actions"
HEAD_REPO=$(gh api "$api/runs/$GITHUB_RUN_ID" --jq .head_repository.full_name)
export HEAD_REPO
newer=$(gh api "$api/workflows/windows-render.yml/runs" -X GET \
-f event="$GITHUB_EVENT_NAME" -f branch="$BRANCH" -f per_page=100 \
--jq '[.workflow_runs[] | select(.id > (env.GITHUB_RUN_ID | tonumber) and .head_repository.full_name == env.HEAD_REPO)] | length')
if [ "$newer" -gt 0 ]; then
echo "::notice::Superseded by a newer run; it reports the Windows result."
exit 0
fi
fi
echo "::error::One or more Windows test lanes did not succeed."
exit 1