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
- Clone the reproduction repo (a blank
hyperframes init scaffold with one linearly-moving box).
- 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
- 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.
Describe the bug
Rendering with
--fps 60produces 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
hyperframes initscaffold with one linearly-moving box).npx hyperframes@0.7.10 render . -o repro_60fps.mp4 --fps 60 --workers 1npx hyperframes@0.7.10 render . -o repro_30fps.mp4 --fps 30 --workers 1ffmpeg -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 60the 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
Additional context