Skip to content

feat(core): add studio animation preview APIs#682

Merged
miguel-heygen merged 1 commit into
nextfrom
feat/studio-alpha-runtime-api
May 8, 2026
Merged

feat(core): add studio animation preview APIs#682
miguel-heygen merged 1 commit into
nextfrom
feat/studio-alpha-runtime-api

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 8, 2026

Problem

The Studio alpha editor work needs runtime/server support before the UI can be reviewed cleanly: previews and thumbnails need cache-aware project signatures, and render paths need a deterministic way to apply Studio-authored GSAP motion during preview/render without mixing that logic into the Studio UI commit.

What this fixes

  • Adds a project signature helper used by Studio preview/thumbnail responses.
  • Adds the Studio motion render-body script helper and package export.
  • Extends preview and thumbnail route coverage for the animation/layer inspector stack.
  • Keeps this bottom PR scoped to Core Studio API support so the upstack UI PR can stay reviewable.

Root cause

The previous single PR mixed server API support with the full editor surface. Reviewers could not reason about whether deterministic preview/thumbnail behavior was correct independently from the UI interactions.

Verification

Local checks

  • bun run --filter @hyperframes/core test -- src/studio-api/routes/preview.test.ts src/studio-api/routes/thumbnail.test.ts src/studio-api/helpers/studioMotionRenderScript.test.ts
  • bun run --filter @hyperframes/core build
  • bun run --filter @hyperframes/studio build from the top of the stack

Browser verification

Browser proof is exercised in the upstack Studio UI PR because this PR only adds the server/runtime support. The full stacked behavior was verified at:

  • qa-artifacts/graphite-stack/default-layer-panel.png
  • qa-artifacts/graphite-stack/default-layer-panel-flow.webm
  • qa-artifacts/graphite-stack/env-enabled-motion-tab.png

Notes

  • This is the bottom of the Graphite stack.
  • Local browser artifacts are intentionally untracked and not part of the PR diff.

