The browser's default 8px body margin was causing white bars at the top
and left edges of rendered videos. Previously normalizePreviewViewport
(a React call on the studio iframe) handled this for preview, but the
render pipeline never called it — creating a preview/render parity gap.
Fix: apply margin:0 / padding:0 / overflow:hidden on documentElement and
body at the start of initSandboxRuntimeModular, so both preview and render
contexts get identical normalization from the same code path.
Removes outline: '1px solid black' from the preview iframe in Player.tsx —
a leftover debug style that drew a visible rectangular border around every
composition preview.
Adds regression test 'sub-composition-video' to prevent recurrence of
the bug where video elements inside sub-compositions (lacking explicit
id attributes) rendered as black frames. Covers the three-part fix:
union selector in parseVideoElements, Infinity end fallback, and
auto-assigned id persistence into compiled HTML.
Blocks git commit via Claude Code if pnpm build, pnpm run -w lint,
or per-package typecheck fails, catching issues like the TS2345
error that slipped through to CI.
Root cause: buildChromeArgs/acquireBrowser imported from
@hyperframes/producer which does not re-export them; only
@hyperframes/engine does. All requests silently returned null.
Also: singleton shared browser (was spawning per request),
domcontentloaded instead of networkidle2, seek time 0.5→2s.
object-cover was cropping 9:16 portrait videos to a narrow slice inside
the fixed landscape 80x45px thumbnail cells. object-contain shows the
full frame with neutral-background letterboxing. Applies to Renders tab,
Compositions tab, Assets tab, and timeline clip strip thumbnails.
Render fix: parseVideoElements assigned IDs in JSDOM memory but the
compiled HTML written to disk had no id on id-less video elements, so
injectVideoFramesBatch's getElementById returned null. Now does a second
DOM pass to inject IDs into the HTML string before returning it.
Studio thumbnails: extracted VideoFrameThumbnail into a shared ui/
component. AssetsTab now uses canvas frame extraction (seeks to 10%
duration) instead of <video preload=metadata> which shows a black t=0
frame. RenderQueueItem imports from the same shared component.
The runtime uses data-start + data-duration (not data-end) to bound
media clips. parseVideoElements was only reading data-end, so videos
with only data-start (no data-end, no data-duration) got end=0 and zero
frames were extracted.
Two-part fix:
1. parseVideoElements now derives end from data-end → data-start+data-duration
→ Infinity (meaning 'play for natural duration').
2. The frame extractor fallback that probes the actual file now triggers
for both end<=0 and !isFinite(end), so Infinity correctly causes
the video file to be probed for its real duration.
Added info-level lint rule that flags compositions loading external CDN
libraries via <script src>. The bundler auto-hoists these into the
parent document and the runtime re-injects them in unbundled mode, but
the rule surfaces the dependency so developers know it exists if using
a custom pipeline.
Also added tests for the htmlBundler fix (external CDN scripts from
sub-compositions are preserved and deduped in the bundle output).
Replaced direct puppeteer.launch() call with the same acquireBrowser /
releaseBrowser / buildChromeArgs infrastructure the render pipeline
already uses. No new dependency is introduced (puppeteer-core is already
a transitive dep through @hyperframes/producer).
createDurationFloorTimeline appended a tween AFTER the existing
timeline, making it originalDuration + declaredDuration (e.g. 4.6+5.6=
10.2s) instead of just declaredDuration (5.6s). This caused regression
tests to render the wrong number of frames.
Now extends the original timeline in-place by placing a zero-duration
no-op tween at the declared end position — GSAP then reports the correct
duration with no composite overhead. Also adds a 0.5s minimum gap guard
to avoid floating-point false positives on compositions where the GSAP
timeline is already close to data-duration.
On hover the thumbnail slot swaps from the static extracted frame to a
live muted looping video in the same w-20 h-[45px] cell — no overlay,
no expansion. Static frame fades out (150ms opacity) as the video takes
over; video is only mounted while hovered so it doesn't run in the
background.
Extracted shared ExpandedVideoPreview component (src/components/ui/)
used by both AssetsTab (video assets) and RenderQueueItem (rendered
videos). Wraps completed render rows in ExpandOnHover with the same
spring-expand + video autoplay pattern as the Compositions and Assets
tabs. No code duplication: AssetsTab video case delegates to the shared
component, RenderQueueItem reuses it with an Open action button.
Extracts a first-representative-frame from the rendered MP4 using a
hidden video + canvas (same technique as VideoThumbnail). The thumbnail
cell matches CompCard's w-20 h-[45px] sizing so Renders, Compositions,
and Assets tabs all share the same visual language. Rendering/failed/
cancelled rows show a status indicator in the thumbnail slot instead.
The video[src][data-start] selector excluded production compositions
whose video elements have id+src but no data-start, breaking all visual
frames while audio passed. Now unions both selectors so videos are
discovered if they match either video[id][src] (original) or
video[src][data-start] (sub-composition without explicit id).
Clicking a completed render row now opens the video inline in the
browser instead of forcing a download. Added /render/:jobId/view
endpoint with Content-Disposition: inline so the browser plays the
video natively. Download button is preserved on hover for explicit
saves. stopPropagation on action buttons prevents row click conflicts.
When bundleToSingleHtml inlined sub-compositions, external <script src>
tags were silently dropped because $content(s).html() returns "" for
external scripts. This caused CDN libraries like lottie-web loaded only
in a sub-composition to be missing from the bundle, breaking animations.
Now external script URLs are collected and deduped into the bundle,
preserving CDN dependencies from any sub-composition.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
parseVideoElements used "video[id][src]" which silently excluded video
elements without an id attribute — common in sub-compositions. Changed
selector to "video[src][data-start]" and auto-assign stable ids for
any video that lacks one, so the frame injection pipeline processes all
timed video clips.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The CLI adapter was missing generateThumbnail, so the thumbnail route
always returned 501 and composition thumbnails showed as blank black
rectangles in both the Compositions sidebar and the Timeline.
The SSE progress stream already sent error details on failure but
the client never stored or displayed them. Failed renders now show
the error message below the status indicator, matching the detail
level of the CLI render command.
Two issues caused GSAP-controlled Lottie animations to display at the
wrong frame or not at all:
1. Adapter order: Lottie ran after GSAP, overriding the correct frame
from GSAP's onUpdate with wrong absolute-time ms.
Fix: move Lottie before GSAP so GSAP's onUpdate fires last and wins.
2. play() conflict: adapter called anim.play() causing Lottie to
advance independently, fighting GSAP's goToAndStop scrubbing.
Fix: remove play() from the Lottie adapter (it is optional).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
When a root composition declares data-duration="5" but the captured
GSAP timeline only has 3s of content, playback stopped at 3s.
resolveRootTimelineFromDocument now pads the timeline with a duration
floor tween to match the declared data-duration, so the composition
plays for its full declared length.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The static asset server was responding 200 OK with the full file for
all requests, including browser Range requests. Browsers send Range
requests when seeking audio/video elements; without 206 Partial
Content responses the seek fails silently and audio goes silent.
Now responds with 206 + Content-Range for byte-range requests and
always includes Accept-Ranges + Content-Length headers.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The download endpoint only checked the in-memory renderJobs map, which
is empty after a server restart. The list endpoint now registers on-disk
renders into the map when it serves them, so subsequent download
requests find the correct output file.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The spread operator created a shallow copy of jobState at call time.
Async mutations to the original state (progress, status) were never
reflected in the copy stored in renderJobs, so the SSE stream always
emitted progress:0.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
## Summary
- **Lint rules** catch media elements missing `id` (renderer silently skips them), missing `src`, `preload="none"` (blocks renderer), and video nested in timed divs (freezes playback). Upgraded `video_nested_in_timed_element` from warning to error.
- **Compiler** strips `preload="none"` from media during compilation. Runs parallel, cached keyframe interval analysis via ffprobe — warns on sparse keyframes (>2s) that cause seek failures and audio/video desync. Suggested ffmpeg command preserves audio (`-c:a copy`).
- **Pre-render lint** lints `index.html` + all `compositions/*.html` sub-compositions before render via shared `lintProject()` helper. Warns by default; `--strict` blocks on errors, `--strict-all` blocks on errors + warnings.
- **Render orchestrator** logs a hint to retry with `--workers 1` when parallel capture times out on video-heavy compositions.
- **Refactor**: extracted `runFfprobe()` + `parseProbeJson()` helpers to deduplicate ~80 lines of spawn boilerplate across 3 ffprobe functions. Extracted `shouldBlockRender()` so strict flag tests exercise production code. Shared `lintProject()` used by both `lint` and `render` commands.
## Context
Discovered during a real composition build session where:
1. `<audio>` without `id` rendered silently (preview worked fine because runtime queries `[data-start]`, but renderer queries `[id][src]`)
2. `<video>` inside timed `<div>` froze on first frame
3. `preload="none"` caused 45s renderer timeout
4. YouTube clips with sparse keyframes from `yt-dlp --download-sections` caused audio/video desync
5. Parallel workers timed out on video-heavy compositions
## Test plan
- [x] Core: 365/365 tests passing (5 new lint tests)
- [x] Engine: 24/24 tests passing
- [x] CLI: 14/14 tests passing (7 lintProject + 7 shouldBlockRender)
- [x] Lint + format hooks pass
- [ ] Manual: create a composition with `<audio data-start="0" src="test.wav">` (no id) — verify `npx hyperframes lint` catches it
- [ ] Manual: run `npx hyperframes render --strict` with lint errors — verify it blocks
- [ ] Manual: run `npx hyperframes render --strict-all` with lint warnings — verify it blocks
## Summary
- Surfaces error message when render fails due to producer server not running
- Two cases covered: initial POST failure and SSE connection drop
- Failed jobs now show the error reason in red text in the Renders panel
## Test plan
- [x] Start studio without producer server
- [x] Click render → should show "Could not reach render server" in red
- [x] Start render then kill producer → should show "Connection lost"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- `hyperframes lint` now scans ALL HTML files in the project (not just index.html)
- Includes composition files in `compositions/` directory
- `--json` output includes `file` field and `filesScanned` count
- Exit code 1 on errors for CI integration
## Examples
```bash
# Human-friendly output
hyperframes lint
# Agent/CI-friendly JSON
hyperframes lint --json
```
## Test plan
- [ ] `hyperframes lint` on a project with compositions → shows findings from all files
- [ ] `hyperframes lint --json` → outputs valid JSON with all findings
- [ ] Exit code 1 when errors found, 0 when clean
## Summary
- When running `hyperframes dev .` inside a symlinked directory, the project name showed the resolved target name instead of the visible directory name
- Now uses `$PWD` to preserve the user-facing name
- Added `projectName` option to `StudioServerOptions` for explicit override
## Test plan
- [ ] `ln -s /path/to/project my-project && cd my-project && hyperframes dev .` → should show "my-project"
- [ ] `hyperframes dev /path/to/project` → should show "project" (basename of path)
## Summary
- Replaces ~850 lines of inline route handlers in `vite.config.ts` with the shared `createStudioApi(adapter)` module
- Implements `StudioApiAdapter` for the Vite dev server context (SSR-loaded bundler/linter, producer HTTP proxy, Puppeteer thumbnails)
- Bridges Hono `fetch()` to Vite's Connect middleware with streaming support for SSE
Now **both** consumers (CLI + studio) use the same shared API module, ensuring feature parity.
## Test plan
- [x] `pnpm --filter @hyperframes/studio dev` starts correctly
- [x] Home page shows project grid with thumbnails
- [x] Preview plays with correct fonts/animations
- [x] Sub-composition drill-down works
- [x] Lint modal shows findings
- [x] File read/write works in code editor
- [x] Render queue works (requires producer server)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
Extracts all studio API routes into a shared Hono-based module at `@hyperframes/core/studio-api`.
### Architecture
- **`StudioApiAdapter` interface** — consumers inject host-specific behavior (project resolution, bundling, rendering, thumbnails)
- **Shared route modules**: projects, files, preview, lint, render, thumbnail
- **Shared helpers**: `isSafePath`, `walkDir`, `getMimeType`, `buildSubCompositionHtml`
### What this PR does
- Creates the shared module with all API routes extracted from both `vite.config.ts` and `studioServer.ts`
- Both consumers will be refactored in follow-up commits to mount this module with their own adapter
### What stays in each consumer
- **Vite**: SSR module loading, Puppeteer thumbnails, file watcher + HMR, producer HTTP proxy, multi-project scanning
- **CLI**: in-process `executeRenderJob`, local runtime serving, browser management, SPA static file serving
### Follow-up needed
- [ ] Refactor `packages/studio/vite.config.ts` to use `createStudioApi(adapter)` via `@hono/node-server`'s `getRequestListener`
- [ ] Refactor `packages/cli/src/server/studioServer.ts` to use `createStudioApi(adapter)`
- [ ] Add `./studio-api` export path to `packages/core/package.json`
- [ ] Add `hono` as peer dependency of `@hyperframes/core`
## Test plan
- [ ] Verify shared module compiles without type errors
- [ ] After consumer refactoring: all studio features work identically via both vite dev and CLI embedded servers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- Composition HTML files are always wrapped in `<template id="...">` tags
- The linter was checking the raw HTML including the template wrapper, causing false positives:
- `missing-composition-id` on files that have it inside `<template>`
- `missing-dimensions` on files that have `data-width`/`data-height` inside `<template>`
- Fix: strip `<template>` wrapper before linting, matching how the runtime and preview server handle these files
## Test plan
- [x] Added test: `strips <template> wrapper before linting composition files`
- [x] All 345 existing tests pass
- [ ] Verify lint panel no longer shows false positives for composition files
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- CLI render: use timestamped filenames (`project_date_time.ext`) matching the studio's naming convention, preventing overwrites of previous renders
- studioServer: read `fps`/`quality`/`format` from POST body instead of hardcoding `fps:30`/`quality:standard`/`mp4`
- studioServer: use timestamped job IDs matching the studio pattern
- studioServer: fix download endpoint to serve correct content-type for WebM
## Test plan
- [x] `hyperframes render --format webm` outputs timestamped WebM file
- [x] `hyperframes render` outputs timestamped MP4 (no overwrite)
- [x] Studio embedded server (`hyperframes dev`) renders with correct format when selected in UI
- [x] Download endpoint serves correct MIME type for WebM renders
## Summary
- Add render queue panel with progress tracking, download, and delete actions
- Restructure App layout: home page with project picker, session-based routing
- Add ExpandOnHover component for preview-on-hover interactions (uses motion/react)
- CompositionsTab now supports hover preview with expanded iframe view
- Vite config: guard setInterval cleanup to dev-only (fixes CI build timeout)
- Add favicon and update studio package deps
## Test plan
- [x] Render queue shows progress, completes, and allows download
- [x] Home page lists projects and navigates to session view
- [x] ExpandOnHover shows expanded preview on mouse hover with spring animation
- [x] `vite build` exits cleanly (no hanging process from setInterval)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- Add collapsible left sidebar with Compositions and Assets tabs
- Compositions tab lists all sub-compositions with thumbnail previews and navigation
- Clicking a composition opens it in the code editor AND navigates the preview to show that composition
- Assets tab categorizes project files by type (images, fonts, media)
- Race condition guard on composition fetch with functional state update
- Thumbnail error fallback shows name initial instead of empty box
## Test plan
- [x] Sidebar opens/closes with smooth transition
- [x] Compositions tab lists files from project API
- [x] Clicking a composition changes the preview iframe to that composition
- [x] Assets tab categorizes files by type
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- Replace Split/Delete buttons with Edit Range toolbar
- Edit button opens a time-range selection on the timeline
- "Copy to Agent" exports the selected range as a prompt-ready description
- Range selection shows start/end times with drag handles
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- **NLELayout**: Add toolbar slot, composition breadcrumb navigation, improved responsive layout
- **Vite config**: Add full project API (preview, thumbnail, render, file CRUD) for standalone dev mode
- Remove AgentActivityTrack component (replaced by timeline clips)
- Add HTML editor utilities for composition source editing
- Guard setInterval cleanup to dev-only to prevent `vite build` from hanging in CI
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- Add zoom state (zoomMode, pixelsPerSecond) to player store
- Add timeline element updates (setElements, clearElements)
- Remove unused/duplicate exports from player barrel file
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* feat(cli): add system metrics to telemetry and expand doctor command
Enrich render telemetry with device/environment metadata (CPU, memory,
OS, Docker/CI/WSL detection) following patterns from Next.js and
Turborepo. Add speed_ratio (render time / composition duration),
per-frame capture timing, and resource usage to render events.
Expand the doctor command with CPU, memory, disk, /dev/shm, and
environment checks to help debug rendering issues on user machines.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): invert speed_ratio to match experiment-framework convention
composition_duration / render_time — higher is better, >1 means faster
than realtime. Matches magic_edit.render.speed_ratio in experiment-framework.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): wire errorMessage into render error telemetry
Address review feedback — the errorMessage field was declared in the
trackRenderError interface but never populated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add render telemetry to embedded studio server
Track render_complete and render_error from the studio's render API
endpoint (hyperframes dev). Uses dynamic imports so telemetry is
resolved at call time within the CLI package — no telemetry coupling
added to @hyperframes/studio or @hyperframes/producer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Namespace skill names with `hyperframes-` prefix for clearer identity in
OSS contexts where users may have other skills installed.
Updates skill directories, SKILL.md frontmatter, CLAUDE.md, README.md,
CLI build script, init command, and project template.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Changes
Added a new lint rule `gsap_css_transform_conflict` that detects when GSAP animations will silently overwrite CSS transforms.
**`gsap_css_transform_conflict` (error)** — fires when an element has `transform: translateX(-50%)` or `transform: scale()` in CSS and a GSAP `tl.to/from` tween animates `x`, `y`, `xPercent`, `yPercent`, or `scale`. GSAP silently overwrites the full CSS transform, discarding centering tricks like `translateX(-50%)`. Fix hint guides authors to the safe `fromTo` + `xPercent` pattern.
## Root cause
This bug surfaced while building compositions where title reveals were placed off-center because `tl.to("#title", { x: 0 })` stripped the `translateX(-50%)` centering from CSS.
## Test coverage
- [x] `gsap_css_transform_conflict` — `tl.to` with `x` on CSS `translateX` element → error
- [x] `gsap_css_transform_conflict` — `tl.to` with `scale` on CSS `scale()` element → error
- [x] `gsap_css_transform_conflict` — `tl.fromTo` without CSS transform → no finding