* fix(producer): render -c <scene> uses the scene's own duration, not the project's
When rendering a single sub-composition standalone (`hyperframes render -c
compositions/scene.html`), the producer extracts the scene's mount from
index.html and wraps it in a shallow clone of the master root. That clone
kept the master's `data-duration`, so the standalone composition advertised
the whole project's length instead of the scene's own: a 2s scene rendered
for the full 12s project, and a master that derives its length from sibling
mounts (now removed) produced "Composition has zero duration".
Re-point the extracted wrapper's `data-duration` at the scene's own, read
from the scene file's `<template>` root (the source of truth for that scene),
with a fallback to the mount's `data-duration`. Full-project renders are
unaffected — they never take the extraction branch.
Verified end-to-end via the pre-capture duration gate: a 2s scene now resolves
to 2s and a 10s scene to 10s (both were 12s), while the full index render stays
at 12s. Adds unit coverage for both the scene-file and mount-fallback paths.
* test(producer): distinguish scene and mount durations
* fix(hyperframes-media): surface the real reason a TTS line failed
synthesizeHeygen() swallowed every failure into a bare { ok:false }: a thrown
HTTP error (e.g. 402 plan_upgrade_required from heygenJSON) was caught and
discarded, a missing audio_url / failed audio fetch / failed transcode all
returned nothing. audio.mjs then logged 'TTS failed — omitted' for every line
with zero detail, so the actual cause took a hand-rolled repro to find.
Each failure path now returns an { error } string (the caught message, the HTTP
status, or the specific stage that failed), and audio.mjs appends it to the
anomaly. The subprocess providers (elevenlabs/kokoro) get the same treatment via
a shared synthResult() helper. synthesizeHeygen takes an injectable deps arg so
the failure paths are unit-tested (thrown 402, non-ok fetch, missing audio_url).
* fix(media-use): report wav transcode failures accurately
* chore: regenerate skills manifest
Addresses review feedback on #2254: the previous fix hardcoded every clone
to the full composition width/height regardless of the source scene's own
sizing, silently overriding any authored explicit width/height. Measuring
each scene's getBoundingClientRect() while still live in the document (its
inset:0 already resolves correctly against the real ancestor chain there)
and reapplying that exact box to the clone fixes the 0x0 collapse without
ever clobbering an author's own layout.
Addresses non-blocking review feedback on #2256: the previous shape used
err.message.includes(...) inside a catch to distinguish a real headroom
violation from a statfsSync failure — a future message tweak would silently
fail open (statfs-unsupported and headroom-violation would take the same
code path). Now statfsSync failure returns early (skip the gate) and the
headroom check/throw happens in plain control flow outside any try/catch.
## What
Two fixes from adversarial testing of the DE parallel router (10 hostile comps, routed vs screenshot-baseline PSNR). The router itself held — both bugs are in general drawElement fast capture, and one slipped past self-verify.
### 1. Compile gate: ancestor background-image (`producer`)
`drawElementService`'s per-frame ancestor fill replicates what lies behind the captured subtree by walking up the DOM for the nearest non-transparent **`backgroundColor`**. A background-**image** (`linear-gradient`, `url()`) on `body`/`html`/a wrapper reads as transparent in that scan, so a deeper ancestor's solid color paints instead wherever the subtree leaves pixels uncovered.
Measured repro: body `linear-gradient` + html solid color + an element shrinking late in the comp → DE paints the html purple instead of the body gradient. 30.9 dB min frame vs baseline, visually unmistakable. Identical damage single-worker and parallel — general DE bug, in every wild DE render matching this (very common) authoring pattern.
Fix: `detectAncestorBackgroundImage()` in the compiler (DOM-aware — inline styles on the root's ancestor chain + `<style>` rules resolved via `querySelectorAll`, so class-selected wrappers are covered; backgrounds *inside* the root are deliberately not matched). New compile gate `ancestor_background_image`, same shape as the 3D/mix-blend gates, bypass `HF_FAST_CAPTURE_ANCESTOR_BG=true`.
### 2. Self-verify tail sample (`engine`)
The verify grid sampled at `(i+1)/(k+1)` → [20/40/60/80]% of the timeline. The damage above starts at ~79% and peaks after the last sample — verification **passed** on output that bottomed at 30.9 dB (threshold 32 dB would have caught it, it just never looked there).
Fix: `computeDeVerifySampleFractions()` — first k−1 samples evenly spaced, last pinned at 95%. Default k=4 grid becomes [25/50/75/95]%. Kills the whole late-onset damage class, not just this repro.
## Validation
- Repro comp (body gradient + shrink reveal): now gates → baseline route, 54.7 dB avg vs ground truth (was 41.6 avg / 30.9 min with the purple surround)
- Control comp (nested stacked fades, routed): still routes, verify grid `[90, 180, 270, 342] of 360`, passes, 60.6 dB avg — unchanged
- Full adversarial matrix context: 6/10 comps routed clean (49–68 dB min), blend/3D gated correctly, animated-canvas damage caught by verify at 16.5 dB with clean revert, video comps route legitimately (frames pre-extracted)
- Tests: 7 new detection cases (`htmlCompiler.test.ts`), 5 new grid cases (`frameCapture-verifySampleFractions.test.ts`); `compileStage.test.ts` + `frameCapture.test.ts` suites green
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Selecting a rotated (cropped) element appeared to straighten it: the crop
dim and dashed window were drawn on the axis-aligned bounding box, so the
bright window was a straight rectangle and the element's rotated corners
were masked to near-black — while the DOM transform was untouched.
clip-path applies in the element's LOCAL frame, before its transform, so
the crop visualization now renders inside a container rotated with the
element: readElementCropFrame decomposes the computed 2D matrix into
angle + per-axis scale (element scale finally factored into the px
mapping too) and 3D/unparseable transforms keep the axis-aligned
presentation. Pointer deltas rotate into the element frame before the
inset resolvers, so edge/pan drags track the rotated handles correctly.
Review follow-ups (both reviewers, all findings):
- resize captures scope to the resize group: convert-to-keyframes
resolvedFromValues and the whole-offset backfill pass the group filter,
so an opacity-touching intro tween can't ride into a converted scale
tween (the rotation fix's contract, now uniform across intercepts)
- commitStaticSet resolves every group's target set BEFORE committing and
coalesces groups landing on the same legacy mixed set into one commit —
the second commit can no longer chase a stale group-derived id
- installAuthoredOpacityCapture also stamps an element the moment it GAINS
data-color-grading at runtime (attributeFilter), not just at insertion
- both writer twins now share the same emitted-set dedupe shape
- applySoftReload's positional tail becomes a SoftReloadOptions object
- readAllAnimatedProperties builds the group-filtered key set immutably
instead of deleting from the set mid-iteration
- applyAuthoredInlineOpacity documents the priority-lossy round-trip
- the marquee hit-test reads activeCompositionPathRef like its neighbors
New tests: resize intercept (scale route + group filter + non-uniform
longhands), after-write-HTML / stamp / empty-stamp opacity restore, the
no-op-commit-with-missed-instant-patch soft-reload contract, and the
runtime-gained-grading stamp.
The deselect restore read a ref recomputed from RENDER state — on a direct
A→B selection switch, state re-syncs to B before A's effect cleanup runs,
so after a committed crop gesture A was restored with B's crop string (or
lost its crop when B had none). The committed value is now written at
gesture-commit time (tri-state: none committed / crop removal / the exact
committed string), so cleanup never touches render state. Adds component
tests for lift/restore ordering, including the direct A→B switch and the
uneditable-clip stand-down.
## What
Remove tracked nested dependency links and platform artifacts, reject them with a repository invariant, and make the regression-image cache single-writer.
## Why
A frozen install must not rewrite tracked files or leave a fresh checkout dirty. Separately, stacked PR matrices were all exporting to one GitHub Actions cache scope, and Graphite branch/base updates could launch duplicate suites for one head SHA. The resulting concurrent writers exhausted the cache service before any regression fixture ran.
## How
Delete the tracked artifacts, wire an AST-free path checker into lint and pre-commit, let pull-request regression jobs consume the shared image cache, restrict cache publication to main pushes, and cancel superseded regression runs per PR/ref.
## Test plan
- [x] `bun install --frozen-lockfile`; tracked-artifact unit tests; repository lint
- [x] Workflow YAML parse and formatting checks
- [x] Stack-wide lint, format, build, typecheck, unit, packed-consumer, and player-performance gates
gsap_from_opacity_noop matched 'opacity: 0' as a prefix — an authored
'opacity: 0.98' triggered the rule. One boundary-anchored predicate now
owns the exactly-zero test for both block and inline declarations,
including a block's last declaration without a trailing semicolon.
domEditingDom now imports the grading contract attribute from core
instead of re-declaring the literal.
A parsed timeline set carries immediateRender in extras; the recast
statement builder ALSO pushed the flag unconditionally, so every
split/re-add of a set wrote 'immediateRender: true, immediateRender:
true' into the file, doubling on each pass. Both writer twins (recast +
acorn) now emit each vars key exactly once, properties winning over
extras, and reconcileEditableProps skips newProps keys it already
preserved.
commitStaticSet merged every property into the FIRST set found for the
selector: a panel W edit on an element whose only set was positional
produced tl.set("#el",{x,y,width}) — a mixed-group set the split
machinery exists to prevent — labeled "Set 3D transform" in undo.
Commits now batch per property group into a set that owns that group
(exact group match, then a mixed set already carrying the group, then a
fresh off-timeline gsap.set), with undo labels derived from the group
(Move layer / Resize layer / Rotate layer / Set 3D transform).