feat: initialize wheelofnames API

This commit is contained in:
tiennm99 committed 2026-07-06 22:20:48 +07:00
1 parent 194caf17eb
commit 3f5ed700bb
41 files changed
+5031

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
export const minRenderTimeoutMs = 7000;
/**
* @typedef {object} AppConfig
* @property {string} host
* @property {number} port
* @property {number} maxConcurrentRenders
* @property {number} renderTimeoutMs
* @property {number} maxOptions
* @property {number} maxOptionChars
* @property {string | undefined} apiToken
* @property {boolean} requiresApiToken
*/
/**
* @param {string | undefined} value
* @param {number} fallback
* @returns {number}
*/
const parsePositiveInt = (value, fallback) => {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
/**
* @param {NodeJS.ProcessEnv} [env]
* @returns {AppConfig}
*/
export const loadConfig = (env = process.env) => {
const apiToken = env.API_TOKEN || undefined;
const requiresApiToken = env.NODE_ENV === 'production' || env.REQUIRE_API_TOKEN === 'true';
if (requiresApiToken && !apiToken) {
throw new Error('API_TOKEN is required when NODE_ENV=production or REQUIRE_API_TOKEN=true');
}
return {
host: env.HOST || '0.0.0.0',
port: parsePositiveInt(env.PORT, 3000),
maxConcurrentRenders: parsePositiveInt(env.MAX_CONCURRENT_RENDERS, 1),
renderTimeoutMs: Math.max(
minRenderTimeoutMs,
parsePositiveInt(env.RENDER_TIMEOUT_MS, 15000),
),
maxOptions: parsePositiveInt(env.MAX_OPTIONS, 32),
maxOptionChars: parsePositiveInt(env.MAX_OPTION_CHARS, 40),
apiToken,
requiresApiToken,
};
};
+17
View File
@@ -0,0 +1,17 @@
export class RenderTimeoutError extends Error {
/**
* @param {number} timeoutMs
* @param {ErrorOptions} [options]
*/
constructor(timeoutMs, options) {
super(`GIF render exceeded ${timeoutMs}ms`, options);
this.name = 'RenderTimeoutError';
this.timeoutMs = timeoutMs;
}
}
/**
* @param {unknown} error
* @returns {error is RenderTimeoutError}
*/
export const isRenderTimeoutError = (error) => error instanceof RenderTimeoutError;
+31
View File
@@ -0,0 +1,31 @@
/**
* @param {number} max
*/
export const createRenderSemaphore = (max) => {
let active = 0;
return {
/**
* @returns {boolean}
*/
tryAcquire() {
if (active >= max) {
return false;
}
active += 1;
return true;
},
release() {
active = Math.max(0, active - 1);
},
/**
* @returns {{active: number, max: number}}
*/
state() {
return {active, max};
},
};
};
+16
View File
@@ -0,0 +1,16 @@
import {mkdtemp, rm} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
/**
* @returns {Promise<string>}
*/
export const createRenderTempDir = () => mkdtemp(path.join(os.tmpdir(), 'wheelofnames-'));
/**
* @param {string} dir
* @returns {Promise<void>}
*/
export const cleanupTempDir = async (dir) => {
await rm(dir, {force: true, recursive: true});
};
+13
View File
@@ -0,0 +1,13 @@
import {randomInt} from 'node:crypto';
/**
* @param {number} optionCount
* @returns {number}
*/
export const pickWinnerIndex = (optionCount) => {
if (!Number.isInteger(optionCount) || optionCount < 1) {
throw new RangeError('optionCount must be a positive integer');
}
return randomInt(optionCount);
};
+43
View File
@@ -0,0 +1,43 @@
import {Composition} from 'remotion';
import {WheelComposition} from './WheelComposition.jsx';
/** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */
const defaultProps = {
options: ['alice', 'bob', 'carol', 'dave', 'erin', 'frank', 'grace', 'heidi'],
winnerIndex: 2,
durationMs: 6500,
holdMs: 1200,
fps: 15,
size: 512,
theme: 'classic',
};
/**
* @param {{durationMs: number, holdMs: number, fps: number, size: number}} props
*/
export const getCompositionMetadata = (props) => {
const fps = props.fps || defaultProps.fps;
const totalMs = (props.durationMs || defaultProps.durationMs) + (props.holdMs || defaultProps.holdMs);
const durationInFrames = Math.max(1, Math.ceil((totalMs / 1000) * fps));
const size = props.size || defaultProps.size;
return {
durationInFrames,
fps,
height: size,
width: size,
};
};
export const RemotionRoot = () => (
<Composition
calculateMetadata={({props}) => getCompositionMetadata(props)}
component={WheelComposition}
defaultProps={defaultProps}
durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
fps={defaultProps.fps}
height={defaultProps.size}
id="WheelGif"
width={defaultProps.size}
/>
);
+185
View File
@@ -0,0 +1,185 @@
import React from 'react';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {Circle, Pie} from '@remotion/shapes';
import {
cssDegreesToPieRadians,
getFinalWheelRotationDegrees,
getReadableTextRotationDegrees,
getSliceCenterDegrees,
getSliceDegrees,
} from './wheel-layout.js';
import {getTheme} from './themes.js';
const baseFont =
'Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
/**
* @typedef {import('../schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
*/
/**
* @param {{text: string, maxLength?: number}} props
*/
const LabelText = ({text, maxLength = 24}) => {
const displayText = text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
return <>{displayText}</>;
};
/**
* @param {WheelRenderRequest} props
*/
export const WheelComposition = (props) => {
const frame = useCurrentFrame();
const {durationInFrames, fps, width, height} = useVideoConfig();
const theme = getTheme(props.theme);
const spinFrames = Math.max(1, durationInFrames - Math.round((props.holdMs / 1000) * fps));
const holdStartFrame = spinFrames;
const progress = interpolate(frame, [0, spinFrames], [0, 1], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const settle = interpolate(frame, [holdStartFrame, durationInFrames - 1], [0, 1], {
easing: Easing.out(Easing.sin),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const winnerPulse = frame >= holdStartFrame ? Math.sin(settle * Math.PI) : 0;
const size = Math.min(width, height);
const center = size / 2;
const radius = size * 0.41;
const sliceDegrees = getSliceDegrees(props.options.length);
const finalRotation = getFinalWheelRotationDegrees(props.options.length, props.winnerIndex);
const startRotation = -24;
const rotation = interpolate(progress, [0, 1], [startRotation, finalRotation], {
easing: Easing.out(Easing.cubic),
});
const labelRadius = radius * (props.options.length > 20 ? 0.64 : 0.58);
const fontSize = Math.max(10, Math.min(24, radius / Math.max(5.8, props.options.length * 0.33)));
return (
<AbsoluteFill
style={{
alignItems: 'center',
background: theme.background,
display: 'flex',
fontFamily: baseFont,
justifyContent: 'center',
}}
>
<div
style={{
height: size,
position: 'relative',
width: size,
}}
>
<div
style={{
borderRadius: '50%',
boxShadow: theme.wheelShadow,
height: radius * 2,
left: center - radius,
position: 'absolute',
top: center - radius,
width: radius * 2,
}}
/>
<div
style={{
height: size,
left: 0,
position: 'absolute',
top: 0,
transform: `rotate(${rotation}deg)`,
transformOrigin: '50% 50%',
width: size,
}}
>
{props.options.map((option, index) => {
const start = index * sliceDegrees;
const centerDegrees = getSliceCenterDegrees(props.options.length, index);
const isWinner = index === props.winnerIndex;
const color = theme.slices[index % theme.slices.length] ?? theme.slices[0];
const x = center + Math.cos((centerDegrees * Math.PI) / 180) * labelRadius;
const y = center + Math.sin((centerDegrees * Math.PI) / 180) * labelRadius;
const textRotation = getReadableTextRotationDegrees(centerDegrees);
return (
<React.Fragment key={`${option}-${index}`}>
<Pie
fill={color}
progress={1 / props.options.length}
radius={radius}
rotation={cssDegreesToPieRadians(start)}
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,
}}
/>
<div
style={{
alignItems: 'center',
color: theme.text,
display: 'flex',
fontSize,
fontWeight: 400,
height: fontSize * 1.35,
justifyContent: 'center',
left: x,
letterSpacing: 0,
lineHeight: 1,
maxWidth: radius * 0.68,
overflow: 'hidden',
padding: '0 4px',
position: 'absolute',
textAlign: 'center',
textShadow: `0 1px 0 ${theme.textHalo}, 0 -1px 0 ${theme.textHalo}, 1px 0 0 ${theme.textHalo}, -1px 0 0 ${theme.textHalo}`,
top: y,
transform: `translate(-50%, -50%) rotate(${textRotation}deg)`,
transformOrigin: '50% 50%',
whiteSpace: 'nowrap',
}}
>
<LabelText text={option} />
</div>
</React.Fragment>
);
})}
</div>
<Circle
fill={theme.centerRing}
radius={size * 0.09}
style={{left: center - size * 0.09, position: 'absolute', top: center - size * 0.09}}
/>
<Circle
fill={theme.center}
radius={size * 0.06}
style={{left: center - size * 0.06, position: 'absolute', top: center - size * 0.06}}
/>
<div
style={{
background: theme.pointer,
border: `2px solid ${theme.pointerStroke}`,
boxShadow: '0 7px 16px rgba(15, 23, 42, 0.24)',
clipPath: 'polygon(100% 50%, 0 0, 0 100%)',
height: size * 0.085,
left: center + radius - size * 0.02,
position: 'absolute',
top: center - size * 0.0425,
width: size * 0.105,
zIndex: 4,
}}
/>
</div>
</AbsoluteFill>
);
};
+4
View File
@@ -0,0 +1,4 @@
import {registerRoot} from 'remotion';
import {RemotionRoot} from './Root.jsx';
registerRoot(RemotionRoot);
+43
View File
@@ -0,0 +1,43 @@
export const themes = {
classic: {
background: '#f8fafc',
wheelShadow: '0 18px 52px rgba(15, 23, 42, 0.22)',
center: '#111827',
centerRing: '#f9fafb',
pointer: '#ef4444',
pointerStroke: '#991b1b',
text: '#111827',
textHalo: 'rgba(255, 255, 255, 0.88)',
winnerGlow: 'rgba(250, 204, 21, 0.45)',
slices: ['#f97316', '#14b8a6', '#facc15', '#60a5fa', '#f472b6', '#a78bfa', '#34d399', '#fb7185'],
},
festival: {
background: '#fff7ed',
wheelShadow: '0 20px 56px rgba(124, 45, 18, 0.24)',
center: '#7f1d1d',
centerRing: '#fffbeb',
pointer: '#dc2626',
pointerStroke: '#7f1d1d',
text: '#1f2937',
textHalo: 'rgba(255, 251, 235, 0.92)',
winnerGlow: 'rgba(251, 146, 60, 0.5)',
slices: ['#f43f5e', '#f59e0b', '#84cc16', '#06b6d4', '#8b5cf6', '#ec4899', '#22c55e', '#fb923c'],
},
mono: {
background: '#f4f4f5',
wheelShadow: '0 18px 52px rgba(24, 24, 27, 0.18)',
center: '#18181b',
centerRing: '#fafafa',
pointer: '#27272a',
pointerStroke: '#09090b',
text: '#18181b',
textHalo: 'rgba(250, 250, 250, 0.9)',
winnerGlow: 'rgba(161, 161, 170, 0.45)',
slices: ['#e4e4e7', '#a1a1aa', '#d4d4d8', '#71717a', '#f4f4f5', '#52525b'],
},
};
/**
* @param {'classic' | 'festival' | 'mono'} name
*/
export const getTheme = (name) => themes[name] ?? themes.classic;
+45
View File
@@ -0,0 +1,45 @@
export const fullTurnDegrees = 360;
/**
* @param {number} optionCount
* @returns {number}
*/
export const getSliceDegrees = (optionCount) => fullTurnDegrees / optionCount;
/**
* Angles use CSS coordinates: 0deg points to 3h and positive values rotate
* clockwise on screen.
*
* @param {number} optionCount
* @param {number} index
* @returns {number}
*/
export const getSliceCenterDegrees = (optionCount, index) =>
index * getSliceDegrees(optionCount) + getSliceDegrees(optionCount) / 2;
/**
* @param {number} optionCount
* @param {number} winnerIndex
* @param {number} fullTurns
* @returns {number}
*/
export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns = 7) =>
fullTurns * fullTurnDegrees - getSliceCenterDegrees(optionCount, winnerIndex);
/**
* Converts CSS angle convention to @remotion/shapes Pie convention, where 0deg
* starts at 12h.
*
* @param {number} cssDegrees
* @returns {number}
*/
export const cssDegreesToPieRadians = (cssDegrees) => ((cssDegrees - 90) * Math.PI) / 180;
/**
* @param {number} degrees
* @returns {number}
*/
export const getReadableTextRotationDegrees = (degrees) => {
const normalized = ((degrees % fullTurnDegrees) + fullTurnDegrees) % fullTurnDegrees;
return normalized > 90 && normalized < 270 ? degrees + 180 : degrees;
};
+21
View File
@@ -0,0 +1,21 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {bundle} from '@remotion/bundler';
/** @type {Promise<string> | undefined} */
let bundlePromise;
/**
* @returns {Promise<string>}
*/
export const getRemotionServeUrl = () => {
if (!bundlePromise) {
const entryPoint = fileURLToPath(new URL('../remotion/index.jsx', import.meta.url));
bundlePromise = bundle({
entryPoint,
outDir: path.join(process.cwd(), '.tmp', 'remotion-bundle'),
});
}
return bundlePromise;
};
+79
View File
@@ -0,0 +1,79 @@
import {readFile} from 'node:fs/promises';
import path from 'node:path';
import {makeCancelSignal, renderMedia, selectComposition} from '@remotion/renderer';
import {minRenderTimeoutMs} from '../config.js';
import {RenderTimeoutError} from '../lib/render-errors.js';
import {cleanupTempDir, createRenderTempDir} from '../lib/tmp-files.js';
import {getRemotionServeUrl} from './remotion-bundle.js';
/**
* @typedef {import('../schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
*/
/**
* @typedef {object} RenderWheelGifOptions
* @property {number} timeoutInMilliseconds
*/
/**
* @typedef {object} RenderGifResult
* @property {Buffer} buffer
* @property {number} durationMs
* @property {number} byteLength
*/
/**
* @param {WheelRenderRequest} inputProps
* @param {RenderWheelGifOptions} options
* @returns {Promise<RenderGifResult>}
*/
export const renderWheelGif = async (inputProps, options) => {
const startedAt = Date.now();
const tempDir = await createRenderTempDir();
const outputLocation = path.join(tempDir, 'wheelofnames.gif');
const {cancel, cancelSignal} = makeCancelSignal();
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
cancel();
}, options.timeoutInMilliseconds);
const rendererTimeout = Math.max(minRenderTimeoutMs, options.timeoutInMilliseconds);
try {
const serveUrl = await getRemotionServeUrl();
const composition = await selectComposition({
id: 'WheelGif',
inputProps,
serveUrl,
timeoutInMilliseconds: rendererTimeout,
});
await renderMedia({
cancelSignal,
codec: 'gif',
composition,
imageFormat: 'png',
inputProps,
outputLocation,
overwrite: true,
serveUrl,
timeoutInMilliseconds: rendererTimeout,
});
const buffer = await readFile(outputLocation);
return {
buffer,
byteLength: buffer.byteLength,
durationMs: Date.now() - startedAt,
};
} catch (error) {
if (timedOut || Date.now() - startedAt >= options.timeoutInMilliseconds) {
throw new RenderTimeoutError(options.timeoutInMilliseconds, {cause: error});
}
throw error;
} finally {
clearTimeout(timeoutId);
await cleanupTempDir(tempDir);
}
};
+88
View File
@@ -0,0 +1,88 @@
import {ZodError} from 'zod';
import {isRenderTimeoutError} from '../lib/render-errors.js';
import {pickWinnerIndex} from '../lib/winner.js';
import {parseWheelRequest} from '../schemas/wheel-request.js';
/**
* @typedef {import('../config.js').AppConfig} AppConfig
* @typedef {import('../lib/render-semaphore.js').createRenderSemaphore} CreateRenderSemaphore
* @typedef {import('../schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
*/
/**
* @typedef {object} GifRouteDeps
* @property {AppConfig} config
* @property {{tryAcquire: () => boolean, release: () => void}} semaphore
* @property {(request: WheelRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} renderGif
*/
/**
* @param {unknown} issues
*/
const formatValidationError = (issues) => ({
error: 'invalid_request',
issues,
});
/**
* @param {import('fastify').FastifyInstance} app
* @param {GifRouteDeps} deps
*/
export const registerGifRoute = async (app, deps) => {
app.post('/api/gif', async (request, reply) => {
if (deps.config.apiToken) {
const expected = `Bearer ${deps.config.apiToken}`;
if (request.headers.authorization !== expected) {
return reply.code(401).send({error: 'unauthorized'});
}
}
let wheelRequest;
try {
wheelRequest = parseWheelRequest(
request.body,
{
maxOptionChars: deps.config.maxOptionChars,
maxOptions: deps.config.maxOptions,
},
pickWinnerIndex,
);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send(formatValidationError(error.issues));
}
throw error;
}
if (!deps.semaphore.tryAcquire()) {
return reply.code(429).send({error: 'too_many_renders'});
}
try {
const result = await deps.renderGif(wheelRequest, {
timeoutInMilliseconds: deps.config.renderTimeoutMs,
});
const winner = wheelRequest.options[wheelRequest.winnerIndex];
if (winner === undefined) {
throw new Error('winner index resolved outside options range');
}
return reply
.header('Content-Type', 'image/gif')
.header('Content-Disposition', 'inline; filename="wheelofnames.gif"')
.header('Cache-Control', 'no-store')
.header('X-Wheel-Winner-Index', String(wheelRequest.winnerIndex))
.header('X-Wheel-Winner', encodeURIComponent(winner))
.header('X-Render-Duration-Ms', String(result.durationMs))
.send(result.buffer);
} catch (error) {
if (isRenderTimeoutError(error)) {
return reply.code(504).send({error: 'render_timeout'});
}
throw error;
} finally {
deps.semaphore.release();
}
});
};
+9
View File
@@ -0,0 +1,9 @@
/**
* @param {import('fastify').FastifyInstance} app
*/
export const registerHealthRoute = async (app) => {
app.get('/api/healthz', async () => ({
ok: true,
service: 'wheelofnames',
}));
};
+71
View File
@@ -0,0 +1,71 @@
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')]);
/**
* @typedef {object} WheelRequestLimits
* @property {number} maxOptions
* @property {number} maxOptionChars
*/
/**
* @typedef {object} WheelRenderRequest
* @property {string[]} options
* @property {number} winnerIndex
* @property {number} durationMs
* @property {number} holdMs
* @property {12 | 15 | 20} fps
* @property {384 | 480 | 512} size
* @property {'classic' | 'festival' | 'mono'} theme
*/
/**
* @param {WheelRequestLimits} limits
*/
export const createWheelRequestSchema = (limits) =>
z
.object({
options: z
.array(z.string())
.transform((options) => options.map((option) => option.trim()))
.pipe(
z
.array(z.string().min(1).max(limits.maxOptionChars))
.min(2)
.max(limits.maxOptions),
),
winnerIndex: z.number().int().nonnegative().optional(),
durationMs: z.number().int().min(3000).max(10000).default(6500),
holdMs: z.number().int().min(500).max(2500).default(1200),
fps: z.union([z.literal(12), z.literal(15), z.literal(20)]).default(15),
size: z.union([z.literal(384), z.literal(480), z.literal(512)]).default(512),
theme: themeSchema.default('classic'),
})
.strict()
.superRefine((value, context) => {
if (value.winnerIndex !== undefined && value.winnerIndex >= value.options.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['winnerIndex'],
message: 'winnerIndex must be within options range',
});
}
});
/**
* @param {unknown} input
* @param {WheelRequestLimits} limits
* @param {(optionCount: number) => number} pickWinner
* @returns {WheelRenderRequest}
*/
export const parseWheelRequest = (input, limits, pickWinner) => {
const parsed = createWheelRequestSchema(limits).parse(input);
return {
...parsed,
winnerIndex: parsed.winnerIndex ?? pickWinner(parsed.options.length),
};
};
+52
View File
@@ -0,0 +1,52 @@
import Fastify from 'fastify';
import {loadConfig} from './config.js';
import {createRenderSemaphore} from './lib/render-semaphore.js';
import {getRemotionServeUrl} from './render/remotion-bundle.js';
import {renderWheelGif} from './render/render-gif.js';
import {registerGifRoute} from './routes/gif.js';
import {registerHealthRoute} from './routes/health.js';
/**
* @typedef {import('./config.js').AppConfig} AppConfig
* @typedef {import('./schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
*/
/**
* @param {object} [options]
* @param {AppConfig} [options.config]
* @param {(request: WheelRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGif]
* @param {boolean} [options.warmRemotionBundle]
*/
export const buildServer = async (options = {}) => {
const config = options.config ?? loadConfig();
const warmRemotionBundle = options.warmRemotionBundle ?? process.env.NODE_ENV !== 'test';
if (warmRemotionBundle) {
await getRemotionServeUrl();
}
const app = Fastify({
bodyLimit: 32 * 1024,
logger: process.env.NODE_ENV !== 'test',
});
app.setErrorHandler(async (error, _request, reply) => {
app.log.error(error);
return reply.code(500).send({error: 'internal_error'});
});
await registerHealthRoute(app);
await registerGifRoute(app, {
config,
renderGif: options.renderGif ?? renderWheelGif,
semaphore: createRenderSemaphore(config.maxConcurrentRenders),
});
return app;
};
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});
}