Replaces the phone screen content in vfx-iphone-device with an iOS 26
home screen: glass app icons (Weather, Stocks, ChatGPT, Slack, X, etc.),
status bar with battery/signal, search pill, dock with badges. All on a
real GLTF iPhone model with camera choreography.
Complete rewrite of all 4 liquid glass registry blocks using
jeantimex/liquid-glass-html-in-canvas for real WebGPU glass rendering.
Architecture: Three.js aurora shader (z:0) + empty glass panels in
layoutsubtree canvas (z:1) + CSS text overlay (z:2). Text is crisp
and never passes through the glass shader.
- Renders via Brave with WebGPU + drawElementImage flags
- Continuous motion throughout — panels sweep across the screen
- liquid-glass.iife.js bundle (25KB) replaces liquid-dom (88KB)
Remaining review follow-ups:
- killProcessTree now escalates to SIGKILL after 500ms if SIGTERM
doesn't kill the process (same pattern as killTrackedProcesses).
Covers orphan cleanup and dev/local mode tree kill.
- Added unit tests for both new modules:
- processTracker.test.ts (6 tests): track/remove on exit/error,
kill running processes, SIGKILL escalation for SIGTERM-resistant
processes, idempotency.
- orphanCleanup.test.ts (5 tests): tree kill with children,
SIGKILL escalation, non-existent PID handling, orphan detection
returns 0 when clean.
- Blocker: arm 3s force-exit timer BEFORE awaiting cleanup, not
inside .finally(). Prevents hang if drainBrowserPool() blocks on
dead Chrome.
- Reorder cleanup: killTrackedProcesses() (sync, fast) runs first,
then async browser drain. Ffmpeg dies immediately instead of
surviving if the hard timer fires early.
- SIGKILL escalation: processTracker now SIGTERMs all tracked
processes, then SIGKILLs survivors after 500ms grace period.
- Scope pgrep to current user (pgrep -u $(id -u)) so orphan
detection doesn't touch other users' Chrome on shared machines.
- Add process.on('exit') handler for crash paths (unhandled
exceptions/rejections that bypass signal handlers).
- Document Windows no-op behavior on killProcessTree handlers.
FFmpeg's VFR-to-CFR normalization produces slightly different frame
counts across versions due to timestamp rounding in the fps filter.
The ±1 tolerance was too tight for Linux FFmpeg builds. Widen to ±3
frames — still catches the 25% shortfall regression these tests
guard against.
The preview command's shutdown handler only closed the HTTP server,
leaving Chrome (browser pool) and ffmpeg processes alive. This caused
silent resource leaks — orphaned processes consuming CPU and RAM with
no parent.
Root cause: preview.ts never called drainBrowserPool() or killed
tracked ffmpeg processes. The thumbnail browser in studioServer.ts
registered its own competing signal handlers that raced with
preview's shutdown.
Fix:
- Add a central process tracker (processTracker.ts) that registers
every spawned ffmpeg across engine and producer packages
- Centralize thumbnail browser cleanup via exported
closeThumbnailBrowser() instead of scattered signal handlers
- Wire preview shutdown to call closeThumbnailBrowser(),
drainBrowserPool(), and killTrackedProcesses() before closing the
HTTP server (embedded mode)
- Add killProcessTree() for dev/local modes where Chrome runs in a
child process tree
- Add startup orphan detection that finds and kills orphaned
chrome-headless-shell/Puppeteer Chrome processes (PPID=1) from
previously crashed sessions
Closes#1038
Split PlayerControls.tsx into focused sub-components (SeekBar,
WorkAreaOverlay, MuteButton, LoopButton, FullscreenButton,
ShortcutsPanel, SpeedMenu) and extracted seek bar drag/progress
tracking into useSeekBarDrag hook.
Split manualEditsDom.ts patch-builder functions into
manualEditsDomPatches.ts with data-driven helpers to reduce
duplication and complexity.
Extracted per-type reapply helpers from reapplyPositionEditsAfterSeek
and factored out identity-matrix check from
stripGsapTranslateFromTransform.
Raised file-size limit from 500 to 600 lines, removed
.filesize-allowlist.
The #f6f6f4 off-white background was showing through the 30px grid
gaps and behind translated cards during the zoom/unzoom transitions,
creating visible white strips in the rendered video. Changed to
#1a1a20 (near-black) so gaps read as intentional dark separators.
Also added a .zoom-backdrop div behind the grid in the zoom demo that
fades to the focus card's gold gradient during the transition, covering
any gaps left by sibling cards translating off-screen.
Co-authored-by: Kanyini <onebenson@gmail.com>
The renderer's Page.captureScreenshot can introduce a 1-2px offset
at viewport boundaries due to compositor subpixel rounding. Grid rows
are now 341px (3×341 + 2×30 = 1083), overflowing the 1080 viewport
by 3px. The overflow is clipped by overflow: hidden, but ensures any
capture offset still sees grid content rather than the body background.
Also removed flex centering from .demo-canvas — unnecessary now that
the grid fills the viewport, and it was a source of non-deterministic
positioning under multi-worker rendering.
Co-authored-by: Kanyini <onebenson@gmail.com>
Cards 360×340 with 30px gap = exactly 1920×1080. No body background
visible at any frame. Focus scale drops from 9 to 6 (360×6 = 2160,
still overshoots viewport by 240px).
Co-authored-by: Kanyini <onebenson@gmail.com>
- Increase --focus-scale from 8 to 9 — at scale 8 the card matched
the viewport width exactly (240×8=1920), leaving zero tolerance for
subpixel rounding. Scale 9 overshoots by 240px on each axis.
- Reduce grid gap from 40px to 24px — the 1040px grid in a 1080px
viewport left only 20px of padding, causing bottom-row cards and
their box-shadows to clip at the frame edge.
- Remove transform-style: preserve-3d from both snippets — no 3D
transforms are used, and preserve-3d can cause compositing artifacts
with overflow: hidden ancestors.
- Unzoom: set --pu-sibling-fade to 0 and drive sibling opacity via
GSAP (starting at t=2.0s). Previously, CSS-driven opacity made cards
70% visible when they first peeked into the viewport, creating
colored strips at the frame edge during the reveal.
Co-authored-by: Kanyini <onebenson@gmail.com>
Two companion components inspired by the eBay Playbook hero transition:
- parallax-zoom: center card scales up to fill the frame while siblings
parallax outward. Single CSS variable (--pz-progress 0→1), fully
seekable and deterministic.
- parallax-unzoom: the reverse — focus card starts at full-frame scale
and shrinks back into its grid position while siblings parallax inward.
Uses --pu-progress with the pu prefix to avoid variable collisions when
both components live in the same composition.
Designed to chain: zoom INTO a card in scene 1, unzoom OUT of it in
scene 2 to reveal a fresh grid underneath.
Includes demo compositions, registry manifests, and catalog pages.
Preview assets rendered and uploaded to CDN.
Co-authored-by: Kanyini <onebenson@gmail.com>
The rational `Fps = { num, den }` refactor in 5dcc89c broke callers
passing `fps: 30` (the form documented in every code example and used
by external consumers). FFmpeg received `undefined/undefined` as the
framerate, causing a cryptic exit-code error.
Add `FpsInput = number | Fps` and `toFps()` normalizer in
@hyperframes/core. `createRenderJob` now accepts both forms —
plain integers are promoted to `{ num, den: 1 }` at the boundary;
`RenderConfig.fps` stays strict `Fps` internally so no downstream
code changes.
Also fixes the producer and engine docs, which showed phantom
`input`/`output` fields on `createRenderJob` and a wrong
`executeRenderJob(job)` signature (missing `projectDir`/`outputPath`
args).
Closes#1031
Two fixes for the 3M+ unhandled_promise_rejection events/day spike:
1. Filter: suppress "Error fetching ... 404" rejections from composition
code — these are asset-not-found content errors, not Studio bugs.
2. Rate-limit: cap both error and rejection telemetry at 50 per session.
After the cap, emit a single *_cap_reached event so we know capping
occurred without generating unlimited events.
3. Root cause: webAudioTransport now checks response.ok before decode
and caches failed URLs in _failedSrcs so repeat ticks don't re-fetch
the same 404 on every playback frame.
Also add playground/ to fallow ignorePatterns — local experiment
directory was tripping the audit gate.
Flip the fallback from false to true so the blocks panel is on for
everyone out of the box. Users can still disable it via
VITE_STUDIO_ENABLE_BLOCKS_PANEL=false if needed.
Create a Tooltip component with styled popover (dark bg, border,
shadow) that appears on hover with a 400ms delay. Applied to:
- Left sidebar tabs: Code, Comps, Assets, Catalog
- Right panel tabs: Design, Layers, Motion, Renders
Replaces native title attributes with proper styled tooltips.
Add title attributes to interactive elements that were missing them:
- PlayerControls: Play/Pause, playback speed, shortcuts panel, clear
in/out-point buttons, and jump-to-frame Go button
- StudioRightPanel: Design, Layers, Motion, and Renders tab buttons
Call tl.pause() both before AND after tl.seek() in the adapter.
GSAP's seek() can reactivate a timeline depending on internal state;
the second pause() guarantees it stays frozen at the seeked position.
When seeking via the slider or timeline scrub, explicitly pause all
<video> and <audio> elements inside the preview iframe. The GSAP
timeline pauses but iframe media elements can continue playing
independently, causing audio to keep going after a seek.
Clicking "Ask agent" now opens a modal that shows the full generated
prompt so the user can read it before copying. The modal has a "Copy
prompt" button that turns green on success. This replaces the silent
clipboard copy that gave no visibility into what was copied.
- Ask agent button turns green with checkmark on copy
- Add button shows tooltip "Add to composition at current time"
- Ask agent shows tooltip "Copy a prompt to paste into your AI agent"
- Tab tooltips: Code, Comps, Assets, Catalog each explain their purpose
- Search placeholder updated to "Search by name, category, or tag…"
Add button rules: VFX, Social, Scenes get Add + Ask agent. Captions,
Transitions, Effects, Data get Ask agent only.
Search now matches category names and tags, so searching "captions"
shows all caption blocks.
Components (hyperframes:component) get both "Add" and "Ask agent"
buttons since they work as drop-in overlays. Blocks (scenes, data,
VFX, transitions) show only "Ask agent" since they need agent-guided
customization.
The "Ask agent" button now copies the current composition state along
with the category-specific prompt: playback time, active composition
path, dimensions, and all elements visible at the current time with
their track, timing, and source paths. Gives the agent full context
to place and customize the block correctly.
When previewing a component (compositions/components/*), render the
main composition player as a backdrop behind the transparent component
overlay. This lets users see captions, vignettes, and other overlays
in context instead of against a black void.
Registry blocks are authored at 1920x1080 but projects may use
different dimensions (e.g. 1280x720). After installing a block, the
server now reads the host project's data-width/data-height from
index.html and rewrites the block's viewport meta and CSS dimensions
to match, preventing overflow.
Remove the Add button and drag-and-drop from catalog cards — blocks
and components need agent-guided customization, not blind insertion.
Each card now shows a single "Ask agent" button that copies a rich,
category-specific prompt to clipboard with context about what the
block does and how to customize it (captions: transcribe + style,
transitions: place at cut point, data: replace values, etc.).
Each catalog card now shows two hover buttons:
- "Add" — inserts the block/component into the composition at the
current playhead position (existing behavior, now with + icon)
- "Agent prompt" — copies a contextual prompt to clipboard tailored
to the block's category (captions, transitions, VFX, etc.) so the
user can paste it into their AI agent for guided customization
Blocks and components now start at the current playhead position
instead of being appended after all existing content. If the new
element extends beyond the root composition's data-duration, the
root is automatically extended to fit.
insertTimelineAssetIntoSource now detects the parent indent level and
adds the new element with matching child indentation. Block attributes
are written one-per-line for readability.
Blocks were using their own native dimensions (e.g. 1920x1080) instead
of the host composition dimensions (e.g. 1280x720), causing them to
overflow the viewport and break the layout. The block's iframe scales
its content to fit the container, so using host dimensions is correct.
Replace fragile regex z-index parsing with getComputedStyle on the
preview iframe elements — the same source of truth the inspector uses.
Rename "Layer" to "Z-index" in the design panel for clarity.
The DomEditOverlay sits at z-10 with pointer-events:auto over the
preview, intercepting all drag events before they reach NLEPreview's
viewport. Move block drop handling from NLEPreview up to the wrapper
div in NLELayout that contains both the preview and the overlay, so
drag-and-drop from the Catalog panel onto the preview area works
regardless of inspector state.
VITE_STUDIO_* env vars set in the user's shell had no effect when
running `hyperframes preview` because the pre-built studio bundle had
them baked at Vite build time.
The embedded Hono server now collects VITE_STUDIO_* vars from
process.env and injects them as a `window.__HF_STUDIO_ENV__` script
tag into index.html. The client merges this runtime object on top of
the baked `import.meta.env`, so flags like
VITE_STUDIO_ENABLE_BLOCKS_PANEL=1 work as expected at runtime.
- Rename "Blocks" tab to "Catalog"
- Replace fullscreen hover popup with inline preview in main area
- Fix z-index: newly added blocks/components use max existing z-index + 1
instead of element count, ensuring they appear on top
Blocks dragged from the Blocks panel and dropped onto the composition
preview now land at the position where they were dropped, instead of
always being placed at (0, 0).
The preview viewport converts screen coordinates to composition space
using the stage element's bounding rect, accounting for zoom and pan.
A visual drop indicator (dashed border overlay) appears while dragging
over the preview.
When the Code tab is active and a user clicks an element in the preview,
the code editor now auto-scrolls to the corresponding HTML source. This
removes the need for Alt+click — the existing click-to-source mechanism
fires automatically when the Code tab is already open.
- Add getTab() to LeftSidebarHandle for reading the active tab
- Add getSidebarTab getter to useDomEditSession
- Add effect that calls openSourceForSelection on selection change
when Code tab is active
Previous baseline was generated on arm64 host, causing 87 failed
frames in CI (amd64). Regenerated with --platform linux/amd64 to
match CI's Chrome/FFmpeg pixel output.
gsap.fromTo(target, fromVars, toVars) animates to toVars, not to
the current CSS value — so fromTo({opacity:0}, {opacity:1}) with
CSS opacity:0 is a legitimate 0→1 fade-in, not a noop. The rule
was false-positiving on these calls with error severity, which
would block the render pipeline.
Drop the fromTo branch from the trigger guard and add a test case
that proves fromTo does not fire.
Detects when an element has CSS `opacity: 0` (inline or style block) AND
is targeted by gsap.from({opacity: 0}). Since from() animates FROM the
specified value TO the CSS value, this produces a 0→0 animation where
the element never becomes visible.
Root cause of all-black renders from the product-launch-video skill:
every text element had opacity:0 in CSS + gsap.from({opacity:0}),
making all text permanently invisible despite the timeline "working."
Fires as error (not warning) to block the render pipeline. Includes
actionable fix hint. 4 test cases: inline style, style block, clean
code (no false positive), and gsap.to() exit (no false positive).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up to the playhead-preservation fix: clean up the now-unreachable
crossfade infrastructure that was only triggered by refreshKey changes.
- Remove retiringKey state, retiringTimerRef, handleNewPlayerLoad
- Remove the retiring Player render block and conditional onLoad/style
- Drop refreshKey from getPreviewPlayerKey signature and NLEPreviewProps
- Stop passing refreshKey from NLELayout to NLEPreview
- Update NLELayout comment to reflect current iframe.src reload model
- Simplify getPreviewPlayerKey test
The text-rendering:geometricPrecision rule (b7bd9565) shifted glyph advances
~1% in headless Chrome. Baselines for png-sequence, heygen-promo-preview-assets,
and sub-composition-video were already regenerated — sub-comp-t0 was missed.
Also improves the fixture's visual contrast:
- Background: #07110d (near-black) → #0f172a (slate-900)
- Hook scene: background #1e293b, text #ef4444/#ffffff
- Later scene: background #334155, text #ffffff
- Absolute positioning for text elements (compiler inlines sub-comp
content at intrinsic width, collapsing the flex container)
Baseline regenerated inside Dockerfile.test per CLAUDE.md. Test passes:
0 failed frames, 100/100 checkpoints green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stop NLEPreview from including refreshKey in the Player's React key.
Previously, a refreshKey change caused a full Player teardown + remount,
which destroyed the playback adapter before refreshPlayer() could save
the seek position — so the playhead always reset to 0:00.
Now refreshKey changes only trigger refreshPlayer()'s lightweight
iframe.src reload path, which correctly captures the current time via
saveSeekPosition() before reloading the iframe content.
Closes#996
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.
Add distraction-free fullscreen mode using the HTML5 Fullscreen API.
Press F to enter fullscreen (Esc to exit). When active, the composition
fills the screen — timeline, sidebars, and editing overlays are hidden
while all playback shortcuts (Space, J/K/L, arrows, etc.) remain
functional. A fullscreen toggle button is also added to player controls.
Closes#995
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)
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
## Summary
### `data-timeline-locked` attribute
- Clips with this attribute are fully locked in the Studio timeline (no move, no trim-start, no trim-end)
- Parsed in `timelineDOM.ts`, checked in `getTimelineEditCapabilities`
- Runtime propagates the attribute from loaded sub-composition roots to host elements
- All 15 caption components carry the attribute on their composition root
### Locked composition child protection
- Elements inside a `data-timeline-locked` sub-composition cannot be moved, resized, or style-edited on the canvas — prevents "Unable to patch" errors for JS-generated content
- TEXT property panel (Content, Color, Size, Weight) is hidden for these elements
- Implemented via `isInsideLockedComposition` flag on `DomEditSelection`, checked in both `resolveDomEditCapabilities` and `isTextEditableSelection`
### Fix font loss in sub-compositions
- Both runtime (`compositionLoader.ts`) and compiler (`inlineSubCompositions.ts`, `htmlBundler.ts`, `htmlCompiler.ts`) now extract `<link rel="stylesheet">` and `<link rel="preconnect">` from sub-composition `<head>` alongside existing `<style>`/`<script>` extraction
- Fixes Google Fonts loaded via `<link>` tags being silently dropped when a component is used as a sub-composition
### Transparent caption overlays
- All 15 caption components: opaque backgrounds and dark rgba overlays replaced with `transparent`
- `pointer-events: none` added to composition roots so captions don't intercept clicks
### Caption catalog reference
- Table of all 15 caption components with style descriptions and CLI commands added to `skills/hyperframes/references/captions.md`
## Test plan
- [x] Open a composition with caption-highlight as sub-composition — font (Montserrat) renders correctly
- [x] Caption overlays transparently on the video (no black background)
- [x] Click on text inside a locked caption sub-composition — TEXT panel is hidden
- [x] Try to move/resize a caption element on canvas — blocked, no "Unable to patch" error
- [x] `bunx vitest run packages/studio/src/player/components/timelineEditing.test.ts` — 37 tests pass
- [x] In Studio timeline, verify a `data-timeline-locked` clip cannot be moved or trimmed
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.