From 97d8aeaadfda5bdc5b98dbf2b47a64fa2b6c8d18 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 11:43:42 +0700 Subject: [PATCH] feat(gacha): add /api/gacha wish animation rendered as MP4 A meteor coloured by rarity falls across a night sky, lands in a white flash, and the label is revealed with its stars popping in. The caller picks the result and rarity; the route only draws it. Renders as silent H.264 so Telegram plays it as an animation without GIF banding, and shares the render slots with /api/gif. --- renderer/.dockerignore | 1 + renderer/.gitignore | 1 + renderer/README.md | 42 +- renderer/docs/miti99bot-integration.md | 8 + renderer/scripts/api-smoke.js | 20 + renderer/scripts/render-fixtures.js | 27 +- renderer/src/remotion/GachaComposition.jsx | 448 +++++++++++++++++++++ renderer/src/remotion/Root.jsx | 57 ++- renderer/src/remotion/gacha-timeline.js | 153 +++++++ renderer/src/render/render-gacha.js | 23 ++ renderer/src/routes/gacha.js | 67 +++ renderer/src/schemas/gacha-request.js | 40 ++ renderer/src/server.js | 13 +- renderer/test/gacha-request.test.js | 32 ++ renderer/test/gacha-route.test.js | 135 +++++++ renderer/test/gacha-timeline.test.js | 64 +++ 16 files changed, 1111 insertions(+), 20 deletions(-) create mode 100644 renderer/src/remotion/GachaComposition.jsx create mode 100644 renderer/src/remotion/gacha-timeline.js create mode 100644 renderer/src/render/render-gacha.js create mode 100644 renderer/src/routes/gacha.js create mode 100644 renderer/src/schemas/gacha-request.js create mode 100644 renderer/test/gacha-request.test.js create mode 100644 renderer/test/gacha-route.test.js create mode 100644 renderer/test/gacha-timeline.test.js diff --git a/renderer/.dockerignore b/renderer/.dockerignore index 480061c..f9ca252 100644 --- a/renderer/.dockerignore +++ b/renderer/.dockerignore @@ -3,5 +3,6 @@ node_modules/ .tmp/ coverage/ fixtures/*.gif +fixtures/*.mp4 .env *.log diff --git a/renderer/.gitignore b/renderer/.gitignore index b279e90..ad78903 100644 --- a/renderer/.gitignore +++ b/renderer/.gitignore @@ -6,5 +6,6 @@ coverage/ !.env.example .tmp/ fixtures/*.gif +fixtures/*.mp4 /wheel.gif *.log diff --git a/renderer/README.md b/renderer/README.md index d361121..b4c3a1c 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -1,6 +1,7 @@ # wheelofnames -Self-hosted API that renders wheel-of-names GIF animations with Remotion. +Self-hosted API that renders wheel-of-names GIF animations and gacha wish +MP4 animations with Remotion. ## API @@ -31,6 +32,36 @@ Response is `image/gif` with winner metadata headers: `X-Wheel-Winner` is URL-encoded so non-ASCII labels are safe in HTTP headers. +### Gacha wish + +```http +POST /api/gacha +Content-Type: application/json +Accept: video/mp4 +Authorization: Bearer change-me +``` + +```json +{ + "label": "Pizza", + "rarity": 5, + "fps": 24, + "width": 640 +} +``` + +Renders a 7-second wish animation in the style of a gacha game: a meteor +coloured by rarity (blue 3★, purple 4★, gold 5★) falls across a night sky, +lands in a white flash, and the label is revealed with its stars popping in. +`rarity` is required; `fps` is `24` or `30`; `width` is `640` (360 tall) or +`854` (480 tall). The caller chooses the result and its rarity — the service +only draws it. + +Response is a silent H.264 `video/mp4` (Telegram plays it as an animation) +with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. Both routes share the +`MAX_CONCURRENT_RENDERS` slots. All visuals are drawn procedurally; no game +assets are used. + ## Local Install dependencies and Chromium once: @@ -48,15 +79,16 @@ npm run dev ### Generate GIF files locally -Generate the quick smoke fixture at the git-ignored path -`fixtures/smoke.gif`: +Generate the quick smoke fixtures at the git-ignored paths +`fixtures/smoke.gif` and `fixtures/gacha-5-star.mp4`: ```sh npm run render:smoke ``` Generate the complete fixture set at `fixtures/smoke.gif`, -`fixtures/vietnamese.gif`, and `fixtures/sixteen-options.gif`: +`fixtures/vietnamese.gif`, `fixtures/sixteen-options.gif`, and +`fixtures/gacha-{3,4,5}-star.mp4`: ```sh npm run render:fixtures @@ -86,7 +118,7 @@ npm run render:local -- \ `--winner` is a zero-based index and is random when omitted. Run `npm run render:local -- --help` for duration, hold, FPS, size, theme, and timeout -options. The documented root `wheel.gif` and `fixtures/*.gif` outputs are +options. The documented root `wheel.gif` and `fixtures/*.gif`/`*.mp4` outputs are git-ignored and safe to delete; custom output paths may need their own ignore rule. diff --git a/renderer/docs/miti99bot-integration.md b/renderer/docs/miti99bot-integration.md index a43a7c8..69ed73b 100644 --- a/renderer/docs/miti99bot-integration.md +++ b/renderer/docs/miti99bot-integration.md @@ -37,3 +37,11 @@ Send `Authorization: Bearer ` with the JSON body. `X-Wheel-Winner` is URL-encoded. Decode it before displaying if service-side winner selection is used. + +## Gacha + +`/gacha` in miti99bot calls `POST /api/gacha` on the same service and token. +The bot derives the endpoint from `WHEELOFNAMES_API_URL` by replacing its last +path segment (`/api/gif` becomes `/api/gacha`), picks the result and rarity +itself, and sends the MP4 with `sendAnimation`. On any failure it falls back to +a text reply. diff --git a/renderer/scripts/api-smoke.js b/renderer/scripts/api-smoke.js index 43d49e8..d00c7ca 100644 --- a/renderer/scripts/api-smoke.js +++ b/renderer/scripts/api-smoke.js @@ -45,6 +45,26 @@ try { } console.log(`api-smoke: ${response.rawPayload.byteLength} bytes`); + + const gacha = await app.inject({ + method: 'POST', + url: '/api/gacha', + headers: { + authorization: 'Bearer smoke-token', + }, + payload: {label: 'Pizza', rarity: 5}, + }); + + if (gacha.statusCode !== 200) { + throw new Error(`Expected gacha 200, got ${gacha.statusCode}: ${gacha.body}`); + } + + const box = gacha.rawPayload.subarray(4, 8).toString(); + if (box !== 'ftyp') { + throw new Error(`Expected MP4 ftyp box, got ${box}`); + } + + console.log(`api-smoke gacha: ${gacha.rawPayload.byteLength} bytes`); } finally { await app.close(); } diff --git a/renderer/scripts/render-fixtures.js b/renderer/scripts/render-fixtures.js index 2013ade..ec4fd88 100644 --- a/renderer/scripts/render-fixtures.js +++ b/renderer/scripts/render-fixtures.js @@ -1,5 +1,6 @@ import {mkdir, writeFile} from 'node:fs/promises'; import path from 'node:path'; +import {renderGachaVideo} from '../src/render/render-gacha.js'; import {renderWheelGif} from '../src/render/render-gif.js'; const smoke = process.argv.includes('--smoke'); @@ -47,9 +48,27 @@ const fixtures = [ 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`); +/** @type {{name: string, request: import('../src/schemas/gacha-request.js').GachaRenderRequest}[]} */ +const gachaFixtures = [ + {name: 'gacha-5-star', request: {label: 'Pizza', rarity: 5, fps: 24, width: 640}}, + {name: 'gacha-4-star', request: {label: 'Phở bò tái nạm gầu', rarity: 4, fps: 24, width: 640}}, + {name: 'gacha-3-star', request: {label: 'Cơm tấm', rarity: 3, fps: 24, width: 854}}, +]; + +/** + * @param {string} name + * @param {{buffer: Buffer, byteLength: number, durationMs: number}} result + */ +const save = async (name, result) => { + const output = path.join(fixturesDir, name); await writeFile(output, result.buffer); - console.log(`${fixture.name}: ${output} ${result.byteLength} bytes ${result.durationMs}ms`); + console.log(`${name}: ${output} ${result.byteLength} bytes ${result.durationMs}ms`); +}; + +for (const fixture of smoke ? fixtures.slice(0, 1) : fixtures) { + await save(`${fixture.name}.gif`, await renderWheelGif(fixture.request, {timeoutInMilliseconds: 30000})); +} + +for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) { + await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 30000})); } diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx new file mode 100644 index 0000000..549d604 --- /dev/null +++ b/renderer/src/remotion/GachaComposition.jsx @@ -0,0 +1,448 @@ +import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + createRandom, + createStarfield, + gachaTimeline, + getEmblemGlyph, + getLabelFontSize, + getMeteorPoint, + getRarityPalette, + getStarRevealTimes, + starColor, +} from './gacha-timeline.js'; + +const baseFont = + 'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + +const skyStars = createStarfield(70, 7); +const trailSamples = 34; +const trailSpacing = 0.014; + +/** + * Sparks shed by the meteor. Each is born at a point along the fall and + * drifts away from it while fading. + */ +const meteorSparks = (() => { + const random = createRandom(11); + return Array.from({length: 40}, () => ({ + born: random(), + driftX: (random() - 0.5) * 0.08, + driftY: (random() - 0.2) * 0.08, + size: 0.006 + random() * 0.01, + })); +})(); + +/** Motes that float upward behind the revealed item. */ +const revealMotes = (() => { + const random = createRandom(23); + return Array.from({length: 36}, () => ({ + x: random(), + y: random(), + speed: 0.03 + random() * 0.06, + size: 0.004 + random() * 0.008, + phase: random() * Math.PI * 2, + })); +})(); + +/** + * @param {string} hex + * @param {number} alpha + */ +const withAlpha = (hex, alpha) => { + const value = Number.parseInt(hex.slice(1), 16); + return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`; +}; + +/** + * @param {{size: number}} props + */ +const StarIcon = ({size}) => ( + + + +); + +/** + * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest + */ + +/** + * Wish animation: a meteor coloured by rarity falls across a night sky, lands + * in a white flash, and the result is revealed with its stars popping in. + * + * @param {GachaRenderRequest} props + */ +export const GachaComposition = (props) => { + const frame = useCurrentFrame(); + const {fps, width, height} = useVideoConfig(); + const seconds = frame / fps; + const palette = getRarityPalette(props.rarity); + const tl = gachaTimeline; + + const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], { + ...clamp, + easing: Easing.in(Easing.quad), + }); + const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd; + const head = getMeteorPoint(meteorProgress, width, height); + const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp); + + const flashOpacity = interpolate(seconds, [tl.flashStart, tl.flashPeak, tl.flashHoldEnd, tl.flashEnd], [0, 1, 1, 0], { + ...clamp, + easing: Easing.inOut(Easing.quad), + }); + const impact = getMeteorPoint(1, width, height); + const bloomRadius = interpolate(seconds, [tl.flashStart, tl.flashPeak], [0.1, 2.2], clamp) * width; + + const revealed = seconds >= tl.revealStart; + const emblemSize = height * 0.52; + const emblemX = width * 0.66; + const emblemY = height * 0.47; + const emblemScale = interpolate(seconds, [tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled], [0.72, 1.06, 1], { + ...clamp, + easing: Easing.out(Easing.cubic), + }); + const silhouette = interpolate(seconds, [tl.revealStart + 0.2, tl.emblemSettled], [1, 0], clamp); + const ringProgress = interpolate(seconds, [tl.revealStart, tl.revealStart + 0.9], [0, 1], { + ...clamp, + easing: Easing.out(Easing.quad), + }); + const raysOpacity = interpolate( + seconds, + [tl.revealStart, tl.emblemSettled], + [0, props.rarity === 3 ? 0.22 : 0.4], + clamp, + ); + const emblemPulse = 0.5 + 0.5 * Math.sin(seconds * 3); + + const nameProgress = interpolate(seconds, [tl.nameIn, tl.nameSettled], [0, 1], { + ...clamp, + easing: Easing.out(Easing.cubic), + }); + const starSize = height * 0.09; + const starTimes = getStarRevealTimes(props.rarity); + + return ( + + {!revealed ? ( + + + {skyStars.map((star, index) => ( +
+ ))} + + {meteorVisible + ? Array.from({length: trailSamples}, (_, index) => { + const sampleProgress = meteorProgress - index * trailSpacing; + if (sampleProgress <= 0) { + return null; + } + const point = getMeteorPoint(sampleProgress, width, height); + const fade = 1 - index / trailSamples; + const radius = headRadius * (0.25 + 0.75 * fade ** 1.3); + return ( +
+ ); + }) + : null} + + {meteorVisible + ? meteorSparks.map((spark, index) => { + if (spark.born > meteorProgress) { + return null; + } + const age = (meteorProgress - spark.born) * 4; + if (age >= 1) { + return null; + } + const origin = getMeteorPoint(spark.born, width, height); + const size = spark.size * height * (1 - age); + return ( +
+ ); + }) + : null} + + {meteorVisible ? ( + <> +
+ {props.rarity === 5 ? ( + <> +
+
+ + ) : null} + + ) : null} + + ) : null} + + {revealed ? ( + +
+ + {revealMotes.map((mote, index) => { + const y = (((mote.y - (seconds - tl.revealStart) * mote.speed) % 1) + 1) % 1; + const size = mote.size * height; + return ( +
+ ); + })} + + {ringProgress < 1 ? ( +
+ ) : null} + +
+
+
+
+
+
+ {getEmblemGlyph(props.label)} +
+
+ +
+
+ {props.label} +
+
+ {starTimes.map((time, index) => { + const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.6, 1], clamp); + const flare = interpolate(seconds, [time, time + 0.3], [1, 0], clamp); + return ( +
= time ? 1 : 0, + position: 'relative', + transform: `scale(${pop})`, + width: starSize, + }} + > +
+ +
+ ); + })} +
+
+ + ) : null} + + {flashOpacity > 0 ? ( + +
+ + + ) : null} + + ); +}; diff --git a/renderer/src/remotion/Root.jsx b/renderer/src/remotion/Root.jsx index e54c90f..74ff31c 100644 --- a/renderer/src/remotion/Root.jsx +++ b/renderer/src/remotion/Root.jsx @@ -1,5 +1,8 @@ import {Composition} from 'remotion'; +import {GachaComposition} from './GachaComposition.jsx'; +import {gachaTotalSeconds} from './gacha-timeline.js'; import {WheelComposition} from './WheelComposition.jsx'; +import {gachaFrameSizes} from '../schemas/gacha-request.js'; /** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */ const defaultProps = { @@ -29,15 +32,49 @@ export const getCompositionMetadata = (props) => { }; }; +/** @type {import('../schemas/gacha-request.js').GachaRenderRequest} */ +const defaultGachaProps = { + label: 'Pizza', + rarity: 5, + fps: 24, + width: 640, +}; + +/** + * @param {{fps: number, width: number}} props + */ +export const getGachaCompositionMetadata = (props) => { + const fps = props.fps || defaultGachaProps.fps; + const width = props.width in gachaFrameSizes ? props.width : defaultGachaProps.width; + return { + durationInFrames: Math.ceil(gachaTotalSeconds * fps), + fps, + height: gachaFrameSizes[/** @type {640 | 854} */ (width)], + width, + }; +}; + export const RemotionRoot = () => ( - 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} - /> + <> + 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} + /> + getGachaCompositionMetadata(props)} + component={GachaComposition} + defaultProps={defaultGachaProps} + durationInFrames={Math.ceil(gachaTotalSeconds * defaultGachaProps.fps)} + fps={defaultGachaProps.fps} + height={gachaFrameSizes[defaultGachaProps.width]} + id="GachaWish" + width={defaultGachaProps.width} + /> + ); diff --git a/renderer/src/remotion/gacha-timeline.js b/renderer/src/remotion/gacha-timeline.js new file mode 100644 index 0000000..8a28848 --- /dev/null +++ b/renderer/src/remotion/gacha-timeline.js @@ -0,0 +1,153 @@ +/** + * Timing, palette, and geometry for the gacha wish animation. Kept free of + * React so the choreography can be unit tested. + */ + +/** @typedef {3 | 4 | 5} GachaRarity */ + +/** + * @typedef {object} RarityPalette + * @property {string} core Meteor head and hottest highlights. + * @property {string} glow Trail, ring burst, and emblem glow. + * @property {string} deep Background tint of the reveal scene. + * @property {string} emblem Emblem fill gradient, light to dark. + */ + +export const gachaTotalSeconds = 7; + +/** + * Seconds from the start of the clip. The meteor lands at `meteorEnd`, the + * white flash peaks and holds, then fades to reveal the result. + */ +export const gachaTimeline = Object.freeze({ + meteorStart: 0.3, + meteorEnd: 2.5, + flashStart: 2.3, + flashPeak: 2.7, + flashHoldEnd: 2.95, + flashEnd: 3.5, + revealStart: 2.95, + emblemSettled: 3.7, + nameIn: 3.6, + nameSettled: 4.1, + starsStart: 4.1, + starStep: 0.18, +}); + +/** Every rarity reveals its stars in the same gold, as in the source game. */ +export const starColor = '#ffcc33'; + +/** @type {Record} */ +const palettes = { + 3: {core: '#f2f8ff', glow: '#6fb6ff', deep: '#163a8a', emblem: '#9fd0ff, #3a6fd8'}, + 4: {core: '#fbf2ff', glow: '#c88cff', deep: '#4a1d8f', emblem: '#e2c2ff, #8a4ae6'}, + 5: {core: '#fffbe8', glow: '#ffd36b', deep: '#7a4a10', emblem: '#fff0b8, #e2a12e'}, +}; + +/** + * @param {GachaRarity} rarity + * @returns {RarityPalette} + */ +export const getRarityPalette = (rarity) => palettes[rarity]; + +/** + * Point on the meteor's curved fall from the upper right toward the lower + * left of the frame, as a quadratic Bézier. + * + * @param {number} progress 0 at launch, 1 at landing; values outside clamp. + * @param {number} width + * @param {number} height + * @returns {{x: number, y: number}} + */ +export const getMeteorPoint = (progress, width, height) => { + const t = Math.min(1, Math.max(0, progress)); + const start = {x: width * 1.08, y: -height * 0.18}; + const control = {x: width * 0.8, y: height * 0.3}; + const end = {x: width * 0.42, y: height * 0.6}; + const u = 1 - t; + return { + x: u * u * start.x + 2 * u * t * control.x + t * t * end.x, + y: u * u * start.y + 2 * u * t * control.y + t * t * end.y, + }; +}; + +/** + * Seconds at which each reveal star pops in, one per rarity level. + * + * @param {GachaRarity} rarity + * @returns {number[]} + */ +export const getStarRevealTimes = (rarity) => + Array.from({length: rarity}, (_, index) => gachaTimeline.starsStart + index * gachaTimeline.starStep); + +/** + * Deterministic pseudo-random generator (mulberry32) so every render of the + * same request draws the same sky. + * + * @param {number} seed + * @returns {() => number} values in [0, 1) + */ +export const createRandom = (seed) => { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +}; + +/** + * @typedef {object} SkyStar + * @property {number} x Fraction of width. + * @property {number} y Fraction of height. + * @property {number} radius Fraction of height. + * @property {number} phase Twinkle phase offset in radians. + */ + +/** + * @param {number} count + * @param {number} seed + * @returns {SkyStar[]} + */ +export const createStarfield = (count, seed) => { + const random = createRandom(seed); + return Array.from({length: count}, () => ({ + x: random(), + y: random() * 0.8, + radius: 0.002 + random() * 0.004, + phase: random() * Math.PI * 2, + })); +}; + +/** + * Font size for the revealed name, shrinking long labels so they fit within + * two lines on the left half of the frame. + * + * @param {string} label + * @param {number} height + * @returns {number} + */ +export const getLabelFontSize = (label, height) => { + const length = Array.from(label).length; + if (length <= 8) { + return Math.round(height * 0.12); + } + if (length <= 16) { + return Math.round(height * 0.09); + } + if (length <= 28) { + return Math.round(height * 0.07); + } + return Math.round(height * 0.055); +}; + +/** + * The emblem shows the label's first character, keeping emoji and other + * multi-unit code points intact. + * + * @param {string} label + * @returns {string} + */ +export const getEmblemGlyph = (label) => (Array.from(label.trim())[0] ?? '?').toUpperCase(); diff --git a/renderer/src/render/render-gacha.js b/renderer/src/render/render-gacha.js new file mode 100644 index 0000000..fac435f --- /dev/null +++ b/renderer/src/render/render-gacha.js @@ -0,0 +1,23 @@ +import {renderComposition} from './render-composition.js'; + +/** + * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest + * @typedef {import('./render-composition.js').RenderResult} RenderGachaResult + */ + +/** + * Renders the wish animation as a silent H.264 MP4, which Telegram plays as a + * looping animation and which keeps the glow gradients free of GIF banding. + * + * @param {GachaRenderRequest} inputProps + * @param {{timeoutInMilliseconds: number}} options + * @returns {Promise} + */ +export const renderGachaVideo = (inputProps, options) => + renderComposition({ + compositionId: 'GachaWish', + filename: 'gacha.mp4', + inputProps: {...inputProps}, + media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true}, + timeoutInMilliseconds: options.timeoutInMilliseconds, + }); diff --git a/renderer/src/routes/gacha.js b/renderer/src/routes/gacha.js new file mode 100644 index 0000000..6f1b8a5 --- /dev/null +++ b/renderer/src/routes/gacha.js @@ -0,0 +1,67 @@ +import {ZodError} from 'zod'; +import {isAuthorized} from '../lib/bearer-auth.js'; +import {isRenderTimeoutError} from '../lib/render-errors.js'; +import {parseGachaRequest} from '../schemas/gacha-request.js'; + +/** + * @typedef {import('../config.js').AppConfig} AppConfig + * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest + */ + +/** + * @typedef {object} GachaRouteDeps + * @property {AppConfig} config + * @property {{tryAcquire: () => boolean, release: () => void}} semaphore + * @property {(request: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} renderGacha + */ + +/** + * The caller picks the result and its rarity; this route only draws the wish + * animation for it. + * + * @param {import('fastify').FastifyInstance} app + * @param {GachaRouteDeps} deps + */ +export const registerGachaRoute = async (app, deps) => { + app.post('/api/gacha', async (request, reply) => { + if (!isAuthorized(request, deps.config.apiToken)) { + return reply.code(401).send({error: 'unauthorized'}); + } + + let gachaRequest; + try { + gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars}); + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({error: 'invalid_request', issues: error.issues}); + } + throw error; + } + + if (!deps.semaphore.tryAcquire()) { + return reply.code(429).send({error: 'too_many_renders'}); + } + + try { + const result = await deps.renderGacha(gachaRequest, { + timeoutInMilliseconds: deps.config.renderTimeoutMs, + }); + + return reply + .header('Content-Type', 'video/mp4') + .header('Content-Disposition', 'inline; filename="gacha.mp4"') + .header('Cache-Control', 'no-store') + .header('X-Gacha-Rarity', String(gachaRequest.rarity)) + .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(); + } + }); +}; diff --git a/renderer/src/schemas/gacha-request.js b/renderer/src/schemas/gacha-request.js new file mode 100644 index 0000000..14dfc54 --- /dev/null +++ b/renderer/src/schemas/gacha-request.js @@ -0,0 +1,40 @@ +import {z} from 'zod'; + +/** Landscape frame sizes; heights keep 16:9 rounded to even pixels for H.264. */ +export const gachaFrameSizes = Object.freeze({640: 360, 854: 480}); + +/** + * @typedef {object} GachaRequestLimits + * @property {number} maxOptionChars + */ + +/** + * @typedef {object} GachaRenderRequest + * @property {string} label + * @property {3 | 4 | 5} rarity + * @property {24 | 30} fps + * @property {640 | 854} width + */ + +/** + * @param {GachaRequestLimits} limits + */ +export const createGachaRequestSchema = (limits) => + z + .object({ + label: z + .string() + .transform((label) => label.trim()) + .pipe(z.string().min(1).max(limits.maxOptionChars)), + rarity: z.union([z.literal(3), z.literal(4), z.literal(5)]), + fps: z.union([z.literal(24), z.literal(30)]).default(24), + width: z.union([z.literal(640), z.literal(854)]).default(640), + }) + .strict(); + +/** + * @param {unknown} input + * @param {GachaRequestLimits} limits + * @returns {GachaRenderRequest} + */ +export const parseGachaRequest = (input, limits) => createGachaRequestSchema(limits).parse(input); diff --git a/renderer/src/server.js b/renderer/src/server.js index 9247b4a..137ff6f 100644 --- a/renderer/src/server.js +++ b/renderer/src/server.js @@ -2,19 +2,23 @@ import Fastify from 'fastify'; import {loadConfig} from './config.js'; import {createRenderSemaphore} from './lib/render-semaphore.js'; import {getRemotionServeUrl} from './render/remotion-bundle.js'; +import {renderGachaVideo} from './render/render-gacha.js'; import {renderWheelGif} from './render/render-gif.js'; +import {registerGachaRoute} from './routes/gacha.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 + * @typedef {import('./schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest */ /** * @param {object} [options] * @param {AppConfig} [options.config] * @param {(request: WheelRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGif] + * @param {(request: GachaRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGacha] * @param {boolean} [options.warmRemotionBundle] */ export const buildServer = async (options = {}) => { @@ -36,10 +40,17 @@ export const buildServer = async (options = {}) => { }); await registerHealthRoute(app); + // One semaphore across both routes: they share the same browser and CPU. + const semaphore = createRenderSemaphore(config.maxConcurrentRenders); await registerGifRoute(app, { config, renderGif: options.renderGif ?? renderWheelGif, - semaphore: createRenderSemaphore(config.maxConcurrentRenders), + semaphore, + }); + await registerGachaRoute(app, { + config, + renderGacha: options.renderGacha ?? renderGachaVideo, + semaphore, }); return app; diff --git a/renderer/test/gacha-request.test.js b/renderer/test/gacha-request.test.js new file mode 100644 index 0000000..05b6c18 --- /dev/null +++ b/renderer/test/gacha-request.test.js @@ -0,0 +1,32 @@ +import {describe, expect, test} from 'vitest'; +import {parseGachaRequest} from '../src/schemas/gacha-request.js'; + +const limits = {maxOptionChars: 12}; + +describe('parseGachaRequest', () => { + test('trims the label and applies defaults', () => { + const request = parseGachaRequest({label: ' Pizza ', rarity: 5}, limits); + + expect(request).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640}); + }); + + test('accepts the larger frame and fps', () => { + const request = parseGachaRequest({label: 'Pho', rarity: 4, fps: 30, width: 854}, limits); + + expect(request.fps).toBe(30); + expect(request.width).toBe(854); + }); + + test.each([ + {label: 'Pizza', rarity: 2}, + {label: 'Pizza', rarity: 6}, + {label: 'Pizza'}, + {label: ' ', rarity: 3}, + {label: 'x'.repeat(13), rarity: 3}, + {label: 'Pizza', rarity: 3, width: 512}, + {label: 'Pizza', rarity: 3, fps: 15}, + {label: 'Pizza', rarity: 3, extra: true}, + ])('rejects %o', (input) => { + expect(() => parseGachaRequest(input, limits)).toThrow(); + }); +}); diff --git a/renderer/test/gacha-route.test.js b/renderer/test/gacha-route.test.js new file mode 100644 index 0000000..7614202 --- /dev/null +++ b/renderer/test/gacha-route.test.js @@ -0,0 +1,135 @@ +import {describe, expect, test} from 'vitest'; +import {RenderTimeoutError} from '../src/lib/render-errors.js'; +import {buildServer} from '../src/server.js'; + +/** @type {import('../src/config.js').AppConfig} */ +const config = { + apiToken: undefined, + host: '127.0.0.1', + maxConcurrentRenders: 1, + maxOptionChars: 40, + maxOptions: 32, + port: 0, + requiresApiToken: false, + renderTimeoutMs: 15000, +}; + +const mp4 = Buffer.from('\0\0\0\x18ftypisom-test'); + +/** + * @param {Partial} [overrides] + * @param {Parameters[0]} [options] + */ +const build = (overrides = {}, options = {}) => + buildServer({ + config: {...config, ...overrides}, + renderGacha: async () => ({buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}), + ...options, + }); + +describe('POST /api/gacha', () => { + test('returns mp4 bytes with rarity metadata', async () => { + /** @type {unknown} */ + let rendered; + const app = await build( + {}, + { + renderGacha: async (request) => { + rendered = request; + return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}; + }, + }, + ); + + const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5}}); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('video/mp4'); + expect(response.headers['x-gacha-rarity']).toBe('5'); + expect(response.rawPayload.subarray(4, 8).toString()).toBe('ftyp'); + expect(rendered).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640}); + + await app.close(); + }); + + test('rejects an invalid rarity', async () => { + const app = await build(); + + const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 6}}); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + await app.close(); + }); + + test('rejects labels longer than the configured limit', async () => { + const app = await build({maxOptionChars: 4}); + + const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 3}}); + + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + test('enforces bearer token when configured', async () => { + const app = await build({apiToken: 'secret'}); + + const denied = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 3}}); + const allowed = await app.inject({ + method: 'POST', + url: '/api/gacha', + headers: {authorization: 'Bearer secret'}, + payload: {label: 'Pizza', rarity: 3}, + }); + + expect(denied.statusCode).toBe(401); + expect(allowed.statusCode).toBe(200); + + await app.close(); + }); + + test('returns timeout response when rendering exceeds configured limit', async () => { + const app = await build( + {renderTimeoutMs: 7}, + { + renderGacha: async (_request, renderOptions) => { + throw new RenderTimeoutError(renderOptions.timeoutInMilliseconds); + }, + }, + ); + + const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 4}}); + + expect(response.statusCode).toBe(504); + expect(response.json().error).toBe('render_timeout'); + + await app.close(); + }); + + test('shares the render slot with the wheel route', async () => { + /** @type {() => void} */ + let finishGacha = () => {}; + const app = await build( + {}, + { + renderGacha: () => + new Promise((resolve) => { + finishGacha = () => resolve({buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}); + }), + renderGif: async () => ({buffer: Buffer.from('GIF89a'), byteLength: 6, durationMs: 1}), + }, + ); + + const pending = app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 3}}); + await new Promise((resolve) => setTimeout(resolve, 20)); + const busy = await app.inject({method: 'POST', url: '/api/gif', payload: {options: ['a', 'b']}}); + finishGacha(); + + expect(busy.statusCode).toBe(429); + expect((await pending).statusCode).toBe(200); + + await app.close(); + }); +}); diff --git a/renderer/test/gacha-timeline.test.js b/renderer/test/gacha-timeline.test.js new file mode 100644 index 0000000..97edd84 --- /dev/null +++ b/renderer/test/gacha-timeline.test.js @@ -0,0 +1,64 @@ +import {describe, expect, test} from 'vitest'; +import { + createStarfield, + gachaTimeline, + gachaTotalSeconds, + getEmblemGlyph, + getLabelFontSize, + getMeteorPoint, + getRarityPalette, + getStarRevealTimes, +} from '../src/remotion/gacha-timeline.js'; + +describe('gacha timeline', () => { + test('phases run in order and finish before the clip ends', () => { + const tl = gachaTimeline; + expect(tl.meteorStart).toBeLessThan(tl.meteorEnd); + expect(tl.flashStart).toBeLessThan(tl.flashPeak); + expect(tl.meteorEnd).toBeLessThanOrEqual(tl.flashPeak); + expect(tl.flashPeak).toBeLessThan(tl.flashHoldEnd); + expect(tl.revealStart).toBeLessThanOrEqual(tl.flashHoldEnd); + expect(tl.flashHoldEnd).toBeLessThan(tl.flashEnd); + expect(tl.nameIn).toBeLessThan(tl.nameSettled); + const lastStar = getStarRevealTimes(5).at(-1) ?? 0; + expect(lastStar + 1).toBeLessThan(gachaTotalSeconds); + }); + + test.each([3, 4, 5])('reveals one star per rarity level for %i stars', (rarity) => { + const times = getStarRevealTimes(/** @type {3 | 4 | 5} */ (rarity)); + expect(times).toHaveLength(rarity); + expect(times[0]).toBe(gachaTimeline.starsStart); + expect([...times].sort((a, b) => a - b)).toEqual(times); + }); + + test('each rarity has a distinct meteor colour', () => { + const glows = [3, 4, 5].map((rarity) => getRarityPalette(/** @type {3 | 4 | 5} */ (rarity)).glow); + expect(new Set(glows).size).toBe(3); + }); + + test('meteor falls from off-screen upper right toward the lower left', () => { + const start = getMeteorPoint(0, 640, 360); + const end = getMeteorPoint(1, 640, 360); + expect(start.x).toBeGreaterThan(640); + expect(start.y).toBeLessThan(0); + expect(end.x).toBeLessThan(start.x); + expect(end.y).toBeGreaterThan(start.y); + expect(getMeteorPoint(2, 640, 360)).toEqual(end); + expect(getMeteorPoint(-1, 640, 360)).toEqual(start); + }); + + test('starfield is deterministic per seed', () => { + expect(createStarfield(5, 7)).toEqual(createStarfield(5, 7)); + expect(createStarfield(5, 7)).not.toEqual(createStarfield(5, 8)); + }); + + test('long labels get smaller text', () => { + expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360)); + }); + + test('emblem glyph keeps multi-unit characters whole', () => { + expect(getEmblemGlyph(' pizza')).toBe('P'); + expect(getEmblemGlyph('🍕 time')).toBe('🍕'); + expect(getEmblemGlyph('ăn')).toBe('Ă'); + }); +});