## Problem
Template-wrapped sub-compositions could still lose correct parent timing during render in more than one place.
In the validated repros, a host sub-composition starting after the intro (and in one follow-up repro, starting at `20s` after earlier compositions) contained scene-local media inside it. On the broken paths:
- template-wrapped media could be missed during compile and scheduled at raw scene-local time
- already-correct first-pass offsets could be clobbered during `recompileWithResolutions()`
- even after those two fixes, the browser-metadata reconcile step in `executeRenderJob()` could still overwrite a compiled global `end` with a scene-local `data-end` from the inlined DOM, clipping the tail off late-start sub-composition media
## What this fixes
### Template-wrapped media discovery
- `parseVideoElements`, `parseImageElements`, and `parseAudioElements` now unwrap a single top-level `<template>` wrapper before scraping media
- the unwrap helper is DOM-based, not regex-based, so it avoids the CodeQL backtracking warning and only unwraps the exact single-wrapper shape we want
- multiple sibling templates or other top-level content are left untouched instead of being rewritten heuristically
### Offset preservation after duration resolution
- `recompileWithResolutions()` now preserves the first-pass sub-composition media arrays when the already-inlined HTML no longer contains `[data-composition-src]` hosts
- that prevents correctly offset media metadata from being overwritten by scene-local media parsed from the merged DOM
### Browser metadata reconciliation in the compiled time origin
- browser-discovered media can still report scene-local `data-start` / `data-end` from the merged DOM after inlining
- the producer now reprojects browser `end` values into the compiled element's time origin before reconciling them back into `composition.videos` / `composition.audios`
- this prevents late-start sub-composition media from getting truncated back to a scene-local end during the probe phase
### Regression coverage
- adds focused engine tests for the template unwrap helper
- adds producer regression coverage for both the initial compile path and the post-inline `recompileWithResolutions()` path
- adds producer regression coverage for late-start host compositions (`t≈20`) with scene-local media inside them
- adds producer unit coverage for the browser-end reprojection helper used by the reconcile path
## Root cause
There were three distinct renderer failures behind the bug:
### 1. Template contents were invisible to the media scrapers
`parseSubCompositions()` reads raw sub-composition HTML and applies the host offset to discovered media. But the engine media helpers were querying the parsed document directly, and linkedom follows browser semantics here: top-level `<template>` contents live in a `DocumentFragment`, so `querySelectorAll()` never saw those `<video>` / `<audio>` / `<img>` nodes.
That meant template-wrapped sub-compositions could silently produce zero discovered media during the first pass.
### 2. The duration-resolution recompile could clobber already-correct offsets
After the browser resolves composition durations, `recompileWithResolutions()` reparses the already-inlined HTML. By that point the original `[data-composition-src]` hosts are gone, so `parseSubCompositions()` legitimately returns no nested media.
The old code still rebuilt the deduped media arrays from the merged DOM, which let scene-local media parsed from the inlined HTML overwrite the correctly offset first-pass metadata.
### 3. The browser probe reconcile path mixed two timing coordinate systems
`discoverMediaFromBrowser()` reads `data-start` / `data-end` directly from the live DOM after sub-compositions are already inlined. For nested media, those attributes can still be scene-local even though the compiled metadata has already been offset into the parent host timeline.
The old reconcile path compared those values directly and overwrote `existing.end` whenever the numbers differed. For a late-start sub-composition, that could replace a correct global end like `25.5` with a scene-local end like `5.5`, cutting the clip off during render.
## Verification
### Local checks
- `bun test packages/engine/src/utils/htmlTemplate.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bunx vitest run packages/producer/src/services/renderOrchestrator.test.ts`
- `bun run --filter @hyperframes/engine test`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bunx oxlint packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts packages/producer/src/services/renderOrchestrator.ts packages/producer/src/services/renderOrchestrator.test.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxfmt --check packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts`
- `bun run build:producer`
### Render / browser verification
Verified against two local repros:
1. **Early offset repro**
- host starts at `2s`
- child media is scene-local `0-4s`
- compiled render summary keeps the child video/audio at `start: 2`
- browser verification via `agent-browser` confirmed the `2.2s` frame still shows the child clip active in the host timeline
2. **Late offset repro**
- earlier compositions run first, then the target host starts at `20s`
- child media starts scene-local at `1.5s` and should remain visible through `24.5s`
- compiled render summary keeps the child video/audio at `start: 21.5`, `end: 25.5`
- browser verification via `agent-browser` confirmed the `24.5s` frame still shows the late clip visible, which is the exact tail-clipping case the old reconcile path could break
## Notes
- the `/tmp/hf-pr475-repro` and `/tmp/hf-pr476-late-offset-repro` projects plus their browser-proof artifacts are verification-only and are not part of this PR
- this PR stays narrowly scoped to sub-composition media timing across compile, recompile, and browser probe reconciliation; it does not broaden into general sub-composition HTML normalization beyond the single-wrapper case
Guard buildHyperframesRuntimeScript() against missing entry.ts so it
returns null instead of crashing with esbuild stderr output. Add
getHyperframeRuntimeScript() that returns the pre-built IIFE as a
baked-in string constant — no esbuild, no file I/O, no import.meta.url.
Consolidate CLI runtime source resolution into a single module with
a clear priority chain: esbuild from source (dev) → inlined constant
(production) → pre-built artifact file (fallback).
Add CI smoke test that npm-packs the CLI, installs globally, runs
hyperframes preview, and asserts no stderr errors + runtime endpoint
returns JS.
Bump version to 0.4.16.
## Summary
Track `tests/*/src/*.png` via Git LFS to mirror the existing policy for golden videos and `.mp4` fixtures.
## Why
`Chunk 11C` of `plans/hdr-followups.md`. Without this rule, regression suites that grow PNG fixtures over time would bloat the working-tree history and slow shallow clones.
## What changed
- `.gitattributes`: add `tests/*/src/*.png` to the LFS-tracked patterns.
- Migrates the six existing PNG fixtures (1.6 MB combined: `hdr-photo-pq.png` plus `heygen-promo-preview-assets/` screenshots) onto LFS in the same commit so the rule applies retroactively.
## Test plan
- [x] `git lfs ls-files` includes the HDR PNG fixtures after commit.
- [x] Working tree size for these files goes from 1.6 MB to 6 × ~130 B LFS pointers.
## Stack
Chunk 11C of `plans/hdr-followups.md`. Independent of all code changes.
## What
Brief description of the change.
## Why
Why is this change needed?
## How
How was this implemented? Any notable design decisions?
## Test plan
How was this tested?
- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
* ci: skip PR runs when targeting a non-main base branch
Adds `branches: [main]` to the `pull_request:` trigger of each workflow
that runs on PRs (CI, regression, Windows render verification, Docs,
Catalog Previews). PRs whose base is something other than main — typical
for stacked PRs — no longer trigger these workflows.
On a 5-PR Graphite stack this turns 5× CI runs into 1× (when the tip
of the stack reaches main). When a child PR is rebased/promoted so its
base becomes main, CI fires as normal.
publish.yml and the default CodeQL setup are untouched: publish already
filters to main, and CodeQL is default-setup (org UI, not a repo YAML).
* chore(ci): fix oxfmt formatting on renovate.json
Same drive-by as #423. Renovate's config-migration PR #422 landed
unformatted (Renovate bot skips lefthook), so every PR branched from
current main fails `bun run format:check`. Whichever of #423 / #426
merges first cleans it up.
* fix(player): parent-frame media playback for mobile
Mobile browsers block media.play() inside iframes when the user
gesture happened in the parent frame — postMessage doesn't transfer
user activation (per the User Activation v2 spec).
## Problem
The player renders compositions in a sandboxed iframe. When a user
taps play in the parent frame, the player sends a postMessage to the
iframe's runtime, which calls audio.play(). On mobile, this fails
silently because the iframe has no user activation context.
## Solution
The player now extracts ALL timed media elements (audio/video with
data-start) from the iframe's DOM (same-origin access), creates
parent-frame copies, and disables the iframe originals. On play(),
parentMedia.play() runs synchronously in the gesture call stack,
satisfying mobile autoplay policy.
### Generic media handling
- Finds all `audio[data-start], video[data-start]` in the iframe
- Creates a parent-frame copy for each (Audio or Video element)
- Preserves data-start offsets for correct seek positioning
- Strips data-start from iframe elements so the runtime ignores them
- Falls back to iframe media for cross-origin iframes
### `audio-src` attribute
Convenience for the common single-narration case. When set, the
player starts preloading audio immediately — before the iframe loads.
This eliminates the loading delay that caused jittery playback.
### No active sync
Both parent media and the GSAP timeline are real-time systems. When
started simultaneously, they naturally stay within ~10ms — no drift
correction needed. Active sync with coarse granularity (50ms polling)
caused MORE jitter than it prevented via repeated audio seeks.
## CI
- Added unified `test` job replacing separate per-package test jobs
- Added root `test` script: `bun run --filter '*' test`
- New packages with test scripts are automatically included
- Added happy-dom for player DOM tests
## Tests
- 10 new tests for parent-frame media: preloading, play, pause,
seek, muted/rate sync, cleanup, attribute changes
- All 21 player tests pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(shader-transitions): pass CI when no test files exist
Add --passWithNoTests to vitest run so the unified test job
doesn't fail on packages that have a test script but no test
files yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): update tests for new id field and GSAP lint rule
- normalize.test.ts: loadTranscript now assigns id fields (w0, w1, etc.)
to SRT/VTT results and empty string for words-json passthrough
- lintProject.test.ts: add GSAP CDN script to validHtml() fixture to
satisfy the missing_gsap_script lint rule added in core
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add missing data-start/data-duration to validHtml fixture
The validHtml() test fixture was missing data-start and data-duration
attributes, triggering the root_composition_missing_data_start and
root_composition_missing_data_duration lint warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ci): fetch LFS objects for producer test job
Producer regression tests compare rendered output against reference MP4
files stored in git LFS. Without lfs: true, checkout fetches pointer
files instead of actual videos, causing "moov atom not found" errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ci: remove redundant test-producer job
The regression workflow already runs the same 28 producer fixtures
in a Docker container with prod-matching Chrome/fonts/ffmpeg, sharded
across 8 parallel matrix jobs with 40-min timeouts. The CI test-producer
job was a duplicate that ran on bare runners with worse determinism
and a 15-min timeout too short for all fixtures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Lint (oxlint) only runs when code changes are detected
- Format (oxfmt) runs on all PRs including docs-only changes
- Update path filter: pnpm-lock.yaml → bun.lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CI and regression path filters included their own workflow files,
which meant any PR that changed CI config would trigger the full
build/test/regression suite. Workflow file changes don't need code
validation — they need a test run of the workflow itself, which
happens automatically.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The repo has a ruleset requiring these checks: Build, Typecheck,
Test: core, Test: engine, Test: runtime contract, regression.
With paths-ignore, docs-only PRs would never report these checks,
blocking merge forever.
Fix: add a `changes` job using dorny/paths-filter that detects
whether code files changed. Each job uses `if: needs.changes.outputs.code == 'true'`
which causes GitHub to report the job as "skipped" (counts as passing)
rather than "never started" (counts as pending).
The regression summary job explicitly handles the no-code-changes case
by checking the filter output before evaluating shard results.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New docs.yml: runs `mint validate` and `mint broken-links` on docs/** changes
- ci.yml: paths-ignore docs/**, *.md so build/typecheck/tests don't run on docs-only PRs
- regression.yml: same paths-ignore to skip Docker regression tests on docs-only PRs
No branch protection is configured, so paths-ignore won't block merges.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>