From 376a7ae68811c7678b90c22bb3eabf932b754964 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 24 Jul 2026 23:34:13 +0700 Subject: [PATCH] feat(renderer): add winner-reveal confetti burst Deterministic frame-based confetti fired from the wheel center during the winner hold, seeded by winner index and colored from the theme palette. Uses Remotion's random() so each GIF frame renders identically. --- renderer/src/remotion/Confetti.jsx | 64 ++++++++++++ renderer/src/remotion/WheelComposition.jsx | 3 + renderer/src/remotion/confetti-layout.js | 85 +++++++++++++++ renderer/test/confetti-layout.test.js | 116 +++++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 renderer/src/remotion/Confetti.jsx create mode 100644 renderer/src/remotion/confetti-layout.js create mode 100644 renderer/test/confetti-layout.test.js diff --git a/renderer/src/remotion/Confetti.jsx b/renderer/src/remotion/Confetti.jsx new file mode 100644 index 0000000..1017fc9 --- /dev/null +++ b/renderer/src/remotion/Confetti.jsx @@ -0,0 +1,64 @@ +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 paper-strip burst from the wheel center 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 slice 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; + const progress = localFrame / lifetimeFrames; + const opacity = interpolate(progress, [0, 0.06, 0.7, 1], [0, 1, 1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + const particles = createConfettiParticles({ + colors, + count, + random: (key) => random(`${seed}-${key}`), + size, + }); + + return ( + + {particles.map((particle, index) => { + const state = getConfettiParticleState(particle, seconds); + + return ( +
+ ); + })} + + ); +}; diff --git a/renderer/src/remotion/WheelComposition.jsx b/renderer/src/remotion/WheelComposition.jsx index 58fd3fa..3550e87 100644 --- a/renderer/src/remotion/WheelComposition.jsx +++ b/renderer/src/remotion/WheelComposition.jsx @@ -11,6 +11,7 @@ import { } from './wheel-layout.js'; import {getRadialLabelLayout} from './wheel-label-layout.js'; import {getTheme} from './themes.js'; +import {Confetti} from './Confetti.jsx'; const baseFont = 'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; @@ -181,6 +182,8 @@ export const WheelComposition = (props) => { }} />
+ +
); }; diff --git a/renderer/src/remotion/confetti-layout.js b/renderer/src/remotion/confetti-layout.js new file mode 100644 index 0000000..dbf2565 --- /dev/null +++ b/renderer/src/remotion/confetti-layout.js @@ -0,0 +1,85 @@ +/** + * 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 + * ballistic path at an arbitrary time, keeping the visual identical on every + * render of the same seed. + */ + +/** Canvas size the physics constants below are tuned for. */ +export const CONFETTI_REFERENCE_SIZE = 512; + +/** + * @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); + +/** + * @typedef {object} ConfettiParticle + * @property {string} color + * @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} vx px/s + * @property {number} vy px/s (negative points up on screen) + * @property {number} width px + */ + +/** + * Builds the confetti particles bursting outward and upward from the wheel + * center. Physics scale with `size` relative to {@link CONFETTI_REFERENCE_SIZE}. + * + * @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 + * @returns {ConfettiParticle[]} + */ +export const createConfettiParticles = ({count, size, colors, random}) => { + const scale = size / CONFETTI_REFERENCE_SIZE; + const center = size / 2; + const palette = colors.length > 0 ? colors : ['#f97316']; + + return Array.from({length: count}, (_, index) => { + const rand = (/** @type {string} */ key) => random(`${index}-${key}`); + // Fan upward: -160deg..-20deg in screen coords (0deg right, -90deg up). + const angleRadians = (lerp(rand('angle'), -160, -20) * Math.PI) / 180; + const speed = lerp(rand('speed'), 320, 820) * scale; + const width = lerp(rand('width'), 6, 12) * scale; + + return { + color: palette[Math.floor(rand('color') * palette.length)] ?? palette[0] ?? '#f97316', + gravity: 1500 * scale, + height: width * lerp(rand('ratio'), 0.4, 1), + originX: center + lerp(rand('originX'), -0.06, 0.06) * size, + originY: center + lerp(rand('originY'), -0.04, 0.02) * size, + rotationSpeed: lerp(rand('spin'), -540, 540), + startRotation: lerp(rand('rotation'), 0, 360), + vx: Math.cos(angleRadians) * speed, + vy: Math.sin(angleRadians) * speed, + width, + }; + }); +}; + +/** + * Evaluates a particle's position and rotation at `seconds` after the burst. + * + * @param {ConfettiParticle} particle + * @param {number} seconds elapsed since the burst started + * @returns {{rotation: number, x: number, y: number}} + */ +export const getConfettiParticleState = (particle, seconds) => ({ + rotation: particle.startRotation + particle.rotationSpeed * seconds, + x: particle.originX + particle.vx * seconds, + y: particle.originY + particle.vy * seconds + 0.5 * particle.gravity * seconds * seconds, +}); diff --git a/renderer/test/confetti-layout.test.js b/renderer/test/confetti-layout.test.js new file mode 100644 index 0000000..b5ef192 --- /dev/null +++ b/renderer/test/confetti-layout.test.js @@ -0,0 +1,116 @@ +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); + }; +}; + +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('reference size keeps gravity at the tuned constant', () => { + const [particle] = createConfettiParticles({ + colors: ['#000'], + count: 1, + random: makeRandom(), + size: CONFETTI_REFERENCE_SIZE, + }); + + expect(particle?.gravity).toBe(1500); + }); + + 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, + }); + + 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); + }); +});