## What
Allow Studio Preview to serve an asset reached through a project-local symlink whose target is in a shared directory outside the project, including browser-hostile video assets that need an authoring proxy.
## Why
Preview rejected these assets with a 404 while the renderer accepted the same path. The initial static-route fix still failed for HEVC, ProRes, AV1, and VP9 assets because the proxy transcoder rejected the external target.
## How
Use lexical project-root containment for the read-only static asset route and proxy source request. The transcoder canonicalizes the target for ffmpeg and includes that identity in its cache key, while keeping the proxy cache inside the project. Composition source paths retain canonical containment because preview can persist their data-hf-id values.
## Test plan
- [x] Unit tests added/updated
- [x] `bun run --cwd packages/studio-server test` (397 tests)
- [x] Studio Server typecheck, oxlint, and oxfmt
- [x] External-symlinked hostile-video proxy route regression
- [x] Static-route traversal regression
- [ ] Documentation updated (not applicable)
- caption-emoji-pop: shadowForColor now builds its glow via color-mix()
instead of hex-pair slicing, so the strict 6-digit brand-color gate is
gone — any CSS color the sibling templates accept (#fff, rgb(), named)
now renders instead of silently falling back to the default palette
- caption-weight-shift: fitFontSize now sizes against the WIDEST split
line rather than the joined group text (two-line groups no longer shrink
unnecessarily), and avoidSingleWordGroups' merges re-check fitsInTwoLines
like makeGroups' first pass does (merged groups can no longer overflow
the split budget)
- all 5: hfApplyStageConfig clamps resolution to the published validator's
<=8192 bound (validator is optional pre-flight; unbounded stages OOM
render workers), and fit floors carry a comment documenting that the
minimum size is returned unverified by design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review findings on the caption-data runtime, applied to all 5 templates:
- hfValidate's version gate used Math.floor(Number(v)) > HF_CONTRACT_VERSION,
and Number("v2") is NaN — NaN comparisons are always false, so malformed
versions slid through with no unsupported-version signal. An explicit
Number.isFinite check closes it.
- hfBoot's sibling-fetch .then called hfAttach unconditionally; a manual
window.__HF_CAPTION_ATTACH__ call landing while the fetch or fonts.ready
was still pending got clobbered by the late boot payload. Boot now yields
if a timeline already exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
caption-highlight was already IIFE-wrapped; the other four leaked their
runtime (hfAttach/hfBuild/...) as script-scope globals. Harmless when boot
was synchronous, but the data-driven retrofit defers attach behind
fonts.ready + the sibling fetch — with two caption components pasted into
one composition document, every script finishes before any deferred boot
runs, the last script's definitions win the shared scope, and the first
component never registers its timeline (a renderer waiting on it hangs to
timeout). Wrapping each template's script keeps its internals private so
each boot attaches its own component. window.__HF_CAPTION_ATTACH__ remains
intentionally window-scoped (last-defined-wins).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a parallel-capture disk-verify or streaming-drain breach, the outer
catch cleared probeSession without first closing the still-owned session,
orphaning the probe Chrome process precisely when the retry was recovering
from GPU/memory pressure. Introduce closeOrphanedProbeForRetry so both
retry catches close the session (with defensive .catch that logs on close
error) before releasing the reference, and cover it with a focused unit
test asserting closure-before-clear and the swallow-and-warn behaviour.
Addresses Magi's REQUEST_CHANGES on #2749; also closes Rames' sibling
concern at the streaming-retry path (renderOrchestrator.ts:3093).
— Via
hfApplyStageConfig (Block B, verbatim across all 5 retrofitted caption
identities) only ever set --hf-caption-primary/--hf-caption-accent when the
corresponding brand.primaryColor/accentColor key was present, with no else
branch to clear it when absent. Re-attaching a brand-less payload after a
branded one left the custom property (and any JS-cached color derived from
it, e.g. caption-pill-karaoke's hfColorActive and caption-emoji-pop's
hfAccentColors) stuck at the stale value instead of reverting to the CSS
fallback, violating idempotent re-attach.
Also removes caption-editorial-emphasis's dead .word--italic CSS rule and
CLASS_MAP.i entry — hfMakeBlocks only ever emits "n"/"e" tags, "i" was only
reachable via the old hand-authored BLOCKS literal this task replaced.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both identities looped over every OTHER group/block to force its opacity
to 0 at each group's own start time, in addition to each group already
setting its own opacity to 0 at its own end. Since groups/blocks occupy
non-overlapping time windows and already own their full opacity
lifecycle, that loop was dead weight — but it made timeline construction
O(n^2) in the number of groups/blocks. Under the Task 7 stress transcript
(long, frequent "emphasis" words forcing near single-word blocks/groups),
n reached ~2000 and the page hung well past a 30s test timeout for both
identities.
Verified behavior-preserving: full templates.test.ts (41 tests, incl.
opacity/seek assertions) and the new limits.test.ts stress suite pass
against both identities after the removal.
Replaces the hand-authored BLOCKS literal with hfMakeBlocks, a heuristic that
groups words into blocks/lines from timing (pauses, punctuation, max words per
block) and hfIsEmphasisWord (long, non-stopword content words) to decide which
word gets the large Playfair Display emphasis treatment and its own slide-in
line. computeLineSize/buildBlocks/fitBlocks and timeline construction now live
inside hfBuild, closing over layout-scaled font sizes and widths. Adds the
shared Blocks A-E caption-data runtime (attach/gate/brand config) and the
.word/.word--emphasis CSS brand hook for --hf-caption-primary. This is the
last of the five caption identities to go data-driven.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the hardcoded RAW_GROUPS index-pair array with hfMakeGroups, a
real fitting/pause/punctuation-based grouper, and wires the component
into the shared caption-data runtime (validate/gate/attach). Adds CSS
brand hooks: .hl-word text color from --hf-caption-primary, .hl-word-bg
gradient from --hf-caption-accent (second stop via color-mix()).
Retrofits caption-emoji-pop onto the shared HyperFrames caption-data runtime
(Blocks A-D) and the emphasis heuristic (Block E), replacing the hardcoded
7-word emoji map, fixed KEYWORDS set, and TRANSITION_WORDS set with a
generic ~40-entry lexicon and HF_STOPWORDS/hfIsEmphasisWord. The component
now consumes brand.primaryColor (hfPrimaryColor) and brand.accentColor
(hfAccentColors) and rescales stage/font/emoji sizing to the runtime
resolution via layout.fontScale/scaleX/scaleY.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A `<br>` is a void element but `resolveSingleChildTextTarget` treated a lone
`<br>` child as the element's text target. So `getOwnText(<h1>A<br>B</h1>)`
read the `<br>`'s (empty) textContent → `text: null`, which consumers surface
as "not editable", and `setOwnText` wrote into the `<br>`, corrupting it
(serialized as invalid `</br>`).
Exclude void elements from the single-child text target; read `<br>` as "\n";
and rebuild the text/`<br>` run from the newline-separated value on write,
reusing existing `<br>` nodes so their identity (data-hf-id) survives an
in-place edit.
## What
New cross-sample layout check `rotation_pivot_drift` — flags a rotating element that should spin **in place** but pivots about the **wrong point** (e.g. a wheel whose spokes use a hardcoded px `transformOrigin` instead of `svgOrigin`/`%`, so they swing off-center while every existing check still passes).
Motivating prod case: a portrait ad's spoked-wheel whose `#spokes` rotated about `transformOrigin:"250px 250px"` in a resized 460px container — spokes detached from the hub, shipped clean because no rule inspects rotation.
## How
- `layout-audit.browser.js`: `window.__hyperframesRotationSample()` reports each visible transformed element's bbox center + decoded rotation angle per layout sample. Skips `[data-layout-allow-orbit]`.
- `checkPipeline.ts`: accumulates samples across the seek grid; `detectRotationPivotDrift()` (modeled on `detectSweepStatic`) flags an element that (a) actually spins (angle spread > 20° over ≥3 samples), (b) is size-stable (bbox width ratio ≤ 1.6), and (c) whose bbox **center** drifts > `max(10% of its size, 2% of min viewport dim)`. Emits `warning`; not persistence-tiered (not demoted to info).
## FP guards
Real rotation required, ≥3 samples, size stability, `data-layout-allow-orbit` exemption, min area ~2500px². Center-drift (not bbox size) is the discriminator, so a correctly-centered spinner reads drift ≈ 0.
## Validation (`check --json`)
| Fixture | Expected | Result |
|---|---|---|
| off-transformOrigin spoked wheel | fire | **fired — 109px drift on `#spokes`** |
| non-spinning comps (node diagram, device tree) | clean | clean, no FP |
| correctly-centered spinner (`svgOrigin`) | clean | clean (spins 162°, drift 0) |
| `data-layout-allow-orbit` off-origin spinner | clean | clean (exempt) |
| off-`svgOrigin` control, no opt-out | fire | fired — 251px drift |
No false positives. `tsc --noEmit` clean, `oxlint` clean, `check.test.ts` + `layout-audit.browser.test.ts` = 112/112 pass.
## Note
`ROTATION_MAX_SIZE_RATIO` is 1.6 (not 1.3): a rotating anisotropic shape's axis-aligned bbox inherently oscillates (8-spoke star ~1.32×, square 1.41×), so a tighter ratio rejects legitimate targets. Center-drift stays the real discriminator; thin swinging bars are excluded.
Follow-up: a `detectRotationPivotDrift` unit test via the fake driver's `collectRotationSample` (mirroring the sweep_static tests).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The pre-commit build/lint/typecheck PreToolUse hook emitted {continue:false,
stopReason}, which halts the agent's turn — the dev had to type 'continue'
after every failed gate. Switch to a PreToolUse deny decision so the failing
commit is still blocked but the failure text is fed back to the agent as tool
feedback, letting it fix and retry without a manual nudge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chrome 112+ / Firefox 117+ / Safari 16.5+ support native CSS Nesting.
A nested rule like '.title { … }' inside '[data-composition-id="intro"]
{ … }' resolves at match time to '<parent> .title' via the implicit
'&' prefix.
'scopeCssToComposition' walks every rule via 'root.walkRules' and re-
scopes selectors, but it did so for nested rules too — producing
'[…scope…] .title' inside '[…scope…] { … }', which nesting then
prepends AGAIN to '[…scope…] […scope…] .title'. Since the composition
root only appears once in the DOM, the doubly-scoped selector never
matches — the nested rule appears 'just ignored' as the reporter
described (#2721).
Reproduced on 0.7.66 with the reporter's exact composition. Fix: add
'isNestedInsideAnotherRule' predicate — mirrors the existing
'isInsideGlobalAtRule' — and skip nested rules in the walkRules
callback. Top-level rules still get scoped; their nested descendants
inherit scope naturally via CSS Nesting at match time.
Added two focused tests:
- 'preserves nested-rule selectors so CSS Nesting inheritance works
(#2721)' — asserts nested '.title' and 'h2' selectors stay verbatim
while top-level rules keep scoping.
- 'preserves deeply-nested CSS Nesting rules (#2721)' — same rule at
depth 3.
All 37 existing scopeCssToComposition tests still pass.
Fixes#2721.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
#2606 taught the video-coverage gate that a non-looping short video holds
its final decoded frame across the tail, so the delivered source frames
are enough to cover the authored slot. But that fix gated the credit on
'!video.loop' — a looping short video was still measured as
unique-source-frames / slot-frames and aborted at ratio << threshold.
Reproduced on 0.7.64 with the reporter's exact composition (3s clip in a
10s slot, loop attribute): render aborts with 'captured 90 of expected
300 frames (coverage 30.0%)'. Same source without loop renders clean via
#2606's freeze credit. This is the mainline 'loop a short clip to fill a
longer scene' case, the reason loop exists.
Fix: extend #2606's source-credit to loops symmetrically — the delivered
set (all N source frames) covers every repeat within the slot, so
expectedFrames = min(slotFrames, sourceFrames) for both hold and loop.
Fail-loud preserved for a genuinely-broken loop (extractor truncated
below its own source): a 60/90 delivery still aborts at 66.7% < 95%
because the delivered set no longer covers the full source period the
loop reuses. Missing extractions still require the full slot.
Test updates:
- 'still requires the full authored slot for looping clips' locked in
the buggy behavior; replaced with 'credits a looping short clip
against the source portion' which asserts the correct 90/90/1.0.
- Added 'still fails when a looping clip's source extraction is
truncated' as the new fail-loud floor.
Fixes#2665. Regression window: 0.7.60 (#2606's original ship)
through 0.7.67 (current).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* docs(skills): make captions non-optional in changelog-video
The Jul 13-20 build shipped without captions because the pipeline had two soft
failure modes:
- Empty vo-words.json from the TTS provider was silently accepted, so
align-captions.mjs had nothing to align. captions.json was never produced.
- Step 5 tolerated the master-skeleton's LINES array staying empty. The build
lint-checked and validated clean, but the caption rail rendered no text.
This adds three hard gates on top of the existing scaffold:
- Step 4 adds a whisper forced-alignment fallback so a missing TTS timestamp
payload no longer breaks the caption pipeline. Whisper only supplies timings;
captions still use the DISPLAY layer from script-tokens.json.
- Step 5 flags an empty LINES array as a shipped bug and shows the exact
IIFE-input shape it expects.
- Step 6 adds gate 5: sample 3-4 frames across the VO window and confirm visible
caption text on each. If any spoken window renders no caption, the build is
red.
Anti-patterns table gets two rows covering the empty-LINES and
missing-word-timings failure modes. .claude and .agents mirrors stay
byte-identical.
Follow-up to #2669 (pre-build gate); paired with the v5 rebuild that
retrofitted captions onto the shipped Jul 13-20 video.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(skills): bump changelog caption size to 32px for phone-screen legibility
25px reads too small at 1080² when the video is viewed on a phone without
zoom. Bumped to 32px (font-size 25→32, top 1002→990, height 40→52, alpha
.92→.94) so captions carry across small-screen playback. Change is CSS-only
on the master-skeleton; existing per-scene chrome and layouts are untouched.
Retrofitted the Jul 13-20 v6 build with the same values (identical CSS
block) so the shipped video and the future default match. .claude and
.agents mirrors stay byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(skills): sync caption position refs to top: 990 across SKILL.md + build-spec
Miga caught: the master-skeleton bump to top: 990 / 32px / height: 52 was
not reflected in the SKILL.md prose or build-spec.md. All three files now
name the same values so the doc, gate, and code stay consistent. Also
clarifies build-spec.md's caption-rail line to point at SKILL.md step 5 as
the source of the LINES-populated requirement.
.claude and .agents mirrors byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(skills): sync script-voice.md caption rail to top: 990 / 32px
Third and final stale reference to top: 1002 / 25px — script-voice.md was
still stating the pre-bump values. Now matches master-skeleton.html,
build-spec.md, and SKILL.md. Also bumps the 'keep critical small text out'
guidance from ~80px to ~100px to reflect the slightly taller caption rail.
Full-directory sweep confirms zero remaining occurrences of 1002 or 25px
across .claude/ and .agents/ skill mirrors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* docs(changelog): weekly digest 2026-07-13–2026-07-20
* docs(changelog): embed the weekly video in the Jul 13-20 digest
Adds the Jul 13-20 changelog video (produced via the changelog-video
skill, hosted at static.heygen.ai) at the top of the Update block using
Mintlify's <Frame> component with a native <video controls>.
Video URL: https://static.heygen.ai/hyperframes/changelog-videos/weekly-changelog-jul13-20.mp4
Slack thread: C0ACCNHLG3U · 1784565857.520599