chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
# Producer regression test fixtures
|
||||
|
||||
Each subdirectory under this folder is a **regression fixture** for the
|
||||
HTML-to-video pipeline. The harness at
|
||||
`packages/producer/src/regression-harness.ts` walks every subdirectory,
|
||||
runs the composition, and PSNR-compares the rendered output against a
|
||||
checked-in golden baseline.
|
||||
|
||||
## Fixture layout
|
||||
|
||||
```
|
||||
<fixture-name>/
|
||||
├── meta.json # name, tags, PSNR threshold, renderConfig
|
||||
├── src/
|
||||
│ ├── index.html # composition entry point
|
||||
│ └── assets/... # any locally-referenced media
|
||||
└── output/
|
||||
├── compiled.html # golden compiled HTML (validated as a snapshot)
|
||||
└── output.mp4 # golden rendered video
|
||||
```
|
||||
|
||||
`meta.json` is validated by `validateMetadata` in
|
||||
`src/regression-harness.ts`. The required fields are:
|
||||
|
||||
- `name` (string), `description` (string), `tags` (string[])
|
||||
- `minPsnr` (number, dB)
|
||||
- `maxFrameFailures` (integer)
|
||||
- `minAudioCorrelation` (0..1), `maxAudioLagWindows` (integer ≥1)
|
||||
- `renderConfig.fps` (integer like `30` or a rational string like `"30000/1001"`)
|
||||
|
||||
Optional `renderConfig` fields:
|
||||
|
||||
- `format` — `"mp4"` (default) or `"webm"`
|
||||
- `workers` — integer ≥ 1
|
||||
- `hdr` — boolean (default `false`)
|
||||
- `variables` — JSON object of render-time variable overrides
|
||||
- `chunkSize` — integer ≥ 1 (used by `--mode=distributed-simulated`)
|
||||
- `maxParallelChunks` — integer ≥ 1 (used by `--mode=distributed-simulated`)
|
||||
|
||||
## Generating / updating a baseline
|
||||
|
||||
**Always inside Docker.** Host Chrome / FFmpeg versions drift across
|
||||
distros, so a baseline captured on the host won't match the bytes CI
|
||||
renders.
|
||||
|
||||
```bash
|
||||
# From the repo root.
|
||||
docker build -t hyperframes-producer:test -f Dockerfile.test .
|
||||
|
||||
# Generate a baseline (single fixture):
|
||||
bun run --cwd packages/producer docker:test:update <fixture-name>
|
||||
|
||||
# Generate all baselines (rarely needed):
|
||||
bun run --cwd packages/producer docker:test:update
|
||||
```
|
||||
|
||||
The `--update` flag writes `output/compiled.html` and `output/output.mp4`
|
||||
from the current render. Without `--update`, the harness compares against
|
||||
those baselines.
|
||||
|
||||
## Running the harness locally
|
||||
|
||||
```bash
|
||||
# Run every fixture (parallel, in-process mode — the default).
|
||||
bun run --cwd packages/producer docker:test
|
||||
|
||||
# Run a single fixture:
|
||||
bun run --cwd packages/producer docker:test font-variant-numeric
|
||||
|
||||
# Run sequentially (lower memory):
|
||||
bun run --cwd packages/producer docker:test -- --sequential
|
||||
```
|
||||
|
||||
## Harness modes
|
||||
|
||||
`--mode=<value>` chooses which render path the harness exercises:
|
||||
|
||||
| Mode | What it calls | Use for |
|
||||
|---|---|---|
|
||||
| `in-process` (default) | `executeRenderJob` | Day-to-day baselines. This is the same path the `hyperframes render` CLI takes, and it is what produced every existing `output/output.mp4`. |
|
||||
| `distributed-simulated` | `plan()` → `renderChunk()` × N → `assemble()` from `@hyperframes/producer/distributed` | Validates the distributed pipeline against the in-process baseline. No Temporal or Lambda involvement — the controller and chunk worker are both this process. |
|
||||
|
||||
### `--mode=distributed-simulated`
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/producer docker:test -- --mode=distributed-simulated
|
||||
bun run --cwd packages/producer docker:test font-variant-numeric -- --mode=distributed-simulated
|
||||
```
|
||||
|
||||
The distributed pipeline cannot run every fixture. Fixtures that fail any
|
||||
of these gates are **skipped** with a clear log line (and counted as
|
||||
passing in the summary):
|
||||
|
||||
- `fps.den !== 1` — distributed mode is integer-fps only (no NTSC).
|
||||
- `fps.num ∉ {24, 30, 60}` — closed set per `DistributedRenderConfig`.
|
||||
- `format === "webm"` — `plan()` refuses webm.
|
||||
- `hdr === true` — distributed mode is SDR-only at v1.
|
||||
|
||||
Both modes use the fixture's authored `minPsnr` as the per-test
|
||||
threshold — distributed must clear the same quality bar in-process
|
||||
clears against the same frozen baseline. (Internal contract: distributed
|
||||
vs in-process renders of the same fixture should clear 50 dB PSNR
|
||||
against each other within the same Docker image. Against the frozen
|
||||
committed baseline, neither mode reaches that consistently due to
|
||||
shared encoder/JPEG-capture jitter — that's why the fixture's authored
|
||||
threshold gates here, not the 50 dB contract value.) An absolute 10 dB
|
||||
pathology floor catches fully-black-output regressions when a fixture
|
||||
authors a permissive threshold. A distributed failure at the fixture's
|
||||
own threshold means the distributed pipeline has drifted — file an
|
||||
issue rather than relaxing the fixture.
|
||||
|
||||
`--update` is incompatible with `--mode=distributed-simulated`: the
|
||||
in-process renderer is the source of truth for baselines, and the
|
||||
distributed mode's job is to verify the contract against the same
|
||||
baseline.
|
||||
|
||||
### Validating PR 4.1 (the harness mode itself)
|
||||
|
||||
The smallest fixtures (`font-variant-numeric`, `many-cuts`) are sufficient
|
||||
to verify the mode plumbing end to end:
|
||||
|
||||
```bash
|
||||
docker build -t hyperframes-producer:test -f Dockerfile.test .
|
||||
|
||||
# In-process: existing behavior, unchanged.
|
||||
bun run --cwd packages/producer docker:test font-variant-numeric
|
||||
bun run --cwd packages/producer docker:test many-cuts
|
||||
|
||||
# Distributed-simulated: same baselines, distributed pipeline.
|
||||
bun run --cwd packages/producer docker:test font-variant-numeric -- --mode=distributed-simulated
|
||||
bun run --cwd packages/producer docker:test many-cuts -- --mode=distributed-simulated
|
||||
```
|
||||
|
||||
Both modes must pass at each fixture's authored `minPsnr` against the
|
||||
existing baseline. If `--mode=distributed-simulated` fails where
|
||||
`--mode=in-process` passes, the distributed primitive has a regression —
|
||||
file an issue rather than relaxing the fixture's threshold.
|
||||
|
||||
## Distributed-only fixtures
|
||||
|
||||
Fixtures under `tests/distributed/<name>/` are authored specifically for
|
||||
the distributed pipeline. They follow the same `meta.json` schema as the
|
||||
top-level fixtures, but they always set `chunkSize` / `maxParallelChunks`
|
||||
so a `plan()` over the fixture produces N>1 chunks. Each fixture
|
||||
exercises one of:
|
||||
|
||||
- per-format chunk-boundary correctness (mp4 H.264, mp4 H.265, ProRes, png-sequence)
|
||||
- per-adapter chunk-seam state preservation (GSAP, Anime.js, Three.js, Lottie, CSS, WAAPI)
|
||||
|
||||
Each distributed fixture covers one or more equivalence axes — see the
|
||||
`meta.json` `description` field for what a given fixture is locking in.
|
||||
|
||||
### Fixture pattern (4.2 onward)
|
||||
|
||||
Each `tests/distributed/<name>/` fixture has the same structure as a
|
||||
top-level fixture (`meta.json` + `src/index.html` + `output/output.mp4`).
|
||||
Differences worth knowing:
|
||||
|
||||
- `renderConfig.chunkSize` is **required** — pick a value that yields
|
||||
N≥2 chunks for your fixture's frame count (e.g. 60 frames at
|
||||
`chunkSize: 15` produces N=4). Without this the fixture renders in a
|
||||
single chunk and never exercises the seam.
|
||||
- The fixture's ID on the CLI is just `<name>` (no `distributed/`
|
||||
prefix). `bun run --cwd packages/producer docker:test mp4-h264-sdr`
|
||||
works the same as for a top-level fixture.
|
||||
- The `distributed` tag is informational — it doesn't gate any tag-based
|
||||
filter today. Add it so the fixture is easy to find by tag.
|
||||
- The composition should stress *state continuity* across the chunk
|
||||
seams: an animation crossing a seam, a counter, a rotation. A
|
||||
fully-static composition would pass even if chunk-boundary state was
|
||||
broken.
|
||||
- Baselines must be generated inside Docker — see the section above.
|
||||
The baseline is rendered by the in-process renderer (the source of
|
||||
truth for golden output); `--mode=distributed-simulated` is validated
|
||||
against the same baseline.
|
||||
|
||||
## Tags
|
||||
|
||||
Common `tags` values control which fixtures the default `bun test`
|
||||
invocation runs. `--exclude-tags transparency` (the default for
|
||||
`bun test`) skips webm/png-sequence alpha fixtures that need a working
|
||||
chrome-headless-shell alpha pipeline.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "animejs-adapter",
|
||||
"description": "Regression guard for the anime.js v4 runtime adapter. Verifies that anime.createTimeline(), stagger(), and seek() render deterministically via the __hfAnime adapter bridge.",
|
||||
"tags": ["regression", "adapter"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 1,
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c2ee1b67cc6665a4b9a55da5e35a0b71c6b16acfe388c2104e01f5ffcf54807
|
||||
size 353004
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; background: #0B132B; }
|
||||
[data-composition-id="animejs-test"] { position: relative; overflow: hidden; font-family: monospace; }
|
||||
.box { width: 120px; height: 120px; position: absolute; border-radius: 12px; }
|
||||
.dot { width: 16px; height: 16px; border-radius: 50%; background: #1C2541; position: absolute; }
|
||||
#label { position: absolute; top: 40px; left: 60px; color: #BBFBFF; font-size: 28px; font-weight: bold; opacity: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="animejs-test" data-width="1920" data-height="1080" data-start="0" data-duration="6">
|
||||
|
||||
<div id="label">anime.js adapter test</div>
|
||||
|
||||
<!-- Boxes for transform tests -->
|
||||
<div class="box" id="box-a" style="left:200px;top:300px;background:#5409DA;opacity:0;"></div>
|
||||
<div class="box" id="box-b" style="left:400px;top:300px;background:#FF6D00;opacity:0;"></div>
|
||||
<div class="box" id="box-c" style="left:600px;top:300px;background:#4E71FF;opacity:0;"></div>
|
||||
|
||||
<!-- Dot grid for stagger test -->
|
||||
<div id="dot-container" style="position:absolute;right:100px;top:200px;"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__hfAnime = window.__hfAnime || [];
|
||||
|
||||
// Generate dot grid
|
||||
const container = document.getElementById('dot-container');
|
||||
for (let row = 0; row < 6; row++) {
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'dot';
|
||||
dot.style.left = (col * 40) + 'px';
|
||||
dot.style.top = (row * 40) + 'px';
|
||||
container.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline 1: label + box entrances (0–3s)
|
||||
const tl1 = anime.createTimeline({ autoplay: false });
|
||||
tl1
|
||||
.add('#label', { opacity: [0, 1], translateY: [-30, 0], duration: 400, ease: 'out(4)' }, 200)
|
||||
.add('#box-a', { opacity: [0, 1], scale: [0, 1], rotate: [-90, 0], duration: 500, ease: 'out(5)' }, 500)
|
||||
.add('#box-b', { opacity: [0, 1], scale: [0, 1], rotate: [90, 0], duration: 500, ease: 'out(5)' }, 700)
|
||||
.add('#box-c', { opacity: [0, 1], scale: [0, 1], translateY: [80, 0], duration: 500, ease: 'out(3)' }, 900);
|
||||
|
||||
// Timeline 2: CSS transforms on boxes (2–5s)
|
||||
const tl2 = anime.createTimeline({ autoplay: false });
|
||||
tl2
|
||||
.add('#box-a', { rotate: 360, duration: 2000, ease: 'inOut(2)' }, 0)
|
||||
.add('#box-b', { skewX: 25, scale: 1.3, duration: 1500, ease: 'inOut(3)' }, 200)
|
||||
.add('#box-c', { rotate: -180, translateX: 100, duration: 1800, ease: 'inOut(2)' }, 400);
|
||||
|
||||
// Timeline 3: stagger wave on dots (3–6s)
|
||||
const tl3 = anime.createTimeline({ autoplay: false });
|
||||
tl3.add('.dot', {
|
||||
backgroundColor: '#5409DA',
|
||||
scale: [1, 2.5, 1],
|
||||
duration: 600,
|
||||
ease: 'out(2)',
|
||||
delay: anime.stagger(30, { grid: [8, 6], from: 'center' }),
|
||||
}, 0);
|
||||
tl3.add('.dot', {
|
||||
backgroundColor: '#FF6D00',
|
||||
scale: [1, 2, 1],
|
||||
duration: 500,
|
||||
ease: 'in(2)',
|
||||
delay: anime.stagger(25, { grid: [8, 6], from: 'edges' }),
|
||||
}, 1500);
|
||||
|
||||
// Scene mapping for the adapter
|
||||
const scenes = [
|
||||
{ tl: tl1, start: 0, end: 3 },
|
||||
{ tl: tl2, start: 2, end: 5 },
|
||||
{ tl: tl3, start: 3, end: 6 },
|
||||
];
|
||||
|
||||
window.__hfAnime = [{
|
||||
seek: function(globalTimeMs) {
|
||||
const t = globalTimeMs / 1000;
|
||||
for (const s of scenes) {
|
||||
if (t >= s.start && t < s.end) {
|
||||
s.tl.seek((t - s.start) * 1000);
|
||||
} else if (t >= s.end) {
|
||||
s.tl.seek((s.end - s.start) * 1000);
|
||||
} else {
|
||||
s.tl.seek(0);
|
||||
}
|
||||
}
|
||||
},
|
||||
pause: function() { for (const s of scenes) s.tl.pause(); },
|
||||
play: function() { for (const s of scenes) s.tl.play(); },
|
||||
}];
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines['animejs-test'] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Audio Mux Stream Parity",
|
||||
"description": "Verifies video and audio stream durations match after mux. Catches truncation regressions like the ffmpeg -shortest bug (#1648) where one stream gets silently cut short.",
|
||||
"tags": ["audio", "mux", "parity", "regression"],
|
||||
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7c7b88b0f40c29dcfa13faff6dc16bea06170d8b6b8a686fa1c2b2e53d82498a
|
||||
size 321645
|
||||
Binary file not shown.
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Audio mux parity</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { overflow: hidden; background: #111; }
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #fff;
|
||||
}
|
||||
.scene h1 { font-size: 80px; opacity: 0; }
|
||||
#scene-1 { background: #1a1a2e; }
|
||||
#scene-2 { background: #16213e; }
|
||||
#scene-3 { background: #0f3460; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="audio-mux-parity"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-fps="30"
|
||||
>
|
||||
<!-- Three audio tracks covering different time windows -->
|
||||
<audio id="vo-1" src="assets/tone.wav" data-start="0" data-duration="3" data-volume="1"></audio>
|
||||
<audio id="vo-2" src="assets/tone.wav" data-start="3.5" data-duration="3" data-volume="1"></audio>
|
||||
<audio id="vo-3" src="assets/tone.wav" data-start="7" data-duration="3" data-volume="1"></audio>
|
||||
|
||||
<section id="scene-1" class="scene clip" data-start="0" data-duration="3.5" data-track-index="1">
|
||||
<h1 id="t1">One</h1>
|
||||
</section>
|
||||
<section id="scene-2" class="scene clip" data-start="3.5" data-duration="3.5" data-track-index="1">
|
||||
<h1 id="t2">Two</h1>
|
||||
</section>
|
||||
<section id="scene-3" class="scene clip" data-start="7" data-duration="3" data-track-index="1">
|
||||
<h1 id="t3">Three</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#t1", { opacity: 1, duration: 0.5 }, 0.2);
|
||||
tl.to("#t1", { opacity: 0, duration: 0.3 }, 3);
|
||||
tl.to("#t2", { opacity: 1, duration: 0.5 }, 3.7);
|
||||
tl.to("#t2", { opacity: 0, duration: 0.3 }, 6.5);
|
||||
tl.to("#t3", { opacity: 1, duration: 0.5 }, 7.2);
|
||||
tl.to("#t3", { opacity: 0, duration: 0.3 }, 9.5);
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["audio-mux-parity"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Chat Interface",
|
||||
"description": "Tests chat interface composition with animations",
|
||||
"tags": ["chat", "ui", "animations"],
|
||||
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d002c0d9d9851ec6abce1c662ed7a0a7b78b993ec778650a9aca4a437edb4080
|
||||
size 887615
|
||||
@@ -0,0 +1,85 @@
|
||||
# HyperFrame Schema Compliance Review
|
||||
|
||||
## Executive Summary
|
||||
|
||||
- Total files reviewed: 2
|
||||
- Critical issues: 1
|
||||
- Overall compliance status: NEEDS_WORK
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### Empty Tweens for Duration
|
||||
|
||||
- **File**: index.html:26
|
||||
- **File**: compositions/typography.html:224
|
||||
- **Violation**: `masterTL.to({}, { duration: 15 });` and `tl.to({}, { duration: 15 });`
|
||||
- **Schema Rule**: "NEVER create empty tweens like `tl.to({}, { duration: N })` just to set duration — use `data-duration` instead"
|
||||
- **Impact**: This is a direct violation of the schema's duration management rules. While it might work in some environments, it bypasses the framework's declarative duration system and can lead to rendering inconsistencies or failures in the server-side renderer.
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
- [x] All compositions have `data-width` and `data-height` attributes
|
||||
- [x] All timelines are finite with duration > 0
|
||||
- [x] All compositions registered in `window.__timelines`
|
||||
- [x] No use of `Math.random()`, `Date.now()`, or non-deterministic code
|
||||
- [x] Primitive clips have required data attributes (`id`, `data-start`, `data-track-index`)
|
||||
- [x] `data-duration` specified for all `<img>` clips (N/A - no images)
|
||||
- [x] No manual media playback control (`video.play()`, `audio.pause()`, etc.)
|
||||
- [x] No manual clip mounting/unmounting in scripts
|
||||
- [x] Relative timing references are valid (N/A - no relative timing used)
|
||||
- [x] Clips on same track don't overlap in time
|
||||
- [x] Reusable compositions in separate HTML files
|
||||
- [x] Composition files use `<template>` tags
|
||||
- [x] External compositions loaded via `data-composition-src`
|
||||
- [x] All script-animated content wrapped in compositions
|
||||
- [x] No infinite or zero-duration timelines
|
||||
|
||||
### index.html
|
||||
|
||||
**Status**: HAS_ISSUES
|
||||
|
||||
**Issues Found**:
|
||||
|
||||
- **Line 26**: Uses empty tween `masterTL.to({}, { duration: 15 });` to set duration. Should use `data-duration="15"` on the `#main` div (line 11) instead.
|
||||
|
||||
### compositions/typography.html
|
||||
|
||||
**Status**: HAS_ISSUES
|
||||
|
||||
**Issues Found**:
|
||||
|
||||
- **Line 224**: Uses empty tween `tl.to({}, { duration: 15 });` to set duration. Should use `data-duration="15"` on the composition root div (line 2) instead.
|
||||
- **Line 156**: `const sceneEl = document.querySelector(scene.id);` - While not a strict violation, it's better to scope queries to the composition root to avoid conflicts if multiple instances of the same composition are loaded.
|
||||
|
||||
## Recommended Fixes
|
||||
|
||||
### Fix 1: Declarative Duration in index.html
|
||||
|
||||
Replace the empty tween with `data-duration` on the element.
|
||||
|
||||
```html
|
||||
<!-- Change line 11 -->
|
||||
<div
|
||||
id="main"
|
||||
data-composition-id="main-comp"
|
||||
data-width="1080"
|
||||
data-height="1920"
|
||||
data-start="0"
|
||||
data-duration="15"
|
||||
>
|
||||
<!-- Remove line 26 -->
|
||||
<!-- masterTL.to({}, { duration: 15 }); -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Fix 2: Declarative Duration in typography.html
|
||||
|
||||
Replace the empty tween with `data-duration` on the element.
|
||||
|
||||
```html
|
||||
<!-- Change line 2 -->
|
||||
<div data-composition-id="typography" data-width="1080" data-height="1920" data-duration="15">
|
||||
<!-- Remove line 224 -->
|
||||
<!-- tl.to({}, { duration: 15 }); -->
|
||||
</div>
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kinetic Typography Sequence</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main"
|
||||
data-composition-id="main-comp"
|
||||
data-width="1080"
|
||||
data-height="1920"
|
||||
data-start="0"
|
||||
data-duration="15"
|
||||
>
|
||||
<!-- Audio Asset -->
|
||||
<audio
|
||||
id="bg-music"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/f87f97d4_0c49f6fa95224cc3bd4f5ea3a0d937c5.wav"
|
||||
data-start="0"
|
||||
data-duration="15"
|
||||
data-track-index="0"
|
||||
></audio>
|
||||
|
||||
<!-- Typography Composition -->
|
||||
<div
|
||||
id="typography-layer"
|
||||
data-composition-id="typography"
|
||||
data-composition-src="compositions/typography.html"
|
||||
data-start="0"
|
||||
data-track-index="1"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const masterTL = gsap.timeline({ paused: true });
|
||||
window.__timelines["main-comp"] = masterTL;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: #000;
|
||||
font-family: "Montserrat", sans-serif;
|
||||
}
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@500;900&display=swap");
|
||||
|
||||
#main {
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "chrome-screenshot-bottom-edge",
|
||||
"description": "Regression guard for screenshot capture leaking page background into the bottom of a viewport-sized MP4. The composition paints a red page background below full-frame content so any bottom-edge capture gap collapses PSNR.",
|
||||
"tags": ["regression", "screenshot", "edge"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 1,
|
||||
"renderConfig": {
|
||||
"fps": 12,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ffec9cda98f24e06e74f65440ef13a3889ce110bc2b04394fd93f8afb61c8893
|
||||
size 43189
|
||||
@@ -0,0 +1,76 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Chrome Screenshot Bottom Edge</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: rgb(255, 24, 0);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #084c61 0%, #0b6e4f 100%);
|
||||
}
|
||||
|
||||
.clip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#label {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
bottom: 188px;
|
||||
color: white;
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#bottom-proof {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 160px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
white 0,
|
||||
white 64px,
|
||||
#111 64px,
|
||||
#111 128px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-start="0"
|
||||
data-duration="1"
|
||||
>
|
||||
<section id="proof" class="clip" data-start="0" data-duration="1" data-track-index="1">
|
||||
<div id="label">bottom edge must stay inside the composition</div>
|
||||
<div id="bottom-proof"></div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.main = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "css-import-scoping",
|
||||
"description": "Verifies @import url() rules survive CSS composition scoping. Regression test for scopeCssToComposition corrupting @import into invalid @[data-composition-id] import url().",
|
||||
"tags": ["regression", "css-scoping"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 0,
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4234211da34fdc26ccefc72ea2dd859b24b776fd980ae51a109783397dbc2097
|
||||
size 38945
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1080, height=1920">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body { margin: 0; background: #000; width: 1080px; height: 1920px; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="root" data-width="1080" data-height="1920" data-duration="5"
|
||||
style="position:absolute;width:1080px;height:1920px;">
|
||||
<div data-composition-id="overlay"
|
||||
data-composition-src="compositions/overlay.html"
|
||||
data-start="0" data-track-index="1"
|
||||
data-width="1080" data-height="1920"></div>
|
||||
</div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set({}, {}, 5);
|
||||
window.__timelines["root"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "css-spinner-render-compat",
|
||||
"description": "Regression test for pure CSS keyframe animations. The spinner arc must continue advancing under deterministic seek-driven renders.",
|
||||
"tags": ["regression", "render-compat"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 1,
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d49bbe3e2b472d48c2c88ab5f10fd9111147cf77536149def51fef5232b10bb7
|
||||
size 227604
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; background: #0a0a0f; width: 1920px; height: 1080px; color: #e0e0e0; font-family: system-ui, sans-serif; }
|
||||
.stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 60px; }
|
||||
.spinner { width: 200px; height: 200px; border: 16px solid rgba(255,255,255,0.12); border-top-color: #5eead4; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
.label { font-size: 40px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="css-spinner" data-width="1920" data-height="1080" data-start="0" data-duration="5" data-no-timeline>
|
||||
<div class="stage clip" data-start="0" data-duration="5">
|
||||
<div class="spinner"></div>
|
||||
<div class="label">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "d3-adapter",
|
||||
"description": "Regression guard for the D3.js runtime adapter. Verifies that D3 transitions with an SVG bar chart register on window.__hfD3 and render deterministically via the adapter bridge.",
|
||||
"tags": ["regression", "adapter"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 1,
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0693b24a6fad50609f05c4b5ea103a4e533113703725e64d9244435a1b0dbdcc
|
||||
size 250658
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; background: #111827; }
|
||||
[data-composition-id="d3-test"] { position: relative; overflow: hidden; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
#chart-container {
|
||||
position: absolute; top: 0; left: 0; width: 1920px; height: 1080px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#label {
|
||||
position: absolute; top: 36px; left: 48px; z-index: 10;
|
||||
color: #f9fafb; font-size: 32px; font-weight: 700;
|
||||
background: rgba(17, 24, 39, 0.9); padding: 12px 24px; border-radius: 10px;
|
||||
border: 2px solid #8b5cf6; opacity: 0;
|
||||
}
|
||||
#subtitle {
|
||||
position: absolute; top: 100px; left: 48px; z-index: 10;
|
||||
color: #9ca3af; font-size: 20px;
|
||||
opacity: 0;
|
||||
}
|
||||
.axis text { fill: #9ca3af; font-size: 14px; }
|
||||
.axis line, .axis path { stroke: #374151; }
|
||||
.grid line { stroke: #1f2937; stroke-dasharray: 2,4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="d3-test" data-width="1920" data-height="1080" data-start="0" data-duration="4">
|
||||
|
||||
<div id="chart-container"></div>
|
||||
<div id="label">D3 adapter test</div>
|
||||
<div id="subtitle">Animated bar chart with transitions</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__hfD3 = window.__hfD3 || [];
|
||||
|
||||
// Chart dimensions
|
||||
var margin = { top: 160, right: 120, bottom: 100, left: 120 };
|
||||
var width = 1920 - margin.left - margin.right;
|
||||
var height = 1080 - margin.top - margin.bottom;
|
||||
|
||||
// Data
|
||||
var data = [
|
||||
{ label: 'React', value: 92, color: '#61dafb' },
|
||||
{ label: 'Vue', value: 78, color: '#42b883' },
|
||||
{ label: 'Svelte', value: 65, color: '#ff3e00' },
|
||||
{ label: 'Angular', value: 58, color: '#dd0031' },
|
||||
{ label: 'Solid', value: 45, color: '#4f88c6' },
|
||||
{ label: 'Qwik', value: 38, color: '#18b6f6' },
|
||||
{ label: 'Astro', value: 72, color: '#bc52ee' },
|
||||
{ label: 'Next', value: 85, color: '#fff' },
|
||||
];
|
||||
|
||||
// Create SVG
|
||||
var svg = d3.select('#chart-container')
|
||||
.append('svg')
|
||||
.attr('width', 1920)
|
||||
.attr('height', 1080)
|
||||
.append('g')
|
||||
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
|
||||
|
||||
// Gradient definitions
|
||||
var defs = svg.append('defs');
|
||||
data.forEach(function(d, i) {
|
||||
var grad = defs.append('linearGradient')
|
||||
.attr('id', 'grad-' + i)
|
||||
.attr('x1', '0%').attr('y1', '100%')
|
||||
.attr('x2', '0%').attr('y2', '0%');
|
||||
grad.append('stop').attr('offset', '0%').attr('stop-color', d.color).attr('stop-opacity', 0.6);
|
||||
grad.append('stop').attr('offset', '100%').attr('stop-color', d.color).attr('stop-opacity', 1);
|
||||
});
|
||||
|
||||
// Scales
|
||||
var x = d3.scaleBand()
|
||||
.domain(data.map(function(d) { return d.label; }))
|
||||
.range([0, width])
|
||||
.padding(0.25);
|
||||
|
||||
var y = d3.scaleLinear()
|
||||
.domain([0, 100])
|
||||
.range([height, 0]);
|
||||
|
||||
// Grid lines
|
||||
svg.append('g')
|
||||
.attr('class', 'grid')
|
||||
.selectAll('line')
|
||||
.data(y.ticks(5))
|
||||
.enter().append('line')
|
||||
.attr('x1', 0).attr('x2', width)
|
||||
.attr('y1', function(d) { return y(d); })
|
||||
.attr('y2', function(d) { return y(d); });
|
||||
|
||||
// X axis
|
||||
svg.append('g')
|
||||
.attr('class', 'axis')
|
||||
.attr('transform', 'translate(0,' + height + ')')
|
||||
.call(d3.axisBottom(x).tickSize(0))
|
||||
.selectAll('text')
|
||||
.style('font-size', '18px')
|
||||
.style('fill', '#d1d5db');
|
||||
|
||||
// Y axis
|
||||
svg.append('g')
|
||||
.attr('class', 'axis')
|
||||
.call(d3.axisLeft(y).ticks(5).tickSize(-8))
|
||||
.selectAll('text')
|
||||
.style('font-size', '16px');
|
||||
|
||||
// Bars — start at zero height, transition to final values
|
||||
var bars = svg.selectAll('.bar')
|
||||
.data(data)
|
||||
.enter().append('rect')
|
||||
.attr('class', 'bar')
|
||||
.attr('x', function(d) { return x(d.label); })
|
||||
.attr('y', height)
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('height', 0)
|
||||
.attr('rx', 6)
|
||||
.attr('fill', function(d, i) { return 'url(#grad-' + i + ')'; });
|
||||
|
||||
// Value labels — start hidden
|
||||
var labels = svg.selectAll('.value-label')
|
||||
.data(data)
|
||||
.enter().append('text')
|
||||
.attr('class', 'value-label')
|
||||
.attr('x', function(d) { return x(d.label) + x.bandwidth() / 2; })
|
||||
.attr('y', height)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', '#f9fafb')
|
||||
.attr('font-size', '20px')
|
||||
.attr('font-weight', '600')
|
||||
.attr('opacity', 0)
|
||||
.text(function(d) { return d.value; });
|
||||
|
||||
// D3 transition: bars grow from bottom to their value
|
||||
var barTransition = bars.transition()
|
||||
.duration(1200)
|
||||
.delay(function(d, i) { return i * 80; })
|
||||
.attr('y', function(d) { return y(d.value); })
|
||||
.attr('height', function(d) { return height - y(d.value); })
|
||||
.ease(d3.easeCubicOut);
|
||||
|
||||
// D3 transition: value labels appear
|
||||
var labelTransition = labels.transition()
|
||||
.duration(600)
|
||||
.delay(function(d, i) { return 600 + i * 80; })
|
||||
.attr('y', function(d) { return y(d.value) - 12; })
|
||||
.attr('opacity', 1)
|
||||
.ease(d3.easeCubicOut);
|
||||
|
||||
// Register transitions for the adapter
|
||||
// D3 transitions have .end() returning a Promise
|
||||
window.__hfD3 = [barTransition, labelTransition];
|
||||
|
||||
// GSAP timeline for overlay labels
|
||||
var compId = 'd3-test';
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
tl.to('#label', { opacity: 1, duration: 0.5, ease: 'power3.out' }, 0.2)
|
||||
.from('#label', { y: -20, duration: 0.5, ease: 'power3.out' }, 0.2)
|
||||
.to('#subtitle', { opacity: 1, duration: 0.4, ease: 'power2.out' }, 0.6)
|
||||
.from('#subtitle', { x: -15, duration: 0.4, ease: 'power2.out' }, 0.6);
|
||||
|
||||
window.__timelines[compId] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Smoke test for the WebM (VP9) distributed concat-copy path.
|
||||
*
|
||||
* Asserts that `buildEncoderArgs(..., { codec: "vp9",
|
||||
* lockGopForChunkConcat: true, gopSize: N })` produces VP9 chunk files
|
||||
* that `ffmpeg -f concat -c copy` can stitch into a single playable
|
||||
* WebM.
|
||||
*
|
||||
* Uses direct ffmpeg invocation instead of `plan() / renderChunk() /
|
||||
* assemble()` so the contract this test pins is exactly the encoder-arg
|
||||
* surface — independent of plan-time validation, file servers, browser
|
||||
* capture, and the rest of the distributed-pipeline stack.
|
||||
*
|
||||
* Each chunk + concat-copy + ffprobe verification surfaces its failure
|
||||
* fingerprint in the error message so a regression-driven concat-copy
|
||||
* failure (alt-ref reaching across a seam, libvpx bumping its default
|
||||
* cpu-used, etc.) can be diagnosed without re-running locally.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildEncoderArgs } from "@hyperframes/engine";
|
||||
|
||||
const FPS = 30;
|
||||
const TOTAL_FRAMES = 60;
|
||||
const CHUNK_SIZE = 15;
|
||||
const CHUNK_COUNT = TOTAL_FRAMES / CHUNK_SIZE; // 4
|
||||
const WIDTH = 320;
|
||||
const HEIGHT = 240;
|
||||
|
||||
let runRoot: string;
|
||||
let framesDir: string;
|
||||
let chunkDir: string;
|
||||
let concatListPath: string;
|
||||
let outputPath: string;
|
||||
let frameGenStderr = "";
|
||||
|
||||
interface FfmpegResult {
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
function runFfmpegSync(args: string[]): FfmpegResult {
|
||||
const result = spawnSync("ffmpeg", args, { encoding: "utf8" });
|
||||
return {
|
||||
exitCode: result.status,
|
||||
stderr: result.stderr ?? "",
|
||||
stdout: result.stdout ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function runFfprobeSync(args: string[]): FfmpegResult {
|
||||
const result = spawnSync("ffprobe", args, { encoding: "utf8" });
|
||||
return {
|
||||
exitCode: result.status,
|
||||
stderr: result.stderr ?? "",
|
||||
stdout: result.stdout ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-webm-concat-smoke-"));
|
||||
framesDir = join(runRoot, "frames");
|
||||
chunkDir = join(runRoot, "chunks");
|
||||
mkdirSync(framesDir, { recursive: true });
|
||||
mkdirSync(chunkDir, { recursive: true });
|
||||
concatListPath = join(runRoot, "concat-list.txt");
|
||||
outputPath = join(runRoot, "output.webm");
|
||||
|
||||
// Generate 60 PNG frames using lavfi testsrc2 (animated counter / color
|
||||
// bars — easy to eyeball for seam errors if a human inspects the output).
|
||||
// Each frame is a real image; we use a frame sequence rather than a single
|
||||
// mp4 source so the per-chunk encode is a pure image2 → VP9 pass with no
|
||||
// intermediate decode.
|
||||
const frameGen = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`testsrc2=s=${WIDTH}x${HEIGHT}:r=${FPS}:d=${TOTAL_FRAMES / FPS}`,
|
||||
"-frames:v",
|
||||
String(TOTAL_FRAMES),
|
||||
join(framesDir, "frame_%04d.png"),
|
||||
]);
|
||||
frameGenStderr = frameGen.stderr;
|
||||
if (frameGen.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[smoke setup] frame generation failed (exit ${frameGen.exitCode}): ${frameGen.stderr.slice(-400)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("webm VP9 concat-copy smoke", () => {
|
||||
it("generates 60 source PNG frames", () => {
|
||||
// Sanity check — if testsrc2 frame generation broke, downstream
|
||||
// failures would be miscategorized as concat-copy errors.
|
||||
const firstFrame = join(framesDir, "frame_0001.png");
|
||||
const lastFrame = join(framesDir, `frame_${String(TOTAL_FRAMES).padStart(4, "0")}.png`);
|
||||
expect(existsSync(firstFrame)).toBe(true);
|
||||
expect(existsSync(lastFrame)).toBe(true);
|
||||
expect(frameGenStderr).toBeDefined();
|
||||
});
|
||||
|
||||
it("encodes 4 VP9 chunks with closed-GOP args from buildEncoderArgs", () => {
|
||||
// The contract this test asserts: buildEncoderArgs with
|
||||
// lockGopForChunkConcat=true + codec=vp9 + gopSize=chunkSize produces
|
||||
// VP9 chunks whose first frame is an independently-decodable keyframe
|
||||
// and whose alt-ref behavior doesn't reach back across chunk seams.
|
||||
//
|
||||
// Use the exact args buildEncoderArgs returns. We only swap the input
|
||||
// args (image2 input range per chunk) — the encoder args (everything
|
||||
// after `-r <fps>`) are byte-identical to what a real renderChunk()
|
||||
// call would invoke.
|
||||
for (let chunkIdx = 0; chunkIdx < CHUNK_COUNT; chunkIdx++) {
|
||||
const startNumber = chunkIdx * CHUNK_SIZE + 1; // image2 frame numbers are 1-based
|
||||
const chunkPath = join(chunkDir, `chunk_${String(chunkIdx).padStart(4, "0")}.webm`);
|
||||
const inputArgs = [
|
||||
"-framerate",
|
||||
String(FPS),
|
||||
"-start_number",
|
||||
String(startNumber),
|
||||
"-i",
|
||||
join(framesDir, "frame_%04d.png"),
|
||||
"-frames:v",
|
||||
String(CHUNK_SIZE),
|
||||
];
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
fps: { num: FPS, den: 1 },
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
codec: "vp9",
|
||||
preset: "good",
|
||||
quality: 32,
|
||||
pixelFormat: "yuv420p",
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: CHUNK_SIZE,
|
||||
},
|
||||
inputArgs,
|
||||
chunkPath,
|
||||
);
|
||||
const result = runFfmpegSync(["-hide_banner", "-loglevel", "error", ...args]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[smoke chunk ${chunkIdx}] VP9 encode failed (exit ${result.exitCode}):\n` +
|
||||
`args: ${JSON.stringify(args)}\n` +
|
||||
`stderr: ${result.stderr.slice(-1000)}`,
|
||||
);
|
||||
}
|
||||
expect(existsSync(chunkPath)).toBe(true);
|
||||
expect(statSync(chunkPath).size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("concat-copies the 4 chunks into a single WebM", () => {
|
||||
const lines: string[] = [];
|
||||
for (let chunkIdx = 0; chunkIdx < CHUNK_COUNT; chunkIdx++) {
|
||||
const chunkPath = join(chunkDir, `chunk_${String(chunkIdx).padStart(4, "0")}.webm`);
|
||||
lines.push(`file '${chunkPath.replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
writeFileSync(concatListPath, `${lines.join("\n")}\n`, "utf-8");
|
||||
|
||||
const result = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concatListPath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
// Surface ffmpeg's full stderr in the assertion message — a broken
|
||||
// concat-copy fails with something specific ("Non-monotonous DTS",
|
||||
// "missing keyframe at chunk 2", matroska/webm cluster errors) that
|
||||
// the message above wouldn't disambiguate.
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[smoke concat-copy] failed (exit ${result.exitCode}). ` +
|
||||
`Failure fingerprint: ${result.stderr.slice(-1000)}`,
|
||||
);
|
||||
}
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
expect(statSync(outputPath).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ffprobe -show_streams reports a single playable VP9 stream", () => {
|
||||
// First verification — the output file is structurally a valid WebM
|
||||
// with one video stream encoded as VP9. A broken concat-copy can
|
||||
// produce a file whose container parses but whose stream metadata is
|
||||
// corrupted (no codec ID, zero duration, broken pixel format).
|
||||
const result = runFfprobeSync([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name,width,height,pix_fmt,r_frame_rate",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1",
|
||||
outputPath,
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[smoke ffprobe] -show_streams failed (exit ${result.exitCode}). ` +
|
||||
`This means concat-copy produced a structurally broken WebM. ` +
|
||||
`Failure fingerprint: ${result.stderr.slice(-1000)}`,
|
||||
);
|
||||
}
|
||||
expect(result.stdout).toMatch(/codec_name=vp9/);
|
||||
expect(result.stdout).toMatch(new RegExp(`width=${WIDTH}`));
|
||||
expect(result.stdout).toMatch(new RegExp(`height=${HEIGHT}`));
|
||||
});
|
||||
|
||||
it("ffmpeg -i ... -f null - decodes the concat'd WebM without errors", () => {
|
||||
// Second verification — the bitstream actually decodes end-to-end.
|
||||
// A WebM whose containers parse but whose VP9 frames reference
|
||||
// non-existent alt-ref frames (because alt-ref crossed a chunk
|
||||
// seam) will fail here with "Reference frame not found" or
|
||||
// "Invalid frame" errors.
|
||||
const result = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
outputPath,
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]);
|
||||
if (result.exitCode !== 0 || result.stderr.length > 0) {
|
||||
throw new Error(
|
||||
`[smoke decode-test] ffmpeg -f null - reported decode errors ` +
|
||||
`(exit ${result.exitCode}). This means concat-copy seams produce ` +
|
||||
`invalid VP9 references ` +
|
||||
`Failure fingerprint: ${result.stderr.slice(-1000) || "(no stderr; check exit code)"}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ffprobe -count_frames matches the sum of chunk frames", () => {
|
||||
// Third verification — playable frame count equals what we encoded.
|
||||
// A broken concat-copy can produce a file that decodes "without
|
||||
// errors" up to the first bad seam and then silently truncates,
|
||||
// leaving fewer frames than expected.
|
||||
const result = runFfprobeSync([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-count_frames",
|
||||
"-show_entries",
|
||||
"stream=nb_read_frames",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
outputPath,
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[smoke ffprobe count_frames] failed (exit ${result.exitCode}): ` +
|
||||
`${result.stderr.slice(-1000)}`,
|
||||
);
|
||||
}
|
||||
const nbFrames = Number.parseInt(result.stdout.trim(), 10);
|
||||
if (!Number.isFinite(nbFrames) || nbFrames !== TOTAL_FRAMES) {
|
||||
throw new Error(
|
||||
`[smoke ffprobe count_frames] expected ${TOTAL_FRAMES} frames, got ${result.stdout.trim()} ` +
|
||||
`— concat-copy dropped frames at one or more chunk seams.`,
|
||||
);
|
||||
}
|
||||
expect(nbFrames).toBe(TOTAL_FRAMES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webm VP9 concat-copy smoke (yuva420p alpha)", () => {
|
||||
// The wired-up distributed webm path uses yuva420p. This block proves
|
||||
// (a) the closed-GOP args + alpha pixel format don't break concat-copy
|
||||
// at the bitstream level, and (b) the alpha plane round-trips with
|
||||
// real spatial content — catching the failure mode where the encoder
|
||||
// accepted yuva420p input but dropped the alpha sub-stream silently.
|
||||
// The source frames carry a per-pixel alpha gradient so the encoder
|
||||
// cannot treat the alpha plane as uniform/redundant and drop it.
|
||||
it("encode + concat-copy + decode round-trip works for yuva420p", () => {
|
||||
const alphaRoot = mkdtempSync(join(tmpdir(), "hf-webm-concat-smoke-alpha-"));
|
||||
try {
|
||||
const alphaFramesDir = join(alphaRoot, "frames");
|
||||
const alphaChunkDir = join(alphaRoot, "chunks");
|
||||
mkdirSync(alphaFramesDir, { recursive: true });
|
||||
mkdirSync(alphaChunkDir, { recursive: true });
|
||||
const alphaConcatListPath = join(alphaRoot, "concat-list.txt");
|
||||
const alphaOutputPath = join(alphaRoot, "output.webm");
|
||||
|
||||
// `geq=a='X*255/W'` writes a horizontal alpha gradient on top of
|
||||
// the testsrc2 RGB. `testsrc2 + format=rgba` alone produced
|
||||
// uniformly-opaque alpha and libvpx-vp9 silently downgraded the
|
||||
// output to yuv420p, masking any alpha-pipeline bug — the
|
||||
// gradient ensures the encoder has spatially-varying alpha to
|
||||
// preserve.
|
||||
const frameGen = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`testsrc2=s=${WIDTH}x${HEIGHT}:r=${FPS}:d=${TOTAL_FRAMES / FPS}`,
|
||||
"-vf",
|
||||
"format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*255/W'",
|
||||
"-frames:v",
|
||||
String(TOTAL_FRAMES),
|
||||
join(alphaFramesDir, "frame_%04d.png"),
|
||||
]);
|
||||
if (frameGen.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke setup] frame generation failed: ${frameGen.stderr.slice(-400)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const chunkPaths: string[] = [];
|
||||
for (let chunkIdx = 0; chunkIdx < CHUNK_COUNT; chunkIdx++) {
|
||||
const startNumber = chunkIdx * CHUNK_SIZE + 1;
|
||||
const chunkPath = join(alphaChunkDir, `chunk_${String(chunkIdx).padStart(4, "0")}.webm`);
|
||||
chunkPaths.push(chunkPath);
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
fps: { num: FPS, den: 1 },
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
codec: "vp9",
|
||||
preset: "good",
|
||||
quality: 32,
|
||||
pixelFormat: "yuva420p",
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: CHUNK_SIZE,
|
||||
},
|
||||
[
|
||||
"-framerate",
|
||||
String(FPS),
|
||||
"-start_number",
|
||||
String(startNumber),
|
||||
"-i",
|
||||
join(alphaFramesDir, "frame_%04d.png"),
|
||||
"-frames:v",
|
||||
String(CHUNK_SIZE),
|
||||
],
|
||||
chunkPath,
|
||||
);
|
||||
const result = runFfmpegSync(["-hide_banner", "-loglevel", "error", ...args]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke chunk ${chunkIdx}] yuva420p VP9 encode failed: ${result.stderr.slice(-1000)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
alphaConcatListPath,
|
||||
`${chunkPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n")}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const concatResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
alphaConcatListPath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
alphaOutputPath,
|
||||
]);
|
||||
if (concatResult.exitCode !== 0) {
|
||||
throw new Error(`[alpha smoke concat-copy] failed: ${concatResult.stderr.slice(-1000)}`);
|
||||
}
|
||||
|
||||
// Decode-test gates only on exit code — `-v error` ffmpeg builds
|
||||
// can emit non-fatal stderr (DTS warnings, container-quirk notes)
|
||||
// and we don't want the test to flake on chatty stderr in a
|
||||
// future libavformat upgrade.
|
||||
const decodeResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
alphaOutputPath,
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]);
|
||||
if (decodeResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke decode-test] failed (exit ${decodeResult.exitCode}): ` +
|
||||
`${decodeResult.stderr.slice(-1000) || "(no stderr)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// libvpx-vp9 stores the alpha plane as a Matroska `BlockAdditional`
|
||||
// sidecar, NOT in the main stream's `pix_fmt` — `ffprobe` always
|
||||
// reports `pix_fmt=yuv420p` for VP9-with-alpha. The right signal
|
||||
// is the stream-level `TAG:ALPHA_MODE=1` tag the encoder writes
|
||||
// when `-metadata:s:v:0 alpha_mode=1` is set on yuva420p input.
|
||||
const probeResult = runFfprobeSync([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_streams",
|
||||
alphaOutputPath,
|
||||
]);
|
||||
expect(probeResult.exitCode).toBe(0);
|
||||
expect(probeResult.stdout).toMatch(/codec_name=vp9/);
|
||||
expect(probeResult.stdout).toMatch(/ALPHA_MODE=1/);
|
||||
|
||||
// Decode the alpha plane and check it has spatially-varying
|
||||
// content — catches the case where the encoder accepted yuva420p
|
||||
// input but dropped the alpha sub-stream silently (a uniform
|
||||
// alpha plane would mask any plan-time bug like a misconfigured
|
||||
// `needsAlpha` gate). The horizontal gradient source produces
|
||||
// YMIN ≈ 0 / YMAX ≈ 255 on the alpha plane; uniform alpha would
|
||||
// give YMIN == YMAX. Spread > 100 cleanly rejects the bad case.
|
||||
//
|
||||
// `-c:v libvpx-vp9` before `-i` is load-bearing: ffmpeg's default
|
||||
// VP9 decoder strips the BlockAdditional alpha track when
|
||||
// decoding to non-rgba pixel formats; forcing the libvpx-vp9
|
||||
// decoder + `-pix_fmt rgba` is how the alpha plane comes back.
|
||||
const statsResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-v",
|
||||
"error",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-i",
|
||||
alphaOutputPath,
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-vf",
|
||||
"extractplanes=a,signalstats,metadata=mode=print:file=-",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]);
|
||||
if (statsResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke signalstats] failed (exit ${statsResult.exitCode}): ` +
|
||||
`${statsResult.stderr.slice(-500)}`,
|
||||
);
|
||||
}
|
||||
const yminMatch = statsResult.stdout.match(/lavfi\.signalstats\.YMIN=(\d+)/);
|
||||
const ymaxMatch = statsResult.stdout.match(/lavfi\.signalstats\.YMAX=(\d+)/);
|
||||
if (!yminMatch || !ymaxMatch) {
|
||||
throw new Error(
|
||||
`[alpha smoke signalstats] could not parse YMIN/YMAX from output: ` +
|
||||
`${statsResult.stdout.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
const ymin = Number.parseInt(yminMatch[1], 10);
|
||||
const ymax = Number.parseInt(ymaxMatch[1], 10);
|
||||
expect(ymax - ymin).toBeGreaterThan(100);
|
||||
expect(statSync(alphaOutputPath).size).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(alphaRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>chunk-boundary: anime.js</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
}
|
||||
#box {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 70px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #ec4899;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="320"
|
||||
data-height="180"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div id="box"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__hfAnime = window.__hfAnime || [];
|
||||
const tl = anime.createTimeline({ autoplay: false });
|
||||
tl.add(
|
||||
"#box",
|
||||
{ translateX: [0, 280], rotate: [0, 360], duration: 2000, ease: "linear" },
|
||||
0,
|
||||
);
|
||||
window.__hfAnime.push({
|
||||
seek: function (globalTimeMs) {
|
||||
tl.seek(globalTimeMs);
|
||||
},
|
||||
pause: function () {
|
||||
tl.pause();
|
||||
},
|
||||
play: function () {
|
||||
tl.play();
|
||||
},
|
||||
});
|
||||
const dur = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = dur;
|
||||
dur.to({}, { duration: 2 }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>chunk-boundary: CSS @keyframes</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
}
|
||||
@keyframes slide {
|
||||
from {
|
||||
transform: translateX(0px) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: translateX(280px) rotate(360deg);
|
||||
}
|
||||
}
|
||||
#box {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 70px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f97316;
|
||||
border-radius: 6px;
|
||||
animation: slide 2s linear forwards;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="320"
|
||||
data-height="180"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div id="box"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// CSS animation is seek-driven by the HyperFrames CSS adapter — at
|
||||
// each frame the runtime sets `animation-delay` so the keyframe
|
||||
// playhead lands on the right point. A boundary regression here
|
||||
// would show up as box position drift at frames 15, 30, 45.
|
||||
// The empty GSAP timeline is just to satisfy the composition's
|
||||
// duration-driver requirement.
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = tl;
|
||||
tl.to({}, { duration: 2 }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Distributed: CSS var() font-family",
|
||||
"description": "Composition that declares font-family via CSS custom properties (var(--ui-font), var(--display-font)). Exercises the fix for FONT_FETCH_FAILED when the font resolver encounters var() expressions in fail-closed distributed renders.",
|
||||
"tags": ["distributed", "mp4", "h264", "sdr", "fonts", "css-variables"],
|
||||
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"chunkSize": 15
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7e9a156b6ee3a7dd157ec0c44a337e7e9f5e4c7bbb80159e8f2b0b901554deeb
|
||||
size 71779
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>CSS var() font-family regression fixture</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Montserrat:wght@400;700;900&display=swap");
|
||||
|
||||
:root {
|
||||
--ui-font: "Inter";
|
||||
--display-font: "Montserrat";
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
background: #1a1a2e;
|
||||
overflow: hidden;
|
||||
font-family: var(--ui-font), sans-serif;
|
||||
}
|
||||
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--display-font), sans-serif;
|
||||
font-size: 48px;
|
||||
font-weight: 900;
|
||||
color: #e94560;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.body-text {
|
||||
position: absolute;
|
||||
top: 60%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--ui-font), sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: #c4c4c4;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-comp" data-composition-id="css-var-fonts" data-width="640" data-height="360" data-start="0">
|
||||
<div class="heading">CSS Variable Fonts</div>
|
||||
<div class="body-text">var(--ui-font) and var(--display-font)</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline();
|
||||
tl.to(".heading", { opacity: 1, duration: 0.5 });
|
||||
tl.to(".body-text", { opacity: 1, duration: 0.5 }, "+=0.2");
|
||||
tl.to({}, { duration: 1 });
|
||||
window.__timelines["css-var-fonts"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>chunk-boundary: GSAP</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
}
|
||||
#box {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 70px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #6366f1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="320"
|
||||
data-height="180"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div id="box"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Single GSAP timeline tween that straddles every chunk seam at
|
||||
// chunkSize=15 (frames 15, 30, 45). Box translates from x=0 to x=280
|
||||
// continuously across the 2s duration. Identical state on every
|
||||
// frame regardless of chunk count.
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = tl;
|
||||
tl.to("#box", { x: 280, duration: 2, ease: "none" }, 0);
|
||||
tl.to("#box", { rotation: 360, duration: 2, ease: "none" }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>chunk-boundary: Lottie</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
}
|
||||
#lottie-host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="320"
|
||||
data-height="180"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div id="lottie-host"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Minimal Lottie JSON: 60-frame composition (30fps × 2s) with a
|
||||
// single rectangle layer animating its position from x=20 to x=280
|
||||
// linearly. The chunk-boundary test asserts N=1 == N=4 byte-identical
|
||||
// — any Lottie sub-frame interpolation drift across seams trips it.
|
||||
const lottieJson = {
|
||||
v: "5.7.0",
|
||||
fr: 30,
|
||||
ip: 0,
|
||||
op: 60,
|
||||
w: 320,
|
||||
h: 180,
|
||||
nm: "boundary",
|
||||
ddd: 0,
|
||||
assets: [],
|
||||
layers: [
|
||||
{
|
||||
ddd: 0,
|
||||
ind: 1,
|
||||
ty: 4,
|
||||
nm: "rect",
|
||||
sr: 1,
|
||||
ks: {
|
||||
o: { a: 0, k: 100 },
|
||||
p: {
|
||||
a: 1,
|
||||
k: [
|
||||
{
|
||||
i: { x: [1], y: [1] },
|
||||
o: { x: [0], y: [0] },
|
||||
t: 0,
|
||||
s: [20, 90],
|
||||
},
|
||||
{ t: 60, s: [280, 90] },
|
||||
],
|
||||
},
|
||||
r: {
|
||||
a: 1,
|
||||
k: [
|
||||
{
|
||||
i: { x: [1], y: [1] },
|
||||
o: { x: [0], y: [0] },
|
||||
t: 0,
|
||||
s: [0],
|
||||
},
|
||||
{ t: 60, s: [360] },
|
||||
],
|
||||
},
|
||||
s: { a: 0, k: [100, 100, 100] },
|
||||
a: { a: 0, k: [0, 0, 0] },
|
||||
},
|
||||
shapes: [
|
||||
{
|
||||
ty: "rc",
|
||||
p: { a: 0, k: [0, 0] },
|
||||
s: { a: 0, k: [40, 40] },
|
||||
r: { a: 0, k: 6 },
|
||||
},
|
||||
{
|
||||
ty: "fl",
|
||||
c: { a: 0, k: [0.337, 0.408, 0.941, 1] },
|
||||
o: { a: 0, k: 100 },
|
||||
},
|
||||
],
|
||||
ip: 0,
|
||||
op: 60,
|
||||
st: 0,
|
||||
bm: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const anim = lottie.loadAnimation({
|
||||
container: document.getElementById("lottie-host"),
|
||||
renderer: "svg",
|
||||
loop: false,
|
||||
autoplay: false,
|
||||
animationData: lottieJson,
|
||||
});
|
||||
window.__hfLottie = window.__hfLottie || [];
|
||||
window.__hfLottie.push(anim);
|
||||
|
||||
const dur = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = dur;
|
||||
dur.to({}, { duration: 2 }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Distributed: mov ProRes",
|
||||
"description": "60-frame composition (2s @ 30fps) with transparent background, text, and a rotating SVG icon. Output is mov + ProRes 4444 with alpha. ProRes is intra-only (every frame is a keyframe), so concat-copy at assemble time is structurally trivial — this fixture pins the orthogonal contract: that `-c copy` across N=4 chunks preserves QuickTime atoms (moov, mvhd, trak, mdia) intact so the assembled file plays in editors that consume mov directly (Premiere, FCPX, DaVinci).",
|
||||
"tags": ["distributed", "mov", "prores", "alpha"],
|
||||
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"format": "mov",
|
||||
"chunkSize": 15
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:697925f9de650010084992df21e9fa8225bb7ac9ae5df185b9596d64a9d0c8f2
|
||||
size 3848214
|
||||
@@ -0,0 +1,109 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>mov ProRes distributed fixture</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap");
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
/* Transparent background so the mov's alpha channel carries real data. */
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family: "Space Mono", monospace;
|
||||
}
|
||||
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 22%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 18px;
|
||||
letter-spacing: 4px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: "Space Mono", monospace;
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
bottom: 18%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="640"
|
||||
data-height="360"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div class="stage" id="stage-a">
|
||||
<div class="label">PHASE</div>
|
||||
<div class="title" id="title-a">PRORES ONE</div>
|
||||
</div>
|
||||
<div class="stage" id="stage-b" style="opacity: 0">
|
||||
<div class="label">PHASE</div>
|
||||
<div class="title" id="title-b">PRORES TWO</div>
|
||||
</div>
|
||||
<svg class="icon" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" id="icon">
|
||||
<circle cx="24" cy="24" r="18" fill="none" stroke="#6366f1" stroke-width="4" />
|
||||
<circle cx="24" cy="24" r="6" fill="#6366f1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Same chunk-seam stressors as the mp4 siblings: a crossfade straddling
|
||||
// the frame-30 seam (0.9-1.1s = frames 27-33) and a continuous icon
|
||||
// rotation across every chunk boundary. The point with ProRes is the
|
||||
// QuickTime atom contract — frame-level continuity is structurally
|
||||
// satisfied because ProRes is intra-only.
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = tl;
|
||||
|
||||
const stageA = document.getElementById("stage-a");
|
||||
const stageB = document.getElementById("stage-b");
|
||||
const icon = document.getElementById("icon");
|
||||
|
||||
tl.to(stageA, { opacity: 0, duration: 0.2, ease: "none" }, 0.9);
|
||||
tl.to(stageB, { opacity: 1, duration: 0.2, ease: "none" }, 0.9);
|
||||
tl.to(icon, { rotation: 360, duration: 2, ease: "none" }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Distributed: mp4 H.264 SDR",
|
||||
"description": "60-frame composition (2s @ 30fps) with text, a crossfade transition, and a small inline-SVG image. renderConfig.chunkSize=15 produces exactly N=4 chunks, exercising libx264's closed-GOP + concat-copy contract end-to-end.",
|
||||
"tags": ["distributed", "mp4", "h264", "sdr"],
|
||||
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"chunkSize": 15
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c6137ec9cb1117e09d8da79b0dc18d160bdc750d5311e3752c353046ccac7f2
|
||||
size 62079
|
||||
@@ -0,0 +1,121 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>mp4 H.264 SDR distributed fixture</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap");
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
font-family: "Space Mono", monospace;
|
||||
}
|
||||
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 22%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 18px;
|
||||
letter-spacing: 4px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: "Space Mono", monospace;
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
bottom: 18%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="640"
|
||||
data-height="360"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div class="stage" id="stage-a">
|
||||
<div class="label">CHUNK</div>
|
||||
<div class="title" id="title-a">PHASE ONE</div>
|
||||
</div>
|
||||
<div class="stage" id="stage-b" style="opacity: 0">
|
||||
<div class="label">CHUNK</div>
|
||||
<div class="title" id="title-b">PHASE TWO</div>
|
||||
</div>
|
||||
<!-- Small inline SVG icon; no external image fetch required. -->
|
||||
<svg class="icon" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" id="icon">
|
||||
<circle cx="24" cy="24" r="18" fill="none" stroke="#6366f1" stroke-width="4" />
|
||||
<circle cx="24" cy="24" r="6" fill="#6366f1" />
|
||||
</svg>
|
||||
<!--
|
||||
No audio element on purpose: AAC frame quantization pads a 2-second
|
||||
silent track past 2.0s of container time, which extends format.duration
|
||||
past nb_frames / fps and trips the harness PSNR sampler at the very
|
||||
last checkpoint. The chunk-boundary contracts this fixture pins are
|
||||
video-only; omitting audio keeps container duration == 2.0s exactly.
|
||||
-->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Build a single timeline pinned to the composition's wall-clock so
|
||||
// every frame is fully determined by the seek position. Chunk
|
||||
// boundaries at frames {15, 30, 45} sit inside the crossfade window
|
||||
// (frames 27-33 = 0.9s-1.1s) and the icon rotation — both of which
|
||||
// therefore exercise per-chunk state continuity.
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = tl;
|
||||
|
||||
const stageA = document.getElementById("stage-a");
|
||||
const stageB = document.getElementById("stage-b");
|
||||
const icon = document.getElementById("icon");
|
||||
|
||||
// Crossfade A → B straddling the frame-30 chunk seam.
|
||||
tl.to(stageA, { opacity: 0, duration: 0.2, ease: "none" }, 0.9);
|
||||
tl.to(stageB, { opacity: 1, duration: 0.2, ease: "none" }, 0.9);
|
||||
// Continuous icon rotation — the absolute angle at any time must match
|
||||
// across chunk boundaries, so a state-keeping regression in the engine's
|
||||
// virtual clock would show up as a rotation discontinuity at frame 15,
|
||||
// 30, or 45.
|
||||
tl.to(icon, { rotation: 360, duration: 2, ease: "none" }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Distributed: mp4 H.265 SDR",
|
||||
"description": "60-frame composition (2s @ 30fps) with text, a crossfade transition, and a small rotating SVG icon. renderConfig.format=mp4 + codec=h265 routes the distributed pipeline through libx265 with closed-GOP keyint params (min-keyint=N:scenecut=0:open-gop=0:repeat-headers=1). The in-process baseline is rendered as h264 because the in-process renderer doesn't expose a codec hint — the harness's PSNR comparison therefore measures 'libx265 chunked + concat' against 'libx264 single-pass', catching gross codec failures while accepting normal cross-codec PSNR drift (~30-35 dB at standard quality).",
|
||||
"tags": ["distributed", "mp4", "h265", "hevc", "sdr"],
|
||||
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"format": "mp4",
|
||||
"codec": "h265",
|
||||
"chunkSize": 15
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c62e51a616e24c4ba16504a8508bafa58866bd3dabc68cbf32076a49eabb53bd
|
||||
size 59699
|
||||
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>mp4 H.265 SDR distributed fixture</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap");
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
font-family: "Space Mono", monospace;
|
||||
}
|
||||
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 22%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 18px;
|
||||
letter-spacing: 4px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: "Space Mono", monospace;
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
bottom: 18%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="640"
|
||||
data-height="360"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div class="stage" id="stage-a">
|
||||
<div class="label">CODEC</div>
|
||||
<div class="title" id="title-a">HEVC ONE</div>
|
||||
</div>
|
||||
<div class="stage" id="stage-b" style="opacity: 0">
|
||||
<div class="label">CODEC</div>
|
||||
<div class="title" id="title-b">HEVC TWO</div>
|
||||
</div>
|
||||
<svg class="icon" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" id="icon">
|
||||
<circle cx="24" cy="24" r="18" fill="none" stroke="#6366f1" stroke-width="4" />
|
||||
<circle cx="24" cy="24" r="6" fill="#6366f1" />
|
||||
</svg>
|
||||
<!--
|
||||
No audio element on purpose: AAC frame quantization pads a 2-second
|
||||
silent track past 2.0s of container time, which extends format.duration
|
||||
past nb_frames / fps and trips the harness PSNR sampler at the very
|
||||
last checkpoint. The chunk-boundary contracts this fixture pins are
|
||||
video-only; omitting audio keeps container duration == 2.0s exactly.
|
||||
(Same rationale as mp4-h264-sdr.)
|
||||
-->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Same chunk-seam stressors as the mp4-h264-sdr sibling: crossfade
|
||||
// straddles the frame-30 seam (0.9-1.1s = frames 27-33); continuous
|
||||
// icon rotation makes per-frame angle a pure function of seek-position
|
||||
// so virtual-clock drift surfaces as discontinuity at frame 15/30/45.
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = tl;
|
||||
|
||||
const stageA = document.getElementById("stage-a");
|
||||
const stageB = document.getElementById("stage-b");
|
||||
const icon = document.getElementById("icon");
|
||||
|
||||
tl.to(stageA, { opacity: 0, duration: 0.2, ease: "none" }, 0.9);
|
||||
tl.to(stageB, { opacity: 1, duration: 0.2, ease: "none" }, 0.9);
|
||||
tl.to(icon, { rotation: 360, duration: 2, ease: "none" }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Distributed: png-sequence",
|
||||
"description": "60-frame composition (2s @ 30fps) with transparent background, text, and a rotating SVG icon. Output is a directory of zero-padded RGBA PNGs; the assemble path merges chunk frame directories rather than concat-copying mp4 files. renderConfig.chunkSize=15 produces N=4 chunks, exercising the per-frame state continuity across chunk seams that distinguishes the assemble path's directory-merge from mp4's concat-copy. maxFrameFailures=0 makes this a strict byte-identity gate; the upstream byte sources are Chrome's CDP screenshot output and libpng's deflate, so a Chromium or zlib bump in Dockerfile.test will produce identical pixels but different bytes — the failure mode on a Chrome version bump is 'regenerate baselines via docker:test:update', not 'investigate regression'.",
|
||||
"tags": ["distributed", "png-sequence", "alpha"],
|
||||
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30,
|
||||
"format": "png-sequence",
|
||||
"chunkSize": 15
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4933d5dd2abc677fa661d5e94b673f26fed05b7921b252d0a65da0e17f51824f
|
||||
size 7997
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9195c93140f142bfbded3a1f5758141f87bcb11eaa8ec8350a37eef7c0902fe4
|
||||
size 9205
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e8a5d321bcbf9edff83068102f14ff4ea599034d1a97a33b684c5c33f384fa73
|
||||
size 9261
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3daa0e36f959ce465b04db4180733ed9c309bdc13851a3caedd0261d004401f9
|
||||
size 9272
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bef89116d6825d572d9c655e8a01cb0e391087e1175d70ececc90ee6941b148f
|
||||
size 9249
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1100827ceda13433b600ea226331eb3f8a1e9f73517d73381e626302d5e3b675
|
||||
size 9322
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bcd7d85837277d51b52804bb0761c93ac49d2a5d0d1e11d1c411e1f4386d4f7b
|
||||
size 9302
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc9826a6adbbf40cca08779aa9c6e8564e1d39902eb4c052c517d0574324d699
|
||||
size 9371
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6fa8146dcc47b6a78a5b55db9289693772aaa2c366ff7ee124b7053155af058c
|
||||
size 9385
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d0c8bf011435e3e229f615e081d2933fca5c8937505465a8481582ac46de94cb
|
||||
size 9285
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3baf13eab4045857adc8aa75bf1cc4fe4fa829c50ef6c857eab0bd3c28a45293
|
||||
size 9303
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b71dfee4cd91267f4d6ea65a2d6c846dd5ac5689cfe8781a90ded5c10ca50afa
|
||||
size 9236
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:737a38a778b722d280ec11986650c57e72466245687db964222bbe47a085aea5
|
||||
size 9265
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b3bc25a8bcf17103040f6bec9642e09f155d3115397c6ba2b59dd5d60314bf92
|
||||
size 9286
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bf033c619debdcaa970ef0a32e67001ee3d2caee6df54d671f23003008ca7ef7
|
||||
size 9204
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4933d5dd2abc677fa661d5e94b673f26fed05b7921b252d0a65da0e17f51824f
|
||||
size 7997
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9195c93140f142bfbded3a1f5758141f87bcb11eaa8ec8350a37eef7c0902fe4
|
||||
size 9205
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e8a5d321bcbf9edff83068102f14ff4ea599034d1a97a33b684c5c33f384fa73
|
||||
size 9261
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3daa0e36f959ce465b04db4180733ed9c309bdc13851a3caedd0261d004401f9
|
||||
size 9272
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bef89116d6825d572d9c655e8a01cb0e391087e1175d70ececc90ee6941b148f
|
||||
size 9249
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1100827ceda13433b600ea226331eb3f8a1e9f73517d73381e626302d5e3b675
|
||||
size 9322
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3de351c6f5e536ea58347cddb373b7521eb146558ed058a2609cd3e59e46bd62
|
||||
size 9301
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc9826a6adbbf40cca08779aa9c6e8564e1d39902eb4c052c517d0574324d699
|
||||
size 9371
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90b58af819c992b79c43b9681028f5f9f2467253c36a16dbb927e77fbad27bc1
|
||||
size 9389
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:49e4a510bfe9c2a983cdc93a9760ade16e8da6818ac75b21eff3eea17218f3f8
|
||||
size 9285
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3baf13eab4045857adc8aa75bf1cc4fe4fa829c50ef6c857eab0bd3c28a45293
|
||||
size 9303
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b71dfee4cd91267f4d6ea65a2d6c846dd5ac5689cfe8781a90ded5c10ca50afa
|
||||
size 9236
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:737a38a778b722d280ec11986650c57e72466245687db964222bbe47a085aea5
|
||||
size 9265
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:40e724c558e8e1597943ec672224cd2e18ca8a5100fad3f153bca8bca0df4969
|
||||
size 11311
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:94e3e8ce6cbffa74557fcc76ecc561471d3d49ecdb3591a0fe01b8e14013fe5a
|
||||
size 11391
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48602bb09bff3d79210729565bdb524488149574cae55eeda53671eae88e01dc
|
||||
size 10344
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:389ae5df4c313fbe65043e3edc8e1d1a64d58a195c006c60c089164700cb12de
|
||||
size 11471
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d9552049a6b5c263b350d98d86a3f1e9ba474a0cba8af2d8899903c3a2b401d0
|
||||
size 11467
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7b00cd02037d594c4576f0413d61d6a88de62cca131de314d002dbf33c6292aa
|
||||
size 10046
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:46d60ddd91ec1d7185aaeb6686d6add1aed7d81aae53612ac2edbf6a7d93c345
|
||||
size 10031
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5e13cd286a3233563cdbeb4ba5b1b2bab04b4cb29bac5c93e5702744d11c088
|
||||
size 10110
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:633d22d5463e34c3fb2c6335b249467cb9807a04980d05eb0bbca6d82f203b76
|
||||
size 10093
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f0f2982c19feb426c3d813a1e552d8d89f10b244744cb2664c31404d918e5471
|
||||
size 10156
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6019e56d9876d24e67ee8f3458f9d2073e8af0be9852876ea3e377c79b24fae6
|
||||
size 10164
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:69839d2376bb82be36e1ebb4b3250c2b8d8e554285008c1ae30b6bc1d52f1752
|
||||
size 10070
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9f1d1e98f9d5a24ce456a3a13c4b47d83f602a3c3e1a61c72726335d62e14657
|
||||
size 10093
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bc31fc2b24e5a97e8ffbf87e8428354adb35eec131693ddd171f8485017477c4
|
||||
size 10020
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48a14b5afb08bd5770f8b462df9ee5077837ca0c0235b74c94bc158e97e0519c
|
||||
size 10040
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4de5c8e9be3371102aef78373ae26173c6cd90014a4ea38a948650b3631644f6
|
||||
size 10070
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:093dd3500e5d1897547fc58b9555692f678a4a9fb85b01963ebf0035c59ac36c
|
||||
size 9990
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8315981ec4add959e8e7807cf5d80e02b1f2c9d87f4606bb0ecf3d593e4d72db
|
||||
size 8828
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user