Files
hyperframes/packages
Miguel Ángel 2ab7b86868 feat(engine): adaptive per-frame sample count for motion blur (#4031)
* feat(engine): adaptive per-frame sample count for motion blur

Measured against the accuracy bar set in #4010 (72.4dB mean PSNR vs the
960fps shutter integral), the fixed default of 16 samples misses it for
hard-edge content while paying nothing extra for static/slow content is
still important. This picks a sample count per frame from measured
motion, floor 16 ceiling 64 (issue #4029).

Two probe captures at the true shutter-window edges (never a sample that
enters the average) measure motion magnitude, mapped through a monotone
step table to a sample count. Frames confirmed to move only non-spatial
properties (opacity, colour) skip the probe and use the floor directly,
since a fade's shutter integral is a smooth blend that does not need many
samples. MotionBlurPlan's window (from shutter angle/phase alone) is now
independent of how many samples fill it, which is what lets the count
vary per frame without moving the window; motionBlurWindowIsStatic is
bounded by the widest sample inset any chosen K could produce so the
static-dedup safety guarantee holds for every K in [16, 64].

* refactor(engine): simplify the adaptive motion-blur sample selection

Two findings from an independent code-reuse/quality/efficiency review of
the previous commit:

- armStaticDedup ran the GSAP-timeline walk (computeStaticFrameSet)
  twice on the same page when adaptive motion blur and static dedup
  both apply, once for each purpose. Shares the one result instead.
- The non-spatial floor path read the sample count from
  adaptiveSampleCount(0), which only worked because the step table
  happens to be sorted with the first bucket covering zero. Reads
  the now-exported DEFAULT_SAMPLES_PER_FRAME directly instead.

* fix(engine): read raw options for the adaptive motion-blur gate

Independent review caught it: every armStaticDedup call site runs
before finalizeSessionInit resolves session.motionBlur, so the
adaptive-classification gate's `session.motionBlur &&
session.motionBlur.fixedSamplesPerFrame === null` was always false at
runtime. The non-spatial probe-skip described in the PR never engaged
outside tests that hand-set motionBlurNonSpatialFrames directly,
bypassing real init order.

Reads session.options.motionBlur (set unconditionally at session
creation) instead, which carries the same "adaptive requested"
information the resolved plan would have. Adds a source-order lock
test plus direct coverage of computeStaticFrameSet's
nonSpatialOnlyFrameSet, mutation-proved against the exact regression.

* fix(engine): add xPercent/yPercent to the spatial-property list

Round 2 of the independent review: the spatial-property list deciding
whether a frame's motion needs the adaptive probe omitted GSAP's
percentage-based position properties (xPercent/yPercent) and two 3D
transform properties (perspective/transformPerspective) that two other
lists already in this repo (packages/parsers/src/gsapConstants.ts,
packages/studio/.../gsapAnimatesProperty.ts) both carry. A composition
positioned via xPercent/yPercent would have been silently floored to
K=16 with no probe and no log line, the same failure shape as the
round-1 bug through a data gap instead of a dead gate.

Moves the list to an exported SPATIAL_TWEEN_PROPERTIES array in
motionBlur.ts, passed into computeStaticFrameSet's page.evaluate as
data rather than duplicated inline, so it stays a single list and is
directly testable. Adds tests that execute the real page-side closure
(the existing tests all mocked the whole result and never exercised
the matching code), mutation-proved against the exact gap found.

* fix(engine): add rotate to the spatial-property list

Round 3 of the independent review, comparing SPATIAL_TWEEN_PROPERTIES
side by side against the two sibling lists rather than trusting the
prior commit's summary: GSAP's individual "rotate" transform property
(distinct from "rotation", carried in
packages/studio/.../gsapAnimatesProperty.ts's GSAP_TRANSFORM_PROPS)
was still missing, same failure shape as the two prior rounds.

The same pass also raised transformOrigin, which is deliberately NOT
added: gsapConstants.ts's own classifyTweenPropertyGroup excludes it
as a pivot-point modifier with no independent visual effect, and a
test now locks that reasoning in rather than leaving it to be
"fixed" again by a future pass that does not read that far.
2026-09-17 01:09:12 -04:00
..
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00
2026-09-16 22:56:40 -04:00