The runtime path (compositionLoader.ts) already propagated
data-timeline-locked from inner root to host, but both compiler
inliners (bundler + producer) did not. Bundled output re-opened in
Studio would lose the lock. Now propagated in inlineSubCompositions
alongside the existing data-hf-authored-id propagation.
Escape href values in querySelector calls for link dedup in both
htmlBundler.ts and htmlCompiler.ts to match the runtime path (which
uses CSS.escape). Prevents SyntaxError on hrefs containing quotes.
Add two tests for inlineSubCompositions font-link extraction:
- Verifies <link> elements are extracted with original rel + crossorigin
- Verifies dedup across multiple sub-compositions sharing the same font
Store {href, rel, crossorigin} from source <link> elements instead of
re-deriving rel from a URL substring heuristic. Fixes preview-vs-render
parity: a stylesheet link whose href lacks ".css" or "css2?" was
emitted as preconnect in the compiled output, silently dropping the font.
Also documents that caption components ship with transparent backgrounds
intentionally — users add contrast layers in the host composition.
Timeline locking:
- Add data-timeline-locked attribute support — fully disables move,
trim-start, and trim-end in Studio for clips that carry this attr
- Runtime propagates the attribute from inner composition root to host
element so component authors control it from their HTML
- All 15 caption components in the registry now carry the attribute
Font fix:
- Extract <link rel="stylesheet"> and <link rel="preconnect"> from
sub-composition <head> alongside existing <style>/<script> extraction
- Fixes caption components (and any sub-comp using Google Fonts via
<link> tags) losing their font-family when loaded as sub-compositions
- Applied in both runtime (compositionLoader) and compiler
(inlineSubCompositions) paths
Add mediabunny (MPL-2.0) to CREDITS.md third-party licenses section.
Add regression test for 4-5 clip compositions under the lowered lazy
threshold — verifies lazy mode activates and no spurious eviction churn
occurs when all clips fit within the promoted cap.
Enable trim-start and trim-end for all authored timeline elements (divs,
sections, compositions) — not just video/audio/img. The deterministic-window
gate was overly restrictive since all non-implicit elements have authored
data-start/data-duration that define their timeline window.
Replace iframe reload after resize/move with direct DOM attribute patching
via patchIframeDomTiming(). This eliminates playhead-jump-to-zero, visual
blinking, and race conditions from file-watcher echoes. File persistence
runs in a serialized background queue (persistTimelineEdit + enqueueEdit)
so rapid edits don't overwrite each other.
Add mediabunny-based media probe service (mediaProbe.ts) for fast metadata
extraction from file headers. Timeline elements missing sourceDuration are
enriched asynchronously without waiting for DOM loadedmetadata events.
Tune the runtime media preloader: lower lazy threshold from 6 to 3 clips,
add 3s lookbehind window for reverse scrub, adaptive promoted-clip cap.
Deduplicate getTimelineEditCapabilities — computed once in TimelineCanvas
and passed as a prop to TimelineClip instead of recomputing per clip.
Remove dead PlaybackAdapter re-export from useTimelinePlayer — all consumers
import directly from playbackTypes.
When the producer inlines a sub-composition with compId match, it takes
innerRoot.innerHTML which strips the wrapper div and its id attribute.
CSS/GSAP selectors rewritten from #ID to [data-hf-authored-id=ID] then
match nothing.
Fix: after innerHTML injection, copy the inner root's id as
data-hf-authored-id on the host element. This makes #ID selectors work
identically in both preview (bundler) and render (producer) paths.
Closes#969
Addresses review feedback from Rames and Vai:
1. Add 7 new tests for createStudioPositionSeekReapplyScript:
box-size reapplication, GSAP translate stripping (identity removal,
scale+translate preservation, transform:none no-op), and rotation-
only elements with GSAP-baked translate.
2. Add pinning test for the PiP-over-sub-composition selection bug:
elementsFromPoint returns [pipVideo, subCompRoot, sfChromeImg] as
siblings — assert the topmost (pipVideo) wins.
3. Apply stripGsapTranslateFromTransform to rotation-only elements
too, not just path-offset elements. A rotation-only element with
a GSAP-animated translate would have its position clobbered.
4. Remove dead exports: getPreviewLocalPointer,
buildRasterClickSelectionContext, getPreviewPlayer,
seekStudioPreview, PreviewPlayerCompat, PreviewLocalPointer from
studioPreviewHelpers.ts. Unexport resolvePreviewLocalPointer.
- Rename activateNestedChildTimelines → activateSiblingTimelines (matches player.ts)
- Use tl.play() instead of tl.paused(false) for consistency
- Convert positional activateChildren boolean to { activateChildren } opts
- Add FIXME(#969) to divergence test with tracking issue link
- Add [id="intro"] no-rewrite boundary test
- Add comment about deliberate no-restore behavior in render-seek path
- Create sub-comp-t0 and sub-comp-id-selector as proper regression tests
under packages/producer/tests/ with golden MP4 baselines
- Add both to shard-7 in regression.yml
- Add clarifying comment on activateNestedChildTimelines scope
- Confirm test fixture network safety in comment
When GSAP animates an element (e.g. scale, opacity), it captures the
element's translate into its internal transform matrix (m41/m42). The
render reapply script was setting the CSS `translate` property but
leaving GSAP's translate baked into `transform`, causing the manual
edit offset to be ignored or doubled.
Port the same stripGsapTranslateFromTransform logic the studio uses:
parse the transform matrix, zero out m41/m42, and remove or rewrite
the transform property so the CSS `translate` takes effect cleanly.
Three interrelated studio UX and rendering fixes:
1. Remove the "Ask agent" popup that auto-triggered when clicking large
raster elements in the preview. The modal intercepted clicks meant
for editable elements and blocked normal selection workflow.
2. Rewrite preview click selection to respect visual stacking order.
The previous scoring algorithm weighted DOM depth at 10,000× per
level, causing elements inside sub-compositions to beat visually-
on-top elements (e.g., clicking Pip Studio selected Sf Chrome
instead). The new algorithm trusts elementsFromPoint order and only
prefers a deeper candidate when it is a descendant of the current
pick — never jumping to an unrelated element painted behind it.
3. Fix manual edits (resize) not surviving video rendering. The
producer's seek-reapply script handled translate and rotation but
was missing box-size (width/height) reapplication after each GSAP
seek. Also added data-hf-studio-box-size to the detection list in
htmlCompiler so the script is injected for resize-only edits.
The renderSeek override in init.ts called seekTimelineAndAdapters() which
only did rootTimeline.totalTime(t) without activating child timelines.
GSAP does not propagate totalTime() to internally paused children.
Also simplifies pollSubCompositionTimelines to always call rebind when
timelines are ready, removing the before/after count comparison that
could skip the rebind on fast page loads.
The producer inlining path strips the inner root element (taking innerHTML
when compId matches), losing the id attribute. The bundler path preserves it
via flattenInnerRoot + data-hf-authored-root-id. This causes #ID selectors
in sub-comp CSS and GSAP to fail silently during render while working in
preview.
Adds a minimal fixture with a sub-comp using #intro scope to catch this
divergence in future compiler changes.
* ci: run fallow audit in lefthook pre-commit
Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.
Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.
Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.
The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.
* refactor: delete orphan declarations flagged by fallow
After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.
Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.
Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.
Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.
Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.
Adds a Blocks tab to the Studio left sidebar with the full 78-item registry
catalog (58 blocks + 20 components). Users can browse by category, search by
title/description, preview CDN-hosted poster thumbnails with video-on-hover,
and install items on-demand with one click or drag-to-timeline.
Core changes:
- BlockCategory type + resolveBlockCategory() for 7 categories (Captions, VFX,
Transitions, Effects, Social, Data, Scenes)
- Registry API routes: GET /api/registry/blocks (catalog) + POST install
- StudioApiAdapter extended with listRegistryCatalog + installRegistryBlock
- Vite adapter reads from disk; CLI adapter fetches from GitHub (24h cache)
- BlockParam interface + params on 6 blocks for future parameter controls
Studio UI:
- 4th sidebar tab "Blocks" with responsive grid, category pills, search bar
- BlockCard: CDN poster thumbnail, video autoplay on hover, duration + WebGL badges
- On-demand install: blocks append as sub-compositions on timeline; components
overlay at start=0 spanning full duration with transparent background patching
- TIMELINE_BLOCK_MIME drag-and-drop to timeline
- BlockParamsPanel (Phase 3 scaffold) auto-opens for parameterized blocks
Registry manifests:
- All 58 blocks backfilled with preview: { video, poster } CDN URLs
- All 20 components normalized to object format + poster URLs added
- 6 blocks annotated with params (Liquid Glass/Background, Portal, Chart,
Logo Outro, Magnetic)
- flowchart-vertical preview generated and uploaded to CDN
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols
fallow's reachability analysis identifies as unused. Keeps only the cases
where the symbol is still referenced internally in its own file (so
removing `export` doesn't surface a new oxlint `no-unused-vars` error).
Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused
exports), with no behavior change — each symbol is still defined and used
exactly the same way within its file.
Reverted ~20 files where fallow's auto-fix would have created cascading
"declared but never used" lint errors — those are cases where the symbol
isn't used at all, and properly cleaning them up means deleting the
declaration, not just dropping `export`. Better to land that as a
separate, narrower PR rather than mixing it into a mechanical de-export.
Also reverted four false positives where fallow missed real consumers:
- `captureCost.ts` (renderOrchestrator has two separate import blocks
from the same module; fallow only saw the first)
- `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses
fallow's reachability missed)
- `render.ts` (functions imported via `await import()` dynamic import,
which fallow's static analysis doesn't follow)
Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean,
cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
Configure fallow via .fallowrc.jsonc so its analysis reflects this repo's
real entry surface, then fix the genuine issues it found.
Fallow noise reduction (601 → 276 dead-code findings):
- Ignore docs/, test fixtures, skill test-corpora, registry/, examples/
- Declare worker entry points loaded dynamically by file path
(pngDecodeBlitWorker.ts, shaderTransitionWorker.ts)
- Declare runtime IIFE entry (core/src/runtime/entry.ts) built outside the
import graph by build-hyperframes-runtime-artifact.ts
- Declare bun:test files in producer + aws-lambda as test entries
- Ignore dynamically-resolved deps: tsup external (puppeteer-core, esbuild,
giget), peer/static-file (gsap in player perf tests), workspace deps
hoisted by bun (happy-dom, @hyperframes/*), and @fontsource/* packages
read via readFileSync in generate-font-data.ts
Extract inline build:fonts scripts:
- packages/{cli,producer}/package.json had multi-line `node -e ...` blobs
containing braces that fallow mis-parsed as glob alternate groups. Moved
to dedicated build-fonts.mjs scripts.
Fix duplicate exports:
- Remove dead FileIcon alias in studio/SystemIcons.tsx (FileTreeIcons.tsx
has the real, used one)
- Consolidate ValidationResult: drop the identical duplicate in
gsapParser.ts; both parsers now import from core.types
- Suppress intentional namespace patterns (per-namespace ML manager
exports; CLI per-command 'examples' convention; fileServer.ts test-only
isPathInside which has different symlink semantics from utils/paths.ts)
Break circular dep (studio/components/editor):
- manualEditsDom.ts re-exported clearStudioPathOffset / clearStudioRotation
/ clearStudioBoxSize from manualEditsSnapshot.ts, which imports four
helpers from manualEditsDom.ts — back-edge cycle
- Re-export moved to manualEdits.ts (the package-public barrel) where the
rest of the snapshot re-exports already live; underlying files now form
a clean DAG
Remove genuinely unused deps:
- studio: motion (no imports anywhere), codemirror (umbrella package; the
@codemirror/* sub-packages are used directly)
- cli: mime-types (plus its only consumer src/utils/mime.ts, which was a
hardcoded mime table that didn't use the package), and its now-stale
tsup external entry
Verified: typecheck across core/cli/producer/studio is clean, oxlint
+ oxfmt pass, manualEdits.test.ts (18 tests) and core parser tests (69
tests) still pass.
Deferred follow-ups (real findings, separate PRs):
- 8 circular deps in producer/services/render/stages/ — renderOrchestrator
↔ captureHdr* / captureStage / extractVideosStage form a hub cycle
- ~14 unused files in producer/src/services/ that look like dead
re-export shims to @hyperframes/engine, but aren't in the public
exports map — need to confirm no deep-import consumers before deletion
- waveform.ts complexity hotspot
The missing_three_script rule only checked <script src> attributes
for Three.js. Now also detects:
- importmap entries defining "three"
- ES module import/from statements referencing "three"
Closes#931
The setInterval-based late-bind polling in init.ts caused visual
regressions across all style-prod tests. Even with the sawMissing
guard, the mere presence of the interval registration altered
event loop timing enough to shift rendered frames.
The engine's pollSubCompositionTimelines + conditional
__hfForceTimelineRebind already handles async timeline detection
for renders. The runtime only needs to expose the rebind hook —
it shouldn't poll on its own.
For studio preview of async compositions, the engine's rebind
call (via __hfForceTimelineRebind) is the correct mechanism.
The late-bind polling was unconditionally rebinding on its first
check even when all timelines were already present, causing visual
regressions across style-prod tests. Now tracks sawMissing flag —
only rebinds if the poll previously detected missing timelines that
subsequently appeared. Compositions with synchronous timeline
registration exit the poll immediately with no side effects.
1. Only call __hfForceTimelineRebind() when the timeline poll actually
had to wait (pollDuration > 2 intervals). For compositions with
synchronous timeline registration, the rebind was unnecessary and
shifted render timing, causing PSNR regressions in chat and
gsap-letters-render-compat.
2. Regenerate compiled.html baselines for missing-host-comp-id and
overlay-montage-prod to match the new flattenInnerRoot behavior
(data-composition-id stripped from inlined inner roots, replaced
with data-hf-authored-id).
3. Add late-bind polling to runtime init.ts — after external
compositions load, poll for 5s to detect async timelines that
register after initial binding (e.g. from fetch callbacks).
Review items addressed:
1. Mirror video-failure warning in beginFrame path (was screenshot-only)
2. Fix resolveProjectRelativeSrc escape-fallback to use query-stripped
cleanSrc instead of raw src for the normalize/strip arm
3. Export prepareFlattenedInnerRoot from @hyperframes/core/compiler and
consume in the producer instead of duplicating the implementation
4. Use typed Window cast instead of (window as any) for __hfForceTimelineRebind
5. Regenerate docs/public/catalog-index.json with all 6 map blocks
6. Restore Maps nav group in docs.json (catalog generator had merged
them into Data)
- Replace from:"random" with from:"center" stagger in us-map,
world-map, spain-map — random stagger is non-deterministic across
parallel render workers, causing visual jumps at chunk boundaries.
- Exempt type="importmap" and type="module" inline scripts from the
invalid_inline_script_syntax lint rule. The rule used new Function()
to parse, which rejects import statements and JSON import maps.
Closes#929.
- Cache-bust all map MDX preview video URLs after re-rendering with
the deterministic stagger fix.
Two fixes for compositions that register timelines after async data
loading (e.g. fetch for TopoJSON map data):
1. engine/frameCapture: remove the hosts.length <= 1 early return
so the timeline readiness poll runs for ALL compositions, not just
multi-composition galleries. Single-composition pages with async
setup were silently skipped.
2. core/runtime/init: expose window.__hfForceTimelineRebind() which
resets childrenBound and re-runs bindRootTimelineIfAvailable().
The renderer calls this after all timelines are confirmed present,
ensuring the root player discovers late-registered timelines from
fetch callbacks.
Without these fixes, compositions using fetch() to load data at
runtime would render blank frames because the root player bound
timelines before the async setup completed, and seek() never
reached the unbound composition timeline.
Fixes from review #4306329284 on hf#922:
- Normalize path.relative() output with .split(sep).join("/") so
rebased url() paths use forward slashes on Windows, matching the
posix-path convention in rewriteSubCompPaths.ts.
- Return empty string (not the original @import statement) when the
visited set detects a diamond import. Previously the stale @import
leaked through and caused a 404 after bundling.
- Strip CSS block comments before @import matching so commented-out
imports (/* @import url(...) */) are not resolved. Comments are
restored after processing via placeholder substitution.
When CSS files in subdirectories are inlined into the bundle's <style>
block, their url() references (fonts, images, cursors) break because
they resolve relative to the HTML document root instead of the CSS
file's original directory.
Rebase all relative url() paths to the project root during CSS
inlining, for both <link>-referenced stylesheets and @import-resolved
content. Uses a placeholder approach to avoid double-rebasing when
nested @import chains each carry their own url() references.
Preserves absolute URLs, data URIs, query strings, and hash fragments.
The bundler inlines local CSS files by reading their content and
concatenating into a <style> block. @import statements inside those
files were left unresolved — their paths were relative to the original
CSS file location, but after inlining they resolve against the HTML
document, causing 404s for tokens, fonts, and variables.
Recursively resolve relative @import statements during CSS inlining,
with circular-import protection and @media wrapping for conditional
imports. Absolute URLs (CDN, Google Fonts) are preserved as-is.
When linkedom parses a fragment like `<div data-composition-id="X">...
</div>`, the div becomes the documentElement and body is empty.
contentDoc.body?.innerHTML returns "" losing the composition wrapper.
Fall back to contentDoc.documentElement?.outerHTML when body content
is empty, preserving composition IDs for sub-compositions where the
host data-composition-id differs from the inner root's.
Fixes style-1-prod regression (captions sub-comp has host id
"captions-comp" but inner root id "captions").
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both the core bundler (htmlBundler.ts) and the producer (htmlCompiler.ts)
had parallel ~200-line implementations of sub-composition inlining. This
divergence caused bug #911 (producer didn't set data-composition-file).
Extract the shared logic into core/compiler/inlineSubCompositions.ts:
- Single function handles: template/body extraction, CSS/script scoping,
asset path rewriting, data-composition-file attribution, content injection
- Callers provide environment-specific callbacks (HTML resolution, parsing,
variable handling, inner root flattening)
- Core bundler passes its advanced features (runtime IDs, variables,
inline style rewriting, inner root flattening)
- Producer passes a simpler resolver (map + filesystem fallback) and
adds pixel sizing post-hoc
Net: -215 lines, one source of truth for sub-comp inlining.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>