mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-13 22:16:33 +00:00
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.
255 lines
7.8 KiB
JavaScript
255 lines
7.8 KiB
JavaScript
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('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,
|
|
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);
|
|
}
|
|
});
|
|
});
|