Copy link
Copy Markdown
Collaborator Author

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: comment (no blockers; a few important points worth addressing before #683 merges).

The change is scoped well — bottom-of-stack runtime/server support, no UI churn, strong tests. Tight typing throughout, no any leaks across the new public surface. Reviewing this independently of #683 confirms the slice was the right call.

Below: review focuses on long-lived contract shape (this is now exported public API) and a couple of correctness/observability gaps. Nothing here blocks merge if you're comfortable accepting the trade-offs.


Important

  • important — packages/core/src/studio-api/helpers/projectSignature.ts:142createProjectSignature is fully sync: recursive readdirSync, lstatSync per entry, and readFileSync of every text-eligible file (up to 2 MB each) on the FIRST call per project. The fingerprint cache makes subsequent calls cheap (one walk, one short hash), but the first call after every project resolve, server restart, or fingerprint miss runs in the request path of /projects/:id/preview and blocks the event loop. On a non-trivial project this will be noticeable; in a multi-tenant studio host it could cascade. Two options worth considering: (a) move to async fs APIs and await it from the preview handler, or (b) stream-warm the cache on resolveProject so the preview path is always a fingerprint-only pass. Not a blocker today, but the API shape (sync string return) bakes the choice in — worth deciding now.

  • important — packages/core/src/studio-api/helpers/projectSignature.ts:23.hyperframes is in SIGNATURE_EXCLUDED_DIRS, so the signature does not change when studio-motion.json or studio-manual-edits.json change. thumbnail.ts already keys its disk cache on motionKey + manualEditsKey independently, which is fine. But the new <meta name="hyperframes-project-signature"> tag is now an implicit contract for the upstack (#683) and any future consumer. If #683 — or anything else — treats this signature as a "did the project change" signal to bust its own caches, motion-only or manual-edits-only edits will silently use stale frames. Either rename the meta to make the scope explicit (hyperframes-project-source-signature) or fold the manifest content hashes into it. Please confirm #683 isn't already reading this for cache invalidation; if it is, this is a correctness issue.

  • important — packages/core/src/studio-api/routes/preview.ts:16-17 — GSAP and CustomEase are pulled from cdn.jsdelivr.net. Because preview HTML is also what the headless renderer loads (per the PR body: "render paths need a deterministic way to apply Studio-authored GSAP motion during preview/render"), renders now have a hard runtime dependency on jsdelivr availability + the floating gsap@3 tag. That's a determinism + reliability hit on the render path that the old non-Studio path didn't have. At minimum, pin the version (gsap@3.12.x instead of gsap@3); ideally serve from a local/asset-bundled path the way the runtime script already is. Acceptable to defer if Studio-authored motion is preview-only for now and the render path will be locked down before Studio motion ships to users — please call out which one.

  • important — packages/core/src/studio-api/routes/thumbnail.ts:9 vs packages/core/src/studio-api/helpers/studioMotionRenderScript.ts:5STUDIO_MOTION_PATH is now exported from the helper and re-declared as a local string in thumbnail.ts. Two sources of truth; if the on-disk path ever moves, thumbnail.ts will silently drift and stop busting its own cache. Same pattern already exists for STUDIO_MANUAL_EDITS_PATH (thumbnail.ts:8) — if it's worth a constant export here, it's worth it for both. Cheap fix: import STUDIO_MOTION_PATH from the helper, lift the manual-edits constant similarly while you're in there, or kill the export and standardize on local declaration. Either way, pick one.

Nits

  • nit — packages/core/src/studio-api/helpers/studioMotionRenderScript.ts:14createStudioMotionRenderBodyScript returns a non-null script for {"version":1,"motions":[]} (valid JSON, empty motions). The runtime then no-ops inside applyManifest. Small wasted bytes on the wire and a body script tag that has nothing to do. Cheap to short-circuit on parsed-empty.

  • nit — packages/core/src/studio-api/helpers/studioMotionRenderScript.ts:80__hfStudioMotionApply closes over manifestContent from page-load, so consumers can't push a new manifest without a full preview reload. That's a fine design choice for now, but it should be documented on the export — otherwise #683 / Studio UI may assume they can hot-swap manifests via this hook.

  • nit — packages/core/src/studio-api/index.ts:2 — None of the new exports (createProjectSignature, STUDIO_MOTION_PATH, createStudioMotionRenderBodyScript, StudioMotionRenderScriptOptions, the getProjectSignature adapter hook in types.ts:44) carry JSDoc. These are now public API surface. A two-line JSDoc on each — what it does, when to use it, what it does not cover (e.g., the .hyperframes exclusion above) — would save the next consumer a code-archaeology trip.

  • nit — packages/core/src/studio-api/routes/preview.ts:66htmlHasGsap matches gsap.config|defaults|registerPlugin|version. False positives (e.g., a string literal "gsap.version" in user code) silently skip the CDN inject; the runtime guard runtimeWindow.gsap?.timeline keeps that safe. Worth one comment line noting it's an intentional best-effort heuristic.

  • nit — packages/core/src/studio-api/helpers/projectSignature.ts:36projectSignatureCache: Map has no LRU / size cap. Entries are tiny so this is unlikely to matter, but in long-lived multi-tenant hosts it grows monotonically with distinct project dirs. Consider a small LRU or eviction on adapter project teardown if that ever becomes a thing.

Praise

  • Tests cover the meaningful surface: empty manifest, GSAP-missing fast path, CustomEase registration ordering, sub-composition path resolution, signature stability across calls, content-edit invalidation, AND symlink-file + symlink-loop handling. The symlink-loop test in particular is the kind of thing that usually only gets added after a postmortem.
  • Original-attribute snapshot pattern in the motion runtime (data-hf-studio-motion-original-{transform,opacity,visibility}) plus restoreElement gives a clean teardown story when the manifest is re-applied. Good thinking on lifecycle.
  • Public-API split is the right shape: route does HTML wiring, helper does runtime, manifest path is a single exported constant — that's the contract the upstack should consume.
  • PR body is the kind of write-up that makes Graphite stack review tractable: explicit scope, what each PR is not doing, the reasoning behind splitting render-body support out from the UI commit, and verification artifacts at the top of stack. More of this please.

— Vai

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bottom of a 2-PR Graphite stack. Approve. Architecture is right, public API surface is minimal and matches what the upstack UI PR (#683) actually consumes, test coverage is comprehensive. One non-blocking cleanup nit on a duplicated path constant.

Scope I audited (≈968 LoC)

Read in full:

  • packages/core/src/studio-api/helpers/projectSignature.ts (new, 142)
  • packages/core/src/studio-api/helpers/studioMotionRenderScript.ts (new, 248)
  • packages/core/src/studio-api/helpers/studioMotionRenderScript.test.ts (new, 201)
  • packages/core/src/studio-api/routes/preview.ts (+130/-4)
  • packages/core/src/studio-api/routes/preview.test.ts (new, 197)
  • packages/core/src/studio-api/routes/thumbnail.ts (+9/-1)
  • packages/core/src/studio-api/routes/thumbnail.test.ts (+24)
  • packages/core/src/studio-api/types.ts (+3)
  • packages/core/src/studio-api/index.ts (+6)
  • packages/core/package.json (+8) — new export entry

What I verified against the source and #683's needs

Public API surface is exactly what #683 needs, no over-exporting. Cross-referenced studio-api/index.ts exports against git grep of #683's consumption:

  • STUDIO_MOTION_PATH — consumed by packages/studio/src/App.tsx:117 (writes the manifest file from the studio app to disk). ✓
  • createProjectSignature — exported as a fallback for adapters that don't supply their own; only used internally by routes/preview.ts:20 via the getProjectSignature? adapter hook. ✓
  • createStudioMotionRenderBodyScript + StudioMotionRenderScriptOptions — exported for future render-adapter consumers; only used internally by preview.ts today. Reasonable forward-compatibility surface. ✓
  • getProjectSignature? on StudioApiAdapter — additive (?), doesn't break existing adapters. ✓

Project signature is content-aware and safe under the obvious adversarial inputs:

  • Symlinks are explicitly skipped (test skips symlinked files and skips symlinked directories both pass) — verified lstatSync + isSymbolicLink() short-circuit in collectProjectSignatureFiles. ✓
  • MAX_SIGNATURE_TEXT_BYTES = 2_000_000 caps per-file content reads; oversize files fall back to mtime in the hash. ✓
  • SIGNATURE_EXCLUDED_DIRS (node_modules, dist, .git, .cache, etc.) keeps the walk bounded.
  • Two-stage hash (cheap fingerprint of name+size+mtime+text-eligibility → cache check → expensive content hash on miss) means repeat previews don't re-read the project. ✓
  • 24 hex chars (96 bits) of entropy on the signature — collision risk is negligible for cache-key purposes.
  • readFileSync errors caught and fall back to mtime — doesn't crash the preview.

Studio motion runtime is idempotent and contract-locked:

  • parseMotionValues is an explicit allowlist of x, y, scale, rotation, opacity, autoAlpha. Future motion fields can't sneak in without updating the runtime — that's the right contract surface for a runtime-injected script. ✓
  • restoreStudioMotionElements reads data-hf-studio-motion-tagged elements and restores the original transform/opacity/visibility from data-* attributes captured at first apply. Re-apply is safe. ✓
  • runtimeWindow.__hfStudioMotionApply = applyManifest exposes the apply function so the studio UI can re-trigger it after manifest writes (verified #683's App.tsx calls into this). ✓
  • CustomEase is registered before tweens are added — resolveEase runs registerPlugin + create before fromTo consumes the eased timeline. Plugin ordering is correct.
  • applied === 0 early-return calls timeline.kill?.() — no dead empty timeline left in __timelines. ✓

Thumbnail cache invalidates on motion changes. thumbnail.ts now hashes studio-motion.json content (sha1, 16 hex chars) and folds it into the cache key alongside manualEditsKey. The added test (keeps changed studio motion separated in the disk cache) confirms two different motion contents produce two generateThumbnail calls. ✓

Preview HTML augmentation is conditional, not unconditional.

  • injectStudioMotionDependencies only adds the GSAP CDN script tag if manifest.motions.length > 0 — a project with an empty/missing manifest pays no GSAP-CDN cost. ✓
  • htmlHasGsap is a defensive detector covering CDN, inlined, GreenSock global, and gsap.config/.defaults/.registerPlugin/.version patterns — won't double-inject if the user's composition already loads GSAP. ✓
  • CustomEase CDN injection is gated on both hasCustomEase AND !htmlHasCustomEase — no double load.
  • Script ordering enforced and tested: gsap.min.js < CustomEase.min.js < __hfStudioMotionApply (test injects the GSAP CustomEase plugin when Studio motion uses a custom ease asserts the indexOf order). ✓

Non-blocking cleanup — STUDIO_MOTION_PATH is now defined twice

packages/core/src/studio-api/routes/thumbnail.ts:9 declares its own local copy:

const STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";

While helpers/studioMotionRenderScript.ts:5 is the canonical export, and routes/preview.ts:12 correctly imports it. The duplication mirrors the existing STUDIO_MANUAL_EDITS_PATH pattern (which also duplicates between thumbnail.ts and elsewhere) — so this isn't introducing a new pattern, just extending the existing one. But: the canonical STUDIO_MOTION_PATH is now an exported public constant, and the file in the same package re-declares it locally. That's a drift hazard if the path ever changes (it won't, but the discipline is cheaper to clean now than later).

Easy one-line fix:

import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";

Optional. Not blocking the stack.

Praise

  • The two-stage signature (fingerprint → cache check → content hash on miss) is exactly the right shape for cache-key generation. The fast path is just lstatSync per file, which a studio server can serve for free on every preview request.
  • Strict allowlist in parseMotionValues for the runtime contract — future motion fields require an explicit runtime change. That's the correct posture for code that's stringified and executed in a sandboxed iframe.
  • applyManifest exposes itself as __hfStudioMotionApply for re-entry — gives the studio UI a clean integration point without coupling the runtime to the UI's lifecycle.
  • The signature meta tag in preview HTML (<meta name="hyperframes-project-signature">) gives the studio a first-class way to detect "did the underlying project change?" without polling timestamps. Clean primitive.
  • Test coverage covers the trickier paths: symlink handling, sub-composition activeCompositionPath resolution, plugin-injection ordering, signature stability across no-op reads.

Summary

Clean foundation. Public API matches consumer needs. ✅ — moving to #683.

— Review by Rames Jusso (pr-review)

@miguel-heygen miguel-heygen force-pushed the feat/studio-alpha-runtime-api branch from 3ec6f57 to f98a7ef Compare May 8, 2026 22:19
@miguel-heygen miguel-heygen force-pushed the feat/studio-alpha-runtime-api branch from f98a7ef to 74d8025 Compare May 8, 2026 23:13
Copy link
Copy Markdown
Collaborator Author

miguel-heygen commented May 8, 2026

Merge activity

  • May 8, 11:23 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • May 8, 11:23 PM UTC: @miguel-heygen merged this pull request with Graphite.

@miguel-heygen miguel-heygen merged commit 4c211d5 into next May 8, 2026
24 checks passed
@miguel-heygen miguel-heygen deleted the feat/studio-alpha-runtime-api branch May 8, 2026 23:23
miguel-heygen added a commit that referenced this pull request May 8, 2026
## Problem

The Studio animation/manual-editing alpha had grown into one oversized PR. Reviewers need the actual user-facing editor changes separated from the lower-level preview API support, and the alpha controls need safer defaults while we keep testing manual dragging and the motion panel.

## What this fixes

- Moves Clip Layers into the left sidebar as a takeover surface so it no longer looks like part of the video canvas.
- Hides the normal left-panel tabs while Clip Layers is open, keeps nested layer counts visible, and keeps timeline clip inspector buttons available.
- Keeps Motion and preview manual dragging behind env flags: `VITE_STUDIO_ENABLE_MOTION_PANEL` and `VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING`.
- Keeps the default inspector focused on Design/Renders while preserving the opt-in GSAP motion panel for alpha testing.
- Carries the timeline thumbnail/player performance work and selection timing fixes needed for the layer inspector flow.
- Adds/updates tests for layer inspection, motion availability, DOM editing, timeline behavior, and player seeking.

## Root cause

The old overlay-based layer picker competed visually with the preview content, and the motion/manual-dragging alpha controls were always present even when the team wanted to test the design panel without those experimental surfaces. Splitting this into a Graphite stack also avoids reviewing Core API changes and Studio UI changes in one giant diff.

## Verification

### Local checks

- `bun run --filter @hyperframes/studio test -- src/components/editor/TimelineLayerPanel.test.ts src/components/editor/manualEditingAvailability.test.ts src/components/editor/PropertyPanel.test.ts src/components/editor/domEditing.test.ts src/components/editor/studioMotion.test.ts src/utils/timelineInspector.test.ts src/player/components/Timeline.test.ts src/player/components/timelineEditing.test.ts src/player/hooks/useTimelinePlayer.test.ts`
- `bun run --filter @hyperframes/core test -- src/studio-api/routes/preview.test.ts src/studio-api/routes/thumbnail.test.ts src/studio-api/helpers/studioMotionRenderScript.test.ts`
- `bun run --filter @hyperframes/core build`
- `bun run --filter @hyperframes/studio build`

### Browser verification

Tested with `agent-browser` against `hf-manual-editing-demo` on the split stack.

- Default flags on `http://127.0.0.1:5298/?v=graphite-default-proof#project/hf-manual-editing-demo`: Clip Layers opens in the left sidebar, Design/Renders remain visible, and Motion is hidden.
- Env flags on `http://127.0.0.1:5299/?v=graphite-env-proof#project/hf-manual-editing-demo`: Inspector exposes the Motion tab.
- Screenshots: `qa-artifacts/graphite-stack/default-loaded.png`, `qa-artifacts/graphite-stack/default-layer-panel.png`, `qa-artifacts/graphite-stack/env-enabled-inspector.png`, `qa-artifacts/graphite-stack/env-enabled-motion-tab.png`.
- Recording: `qa-artifacts/graphite-stack/default-layer-panel-flow.webm`.

## Notes

- This PR intentionally depends on #682 for the Core Studio API support.
- `qa-artifacts/` remains local/untracked.
- Existing PR #680 is superseded by this Graphite stack.
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-merge advisory review (this PR is already merged into next). Findings flagged for follow-up, not gating.

Verdict — Approve in spirit, 3 follow-ups worth pulling out

This is the right cut of the stack. Splitting Core API/runtime support from the Studio UI commit makes the inspector alpha auditable in isolation. Tests for studioMotionRenderScript, preview, and thumbnail are solid additions. Kicking off below with where I'd want eyes before the alpha widens.

Follow-ups (worth a separate ticket)

  1. projectSignature.ts does synchronous full-tree reads on every cache check. collectProjectSignatureFiles walks the entire project recursively with lstatSync/readdirSync, and createProjectSignature then readFileSyncs every text-eligible file up to 2 MB. This is gated by an mtime fingerprint cache — which is fine — but the first request and any mtime-change request re-reads the entire project under the request handler. For multi-slide decks like apple-presentation this is non-trivial I/O on the hot path for preview/thumbnail responses. Reason: under load (multiple Studio tabs, watcher-triggered cache invalidation, render queues), this can stall the dev server. Suggest: stream the hash via a single walker, or use a content-hash cache keyed on (file, mtime, size) instead of re-reading.

  2. Symlink behavior intentional but undocumented. if (stat.isSymbolicLink()) continue; silently drops symlinked sub-projects from the signature. That's safe but causes silent cache-bust misses if someone symlinks an assets dir in (e.g. Miguel's own Downloads/apple-presentation workflow described in #687). Reason: edits to a symlinked asset won't bust the preview cache and the user sees stale renders with no diagnostic. Suggest: at minimum a debug log when a symlink is skipped, or follow symlinks that resolve inside projectDir.

  3. MAX_SIGNATURE_TEXT_BYTES = 2_000_000 falls back to mtime-only. Files > 2 MB get hashed by mtime alone. For a .svg or generated .json near the boundary this means edits get represented by mtime, which can flap on a touch even without content change. Low-impact today, but flag it before someone debugs a stale preview.

Nits

  • STUDIO_SIGNATURE_MANIFEST_PATHS is declared as const but STUDIO_MANUAL_EDITS_PATH was just lifted from manualEditsRenderScript.ts — the two duplicate the manifest path string. Consider re-exporting one canonical constant.
  • projectSignatureCache is a module-level Map with no eviction. In a long-running dev server with many projects opened/closed, this leaks. Bounded LRU or per-project invalidation would be cleaner.

Praise

  • Clean separation. The fact that you can read this PR without needing the Studio UI context is exactly the point of the Graphite split.
  • The mtime fingerprint → content hash two-tier check is the right pattern for this kind of cache.
  • Test coverage on preview.ts and thumbnail.ts is strong, including the new signature paths.

— Vai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants