Skip to content

@remotion/paths: Fix getBoundingBox() for curves starting with a flat tangent - #12687

Merged
JonnyBurger merged 1 commit into
remotion-dev:mainfrom
b84330808:paths-bbox-flat-tangent
Oct 29, 2026
Merged

JonnyBurger merged 1 commit into
remotion-dev:mainfrom
b84330808:paths-bbox-flat-tangent

Conversation

@b84330808

Copy link
Copy Markdown
Contributor

getBoundingBox() leaves out the end of a curve when its first control point is level with the start point. For example getBoundingBox('M 0 0 Q 0 10 10 10') returns a box that is 0 wide instead of 10. This also affects C curves whose first control point is the start point, and T after a line (its control point is the current point). centerPath() uses the same box, so it shifts these paths by the wrong amount.

The cause is in minmaxQ(). When A[1] === A[0] and the curve rises, the "no extremum in ]0,1[" check does not return early. The extremum formula then gives A[0] and [min, E] drops the maximum. The check now tests whether A[1] - A[0] and A[2] - A[1] have opposite signs, which is the only case where the extremum is strictly inside the curve.

Before (main 4725fad, box values rounded to 3 decimals):

getBoundingBox('M 0 0 Q 0 10 10 10')       x1 0, y1 0, x2 0,  y2 10
getBoundingBox('M 0 0 C 0 0 5 5 15 15')    x1 0, y1 0, x2 0,  y2 0
getBoundingBox('M 0 0 L 10 0 T 20 10')     x1 0, y1 0, x2 10, y2 0
centerPath('M 0 0 Q 0 10 10 10')           M 0 -5 Q 0 5 10 5

After (same rounding):

getBoundingBox('M 0 0 Q 0 10 10 10')       x1 0, y1 0, x2 10, y2 10
getBoundingBox('M 0 0 C 0 0 5 5 15 15')    x1 0, y1 0, x2 15, y2 15
getBoundingBox('M 0 0 L 10 0 T 20 10')     x1 0, y1 0, x2 20, y2 10
centerPath('M 0 0 Q 0 10 10 10')           M -5.000000000000001 -5 Q -5.000000000000001 5 4.999999999999999 5

I added a test to get-bounding-box.test.ts. It fails on main (Expected: 10, Received: 0) and passes with the change. bun test src in packages/paths passes (51 tests), and oxfmt --check is clean on both files.

I found this by checking getBoundingBox() against points sampled with getPointAtLength() on random paths. The same check also shows that a T following another T that came after a non-quadratic command (e.g. M 0 0 H 10 T 20 10 T 30 0) gets the wrong control point in both getLength() and reduceInstructions(). I can send that separately if you want it.

I used Claude Code to write this change.

…flat tangent

`minmaxQ()` treated a control value equal to the start value as an
extremum inside the curve and returned [min, E] with E equal to the
start, dropping the end of the curve from the box. This happens for
`Q` and `C` curves whose first control point is level with the start
point in x or y, and for `T` after a non-quadratic command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
bugs Ready Ready Preview Oct 29, 2026 1:49am UTC
remotion Ready Ready Preview Oct 29, 2026 1:49am UTC

Request Review

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • minmaxQ() extremum test (get-bounding-box.ts:20): replaces the branchy A[1] > A[0] ? A[2] >= A[1] : A[2] <= A[1] check with (A[1] - A[0]) * (A[2] - A[1]) >= 0. Since the derivative of a quadratic Bézier is linear, an interior extremum exists iff the derivative flips sign, i.e. d0 = A[1] - A[0] and d1 = A[2] - A[1] have strictly opposite signs — the new condition is exact. The >= 0 (rather than > 0) is essential: it makes the flat-tangent case (d0 === 0 or d1 === 0) return early instead of computing E = A[0], which is what dropped the curve's end.
  • Regression test (get-bounding-box.test.ts:29): covers the Q, C (near-quadratic, K ≈ 0 reduction path), and T-after-line cases.

I verified the algebra independently: the new predicate is strictly more correct than the old one. It only computes E when d0 * d1 < 0, which guarantees a genuine interior extremum, and never divides by zero (d0 * d1 < 0 implies A[0] - 2*A[1] + A[2] = d1 - d0 ≠ 0). The old check also mis-handled symmetric humps like A = [0, 5, 0] (returned [0, 0] instead of [0, 2.5]), which this fixes too. bun test src passes locally (51 tests).

The separate T-after-T control-point issue the author mentions in getLength() / reduceInstructions() is a pre-existing bug outside this diff, so it is correctly out of scope for this PR.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash | 𝕏

@JonnyBurger
JonnyBurger merged commit d8fe023 into remotion-dev:main Oct 29, 2026
20 checks passed
@JonnyBurger

Copy link
Copy Markdown
Member

Thank you!

This branch was successfully deployed

2 active deployments
Preview – remotion — 49f125c5 Deployed Oct 29, 2026 by vercel[bot]
Preview – bugs — 49f125c5 Deployed Oct 29, 2026 by vercel[bot]
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