Skip to content

Commit 18d75f9

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/runtime-fallback-resolution
2 parents 9de3c28 + 147bb73 commit 18d75f9

39 files changed

Lines changed: 1607 additions & 316 deletions

‎.gitattributes‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text
44
# Source video clips for regression test fixtures (HDR samples, etc.)
55
packages/producer/tests/*/src/*.mp4 filter=lfs diff=lfs merge=lfs -text
66

7+
# Source image assets for regression test fixtures (HDR PNGs, screenshot fixtures, etc.)
8+
packages/producer/tests/*/src/*.png filter=lfs diff=lfs merge=lfs -text
9+
710
# GitHub Linguist overrides — HTML files are compositions (user content / templates),
811
# not the framework source. Hide them from the repo language stats so TypeScript,
912
# which is the actual implementation, surfaces as the dominant language.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Install FFmpeg (Windows)
2+
description: >-
3+
Download a pinned FFmpeg GPL build for Windows from BtbN/FFmpeg-Builds and put
4+
ffmpeg.exe / ffprobe.exe on PATH. We bypass `choco install ffmpeg` because
5+
the Chocolatey community feed regularly returns 503 / 504 / NuGet resolver
6+
errors with no retry, which makes it unsuitable as a CI dependency. From the
7+
consumer's perspective this is equivalent — `where ffmpeg` (the PR #336
8+
validation) and `findFFmpeg()` both pass.
9+
10+
inputs:
11+
release-url:
12+
description: >-
13+
URL of a BtbN/FFmpeg-Builds zip to install. Pinned to a specific
14+
autobuild tag so a new upstream nightly can't silently change encoder
15+
behavior under us. The asset filename embeds the git hash, so both
16+
the tag and the filename must be bumped together when upgrading.
17+
required: false
18+
default: https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-04-23-13-16/ffmpeg-N-124085-g162ad61486-win64-gpl.zip
19+
max-attempts:
20+
description: Max download attempts before failing.
21+
required: false
22+
default: "3"
23+
24+
runs:
25+
using: composite
26+
steps:
27+
- name: Install FFmpeg from BtbN/FFmpeg-Builds
28+
shell: pwsh
29+
run: |
30+
$ErrorActionPreference = 'Stop'
31+
32+
$url = '${{ inputs.release-url }}'
33+
$maxAttempts = [int]'${{ inputs.max-attempts }}'
34+
35+
$zip = Join-Path $env:RUNNER_TEMP 'ffmpeg.zip'
36+
$dir = Join-Path $env:RUNNER_TEMP 'ffmpeg'
37+
New-Item -ItemType Directory -Force -Path $dir | Out-Null
38+
39+
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
40+
Write-Host "--- Downloading ffmpeg from BtbN/FFmpeg-Builds (attempt $attempt/$maxAttempts) ---"
41+
try {
42+
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
43+
break
44+
} catch {
45+
Write-Warning "Download failed: $($_.Exception.Message)"
46+
if ($attempt -eq $maxAttempts) { throw }
47+
Start-Sleep -Seconds (10 * $attempt)
48+
}
49+
}
50+
51+
Write-Host "--- Extracting ffmpeg ---"
52+
Expand-Archive -Path $zip -DestinationPath $dir -Force
53+
54+
$bin = Get-ChildItem -Path $dir -Recurse -Filter 'ffmpeg.exe' | Select-Object -First 1
55+
if (-not $bin) { throw "ffmpeg.exe not found after extracting $url" }
56+
57+
Add-Content -Path $env:GITHUB_PATH -Value $bin.Directory.FullName
58+
59+
Write-Host "--- ffmpeg sanity check ---"
60+
& $bin.FullName -version | Select-Object -First 1

‎.github/workflows/ci.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ jobs:
4545
timeout-minutes: 10
4646
steps:
4747
- uses: actions/checkout@v4
48+
with:
49+
lfs: true
4850
- uses: oven-sh/setup-bun@v2
4951
- uses: actions/setup-node@v4
5052
with:
@@ -60,6 +62,8 @@ jobs:
6062
timeout-minutes: 5
6163
steps:
6264
- uses: actions/checkout@v4
65+
with:
66+
lfs: true
6367
- uses: oven-sh/setup-bun@v2
6468
- uses: actions/setup-node@v4
6569
with:
@@ -73,6 +77,8 @@ jobs:
7377
timeout-minutes: 5
7478
steps:
7579
- uses: actions/checkout@v4
80+
with:
81+
lfs: true
7682
- uses: oven-sh/setup-bun@v2
7783
- uses: actions/setup-node@v4
7884
with:
@@ -88,6 +94,8 @@ jobs:
8894
timeout-minutes: 10
8995
steps:
9096
- uses: actions/checkout@v4
97+
with:
98+
lfs: true
9199
- uses: oven-sh/setup-bun@v2
92100
- uses: actions/setup-node@v4
93101
with:
@@ -104,6 +112,8 @@ jobs:
104112
timeout-minutes: 10
105113
steps:
106114
- uses: actions/checkout@v4
115+
with:
116+
lfs: true
107117
- uses: oven-sh/setup-bun@v2
108118
- uses: actions/setup-node@v4
109119
with:
@@ -119,6 +129,8 @@ jobs:
119129
timeout-minutes: 10
120130
steps:
121131
- uses: actions/checkout@v4
132+
with:
133+
lfs: true
122134
- uses: oven-sh/setup-bun@v2
123135
- uses: actions/setup-node@v4
124136
with:

‎.github/workflows/windows-render.yml‎

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ jobs:
6060
uses: actions/checkout@v4
6161
with:
6262
ref: ${{ github.event.inputs.ref }}
63+
lfs: true
6364

6465
- name: Show platform info
6566
shell: pwsh
@@ -69,17 +70,62 @@ jobs:
6970
Write-Host "Runner: windows-latest"
7071
7172
# -----------------------------------------------------------------
72-
# Install FFmpeg via Chocolatey (mirrors the recommended path for
73-
# real Windows users — no Docker, no WSL).
73+
# Install FFmpeg via the shared composite action so the install logic
74+
# stays identical between this job and `test-windows` below. See
75+
# .github/actions/install-ffmpeg-windows for why we bypass Chocolatey.
7476
# -----------------------------------------------------------------
75-
- name: Install FFmpeg (Chocolatey)
77+
- name: Install FFmpeg
78+
uses: ./.github/actions/install-ffmpeg-windows
79+
80+
# -----------------------------------------------------------------
81+
# Verify FFmpeg feature inventory.
82+
#
83+
# The engine shells out to a fixed set of encoders (libx264 for MP4,
84+
# libx265 for HEVC, libvpx-vp9 for WebM, prores_ks for transparent
85+
# MOV, aac for audio), muxers (mp4 / mov / webm), and demuxers
86+
# (image2pipe for streaming RGBA frames, rawvideo for HDR PQ frames,
87+
# mov,mp4 for video frame extraction). Some of these are GPL-only,
88+
# so a future build swap could silently drop one and break a code
89+
# path the canary render doesn't exercise. Fail fast here instead.
90+
# -----------------------------------------------------------------
91+
- name: Verify FFmpeg feature inventory
7692
shell: pwsh
7793
run: |
78-
choco install ffmpeg -y --no-progress
79-
refreshenv
80-
Write-Host "--- ffmpeg sanity check ---"
81-
where.exe ffmpeg
82-
ffmpeg -version | Select-Object -First 1
94+
$ErrorActionPreference = 'Stop'
95+
96+
function Assert-FfmpegFeature {
97+
param(
98+
[Parameter(Mandatory)] [string] $Listing,
99+
[Parameter(Mandatory)] [string] $Name,
100+
[Parameter(Mandatory)] [string] $Kind
101+
)
102+
# `ffmpeg -encoders` etc. emit one feature per line as
103+
# `<flags> <name> <description>`, so a whitespace boundary on
104+
# each side is enough to disambiguate (e.g. `mov` vs `movflags`).
105+
$pattern = "(^|\s)$([regex]::Escape($Name))(\s|$)"
106+
if ($Listing -notmatch $pattern) {
107+
throw "Required FFmpeg $Kind '$Name' not present in this build"
108+
}
109+
Write-Host " ok: $Kind $Name"
110+
}
111+
112+
Write-Host "--- encoders ---"
113+
$encoders = (& ffmpeg -hide_banner -encoders 2>&1) -join "`n"
114+
foreach ($enc in @('libx264', 'libx265', 'libvpx-vp9', 'prores_ks', 'aac')) {
115+
Assert-FfmpegFeature -Listing $encoders -Name $enc -Kind 'encoder'
116+
}
117+
118+
Write-Host "--- muxers ---"
119+
$muxers = (& ffmpeg -hide_banner -muxers 2>&1) -join "`n"
120+
foreach ($mux in @('mp4', 'mov', 'webm')) {
121+
Assert-FfmpegFeature -Listing $muxers -Name $mux -Kind 'muxer'
122+
}
123+
124+
Write-Host "--- demuxers ---"
125+
$demuxers = (& ffmpeg -hide_banner -demuxers 2>&1) -join "`n"
126+
foreach ($dem in @('image2pipe', 'rawvideo', 'mov,mp4,m4a,3gp,3g2,mj2')) {
127+
Assert-FfmpegFeature -Listing $demuxers -Name $dem -Kind 'demuxer'
128+
}
83129
84130
- name: Install Bun
85131
uses: oven-sh/setup-bun@v2
@@ -177,6 +223,17 @@ jobs:
177223
uses: actions/checkout@v4
178224
with:
179225
ref: ${{ github.event.inputs.ref }}
226+
lfs: true
227+
228+
# -----------------------------------------------------------------
229+
# Install FFmpeg so vitest suites that gate on `HAS_FFMPEG`
230+
# (e.g. packages/engine videoFrameExtractor.test.ts) actually run on
231+
# Windows. Without it those suites `describe.skipIf(!HAS_FFMPEG)`
232+
# themselves silently and any Windows-specific regression in the
233+
# FFmpeg-driven code paths would not be caught here.
234+
# -----------------------------------------------------------------
235+
- name: Install FFmpeg
236+
uses: ./.github/actions/install-ffmpeg-windows
180237

181238
- name: Install Bun
182239
uses: oven-sh/setup-bun@v2
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Regenerate the sRGB → BT.2020 (HLG/PQ) LUT reference values pinned by
4+
packages/engine/src/utils/alphaBlit.test.ts.
5+
6+
This is a paste-helper for the *very rare* case the LUT genuinely needs to
7+
shift — e.g. a spec update changes one of the OETF constants, or we change
8+
the SDR-white reference level in the PQ branch. The reference values in
9+
alphaBlit.test.ts are byte-exact integers, and updating ~12 hand-edited
10+
literals (or all 256 of them, if the test grows) is exactly the kind of
11+
mechanical churn we want to keep out of the diff.
12+
13+
Usage:
14+
# Regenerate the probe table that lives in alphaBlit.test.ts (paste over
15+
# the SRGB_TO_HDR_REFERENCE literal):
16+
python3 packages/engine/scripts/generate-lut-reference.py --probes
17+
18+
# Dump the full 256-entry LUTs as JSON (for ad-hoc analysis or new tests):
19+
python3 packages/engine/scripts/generate-lut-reference.py
20+
21+
# Override the probe set:
22+
python3 packages/engine/scripts/generate-lut-reference.py --probes \
23+
--probe-indices 0,32,64,128,192,255
24+
25+
## How to use this when the LUT changes
26+
27+
1. Edit buildSrgbToHdrLut() in packages/engine/src/utils/alphaBlit.ts.
28+
2. Mirror the same edit here (constants, branch logic — keep them in sync).
29+
3. Run with --probes and paste the output over SRGB_TO_HDR_REFERENCE in
30+
alphaBlit.test.ts. Update the asymmetric-R/G/B and BT.2408-invariant
31+
tests by hand if those probe values shifted.
32+
4. Re-run `bun test src/utils/alphaBlit.test.ts` to confirm the engine LUT
33+
and the test-pinned values still agree.
34+
35+
## Why Python (not TS)?
36+
37+
A standalone script avoids dragging the engine's bun/Node/build environment
38+
into a one-off codegen flow, and matches the existing fixture-generation
39+
pattern at packages/producer/tests/hdr-regression/scripts/generate-hdr-photo-pq.py.
40+
Python's math.log / math.pow are libm-backed and produce IEEE-754-equivalent
41+
results to JS's Math.log / Math.pow for these inputs — see js_round_nonneg
42+
below for the one rounding quirk we have to match by hand.
43+
44+
## Drift contract
45+
46+
This file MIRRORS buildSrgbToHdrLut() in alphaBlit.ts. If the two diverge,
47+
this script silently emits wrong values. Any change to one MUST be reflected
48+
in the other; run the script and the test suite together to catch drift.
49+
"""
50+
51+
import argparse
52+
import json
53+
import math
54+
import sys
55+
from collections.abc import Iterable
56+
57+
# HLG OETF constants (Rec. 2100) — keep in sync with alphaBlit.ts
58+
HLG_A = 0.17883277
59+
HLG_B = 1 - 4 * HLG_A
60+
HLG_C = 0.5 - HLG_A * math.log(4 * HLG_A)
61+
62+
# PQ (SMPTE 2084) OETF constants — keep in sync with alphaBlit.ts
63+
PQ_M1 = 0.1593017578125
64+
PQ_M2 = 78.84375
65+
PQ_C1 = 0.8359375
66+
PQ_C2 = 18.8515625
67+
PQ_C3 = 18.6875
68+
PQ_MAX_NITS = 10000.0
69+
SDR_NITS = 203.0 # BT.2408 SDR-reference white in PQ
70+
71+
72+
def js_round_nonneg(x: float) -> int:
73+
"""
74+
Match JS Math.round semantics for non-negative inputs.
75+
76+
JS Math.round rounds half toward +∞ (Math.round(0.5) === 1). Python's
77+
built-in round() uses banker's rounding (round half to even, so
78+
round(0.5) === 0 and round(2.5) === 2), which would diverge from
79+
Math.round for the ~ten or so probe values that fall on a half-integer
80+
after signal*65535. This helper is only correct for x >= 0 — that's
81+
fine because signal is always in [0, 1] here.
82+
"""
83+
return int(math.floor(x + 0.5))
84+
85+
86+
def srgb_eotf(i: int) -> float:
87+
"""sRGB 8-bit code value → linear light in [0, 1] relative to SDR white."""
88+
v = i / 255
89+
return v / 12.92 if v <= 0.04045 else math.pow((v + 0.055) / 1.055, 2.4)
90+
91+
92+
def hlg_oetf(linear: float) -> float:
93+
if linear <= 1 / 12:
94+
return math.sqrt(3 * linear)
95+
return HLG_A * math.log(12 * linear - HLG_B) + HLG_C
96+
97+
98+
def pq_oetf(linear: float) -> float:
99+
# Place SDR-reference white at 203 nits within the 10000-nit PQ peak.
100+
# This is what reserves headroom for HDR highlights above SDR-white.
101+
lp = max(0.0, (linear * SDR_NITS) / PQ_MAX_NITS)
102+
lm1 = math.pow(lp, PQ_M1)
103+
return math.pow((PQ_C1 + PQ_C2 * lm1) / (1.0 + PQ_C3 * lm1), PQ_M2)
104+
105+
106+
def build_lut(transfer: str) -> list[int]:
107+
out: list[int] = []
108+
for i in range(256):
109+
linear = srgb_eotf(i)
110+
signal = hlg_oetf(linear) if transfer == "hlg" else pq_oetf(linear)
111+
out.append(min(65535, js_round_nonneg(signal * 65535)))
112+
return out
113+
114+
115+
# Mirror SRGB_TO_HDR_REFERENCE indices in alphaBlit.test.ts. Endpoints
116+
# (0, 1, 254, 255) catch off-by-one regressions; mid-range values (32, 64,
117+
# 96, 128, 160, 192, 224) sample the middle of both transfer curves.
118+
DEFAULT_PROBES: tuple[int, ...] = (0, 1, 10, 32, 64, 96, 128, 160, 192, 224, 254, 255)
119+
120+
121+
def emit_json(hlg: list[int], pq: list[int]) -> None:
122+
print(json.dumps({"size": 256, "hlg": hlg, "pq": pq}, indent=2))
123+
124+
125+
def emit_probes(hlg: list[int], pq: list[int], probes: Iterable[int]) -> None:
126+
# Output is paste-ready TS for SRGB_TO_HDR_REFERENCE in alphaBlit.test.ts.
127+
print("const SRGB_TO_HDR_REFERENCE: readonly SrgbHdrProbe[] = [")
128+
for i in probes:
129+
if not 0 <= i <= 255:
130+
raise ValueError(f"probe index {i} out of range [0, 255]")
131+
print(f" {{ srgb: {i}, hlg: {hlg[i]}, pq: {pq[i]} }},")
132+
print("];")
133+
134+
135+
def parse_indices(s: str) -> list[int]:
136+
return [int(x.strip()) for x in s.split(",") if x.strip()]
137+
138+
139+
def main() -> int:
140+
parser = argparse.ArgumentParser(
141+
description="Regenerate sRGB → BT.2020 (HLG/PQ) LUT reference values.",
142+
formatter_class=argparse.RawDescriptionHelpFormatter,
143+
)
144+
parser.add_argument(
145+
"--probes",
146+
action="store_true",
147+
help="Emit a TS snippet ready to paste over SRGB_TO_HDR_REFERENCE.",
148+
)
149+
parser.add_argument(
150+
"--probe-indices",
151+
type=parse_indices,
152+
default=list(DEFAULT_PROBES),
153+
help="Comma-separated probe indices (default mirrors alphaBlit.test.ts).",
154+
)
155+
args = parser.parse_args()
156+
157+
hlg = build_lut("hlg")
158+
pq = build_lut("pq")
159+
160+
if args.probes:
161+
emit_probes(hlg, pq, args.probe_indices)
162+
else:
163+
emit_json(hlg, pq)
164+
return 0
165+
166+
167+
if __name__ == "__main__":
168+
sys.exit(main())

‎packages/engine/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export {
152152
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
153153

154154
export {
155+
extractMediaMetadata,
155156
extractVideoMetadata,
156157
extractAudioMetadata,
157158
analyzeKeyframeIntervals,

0 commit comments

Comments
 (0)