revert: restore tree to 757e41d869f20acbecfee64642514878b18dd528

This commit is contained in:
tiennm99 committed 2026-07-28 10:42:34 +07:00
1 parent 3b28e46069
commit ffa9729683
31 files changed
+254 -3392

No files matched your search

-1
View File
@@ -8,4 +8,3 @@ coverage/
fixtures/*.gif
/wheel.gif
*.log
.claude/settings.local.json
-14
View File
@@ -31,20 +31,6 @@ Response is `image/gif` with winner metadata headers:
`X-Wheel-Winner` is URL-encoded so non-ASCII labels are safe in HTTP headers.
The GIF ends on a winner celebration: the wheel settles, the winning wedge is
outlined edge to edge, the winner's name appears over the hub, and a
deterministic two-cannon confetti burst crosses the wheel in front of it. The
announcement is colored from the winning slice — its own color as the chip, a
deepened version of that color as the frame and as the wedge outline — so the
theme picks the palette but the winner picks the accent. Each theme carries its
own confetti palette, chosen for contrast against that theme's background rather
than reusing the slice colors. The celebration starts a few frames before the
wheel mathematically settles, so it lands on the perceived stop.
Labels stay radial but flip where needed so every name reads upright in the
final frame, which is the frame most chat clients show as the GIF's poster
image.
## Local
Install dependencies and Chromium once:
+2 -78
View File
@@ -10,81 +10,5 @@ pnpm render:fixtures
|---|---:|---:|---:|---:|---:|---|
| smoke | 384 | 12 | 3.5s | 315661 | 13917ms | Docker `pnpm render:smoke` on 2026-07-06 |
| api-smoke | 384 | 12 | 3.5s | 298374 | 4100ms | Docker `pnpm api:smoke`, after startup bundle warm-up |
| smoke | 384 | 12 | 3.5s | 1495369 | 3608ms | Windows dev box, 2026-07-25 |
| vietnamese | 512 | 15 | 7.7s | 2894104 | 3416ms | Windows dev box, 2026-07-25, label readability |
| sixteen-options | 512 | 15 | 7.7s | 4930088 | 3516ms | Windows dev box, 2026-07-25, dense wheel |
The Docker rows and the dev-box rows are not comparable to each other: different
hardware, and the Docker rows predate the celebration rework below.
## Celebration rework, 2026-07-25
Measured on the same Windows dev box, immediately before and after the change,
so these pairs are comparable:
Bytes grew and render time did not. Measured back to back on one machine,
covering both the celebration rework and the motion refinement that followed:
| Case | Bytes before | Bytes after | Time before | Time after |
|---|---:|---:|---:|---:|
| smoke, 384 @12fps, 4 options | 1251208 | 1495369 | 3730ms | 3608ms |
| classic, 512 @15fps, 8 options | 2066068 | 2623039 | 4359ms | 4830ms |
| vietnamese, 512 @15fps, festival | 2286771 | 2894104 | 3065ms | 3416ms |
| sixteen-options, 512 @15fps | 3834855 | 4930088 | 3368ms | 3516ms |
| mono, 512 @15fps, 6 options | 1745305 | 2564362 | 4173ms | 3775ms |
| schema max: 32 options, 20fps, 12.5s | 10825678 | 11998202 | 9079ms | 7800ms |
The dense cases moved least, and the schema maximum actually shrank, because the
turn count now scales with the frame budget: a 32-option wheel spins 2 turns
instead of 5, so there is far less rotation to encode.
## Where the bytes went
Isolated by rendering with one change removed at a time, 512 @15fps, 8 options:
| Build | Bytes | vs baseline |
|---|---:|---:|
| baseline, before any of this work | 2066068 | — |
| everything except grayscale label AA | 2133986 | +3.3% |
| everything, AA included | 2597190 | +25.7% |
The confetti rework, winner pill, spin retune, palette reorder and SVG pointer
together account for **+3.3%**. Grayscale label antialiasing accounts for
**+22.4%** on its own, because it affects every spin frame rather than only the
celebration.
That AA cost buys a real fix. Chromium's default LCD sub-pixel text
antialiasing emits saturated color fringes on every glyph edge: in the
all-grayscale `mono` theme 2178 pixels of frame 0 carried a visible color cast,
and frame 0 used 255 of 256 palette entries, so everything else in the image had
to dither. Both drop to 0 fringe pixels and 204 colors. Grayscale AA spends more
distinct gray levels per glyph, which costs bytes but stops starving the palette.
Promote the one rotating container, not each label. Per-label promotion gives the
same visual result and doubles render time on a dense wheel — 18112ms against
8999ms for the 32-option case, which would breach the default
`RENDER_TIMEOUT_MS` of 15000. `-webkit-font-smoothing: antialiased` does not work
here; Chromium only honors it on macOS.
## Levers, measured
Particle count is not a lever. Measured at 512 @15fps, 8 options:
| Change | Bytes | vs default |
|---|---:|---:|
| `Confetti` `count` 70 to 40 | 2568902 | -1.1% |
| `holdMs` 1200 to 500 | 2492491 | -4.0% |
| `holdMs` 1200 to 2500 | 2859979 | +10.1% |
| drop grayscale AA | 2133986 | -17.8% |
Cutting 43% of the particles changes the file by about one percent, so reach for
`holdMs` or the AA trade instead. Keeping grayscale AA is a decided trade, not an
oversight.
## Measurement note
Render times on this machine varied by more than 2x across repeat runs of an
identical input — one 512 render took 23.5s against a 4.3s median — depending on
background load. Byte counts are deterministic and comparable; treat any single
timing figure as indicative and take a median of at least three runs before
concluding anything from it.
| vietnamese | 512 | 15 | 7.7s | TBD | TBD | label readability |
| sixteen-options | 512 | 15 | 7.7s | TBD | TBD | dense wheel |
+1
View File
@@ -35,6 +35,7 @@
"@types/react-dom": "19.2.3",
"eslint": "10.6.0",
"globals": "17.7.0",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vitest": "4.1.10"
}
@@ -1,131 +0,0 @@
# Phase 01 — Motion truth
Make the spin behave like a physical wheel at every option count: never advance
more than one slice per frame, and stop with a settle instead of an asymptotic
crawl.
## Context
- `src/remotion/wheel-layout.js` — `getSpinProgress`, `getFinalWheelRotationDegrees`,
`defaultFullTurns`, `getPointerDeflectionDegrees`
- `src/remotion/WheelComposition.jsx:52-62` — computes `finalRotation` and `rotation`
- `test/wheel-layout.test.js` — existing invariant tests
## Problem 1: dense wheels run backwards
Peak spin speed is ~39.9 deg/frame at every option count, because total rotation
is fixed at 5 turns regardless of how wide a slice is. Measured aliasing frames
out of 98, at the 512/15fps default:
| Options | 2 | 8 | 10 | 12 | 16 | 24 | 32 |
|---|---:|---:|---:|---:|---:|---:|---:|
| Frames advancing >1 slice | 0 | 0 | 8 | 21 | 38 | 56 | 65 |
At 32 options two thirds of the spin reads as jitter or reverse rotation.
### Fix: derive turn count from the frame budget
Peak speed in progress units is the slope at the launch/deceleration handoff,
already named `handoffSlope` in `wheel-layout.js`. Requiring
`handoffSlope * turns * 360 / spinFrames <= 360 / optionCount` gives:
```js
export const getSpinTurns = (spinFrames, optionCount) =>
Math.max(1, Math.min(7, Math.floor(spinFrames / (handoffSlope * Math.max(1, optionCount)))));
```
**Floor, not round.** Rounding to nearest pushes 8 options from 5.63 to 6 turns
and peak to 47.9 deg/frame against a 45 degree slice — worse than today.
Flooring keeps the current 5 turns for the default wheel.
**Must stay an integer.** The winner lands under the pointer only because
`turns * 360 mod 360 === 0`. A fractional 2.8 turns leaves the wheel 288 degrees
off. Any change here must preserve integrality.
Resulting turn counts and outcomes:
| Config (spin frames) | n=2 | n=8 | n=12 | n=16 | n=24 | n=32 |
|---|---|---|---|---|---|---|
| default 6500/15fps (98) | 7 | 5 | 3 | 2 | 1 | 1 |
| 6500/12fps (78) | 7 | 4 | 2 | 2 | 1 | 1 |
| max 10000/20fps (200) | 7 | 7 | 7 | 5 | 3 | 2 |
| min 3000/12fps (36) | 7 | 2 | 1 | 1 | 1 ALIAS | 1 ALIAS |
Only the minimum-duration dense cases still alias: 36 spin frames cannot carry
even one turn past 24 slices. Assert that boundary rather than pretend it is
solved.
Wire it in `WheelComposition.jsx` by passing
`getSpinTurns(spinFrames, props.options.length)` into
`getFinalWheelRotationDegrees`, and keep `defaultFullTurns` as the fallback for
direct callers.
### Risk to validate before accepting
At 32 options with the default duration this yields **1 turn over 6.5 seconds**.
Correct by the aliasing metric, but it may read as a broken or barely-moving
wheel rather than a slow one. Render 24 and 32 options and judge before
committing. If it looks wrong, the fallback is a floor of 2 turns with the
remaining aliasing documented — a deliberate choice of drama over correctness on
dense wheels, not a silent regression.
## Problem 2: the wheel stops without settling
`getSpinProgress` approaches 1 asymptotically, so the wheel eases to a halt with
no impact. Real wheels overshoot the final detent slightly and rock back.
### Fix: damped recoil in the deceleration tail
Add a decaying oscillation to the last portion of the spin that is exactly zero
at `frame === spinFrames`, so the landing guarantee is untouched:
```js
const recoilFraction = 0.18; // last 18% of the spin
const recoilDegrees = 2.2; // peak overshoot, scaled below
```
Apply the recoil as a rotation offset in degrees rather than inside
`getSpinProgress`, so progress stays monotonic in [0, 1] and the existing
invariant tests keep their meaning. The offset must:
- be 0 for `frame <= spinFrames * (1 - recoilFraction)`
- be exactly 0 at `frame >= spinFrames`
- peak around half a slice at most, and never more than `recoilDegrees`, so a
neighbouring slice never touches the pointer
Scale the peak by `Math.min(recoilDegrees, sliceDegrees * 0.25)` so a 2-option
wheel does not visibly swing.
Because the celebration fires at `revealFrame` (about 5 frames before
`spinFrames`), the recoil and the confetti burst overlap. That is the desired
read: impact, recoil, confetti.
### Interaction with the pointer flapper
`getPointerDeflectionDegrees` is driven by `rotation`. Feeding it the recoiled
rotation makes the flapper twitch during the settle, which is free realism, but
the rest-neutral proof depends on the final rotation being exactly the detent
value. Since the recoil is exactly 0 at `spinFrames` and beyond, the proof holds.
Add a test rather than relying on that argument.
## Tests
- `getSpinTurns` returns an integer in [1, 7] for every combination of
`spinFrames` in {36, 78, 98, 200} and option counts 2..32.
- Winner lands at exactly screen angle 0 for every option count 2..32 and every
winner index, using the adaptive turn count.
- No frame advances more than one slice, for all option counts, except the
documented minimum-duration dense cases, which are asserted explicitly as
known-aliasing so the boundary cannot move silently.
- Recoil offset is exactly 0 at and after `spinFrames`, and never exceeds a
quarter slice.
- Flapper still parks at exactly 0 at rest with recoil applied.
- Existing tests updated: `caps peak spin speed regardless of option count` and
`never advances a full slice in one frame at typical option counts` now cover
all counts, so the "typical" qualifier goes away.
## Validation
Render 2, 8, 16, 24, 32 options at the default, plus the minimum and maximum
duration cases. Confirm the winner sits under the pointer in the last frame of
each, and judge the 24/32 slowness question above.
@@ -1,102 +0,0 @@
# Phase 02 — Confetti craft
Two additions to a burst that already works: fake the motion blur a GIF cannot
have, and keep long holds populated to the end.
## Context
- `src/remotion/confetti-layout.js` — particle construction and closed-form flight
- `src/remotion/Confetti.jsx` — rendering, opacity envelope, launch gating
- `test/confetti-layout.test.js`
Current measured behaviour at 512/15fps, default hold (22 frame window):
on-screen count `25,37,46,53,59,67,70,...,70,69,68,66,62`, peak step 50.9px
against a 16.3px median body.
## Problem 1: fast chips read as disconnected dots
Peak travel is about 3.1x body length per frame in the opening frames, and a GIF
has no motion blur to tie those samples together. Real footage of a confetti
cannon shows streaks, not dots.
### Fix: elongate along the direction of travel
Stretch each particle along its current velocity vector in proportion to speed,
so fast chips become short ribbons and slow ones stay square. Deterministic and
closed form, since velocity is already analytic:
```js
// velocity at t, from the same closed form as position
const speedNow = Math.hypot(vx, vy) * Math.exp(-drag * t);
const stretch = 1 + Math.min(maxStretch, speedNow / stretchReference);
```
Apply as a `scaleX` in the particle's own rotated frame, with the rotation set to
the direction of travel rather than the tumble angle while `stretch` is
significant. Blending between "oriented along velocity when fast" and "tumbling
when slow" is what sells it.
Constraints:
- `maxStretch` around 1.8. Beyond that the chips look like rain.
- The stretched long axis must stay under about 40px at the reference size, or
the burst reads as streaks of paint.
- The 3px minimum thickness from `minHeight` still applies to the short axis
after stretching.
Cheaper alternative if the velocity-orientation blend gets fiddly: scale only the
width along the existing rotation, accepting that the streak direction is
approximate. Try the correct version first; it is a few lines.
## Problem 2: a maximum hold outlives the burst
At `holdMs` 2500 the field drains from frame 33 of 37, leaving the last few
frames nearly empty. Confirmed by probe: `...,12,8,4,4,2,1,0,0`.
### Fix: a second volley sized to the window
Give a fraction of the particles a launch delay in the second half of the
celebration window, but only when the window is long enough to need it. The
particle builder does not currently know the window length, so pass it in:
```js
createConfettiParticles({count, size, colors, random, windowSeconds})
```
Then a particle in the second wave gets
`delay = windowSeconds * lerp(rand('wave'), 0.42, 0.55)`.
At the default 1.47s window the second wave lands around 0.62-0.81s, which
overlaps the first wave's peak and simply thickens it. At a 2.47s window it lands
around 1.04-1.36s and refills the tail. At the 0.5s minimum hold the window is
short enough that the second wave collapses into the first, which is correct
behaviour rather than a special case.
Split roughly 30% of particles into the second wave. Do **not** raise `count` to
compensate: measured byte cost of particle count is negligible (70 to 40
particles changed the GIF by 1.1%), so if density needs a lift, raising `count`
to 90 is nearly free. Confirm that against a render rather than assuming.
## Interaction with the opacity tail
`Confetti.jsx` fades over the last `min(4, round(lifetime * 0.25))` frames and
quantizes opacity to quarters. A second wave arriving late must not still be
mid-flight when the fade starts, or particles vanish in mid-air. Check that the
second wave's latest launch plus its rise time lands before the fade begins; if
not, pull the 0.55 upper bound down.
## Tests
- Stretch factor is 1.0 at rest and bounded by `maxStretch` at launch.
- Short axis stays at or above 3px after stretching, at sizes 384, 480, 512.
- Every particle still launches within the celebration window for `holdMs` 500,
1200, and 2500.
- Second-wave particles have strictly larger delays than first-wave ones.
- Determinism unchanged: same seed produces identical particles.
## Validation
Re-run the density probe for `holdMs` 500, 1200, 2500 at 384/12fps and 512/15fps.
Target: no frame in the celebration window below 40 live particles, and the last
frame of a 2500ms hold above 25. Then render and view frames at the reveal, mid
burst, and the end.
@@ -1,78 +0,0 @@
# Phase 03 — Label orientation at the vertical case
## Context
- `src/remotion/wheel-label-layout.js` — `getRadialLabelLayout`, the rest-frame flip
- `src/remotion/WheelComposition.jsx` — renders `label.rotation`
- `test/wheel-label-layout.test.js`
The rest-frame flip added in the previous pass makes every label upright when the
wheel stops, except at the boundaries. A label's rest angle is
`(centerDegrees - winnerCenterDegrees) mod 360`, and the flip triggers strictly
inside (90, 270). A label sitting at exactly 90 or 270 is not mirrored, so it is
not flipped, but it renders stacked vertically.
Observed in the 8-option sample: `heidi` and `dave` read vertically while every
other label is horizontal.
## When it happens
Rest angles are `360 * (i - winnerIndex) / optionCount`. Exactly 90 or 270
requires `(i - winnerIndex) / optionCount` to be 1/4 or 3/4, so:
- `optionCount` divisible by 4: exactly two labels land vertical, every time.
- Otherwise: no label is exactly vertical, but the nearest is within
`180 / optionCount` degrees of it, which is still steeply tilted on dense
wheels.
So this is not a rare edge case. Every 4, 8, 12, 16, 20, 24, 28, 32-option wheel
has two vertical labels in its poster frame.
## Options
### A. Consistent vertical direction (cheap)
Move the flip boundary so 90 and 270 resolve the same way, e.g. flip on
`restAngle >= 90 && restAngle < 270`. Both vertical labels then read in the same
direction instead of opposite ones, so a viewer tilts their head one way for
both. Two characters of change, no layout work, no new failure modes. Does not
make them horizontal.
### B. Tangential fallback for near-vertical labels (real fix)
When a label's rest angle is within a threshold of vertical, lay it out
tangentially — rotated 90 degrees from radial — so it reads horizontally at rest.
This means a different track geometry for those labels: the available box becomes
the slice's chord width rather than its radial length, so the existing
`getLabelTrack` and wrap logic do not directly apply.
Cost: a second layout path in a module that is already the most intricate in the
codebase, and it partially reopens the settled radial-labels decision. Benefit:
every label horizontal in the poster frame.
### C. Accept it
Radial labels on a wheel are conventional, including on wheelofnames.com, and a
vertical label is legible with a head tilt. Document and move on.
## Recommendation
Start with A, then judge B from a render. A is nearly free and strictly better
than today. B is only worth its complexity if the vertical labels actually look
wrong in the poster frame at 200px, which is the size that matters — check that
before building it.
Whichever is chosen, it must not regress the rest-frame flip guarantee: the
existing test asserting no label rests mirrored has to keep passing.
## Tests
- No label rests with a screen angle strictly inside (90, 270), unchanged.
- Labels at exactly 90 and 270 resolve to the same reading direction.
- If B is built: no label rests within 15 degrees of vertical, for option counts
2..32 and winner indices across the range.
## Validation
Render 4, 8, 12, 16 options and inspect the poster frame at both 512 and a
200px downscale, which is the realistic chat display size.
@@ -1,87 +0,0 @@
# Phase 04 — Verification and documentation
Runs after phases 1-3. Two jobs: re-measure honestly, and fix a documentation
error already sitting on `main`.
## Documentation correction (do this even if phases 1-3 slip)
`docs/render-benchmarks.md` currently misattributes where the GIF growth came
from. It says the extra bytes are split between denser confetti and grayscale
antialiasing. Measurement says otherwise:
| Build | Bytes | vs baseline |
|---|---:|---:|
| baseline before the celebration rework | 2,066,068 | — |
| every change **except** the AA fix | 2,133,986 | +3.3% |
| every change, AA fix included | 2,597,190 | +25.7% |
The confetti rework, winner pill, spin retune, palette reorder and SVG pointer
together account for +3.3%. Grayscale AA alone accounts for +22.4%, because it
affects all 98 spin frames rather than only the celebration.
The "levers if a deployment needs bytes back" list is wrong for the same reason.
Lowering the `Confetti` particle count from 70 to 40 changes the GIF by **1.1%**
(2,597,190 to 2,568,902), so it is not a lever. Measured alternatives:
| Change | Bytes | vs default |
|---|---:|---:|
| `count` 70 to 40 | 2,568,902 | -1.1% |
| `holdMs` 1200 to 500 | 2,492,491 | -4.0% |
| `holdMs` 1200 to 2500 | 2,859,979 | +10.1% |
| drop the AA fix | 2,133,986 | -17.8% |
The only large lever is the AA fix itself, and keeping it is a decided trade.
Rewrite the section to say that plainly instead of offering a knob that does
nothing.
## Re-measurement after phases 1-3
Adaptive turn count changes total rotation at most option counts, which changes
per-frame pixel deltas and therefore file size — likely downward on dense wheels,
since they now turn less. Confetti streaks and a second wave push the other way.
Neither is predictable; measure rather than estimate.
Capture before and after for each, on one machine, back to back, warm bundle:
- smoke, 384 @12fps, 4 options
- classic, 512 @15fps, 8 options
- vietnamese fixture
- sixteen-options fixture
- mono, 512 @15fps, 6 options
- schema maximum: 32 options, 512, 20fps, 10000+2500
Note on method: render times in the last session varied by more than 2x on
repeat runs of an identical input (one 512 render took 23.5s against a 4.3s
median) because of background load. Sizes are deterministic and trustworthy;
times need a median of at least three runs before any conclusion.
Then update both tables in `docs/render-benchmarks.md`.
## Visual verification checklist
Render and inspect, at 512 and downscaled to 200px:
- Poster frame, classic 8 options: winner pill legible, every label upright,
winner slice outlined, pointer outlined with margin
- Poster frame, mono: wheel has a silhouette, confetti visible against the
background
- Reveal frame: burst is a compact pop, not an empty frame
- Mid-burst frame: field crosses the wheel, streaks read as motion
- Final frame at `holdMs` 2500: field still populated
- 24 and 32 options: judge whether the reduced turn count reads as slow or as
broken, which is the open risk from phase 1
- Long Vietnamese winner name: pill stays two lines and diacritics are complete
## Gates
`pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm api:smoke`, `pnpm render:fixtures`.
## Unresolved questions
1. Does 1 turn at 24-32 options read as intentional? Decided by looking at a
render, not by argument. Fallback is a floor of 2 turns with the residual
aliasing documented.
2. Is the `night` theme wanted? Still open from the previous pass, still a
contract addition, still out of scope here.
3. Does the 14.4MB schema-maximum GIF matter in practice? Depends on whether any
caller sends those parameters, which cannot be answered from this repo.
@@ -1,80 +0,0 @@
# Animation and Confetti Refinement
**Status:** complete. Phases 1-4 implemented and verified on 2026-07-25.
**Branch:** main
**Depends on:** the celebration rework already on `main` (two-cannon confetti,
winner pill, SVG pointer, slope-matched spin, grayscale label AA)
Second pass over the rendered animation. The first pass fixed defects; this pass
is about motion truth and craft, plus one documentation correction.
## Accepted trade already decided
Grayscale label antialiasing stays, at +22.4% GIF size (+463KB on the 512
default). Clean text and an unstarved palette are worth the bytes. The offset
programme that would claw them back (opaque bezel replacing the soft
`wheelShadow`, halo cut from 4 offsets to 2) is explicitly **out of scope** —
see Not doing.
## Phases
| # | Phase | Depends on | Risk | Outcome |
|---|---|---|---|---|
| 1 | [Motion truth](phase-01-motion-truth.md) — adaptive turn count, settle recoil | — | medium | done |
| 2 | [Confetti craft](phase-02-confetti-craft.md) — motion streaks, second wave | — | low | done |
| 3 | [Label orientation](phase-03-label-orientation.md) — the 90/270 vertical case | — | medium | done, option A only |
| 4 | [Verification and docs](phase-04-verification-and-docs.md) | 1, 2, 3 | low | done |
Phases 1-3 touch disjoint files and can run in any order or in parallel. Phase 4
re-measures everything once they land.
## Outcome
Turn count now scales with the frame budget: 5 turns at 8 options as before, 2 at
16, 1 at 24 and above. Aliasing goes from 65 of 98 frames at 32 options to zero
at every count the frame budget can serve.
The open risk — that one turn would read as a broken wheel rather than a slow
one — did not materialise. At 32 options the rim still moves 30px per frame and
77 of 98 frames are perceptibly in motion, so the fallback floor of 2 turns was
not needed.
Confetti now holds a populated frame throughout: a 2500ms hold ended with 0
particles before and 68 after. Streaks are strongest at launch and relax as chips
slow.
Reducing turns on dense wheels also cut the schema-maximum render from 14.4MB and
8999ms to 12.0MB and 7800ms.
`88 tests passing`, lint and typecheck clean.
## Acceptance criteria
- No spin frame advances more than one slice, at every option count, except
where the frame budget makes it impossible (3000ms at 12fps leaves 36 spin
frames; 24+ slices cannot avoid it). That exception is asserted, not ignored.
- The winner still lands at exactly screen angle 0 for every option count and
winner index, including through the recoil.
- Confetti holds a populated frame for the whole celebration at `holdMs` 500,
1200, and 2500.
- No label rests within 15 degrees of vertical, or the vertical case is handled
deliberately and consistently.
- `pnpm lint`, `pnpm typecheck`, `pnpm test` clean. Every new behavior has a test
asserting the invariant rather than a magic number.
- `docs/render-benchmarks.md` states the correct byte attribution.
## Not doing
Carried from the two review reports, deliberately excluded:
- Bezel ring and reduced `wheelShadow`. Would free ~90 palette entries, but the
size trade was decided against pursuing.
- Text halo reduced from 4 offsets to 2.
- Raising the label font-size cap (`radius * 0.15`), which would grow text ~31%
on 2-6 option wheels.
- A 4th `night` theme. Additive schema change, still needs a call.
- Narrowing the request schema to cap total frames. The 32-option maximum is a
14.4MB GIF, but render time is back at baseline (8999ms) so nothing is
breaching a timeout today. Revisit only if a container actually times out.
- `numberOfGifLoops`. The GIF keeps looping forever.
- Everything in the API/DX review — separate track.
@@ -1,390 +0,0 @@
# API / DX Review — Integrator and Operator Experience
Date: 2026-07-25
Scope: API contract, error handling, config, scripts, docs. Visual design excluded (covered by other reviewers).
Method: source read + empirical probes via `app.inject`, `loadConfig`, and real CLI runs. All status codes and bodies below are captured output, not inferred.
---
## 1. Verdict
The happy path is solid: validation runs before the semaphore, the bundle is warm-started before `listen`, the semaphore releases in `finally`, temp dirs clean up in `finally`, the token compare is constant-time, and no stack traces leak to callers. All three quality gates pass. The defects are concentrated in the *unhappy* paths an integrator actually hits: malformed JSON, an empty body, and an oversized body all return `500 {"error":"internal_error"}` because the blanket error handler discards Fastify's own `statusCode`, so a bot will retry a permanently-broken request forever. Every `pnpm render:local -- ...` invocation in the README is broken on the installed pnpm — the literal `--` reaches `parseArgs` and the command dies before rendering, and CI never runs it. The wire contract's accepted ranges (`durationMs` 3000-10000, `fps` 12|15|20, `size` 384|480|512, option count/length limits) exist only in the zod schema and are documented in no README, doc, or error-code table, and `docs/deployment.md:47` makes a timeout claim the code does not implement.
---
## 2. Quality gate results
| Gate | Command | Result | Output |
|---|---|---|---|
| Lint | `pnpm lint` | **PASS** | `eslint .` — clean, exit 0 |
| Typecheck | `pnpm typecheck` | **PASS** | `tsc --noEmit` — clean, exit 0 |
| Test | `pnpm test` | **PASS** | `Test Files 9 passed (9) / Tests 58 passed (58)`, 2.94s |
| API smoke | `pnpm api:smoke` | **PASS** | `api-smoke: 441166 bytes`, render 1461ms, 4.1s wall (warm `.tmp/remotion-bundle` on disk) |
| CLI help | `pnpm render:local -- --help` | **FAIL** | `Unable to render GIF: Unexpected argument '--help'. This command does not take positional arguments`, exit 1 |
| CLI help | `node scripts/render-local.js --help` | PASS | usage printed, exit 0 |
Environment: pnpm 11.1.1, Node v24.14.0. Chromium present (`node_modules/.remotion/chrome-headless-shell`), so no gate was skipped for a missing browser.
Note: CI (`.github/workflows/ci.yml`) and the Dockerfile pin pnpm 11.9.0; `package.json` has no `packageManager` field, so local runs float. CI runs `render:smoke` and `api:smoke` but never `render:local`, which is why the broken CLI path ships green.
---
## 3. Findings
### HIGH-1 — Blanket error handler turns 400/413/415 into 500
`src/server.js:33-36`
```js
app.setErrorHandler(async (error, _request, reply) => {
app.log.error(error);
return reply.code(500).send({error: 'internal_error'});
});
```
Fastify's content-type-parser errors carry a correct `statusCode`; the handler throws it away. Verified directly — the error object is intercepted and its real code printed:
| Request | Fastify error | Real statusCode | Caller receives |
|---|---|---|---|
| `POST /api/gif` with no body | `FST_ERR_CTP_EMPTY_JSON_BODY` | 400 | `500 {"error":"internal_error"}` |
| Body `{"options":[` | `FST_ERR_CTP_INVALID_JSON_BODY` | 400 | `500 {"error":"internal_error"}` |
| 40 KB body (limit 32 KB) | `FST_ERR_CTP_BODY_TOO_LARGE` | 413 | `500 {"error":"internal_error"}` |
| `content-type: application/xml` | `FST_ERR_CTP_INVALID_MEDIA_TYPE` | 415 | `500 {"error":"internal_error"}` |
Scenario: the bot serializes a payload with a trailing comma or an oversized option list. It gets a 5xx, which by every retry convention means "server fault, retry" — so it retries a request that can never succeed, and each attempt writes a full stack trace at `level:50` in the operator's logs. The client-visible symptom is indistinguishable from a Chromium crash.
Fix (`src/server.js:33`):
```js
app.setErrorHandler(async (error, request, reply) => {
const status = error.statusCode ?? 500;
if (status >= 500) {
request.log.error(error);
return reply.code(500).send({error: 'internal_error'});
}
request.log.warn({err: error}, 'request rejected');
return reply.code(status).send({error: error.code ?? 'bad_request', message: error.message});
});
```
This also fixes HIGH-4's logging problem (`request.log` carries `reqId`; `app.log` does not — see MEDIUM-4).
---
### HIGH-2 — Every documented `pnpm render:local -- ...` invocation fails
`README.md:70-77` (PowerShell), `README.md:81-88` (bash), `README.md:91` (`--help`), and the usage string itself at `scripts/render-local.js:7`.
pnpm 11 passes the literal `--` through to the script. Captured:
```
$ pnpm render:local -- --output .tmp/probe.gif --option "alpha" --option "beta" --winner 1
$ node scripts/render-local.js "--" "--output" ".tmp/probe.gif" "--option" "alpha" ...
Unable to render GIF: Unexpected argument '--output'. This command does not take positional arguments
```
`node:util.parseArgs` treats `--` as the options terminator, so everything after it becomes a positional, and `allowPositionals: false` (`scripts/render-local-args.js:41`) rejects it. Both the PowerShell and the bash variants fail identically — this is not a shell-quoting difference. The form that works is without `--`:
```
$ pnpm render:local --option alpha --option beta --winner 1
```
Scenario: a new contributor follows the README's primary CLI example verbatim and the tool refuses to run, printing a usage block that repeats the same broken syntax.
Fix: drop `--` from `README.md:71`, `README.md:82`, `README.md:91`, and `scripts/render-local.js:7`. Optionally add `pnpm render:local --help` to CI so the path is exercised.
---
### HIGH-3 — Accepted ranges and defaults are documented nowhere
`README.md:14-24` and `docs/miti99bot-integration.md:18-28` show one example payload with no ranges. The real constraints live only in `src/schemas/wheel-request.js:26-53`:
| Field | Required | Constraint | Default |
|---|---|---|---|
| `options` | yes | array of strings, trimmed, each 1..`MAX_OPTION_CHARS` (40) after trim, 2..`MAX_OPTIONS` (32) items | — |
| `winnerIndex` | no | integer, `0 <= n < options.length` | random (`crypto.randomInt`) |
| `durationMs` | no | integer 3000..10000 | 6500 |
| `holdMs` | no | integer 500..2500 | 1200 |
| `fps` | no | exactly 12, 15, or 20 | 15 |
| `size` | no | exactly 384, 480, or 512 | 512 |
| `theme` | no | `classic` \| `festival` \| `mono` | `classic` |
| unknown keys | — | rejected (`.strict()`) | — |
| body size | — | 32 KB hard cap (`src/server.js:29`) | — |
Two of these are actively surprising and undocumented: `.strict()` means a typo'd key is a hard 400 (`{"colour":"red"}` -> `unrecognized_keys`), and `size`/`fps` are literal unions, so `size: 500` is rejected rather than snapped to the nearest supported value. `MAX_OPTIONS`/`MAX_OPTION_CHARS` are per-deployment, so a hardcoded client limit can disagree with the server's.
Fix: replace the bare example in `README.md:14-24` with the table above; link to it from `docs/miti99bot-integration.md`.
---
### HIGH-4 — No documented error-code contract for the consuming bot
`docs/miti99bot-integration.md:1-39` documents only the 200 response. Step 6 ("If the service fails, bot falls back") gives no way to distinguish retryable from terminal. Captured actual behavior:
| Condition | Status | Body | Retryable? |
|---|---|---|---|
| Missing / wrong / non-`Bearer` token | 401 | `{"error":"unauthorized"}` | no |
| Schema violation | 400 | `{"error":"invalid_request","issues":[...]}` | no |
| Malformed/empty/oversized body | **500** | `{"error":"internal_error"}` | **misleading — see HIGH-1** |
| Concurrency saturated | 429 | `{"error":"too_many_renders"}`, **no `Retry-After`** | yes |
| Render exceeded timeout | 504 | `{"error":"render_timeout"}` | maybe |
| Render crash | 500 | `{"error":"internal_error"}` | maybe |
| `GET /api/gif` | 404 | `{"message":"Route GET:/api/gif not found","error":"Not Found","statusCode":404}` | no |
Two things to note. The 404 body uses Fastify's default `{message, error, statusCode}` shape, inconsistent with the `{error: "snake_case"}` convention everywhere else — a client parsing `body.error` gets `"Not Found"` instead of a machine token. And the 401 is identical for "no header", "wrong token", and "token sent without the `Bearer ` prefix"; the last is the single most common integration mistake and the response gives no hint.
Fix: add this table to `docs/miti99bot-integration.md`. Set `app.setNotFoundHandler` to the `{error: 'not_found'}` shape for consistency.
---
### HIGH-5 — 429 on saturation with no `Retry-After` and no queue
`src/routes/gif.js:71-73`
```js
if (!deps.semaphore.tryAcquire()) {
return reply.code(429).send({error: 'too_many_renders'});
}
```
With the recommended `MAX_CONCURRENT_RENDERS=1` (`README.md:118-119`, `.env.example:4`, `compose.yml:9`) and a measured render of ~1.5s at 384px/12fps — and `docs/render-benchmarks.md:11` recording 13917ms for the same fixture in Docker — the *second* concurrent chat user gets an instant 429. This is the single most likely production failure, and the response carries no `Retry-After`, no indication of how long a render takes, and no queue. A naive bot retry loop will hot-spin.
Fix: one header plus a doc line.
```js
return reply.code(429).header('Retry-After', '5').send({error: 'too_many_renders'});
```
Document in `docs/miti99bot-integration.md` that the bot must serialize its calls or back off on 429.
---
### MEDIUM-1 — `RENDER_TIMEOUT_MS` is not a total timeout, contrary to the docs
`docs/deployment.md:47` states: "`RENDER_TIMEOUT_MS` is a total render timeout." The implementation does not guarantee that.
In `src/render/render-gif.js:34-61`, the `cancelSignal` from `makeCancelSignal()` is passed **only** to `renderMedia` (line 52). It is not passed to `selectComposition` (lines 44-49) — and cannot be: `selectComposition` does not accept `cancelSignal` in this version (verified against `node_modules/@remotion/renderer/dist/select-composition.d.ts`, which exposes `timeoutInMilliseconds` but no `cancelSignal`). `getRemotionServeUrl()` (line 43) has no timeout at all.
So when the timer fires during browser launch or composition selection, `cancel()` fires into a signal nobody is listening to and the call runs on under Remotion's own per-operation timeout. If browser launch wedges (OOM-killed Chromium, exhausted file descriptors), the promise may never settle: the `finally` never runs, the semaphore slot is never released, and every subsequent request gets 429 forever while `/api/healthz` still returns `{"ok":true}`. Restart is the only recovery, and nothing signals that a restart is needed.
Fix — bound the whole operation, not just `renderMedia`:
```js
const deadline = new Promise((_resolve, reject) => {
timeoutId = setTimeout(() => {
timedOut = true;
cancel();
reject(new RenderTimeoutError(options.timeoutInMilliseconds));
}, options.timeoutInMilliseconds);
});
// then: await Promise.race([deadline, renderPipeline()])
```
Either implement the guarantee or correct `docs/deployment.md:47` to say it bounds the render phase only.
---
### MEDIUM-2 — Client disconnect does not cancel the render
`src/routes/gif.js:75-100`. Nothing wires `request.raw` aborts to the existing `cancelSignal`. If the bot's HTTP client times out at 10s and retries, the abandoned render keeps the only slot for its full duration; the retry gets 429. Under `MAX_CONCURRENT_RENDERS=1` a client with a timeout shorter than the render time can deadlock itself indefinitely.
Fix: plumb an abort into `renderWheelGif`, or at minimum document in `docs/miti99bot-integration.md` that the client timeout must exceed `RENDER_TIMEOUT_MS`.
---
### MEDIUM-3 — Config errors are silently swallowed at boot
`src/config.js:20-27`. `parsePositiveInt` returns the fallback for anything unparseable, with no log. Captured from `loadConfig`:
| Env | Result | Problem |
|---|---|---|
| `MAX_CONCURRENT_RENDERS=abc` | `1` | typo silently ignored |
| `MAX_CONCURRENT_RENDERS=0` | `1` | intent (disable) silently inverted |
| `RENDER_TIMEOUT_MS="ten seconds"` | `15000` | silently ignored |
| `MAX_OPTION_CHARS=0` | `40` | silently ignored |
| `PORT=99999` | `99999` | accepted, then `listen` fails with a raw `ERR_SOCKET_BAD_PORT` |
| `MAX_OPTIONS=1` | `1` | **every request 400s forever** |
| `REQUIRE_API_TOKEN=TRUE` | `requiresApiToken: false` | case-sensitive `=== 'true'` (`src/config.js:35`); operator believes auth is enforced when it is not |
`MAX_OPTIONS=1` is the worst: the schema becomes `.min(2).max(1)`, unsatisfiable. Verified — every payload returns `{"code":"too_big","maximum":1,...}`. The service boots healthy and 400s 100% of traffic.
The `REQUIRE_API_TOKEN=TRUE` case is a security-relevant silent failure: the operator sets what they believe is the auth switch, boot succeeds, and the endpoint is wide open.
Fix in `loadConfig`: throw on a set-but-unparseable value instead of falling back, and reject `maxOptions < 2`. Compare `REQUIRE_API_TOKEN` case-insensitively.
---
### MEDIUM-4 — Error logs have no `reqId` and no request context
`src/server.js:34` uses `app.log.error(error)`. Captured output for a simulated Chromium crash:
```
{"level":50,"time":...,"err":{"type":"Error","message":"chromium crashed: page closed unexpectedly","stack":"..."},"msg":"chromium crashed..."}
```
No `reqId` field — compare the surrounding `incoming request` / `request completed` lines, which both carry `"reqId":"req-1"`. With `MAX_CONCURRENT_RENDERS>1` an operator cannot correlate a stack trace with the request that produced it. There is also no render context anywhere: option count, `size`, `fps`, `durationMs` are never logged, so "renders started failing" cannot be narrowed to "only 512px/20fps fails".
Fix: `request.log.error(error)` (included in the HIGH-1 patch), and log a one-line success/failure record in the route with `{optionCount, size, fps, durationMs, byteLength}`.
---
### MEDIUM-5 — `/api/healthz` proves only that the event loop is alive
`src/routes/health.js:4-9` returns a static object. It cannot detect a wedged semaphore (MEDIUM-1), a failed bundle, or a missing Chromium. `compose.yml:16` wires the container healthcheck to it, so Docker will report a permanently-429ing container as healthy.
Mitigating fact worth noting: `src/server.js:24-26` awaits the bundle *before* `Fastify()` and `listen`, so the port does not open until the bundle exists. Readiness is therefore implicitly correct at startup — the gap is steady-state liveness only.
Fix (small): report semaphore state so saturation is observable.
```js
app.get('/api/healthz', async () => ({ok: true, service: 'wheelofnames', activeRenders: semaphore.active, maxConcurrentRenders: config.maxConcurrentRenders}));
```
Requires exposing a read-only `active` from `createRenderSemaphore`.
---
### MEDIUM-6 — Auth silently disabled when `API_TOKEN` is unset outside production
`src/routes/gif.js:47` gates on `deps.config.apiToken` being truthy. If it is unset and `NODE_ENV !== 'production'`, the boot check at `src/config.js:37-39` does not fire and `/api/gif` is fully open — no warning logged, no mention in any doc. A deploy target that does not set `NODE_ENV=production` by default (Cloud Run and Railway among the recommended targets at `docs/deployment.md:8-15`) yields an unauthenticated GIF-rendering endpoint: unmetered CPU for anyone who finds it.
Note `config.requiresApiToken` is computed and returned but never read by the route — the only enforcement is the boot check.
Fix: log a startup warning when `apiToken` is undefined, and state the "no token = no auth" rule in `docs/deployment.md`.
---
### MEDIUM-7 — `compose.yml` defaults the production token to `change-me`
`compose.yml:5,8`: `NODE_ENV` defaults to `production` and `API_TOKEN` defaults to `change-me`. `docker compose up` with no `.env` produces a production deployment whose bearer token is published in this repo. The boot guard at `src/config.js:37` is satisfied and never fires.
Fix: `API_TOKEN: "${API_TOKEN:?API_TOKEN must be set}"` so compose refuses to start instead of defaulting. Same for the `docker run -e API_TOKEN=change-me` example at `README.md:115` — mark it explicitly as a placeholder.
---
### MEDIUM-8 — `.env.example` is orphaned: nothing loads it, nothing documents it
`.env.example` exists with 8 variables. There is no `dotenv` dependency, no `--env-file` flag in any `package.json` script, and `grep` finds zero references to `.env.example` in `README.md`, `AGENTS.md`, or `docs/`. A developer who does the conventional `cp .env.example .env` and runs `pnpm dev` gets no config applied and no error — the defaults silently win.
Also, `.env.example:3` sets `NODE_ENV=production`, which is wrong for the local-dev file it appears to be.
Fix: either add `--env-file=.env` to the `dev` script (Node 24 supports it natively; no dependency needed) and document the copy step in README, or delete `.env.example` and keep `docs/deployment.md`'s shell-variable model as the single story.
---
### MEDIUM-9 — No graceful shutdown
No `SIGTERM`/`SIGINT` handler exists in `src/` (grep confirms the only `app.close()` is in `scripts/api-smoke.js:49`). `Dockerfile:48` uses `CMD ["pnpm", "start"]`, so PID 1 is pnpm and signal forwarding to Node is unreliable. On `docker stop` / a rolling deploy, an in-flight render is killed mid-request and the client sees a connection reset rather than a clean 5xx; the container takes the full 10s kill grace period.
Fix: `CMD ["node", "src/server.js"]`, plus a handler calling `app.close()`.
---
### MEDIUM-10 — A failed bundle is cached as a permanently rejected promise
`src/render/remotion-bundle.js:11-21`. `bundlePromise` is assigned once; if `bundle()` rejects, every later call returns the same rejected promise. On the server path the boot warm-up makes this mostly moot (the process dies). It bites `scripts/render-fixtures.js` and any caller running with `warmRemotionBundle: false`: one transient failure poisons the module for the process lifetime with no retry.
Fix: clear the cache on rejection.
```js
bundlePromise = bundle({...}).catch((error) => { bundlePromise = undefined; throw error; });
```
---
### MEDIUM-11 — CLI ignores the deployment's configured limits
`scripts/render-local-args.js:5-8` hardcodes `{maxOptions: 32, maxOptionChars: 40}` instead of reading `loadConfig()`. An operator who raises `MAX_OPTIONS` cannot reproduce a production payload locally — the CLI rejects it with a limit the server would accept.
Fix: `const limits = loadConfig();` — `AppConfig` already carries both fields.
---
### MEDIUM-12 — Test coverage gaps on exactly the failure modes above
58 tests pass, but the untested paths are the risky ones.
Not covered at all: `src/render/render-gif.js` (timeout classification, temp-dir cleanup on throw), `src/render/remotion-bundle.js`, `src/lib/tmp-files.js`, `src/routes/health.js`.
Covered module, uncovered behavior in `test/gif-route.test.js`:
- malformed / empty / oversized body — would have caught HIGH-1
- `X-Wheel-Winner` percent-encoding: line 39 asserts `'beta'`, which is ASCII, so `encodeURIComponent` is never exercised. The behavior does work (probe: `Chiều nay uống CraneTea` -> `Chi%E1%BB%81u%20nay%20u%E1%BB%91ng%20CraneTea`), but the header contract README advertises is untested.
- 401 discrimination between missing header, wrong token, and missing `Bearer ` prefix
- absence/presence of `Retry-After` on 429
- 404 body shape
Highest-value additions: an oversized-body test and a non-ASCII winner-header test. Both are three lines each in the existing file.
---
### LOW
- **`scripts/render-local.js:37`** — a validation failure prints the raw `ZodError` JSON. Actual output for a missing `--option`: a 12-line pretty-printed array. Format to one line per issue (`path.join('.')` + `message`).
- **`README.md:10`** — `Accept: image/gif` is shown in the request example but the server never reads `Accept`. Decorative; either honor it or drop it.
- **`scripts/render-local-args.js:57-60`** — `--timeout` is validated before the `--help` branch (line 62), so `--help --timeout 100` errors instead of showing help. Move the help check first.
- **`scripts/render-local.js:17`** — `--theme` is the only flag whose usage line omits its default (`classic`).
- **`docs/render-benchmarks.md:13-14`** — two rows still `TBD` under a heading that says "Fill this after running `pnpm render:fixtures`". Either fill or delete the rows.
- **Duplicate options** are accepted (probe: `["dup","dup"]` -> 200). This is intentional — `README.md:73-74` deliberately repeats an option — but it means `X-Wheel-Winner` is ambiguous and only `X-Wheel-Winner-Index` identifies the slot. Worth one sentence in `docs/miti99bot-integration.md`.
- **`Dockerfile`** — no `HEALTHCHECK` (only `compose.yml` has one, so plain `docker run` gets none) and no `USER node`, so the service runs as root. `package.json` has no `packageManager` field despite CI and the Dockerfile both pinning pnpm 11.9.0.
- **`src/server.js:29`** — `bodyLimit` is hardcoded at 32 KB while `MAX_OPTIONS`/`MAX_OPTION_CHARS` are configurable. `MAX_OPTIONS` above ~700 makes a legitimate maximum payload exceed the body limit, surfacing as HIGH-1's bogus 500.
---
## 4. Doc-accuracy table
| Claim | Location | Actual behavior | Verdict |
|---|---|---|---|
| "`RENDER_TIMEOUT_MS` is a total render timeout." | `docs/deployment.md:47` | Bounds `renderMedia` only; `selectComposition` cannot take the cancel signal, `getRemotionServeUrl()` is unbounded | **Wrong** |
| `pnpm render:local -- --option ...` (PowerShell) | `README.md:71` | Fails: `Unexpected argument '--output'` | **Wrong** |
| `pnpm render:local -- --option ...` (bash) | `README.md:82` | Fails identically | **Wrong** |
| "Run `pnpm render:local -- --help`" | `README.md:91` | Fails: `Unexpected argument '--help'` | **Wrong** |
| Usage line `pnpm render:local -- --option <text>` | `scripts/render-local.js:7` | Same broken form echoed in the tool's own help | **Wrong** |
| "Required env vars: PORT, HOST, MAX_CONCURRENT_RENDERS, RENDER_TIMEOUT_MS, MAX_OPTIONS, MAX_OPTION_CHARS, API_TOKEN" | `docs/deployment.md:24-34` | All have defaults; only `API_TOKEN` is required, and only in production | **Misleading** |
| "`API_TOKEN` is required when `NODE_ENV=production`" | `README.md:121` | True, but omits the `REQUIRE_API_TOKEN=true` path (`src/config.js:35`), which is documented nowhere | **Incomplete** |
| Example payload as the API contract | `README.md:14-24`, `docs/miti99bot-integration.md:18-28` | No ranges, no defaults, no `.strict()` warning, no error codes | **Incomplete** |
| `.env.example` implies a loadable env file | `.env.example` (whole file) | No loader, no dependency, zero doc references | **Misleading** |
| "`RENDER_TIMEOUT_MS` defaults to `15000` and is raised to Remotion's `7000ms` floor when lower" | `README.md:119-120`, `.env.example:5-6`, `docs/deployment.md:47-48` | Matches `src/config.js:45-48`; verified `RENDER_TIMEOUT_MS=3000` -> `7000` | **Accurate** |
| "`X-Wheel-Winner` is URL-encoded so non-ASCII labels are safe" | `README.md:32`, `docs/miti99bot-integration.md:38` | Verified by probe | **Accurate** |
| "compose.yml forwards these values with `${VAR:-default}` fallbacks" | `docs/deployment.md:36-37` | Matches `compose.yml:5-12` — but omits that `API_TOKEN` falls back to `change-me` | **Accurate but incomplete** |
| "Docker image runs `pnpm browser:ensure` during build so production requests do not download Chrome on first render" | `docs/deployment.md:44-45` | Matches `Dockerfile:43`. Does not mention that the Remotion *bundle* is still built at boot (`src/server.js:24-26`), which delays first listen | **Accurate but incomplete** |
| Fixture set: `smoke.gif`, `vietnamese.gif`, `sixteen-options.gif` | `README.md:61-62` | Matches `scripts/render-fixtures.js:9-46` | **Accurate** |
| Root `wheel.gif` and `fixtures/*.gif` are git-ignored | `README.md:92-94` | Matches `.gitignore`; `git ls-files` confirms neither is tracked | **Accurate** |
| Themes `classic`, `festival`, `mono` | `README.md:22`, `scripts/render-local.js:17` | Matches `src/remotion/themes.js:2,14,26` | **Accurate** |
| "API validation must reject oversized requests before render work starts" | `AGENTS.md:24` | `src/routes/gif.js:54-73` validates before `tryAcquire` | **Accurate** |
| "Rendering must not rebundle Remotion per request" | `AGENTS.md:25` | Memoized in `src/render/remotion-bundle.js`, warmed at boot | **Accurate** |
---
## 5. Prioritized actions
**Quick wins (each under ~15 minutes, no design decisions)**
1. HIGH-2 — remove `--` from the four documented CLI invocations. Pure doc/string edit; unblocks the primary local workflow.
2. HIGH-1 — honor `error.statusCode` in the error handler and switch to `request.log`. ~6 lines; also resolves MEDIUM-4's missing `reqId`.
3. HIGH-5 — add `Retry-After: 5` to the 429. One line.
4. MEDIUM-7 — `${API_TOKEN:?...}` in `compose.yml`. One line; closes the `change-me` default.
5. MEDIUM-9 — `CMD ["node", "src/server.js"]` in the Dockerfile. One line (the `app.close()` handler can follow separately).
6. MEDIUM-10 — reset `bundlePromise` on rejection. One line.
7. MEDIUM-11 — `loadConfig()` in `scripts/render-local-args.js` instead of hardcoded limits. Two lines.
8. HIGH-3 / HIGH-4 — add the field-constraint table and the error-code table to `README.md` and `docs/miti99bot-integration.md`. Doc-only, highest integrator value per minute spent.
9. MEDIUM-12 (partial) — add the oversized-body and non-ASCII-winner-header tests to `test/gif-route.test.js`. Six lines, locks in HIGH-1 and the advertised header contract.
10. Fix `docs/deployment.md:24` ("Required env vars" -> "Environment variables and defaults") and either fill or drop the `TBD` rows in `docs/render-benchmarks.md:13-14`.
**Larger work (needs a decision, not just an edit)**
1. MEDIUM-1 — enforce a real wall-clock bound with `Promise.race`, then either keep or correct the `docs/deployment.md:47` claim. This is the finding with the worst tail: a wedged slot is unrecoverable without a restart and invisible to the healthcheck.
2. MEDIUM-3 — decide the config-validation posture: fail fast on any set-but-invalid value, or keep silent fallbacks and log them. Either way, `MAX_OPTIONS < 2` and case-insensitive `REQUIRE_API_TOKEN` should be handled.
3. MEDIUM-6 — decide whether an unauthenticated `/api/gif` is ever acceptable. Minimum: a loud startup warning.
4. MEDIUM-2 — client-disconnect cancellation, or a documented client-timeout contract. Interacts with MEDIUM-1; do them together.
5. MEDIUM-5 — decide how much state `/api/healthz` should expose (`activeRenders` is the cheap 80%).
6. MEDIUM-8 — keep `.env.example` and wire `--env-file`, or delete it. Do not leave it orphaned.
---
## 6. Unresolved questions
1. **Is `MAX_CONCURRENT_RENDERS=1` plus immediate-429 the intended contract, or is a short queue expected?** With a single chat bot and multi-second renders, the second concurrent user always fails. Whether that is acceptable depends on the bot's fallback behavior, which lives outside this repo (`docs/miti99bot-integration.md:12` mentions a local fallback renderer). This changes whether HIGH-5 is "add a header" or "add a queue".
2. **Which deploy target is actually in use?** MEDIUM-6's severity depends on whether the target sets `NODE_ENV=production` by default. `docs/deployment.md:8-15` lists six options with different defaults.
3. **Is `POST /api/gif` internet-exposed or on a private network?** Determines whether the bearer token is the only control and how hard MEDIUM-7 bites.
4. **Should `size`/`fps` reject unsupported values or snap to the nearest supported one?** Current strictness is defensible but undocumented, and `size: 500` is an easy client mistake.
5. **Cold-start budget?** The measured 4.1s `api:smoke` used an on-disk `.tmp/remotion-bundle`. A fresh container builds the bundle before `listen`, and that duration was not measured here (would require a clean Docker build). Relevant to platforms with startup-probe deadlines.
@@ -1,122 +0,0 @@
# Documentation Review and Update Report
**Date:** 2026-07-25
**Scope:** Confetti winner celebration feature documentation and schema validation across docs
---
## Files Reviewed
### Documentation Files
- `README.md` — API and local render instructions
- `docs/miti99bot-integration.md` — Bot integration request/response spec
- `docs/deployment.md` — Deployment configuration and runtime info
- `docs/render-benchmarks.md` — Render performance baseline
- `docs/journals/260715-1510-direct-local-gif-rendering.md` — Historical journal
### Code Files (Verification)
- `src/schemas/wheel-request.js` — Request validation schema
- `src/remotion/themes.js` — Theme definitions
- `src/remotion/Confetti.jsx` — Confetti component
- `src/remotion/confetti-layout.js` — Confetti physics and seeding
- `src/remotion/WheelComposition.jsx` — Integration point
---
## Files Edited
### README.md
**Change:** Added brief mention of confetti winner celebration feature.
**Location:** After response headers section (line 32).
**New text:**
```
The GIF includes a winner celebration with a deterministic confetti burst
during the hold phase, using colors from the chosen theme palette.
```
**Rationale:** Confetti is a user-visible feature automatically included in every GIF render. Users should know it exists. Documentation is concise and focuses on observable behavior (deterministic, theme-colored, timing relative to hold phase).
---
## Schema Validation Results
Verified all request/response examples match current Zod schema (`src/schemas/wheel-request.js`):
| Field | Min | Max | Default | Examples Match |
|-------|-----|-----|---------|-----------------|
| options | 2 | N/A | N/A | ✓ Both examples use 3 options |
| winnerIndex | 0 | < options.length | random | ✓ Value 1 within range |
| durationMs | 3000 | 10000 | 6500 | ✓ Example: 6500 |
| holdMs | 500 | 2500 | 1200 | ✓ Example: 1200 |
| fps | {12, 15, 20} | — | 15 | ✓ Example: 15 |
| size | {384, 480, 512} | — | 512 | ✓ Example: 512 |
| theme | {classic, festival, mono} | — | classic | ✓ Example: classic |
**Result:** No drift detected. Examples in `README.md` and `docs/miti99bot-integration.md` are accurate.
---
## Confetti Feature Documentation
**Status:** Feature is fully implemented and integrated.
**Code Locations:**
- Trigger: `src/remotion/WheelComposition.jsx` line 186 (renders during hold phase, seeded by winner index)
- Rendering: `src/remotion/Confetti.jsx` (deterministic paper-strip particles)
- Physics: `src/remotion/confetti-layout.js` (seeded random, ballistic path)
- Integration: Automatic; no user configuration needed
**User-Visible Behavior:**
- Triggered automatically during the hold phase (after spinning stops)
- Deterministic: same winner index always produces identical confetti
- Color palette: derived from theme slices
- Particle count: 70 by default (constant in component)
**Documented in:** `README.md` (new addition)
---
## Staleness Assessment
### Plans and Reports
- Status: **Empty** (both `plans/` and `plans/reports/` contain no files)
- Action: None required
### Journals
- **File:** `docs/journals/260715-1510-direct-local-gif-rendering.md` (dated 2026-07-15)
- **Status:** Point-in-time historical record, not stale
- **Verification:**
- Claims "Passed all 45 tests" were accurate when written (2026-07-15)
- Describes finalized feature (`pnpm render:local`), not aspirational
- No operational claims that are actively misleading today
- **Action:** No edits needed. Journals document decisions and context at a point in time; test count changes do not make historical records invalid.
---
## No Changes Needed
### deployment.md
Deployment documentation is complete and accurate.
### render-benchmarks.md
Benchmarks table is waiting for updated measurements after confetti feature integration. This is expected placeholder state (marked TBD for two fixtures). **No action taken** — re-running `pnpm render:fixtures` is a performance measurement task outside doc scope.
### pnpm start/dev Windows Fix
The entrypoint guard fix (Windows binding issue) is an internal implementation detail. The README never claimed it was broken, and the command still works correctly. **No documentation change required** per YAGNI and noise-avoidance rules.
---
## Unresolved Questions
None. All verifications complete; no blockers identified.
---
## Summary
**Status:** DONE
Single update to `README.md` (2 lines added) to document the confetti winner celebration feature. All request/response examples validated against current schema with zero drift. Plans and reports directory confirmed empty. Journals assessed as point-in-time historical records (not stale). Deployment and benchmarks docs unchanged.
@@ -1,233 +0,0 @@
# Winner Celebration Visual Review — Confetti Burst
**Date:** 2026-07-25 · **Scope:** rendered motion design of the winner celebration (`Confetti.jsx`, `confetti-layout.js`, winner emphasis in `WheelComposition.jsx`, theme palettes)
**Mode:** advisory only, no source files modified.
---
## 1. Verdict
The celebration currently reads as a **small grey-to-colored dust puff at the hub**, not a confetti burst. Three compounding reasons: the burst fires 8-10 frames *after* the wheel has already visually stopped (so it has no beat to land on), it empties the frame in ~0.8s of a 1.13s window (70 → 3 on-screen particles), and it spends its densest frames clustered on top of the hub in colors identical to the wheel slices, so it looks like speckle noise rather than paper. The winner emphasis that is supposed to carry the moment — an animated `drop-shadow` glow — is at the perceptual threshold (mean ΔRGB 15 vs no-glow, ΔRGB 10 for the whole pulse) while paying for a dithered gradient ring on every hold frame. In `mono` the confetti is effectively invisible: 5 of 6 palette colors sit at 1.00-1.79:1 against the background. Net: the two celebration signals do not compete, because neither is strong enough to compete.
---
## 2. Measured baseline (empirical, this machine)
Verified by rendering, not by reading:
| Fact | Value |
| --- | --- |
| Default composition (`durationMs` 6500 + `holdMs` 1200 @15fps) | 116 frames; `spinFrames` 98; confetti `lifetimeFrames` **17** (1.133 s) |
| Render time, 512@15fps, 6 options | **4.1 s** (`smoke` 384@12fps: 2.5 s) — well inside `RENDER_TIMEOUT_MS` 15000 |
| GIF size, default | **1.80 MB**; spin phase 1.64 MB (88%), **hold phase 226 KB (12%)** |
| GIF loop count | `Iterations: 0` → **loops forever** |
| Particle bodies @512 | width 6.0-12.0 px, height 2.48-11.2 px |
| Particle speed | 326-802 px/s → **21.7-53.5 px per frame** at 15fps |
| On-screen particles, f+0 … f+17 | 70,70,70,70,70,70,68,63,57,51,45,36,26,23,14,11,6,**3** |
| Opacity, f+0 | **0.00** (first burst frame renders 70 invisible divs) |
| Winner-glow salience (60 probe points on winner arc, r=213..230) | no-glow→glow: mean ΔRGB 15.3 / max 46. glow-base→glow-peak: mean ΔRGB **10.2** / max 31 |
| Wheel rim motion before the burst | 3.1 px/frame at f=87, 1.6 px/frame at f=90, 0.2 px/frame at f=95 |
Commands used (outputs deleted afterwards; `fixtures/*.gif` is git-ignored):
`pnpm render:smoke`, then `node scripts/render-local.js -o fixtures/rev-<theme>.gif --option ... --winner 1 --theme <theme>`, frames extracted with `magick <gif> -coalesce`.
---
## 3. Findings
### F1 — HIGH · Ballistic-only physics empties the frame before the window ends
`src/remotion/confetti-layout.js:56` (`speed 320..820`), `:66` (`gravity: 1500`), `:81-85` (pure ballistic `x = x0 + vx·t`, no drag).
Measured depletion: 70 on-screen through f+5, then 45 at f+10, 26 at f+12, **3 at f+17**. The last third of the hold is a static wheel. Real confetti decelerates hard in air, then flutters; this launches like buckshot and leaves.
**Fix — closed-form exponential drag (still a pure function of `t`, no state):**
```js
// confetti-layout.js — replace getConfettiParticleState
const decay = 1 - Math.exp(-particle.drag * t);
const terminalY = particle.gravity / particle.drag;
x = particle.originX + (particle.vx / particle.drag) * decay;
y = particle.originY + ((particle.vy - terminalY) / particle.drag) * decay + terminalY * t;
```
Constants: `gravity: 1300 * scale`, `speed = lerp(rand('speed'), 700, 1500) * scale`, `drag = lerp(1 - sizeNorm, 2.4, 3.6)` (small chips decelerate faster — free depth cue, one line).
Simulated result with the same 17-frame window: on-screen `0,46,62,74,83,89,88,84,83,81,82,82,80,77,77,75,75,75`. Frame-to-frame step drops from 53 px at f+1 (the pop) to 14-25 px from f+7 on (readable flutter).
### F2 — HIGH · The first burst frame is fully transparent
`src/remotion/Confetti.jsx:28` — `interpolate(progress, [0, 0.06, 0.7, 1], [0, 1, 1, 0])` with `progress = localFrame / lifetimeFrames`, so at `localFrame = 0` opacity is exactly 0. Confirmed in the render: frame 98 shows zero confetti.
The only frame where all particles are still compact at the origin — the actual "pop" — is thrown away, and by f+1 they have already travelled 35 px average. Nothing in the GIF ever shows a dense cluster.
**Fix:** no fade-in. Opacity starts at 1 (see F9 for the tail).
### F3 — HIGH · `mono` confetti is invisible
`src/remotion/themes.js:36`, consumed at `src/remotion/WheelComposition.jsx:186` (`colors={theme.slices}`). Contrast vs background `#f4f4f5`:
`#f4f4f5` **1.00** · `#e4e4e7` **1.15** · `#d4d4d8` **1.34** · `#c4c4ca` **1.58** · `#b8b8bf` **1.79** · `#a1a1aa` 2.33
5 of 6 colors are below 1.8:1. The render confirms it: the burst reads as dust or sensor noise on a light grey field.
**Fix — add a `confetti` key per theme** (internal, no request-schema change), fall back to `slices` if absent:
```js
classic: ['#f97316', '#14b8a6', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#10b981', '#f43f5e'] // min 2.05 vs #f8fafc
festival: ['#f43f5e', '#ea580c', '#65a30d', '#0891b2', '#7c3aed', '#db2777', '#16a34a', '#d97706'] // min 2.91 vs #fff7ed
mono: ['#18181b', '#3f3f46', '#52525b', '#71717a', '#a1a1aa'] // 16.1 / 9.5 / 7.0 / 4.4 / 2.3
```
`classic` also drops `#facc15` (1.46 vs `#f8fafc`) and `#34d399` (1.84) — both effectively invisible as 8 px chips today even though they work as large slices. Cost: zero extra palette pressure (flat colors), no new DOM.
### F4 — HIGH · The burst fires ~0.6 s after the wheel has visually stopped
`src/remotion/wheel-layout.js:56` — `1 - (1 - p) ** 3`. Any polynomial ease-out has zero terminal velocity, so the last frames are motionless:
| frame | rim motion | remaining rotation |
| --- | --- | --- |
| 87 | 3.1 px/frame | 3.41° |
| 90 | 1.6 px/frame | 1.31° |
| 93 | 0.6 px/frame | 0.32° |
| 95 | 0.2 px/frame | 0.07° |
`Confetti startFrame = holdStartFrame = spinFrames = 98` (`WheelComposition.jsx:31,186`). The viewer perceives the stop around frame 88-90, then watches ~9 dead frames, then confetti appears out of nowhere. The celebration has no cause.
**Fix (cheap, no schema change):** one shared reveal frame, 5 frames earlier at 15fps:
```js
const revealFrame = Math.max(0, spinFrames - Math.round(fps * 0.33));
```
Cost: the wheel is 0.32° (1.2 px at the rim) short of final when the burst starts — imperceptible, and the winner slice is already fully under the pointer. Payoff: burst lands on the perceived stop *and* the window grows from 17 to 22 frames (+29%) for free. Drive both `Confetti startFrame` and the winner pulse from `revealFrame`.
### F5 — MEDIUM · Particles strobe at 15fps
Measured step 26-35 px average over f+0..f+5 against 6-12 px bodies — 3-5× their own length per frame, with no motion blur available in a GIF. They read as discrete jumping dots, not motion.
Fixed by F1 (step falls to 14-25 px from f+7). Optional extra, one line, deterministic: elongate the body along the launch direction in proportion to launch speed, `width *= 1 + speedNorm * 0.5`, which fakes motion blur with a static shape.
### F6 — MEDIUM · Confetti wears the wheel's own colors, over the wheel
`WheelComposition.jsx:186` passes `theme.slices` as the confetti palette, and the origin is the wheel center, so the densest frames sit entirely on the disc. With 6-8 slices, roughly 1 particle in 6-8 is on a slice of its own color at any moment — orange chips vanish on the orange slice. This is the main reason frames 99-103 read as "dirt on the wheel" rather than paper in the air.
**Fix — hard 1 px light ring, no blur:**
```js
boxShadow: '0 0 0 1px #ffffff',
```
`#ffffff` is already in the palette (slice strokes, `WheelComposition.jsx:115`), and a zero-blur spread produces no gradient, so this costs **zero new palette entries and zero dithering** while guaranteeing separation over any slice. Side benefit: it grows the effective particle by 2 px, which is exactly what the 384 px / 15 fps case needs.
### F7 — MEDIUM · The emission box is entirely inside the hub
`confetti-layout.js:63-64` — origin jitter is `±0.06 · size` = ±31 px at 512. The hub ring is `centerRingRadius = 0.09 · size` = 46 px (`WheelComposition.jsx:41,161`). Every particle is born inside the hub disc, so the launch reads as "the axle exploded".
**Fix, two options:**
- *Quick (2 lines):* origin on the winner slice, pointer side. `originX = center + radius * 0.55` (≈371 px at 512), `originY = center + lerp(rand, -0.03, 0.03) * size`, fan `-175..-45°`. Ties the burst to the thing being announced.
- *Better (small rework):* two cannons framing the wheel. Even index → `origin (0.06·size, 0.90·size)`, fan `-75..-30°`; odd index → `(0.94·size, 0.90·size)`, fan `-150..-105°`. With F1 constants (`v0` 700-1500, `drag` 3.0, `g` 1300) particles crest around `y ≈ 0.40 · size`, sweeping diagonally across the disc. The launch frames happen over the plain corner background, where the pop is legible, and the classic party-popper read is unmistakable.
### F8 — MEDIUM · Winner glow is near-threshold and fights the wheel's own shadow
`WheelComposition.jsx:118` — `drop-shadow(0 0 ${10 + winnerPulse * 10}px rgba(250,204,21,0.45))`, sitting in the same 15-25 px band outside the disc as `theme.wheelShadow` `0 18px 52px rgba(15,23,42,0.22)` (`:75`). The yellow at 45% alpha over a cool grey shadow over a near-white background produces a muddy cream tint: measured mean ΔRGB **15.3** vs no glow, and the entire 10→20 px pulse is only mean ΔRGB **10.2**. A contrast-stretched crop shows the halo is visibly **dithered speckle** — the worst possible payload for a 256-color GIF, animated on every hold frame.
**Fix — make the emphasis geometric instead of gradient.** Scale-pop the winner `Pie` about the wheel center:
```js
const pop = frame >= revealFrame
? Math.sin(Math.min(1, (frame - revealFrame) / Math.round(fps * 0.4)) * Math.PI) * 0.045
: 0;
// on the winner Pie only
transform: `scale(${1 + pop})`, transformOrigin: '50% 50%',
```
A 4.5% scale on a 210 px radius moves the arc edge **9.5 px** — an order of magnitude more legible than ΔRGB 10, and it moves existing flat colors instead of creating new blended ones. Then either delete the animated glow or make it a fixed, tighter, more opaque halo (`drop-shadow(0 0 8px rgba(250,204,21,0.75))`, constant for the whole hold) so it compresses as one static delta rather than 18 changing gradients. For `mono`, `winnerGlow: rgba(161,161,170,0.45)` on `#f4f4f5` is hopeless — use `rgba(24,24,27,0.35)` there.
### F9 — MEDIUM · The fade-out window is wasted and progress-relative
`Confetti.jsx:28` — fade starts at `progress 0.7`. At `holdMs` 1200 that is f+11.9, when only 36 of 70 particles remain, and it reaches 0 with 3 left. At `holdMs` 2500 it starts at f+26, when **zero** remain. So the fade never does visual work; it only spends 5 frames of alpha-blended particles (new blend colors → palette pressure) on nothing. It does not "fight the winner reveal" today — it is simply inert.
**Fix — frame-relative, quantized tail:**
```js
const fadeFrames = Math.min(4, Math.max(1, Math.round(lifetimeFrames * 0.25)));
const raw = interpolate(localFrame, [lifetimeFrames - fadeFrames, lifetimeFrames], [1, 0],
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const opacity = Math.ceil(raw * 4) / 4; // at most 5 alpha levels → bounded palette cost
```
Quantizing to quarters caps the number of new blended colors instead of generating a fresh set per frame. It also guarantees a confetti-free final frame, which matters because the GIF loops forever (F15).
### F10 — MEDIUM · Long holds buy dead air, not a longer celebration
Rendered `--hold 2500` (37 hold frames): confetti is gone by f+19 and the last **18 frames (1.2 s, half the hold)** are a completely static wheel. Burst length is governed by physics, not by the composition, so raising `holdMs` today makes the GIF worse. Do not raise the `holdMs` default as a fix. With F1 constants the same window drains at f+33, leaving 4 dead frames instead of 18.
### F11 — LOW · Sub-3px dimension at `size: 384`
`confetti-layout.js:62` — `height = width * lerp(rand('ratio'), 0.4, 1)`. At 384 (`scale` 0.75) minimum height is **1.87 px**, and 5 of 70 particles are under 2.5 px. After PNG→GIF quantization these are a single dim row of pixels.
**Fix:** `height: Math.max(3, width * ratio)`.
### F12 — LOW · One shape, linear spin, no air
All 70 particles are the same rounded rect, rotating at a constant `±540 deg/s` (`:65`) — 36°/frame at 15fps, which samples as flicker rather than tumble — with no lateral motion at all.
**Fix, three numbers, no new abstraction:**
- Aspect ratio `lerp(rand('ratio'), 0.25, 2.6)` with `height = clamp(width * ratio, 3, 26)`. One distribution yields chips, near-squares, and long ribbons — no `type` field, no branching.
- Size skew `width = lerp(rand('width') ** 1.7, 6, 18) * scale` — mostly 6-10 px with a few 14-18 px anchors that survive 15 fps and quantization.
- `rotationSpeed = lerp(rand('spin'), -300, 300)` — max 20°/frame, reads as a coherent tumble.
- Sway, added to `x`: `Math.sin(phase + t * hz * 2π) * amp * Math.min(1, t * 2)`, with `hz = lerp(rand('hz'), 0.5, 0.9)` and `amp = lerp(rand('sway'), 8, 20) * scale`. At 0.5-0.9 Hz that is a 17-30 frame cycle — well above the Nyquist limit at 15 fps, unlike a per-frame flip. **Do not** add 3D foreshortening (`scaleY(cos …)`): at 15 fps the flip aliases, and it drives the short axis below the 3 px floor from F11.
### F13 — LOW · `borderRadius: 1` on 6-12 px chips
`Confetti.jsx:50`. Invisible at this size but produces ~12 antialiased corner pixels per particle (≈850/frame at 70 particles) that all need palette slots. Set `0`.
### F14 — LOW · Non-winner labels are upside-down at rest
Not confetti, but it is what the reveal frame looks like. Final rotation is `7·360 − sliceCenter` (`wheel-layout.js:27`), e.g. 225° for winner 1 of 6, so labels in the upper-left read inverted (visible in every rendered hold frame). The **winner** label is always upright by construction, so the announcement itself is legible — the surrounding frame just looks sloppy. Out of scope here; worth a separate look.
### F15 — LOW · Infinite loop with a non-empty last frame
`identify` reports `Iterations: 0` and `render-gif.js:51-61` sets no `numberOfGifLoops`. At `holdMs` 1200 the final frame still carries 3 particles, so the loop point snaps confetti away at the same instant the wheel jumps back to `-24°`. F9's guaranteed-empty final frame removes half of that discontinuity for free.
---
## 4. Prioritized recommendations
### Quick wins (each well under 30 min, no contract change)
| # | Change | Files | Visual payoff | Risk |
| --- | --- | --- | --- | --- |
| 1 | Opacity starts at 1; frame-relative quantized tail (F2, F9) | `Confetti.jsx:28` | Recovers the pop frame; removes an inert 5-frame ramp | none to determinism; **reduces** palette churn |
| 2 | `theme.confetti` palettes (F3) | `themes.js`, `WheelComposition.jsx:186` | `mono` goes from invisible to legible; `classic` loses two dead colors | none; flat colors, no size change |
| 3 | Hard 1 px `#ffffff` ring (F6) | `Confetti.jsx:48-58` | Particles stop reading as dirt on the wheel face | none; `#ffffff` already in palette, no blur |
| 4 | `revealFrame = spinFrames − round(fps·0.33)` for confetti **and** winner pulse (F4) | `WheelComposition.jsx:31-37,186` | The burst finally lands on a beat; +29% window | 0.32° rotation error, imperceptible |
| 5 | `Math.max(3, …)` height floor; `borderRadius: 0` (F11, F13) | `confetti-layout.js:62`, `Confetti.jsx:50` | Nothing vanishes at 384; crisper edges | none; slightly smaller GIF |
| 6 | Shape/size/spin/sway numbers (F12) | `confetti-layout.js:55-70` | Ribbons + chips + tumble instead of uniform specks | none (all seeded); +1 `Math.sin` per particle per frame |
### Larger reworks
| # | Change | Payoff | Risk |
| --- | --- | --- | --- |
| A | **Exponential drag physics** (F1) — the single highest-payoff change | 75-90 particles on screen at the last frame instead of 3; punchy launch then real flutter; long holds stop being dead air | Determinism: safe, closed form in `t`. Render time: unchanged (same node count, 2 extra `Math.exp`). GIF: more moving pixels for ~10 extra frames; hold phase is 12% of bytes today, so expect roughly +5-8% total, i.e. ~1.9 MB. Needs `pnpm render:fixtures` re-measurement and a `docs/render-benchmarks.md` update. |
| B | **Two-cannon origin** (F7, option 2) | The clearest "celebration" read; launch frames happen over plain background where they are legible; stops the hub-explosion look | Determinism safe. Composition risk: verify particles do not pile on the winner label — with 90 particles at ~90 px² each, total coverage is ~3% of the frame, so occlusion is not a practical concern |
| C | **Geometric winner emphasis** (F8) — scale-pop, glow made static or removed | Winner emphasis becomes actually visible (9.5 px edge move vs ΔRGB 10); removes 18 frames of animated dithered gradient | Changes the winner's silhouette by 4.5% — check the 2 px white stroke and slice seams do not tear at `size: 384`. Likely a **net GIF-size win** |
| D | **Particle count 70 → 90** | Density during the good frames | +20 divs/frame; render cost negligible at 4.1 s total. Do this only after A, since A alone triples effective on-screen density |
### Explicitly do NOT do
- Per-particle opacity fades, blur, soft shadows, or gradient particle fills — each multiplies palette entries and produces the same dither speckle already visible in the winner glow.
- A back/front depth split (confetti behind the wheel). The disc occupies ~82% of the canvas width, so a back layer would be hidden for most of its life. Rejected on payoff, not on cost.
- Raising the `holdMs` default. F10 shows the current burst cannot fill even 1.2 s; fix physics first. Changing schema defaults would also be a user-visible behavior change.
- Any particle-system abstraction. Every finding above is a constant, a distribution range, or one closed-form expression in the two files that already exist.
### Suggested implementation order
1, 2, 3, 5 (independent, all low-risk) → A → 4 → C → B → D, re-rendering all three themes plus `--hold 500` and `--hold 2500` and `--size 384` after A, B and C.
---
## 5. Not verified empirically
- **GIF size and render time after the proposed changes.** All size/time figures are baseline measurements of the current code. The +5-8% estimate for drag physics is extrapolated from the measured 226 KB / 12% hold-phase share, not measured.
- **The two-cannon and scale-pop designs were not rendered** (advisory-only, no source edits). Trajectory claims for the cannons come from the same closed-form simulation used to tune the drag constants, not from a Chromium render.
- **Perceptual thresholds** — statements like "the viewer perceives the stop around frame 88-90" derive from rim displacement in px/frame (3.1 px at f=87, 1.6 px at f=90), not from user testing.
- **`fps: 12` and `fps: 20` with the proposed constants** were simulated (both hold density well) but not rendered.
- **Frame timing granularity**: `identify` shows delays alternating 6/7 centiseconds for 15 fps. Players that clamp low delays could shift the perceived envelope; not tested in any real client.
---
## 6. Unresolved questions
1. Is the infinite GIF loop intentional? The wheel snapping from the winner back to `-24°` forever undercuts the reveal. `numberOfGifLoops: 1` in `render-gif.js` would end on the winner — but that is a user-visible behavior change and some chat clients handle non-looping GIFs poorly.
2. Should the confetti palette stay tied to the theme, or become a fixed festive palette in all three themes? A fixed palette is the strongest visual answer for `mono`, but it breaks `mono`'s design intent (a deliberately restrained theme). The proposal above keeps `mono` monochrome by inverting to darks; confirm that reads as intended rather than as "black litter".
3. Is a spin easing with non-zero terminal velocity (or a small settle recoil) acceptable? That is the root cause of F4; the `revealFrame` shift is a workaround that leaves the wheel visually frozen for ~5 frames before the pop.
4. Is `mono`'s `winnerGlow` supposed to be visible at all? At `rgba(161,161,170,0.45)` on `#f4f4f5` it is unmeasurable, so today `mono` has no winner emphasis whatsoever.
@@ -1,369 +0,0 @@
# Wheel Animation Visual Design Review (advisory)
**Date:** 2026-07-25 · **Scope:** rendered GIF visual + motion design, excluding confetti particle design
**Verified empirically:** yes — 30 PNG stills + 3 GIFs rendered locally (Chromium present), contrast/ΔE/spin-curve computed numerically. No source file modified. No files written outside this report.
---
## 1. Verdict
The wheel is competently built and the winner never lands ambiguously, but it reads as a *pie chart that stopped*, not as a wheel that was spun. Motion is a single cubic ease-out over 7 turns: the first 22 frames move more than a full slice per frame (visual strobe/reverse illusion) and the last ~35 frames crawl under 4°/frame, so nearly 2.5 s of a 7.7 s GIF is visually static with no tick, no anticipation and no settle. Legibility is the bigger problem: roughly half of all labels render **upside down** at rest (`rotation: centerDegrees` with no 90–270° flip), so the poster frame of an 8-option wheel has 3–4 unreadable names; at a realistic ~200 px chat display almost nothing is readable and the winner is communicated only by a 17 px arrow at the frame edge. `mono` is the weakest theme — one slice is literally the background colour, dividers are white-on-near-white (1.10:1), and it reads as an unfinished grey blob. Finally there are three measurable production defects: LCD sub-pixel text antialiasing puts 268 unique colours into a single 90×30 px label crop (starving the 256-colour GIF palette), `pointerStroke`/`boxShadow` on the pointer are silently discarded by `clip-path`, and the max accepted request produces a **14.3 MB GIF in 10.3 s** on a fast dev box.
---
## 2. Contrast table (computed, WCAG 2.1 relative luminance)
`theme.text` on each slice. Pass thresholds: 3:1 large text (≥18.66px bold), 4.5:1 normal.
| Theme | Slice | text:slice | 3:1 | 4.5:1 | slice:bg | ΔE to next slice (normal) | ΔE deutan | ΔE protan |
|---|---|---:|:--:|:--:|---:|---:|---:|---:|
| classic (`#111827` / bg `#f8fafc`) | `#f97316` | 6.33 | pass | pass | 2.68 | 113 | 116 | 97 |
| | `#14b8a6` | 7.13 | pass | pass | 2.38 | 96 | 104 | 92 |
| | `#facc15` | 11.58 | pass | pass | 1.46 | 132 | 124 | 121 |
| | `#60a5fa` | 6.98 | pass | pass | 2.43 | 65 | 92 | 76 |
| | `#f472b6` | 6.70 | pass | pass | 2.53 | 46 | 65 | 59 |
| | `#a78bfa` | 6.52 | pass | pass | 2.60 | 113 | **16** | **31** |
| | `#34d399` | 9.23 | pass | pass | 1.84 | 109 | 79 | 53 |
| | `#fb7185` | 6.59 | pass | pass | 2.57 | 53 | **25** | **30** |
| festival (`#1f2937` / bg `#fff7ed`) | `#fb7185` | 5.45 | pass | pass | 2.53 | 68 | **18** | **25** |
| | `#f59e0b` | 6.83 | pass | pass | 2.02 | 70 | 50 | 35 |
| | `#84cc16` | 7.43 | pass | pass | 1.86 | 100 | 80 | 78 |
| | `#06b6d4` | 6.05 | pass | pass | 2.29 | 67 | **29** | **12** |
| | `#a78bfa` | 5.39 | pass | pass | 2.56 | 46 | 65 | 59 |
| | `#f472b6` | 5.54 | pass | pass | 2.49 | 130 | 51 | 26 |
| | `#22c55e` | 6.44 | pass | pass | 2.15 | 97 | 84 | 62 |
| | `#fb923c` | 6.49 | pass | pass | 2.13 | 50 | **17** | **22** |
| mono (`#18181b` / bg `#f4f4f5`) | `#e4e4e7` | 13.96 | pass | pass | 1.15 | 24 | 24 | 24 |
| | `#a1a1aa` | 6.91 | pass | pass | 2.33 | 19 | 19 | 19 |
| | `#d4d4d8` | 11.99 | pass | pass | 1.34 | **10** | **10** | **10** |
| | `#b8b8bf` | 8.98 | pass | pass | 1.79 | 22 | 21 | 21 |
| | `#f4f4f5` | 16.12 | pass | pass | **1.00** | 17 | 17 | 17 |
| | `#c4c4ca` | 10.20 | pass | pass | 1.58 | **12** | **11** | **12** |
Text-on-slice contrast **passes everywhere** — the 4-direction halo is not needed for contrast (see F-11). The failures are elsewhere:
Non-text contrast (WCAG 1.4.11 needs 3:1 for meaningful graphics):
| Pair | Ratio | Verdict |
|---|---:|---|
| classic pointer `#ef4444` vs bg `#f8fafc` | 3.60 | pass |
| classic pointer vs slice it overlaps `#f97316` | **1.34** | fail — tip vanishes on orange |
| classic pointer vs `#fb7185` | **1.40** | fail |
| festival pointer `#dc2626` vs `#fb7185` | **1.79** | fail |
| festival pointer vs bg `#fff7ed` | 4.55 | pass |
| mono pointer `#27272a` vs bg | 13.55 | pass |
| slice divider `#ffffff` vs mono `#f4f4f5` | **1.10** | fail — dividers invisible |
| slice divider `#ffffff` vs mono `#fafafa` (proposed) | 1.04 | fail |
ΔE guide: <15 = not reliably distinguishable at small display size. `min` cyclic adjacent ΔE across normal+deutan+protan: **classic 15.9, festival 11.7, mono 10.1** — all below the safe floor.
---
## 3. Findings
### F-1 — HIGH — Half of all labels render upside down
`src/remotion/WheelComposition.jsx:144` + `src/remotion/wheel-label-layout.js:208` (`rotation: centerDegrees`).
Any slice whose centre angle falls in 90°–270° gets text rotated past vertical, i.e. mirrored-looking (`ǝɔɐɹƃ`). In the default 8-option render at the final frame, `heidi grace frank erin` are all unreadable; at 16 options 8 names are unreadable; at 2 options `Yes` is unreadable. This is the single largest legibility loss and it is worst in the poster frame.
**Fix** (keeps radial orientation — settled decision untouched). Flip by the *rest-frame* screen angle so every label is upright in the final frame:
```js
// wheel-label-layout.js — new input: winnerCenterDegrees
const restAngle = ((centerDegrees - winnerCenterDegrees) % 360 + 360) % 360;
const flip = restAngle > 90 && restAngle < 270 ? 180 : 0;
// ...
rotation: centerDegrees + flip,
```
Caller: `winnerCenterDegrees: getSliceCenterDegrees(props.options.length, props.winnerIndex)`.
During the spin, upside-down-ness is unchanged (it is fixed in wheel space either way) — pure win at rest. Determinism: unaffected. GIF size: unaffected. Render time: unaffected.
Cheaper variant if you prefer winner-independence: `flip = centerDegrees % 360 > 90 && < 270`, which fixes the left half but leaves ~half the labels upside down at rest. Recommend the rest-frame version.
### F-2 — HIGH — Winner is not legible in the poster frame
`WheelComposition.jsx:32-37, 118`. During the hold the only winner signal is a `drop-shadow` glow on the winner `Pie` plus the edge pointer. At 200 px the pointer is ~17 px and the winner name ~9 px. The frame most chat clients show as a static thumbnail does not say who won.
**Fix** — winner-name pill over the hub during the hold. Reads at 200 px, costs 1–2 DOM nodes for 18 frames, 2 palette colours, and matches wheelofnames.com's winner modal convention:
```jsx
{frame >= holdStartFrame && (
<div style={{
alignItems: 'center', background: theme.winnerPillBg, borderRadius: size * 0.03,
color: theme.winnerPillText, display: 'flex', fontSize: winnerFontSize, fontWeight: 800,
justifyContent: 'center', left: '50%', lineHeight: 1.15, maxWidth: size * 0.74,
outline: `${Math.round(size * 0.008)}px solid ${theme.winnerPillRing}`,
padding: `${size * 0.022}px ${size * 0.042}px`, position: 'absolute', textAlign: 'center',
top: '50%', transform: `translate(-50%, -50%) scale(${0.72 + 0.28 * pillPop})`,
whiteSpace: 'pre-line', zIndex: 12,
}}>{winnerLines.join('\n')}</div>
)}
```
with `const pillPop = interpolate(frame, [holdStartFrame, holdStartFrame + 4], [0, 1], {easing: Easing.out(Easing.cubic), extrapolateLeft: 'clamp', extrapolateRight: 'clamp'})`, `winnerFontSize` derived from the existing `estimateTextWidth` against `size * 0.66` starting at `size * 0.085` (≈43 px at 512) and floored at `size * 0.045`, `winnerLines = getLabelLines(option, winnerFontSize, size * 0.66, 2)`.
New theme tokens: classic `winnerPillBg '#111827'`, `winnerPillText '#ffffff'`, `winnerPillRing '#facc15'`; festival `'#7f1d1d' / '#fffbeb' / '#f59e0b'`; mono `'#18181b' / '#fafafa' / '#a1a1aa'`. Contrast: 17.7:1, 14.2:1, 16.9:1.
`zIndex: 12` puts it above the confetti layer (`Confetti.jsx` uses `zIndex: 10`) so the name is never occluded — that is the only confetti interaction in scope here.
Determinism: pure `frame` function. GIF size: flat solid fill, adds ~2 palette entries; the pill is static for 14 of 18 hold frames so inter-frame delta is near zero. Render time: negligible.
### F-3 — HIGH — Spin curve strobes at the start and dies at the end
`src/remotion/wheel-layout.js:27` (`fullTurns = 7`) and `:52-57` (`1 - (1 - p)**3`).
Measured for the default 8 options / 15 fps / 6500 ms spin (98 spin frames, 2432° total):
| | current cubic, 7 turns | easeOut^2, 4 turns | **proposed: 12% ease-in + easeOut^2.2, 5 turns** |
|---|---:|---:|---:|
| peak Δ/frame | **73.7°** (1105°/s) | 27.4° | 40.6° |
| frames > 45° (one slice) → strobe | **22** (1.47 s) | 0 | 0 |
| frames < 4°/frame → crawl | **23** (1.53 s) | — | 15 |
| frames < 1.5°/frame → sub-perceptible | **14** | — | 6 |
| rotation per second | 954 / 665 / 428 / 243 / 110 / 30 / 1 | — | 233 / 525 / 403 / 286 / 177 / 79 / 9 |
74°/frame against a 45° slice is above the stroboscopic threshold: the wheel appears to jitter or run backwards for the first 1.5 s (19 % of the GIF). Then the last 1.5 s crawl, and the wheel is fully frozen from f91 — 0.47 s dead tail *plus* the 1.20 s hold = 1.67 s of a still image before the GIF loops.
**Fix** — replace `getSpinProgress`:
```js
const accelFraction = 0.12;
export const getSpinProgress = (frame, spinFrames) => {
const p = clamp(frame / Math.max(1, spinFrames), 0, 1);
if (p < accelFraction) {
return 0.5 * (p / accelFraction) ** 2 * accelFraction;
}
const q = (p - accelFraction) / (1 - accelFraction);
return accelFraction / 2 + (1 - (1 - q) ** 2.2) * (1 - accelFraction / 2);
};
```
and `fullTurns = 5` at `wheel-layout.js:27`. Verified numerically: continuous, monotonic, `progress(spinFrames) === 1` exactly, so the winner still lands dead centre under the pointer (checked: `rot(98) = 1687.500` = target). Determinism: still a pure function of `frame`. GIF size: **smaller** — 1712° instead of 2432° means smaller per-frame pixel deltas and better LZW runs. Render time: unchanged.
Existing tests on `getSpinProgress` will need updated expectations — flag as an intentional behaviour change, not a public contract change.
### F-4 — HIGH — LCD sub-pixel antialiasing poisons the GIF palette
`WheelComposition.jsx:124-154`. Sampled pixels at a glyph edge on `#f97316`: `#62B4EC`, `#111F7D`, `#C2F6BC`, `#F97391` — saturated cyan/blue/green fringes where only browns are possible. Chromium is rendering the HTML labels with RGB sub-pixel AA. Remotion already passes `--font-render-hinting=none` (`node_modules/@remotion/renderer/dist/open-browser.js:150`) but **not** `--disable-lcd-text`, and `chromiumOptions` does not accept arbitrary flags.
Measured palette pressure per region (source PNG, before quantisation):
| region | unique colours |
|---|---:|
| one 90×30 label crop (`carol`) | **268** |
| mono 100×30 label crop | 236 |
| shadow band (512×60, bottom) | 119 |
| mono flat slice interior 60×60 | 46 |
| whole 512² still (classic) | 6350 |
Consequence: GIF frame 0 uses **249/256** colours in classic and **254/256** in `mono` — a theme with ~15 intended colours exhausts the palette. Everything else then dithers.
**Fix** — force grayscale AA by promoting the label to a composited layer. Append `translateZ(0)` to the existing transform at `WheelComposition.jsx:144`:
```js
transform: `translate(-50%, -50%) rotate(${label.rotation}deg) translateZ(0)`,
```
Zero DOM/render cost, deterministic. **Confidence high but unverified in this repo** (I could not test it without editing source). Verification recipe: render one still, then
`magick still.png -crop 90x30+345+240 +repage -format "%k\n" info:` — expect the 268 to drop below ~40. If `translateZ(0)` is insufficient, `opacity: 0.999` on the same element has the same layer-promotion effect.
Expected payoff: the largest single lever on both GIF size and dither quality.
### F-5 — HIGH — `mono` theme is not a usable wheel
`src/remotion/themes.js:26-37`. `slices[4] = '#f4f4f5'` is **identical to `background`** (contrast 1.00) so the wheel silhouette disappears wherever that slice sits; `stroke="#ffffff"` (`WheelComposition.jsx:115`) gives 1.10:1 against it so the dividers vanish too; two adjacent pairs are ΔE 10–12. At 200 px it reads as a grey blob, not a wheel.
**Fix** — three changes:
1. Theme-specific divider. Replace the hardcoded stroke with a token: `stroke={theme.sliceStroke}` — classic/festival `'#ffffff'`, mono `'#71717a'` (4.0:1 against the lightest slice).
2. Re-ramp mono to even L\* steps in a band that keeps `#18181b` above 4.5:1, ordered to maximise adjacent ΔE (verified: min adjacent ΔE **16.4**, up from 10.1; darkest slice still 4.97:1 for text):
```js
slices: ['#87878f', '#cacad0', '#9d9da5', '#e1e1e5', '#b3b3ba', '#f9f9fa'],
```
Text contrasts: 4.97 / 10.86 / 6.58 / 13.59 / 8.50 / 16.84 — all pass 4.5:1.
3. Add the bezel from F-6, which is what actually rescues the silhouette.
Determinism/render time: unaffected. GIF size: fewer, better-separated colours → slightly smaller.
### F-6 — MEDIUM — Large soft `wheelShadow` buys little and costs palette; the wheel has no silhouette
`WheelComposition.jsx:74` with `themes.js:4,16,28` (`0 18px 52px rgba(...)`). At 300 % zoom the ramp is actually smooth — no visible banding — and because the shadow div does not rotate it is byte-cheap after frame 1. But it consumes 119 palette entries and it does nothing for the real problem: on a dark chat background the GIF is a bright square with no defined wheel edge, and in `mono` there is no edge at all.
**Fix** — trade the diffuse shadow for an opaque bezel ring plus a tight drop:
```js
// under the rotating layer, above the shadow div
<Circle fill={theme.bezel} radius={radius + size * 0.016}
style={{left: center - radius - size * 0.016, position: 'absolute', top: center - radius - size * 0.016}} />
```
tokens: classic `bezel '#1f2937'` (14.0:1 vs bg), festival `'#7c2d12'`, mono `'#3f3f46'`; and shrink the shadow to `'0 6px 14px rgba(15, 23, 42, 0.20)'` (footprint ~4× smaller, ~30 palette entries instead of 119).
Payoff: the wheel reads as a deliberate object on both light and dark chat backgrounds, mono gets an edge, and ~90 palette slots go back to the slices. Determinism: static. Render time: +1 SVG circle.
Note the bezel must be inside the *non-rotating* stack so it does not add per-frame delta.
### F-7 — MEDIUM — Adjacent slices collide under colour-vision deficiency; palettes are orderable for free
`themes.js:12,24,36`. Brute-forced all cyclic orderings, scoring each adjacent pair by `min(ΔE_normal, ΔE_deutan, ΔE_protan)`:
| theme | current min | reordered min | change |
|---|---:|---:|---|
| classic | 15.9 | **48.1** | reorder only, same 8 hexes |
| festival | 11.7 | **37.9** | reorder only, same 8 hexes |
| mono (current hexes) | 10.1 | 11.3 | reorder cannot fix — needs F-5 re-ramp |
```js
// classic
slices: ['#f97316', '#14b8a6', '#f472b6', '#60a5fa', '#facc15', '#a78bfa', '#fb7185', '#34d399'],
// festival
slices: ['#fb7185', '#06b6d4', '#84cc16', '#a78bfa', '#f59e0b', '#f472b6', '#fb923c', '#22c55e'],
```
Zero new colours, zero render/size cost, 3× better worst-case separation. Worst current offenders that disappear: classic `#a78bfa`→`#34d399` (ΔE 16 deutan — purple and green look identical), festival `#06b6d4`→`#a78bfa` (ΔE **12 protan**).
### F-8 — MEDIUM — First and last slice share a colour when `optionCount % paletteLength === 1`
`WheelComposition.jsx:98`. With 9 options on the 8-colour palettes, slice 8 and slice 0 are both `#f97316` and sit adjacent — verified visually, they merge into one double-width wedge with only a 2 px white line between. Same at 17 options, and at 7/13 options in `mono` (6 colours).
**Fix**
```js
const paletteLength = theme.slices.length;
const colorIndex =
index === props.options.length - 1 && props.options.length % paletteLength === 1
? Math.floor(paletteLength / 2)
: index % paletteLength;
const color = theme.slices[colorIndex] ?? theme.slices[0] ?? '#cccccc';
```
Worth a unit test asserting min adjacent ΔE ≥ 30 for every `optionCount` in 2..32 across all three themes.
### F-9 — MEDIUM — `pointerStroke` and the pointer `boxShadow` are silently discarded
`WheelComposition.jsx:173-175`. `clip-path` clips the element's whole rendering, including border and box-shadow. Result (confirmed in a 500 % crop): the diagonal edges have no outline at all, the box-shadow is entirely gone, and only a 2 px sliver of `#991b1b` survives on the vertical back edge — reading as an accidental dark stripe. Combined with contrast 1.34:1 against `#f97316`, the pointer tip is invisible when the winner slice is orange or salmon.
**Fix** — draw the pointer as SVG so the stroke is real:
```jsx
<svg width={size * 0.10} height={size * 0.105} viewBox="0 0 100 105" style={{
left: center + radius - size * 0.045, position: 'absolute', top: center - size * 0.0525, zIndex: 4,
}}>
<polygon points="4,52.5 96,5 96,100" fill={theme.pointer}
stroke={theme.pointerStroke} strokeWidth={7} strokeLinejoin="round" />
</svg>
```
Drop the `boxShadow` entirely (it never rendered and a blurred shadow costs palette). The rounded-join dark outline gives ≥3:1 against every slice in every theme. 1 extra DOM node, no filter, deterministic.
### F-10 — MEDIUM — Pointer has 0.5 % framing margin and no reaction to slice boundaries
`WheelComposition.jsx:176-180`. Verified geometry: at `size=512`, `left = 256 + 209.92 - 10.24 = 455.68`, `width = 53.76`, so the back edge sits at **x = 509.44 — 2.56 px from the canvas edge**; at `size=384` it is **1.92 px**. Not clipped, but visually flush with the frame, and `AbsoluteFill`'s `overflow: hidden` means any outline or size increase *will* clip. Meanwhile the tip overlaps the rim by only `size*0.02` (10 px) and the pointer never moves — the strongest "this is a real wheel" cue is missing.
**Fix (a) framing** — the F-9 snippet already moves it: `left = center + radius - size * 0.045`, `width = size * 0.10` → right edge 494 px, **18 px** margin, and tip overlap rises to 23 px. The 0.41 radius leaves 46 px margins, so this fits comfortably; no need to change `radius`.
**Fix (b) tick/flapper** — deterministic, zero DOM cost, resolves to exactly 0 at rest (proved below):
```js
// wheel-layout.js
export const getPointerDeflectionDegrees = (rotationDegrees, sliceDegrees, maxDeflection = 9) => {
const phase = (((rotationDegrees % sliceDegrees) + sliceDegrees) % sliceDegrees) / sliceDegrees;
return -maxDeflection * Math.max(0, 1 - phase * 3);
};
```
applied as `transform: `rotate(${deflection}deg)`` with `transformOrigin: '100% 50%'` on the pointer SVG.
Rest proof: `finalRotation % sliceDegrees = (turns*360 - winnerIndex*slice - slice/2) % slice`; `360 = n*slice`, so the first two terms are exact multiples and the remainder is always `slice/2` → `phase = 0.5` → deflection `0`. The flapper always parks neutral, whatever the winner or option count.
During the slow tail this produces 4–6 legible flicks — by far the cheapest large gain in "reads as a real spin". Determinism: pure `frame` function via `rotation`. GIF size: a few px of the pointer change per frame; negligible.
### F-11 — MEDIUM — Winner glow is an asymmetric smear, not a glow
`WheelComposition.jsx:118`. `drop-shadow` on a `Pie` renders *outside* the shape, so it is overpainted by every later-indexed sibling `Pie` and only survives on the counter-clockwise neighbour. In the 8-option render the yellow glow appears as a dirty greenish smudge bleeding onto the teal `bob` slice and nowhere else. Also `winnerPulse = Math.sin(settle * Math.PI)` is 0 at both ends of the hold, so the final frame gets the *minimum* glow (10 px) — the poster frame gets the weakest treatment. And `filter`+blur is the worst possible effect for a 256-colour palette.
**Fix** — replace the blur with an opaque stroke on the winner slice; drop the `drop-shadow` line entirely:
```jsx
<Pie
fill={color}
stroke={isWinner && frame >= holdStartFrame ? theme.winnerStroke : theme.sliceStroke}
strokeWidth={isWinner && frame >= holdStartFrame ? 4 + Math.round(winnerPulse * 3) : 2}
...
/>
```
tokens: classic `winnerStroke '#111827'`, festival `'#7f1d1d'`, mono `'#18181b'`. Adds 1 colour, no blur, no z-order artefact, 4 discrete pulse states (cheap inter-frame delta). Keep `winnerGlow` only if the confetti reviewer still needs it; otherwise delete the token.
Also rename or repurpose `settle` (`WheelComposition.jsx:32`) — it drives only the pulse; nothing settles.
### F-12 — MEDIUM — `lineHeight: 1` collides Vietnamese diacritics between wrapped lines
`WheelComposition.jsx:137` + `wheel-label-layout.js:206` (`height: Math.min(availableHeight, fontSize * lines.length)`), with `overflow: 'hidden'` at `:138`.
Rendered `Bún đậu mắm tôm Hà Nội` over 3 lines: the dot-below of `ậ` visually merges with the breve of `ắ` on the next line. No hard clipping was observed at 512 with Quicksand (`Ổn`, `Ừ`, `Ạ`, `Nghĩ`, `Phạm Dũng` all render complete), but the box height equals exactly `fontSize × lines`, so the margin is zero — any font fallback with taller metrics *will* clip, and the project explicitly supports Vietnamese.
**Fix** — give the line box breathing room and shrink to fit rather than clip:
```js
// WheelComposition.jsx
lineHeight: 1.16,
// wheel-label-layout.js, after fontSize is chosen
const lineHeightRatio = 1.16;
const blockHeight = fontSize * lineHeightRatio * lines.length;
const fittedFontSize = blockHeight > availableHeight
? Math.max(minLabelFontSize, Math.floor(fontSize * (availableHeight / blockHeight)))
: fontSize;
// return fontSize: fittedFontSize, height: Math.min(availableHeight, fittedFontSize * lineHeightRatio * lines.length)
```
Also update `maxLines` at `:185` to use the same ratio: `Math.floor(availableHeight / (baseFontSize * lineHeightRatio))`.
Does not re-litigate the settled wrap/adaptive-size decisions — it only stops the box from being exactly flush.
### F-13 — MEDIUM — Font size is capped at 24 px, wasting low-option wheels
`wheel-label-layout.js:24,156`. `baseSize = max(10, min(24, radius / max(5.8, optionCount * 0.33)))`. Because the `5.8` floor dominates until `optionCount > 17`, `radius / 5.8 = 36.2` at 512 — so **the 24 cap is active for every option count from 2 to 17**. A 2-option wheel with `Yes` / `No` renders 24 px text inside a 210 px-wide, 417 px-tall track: at a 200 px display that is 9.4 px. Verified in the 2-option still — enormous empty slices, tiny text.
**Fix** — raise the cap and clamp it by the wedge height so dense wheels do not regress:
```js
// in getRadialLabelLayout, before getLabelFontSize
const maxFontSize = Math.min(radius * 0.15, availableHeight * 0.82); // 31.5px @512, and 21px @32 options
```
and pass it through instead of the hardcoded `24`. At 32 options `availableHeight = 26`, so `maxFontSize = 21` < the current 24 — which also removes a latent vertical clip that exists today. At 2 options it becomes 31.5 px (+31 % legibility at thumbnail size); width-fitting still shrinks long labels exactly as before, so no regression for `Nguyễn Văn 32`-style labels.
### F-14 — MEDIUM — Maximum accepted request produces an unusable artefact
`src/schemas/wheel-request.js:38-41` allows `durationMs 10000 + holdMs 2500 + fps 20 + size 512` = 250 frames. Measured on this dev machine (single render, warm bundle):
| case | bytes | render |
|---|---:|---:|
| classic, 8 opts, 512, 15 fps, 7.7 s (default) | **2,036,868** | 4,255 ms |
| mono, 6 opts, 512, 15 fps, 7.7 s | 1,775,252 | 2,688 ms |
| festival, **32 opts, 512, 20 fps, 12.5 s** (schema max) | **14,261,781** | **10,274 ms** |
`docs/render-benchmarks.md:13-14` still says TBD for the 512 fixtures; these numbers fill that gap. A 14 MB GIF is hostile in a chat client, and 10.3 s here will exceed `RENDER_TIMEOUT_MS=15000` on the documented 1–2 vCPU container. Even the *default* 2.0 MB is heavy for inline playback on mobile.
**Fix, non-breaking first** — F-4 (grayscale AA) and F-6 (smaller shadow) free ~200 palette entries, and F-3 cuts total rotation from 2432° to 1712°; all three shrink per-frame entropy. I could not measure the combined saving without editing source — flag as expected, not proven.
**If that is not enough**, the breaking option is to narrow the schema: reject `durationMs + holdMs` such that total frames > 160 (e.g. `superRefine` with a clear message), or drop `fps: 20`. That rejects requests that are valid today → **breaking change**, only justified if the container actually times out; measure in Docker first.
### F-15 — LOW — Light-only backgrounds look like a bright box on dark chat themes
`themes.js:3,15,27` (`#f8fafc`, `#fff7ed`, `#f4f4f5`). Composited the three 200 px final frames on `#1e1e1e`: all three read as leaked white rectangles, and `mono` additionally has no discernible wheel edge.
**On transparency — do not pursue.** GIF transparency is a single binary palette index: the wheel's antialiased rim, the soft shadow and the text halo cannot blend against an unknown background, so you get hard white/grey fringes on every curve and glyph. On top of that, Telegram's `sendAnimation` re-encodes GIFs to MP4 for most clients, which drops alpha entirely — so the cost is real and the benefit does not survive the transport. (Whether Remotion's `codec: 'gif'` even emits alpha I did not verify; moot given the transport.)
**Recommended instead:** ship F-6's dark bezel — it gives the wheel an intentional edge on any background at the cost of one palette colour, which is 90 % of the benefit for none of the risk.
**Optional, additive:** a 4th `night` theme (`background '#0b1220'`, `text '#0b1220'` on light slices, `textHalo 'rgba(255,255,255,0.9)'`, `bezel '#e2e8f0'`). Adding a value to the `theme` union is backward compatible for existing clients (no old value changes meaning), but it does extend the documented request contract and needs a README/`docs/miti99bot-integration.md` update — flagging it as a contract *addition*, and it conflicts with "three theme names are settled", so it needs your explicit call.
### F-16 — LOW — 4-direction text halo costs 4× text rasterisation for contrast that is already met
`WheelComposition.jsx:142`. Every label paints 4 extra offset copies — 128 extra text draws per frame at 32 options. Section 2 shows `text:slice` is ≥5.39:1 in every theme on every slice, so the halo is not doing contrast work; it earns its keep only where confetti or a slice boundary crosses a glyph. It also thickens glyphs at small sizes.
**Fix (low priority):** drop to 2 offsets (`0 1px 0`, `0 -1px 0`) — keeps the vertical separation that matters against slice borders at half the cost. Alternatively keep as-is; this is the least urgent item and worth re-checking only after F-4 lands, since the halo's colour cost is currently masked by the AA fringing.
### F-17 — LOW — Centre hub is decorative only and will be covered by the winner pill
`WheelComposition.jsx:160-169`. Two concentric circles (`0.09` ring, `0.06` core) look clean and hide the label-track convergence — keep them. Just note they sit *under* the F-2 pill, which is fine (the pill is the payoff). No change needed; recorded so the hub is not "fixed" later by accident.
---
## 4. Prioritised list
### Quick wins (<30 min each)
| # | Change | Payoff | Risk |
|---|---|---|---|
| 1 | F-1 label flip (one derived value + `+ flip`) | Removes 3–8 unreadable names per render; biggest legibility gain | none — deterministic, no size/time cost |
| 2 | F-7 palette reorder (two array literals) | worst-case adjacent ΔE 15.9→48.1 / 11.7→37.9 under CVD | none — same hexes |
| 3 | F-4 `translateZ(0)` on the label | 268→(expected) <40 colours per label crop; unlocks the whole palette | low; **verify with `%k` count** — unproven |
| 4 | F-3 spin curve + `fullTurns = 5` | kills 22 strobe frames and 14 dead frames; also shrinks the GIF | low; existing `getSpinProgress` test expectations change |
| 5 | F-10b pointer tick | strongest "real wheel" cue; provably neutral at rest | none — pure `frame` function |
| 6 | F-11 winner stroke instead of `drop-shadow` | removes the one-sided smear; winner strongest in the poster frame; drops a blur filter | none — fewer effects |
| 7 | F-8 last-slice colour guard | fixes merged wedges at 7/9/13/17 options | none |
| 8 | F-12 `lineHeight: 1.16` + fit-not-clip | ends diacritic collisions; removes latent clipping | low; slightly smaller 3-line labels |
| 9 | F-9 pointer as SVG + F-10a reposition | pointer gets a real outline (≥3:1 on every slice) and 18 px framing margin | low; +1 DOM node |
### Larger reworks
| # | Change | Payoff | Risk |
|---|---|---|---|
| A | F-2 winner-name pill (new tokens, fit logic, pop easing) | The poster frame finally states the winner at 200 px — highest user-visible value in the report | medium: needs a size-fit path and 2-line wrap; +2 palette colours; must sit above `Confetti`'s `zIndex: 10` |
| B | F-5 + F-6 mono re-ramp, `sliceStroke` token, bezel ring | Makes `mono` a real theme; gives all themes a silhouette on dark chat backgrounds; frees ~90 palette entries | medium: touches all three theme objects + the hardcoded `stroke="#ffffff"`; snapshot/visual review needed |
| C | F-13 font-size cap by `radius * 0.15` clamped to `availableHeight * 0.82` | +31 % text size on 2–6 option wheels; removes a latent clip at 32 options | medium: changes sizing for every render — re-check 2, 8, 16, 24, 32 options |
| D | F-14 GIF weight programme (measure in Docker after 3/4/6/B land, then decide) | Default 2.0 MB and max 14.3 MB are both too heavy | high if it ends in a schema narrowing — that is a **breaking change**; do the non-breaking levers first |
| E | F-15 optional `night` theme | Fixes dark-background presentation properly | contract addition + conflicts with the settled 3-theme decision; needs your approval |
Suggested order: 1, 2, 3, 4, 5, 6, 7, 8, 9 → A → B → C → measure D.
---
## 5. Not verified empirically
- **F-4 fix effectiveness.** The sub-pixel fringing is measured and certain; that `translateZ(0)` (or `opacity: 0.999`) removes it in this Chromium build is high-confidence but untested, because testing requires editing `WheelComposition.jsx`. Verification recipe given in F-4.
- **GIF size saving from F-3/F-4/F-6.** Direction is certain (less rotation, fewer colours, smaller shadow footprint); the magnitude is not measured. Palette pressure was measured indirectly (249–254 of 256 colours used in frame 0; 268 colours in one label crop; 119 in the shadow band).
- **Render timeout risk in the deployment target.** 10,274 ms for the schema-max case was measured on this Windows dev machine, not on the documented 1–2 vCPU container. The `RENDER_TIMEOUT_MS=15000` breach is inferred, not observed.
- **Remotion `codec: 'gif'` alpha support.** Not tested — moot, since F-15 recommends against transparency on transport grounds.
- **Motion perception.** Strobing was derived numerically (22 frames above 45°/frame) from still-frame geometry, not judged by watching the GIF frame-stepped at 15 fps in a chat client.
- **Font fallback.** All Vietnamese checks used Quicksand as resolved in this environment. Diacritic clipping margins on the `Inter` / `Noto Sans` / `Segoe UI` fallbacks in the `baseFont` stack (`WheelComposition.jsx:16-17`) were not tested — an extra reason to adopt F-12 rather than rely on `lineHeight: 1` fitting exactly.
- `pnpm render:fixtures` was **not** run (it would rewrite `fixtures/*.gif`). Equivalent and broader coverage was rendered to the scratchpad instead; the repo is clean apart from other agents' reports.
---
## 6. Unresolved questions
1. **Winner pill (F-2)** — is covering the hub during the hold acceptable, or must the wheel stay fully visible? If it must, the only alternative at a square 512 canvas is shrinking `radius` from 0.41 to ~0.35 to open a caption band, which costs label size everywhere.
2. **`night` theme (F-15)** — the three theme names are settled, but "GIFs look like a bright box on dark chat backgrounds" is the concern the brief itself raised. Add a 4th value (additive, backward compatible, needs docs) or accept the bezel-only mitigation?
3. **GIF weight (F-14)** — is the 2.0 MB default acceptable to miti99bot, and has a 14 MB max-parameter render ever been requested in practice? The answer decides whether D ends in a schema narrowing.
4. **Spin curve test expectations (F-3)** — existing `getSpinProgress` tests encode the cubic. Confirm they are treated as implementation detail, not a public contract.
5. **`fullTurns = 5`** — 7 turns is currently hardcoded with a default parameter at `wheel-layout.js:27`. Should turn count stay fixed, or scale with `durationMs` (e.g. `Math.round(durationMs / 1300)`) so a 3 s request does not feel rushed and a 10 s one does not crawl?
+176 -166
View File
@@ -10,7 +10,7 @@ importers:
dependencies:
'@remotion/bundler':
specifier: 4.0.485
version: 4.0.485(postcss@8.5.22)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 4.0.485(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@remotion/renderer':
specifier: 4.0.485
version: 4.0.485(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -51,6 +51,9 @@ importers:
globals:
specifier: 17.7.0
version: 17.7.0
prettier:
specifier: 3.9.4
version: 3.9.4
typescript:
specifier: 6.0.3
version: 6.0.3
@@ -366,8 +369,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
'@oxc-project/types@0.138.0':
resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -480,97 +483,97 @@ packages:
'@remotion/zod-types@4.0.485':
resolution: {integrity: sha512-twIeAetwWbrlmADJhf6xtnJR6A6gFrH7SRhVGu8FLXwaqvO7LBs5zlRESUsvZ1qXEhGGqZn1lOv6jlEbt0OUFA==}
'@rolldown/binding-android-arm64@1.1.5':
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
'@rolldown/binding-android-arm64@1.1.4':
resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.1.5':
resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==}
'@rolldown/binding-darwin-arm64@1.1.4':
resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.1.5':
resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==}
'@rolldown/binding-darwin-x64@1.1.4':
resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.1.5':
resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==}
'@rolldown/binding-freebsd-x64@1.1.4':
resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.1.5':
resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==}
'@rolldown/binding-linux-arm-gnueabihf@1.1.4':
resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.1.5':
resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==}
'@rolldown/binding-linux-arm64-gnu@1.1.4':
resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.5':
resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==}
'@rolldown/binding-linux-arm64-musl@1.1.4':
resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.5':
resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==}
'@rolldown/binding-linux-ppc64-gnu@1.1.4':
resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.5':
resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==}
'@rolldown/binding-linux-s390x-gnu@1.1.4':
resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.5':
resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==}
'@rolldown/binding-linux-x64-gnu@1.1.4':
resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.5':
resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==}
'@rolldown/binding-linux-x64-musl@1.1.4':
resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.5':
resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==}
'@rolldown/binding-openharmony-arm64@1.1.4':
resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.1.5':
resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==}
'@rolldown/binding-wasm32-wasi@1.1.4':
resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.1.5':
resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==}
'@rolldown/binding-win32-arm64-msvc@1.1.4':
resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.1.5':
resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==}
'@rolldown/binding-win32-x64-msvc@1.1.4':
resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1035,8 +1038,8 @@ packages:
fast-querystring@1.1.2:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fast-uri@3.1.3:
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
fastify@5.10.0:
resolution: {integrity: sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==}
@@ -1057,8 +1060,8 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
find-my-way@9.7.0:
resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==}
find-my-way@9.6.0:
resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==}
engines: {node: '>=20'}
find-up@5.0.0:
@@ -1183,78 +1186,78 @@ packages:
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
loader-runner@4.3.2:
@@ -1304,8 +1307,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
nanoid@3.3.15:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1410,14 +1413,19 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.22:
resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==}
postcss@8.5.16:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.9.4:
resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==}
engines: {node: '>=14'}
hasBin: true
process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
@@ -1472,8 +1480,8 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rolldown@1.1.5:
resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
rolldown@1.1.4:
resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -1965,7 +1973,7 @@ snapshots:
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.4
fast-uri: 3.1.3
'@fastify/error@4.2.0': {}
@@ -2070,11 +2078,11 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@oxc-project/types@0.139.0': {}
'@oxc-project/types@0.138.0': {}
'@pinojs/redact@0.4.0': {}
'@remotion/bundler@4.0.485(postcss@8.5.22)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@remotion/bundler@4.0.485(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@remotion/media-parser': 4.0.485
'@remotion/studio': 4.0.485(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2082,14 +2090,14 @@ snapshots:
'@remotion/timeline-utils': 4.0.485
'@rspack/core': 1.7.11
'@rspack/plugin-react-refresh': 1.6.1(react-refresh@0.18.0)
css-loader: 7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.22))
css-loader: 7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.16))
esbuild: 0.28.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-refresh: 0.18.0
remotion: 4.0.485(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
style-loader: 4.0.0(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.22))
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.22)
style-loader: 4.0.0(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.16))
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.16)
transitivePeerDependencies:
- '@minify-html/node'
- '@swc/core'
@@ -2238,53 +2246,53 @@ snapshots:
- react
- react-dom
'@rolldown/binding-android-arm64@1.1.5':
'@rolldown/binding-android-arm64@1.1.4':
optional: true
'@rolldown/binding-darwin-arm64@1.1.5':
'@rolldown/binding-darwin-arm64@1.1.4':
optional: true
'@rolldown/binding-darwin-x64@1.1.5':
'@rolldown/binding-darwin-x64@1.1.4':
optional: true
'@rolldown/binding-freebsd-x64@1.1.5':
'@rolldown/binding-freebsd-x64@1.1.4':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.1.5':
'@rolldown/binding-linux-arm-gnueabihf@1.1.4':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.1.5':
'@rolldown/binding-linux-arm64-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-arm64-musl@1.1.5':
'@rolldown/binding-linux-arm64-musl@1.1.4':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.1.5':
'@rolldown/binding-linux-ppc64-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.1.5':
'@rolldown/binding-linux-s390x-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-x64-gnu@1.1.5':
'@rolldown/binding-linux-x64-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-x64-musl@1.1.5':
'@rolldown/binding-linux-x64-musl@1.1.4':
optional: true
'@rolldown/binding-openharmony-arm64@1.1.5':
'@rolldown/binding-openharmony-arm64@1.1.4':
optional: true
'@rolldown/binding-wasm32-wasi@1.1.5':
'@rolldown/binding-wasm32-wasi@1.1.4':
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.1.5':
'@rolldown/binding-win32-arm64-msvc@1.1.4':
optional: true
'@rolldown/binding-win32-x64-msvc@1.1.5':
'@rolldown/binding-win32-x64-msvc@1.1.4':
optional: true
'@rolldown/pluginutils@1.0.1': {}
@@ -2552,7 +2560,7 @@ snapshots:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.4
fast-uri: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -2601,19 +2609,19 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-loader@7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.22)):
css-loader@7.1.4(@rspack/core@1.7.11)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.16)):
dependencies:
icss-utils: 5.1.0(postcss@8.5.22)
postcss: 8.5.22
postcss-modules-extract-imports: 3.1.0(postcss@8.5.22)
postcss-modules-local-by-default: 4.2.0(postcss@8.5.22)
postcss-modules-scope: 3.2.1(postcss@8.5.22)
postcss-modules-values: 4.0.0(postcss@8.5.22)
icss-utils: 5.1.0(postcss@8.5.16)
postcss: 8.5.16
postcss-modules-extract-imports: 3.1.0(postcss@8.5.16)
postcss-modules-local-by-default: 4.2.0(postcss@8.5.16)
postcss-modules-scope: 3.2.1(postcss@8.5.16)
postcss-modules-values: 4.0.0(postcss@8.5.16)
postcss-value-parser: 4.2.0
semver: 7.8.5
optionalDependencies:
'@rspack/core': 1.7.11
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.22)
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.16)
cssesc@3.0.0: {}
@@ -2779,7 +2787,7 @@ snapshots:
'@fastify/merge-json-schemas': 0.2.1
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.4
fast-uri: 3.1.3
json-schema-ref-resolver: 3.0.0
rfdc: 1.4.1
@@ -2789,7 +2797,7 @@ snapshots:
dependencies:
fast-decode-uri-component: 1.0.1
fast-uri@3.1.4: {}
fast-uri@3.1.3: {}
fastify@5.10.0:
dependencies:
@@ -2800,7 +2808,7 @@ snapshots:
abstract-logging: 2.0.1
avvio: 9.2.0
fast-json-stringify: 7.0.0
find-my-way: 9.7.0
find-my-way: 9.6.0
light-my-request: 6.6.0
pino: 10.3.1
process-warning: 5.0.0
@@ -2821,7 +2829,7 @@ snapshots:
dependencies:
flat-cache: 4.0.1
find-my-way@9.7.0:
find-my-way@9.6.0:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
@@ -2862,9 +2870,9 @@ snapshots:
human-signals@2.1.0: {}
icss-utils@5.1.0(postcss@8.5.22):
icss-utils@5.1.0(postcss@8.5.16):
dependencies:
postcss: 8.5.22
postcss: 8.5.16
ignore@5.3.2: {}
@@ -2923,54 +2931,54 @@ snapshots:
process-warning: 4.0.1
set-cookie-parser: 2.7.2
lightningcss-android-arm64@1.33.0:
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.33.0:
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-x64@1.33.0:
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.33.0:
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.33.0:
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.33.0:
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.33.0:
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.33.0:
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.33.0:
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.33.0:
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.33.0:
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss@1.33.0:
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.33.0
lightningcss-darwin-arm64: 1.33.0
lightningcss-darwin-x64: 1.33.0
lightningcss-freebsd-x64: 1.33.0
lightningcss-linux-arm-gnueabihf: 1.33.0
lightningcss-linux-arm64-gnu: 1.33.0
lightningcss-linux-arm64-musl: 1.33.0
lightningcss-linux-x64-gnu: 1.33.0
lightningcss-linux-x64-musl: 1.33.0
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
loader-runner@4.3.2: {}
@@ -3013,7 +3021,7 @@ snapshots:
ms@2.1.3: {}
nanoid@3.3.16: {}
nanoid@3.3.15: {}
natural-compare@1.4.0: {}
@@ -3086,26 +3094,26 @@ snapshots:
sonic-boom: 4.2.1
thread-stream: 4.2.0
postcss-modules-extract-imports@3.1.0(postcss@8.5.22):
postcss-modules-extract-imports@3.1.0(postcss@8.5.16):
dependencies:
postcss: 8.5.22
postcss: 8.5.16
postcss-modules-local-by-default@4.2.0(postcss@8.5.22):
postcss-modules-local-by-default@4.2.0(postcss@8.5.16):
dependencies:
icss-utils: 5.1.0(postcss@8.5.22)
postcss: 8.5.22
icss-utils: 5.1.0(postcss@8.5.16)
postcss: 8.5.16
postcss-selector-parser: 7.1.4
postcss-value-parser: 4.2.0
postcss-modules-scope@3.2.1(postcss@8.5.22):
postcss-modules-scope@3.2.1(postcss@8.5.16):
dependencies:
postcss: 8.5.22
postcss: 8.5.16
postcss-selector-parser: 7.1.4
postcss-modules-values@4.0.0(postcss@8.5.22):
postcss-modules-values@4.0.0(postcss@8.5.16):
dependencies:
icss-utils: 5.1.0(postcss@8.5.22)
postcss: 8.5.22
icss-utils: 5.1.0(postcss@8.5.16)
postcss: 8.5.16
postcss-selector-parser@7.1.4:
dependencies:
@@ -3114,14 +3122,16 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.22:
postcss@8.5.16:
dependencies:
nanoid: 3.3.16
nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
prelude-ls@1.2.1: {}
prettier@3.9.4: {}
process-warning@4.0.1: {}
process-warning@5.0.0: {}
@@ -3156,26 +3166,26 @@ snapshots:
rfdc@1.4.1: {}
rolldown@1.1.5:
rolldown@1.1.4:
dependencies:
'@oxc-project/types': 0.139.0
'@oxc-project/types': 0.138.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.1.5
'@rolldown/binding-darwin-arm64': 1.1.5
'@rolldown/binding-darwin-x64': 1.1.5
'@rolldown/binding-freebsd-x64': 1.1.5
'@rolldown/binding-linux-arm-gnueabihf': 1.1.5
'@rolldown/binding-linux-arm64-gnu': 1.1.5
'@rolldown/binding-linux-arm64-musl': 1.1.5
'@rolldown/binding-linux-ppc64-gnu': 1.1.5
'@rolldown/binding-linux-s390x-gnu': 1.1.5
'@rolldown/binding-linux-x64-gnu': 1.1.5
'@rolldown/binding-linux-x64-musl': 1.1.5
'@rolldown/binding-openharmony-arm64': 1.1.5
'@rolldown/binding-wasm32-wasi': 1.1.5
'@rolldown/binding-win32-arm64-msvc': 1.1.5
'@rolldown/binding-win32-x64-msvc': 1.1.5
'@rolldown/binding-android-arm64': 1.1.4
'@rolldown/binding-darwin-arm64': 1.1.4
'@rolldown/binding-darwin-x64': 1.1.4
'@rolldown/binding-freebsd-x64': 1.1.4
'@rolldown/binding-linux-arm-gnueabihf': 1.1.4
'@rolldown/binding-linux-arm64-gnu': 1.1.4
'@rolldown/binding-linux-arm64-musl': 1.1.4
'@rolldown/binding-linux-ppc64-gnu': 1.1.4
'@rolldown/binding-linux-s390x-gnu': 1.1.4
'@rolldown/binding-linux-x64-gnu': 1.1.4
'@rolldown/binding-linux-x64-musl': 1.1.4
'@rolldown/binding-openharmony-arm64': 1.1.4
'@rolldown/binding-wasm32-wasi': 1.1.4
'@rolldown/binding-win32-arm64-msvc': 1.1.4
'@rolldown/binding-win32-x64-msvc': 1.1.4
safe-regex2@5.1.1:
dependencies:
@@ -3239,9 +3249,9 @@ snapshots:
strip-final-newline@2.0.0: {}
style-loader@4.0.0(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.22)):
style-loader@4.0.0(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.16)):
dependencies:
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.22)
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.16)
supports-color@8.1.1:
dependencies:
@@ -3249,16 +3259,16 @@ snapshots:
tapable@2.3.3: {}
terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.22)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.22)):
terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.16)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.16)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.48.0
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.22)
webpack: 5.105.0(esbuild@0.28.1)(postcss@8.5.16)
optionalDependencies:
esbuild: 0.28.1
postcss: 8.5.22
postcss: 8.5.16
terser@5.48.0:
dependencies:
@@ -3313,10 +3323,10 @@ snapshots:
vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(terser@5.48.0):
dependencies:
lightningcss: 1.33.0
lightningcss: 1.32.0
picomatch: 4.0.5
postcss: 8.5.22
rolldown: 1.1.5
postcss: 8.5.16
rolldown: 1.1.4
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.1.0
@@ -3359,7 +3369,7 @@ snapshots:
webpack-sources@3.5.0: {}
webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.22):
webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.16):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -3383,7 +3393,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(postcss@8.5.22)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.22))
terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(postcss@8.5.16)(webpack@5.105.0(esbuild@0.28.1)(postcss@8.5.16))
watchpack: 2.5.2
webpack-sources: 3.5.0
transitivePeerDependencies:
+2 -2
View File
@@ -1,5 +1,5 @@
import {randomInt} from 'node:crypto';
import {parseArgs} from 'node:util';
import {pickWinnerIndex} from '../src/lib/winner.js';
import {parseWheelRequest} from '../src/schemas/wheel-request.js';
const limits = {
@@ -87,7 +87,7 @@ export const parseRenderLocalArgs = (args) => {
return {
help: false,
output: values.output,
request: parseWheelRequest(input, limits, pickWinnerIndex),
request: parseWheelRequest(input, limits, randomInt),
timeoutInMilliseconds,
};
};
+7
View File
@@ -20,5 +20,12 @@ export const createRenderSemaphore = (max) => {
release() {
active = Math.max(0, active - 1);
},
/**
* @returns {{active: number, max: number}}
*/
state() {
return {active, max};
},
};
};
-85
View File
@@ -1,85 +0,0 @@
import {AbsoluteFill, interpolate, random, useCurrentFrame, useVideoConfig} from 'remotion';
import {createConfettiParticles, getConfettiParticleState} from './confetti-layout.js';
/**
* Winner-celebration confetti burst. Renders nothing before `startFrame`, then
* fires a deterministic two-cannon burst through the end of the composition.
*
* @param {object} props
* @param {number} props.startFrame frame the burst begins on (winner reveal)
* @param {string[]} props.colors palette (theme confetti colors)
* @param {number|string} props.seed reproducibility seed (e.g. winner index)
* @param {number} [props.count] particle count
*/
export const Confetti = ({startFrame, colors, seed, count = 70}) => {
const frame = useCurrentFrame();
const {durationInFrames, fps, height, width} = useVideoConfig();
if (frame < startFrame) {
return null;
}
const size = Math.min(width, height);
const lifetimeFrames = Math.max(1, durationInFrames - 1 - startFrame);
const localFrame = frame - startFrame;
const seconds = localFrame / fps;
// No fade-in: the opening frame is the only one where particles are still
// clustered at the muzzles, and fading it out threw the pop away.
const fadeFrames = Math.min(4, Math.max(1, Math.round(lifetimeFrames * 0.25)));
const fade = interpolate(localFrame, [lifetimeFrames - fadeFrames, lifetimeFrames], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// Quantized so the tail introduces at most a handful of blended colors: a
// 256-entry GIF palette cannot afford a fresh alpha level on every frame.
const opacity = Math.ceil(fade * 4) / 4;
const particles = createConfettiParticles({
colors,
count,
random: (key) => random(`${seed}-${key}`),
size,
windowSeconds: lifetimeFrames / fps,
});
return (
// Above the winner pill: the pill covers the hub the burst arcs across, and
// behind it the celebration lost most of its particles at exactly the moment
// it fires.
<AbsoluteFill style={{opacity, pointerEvents: 'none', zIndex: 20}}>
{particles.map((particle, index) => {
// Staggered launch: an unlaunched particle would otherwise stack on its
// muzzle and the pile reads as a clump of paper sitting in the corner.
if (seconds < particle.delay) {
return null;
}
const state = getConfettiParticleState(particle, seconds);
return (
<div
key={`confetti-${index}`}
style={{
background: particle.color,
// Hard 1px ring, no blur: separates chips from same-colored
// slices without adding a palette entry, since white already
// strokes the slice borders.
boxShadow: '0 0 0 1px #ffffff',
height: particle.height,
left: state.x,
position: 'absolute',
top: state.y,
// scaleX runs along the body's own long axis, and the rotation is
// already aligned with travel while streaking, so this elongates
// in the direction of motion and leaves the short axis at its
// quantisation-safe minimum.
transform: `translate(-50%, -50%) rotate(${state.rotation}deg) scaleX(${state.stretch})`,
transformOrigin: '50% 50%',
width: particle.width,
}}
/>
);
})}
</AbsoluteFill>
);
};
+28 -154
View File
@@ -4,21 +4,13 @@ import {Circle, Pie} from '@remotion/shapes';
import {
cssDegreesToPieRadians,
getFinalWheelRotationDegrees,
getPointerDeflectionDegrees,
getSliceCenterDegrees,
getSliceDegrees,
getSpinRecoilDegrees,
getSpinTurns,
getWheelRotationDegrees,
rightPointerClipPath,
} from './wheel-layout.js';
import {
estimateTextWidth,
getLabelLines,
getRadialLabelLayout,
labelLineHeightRatio,
} from './wheel-label-layout.js';
import {getSliceColor, getTheme, getWinnerPalette} from './themes.js';
import {Confetti} from './Confetti.jsx';
import {getRadialLabelLayout} from './wheel-label-layout.js';
import {getTheme} from './themes.js';
const baseFont =
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
@@ -35,18 +27,13 @@ export const WheelComposition = (props) => {
const {durationInFrames, fps, width, height} = useVideoConfig();
const theme = getTheme(props.theme);
const spinFrames = Math.max(1, durationInFrames - Math.round((props.holdMs / 1000) * fps));
// Any ease-out ends at zero speed, so the wheel is already visually stopped a
// few frames before it mathematically settles. Firing the celebration there
// lands it on the perceived stop instead of after a pause, and costs only a
// fraction of a degree of rotation.
const revealFrame = Math.max(0, spinFrames - Math.round(fps * 0.33));
const revealProgress = interpolate(frame, [revealFrame, durationInFrames - 1], [0, 1], {
const holdStartFrame = spinFrames;
const settle = interpolate(frame, [holdStartFrame, durationInFrames - 1], [0, 1], {
easing: Easing.out(Easing.sin),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const winnerPulse = frame >= revealFrame ? Math.sin(revealProgress * Math.PI) : 0;
const isRevealed = frame >= revealFrame;
const winnerPulse = frame >= holdStartFrame ? Math.sin(settle * Math.PI) : 0;
const size = Math.min(width, height);
const center = size / 2;
@@ -54,53 +41,13 @@ export const WheelComposition = (props) => {
const centerRingRadius = size * 0.09;
const centerRadius = size * 0.06;
const sliceDegrees = getSliceDegrees(props.options.length);
const winnerCenterDegrees = getSliceCenterDegrees(props.options.length, props.winnerIndex);
// Turn count scales with the frame budget and the slice width: a dense wheel
// spun as far as a sparse one moves more than a whole wedge per frame and
// aliases into a backwards-looking blur.
const spinTurns = getSpinTurns(spinFrames, props.options.length);
const finalRotation = getFinalWheelRotationDegrees(
props.options.length,
props.winnerIndex,
spinTurns,
);
const finalRotation = getFinalWheelRotationDegrees(props.options.length, props.winnerIndex);
const startRotation = -24;
const rotation =
getWheelRotationDegrees({
finalRotationDegrees: finalRotation,
frame,
spinFrames,
startRotationDegrees: startRotation,
}) + getSpinRecoilDegrees(frame, spinFrames, sliceDegrees);
const pointerDeflection = getPointerDeflectionDegrees(rotation, sliceDegrees);
const winnerPalette = getWinnerPalette(
theme,
getSliceColor(theme, props.winnerIndex, props.options.length),
);
const winnerOutlineWidth = 4 + Math.round(winnerPulse * 3);
// The width estimator is calibrated against the 700-weight slice labels, so
// the 800-weight pill runs wider than it predicts. Budgeting for that keeps
// the browser from re-wrapping past the two lines chosen here.
const pillBoldWidthFactor = 1.12;
const pillMaxWidth = size * 0.58;
const pillBaseFontSize = size * 0.075;
const winnerText = props.options[props.winnerIndex] ?? '';
const winnerLines = getLabelLines(winnerText, pillBaseFontSize, pillMaxWidth, 2);
const longestWinnerWidth = winnerLines.reduce(
(widest, line) => Math.max(widest, estimateTextWidth(line, pillBaseFontSize)),
0,
) * pillBoldWidthFactor;
const winnerFontSize = Math.max(
size * 0.038,
longestWinnerWidth > pillMaxWidth
? pillBaseFontSize * (pillMaxWidth / longestWinnerWidth)
: pillBaseFontSize,
);
const pillPop = interpolate(frame, [revealFrame, revealFrame + 4], [0, 1], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
const rotation = getWheelRotationDegrees({
finalRotationDegrees: finalRotation,
frame,
spinFrames,
startRotationDegrees: startRotation,
});
return (
@@ -132,19 +79,13 @@ export const WheelComposition = (props) => {
}}
/>
{/* translateZ promotes this one layer, which switches Chromium from LCD
sub-pixel text antialiasing to grayscale for every label inside it.
Sub-pixel AA emits saturated color fringes on each glyph edge that
consume most of the 256-entry GIF palette. Promoting the labels
individually works too, but costs one layer per option and doubles
render time on a dense wheel. */}
<div
style={{
height: size,
left: 0,
position: 'absolute',
top: 0,
transform: `rotate(${rotation}deg) translateZ(0)`,
transform: `rotate(${rotation}deg)`,
transformOrigin: '50% 50%',
width: size,
}}
@@ -152,7 +93,8 @@ export const WheelComposition = (props) => {
{props.options.map((option, index) => {
const start = index * sliceDegrees;
const centerDegrees = getSliceCenterDegrees(props.options.length, index);
const color = getSliceColor(theme, index, props.options.length);
const isWinner = index === props.winnerIndex;
const color = theme.slices[index % theme.slices.length] ?? theme.slices[0] ?? '#cccccc';
const label = getRadialLabelLayout({
center,
radius,
@@ -160,7 +102,6 @@ export const WheelComposition = (props) => {
centerDegrees,
optionCount: props.options.length,
text: option,
winnerCenterDegrees,
});
return (
@@ -170,9 +111,10 @@ export const WheelComposition = (props) => {
progress={1 / props.options.length}
radius={radius}
rotation={cssDegreesToPieRadians(start)}
stroke={theme.sliceStroke}
stroke="#ffffff"
strokeWidth={2}
style={{
filter: isWinner && frame >= holdStartFrame ? `drop-shadow(0 0 ${10 + winnerPulse * 10}px ${theme.winnerGlow})` : undefined,
left: center - radius,
position: 'absolute',
top: center - radius,
@@ -191,7 +133,7 @@ export const WheelComposition = (props) => {
justifyContent: 'center',
left: label.x,
letterSpacing: 0,
lineHeight: labelLineHeightRatio,
lineHeight: 1,
overflow: 'hidden',
padding: `0 ${label.horizontalPadding}px`,
position: 'absolute',
@@ -212,28 +154,6 @@ export const WheelComposition = (props) => {
</React.Fragment>
);
})}
{/* Drawn after every slice rather than as a stroke on the winning one:
slices paint in index order, so the next slice's fill and divider
cover the shared radial edge and the outline stops short of
wrapping the wedge. An unfilled pie on top traces all three edges.
An opaque stroke, not a blurred filter — a filter on a Pie paints
outside the shape and a stroke survives GIF quantisation. */}
{isRevealed && (
<Pie
fill="none"
progress={1 / props.options.length}
radius={radius}
rotation={cssDegreesToPieRadians(props.winnerIndex * sliceDegrees)}
stroke={winnerPalette.outline}
strokeWidth={winnerOutlineWidth}
style={{
left: center - radius,
position: 'absolute',
top: center - radius,
}}
/>
)}
</div>
<Circle
@@ -246,67 +166,21 @@ export const WheelComposition = (props) => {
radius={centerRadius}
style={{left: center - centerRadius, position: 'absolute', top: center - centerRadius}}
/>
{/* Drawn as SVG so the outline actually renders: clip-path discards a
border and a box-shadow along with everything else it clips. */}
<svg
width={size * 0.1}
height={size * 0.105}
viewBox="0 0 100 105"
<div
style={{
left: center + radius - size * 0.045,
background: theme.pointer,
border: `2px solid ${theme.pointerStroke}`,
boxShadow: '0 7px 16px rgba(15, 23, 42, 0.24)',
clipPath: rightPointerClipPath,
height: size * 0.085,
left: center + radius - size * 0.02,
position: 'absolute',
top: center - size * 0.0525,
transform: `rotate(${pointerDeflection}deg)`,
transformOrigin: '100% 50%',
top: center - size * 0.0425,
width: size * 0.105,
zIndex: 4,
}}
>
<polygon
points="4,52.5 96,5 96,100"
fill={theme.pointer}
stroke={theme.pointerStroke}
strokeWidth={7}
strokeLinejoin="round"
/>
</svg>
{isRevealed && (
<div
style={{
alignItems: 'center',
// Winner's own slice color, framed in a deepened version of it.
background: winnerPalette.background,
borderRadius: size * 0.03,
color: winnerPalette.text,
display: 'flex',
fontSize: winnerFontSize,
fontWeight: 800,
justifyContent: 'center',
left: '50%',
lineHeight: 1.2,
outline: `${Math.round(size * 0.008)}px solid ${winnerPalette.outline}`,
padding: `${size * 0.022}px ${size * 0.042}px`,
position: 'absolute',
textAlign: 'center',
top: '50%',
transform: `translate(-50%, -50%) scale(${0.72 + 0.28 * pillPop}) translateZ(0)`,
// `pre`, not `pre-line`: the two lines above are already chosen to
// fit, and letting the browser re-wrap them turned a long name
// into a four-line block that covered the wheel.
whiteSpace: 'pre',
// Above the wheel, below the confetti: chips passing in front sell
// the burst as happening around the announcement instead of being
// swallowed by it, and a chip covers any given glyph for a frame
// or two at most.
zIndex: 12,
}}
>
{winnerLines.join('\n')}
</div>
)}
/>
</div>
<Confetti colors={theme.confetti} seed={props.winnerIndex} startFrame={revealFrame} />
</AbsoluteFill>
);
};
-261
View File
@@ -1,261 +0,0 @@
/**
* Deterministic confetti field for the winner celebration.
*
* Remotion renders every GIF frame independently in headless Chromium, so the
* animation must be a pure function of the frame number. These helpers turn a
* seeded random source into stable particle descriptors and evaluate their
* flight at an arbitrary time, keeping the visual identical on every render of
* the same seed.
*
* Motion is air-drag ballistics evaluated in closed form rather than stepped
* integration: paper decelerates hard after launch instead of flying a clean
* parabola, and a closed form keeps every frame independent.
*/
/** Canvas size the physics constants below are tuned for. */
export const CONFETTI_REFERENCE_SIZE = 512;
/** Downward acceleration in px/s^2 at the reference size. */
const gravity = 340;
/**
* Launch speed range in px/s at the reference size.
*
* Capped by the frame rate rather than by how a cannon behaves: at 15fps a
* particle travelling 800px/s covers 53px per frame, and a GIF has no motion
* blur to tie those samples together. Past roughly 3x the body length per frame
* the burst reads as disconnected dots instead of moving paper.
*/
const minSpeed = 600;
const maxSpeed = 820;
/**
* Drag coefficient range in 1/s. Terminal fall speed is `gravity / drag`, so
* this range is what keeps particles in frame for the whole celebration. Small
* chips get more drag than large ones, which reads as depth.
*/
const minDrag = 1.4;
const maxDrag = 2.0;
/**
* Launch timing. A cannon fires most of its charge at once and then trails, so
* `firstVolleyShare` of the particles leave at t=0 — which is what makes the
* opening frame a compact pop rather than an empty canvas — and the rest spread
* over `launchWindowSeconds`, weighted toward the start.
*/
const launchWindowSeconds = 0.38;
// Raised to offset the share diverted to the second volley below, so the
// opening frame keeps its compact pop.
const firstVolleyShare = 0.5;
/**
* Second volley. This share of the particles launches partway through the
* celebration so a long hold does not end on a thinning field. Expressed as a
* fraction of the window rather than in seconds, so a short hold collapses the
* second wave into the first instead of firing it after the GIF has ended.
*/
const secondWaveShare = 0.22;
const secondWaveWindowStart = 0.42;
const secondWaveWindowEnd = 0.55;
/**
* Motion streak. A GIF carries no motion blur, so a fast chip samples as a
* detached dot. Stretching it along its direction of travel restores the visual
* link between frames. Capped well short of a rain-streak look.
*/
const maxStretch = 1.8;
// Set above the launch speed range so fast and slow chips streak by different
// amounts. Tuned to the reference speed range: at 620 every particle pinned to
// the cap for the first three frames and the burst lost its size variety.
const stretchReferenceSpeed = 1200;
/** Cannon muzzle positions as a fraction of the canvas. */
const cannonInset = 0.08;
const cannonHeight = 0.8;
/**
* Launch fan for the left cannon, in degrees; 0 points right and negative
* points up. The right cannon mirrors it.
*
* Steep rather than flat: horizontal reach is `vx / drag`, so a flat fan parks
* the whole field around mid-canvas, which is exactly where the winner
* announcement sits. Angling the charge up trades that reach for height, and
* the chips rain down across the disc on either side of the pill instead of
* settling on top of it.
*/
const minLaunchDegrees = -86;
const maxLaunchDegrees = -54;
/** Body size range in px at the reference size. */
const minWidth = 6;
const maxWidth = 18;
/**
* Minimum rendered thickness in px, unscaled: below ~3px a chip survives GIF
* quantisation as a single dim row of pixels, or vanishes.
*/
const minHeight = 3;
const maxHeight = 26;
/** Tumble rate range in deg/s. Capped so 15fps samples a rotation, not a flicker. */
const maxSpin = 300;
/** Lateral flutter, in Hz and px at the reference size. */
const minSwayHz = 0.5;
const maxSwayHz = 0.9;
const minSwayAmplitude = 8;
const maxSwayAmplitude = 20;
/**
* @param {number} t normalized value in [0, 1]
* @param {number} min
* @param {number} max
* @returns {number}
*/
const lerp = (t, min, max) => min + t * (max - min);
/**
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
/**
* @typedef {object} ConfettiParticle
* @property {string} color
* @property {number} delay s before this particle launches
* @property {number} drag 1/s air resistance
* @property {number} gravity px/s^2 downward acceleration
* @property {number} height px
* @property {number} originX px
* @property {number} originY px
* @property {number} rotationSpeed deg/s
* @property {number} startRotation deg
* @property {number} swayAmplitude px
* @property {number} swayHz cycles/s
* @property {number} swayPhase rad
* @property {number} vx px/s
* @property {number} vy px/s (negative points up on screen)
* @property {number} width px
*/
/**
* Builds the confetti particles for a two-cannon burst framing the wheel.
*
* Alternating particles launch from the lower-left and lower-right corners and
* arc inward across the disc. Launching from the corners rather than the hub
* keeps the dense opening frames over plain background, where the pop is
* legible, and spreads the field over the whole canvas rather than piling it on
* the winner announcement.
*
* @param {object} params
* @param {number} params.count number of particles
* @param {number} params.size canvas edge length in px
* @param {string[]} params.colors palette to cycle through
* @param {(seed: string) => number} params.random deterministic [0, 1) source
* @param {number} [params.windowSeconds] length of the celebration, for pacing
* the second volley
* @returns {ConfettiParticle[]}
*/
export const createConfettiParticles = ({count, size, colors, random, windowSeconds = 0}) => {
const scale = size / CONFETTI_REFERENCE_SIZE;
const palette = colors.length > 0 ? colors : ['#f97316'];
return Array.from({length: count}, (_, index) => {
const rand = (/** @type {string} */ key) => random(`${index}-${key}`);
const fromLeft = index % 2 === 0;
// Bias size small so a few large chips anchor the burst at 15fps while most
// particles stay light enough to flutter.
const sizeNorm = rand('width') ** 1.7;
const width = lerp(sizeNorm, minWidth, maxWidth) * scale;
// Mirror the left cannon's fan: 0deg points right, negative points up.
const spread = rand('angle');
const angleDegrees = fromLeft
? lerp(spread, minLaunchDegrees, maxLaunchDegrees)
: lerp(spread, -180 - minLaunchDegrees, -180 - maxLaunchDegrees);
const angleRadians = (angleDegrees * Math.PI) / 180;
const speed = lerp(rand('speed'), minSpeed, maxSpeed) * scale;
const delayRoll = rand('delay');
const firstWaveDelay =
delayRoll < firstVolleyShare
? 0
: ((delayRoll - firstVolleyShare) / (1 - firstVolleyShare)) ** 1.5 * launchWindowSeconds;
const secondWaveDelay =
windowSeconds * lerp(rand('wave'), secondWaveWindowStart, secondWaveWindowEnd);
// A particle joins the second volley only when that actually lands later
// than the normal spread; on a short hold the two waves coincide, which is
// the right behaviour rather than a case to special-case.
const inSecondWave = rand('waveRoll') < secondWaveShare;
return {
color: palette[Math.floor(rand('color') * palette.length)] ?? palette[0] ?? '#f97316',
delay: inSecondWave ? Math.max(firstWaveDelay, secondWaveDelay) : firstWaveDelay,
drag: lerp(1 - sizeNorm, minDrag, maxDrag),
gravity: gravity * scale,
height: clamp(width * lerp(rand('ratio'), 0.25, 2.6), minHeight, maxHeight * scale),
originX: (fromLeft ? cannonInset : 1 - cannonInset) * size,
originY: cannonHeight * size,
rotationSpeed: lerp(rand('spin'), -maxSpin, maxSpin),
startRotation: lerp(rand('rotation'), 0, 360),
swayAmplitude: lerp(rand('sway'), minSwayAmplitude, maxSwayAmplitude) * scale,
swayHz: lerp(rand('hz'), minSwayHz, maxSwayHz),
swayPhase: rand('phase') * Math.PI * 2,
vx: Math.cos(angleRadians) * speed,
vy: Math.sin(angleRadians) * speed,
width,
};
});
};
/**
* Evaluates a particle's position, orientation and streak at `seconds` after the
* burst.
*
* Closed-form solution of `v' = g - drag * v`, so position depends only on the
* elapsed time and never on a previous frame. Before its launch delay a
* particle sits at the muzzle.
*
* `stretch` and `rotation` together fake motion blur. While a chip is moving
* fast it is drawn elongated and aligned with its travel direction; as it slows
* the stretch relaxes to 1 and the orientation blends back to its own tumble.
* Without this a chip crossing 50px between frames reads as two unrelated dots.
*
* @param {ConfettiParticle} particle
* @param {number} seconds elapsed since the burst started
* @returns {{rotation: number, stretch: number, x: number, y: number}}
*/
export const getConfettiParticleState = (particle, seconds) => {
const t = Math.max(0, seconds - particle.delay);
const fade = Math.exp(-particle.drag * t);
const decay = 1 - fade;
const terminalSpeed = particle.gravity / particle.drag;
// Sway eases in so particles do not start mid-swing at the muzzle.
const sway =
Math.sin(particle.swayPhase + t * particle.swayHz * Math.PI * 2) *
particle.swayAmplitude *
Math.min(1, t * 2);
// Velocity of the same closed form, differentiated.
const velocityX = particle.vx * fade;
const velocityY = particle.vy * fade + terminalSpeed * decay;
const speedNow = Math.hypot(velocityX, velocityY);
const stretch = 1 + Math.min(maxStretch - 1, speedNow / stretchReferenceSpeed);
// Blend from travel-aligned while streaking to free tumble once settled.
const alignment = Math.min(1, Math.max(0, (stretch - 1) / (maxStretch - 1)));
const travelDegrees = (Math.atan2(velocityY, velocityX) * 180) / Math.PI;
const tumbleDegrees = particle.startRotation + particle.rotationSpeed * t;
// Shortest path: tumble grows without bound while travel wraps at +/-180, so
// a raw difference would swing the chip the long way around.
const towardTravel = (((travelDegrees - tumbleDegrees) % 360) + 540) % 360 - 180;
return {
rotation: tumbleDegrees + alignment * towardTravel,
stretch,
x: particle.originX + (particle.vx / particle.drag) * decay + sway,
y:
particle.originY +
((particle.vy - terminalSpeed) / particle.drag) * decay +
terminalSpeed * t,
};
};
+6 -150
View File
@@ -1,13 +1,3 @@
/**
* Palette definitions for the rendered wheel.
*
* Slice orders are chosen so that neighbouring wedges stay distinguishable
* under normal vision and under deuteranopia/protanopia simulation, because a
* 200px GIF in a chat client is the realistic viewing size. `confetti` is kept
* separate from `slices`: slice colors are tuned to carry dark label text at
* large areas, which makes several of them far too light to read as 8px paper
* chips against the background.
*/
export const themes = {
classic: {
background: '#f8fafc',
@@ -17,11 +7,9 @@ export const themes = {
pointer: '#ef4444',
pointerStroke: '#991b1b',
text: '#111827',
textInverse: '#ffffff',
textHalo: 'rgba(255, 255, 255, 0.88)',
sliceStroke: '#ffffff',
slices: ['#f97316', '#14b8a6', '#f472b6', '#60a5fa', '#facc15', '#a78bfa', '#fb7185', '#34d399'],
confetti: ['#f97316', '#14b8a6', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#10b981', '#f43f5e'],
winnerGlow: 'rgba(250, 204, 21, 0.45)',
slices: ['#f97316', '#14b8a6', '#facc15', '#60a5fa', '#f472b6', '#a78bfa', '#34d399', '#fb7185'],
},
festival: {
background: '#fff7ed',
@@ -31,11 +19,9 @@ export const themes = {
pointer: '#dc2626',
pointerStroke: '#7f1d1d',
text: '#1f2937',
textInverse: '#fffbeb',
textHalo: 'rgba(255, 251, 235, 0.92)',
sliceStroke: '#ffffff',
slices: ['#fb7185', '#06b6d4', '#84cc16', '#a78bfa', '#f59e0b', '#f472b6', '#fb923c', '#22c55e'],
confetti: ['#f43f5e', '#ea580c', '#65a30d', '#0891b2', '#7c3aed', '#db2777', '#16a34a', '#d97706'],
winnerGlow: 'rgba(251, 146, 60, 0.5)',
slices: ['#fb7185', '#f59e0b', '#84cc16', '#06b6d4', '#a78bfa', '#f472b6', '#22c55e', '#fb923c'],
},
mono: {
background: '#f4f4f5',
@@ -45,15 +31,9 @@ export const themes = {
pointer: '#27272a',
pointerStroke: '#09090b',
text: '#18181b',
textInverse: '#fafafa',
textHalo: 'rgba(250, 250, 250, 0.9)',
// Grey dividers instead of white: the lightest slices sit within 1.1:1 of
// the background, so a white stroke leaves the wheel with no silhouette.
sliceStroke: '#71717a',
slices: ['#87878f', '#cacad0', '#9d9da5', '#e1e1e5', '#b3b3ba', '#f9f9fa'],
// Inverted to darks: every slice color in this theme is within 2.4:1 of the
// background and disappears at particle size.
confetti: ['#18181b', '#3f3f46', '#52525b', '#71717a', '#a1a1aa'],
winnerGlow: 'rgba(161, 161, 170, 0.45)',
slices: ['#e4e4e7', '#a1a1aa', '#d4d4d8', '#b8b8bf', '#f4f4f5', '#c4c4ca'],
},
};
@@ -61,127 +41,3 @@ export const themes = {
* @param {'classic' | 'festival' | 'mono'} name
*/
export const getTheme = (name) => themes[name] ?? themes.classic;
/**
* Picks which palette entry a slice uses.
*
* Cycling the palette by index alone makes the first and last slice share a
* color whenever the option count leaves a remainder of one, and those two are
* neighbours on the wheel, so they merge into a single double-width wedge
* separated only by a hairline stroke. The last slice is moved to the opposite
* side of the palette instead.
*
* @param {number} index slice index
* @param {number} optionCount total slices
* @param {number} paletteLength available colors
* @returns {number}
*/
export const getSliceColorIndex = (index, optionCount, paletteLength) => {
if (paletteLength <= 0) {
return 0;
}
if (index === optionCount - 1 && optionCount % paletteLength === 1) {
return Math.floor(paletteLength / 2);
}
return index % paletteLength;
};
/**
* Resolves the color a slice is painted with.
*
* @param {{slices: string[]}} theme
* @param {number} index slice index
* @param {number} optionCount total slices
* @returns {string}
*/
export const getSliceColor = (theme, index, optionCount) =>
theme.slices[getSliceColorIndex(index, optionCount, theme.slices.length)] ??
theme.slices[0] ??
'#cccccc';
/**
* @param {string} hex `#rrggbb`
* @returns {number[]} channel values in [0, 255]
*/
const parseHex = (hex) =>
(hex.replace('#', '').match(/.{2}/gu) ?? []).map((channel) => Number.parseInt(channel, 16));
/**
* @param {number[]} channels
* @returns {string}
*/
const toHex = (channels) =>
`#${channels
.map((channel) => Math.round(Math.min(255, Math.max(0, channel))).toString(16).padStart(2, '0'))
.join('')}`;
/**
* @param {string} from
* @param {string} to
* @param {number} amount 0 keeps `from`, 1 returns `to`
* @returns {string}
*/
const mixColors = (from, to, amount) => {
const source = parseHex(from);
const target = parseHex(to);
return toHex(source.map((channel, index) => channel + ((target[index] ?? channel) - channel) * amount));
};
/**
* @param {string} hex
* @returns {number}
*/
const getRelativeLuminance = (hex) => {
const [red = 0, green = 0, blue = 0] = parseHex(hex)
.map((channel) => channel / 255)
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
/**
* @param {string} foreground
* @param {string} background
* @returns {number} WCAG contrast ratio
*/
const getContrastRatio = (foreground, background) => {
const first = getRelativeLuminance(foreground);
const second = getRelativeLuminance(background);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
};
/**
* How far the winner outline is pulled from the slice color toward the theme
* ink. Enough to clear 3:1 against every slice in every theme, while keeping
* the hue so the outline reads as belonging to the slice it frames.
*/
const winnerOutlineInkMix = 0.72;
/**
* Winner colors derived from the winning slice.
*
* The celebration is tied to the slice that actually won: the announcement
* carries that slice's color and both it and the wheel outline are framed in a
* deepened version of the same hue. A fixed dark chip read as an unrelated
* element pasted over the wheel.
*
* Ink is chosen by contrast rather than fixed, because a palette entry light
* enough to carry dark label text is not guaranteed to carry it at pill size in
* every future theme.
*
* @param {{slices: string[], text: string, textInverse: string}} theme
* @param {string} sliceColor color of the winning slice
* @returns {{background: string, outline: string, text: string}}
*/
export const getWinnerPalette = (theme, sliceColor) => ({
background: sliceColor,
outline: mixColors(sliceColor, theme.text, winnerOutlineInkMix),
text:
getContrastRatio(theme.text, sliceColor) >= getContrastRatio(theme.textInverse, sliceColor)
? theme.text
: theme.textInverse,
});
+4 -44
View File
@@ -23,16 +23,6 @@ const minLabelWidth = 24;
const minLabelFontSize = 8;
const preferredLabelFontSize = 14;
/**
* Line box as a multiple of the font size.
*
* Vietnamese stacks a tone mark above and a dot below the same vowel, so a
* ratio of exactly 1 leaves marks from adjacent wrapped lines touching, and any
* font fallback with taller metrics clips against the label's `overflow:
* hidden`. Exported so the composition renders the same box this module sizes.
*/
export const labelLineHeightRatio = 1.16;
/**
* @param {string} text
* @param {number} fontSize
@@ -185,25 +175,13 @@ export const getLabelFontSize = (radius, optionCount, text = '', trackWidth = Nu
* @param {number} input.centerDegrees
* @param {number} input.optionCount
* @param {string} input.text
* @param {number} [input.winnerCenterDegrees] center angle of the winning slice
* @returns {RadialLabelLayout}
*/
export const getRadialLabelLayout = ({
center,
radius,
hubRadius,
centerDegrees,
optionCount,
text,
winnerCenterDegrees = 0,
}) => {
export const getRadialLabelLayout = ({center, radius, hubRadius, centerDegrees, optionCount, text}) => {
const track = getLabelTrack(radius, hubRadius);
const radians = (centerDegrees * Math.PI) / 180;
const baseFontSize = getLabelFontSize(radius, optionCount);
const availableHeight = (2 * Math.PI * track.midRadius) / optionCount;
// Counted against the bare font size, not the line box: the fitted size below
// shrinks the text if the taller boxes overflow, so charging the ratio here
// too would drop a line that still fits once shrunk.
const maxLines = Math.max(1, Math.min(3, Math.floor(availableHeight / baseFontSize)));
const horizontalPadding = Math.max(4, radius * 0.02);
const contentWidth = Math.max(1, track.width - horizontalPadding * 2);
@@ -219,34 +197,16 @@ export const getRadialLabelLayout = ({
const fontSize = shouldWrap
? getLabelFontSize(radius, optionCount, longestLine, contentWidth)
: singleLineFontSize;
// Shrink to fit the taller line box rather than letting `overflow: hidden`
// crop the last line.
const blockHeight = fontSize * labelLineHeightRatio * lines.length;
const fittedFontSize =
blockHeight > availableHeight
? Math.max(minLabelFontSize, Math.floor(fontSize * (availableHeight / blockHeight)))
: fontSize;
// Labels stay radial, but a slice whose rest-frame angle points left would
// render its text mirrored. Flipping by the angle the slice lands at makes
// every label upright in the final frame, which is the frame chat clients
// show as the GIF's poster image.
//
// The boundary is inclusive at 90 and exclusive at 270 so the two labels that
// land exactly vertical — which happens on every option count divisible by
// four — both read in the same direction. Neither is mirrored either way, but
// opposite reading directions make the pair look like a mistake.
const restAngle = (((centerDegrees - winnerCenterDegrees) % 360) + 360) % 360;
const flip = restAngle >= 90 && restAngle < 270 ? 180 : 0;
return {
x: center + Math.cos(radians) * track.midRadius,
y: center + Math.sin(radians) * track.midRadius,
width: track.width,
contentWidth,
height: Math.min(availableHeight, fittedFontSize * labelLineHeightRatio * lines.length),
height: Math.min(availableHeight, fontSize * lines.length),
horizontalPadding,
rotation: centerDegrees + flip,
fontSize: fittedFontSize,
rotation: centerDegrees,
fontSize,
lines,
};
};
+5 -126
View File
@@ -1,7 +1,5 @@
export const fullTurnDegrees = 360;
/** Turns the wheel makes before landing on the winner. */
export const defaultFullTurns = 5;
export const rightPointerClipPath = 'polygon(0 50%, 100% 0, 100% 100%)';
/**
* @param {number} optionCount
@@ -26,7 +24,7 @@ export const getSliceCenterDegrees = (optionCount, index) =>
* @param {number} fullTurns
* @returns {number}
*/
export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns = defaultFullTurns) =>
export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns = 7) =>
fullTurns * fullTurnDegrees - getSliceCenterDegrees(optionCount, winnerIndex);
/**
@@ -46,74 +44,16 @@ export const cssDegreesToPieRadians = (cssDegrees) => ((cssDegrees - 270) * Math
*/
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
/**
* Fraction of the spin spent accelerating from rest.
*
* A pure ease-out starts at maximum speed, which pushes the wheel past one
* slice per frame at 15fps and reads as a strobe or a backwards spin. A short
* launch ramp keeps peak speed under the slice width.
*/
const accelFraction = 0.12;
/** Deceleration curve exponent; higher means a longer, softer approach. */
const decelExponent = 2.2;
/**
* Speed at the handoff between launch and deceleration, in progress units.
*
* Solved so the two segments share a slope there. Without this the wheel jumps
* from ~18 to ~41 degrees per frame in a single frame and looks like it gets a
* second push.
*/
const handoffSlope = decelExponent / (1 - accelFraction + (decelExponent * accelFraction) / 2);
/** Progress already covered when the launch ramp ends. */
const handoffProgress = (handoffSlope * accelFraction) / 2;
/** Widest turn count allowed, so a sparse wheel does not spin absurdly. */
const maxFullTurns = 7;
/**
* Turns to spin so the wheel never advances more than one slice per frame.
*
* Peak speed is `handoffSlope * turns * 360 / spinFrames` degrees per frame.
* Holding that at or under one slice (`360 / optionCount`) means a viewer can
* always track which wedge is passing; above it the wheel aliases and appears to
* jitter or run backwards. Solving for turns gives the expression below.
*
* Floors rather than rounds: rounding 5.63 up to 6 turns on an 8-option wheel
* would push peak speed to 47.9deg against a 45deg slice, i.e. introduce the
* very aliasing this prevents.
*
* The result must stay an integer. The winner lands under the pointer only
* because `turns * 360` is a whole number of revolutions; 2.8 turns would leave
* the wheel 288 degrees from its detent.
*
* @param {number} spinFrames frames available before the hold
* @param {number} optionCount number of slices
* @returns {number} integer turn count
*/
export const getSpinTurns = (spinFrames, optionCount) =>
Math.max(
1,
Math.min(maxFullTurns, Math.floor(spinFrames / (handoffSlope * Math.max(1, optionCount)))),
);
/**
* @param {number} frame
* @param {number} spinFrames
* @returns {number}
*/
export const getSpinProgress = (frame, spinFrames) => {
const progress = clamp(frame / Math.max(1, spinFrames), 0, 1);
const safeSpinFrames = Math.max(1, spinFrames);
const progress = clamp(frame / safeSpinFrames, 0, 1);
if (progress < accelFraction) {
return (handoffSlope * progress * progress) / (2 * accelFraction);
}
const decelProgress = (progress - accelFraction) / (1 - accelFraction);
return handoffProgress + (1 - (1 - decelProgress) ** decelExponent) * (1 - handoffProgress);
return 1 - (1 - progress) ** 3;
};
/**
@@ -127,64 +67,3 @@ export const getSpinProgress = (frame, spinFrames) => {
export const getWheelRotationDegrees = ({frame, spinFrames, startRotationDegrees, finalRotationDegrees}) =>
startRotationDegrees +
(finalRotationDegrees - startRotationDegrees) * getSpinProgress(frame, spinFrames);
/** Portion of the spin over which the wheel rocks into its detent. */
const recoilFraction = 0.18;
/** Peak rock in degrees, before the slice-width cap below. */
const maxRecoilDegrees = 2.2;
/**
* Rotation offset that rocks the wheel into its final position.
*
* A pure ease-out creeps to a halt, which reads as the wheel being switched off
* rather than coming to rest. This overlays one damped swing over the tail of
* the spin: the wheel drifts a little past its detent, comes back, and settles.
*
* Kept as an offset on top of the rotation rather than folded into
* `getSpinProgress` so progress stays monotonic in [0, 1]. The damping term
* forces the offset to exactly 0 at `spinFrames`, so the winner still lands
* precisely under the pointer and the flapper still parks neutral.
*
* @param {number} frame
* @param {number} spinFrames
* @param {number} sliceDegrees width of one slice
* @returns {number} degrees to add to the wheel rotation
*/
export const getSpinRecoilDegrees = (frame, spinFrames, sliceDegrees) => {
const safeSpinFrames = Math.max(1, spinFrames);
const startFrame = safeSpinFrames * (1 - recoilFraction);
if (frame <= startFrame || frame >= safeSpinFrames) {
return 0;
}
const progress = (frame - startFrame) / (safeSpinFrames - startFrame);
// Capped against the slice so a 2-option wheel does not visibly swing and a
// dense one never rocks a neighbouring wedge under the pointer.
const amplitude = Math.min(maxRecoilDegrees, sliceDegrees * 0.25);
return amplitude * Math.sin(progress * Math.PI * 2) * (1 - progress) ** 1.5;
};
/**
* Deflection of the pointer flapper as slices pass under it.
*
* The flapper is pushed aside as a slice edge arrives and springs back over the
* rest of the slice, which is the cue that sells the wheel as a physical
* object. Resolves to exactly 0 at rest: the final rotation always leaves a
* remainder of half a slice, so `phase` is 0.5 and the flapper parks neutral
* for every winner and option count.
*
* @param {number} rotationDegrees current wheel rotation
* @param {number} sliceDegrees width of one slice
* @param {number} [maxDeflection] peak deflection in degrees
* @returns {number}
*/
export const getPointerDeflectionDegrees = (rotationDegrees, sliceDegrees, maxDeflection = 9) => {
const safeSliceDegrees = Math.max(1e-6, sliceDegrees);
const phase = (((rotationDegrees % safeSliceDegrees) + safeSliceDegrees) % safeSliceDegrees) / safeSliceDegrees;
const push = Math.max(0, 1 - phase * 3);
return push === 0 ? 0 : -maxDeflection * push;
};
+2 -16
View File
@@ -1,25 +1,11 @@
import {timingSafeEqual} from 'node:crypto';
import {ZodError} from 'zod';
import {isRenderTimeoutError} from '../lib/render-errors.js';
import {pickWinnerIndex} from '../lib/winner.js';
import {parseWheelRequest} from '../schemas/wheel-request.js';
/**
* Length-guarded constant-time string comparison so token checks do not leak
* information through response timing.
*
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
const safeEqual = (a, b) => {
const aBuffer = Buffer.from(a);
const bBuffer = Buffer.from(b);
return aBuffer.length === bBuffer.length && timingSafeEqual(aBuffer, bBuffer);
};
/**
* @typedef {import('../config.js').AppConfig} AppConfig
* @typedef {import('../lib/render-semaphore.js').createRenderSemaphore} CreateRenderSemaphore
* @typedef {import('../schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
*/
@@ -46,7 +32,7 @@ export const registerGifRoute = async (app, deps) => {
app.post('/api/gif', async (request, reply) => {
if (deps.config.apiToken) {
const expected = `Bearer ${deps.config.apiToken}`;
if (!safeEqual(request.headers.authorization ?? '', expected)) {
if (request.headers.authorization !== expected) {
return reply.code(401).send({error: 'unauthorized'});
}
}
+3
View File
@@ -1,5 +1,8 @@
import {z} from 'zod';
export const themes = ['classic', 'festival', 'mono'];
export const allowedSizes = [384, 480, 512];
export const allowedFps = [12, 15, 20];
const themeSchema = z.union([z.literal('classic'), z.literal('festival'), z.literal('mono')]);
/**
+1 -1
View File
@@ -45,7 +45,7 @@ export const buildServer = async (options = {}) => {
return app;
};
if (import.meta.filename === process.argv[1]) {
if (import.meta.url === `file://${process.argv[1]}`) {
const config = loadConfig();
const app = await buildServer({config});
await app.listen({host: config.host, port: config.port});
-319
View File
@@ -1,319 +0,0 @@
import {describe, expect, test} from 'vitest';
import {
CONFETTI_REFERENCE_SIZE,
createConfettiParticles,
getConfettiParticleState,
} from '../src/remotion/confetti-layout.js';
// Cyclic pseudo-random source: stable, spans (0, 1), and keys map to values.
const makeRandom = () => {
const seen = new Map();
let next = 0;
return (/** @type {string} */ seed) => {
if (!seen.has(seed)) {
seen.set(seed, (next++ % 10) / 10 + 0.05);
}
return seen.get(seed);
};
};
// Hash source for distribution checks: the cyclic stub above only spans ten
// values, which is too coarse to say anything about where a field of 70
// particles ends up.
const makeHashRandom = (/** @type {number} */ seed) => (/** @type {string} */ key) => {
let hash = 2166136261;
const input = `${seed}-${key}`;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return ((hash >>> 0) % 100000) / 100000;
};
describe('confetti layout', () => {
test('creates the requested particle count', () => {
const particles = createConfettiParticles({
colors: ['#f97316', '#14b8a6'],
count: 24,
random: makeRandom(),
size: 512,
});
expect(particles).toHaveLength(24);
});
test('is deterministic for the same seed source', () => {
const params = {colors: ['#f97316', '#14b8a6'], count: 8, size: 512};
const first = createConfettiParticles({...params, random: makeRandom()});
const second = createConfettiParticles({...params, random: makeRandom()});
expect(second).toEqual(first);
});
test('draws colors only from the provided palette', () => {
const palette = ['#f97316', '#14b8a6', '#facc15'];
const particles = createConfettiParticles({
colors: palette,
count: 40,
random: makeRandom(),
size: 512,
});
for (const particle of particles) {
expect(palette).toContain(particle.color);
}
});
test('falls back to a default color when palette is empty', () => {
const particles = createConfettiParticles({
colors: [],
count: 4,
random: makeRandom(),
size: 512,
});
for (const particle of particles) {
expect(typeof particle.color).toBe('string');
expect(particle.color.length).toBeGreaterThan(0);
}
});
test('scales physics with canvas size', () => {
const [small] = createConfettiParticles({colors: ['#000'], count: 1, random: makeRandom(), size: 256});
const [large] = createConfettiParticles({colors: ['#000'], count: 1, random: makeRandom(), size: 1024});
if (!small || !large) {
throw new Error('expected one particle per call');
}
expect(large.gravity).toBeCloseTo(small.gravity * 4);
expect(large.width).toBeCloseTo(small.width * 4);
});
test('keeps particles thick enough to survive GIF quantization', () => {
for (const size of [384, 480, 512]) {
const particles = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size,
});
for (const particle of particles) {
expect(particle.height).toBeGreaterThanOrEqual(3);
expect(particle.width).toBeGreaterThanOrEqual(3);
}
}
});
test('launches from two cannons framing the wheel, not from the hub', () => {
const size = CONFETTI_REFERENCE_SIZE;
const particles = createConfettiParticles({
colors: ['#000'],
count: 20,
random: makeRandom(),
size,
});
const hubRadius = size * 0.09;
for (const particle of particles) {
const distanceFromHub = Math.hypot(particle.originX - size / 2, particle.originY - size / 2);
expect(distanceFromHub).toBeGreaterThan(hubRadius);
// Upward launch: negative vy points up in screen coordinates.
expect(particle.vy).toBeLessThan(0);
// Inward launch: left cannon throws right, right cannon throws left.
expect(particle.originX < size / 2 ? particle.vx > 0 : particle.vx < 0).toBe(true);
}
});
test('spreads the field around the winner announcement, not onto it', () => {
const size = CONFETTI_REFERENCE_SIZE;
const fps = 15;
const holdFrames = 18;
// The pill sits over the hub, measured as a fraction of the canvas at the
// longest name that still fits two lines.
const pill = {x0: 0.34 * size, x1: 0.66 * size, y0: 0.43 * size, y1: 0.57 * size};
const discRadius = 0.41 * size;
let onPill = 0;
let onDisc = 0;
let samples = 0;
for (let seed = 0; seed < 12; seed += 1) {
const particles = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeHashRandom(seed),
size,
windowSeconds: holdFrames / fps,
});
for (let frame = 0; frame <= holdFrames; frame += 1) {
const seconds = frame / fps;
for (const particle of particles) {
if (seconds < particle.delay) {
continue;
}
const state = getConfettiParticleState(particle, seconds);
samples += 1;
if (state.x >= pill.x0 && state.x <= pill.x1 && state.y >= pill.y0 && state.y <= pill.y1) {
onPill += 1;
}
if (Math.hypot(state.x - size / 2, state.y - size / 2) <= discRadius) {
onDisc += 1;
}
}
}
}
// Confetti draws in front of the pill, so a flat launch fan that parks the
// whole field at mid-canvas buries the winner's name.
expect(onPill / samples).toBeLessThan(0.12);
// ...without pushing the burst off the wheel it is celebrating.
expect(onDisc / samples).toBeGreaterThan(0.6);
});
test('drag gives a terminal fall speed instead of unbounded acceleration', () => {
const [particle] = createConfettiParticles({
colors: ['#000'],
count: 1,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
if (!particle) {
throw new Error('expected one particle');
}
expect(particle.drag).toBeGreaterThan(0);
const terminalSpeed = particle.gravity / particle.drag;
const late = getConfettiParticleState(particle, 6);
const later = getConfettiParticleState(particle, 7);
expect(later.y - late.y).toBeCloseTo(terminalSpeed, 0);
});
test('particle sits at its muzzle until its launch delay elapses', () => {
const [particle] = createConfettiParticles({
colors: ['#000'],
count: 4,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
if (!particle) {
throw new Error('expected one particle');
}
const atLaunch = getConfettiParticleState(particle, particle.delay);
expect(atLaunch.x).toBeCloseTo(particle.originX);
expect(atLaunch.y).toBeCloseTo(particle.originY);
// Orientation at launch is pulled toward the direction of travel, because
// that is where the streak is strongest. It is a blend, not a snap, so
// assert it sits closer to travel than the particle's own tumble does.
const travelDegrees = (Math.atan2(particle.vy, particle.vx) * 180) / Math.PI;
/** @param {number} degrees */
const offFromTravel = (degrees) =>
Math.abs((((degrees - travelDegrees) % 360) + 540) % 360 - 180);
expect(offFromTravel(atLaunch.rotation)).toBeLessThanOrEqual(
offFromTravel(particle.startRotation),
);
});
test('streaks while fast and relaxes to its own tumble once slow', () => {
const particles = createConfettiParticles({
colors: ['#000'],
count: 12,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
for (const particle of particles) {
const launch = getConfettiParticleState(particle, particle.delay);
const settled = getConfettiParticleState(particle, particle.delay + 3);
expect(launch.stretch).toBeGreaterThan(1);
expect(launch.stretch).toBeLessThanOrEqual(1.8);
expect(settled.stretch).toBeLessThan(launch.stretch);
expect(settled.stretch).toBeGreaterThanOrEqual(1);
}
});
test('keeps the streaked long axis short enough to read as paper', () => {
for (const size of [384, 480, 512]) {
const particles = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size,
});
for (const particle of particles) {
const {stretch} = getConfettiParticleState(particle, particle.delay);
expect(particle.width * stretch).toBeLessThanOrEqual(40);
// Stretching runs along the long axis only, so the thin dimension still
// survives GIF quantization.
expect(particle.height).toBeGreaterThanOrEqual(3);
}
}
});
test('holds a second volley back so a long celebration does not thin out', () => {
const longWindow = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
windowSeconds: 2.47,
});
const late = longWindow.filter((particle) => particle.delay > 0.8);
expect(late.length).toBeGreaterThan(8);
// Every particle must still launch inside the window it was built for.
for (const particle of longWindow) {
expect(particle.delay).toBeLessThan(2.47 * 0.6);
}
});
test('collapses the second volley into the first on a short celebration', () => {
const shortWindow = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
windowSeconds: 0.47,
});
for (const particle of shortWindow) {
expect(particle.delay).toBeLessThan(0.47);
}
});
test('rises before it falls', () => {
const particles = createConfettiParticles({
colors: ['#000'],
count: 12,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
for (const particle of particles) {
const launch = getConfettiParticleState(particle, particle.delay);
const soonAfter = getConfettiParticleState(particle, particle.delay + 0.15);
expect(soonAfter.y).toBeLessThan(launch.y);
}
});
});
-41
View File
@@ -89,47 +89,6 @@ describe('POST /api/gif', () => {
await app.close();
});
test('rejects concurrent renders beyond the limit and frees the slot afterward', async () => {
/** @type {(value?: void) => void} */
let releaseRender = () => {};
/** @type {(value?: void) => void} */
let markRenderStarted = () => {};
const renderStarted = new Promise((resolve) => {
markRenderStarted = resolve;
});
const renderGate = new Promise((resolve) => {
releaseRender = resolve;
});
const app = await buildServer({
config,
renderGif: async () => {
markRenderStarted();
await renderGate;
return {buffer: Buffer.from('GIF89a-test'), byteLength: 11, durationMs: 1};
},
});
const payload = {options: ['alpha', 'beta'], winnerIndex: 0};
const first = app.inject({method: 'POST', url: '/api/gif', payload});
// The route acquires the semaphore before calling renderGif, so once the
// render body runs the single slot is held.
await renderStarted;
const rejected = await app.inject({method: 'POST', url: '/api/gif', payload});
expect(rejected.statusCode).toBe(429);
expect(rejected.json().error).toBe('too_many_renders');
releaseRender();
expect((await first).statusCode).toBe(200);
// The slot released in `finally` must let a later request through.
const afterRelease = await app.inject({method: 'POST', url: '/api/gif', payload});
expect(afterRelease.statusCode).toBe(200);
await app.close();
});
test('returns timeout response when rendering exceeds configured limit', async () => {
const app = await buildServer({
config: {...config, renderTimeoutMs: 7},
+2 -77
View File
@@ -5,9 +5,7 @@ import {
getLabelLines,
getLabelTrack,
getRadialLabelLayout,
labelLineHeightRatio,
} from '../src/remotion/wheel-label-layout.js';
import {getFinalWheelRotationDegrees, getSliceCenterDegrees} from '../src/remotion/wheel-layout.js';
describe('wheel label layout', () => {
const readableExample = 'Chiều nay uống CraneTea';
@@ -52,7 +50,7 @@ describe('wheel label layout', () => {
expect(layout.rotation).toBe(centerDegrees);
});
test('flips labels that would rest mirrored so the poster frame reads upright', () => {
test('keeps left-side text aligned with the slice instead of flipping it upright', () => {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
@@ -60,82 +58,9 @@ describe('wheel label layout', () => {
centerDegrees: 180,
optionCount: 8,
text: 'left side',
winnerCenterDegrees: 0,
});
expect(layout.rotation).toBe(360);
});
test('gives both exactly-vertical labels the same reading direction', () => {
// Option counts divisible by four always land two labels at exactly 90 and
// 270 degrees. Radial layout cannot make those horizontal, but it can stop
// them pointing opposite ways.
for (const optionCount of [4, 8, 12, 16, 20, 24, 32]) {
const winnerIndex = 1;
const winnerCenterDegrees = getSliceCenterDegrees(optionCount, winnerIndex);
const restRotation = getFinalWheelRotationDegrees(optionCount, winnerIndex);
/** @type {number[]} */
const verticalScreenAngles = [];
for (let index = 0; index < optionCount; index += 1) {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
hubRadius: 46,
centerDegrees: getSliceCenterDegrees(optionCount, index),
optionCount,
text: 'name',
winnerCenterDegrees,
});
const screenAngle = (((layout.rotation + restRotation) % 360) + 360) % 360;
if (Math.abs(screenAngle - 90) < 0.001 || Math.abs(screenAngle - 270) < 0.001) {
verticalScreenAngles.push(Math.round(screenAngle));
}
}
expect(verticalScreenAngles).toHaveLength(2);
expect(new Set(verticalScreenAngles).size).toBe(1);
}
});
test('leaves every label upright once the wheel rests on the winner', () => {
for (const optionCount of [2, 3, 6, 8, 9, 16]) {
for (const winnerIndex of [0, 1, optionCount - 1]) {
const winnerCenterDegrees = getSliceCenterDegrees(optionCount, winnerIndex);
for (let index = 0; index < optionCount; index += 1) {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
hubRadius: 46,
centerDegrees: getSliceCenterDegrees(optionCount, index),
optionCount,
text: 'name',
winnerCenterDegrees,
});
// Angle the text actually sits at once the wheel has stopped.
const restRotation = getFinalWheelRotationDegrees(optionCount, winnerIndex);
const screenAngle = (((layout.rotation + restRotation) % 360) + 360) % 360;
expect(screenAngle > 90 && screenAngle < 270).toBe(false);
}
}
}
});
test('fits the taller line box instead of cropping the last line', () => {
// Vietnamese stacks a mark above and below the same vowel, so the rendered
// block is taller than fontSize x lines and must still fit the wedge.
for (const optionCount of [2, 4, 8, 12, 16, 24, 32]) {
const layout = get512Layout({optionCount});
const availableHeight =
(2 * Math.PI * getLabelTrack(supportedRadius, supportedHubRadius).midRadius) / optionCount;
const renderedHeight = layout.fontSize * labelLineHeightRatio * layout.lines.length;
expect(renderedHeight).toBeLessThanOrEqual(availableHeight);
expect(layout.height).toBeLessThanOrEqual(availableHeight);
}
expect(layout.rotation).toBe(180);
});
test('keeps short labels on one line at the base size', () => {
+14 -175
View File
@@ -2,13 +2,11 @@ import {describe, expect, test} from 'vitest';
import {
cssDegreesToPieRadians,
getFinalWheelRotationDegrees,
getPointerDeflectionDegrees,
getSliceCenterDegrees,
getSliceDegrees,
getSpinProgress,
getSpinRecoilDegrees,
getSpinTurns,
getWheelRotationDegrees,
rightPointerClipPath,
} from '../src/remotion/wheel-layout.js';
describe('wheel layout', () => {
@@ -47,185 +45,26 @@ describe('wheel layout', () => {
expect(getRenderedStartDegrees(270)).toBe(270);
});
test('starts from rest, rises monotonically, and ends exactly on target', () => {
test('eases spin progress once across the spin duration', () => {
expect(getSpinProgress(0, 100)).toBe(0);
expect(getSpinProgress(25, 100)).toBeCloseTo(0.578125);
expect(getSpinProgress(50, 100)).toBeCloseTo(0.875);
expect(getSpinProgress(100, 100)).toBe(1);
expect(getSpinProgress(125, 100)).toBe(1);
let previous = -1;
for (let frame = 0; frame <= 100; frame += 1) {
const progress = getSpinProgress(frame, 100);
expect(progress).toBeGreaterThanOrEqual(previous);
previous = progress;
}
});
/**
* Per-frame rotation change over a whole spin, including the settle recoil.
*
* @param {number} optionCount
* @param {number} spinFrames
*/
const getPerFrameRotation = (optionCount, spinFrames) => {
const sliceDegrees = getSliceDegrees(optionCount);
const finalRotationDegrees = getFinalWheelRotationDegrees(
optionCount,
0,
getSpinTurns(spinFrames, optionCount),
);
/** @param {number} at */
const rotationAt = (at) =>
getWheelRotationDegrees({
finalRotationDegrees,
frame: at,
spinFrames,
startRotationDegrees: -24,
}) + getSpinRecoilDegrees(at, spinFrames, sliceDegrees);
/** @type {number[]} */
const deltas = [];
test('maps eased spin progress to wheel rotation', () => {
const rotation = getWheelRotationDegrees({
finalRotationDegrees: 696,
frame: 25,
spinFrames: 100,
startRotationDegrees: -24,
});
for (let frame = 1; frame <= spinFrames; frame += 1) {
deltas.push(Math.abs(rotationAt(frame) - rotationAt(frame - 1)));
}
return deltas;
};
test('picks an integer turn count within bounds for every configuration', () => {
for (const spinFrames of [1, 36, 78, 98, 200]) {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const turns = getSpinTurns(spinFrames, optionCount);
expect(Number.isInteger(turns)).toBe(true);
expect(turns).toBeGreaterThanOrEqual(1);
expect(turns).toBeLessThanOrEqual(7);
}
}
expect(rotation).toBeCloseTo(392.25);
});
test('never advances a full slice in one frame when the frame budget allows it', () => {
for (const spinFrames of [78, 98, 200]) {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const deltas = getPerFrameRotation(optionCount, spinFrames);
expect(Math.max(...deltas)).toBeLessThan(getSliceDegrees(optionCount));
}
}
});
test('documents the dense short-duration wheels that cannot avoid aliasing', () => {
// 3000ms at 12fps leaves 36 spin frames. Even a single turn outruns a thin
// slice there, so this is a boundary of the format, not a tuning miss.
// Asserted so the boundary cannot move without someone noticing.
const spinFrames = 36;
/** @param {number} optionCount */
const aliases = (optionCount) =>
Math.max(...getPerFrameRotation(optionCount, spinFrames)) >= getSliceDegrees(optionCount);
// The cutoff sits at 17 options: one turn over 36 frames peaks at ~22.2deg
// per frame, which is wider than a 17-slice wedge.
for (let optionCount = 2; optionCount <= 16; optionCount += 1) {
expect(aliases(optionCount)).toBe(false);
}
for (let optionCount = 17; optionCount <= 32; optionCount += 1) {
expect(aliases(optionCount)).toBe(true);
}
});
test('blends the launch into the deceleration without a speed jump', () => {
const deltas = getPerFrameRotation(8, 98);
// A slope mismatch at the handoff shows up as a single large step in
// per-frame speed, which reads as the wheel getting a second push. The
// launch ramp itself gains only a few degrees per frame.
for (let index = 1; index < deltas.length; index += 1) {
const previous = deltas[index - 1] ?? 0;
const current = deltas[index] ?? 0;
expect(current - previous).toBeLessThan(6);
}
});
test('leaves only a few sub-perceptible frames before the wheel settles', () => {
const deltas = getPerFrameRotation(8, 98);
expect(deltas.filter((delta) => delta < 1.5).length).toBeLessThanOrEqual(8);
});
test('lands the winner exactly under the pointer at every option count', () => {
for (const spinFrames of [36, 78, 98, 200]) {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const sliceDegrees = getSliceDegrees(optionCount);
for (const winnerIndex of [0, 1, optionCount - 1]) {
const finalRotationDegrees = getFinalWheelRotationDegrees(
optionCount,
winnerIndex,
getSpinTurns(spinFrames, optionCount),
);
const restRotation =
getWheelRotationDegrees({
finalRotationDegrees,
frame: spinFrames,
spinFrames,
startRotationDegrees: -24,
}) + getSpinRecoilDegrees(spinFrames, spinFrames, sliceDegrees);
const winnerCenter = getSliceCenterDegrees(optionCount, winnerIndex);
const screenAngle = (((winnerCenter + restRotation) % 360) + 360) % 360;
expect(screenAngle).toBeCloseTo(0);
}
}
}
});
test('rocks into the detent and resolves to exactly zero', () => {
const spinFrames = 98;
const sliceDegrees = 45;
expect(getSpinRecoilDegrees(spinFrames, spinFrames, sliceDegrees)).toBe(0);
expect(getSpinRecoilDegrees(spinFrames + 5, spinFrames, sliceDegrees)).toBe(0);
expect(getSpinRecoilDegrees(0, spinFrames, sliceDegrees)).toBe(0);
/** @type {number[]} */
const offsets = [];
for (let frame = 0; frame <= spinFrames; frame += 1) {
offsets.push(getSpinRecoilDegrees(frame, spinFrames, sliceDegrees));
}
// One full swing: past the detent, then back through it.
expect(Math.max(...offsets)).toBeGreaterThan(0);
expect(Math.min(...offsets)).toBeLessThan(0);
// Never far enough to bring a neighbouring slice near the pointer.
expect(Math.max(...offsets.map(Math.abs))).toBeLessThanOrEqual(sliceDegrees * 0.25);
});
test('scales the rock down so a sparse wheel does not swing', () => {
// A 2-option wheel has 180deg slices; a quarter of that would be a lurch.
const wide = getSpinRecoilDegrees(92, 98, 180);
const narrow = getSpinRecoilDegrees(92, 98, 11.25);
expect(Math.abs(wide)).toBeLessThanOrEqual(2.2);
expect(Math.abs(narrow)).toBeLessThanOrEqual(11.25 * 0.25);
});
test('parks the pointer flapper neutral at rest for every winner', () => {
for (const optionCount of [2, 3, 5, 6, 7, 8, 9, 12, 16, 32]) {
for (let winnerIndex = 0; winnerIndex < optionCount; winnerIndex += 1) {
const restRotation = getFinalWheelRotationDegrees(optionCount, winnerIndex);
expect(getPointerDeflectionDegrees(restRotation, getSliceDegrees(optionCount))).toBe(0);
}
}
});
test('deflects the flapper as a slice edge passes and springs back', () => {
const sliceDegrees = 45;
expect(getPointerDeflectionDegrees(0, sliceDegrees)).toBe(-9);
expect(getPointerDeflectionDegrees(sliceDegrees / 3, sliceDegrees)).toBe(0);
expect(getPointerDeflectionDegrees(sliceDegrees * 0.75, sliceDegrees)).toBe(0);
expect(getPointerDeflectionDegrees(-sliceDegrees, sliceDegrees)).toBe(-9);
test('defines a right-side pointer that points into the wheel', () => {
expect(rightPointerClipPath).toBe('polygon(0 50%, 100% 0, 100% 100%)');
});
});
+1 -67
View File
@@ -1,5 +1,5 @@
import {describe, expect, test} from 'vitest';
import {getSliceColor, getSliceColorIndex, getWinnerPalette, themes} from '../src/remotion/themes.js';
import {themes} from '../src/remotion/themes.js';
/**
* @param {string} color
@@ -31,70 +31,4 @@ describe('wheel themes', () => {
test.each(Object.entries(themes))('%s uses one readable label color across every slice', (_name, theme) => {
expect(theme.slices.every((slice) => getContrastRatio(theme.text, slice) >= 4.5)).toBe(true);
});
test.each(Object.entries(themes))('%s keeps confetti visible against the background', (_name, theme) => {
// Slice colors are tuned to carry dark text over a large area, which leaves
// several of them indistinguishable from the background at particle size.
for (const color of theme.confetti) {
expect(getContrastRatio(color, theme.background)).toBeGreaterThanOrEqual(2);
}
});
test.each(Object.entries(themes))('%s gives the wheel a visible silhouette', (_name, theme) => {
// Either the slices separate from the background on their own, or the
// divider stroke has to carry the outline.
for (const slice of theme.slices) {
const sliceSeparates = getContrastRatio(slice, theme.background) >= 1.2;
const strokeSeparates = getContrastRatio(theme.sliceStroke, theme.background) >= 3;
expect(sliceSeparates || strokeSeparates).toBe(true);
}
});
test.each(Object.entries(themes))('%s reads the winner name over any winning slice', (_name, theme) => {
for (const slice of theme.slices) {
const palette = getWinnerPalette(theme, slice);
expect(getContrastRatio(palette.text, palette.background)).toBeGreaterThanOrEqual(4.5);
}
});
test.each(Object.entries(themes))('%s frames the winner in its own hue', (_name, theme) => {
for (const slice of theme.slices) {
const palette = getWinnerPalette(theme, slice);
// The outline separates the pill from the wheel behind it and the winning
// wedge from its neighbours, so it has to clear both the slice it frames
// and the page.
expect(getContrastRatio(palette.outline, slice)).toBeGreaterThanOrEqual(3);
expect(getContrastRatio(palette.outline, theme.background)).toBeGreaterThanOrEqual(3);
// Derived from the slice, not a fixed chip: two different winners must not
// produce the same frame.
expect(palette.outline).not.toBe(theme.text);
}
});
test.each(Object.entries(themes))('%s paints the winner pill in the winning slice color', (_name, theme) => {
for (let optionCount = 2; optionCount <= 12; optionCount += 1) {
for (let winnerIndex = 0; winnerIndex < optionCount; winnerIndex += 1) {
const slice = getSliceColor(theme, winnerIndex, optionCount);
expect(theme.slices).toContain(slice);
expect(getWinnerPalette(theme, slice).background).toBe(slice);
}
}
});
test.each(Object.entries(themes))('%s never puts one color on adjacent slices', (_name, theme) => {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const colors = Array.from({length: optionCount}, (_unused, index) =>
theme.slices[getSliceColorIndex(index, optionCount, theme.slices.length)],
);
for (let index = 0; index < optionCount; index += 1) {
// Cyclic: the last slice touches the first one on the wheel.
expect(colors[index]).not.toBe(colors[(index + 1) % optionCount]);
}
}
});
});
-23
View File
@@ -1,23 +0,0 @@
import {describe, expect, test} from 'vitest';
import {pickWinnerIndex} from '../src/lib/winner.js';
describe('pickWinnerIndex', () => {
test('returns an index within range across many draws', () => {
for (let i = 0; i < 100; i += 1) {
const index = pickWinnerIndex(4);
expect(Number.isInteger(index)).toBe(true);
expect(index).toBeGreaterThanOrEqual(0);
expect(index).toBeLessThan(4);
}
});
test('always returns 0 for a single option', () => {
expect(pickWinnerIndex(1)).toBe(0);
});
test('throws RangeError for non-positive or non-integer counts', () => {
expect(() => pickWinnerIndex(0)).toThrow(RangeError);
expect(() => pickWinnerIndex(-3)).toThrow(RangeError);
expect(() => pickWinnerIndex(2.5)).toThrow(RangeError);
});
});