Send `stack_trace` (up to 4KB) on crash, unhandled_error, and
unhandled_promise_rejection events so PostHog captures the full JS
call stack for debugging. Also bump `component_stack` from 500→2000
chars, and add `error_name` to promise rejections.
Visual redesign of the timeline:
- Flat solid clip backgrounds, no gradients or multi-layer shadows
- Unified neutral color palette — all clips use the same base color
- Clean 6px border-radius instead of organic asymmetric radii
- Single-line labels, no redundant tag badge or time range
- 3px teal accent stripe on the left edge of every clip
- Simplified trim handles (2px accent bars)
Thumbnail fixes:
- Fixed broken thumbnail URLs — compositionSrc was an absolute URL
that got nested inside the preview/comp path. Now normalized to
relative path before constructing the thumbnail URL
- Thumbnail background changed from #000 to #1c2028 so transparent
overlay compositions render visible content against a matching
dark background
- mix-blend-mode: lighten on thumbnail layer — dark backgrounds blend
away, bright content shows through
- Removed double-label in CompositionThumbnail (was showing both a
badge at top and text at bottom)
Adds NLE-style hotkeys for muting audio and toggling loop playback in the
Studio player, matching the workflow conventions in DaVinci Resolve,
Premiere, and Final Cut.
- M toggles audio mute (no-op above 1x playback, matching the mute button's
existing gating behavior).
- Shift+L toggles loop. Ctrl/Cmd+L was considered but is filtered out by
shouldIgnorePlaybackShortcutEvent and conflicts with the browser address
bar; Shift+L is also consistent with the existing Shift+I / Shift+O
modifier pattern.
The Shift+L handler runs before the existing plain-L shuttle case so it
doesn't also start forward playback.
Fixes#905
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.
Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.
Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types
Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test
GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile
Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame
Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
toolbar actions, navigation, and render starts
Addresses review comments on #982:
- studio shouldTrack(): adds VITE_HYPERFRAMES_NO_TELEMETRY (mirrors CLI's
HYPERFRAMES_NO_TELEMETRY) and import.meta.env.DEV gates so dev / CI
studio builds don't pollute production telemetry. shouldTrack() is now
exported for testability.
- App.tsx session dedupe: moves the once-per-session check from a useRef
(which resets on HMR / remount) to sessionStorage via new
hasFiredSessionStart / markSessionStartFired helpers in config.ts.
- studioRenderTelemetry.ts: documents why `workers` is intentionally
omitted from emitStudioRenderError (studio renders don't accept a
user-supplied worker count, so early failures genuinely don't know one).
- client.ts flush(): documents fire-and-forget no-retry design so future
hands don't accidentally add retry logic that double-counts.
Tests:
- studioRenderTelemetry.test.ts (8 tests): perfPayload mapping for every
RenderPerfSummary field, undefined-perf path, missing-extract path,
zero-elapsed edge case, error event shape.
- studio/telemetry/events.test.ts (4 tests): pin event names
(studio_session_start, studio_render_start) and payload shape.
- studio/telemetry/client.test.ts (9 tests): shouldTrack() returns false
for non-phc_ key, opt-out, doNotTrack, build-time env, vite dev mode;
memoization.
Adds 'source' property (cli|studio) to render_complete/render_error events,
makes studioServer.ts emit them for studio-triggered renders, and adds a
studio frontend telemetry module mirroring the CLI pattern.
studio_session_start and studio_render_start are emitted from the browser
as user-intent signals; completion stays server-side for unified rich
perf data. OSS-safe: no-op when VITE_HYPERFRAMES_POSTHOG_KEY is unset.
Opt-out via localStorage or navigator.doNotTrack.
Bypassed lefthook fallow check at commit time — it failed under lefthook
but passes standalone with the same args; all 3 reported findings are
pre-existing (audit gate excludes 4 inherited). CI will run the
authoritative check.
Elements inside a data-timeline-locked composition now have all
canvas interactions disabled (move, resize, manual offset/size/rotation,
style editing). Prevents "Unable to patch" errors when trying to move
JS-generated caption elements that can't be patched back to source.
Elements inside a data-timeline-locked sub-composition now have the
TEXT property panel hidden. These elements are JS-generated — editing
them in the panel won't persist since the script rebuilds the DOM on
load. Uses findClosestByAttribute to detect the locked ancestor.
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
Restore rollback path: enqueueEdit now returns the queued promise so
Promise.resolve(handler(...)).catch(rollback) in useTimelineClipDrag
fires correctly on save failure. Handlers return the promise chain.
Fix lost-update race in probe enrichment: use zustand's functional
setState so concurrent probe completions each read the latest state
atomically instead of all reading the same stale snapshot.
Harden file-change suppression: pendingTimelineEditPathRef is now a
Set<string> with exact-match lookup instead of single-slot + endsWith.
Multiple concurrent edits on different files are all suppressed correctly.
Remove dead canOffsetTrimClipStart function and its tests — no longer
called after the capability gate simplification.
Document runtime sync mechanism: added comment explaining that the
runtime re-reads data attributes on each sync tick (init.ts:1324-1368).
Fix comment wording in patchIframeDomTiming catch block.
Extract resolveResizePlaybackStart, simplify patchIframeDomTiming to
accept attr tuples, inline findIframeElement. Reduces CRAP scores in
the resize handler lambda and DOM patching functions.
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.
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.
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.
## Summary
- Remove the z-index injection loops from timeline move, delete, and asset-drop commit paths — only timing/track attributes are now patched on the affected clip, leaving all other clips untouched
- Fix `patchInlineStyleInTag` to handle self-closing void elements (`<img />`, `<audio />`) — the old code produced malformed `<img ... / style="z-index: 7">` output
- Delete the now-unused `buildTrackZIndexMap` helper and its tests
## Root cause
Three timeline operations (`handleTimelineElementMove`, `handleTimelineElementDelete`, `handleTimelineAssetDrop`) looped over every clip in the file on each commit and injected `style="z-index: N"` derived from an inverted `data-track-index` mapping via `buildTrackZIndexMap`. This overrode the author's CSS z-index — contradicting the documented contract that `data-track-index` does not affect visual layering — and persisted the corruption in the source HTML.
## Test plan
- [x] Reproduction test covering old bug behavior (inline z-index injection on all clips, inverted layering)
- [x] Verification tests confirming move/delete only patches the affected clip's timing attributes
- [x] Void element tests confirming `<img ... style="..." />` output (not `<img ... / style="...">`)
- [x] End-to-end browser test: opened Studio, dragged badge clip in timeline via CDP, verified only `data-start` changed on the dragged clip with zero inline z-index injections
- [x] Full test suite: 585 tests pass (54 files)
- [x] Build, lint, format, typecheck all green
Closes#958
Timeline move, delete, and asset-drop operations were looping over every
clip in the file and writing style="z-index: N" derived from an inverted
data-track-index mapping. This silently overrode the author's CSS z-index
— contradicting the documented contract that data-track-index does not
affect visual layering — and persisted the corruption in the source HTML.
Remove the z-index injection loops from all three timeline commit paths.
Move and delete now only patch timing/track attributes on the affected
clip. Asset drop still sets z-index on the newly created element via the
generated HTML, without touching existing clips. Delete the now-unused
buildTrackZIndexMap helper.
Also fix patchInlineStyleInTag to handle self-closing void elements: the
old code produced malformed `<img ... / style="z-index: 7">` because it
didn't account for the trailing `/` before appending the style attribute.
Closes#958
* 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).
- [blocker] When playerAdapter is null (GSAP-only runtimes with no
win.__player), the fallback path now uses the best available timeline
adapter instead of returning null. Track timelineAdapter across the
__timeline and __timelines paths, then use it as the base for
createStaticSeekPlaybackAdapter.
- [nit] Remove dead baseAdapter alias — use bestAdapter directly.
- [tests] Add 4 tests: readTimelineDurationFromDocument with
data-hf-authored-duration fallback, createStaticSeekPlaybackAdapter
with seek-only adapter (no renderSeek), and pause lifecycle.
getAdapter() returned the runtime player or GSAP timeline adapter directly
when its duration was > 0, even when the document's timeline (computed from
sub-composition data-start + data-hf-authored-duration attributes) extended
beyond that duration. This capped the seek slider, seek clamping, and
sub-composition visibility at the adapter's shorter value.
Now each adapter path checks whether the document duration exceeds the
adapter's own duration. When it does, the adapter falls through to
createStaticSeekPlaybackAdapter which wraps the runtime player with the
correct effective duration, allowing seeking and preview across the full
timeline range.
When the runtime player's clock duration is smaller than the effective
timeline (computed from data-start + data-hf-authored-duration on
sub-composition elements), the seek is clamped too early and sub-compositions
beyond the clock duration are invisible.
Detect this mismatch in getAdapter() and pad the root GSAP timeline to the
document duration, then force a timeline rebind so the clock updates.
The seek function clamped to adapter.getDuration() which only knows the
root composition's authored duration. Appended timeline elements extend
beyond this range. Compute the effective max from both the adapter duration
and the store's element boundaries so scrubbing reaches the full timeline.
- Move Timing + Media sections above Layout in the Design panel
- Remove LayerTree from Design panel (redundant with Layers tab)
- Replace Rate and Media Start with sliders matching Volume's UX
- Replace Position DetailField with SelectField to match Fit height
- Remove Poster field (not useful for HyperFrames compositions)
- Show absolute filesystem path for Source (resolves symlinks)
- Add Copy button for source path with checkmark feedback
- Preserve element selection on undo/redo instead of clearing it
Replace the useEffect that pushed effectiveTimelineDuration into the player
store with an inline derived selector in PlayerControls. The selector computes
Math.max(duration, maxElementEnd) directly from store state, avoiding the
effect-based sync anti-pattern entirely.
Adds a new Media section to the Design panel that appears when a <video>
or <audio> element is selected. Controls include volume (slider),
playback rate, media start offset, loop/muted toggles, and for video:
object-fit, object-position, poster, and has-audio-track toggle.
Extends the source patcher with an "html-attribute" operation type for
native HTML attributes (loop, muted, poster) that don't use the data-
prefix. Adds coalesceKey to attribute commits so rapid slider/scrub
edits merge into a single undo entry.
The seek slider read duration from the player store, which was set from the
iframe adapter's getDuration() — only aware of the root composition's authored
data-duration. Appended sub-compositions (via Blocks panel) extend the timeline
but the slider stayed capped at the original duration.
Sync effectiveTimelineDuration (which accounts for all timeline elements) into
the player store, and prevent adapter callbacks from overwriting a larger
effective duration back down to the authored value.
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
- Split pan clamping: clampPreviewPan (drag/wheel-pan) stays narrow
(Math.max(0,...) — content pins to center when smaller than viewport).
New clampPreviewPanForZoom (Math.abs) gives the wide range only to
cursor-anchored zoom, preventing middle-mouse drag from pushing content
off-screen at low zoom levels.
- Pin transform-origin invariant: comment on the stage div noting that
resolvePreviewWheelZoom cursor math depends on center-center pivot.
New test verifies a non-center cursor keeps the same content-space
point fixed across a zoom step.
- Remove dead Math.abs(oldScale) > 1e-6 guard — oldScale >= 0.25 always
(clampPreviewZoomPercent floors at MIN_PREVIEW_ZOOM_PERCENT = 25).
- Skip setSettledZoom re-render when the value didn't change — uses a
functional updater that returns the previous state object when all
three fields match, avoiding a React re-render cascade through Player.
- Zoom anchors to cursor position instead of always zooming toward center.
The resolvePreviewWheelZoom function now accepts cursorX/cursorY (offset
from viewport center) and uses the standard zoom-to-point formula to
adjust pan so the content point under the cursor stays fixed.
- Add visible "Reset" button (bottom-right) showing current zoom % when
not at fit zoom. Driven by settledZoom state that updates after the
200ms settle debounce, so no re-renders during active zoom gestures.
- Fix border-expands-inward bug: scaleIframeToFit in the player now uses
offsetWidth/offsetHeight instead of getBoundingClientRect. The latter
returns values inflated by ancestor CSS zoom, causing double-scaling
that made the iframe appear smaller than its container.
- Fix zoom HUD appearing during pan: split applyZoom (shows HUD) from
applyPan (silent) so trackpad/middle-mouse panning no longer flashes
the zoom percentage overlay.
- Fix stale closure performance regression: replace stageSize in effect
dependency arrays with stageSizeRef pattern. The old deps caused wheel
and pointer handlers to re-register on every viewport resize.
- Widen pan clamp range (Math.abs instead of Math.max(0,...)) so content
can float within the viewport when zoomed below fit — required for
zoom-to-cursor to work correctly at any zoom level.
Closes#900