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

+7
View File
@@ -0,0 +1,7 @@
node_modules/
.git/
.tmp/
coverage/
fixtures/*.gif
.env
*.log
+10
View File
@@ -0,0 +1,10 @@
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
MAX_CONCURRENT_RENDERS=1
# Remotion browser timeout floor is 7000ms; lower values are raised to 7000.
RENDER_TIMEOUT_MS=15000
MAX_OPTIONS=32
MAX_OPTION_CHARS=40
# Required in production. Clients must send Authorization: Bearer <token>.
API_TOKEN=change-me
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11.9.0
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- name: Install Remotion browser runtime libraries
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libnspr4 \
libnss3 \
libgtk-3-0 \
libgbm1 \
libasound2t64 \
libxshmfence1 \
fonts-noto
- run: pnpm browser:ensure
- run: pnpm render:smoke
- run: pnpm api:smoke
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
.tmp/
fixtures/*.gif
*.log
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+27
View File
@@ -0,0 +1,27 @@
# AGENTS.md
## Project Context
`wheelofnames` is a Node.js 24 JavaScript + JSDoc API service that renders
animated wheel-of-names GIFs with Remotion.
## Development Rules
- Use JavaScript and JSDoc. Do not add TypeScript source files.
- Keep public API routes under `/api`.
- Run `pnpm lint`, `pnpm typecheck`, and `pnpm test` before committing code
changes.
- Run `pnpm render:smoke` when renderer, Remotion composition, Docker runtime,
or GIF output behavior changes.
- Run `pnpm api:smoke` when API render behavior, bundle warm-up, or production
render flow changes.
- Do not commit `.env`, generated GIFs, tokens, secrets, or temporary render
artifacts.
- Prefer Remotion libraries and CSS transforms over custom SVG path generation.
## Quality Gates
- API validation must reject oversized requests before render work starts.
- Rendering must not rebundle Remotion per request.
- Winner metadata must be returned with GIF responses.
- Keep Docker and README deployment docs in sync with runtime requirements.
+48
View File
@@ -0,0 +1,48 @@
FROM node:24-bookworm-slim AS base
ENV NODE_ENV=production
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
fonts-noto \
fonts-noto-color-emoji \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libexpat1 \
libgbm1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxrandr2 \
libxshmfence1 \
libxtst6 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY scripts/ensure-browser.js ./scripts/ensure-browser.js
RUN corepack enable \
&& corepack prepare pnpm@11.9.0 --activate \
&& pnpm install --prod --frozen-lockfile \
&& pnpm browser:ensure
COPY . .
EXPOSE 3000
CMD ["pnpm", "start"]
+71
View File
@@ -0,0 +1,71 @@
# wheelofnames
Self-hosted API that renders wheel-of-names GIF animations with Remotion.
## API
```http
POST /api/gif
Content-Type: application/json
Accept: image/gif
Authorization: Bearer change-me
```
```json
{
"options": ["alice", "bob", "carol"],
"winnerIndex": 1,
"durationMs": 6500,
"holdMs": 1200,
"fps": 15,
"size": 512,
"theme": "classic"
}
```
Response is `image/gif` with winner metadata headers:
- `X-Wheel-Winner-Index`
- `X-Wheel-Winner`
- `X-Render-Duration-Ms`
`X-Wheel-Winner` is URL-encoded so non-ASCII labels are safe in HTTP headers.
## Local
```sh
pnpm install
pnpm dev
```
Smoke render:
```sh
pnpm browser:ensure
pnpm render:smoke
pnpm api:smoke
```
Quality gates:
```sh
pnpm lint
pnpm typecheck
pnpm test
```
## Deploy
Use a container runtime first. Static-only platforms cannot satisfy
`POST /api/gif` because Remotion server rendering needs Node, Chromium/runtime
dependencies, and FFmpeg/compositor support.
```sh
docker build -t wheelofnames .
docker run --rm -p 3000:3000 -e API_TOKEN=change-me wheelofnames
```
Recommended starting resources: 1-2 vCPU and 1-2 GB RAM, with
`MAX_CONCURRENT_RENDERS=1`. `RENDER_TIMEOUT_MS` defaults to `15000` and is
raised to Remotion's `7000ms` browser timeout floor when configured lower.
`API_TOKEN` is required when `NODE_ENV=production`.
+18
View File
@@ -0,0 +1,18 @@
services:
wheelofnames:
build: .
environment:
HOST: 0.0.0.0
PORT: 3000
API_TOKEN: change-me
MAX_CONCURRENT_RENDERS: 1
RENDER_TIMEOUT_MS: 15000
MAX_OPTIONS: 32
MAX_OPTION_CHARS: 40
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
+64
View File
@@ -0,0 +1,64 @@
# Deployment
## Recommendation
Use a self-hosted/container runtime for v1. Static-only hosts are not enough
because `/api/gif` must render GIF bytes on the server with Remotion.
Good first targets:
- Coolify Docker app
- Fly.io
- Railway
- Render
- VPS with Docker
- Google Cloud Run generic container
Avoid for v1:
- Cloudflare Workers/Pages
- pure static Vercel/Netlify deploys
## Runtime
Required env vars:
```sh
PORT=3000
HOST=0.0.0.0
MAX_CONCURRENT_RENDERS=1
RENDER_TIMEOUT_MS=15000
MAX_OPTIONS=32
MAX_OPTION_CHARS=40
API_TOKEN=change-me
```
Start with 1-2 vCPU and 1-2 GB RAM. Increase only after render benchmarks show
the service is CPU-bound or concurrency-limited.
Local non-Docker render smoke requires Chrome Headless Shell shared libraries,
including `libnspr4` and `libnss3`. Prefer Docker for consistent verification.
The Docker image runs `pnpm browser:ensure` during build so production requests
do not need to download Chrome Headless Shell on first render.
`RENDER_TIMEOUT_MS` is a total render timeout. Values below `7000` are raised to
`7000` because Remotion's browser timeout has that minimum.
Set `API_TOKEN` for every production deployment. The server refuses to start
with `NODE_ENV=production` unless the token is configured.
## Health
```sh
curl http://localhost:3000/api/healthz
```
## Render Test
```sh
curl -X POST http://localhost:3000/api/gif \
-H 'content-type: application/json' \
-H 'authorization: Bearer change-me' \
--output wheel.gif \
--data '{"options":["alice","bob","carol"],"winnerIndex":1}'
```
+39
View File
@@ -0,0 +1,39 @@
# miti99bot Integration
Future bot integration should keep winner handling explicit.
## Recommended Flow
1. Bot parses `/wheelofnamesbeta` options.
2. Bot chooses `winnerIndex` locally.
3. Bot calls `POST /api/gif`.
4. Bot sends response bytes with Telegram `sendAnimation`.
5. Bot caption uses local winner as source of truth.
6. If the service fails, bot falls back to existing local GIF renderer.
## Request
Send `Authorization: Bearer <API_TOKEN>` with the JSON body.
```json
{
"options": ["alice", "bob", "carol"],
"winnerIndex": 1,
"durationMs": 6500,
"holdMs": 1200,
"fps": 15,
"size": 512,
"theme": "classic"
}
```
## Response
- Body: `image/gif`
- Headers:
- `X-Wheel-Winner-Index`
- `X-Wheel-Winner`
- `X-Render-Duration-Ms`
`X-Wheel-Winner` is URL-encoded. Decode it before displaying if service-side
winner selection is used.
+14
View File
@@ -0,0 +1,14 @@
# Render Benchmarks
Fill this after running:
```sh
pnpm render:fixtures
```
| Fixture | Size | FPS | Duration | Bytes | Render Time | Notes |
|---|---:|---:|---:|---:|---:|---|
| smoke | 384 | 12 | 3.5s | 315661 | 13917ms | Docker `pnpm render:smoke` on 2026-07-06 |
| api-smoke | 384 | 12 | 3.5s | 298374 | 4100ms | Docker `pnpm api:smoke`, after startup bundle warm-up |
| vietnamese | 512 | 15 | 7.7s | TBD | TBD | label readability |
| sixteen-options | 512 | 15 | 7.7s | TBD | TBD | dense wheel |
+38
View File
@@ -0,0 +1,38 @@
import js from '@eslint/js';
import globals from 'globals';
export default [
{
ignores: ['node_modules/**', 'dist/**', 'coverage/**', '.tmp/**', 'fixtures/**'],
},
js.configs.recommended,
{
files: ['**/*.js', '**/*.jsx'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: {
...globals.node,
...globals.browser,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'no-unused-vars': ['error', {argsIgnorePattern: '^_'}],
},
},
{
files: ['test/**/*.js'],
languageOptions: {
globals: {
...globals.node,
...globals.browser,
...globals.vitest,
},
},
},
];
+41
View File
@@ -0,0 +1,41 @@
{
"name": "wheelofnames",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24 <25"
},
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"browser:ensure": "node scripts/ensure-browser.js",
"api:smoke": "node scripts/api-smoke.js",
"render:smoke": "node scripts/render-fixtures.js --smoke",
"render:fixtures": "node scripts/render-fixtures.js"
},
"dependencies": {
"@remotion/bundler": "4.0.485",
"@remotion/renderer": "4.0.485",
"@remotion/shapes": "4.0.485",
"fastify": "5.10.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"remotion": "4.0.485",
"zod": "4.4.3"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@types/node": "26.1.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"eslint": "10.6.0",
"globals": "17.7.0",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vitest": "4.1.10"
}
}
+3438
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- '@remotion/bundler@4.0.485'
- '@remotion/canvas-capture@4.0.485'
- '@remotion/compositor-darwin-arm64@4.0.485'
- '@remotion/compositor-darwin-x64@4.0.485'
- '@remotion/compositor-linux-arm64-gnu@4.0.485'
- '@remotion/compositor-linux-arm64-musl@4.0.485'
- '@remotion/compositor-linux-x64-gnu@4.0.485'
- '@remotion/compositor-linux-x64-musl@4.0.485'
- '@remotion/compositor-win32-x64-msvc@4.0.485'
- '@remotion/licensing@4.0.485'
- '@remotion/media-parser@4.0.485'
- '@remotion/media-utils@4.0.485'
- '@remotion/paths@4.0.485'
- '@remotion/player@4.0.485'
- '@remotion/renderer@4.0.485'
- '@remotion/shapes@4.0.485'
- '@remotion/streaming@4.0.485'
- '@remotion/studio-shared@4.0.485'
- '@remotion/studio@4.0.485'
- '@remotion/timeline-utils@4.0.485'
- '@remotion/web-renderer@4.0.485'
- '@remotion/zod-types@4.0.485'
- '@vitest/expect@4.1.10'
- '@vitest/mocker@4.1.10'
- '@vitest/pretty-format@4.1.10'
- '@vitest/runner@4.1.10'
- '@vitest/snapshot@4.1.10'
- '@vitest/spy@4.1.10'
- '@vitest/utils@4.1.10'
- remotion@4.0.485
- vitest@4.1.10
+50
View File
@@ -0,0 +1,50 @@
import {buildServer} from '../src/server.js';
const config = {
apiToken: 'smoke-token',
host: '127.0.0.1',
maxConcurrentRenders: 1,
maxOptionChars: 40,
maxOptions: 32,
port: 0,
requiresApiToken: true,
renderTimeoutMs: 30000,
};
const app = await buildServer({config, warmRemotionBundle: true});
try {
const response = await app.inject({
method: 'POST',
url: '/api/gif',
headers: {
authorization: 'Bearer smoke-token',
},
payload: {
durationMs: 3000,
fps: 12,
holdMs: 500,
options: ['alice', 'bob', 'carol', 'dave'],
size: 384,
theme: 'classic',
winnerIndex: 2,
},
});
if (response.statusCode !== 200) {
throw new Error(`Expected 200, got ${response.statusCode}: ${response.body}`);
}
const magic = response.rawPayload.subarray(0, 6).toString();
if (magic !== 'GIF89a') {
throw new Error(`Expected GIF89a header, got ${magic}`);
}
if (response.headers['x-wheel-winner-index'] !== '2') {
throw new Error(`Expected winner index 2, got ${response.headers['x-wheel-winner-index']}`);
}
console.log(`api-smoke: ${response.rawPayload.byteLength} bytes`);
} finally {
await app.close();
}
+9
View File
@@ -0,0 +1,9 @@
import {ensureBrowser} from '@remotion/renderer';
const status = await ensureBrowser({logLevel: 'warn'});
if (status.type === 'no-browser' || status.type === 'version-mismatch') {
throw new Error(`Unable to prepare Remotion browser, status: ${status.type}`);
}
console.log(`browser: ${status.type}`);
+55
View File
@@ -0,0 +1,55 @@
import {mkdir, writeFile} from 'node:fs/promises';
import path from 'node:path';
import {renderWheelGif} from '../src/render/render-gif.js';
const smoke = process.argv.includes('--smoke');
const fixturesDir = path.join(process.cwd(), 'fixtures');
/** @type {{name: string, request: import('../src/schemas/wheel-request.js').WheelRenderRequest}[]} */
const fixtures = [
{
name: 'smoke',
request: {
options: ['alpha', 'beta', 'gamma', 'delta'],
winnerIndex: 1,
durationMs: smoke ? 3000 : 6500,
holdMs: smoke ? 500 : 1200,
fps: 12,
size: smoke ? 384 : 512,
theme: 'classic',
},
},
{
name: 'vietnamese',
request: {
options: ['khong dau', 'co dau', 'banh mi', 'ca phe sua da', 'pho bo', 'goi cuon', 'ha noi', 'sai gon'],
winnerIndex: 3,
durationMs: 6500,
holdMs: 1200,
fps: 15,
size: 512,
theme: 'festival',
},
},
{
name: 'sixteen-options',
request: {
options: Array.from({length: 16}, (_, index) => `option ${index + 1}`),
winnerIndex: 9,
durationMs: 6500,
holdMs: 1200,
fps: 15,
size: 512,
theme: 'classic',
},
},
];
await mkdir(fixturesDir, {recursive: true});
for (const fixture of smoke ? fixtures.slice(0, 1) : fixtures) {
const result = await renderWheelGif(fixture.request, {timeoutInMilliseconds: 30000});
const output = path.join(fixturesDir, `${fixture.name}.gif`);
await writeFile(output, result.buffer);
console.log(`${fixture.name}: ${output} ${result.byteLength} bytes ${result.durationMs}ms`);
}
+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});
}
+21
View File
@@ -0,0 +1,21 @@
import {describe, expect, test} from 'vitest';
import {loadConfig, minRenderTimeoutMs} from '../src/config.js';
describe('loadConfig', () => {
test('keeps render timeout compatible with Remotion browser timeout limits', () => {
const config = loadConfig({RENDER_TIMEOUT_MS: '500'});
expect(config.renderTimeoutMs).toBe(minRenderTimeoutMs);
});
test('requires API_TOKEN in production', () => {
expect(() => loadConfig({NODE_ENV: 'production'})).toThrow(/API_TOKEN/);
});
test('accepts API_TOKEN in production', () => {
const config = loadConfig({NODE_ENV: 'production', API_TOKEN: 'secret'});
expect(config.requiresApiToken).toBe(true);
expect(config.apiToken).toBe('secret');
});
});
+114
View File
@@ -0,0 +1,114 @@
import {describe, expect, test} from 'vitest';
import {RenderTimeoutError} from '../src/lib/render-errors.js';
import {buildServer} from '../src/server.js';
const config = {
apiToken: undefined,
host: '127.0.0.1',
maxConcurrentRenders: 1,
maxOptionChars: 40,
maxOptions: 32,
port: 0,
requiresApiToken: false,
renderTimeoutMs: 15000,
};
describe('POST /api/gif', () => {
test('returns gif bytes and winner metadata', async () => {
const app = await buildServer({
config,
renderGif: async () => ({
buffer: Buffer.from('GIF89a-test'),
byteLength: 11,
durationMs: 12,
}),
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['alpha', 'beta', 'gamma'],
winnerIndex: 1,
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('image/gif');
expect(response.headers['x-wheel-winner-index']).toBe('1');
expect(response.headers['x-wheel-winner']).toBe('beta');
expect(response.rawPayload.subarray(0, 6).toString()).toBe('GIF89a');
await app.close();
});
test('rejects invalid payload', async () => {
const app = await buildServer({
config,
renderGif: async () => ({
buffer: Buffer.from('GIF89a-test'),
byteLength: 11,
durationMs: 12,
}),
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['only one'],
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe('invalid_request');
await app.close();
});
test('enforces bearer token when configured', async () => {
const app = await buildServer({
config: {...config, apiToken: 'secret'},
renderGif: async () => ({
buffer: Buffer.from('GIF89a-test'),
byteLength: 11,
durationMs: 12,
}),
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['a', 'b'],
},
});
expect(response.statusCode).toBe(401);
await app.close();
});
test('returns timeout response when rendering exceeds configured limit', async () => {
const app = await buildServer({
config: {...config, renderTimeoutMs: 7},
renderGif: async (_request, renderOptions) => {
expect(renderOptions.timeoutInMilliseconds).toBe(7);
throw new RenderTimeoutError(renderOptions.timeoutInMilliseconds);
},
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['alpha', 'beta'],
},
});
expect(response.statusCode).toBe(504);
expect(response.json().error).toBe('render_timeout');
await app.close();
});
});
+27
View File
@@ -0,0 +1,27 @@
import {describe, expect, test} from 'vitest';
import {
getFinalWheelRotationDegrees,
getSliceCenterDegrees,
getSliceDegrees,
} from '../src/remotion/wheel-layout.js';
describe('wheel layout', () => {
test('calculates equal slice sizes', () => {
expect(getSliceDegrees(8)).toBe(45);
});
test('calculates center angle from 3h clockwise', () => {
expect(getSliceCenterDegrees(4, 0)).toBe(45);
expect(getSliceCenterDegrees(4, 1)).toBe(135);
});
test('final rotation places winner center at 3h', () => {
const optionCount = 8;
const winnerIndex = 3;
const rotation = getFinalWheelRotationDegrees(optionCount, winnerIndex, 7);
const winnerCenter = getSliceCenterDegrees(optionCount, winnerIndex);
const screenAngle = ((winnerCenter + rotation) % 360 + 360) % 360;
expect(screenAngle).toBe(0);
});
});
+60
View File
@@ -0,0 +1,60 @@
import {describe, expect, test} from 'vitest';
import {parseWheelRequest} from '../src/schemas/wheel-request.js';
const limits = {
maxOptionChars: 12,
maxOptions: 4,
};
describe('parseWheelRequest', () => {
test('trims options and preserves provided winner', () => {
const request = parseWheelRequest(
{
options: [' alpha ', ' beta '],
winnerIndex: 1,
},
limits,
() => 0,
);
expect(request.options).toEqual(['alpha', 'beta']);
expect(request.winnerIndex).toBe(1);
expect(request.durationMs).toBe(6500);
expect(request.fps).toBe(15);
});
test('rejects blank options so winner index cannot shift after trimming', () => {
expect(() =>
parseWheelRequest(
{
options: ['', 'alpha', 'beta'],
winnerIndex: 1,
},
limits,
() => 0,
),
).toThrow();
});
test('selects winner when omitted', () => {
const request = parseWheelRequest({options: ['a', 'b']}, limits, () => 1);
expect(request.winnerIndex).toBe(1);
});
test('rejects winner outside option range', () => {
expect(() =>
parseWheelRequest(
{
options: ['a', 'b'],
winnerIndex: 2,
},
limits,
() => 0,
),
).toThrow(/winnerIndex/);
});
test('rejects too many options', () => {
expect(() => parseWheelRequest({options: ['a', 'b', 'c', 'd', 'e']}, limits, () => 0)).toThrow();
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"target": "ES2024",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.js", "src/**/*.jsx", "scripts/**/*.js", "test/**/*.js", "eslint.config.js", "vitest.config.js"],
"exclude": ["node_modules", "dist", "coverage", ".tmp"]
}
+8
View File
@@ -0,0 +1,8 @@
import {defineConfig} from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.js'],
},
});