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

+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});