Skip to content

--fps 60 renders GSAP/DOM motion at 30fps (render seek re-quantizes to hardcoded canonicalFps=30) #1737

Description

@zerfl

Describe the bug

Rendering with --fps 60 produces a correct 60fps CFR file, but all GSAP/DOM-driven motion only updates 30 times per second — every other frame is an exact duplicate. WebGL shader transitions are NOT affected and play at a true 60fps, which makes it very visible: shader moments are smooth, every plain scene stutters.

The render-seek path re-quantizes every requested time onto a hardcoded canonicalFps = 30, regardless of the requested render fps. There is no attribute, CLI flag, or env var that changes it.

It does not show up in the Studio preview, because preview plays in real time off requestAnimationFrame and never goes through the render-seek path — so "smooth in preview, juddery in the render" is the tell.

Link to reproduction

https://github.com/zerfl/hyperframes-fps-repro

Steps to reproduce

  1. Clone the reproduction repo (a blank hyperframes init scaffold with one linearly-moving box).
  2. Render at 60 and at 30 fps:
    npx hyperframes@0.7.10 render . -o repro_60fps.mp4 --fps 60 --workers 1
    npx hyperframes@0.7.10 render . -o repro_30fps.mp4 --fps 30 --workers 1
  3. Play repro_60fps.mp4 — the box judders. Then inspect per-frame motion:
    ffmpeg -i repro_60fps.mp4 -vf "select='between(n,60,99)',signalstats,metadata=print:key=lavfi.signalstats.YDIF" -an -f null - 2>&1 | grep -oE 'YDIF=[0-9.]+'

Expected behavior

At --fps 60 the timeline is sampled on a 60fps grid: every output frame is a distinct timeline position, matching the (smooth) Studio preview. For the repro's linear motion, all 240 frames differ.

Actual behavior

The 60fps file updates motion only every other frame. The per-frame luma delta alternates motion/zero:

0.317 0.000 0.317 0.000 0.317 0.000 0.317 0.000 ...

= 30fps motion in a 60fps container. Exact duplicate count: repro_60fps.mp4 → 240 frames, ~104 exact consecutive duplicates; repro_30fps.mp4 (control) → 120 frames, 0 duplicates (30fps renders correctly).

Root cause: renderSeek → seekTimelineDeterministically → quantizeTimeToFrame(t, canonicalFps), with canonicalFps hardcoded to 30 (packages/core/src/runtime/state.ts) and never wired to --fps. So output frame k (time k/60) seeks to floor(k/2)/30, collapsing frames (0,1),(2,3),… in pairs. The shader path is exempt because it samples HF_VIRTUAL_TIME at the exact t. Full file:line breakdown in the attached ISSUE notes / repro README.

Environment

✓ Version          0.7.10 (latest)
✓ Node.js          v20.19.0 (darwin x64)
✓ CPU              16 cores · Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
✓ Memory           16.0 GB total · 6.0 GB available
✓ FFmpeg           ffmpeg 7.1.1 at /usr/local/bin/ffmpeg
✓ FFprobe          ffprobe 7.1.1 at /usr/local/bin/ffprobe
✓ Chrome           system: /Applications/Google Chrome.app/...
✓ Docker           Docker version 29.5.3 · running

Additional context

  • Deterministic, platform-independent (pure seek arithmetic), so Docker/Linux renders are affected identically.
  • The injected window.__HF_EXPORT_RENDER_SEEK_CONFIG (mode/step/offsetFraction, packages/producer/src/services/fileServer.ts) is never consumed (only referenced as a string in packages/core/src/compiler/htmlDocument.ts) — likely the intended home for a fix that drives canonicalFps from the render fps.
  • Workaround for anyone blocked: after hf.seek(t), re-seek the GSAP master to the exact t. Snippet in the repro's notes.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions