mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-10-04 16:13:51 +00:00
feat: initialize wheelofnames API
This commit is contained in:
1 parent
194caf17eb
commit
3f5ed700bb
41 files changed
+5031
No files matched your search
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.git/
|
||||
.tmp/
|
||||
coverage/
|
||||
fixtures/*.gif
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
Vendored
+37
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.tmp/
|
||||
fixtures/*.gif
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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}'
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+3438
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import {registerRoot} from 'remotion';
|
||||
import {RemotionRoot} from './Root.jsx';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @param {import('fastify').FastifyInstance} app
|
||||
*/
|
||||
export const registerHealthRoute = async (app) => {
|
||||
app.get('/api/healthz', async () => ({
|
||||
ok: true,
|
||||
service: 'wheelofnames',
|
||||
}));
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.js'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user