feat(remotion): rework winner celebration and spin motion

The animation's weakest moment was its final frame, which is also the frame
most chat clients show as the GIF's poster image.

Celebration:
- Confetti launches from two cannons framing the wheel instead of bursting
  inside the hub, using closed-form drag ballistics so chips decelerate and
  flutter rather than flying a clean parabola. The field previously drained
  from 70 particles to 3 before the GIF ended; it now stays populated, and a
  2500ms hold ends with 68 on screen instead of 0.
- The opening frame rendered at opacity 0, throwing away the one frame where
  particles are still clustered. Removed the fade-in and made the tail
  frame-relative and quantized to bound its palette cost.
- Chips streak along their direction of travel in proportion to speed, faking
  the motion blur a GIF cannot carry. A second volley, sized to the
  celebration window, keeps long holds from thinning out.
- Each theme carries its own confetti palette. Slice colors are tuned to hold
  dark text over a large area, which left five of six mono colors under 2.4:1
  against the background and invisible at particle size.
- The winner's name appears over the hub during the hold. At a 200px chat
  display the pointer is ~17px and a slice label ~9px, so the poster frame did
  not say who won.
- Winner emphasis is an opaque stroke, not a drop-shadow. A filter on a Pie
  paints outside the shape, so later siblings overpainted it and only one
  neighbour ever showed the halo.

Motion:
- Turn count scales with the frame budget and slice width. A fixed 5 turns put
  peak speed at ~40deg per frame regardless of option count, so 65 of 98
  frames advanced more than a full slice at 32 options and the wheel appeared
  to run backwards. Turn counts floor rather than round, since rounding up
  reintroduces the aliasing, and stay integral so the winner still lands on
  the detent.
- The spin launch now blends into the deceleration at a matched slope. The
  previous curve started at maximum speed, and a naive ease-in jumped from 18
  to 41deg per frame in a single frame.
- A damped recoil rocks the wheel into its detent, resolving to exactly zero
  so the landing and the flapper's rest position are unaffected.
- The pointer is drawn as SVG with a real outline and a tick as slices pass.
  clip-path had been discarding its border and shadow entirely.

Legibility:
- Labels flip by their rest-frame angle so every name reads upright in the
  final frame. This reverses an earlier decision to leave them slice-aligned;
  three of eight names rendered mirrored on a default wheel.
- Label text renders inside a promoted compositing layer, which switches
  Chromium from LCD sub-pixel to grayscale antialiasing. Sub-pixel fringes put
  2178 colored pixels into an all-grayscale theme and consumed 255 of 256
  palette entries. Promoting the one rotating container rather than each label
  keeps render time flat on dense wheels.
- Line boxes leave room for stacked Vietnamese diacritics and shrink to fit
  rather than clipping.
- Slice colors are ordered for colorblind separation, and the last slice
  avoids reusing the first slice's color when the option count leaves a
  remainder of one.
This commit is contained in:
tiennm99 committed 2026-07-25 15:18:04 +07:00
1 parent ad3e50b7f4
commit f9accfd70a
10 files changed
+1017 -114

No files matched your search

+162 -24
View File
@@ -77,7 +77,44 @@ describe('confetti layout', () => {
expect(large.width).toBeCloseTo(small.width * 4);
});
test('reference size keeps gravity at the tuned constant', () => {
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('drag gives a terminal fall speed instead of unbounded acceleration', () => {
const [particle] = createConfettiParticles({
colors: ['#000'],
count: 1,
@@ -85,32 +122,133 @@ describe('confetti layout', () => {
size: CONFETTI_REFERENCE_SIZE,
});
expect(particle?.gravity).toBe(1500);
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 starts at its origin and accelerates downward', () => {
const particle = {
color: '#000',
gravity: 1500,
height: 8,
originX: 100,
originY: 200,
rotationSpeed: 90,
startRotation: 10,
vx: 50,
vy: -300,
width: 8,
};
expect(getConfettiParticleState(particle, 0)).toEqual({
rotation: 10,
x: 100,
y: 200,
test('particle sits at its muzzle until its launch delay elapses', () => {
const [particle] = createConfettiParticles({
colors: ['#000'],
count: 4,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
const later = getConfettiParticleState(particle, 1);
expect(later.x).toBe(150);
expect(later.y).toBe(650); // 200 - 300 + 0.5 * 1500
expect(later.rotation).toBe(100);
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);
}
});
});
+77 -2
View File
@@ -5,7 +5,9 @@ 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';
@@ -50,7 +52,7 @@ describe('wheel label layout', () => {
expect(layout.rotation).toBe(centerDegrees);
});
test('keeps left-side text aligned with the slice instead of flipping it upright', () => {
test('flips labels that would rest mirrored so the poster frame reads upright', () => {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
@@ -58,9 +60,82 @@ describe('wheel label layout', () => {
centerDegrees: 180,
optionCount: 8,
text: 'left side',
winnerCenterDegrees: 0,
});
expect(layout.rotation).toBe(180);
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);
}
});
test('keeps short labels on one line at the base size', () => {
+175 -14
View File
@@ -2,11 +2,13 @@ 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', () => {
@@ -45,26 +47,185 @@ describe('wheel layout', () => {
expect(getRenderedStartDegrees(270)).toBe(270);
});
test('eases spin progress once across the spin duration', () => {
test('starts from rest, rises monotonically, and ends exactly on target', () => {
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;
}
});
test('maps eased spin progress to wheel rotation', () => {
const rotation = getWheelRotationDegrees({
finalRotationDegrees: 696,
frame: 25,
spinFrames: 100,
startRotationDegrees: -24,
});
/**
* 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 = [];
expect(rotation).toBeCloseTo(392.25);
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);
}
}
});
test('defines a right-side pointer that points into the wheel', () => {
expect(rightPointerClipPath).toBe('polygon(0 50%, 100% 0, 100% 100%)');
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);
});
});
+37 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, test} from 'vitest';
import {themes} from '../src/remotion/themes.js';
import {getSliceColorIndex, themes} from '../src/remotion/themes.js';
/**
* @param {string} color
@@ -31,4 +31,40 @@ 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 pill over its own background', (_name, theme) => {
expect(getContrastRatio(theme.winnerPillText, theme.winnerPillBg)).toBeGreaterThanOrEqual(4.5);
});
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]);
}
}
});
});