Skip to content

feat(core): data-motion-blur-phase sets when the shutter opens - #6983

Merged
vanceingalls merged 3 commits into
mainfrom
feat/motion-blur-phase
Nov 5, 2026
Merged

vanceingalls merged 3 commits into
mainfrom
feat/motion-blur-phase

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What this adds

A new root attribute, data-motion-blur-phase, that sets when the shutter opens. It is in degrees of a frame, like the shutter angle, and clamps to -360 to 360.

The sampling window starts at t + phase / 360 / fps and lasts shutter / 360 / fps. So at 180°:

  • unset, or -90: centred on the frame, as today
  • 0: opens on the frame and runs half a frame ahead
  • 360: opens one whole frame later

It works on both the capture and filter tiers, which now take the window from one function, shutterTimes in frames.ts. It is parsed in motionBlurSettings.ts, and the Studio panel's level control leaves it alone. Documented in docs/guides/motion-blur.mdx and the CLI docs.

Why

Page blur can only centre the shutter, and After Effects has a Shutter Phase setting. In a test corpus of 68 After Effects exports, 133 of the 134 blurred layers use a non-centred shutter (180/0 is the most common, then 299/360), and every one of those layers that moves in 2-D is off-centre. Without this, a smear lands in the wrong place, and the difference grows with speed. The After Effects exporter needs it to reproduce those shutters.

What it does not change

  • Any existing page. With no attribute the window is exactly what it was: phase defaults to minus half the shutter, and the arithmetic comes to the same number. Renders below match the released 0.8.223 byte for byte.
  • 3-D movers. Page blur still skips them.
  • The engine's sub-frame blur, the shutter default, and the samples, tier and events attributes.
  • The Studio controls. There is no UI for phase; an authored value survives a level change.

Evidence

A 2-D box moving 10 px/frame, shutter 180°, rendered through this branch. At t = 1 s the box's left edge is at x = 300. Smear columns give the first pixel with any ink and the first fully lit one:

phase filter tier capture tier filter smear capture smear
unset df1341e3 b1e8ae7f 298 → 302 298 → 302
-90 df1341e3 b1e8ae7f 298 → 302 298 → 302
0 e471c1d8 88e006f4 300 → 305 301 → 305
360 2aaa32e7 85d1b52b 310 → 315 311 → 315

Unset and -90 are md5-identical to each other and to the same page on released 0.8.223.

Tests

New tests were seen failing first, and reverting the one line in frames.ts turns 4 of them red.

  • Settings: reading, clamping at ±360, 0 kept as a real value, unreadable treated as unset, round trip.
  • frames.test.ts: no phase reads exactly the old times; phase 0 at 180° starts at t; phase 360 starts a frame late; -90 at 180° equals unset; reads never go before 0.
  • index.test.ts: a root attribute reaches the seek times on both tiers, and both stay centred without it.

Local: core 2,730 passed, Studio panels 95 passed; tsc, runtime typecheck, oxlint, oxfmt, comment citations and the comment ratchet all clean.

🤖 Generated with Claude Code

@mintlify

mintlify Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Nov 2, 2026, 5:33 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 94 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (1)

  • seqplainkeys-none-px-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at 51da80f8. The code is good and I found nothing blocking. I'm posting COMMENT, not APPROVE, only because this head conflicts with main and CI never ran on it. I'll approve the rebased head once it's green.

Merge state. git merge-tree --write-tree HEAD origin/main shows two conflicts, both in tests:

  • frames.test.ts is add/add. #7080 created the same file on main, with its own laidOut and rect helpers. Keep both describe blocks.
  • index.test.ts gets your new describe at the end of the file, and main changed the same tail.

The source changes merge cleanly: frames.ts, filterTier.ts and index.ts all auto-merge, and #7080's readMoverFrames changes keep your start = t + req.phaseSeconds line intact. Every CI job at this head shows skipping, so there's no test evidence at this SHA yet.

