## 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
## 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
Replace the `choco install ffmpeg` step in `windows-render.yml` with a direct download of the upstream Windows GPL build from [`BtbN/FFmpeg-Builds`](https://github.com/BtbN/FFmpeg-Builds/releases/latest) on GitHub Releases.
## Why
The `Render on windows-latest` canary started failing on every PR with:
```
[NuGet] Response status code does not indicate success: 504 (Gateway Timeout).
[NuGet] Response status code does not indicate success: 503 (Service Unavailable).
```
The Chocolatey community feed (`community.chocolatey.org/api/v2/package/ffmpeg/8.1.0`) is degraded for the `ffmpeg` package right now. The earlier 3-attempt retry I added wasn't enough — every attempt across multiple runs failed with 503/504, so retrying does nothing.
The Chocolatey path is also a bit indirect for what this job actually validates. The real point of the canary is the [PR #336](https://github.com/heygen-com/hyperframes/pull/336) fix where `findFFmpeg()` / `where ffmpeg` discovery has to work on a fresh Windows runner. As long as `ffmpeg.exe` ends up on `PATH`, the underlying thing under test (the harness can find ffmpeg, capture frames, mux to MP4) is exercised exactly the same.
BtbN/FFmpeg-Builds is the canonical upstream nightly Windows GPL build (Chocolatey itself rebundles essentially the same artifact), so this is closer to the source, not further from it.
## How
- Download `ffmpeg-master-latest-win64-gpl.zip` from the BtbN release with `Invoke-WebRequest` (3-attempt retry with backoff).
- Extract to `$env:RUNNER_TEMP/ffmpeg` and locate `ffmpeg.exe` recursively.
- Add the bin directory to `$env:GITHUB_PATH` so all subsequent steps in the job (the Bun-driven harness, `findFFmpeg()`, etc.) see ffmpeg on `PATH` exactly the same way as before.
- Print `ffmpeg -version` as a sanity check.
## Test plan
- [ ] CI: `Render on windows-latest` job goes green on this PR.
- [ ] Subsequent PRs no longer get blocked on `choco install ffmpeg` 503s.
## 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.