mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 06:18:06 +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,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});
|
||||
}
|
||||
Reference in new issue
Block a user