What I checked

  • shutterTimes is now the only place either tier computes the window (frames.ts; index.ts:277, filterTier.ts:189). I grepped shutterDeg, shutterSeconds and shutter / 2 on main, and no other site builds a window. The remaining shutterDeg uses are report fields (index.ts:563,703, filterTier.ts:231) and the Studio level presets.
  • Unset is unchanged: -shutterSeconds / 2 is the old t - shutter / 2 term exactly, so no float drift. That fits your md5-identical renders.
  • It matches the engine's sub-frame blur convention: windowStart = t + shutterPhase / 360 / fps, with default -90 at 180° (engine/src/services/motionBlur.ts:14,61,93). So phase means the same thing on both blur paths.
  • Mover discovery (discoverMovers) scans the whole timeline, not the shutter window, so an off-centre window can't hide a mover from the scan.
  • Studio round trip: settingsOf reads phaseDeg and changedEdits writes it back, and patchForLevel only touches enabled, events, shutter and samples. So an authored phase survives a level change, as the body says.

Nits (none block)

  • Report has shutterDeg but no phase, so an off-centre render looks the same as a centred one in the [motion-blur] log. Adding phaseDeg beside shutterDeg in both tiers' report() would make it debuggable.
  • At phase 360 with shutter 720 the window ends 3 frames past t, so the last frame seeks past the composition's duration. Seeks clamp to the end today, and a centred 720 already reads 1 frame past, so this is only more of an existing case. One test at t = duration would pin it.

Need: yes, the After Effects exporter needs non-centred shutters · Size: ok · Reuse: same phase convention as engine motionBlur.ts, one formula per package, sharing across the core/engine boundary isn't worth it · Simpler: already minimal · Structure: fine
Risk: low. Unset is byte-identical, and the new behavior only applies when the attribute is set.

Verdict: COMMENT
Reasoning: The change is correct and minimal. It can't be approved at this head because it conflicts with main and CI didn't run. I'll approve after a rebase and green CI.

— Rames

Page motion blur always centres the shutter on the frame. Add a phase in degrees of a
frame, -360 to 360: the window starts at t + phase / 360 / fps and lasts shutter / 360 /
fps. Unset stays centred, so a page without the attribute renders the same bytes.

Both tiers take the window from one place, shutterTimes in frames.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vanceingalls
vanceingalls force-pushed the feat/motion-blur-phase branch from 51da80f to 9aaf099 Compare November 4, 2026 06:53
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (the two test conflicts were only in frames.test.ts, kept both sides, and index.test.ts). Took the first nit: Report now carries phaseDeg. Not changed: phase 360 with shutter 720 reading past the end on the last frame. It follows the same convention as the engine's sub-frame blur and the old centred shutter also read past the end by half a shutter; say if you want it clamped. Core blur unit tests: 217 pass. @heygengenesis review please, and Rames, ready for your approve once CI is green.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits · head 08896be

The code is correct, but the new Report.phaseDeg doc comment turns the required Comments check red. The fix is one line, inline.

Scope and CI

Additive to @jrusso1020's review at 51da80f8, whose checks I re-ran against the rebased head. shutterTimes is still the only place either tier builds the window, and the source merged cleanly. Both conflicted test files keep #7080's blocks next to the new shutter phase describes. phaseDeg is now on every Report built by both tiers and offReport, which addresses the nit.

CI at this head: CI, Docs, CodeQL, Windows, Player and the captures all pass. Two checks are red. Comments is the blocker, inline. regression shard-2 failed before running any test: the Docker cache fetch returned could not fetch content descriptor ... not found. That's infra, and the push that fixes the comment will rerun it.

— Genesis

… may only fall

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 1 resolved, 0 open, 0 new · 08896be → a3eec9f. CI green (one Studio thumbnail perf-ratchet +1 flake passed on rerun).

@vanceingalls
vanceingalls added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit aace91b Nov 5, 2026
156 of 157 checks passed
@vanceingalls
vanceingalls deleted the feat/motion-blur-phase branch November 5, 2026 01:43
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 5, 2026
…pstream review to aace91b

Hand-ported upstream aace91b (data-motion-blur-phase sets when the
shutter opens) since it does not apply cleanly; the fork's seek recovery
in captureMotionBlur stays as is. Ledger, baseline, UPSTREAM, DECISIONS
and ROADMAP advance the commit watermark to aace91b.

Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants