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.
This commit is contained in:
tiennm99 committed 2026-10-01 11:43:42 +07:00
1 parent 61d0e74709
commit 97d8aeaadf
16 files changed
+1111 -20

No files matched your search

+1
View File
@@ -3,5 +3,6 @@ node_modules/
.tmp/
coverage/
fixtures/*.gif
fixtures/*.mp4
.env
*.log
+1
View File
@@ -6,5 +6,6 @@ coverage/
!.env.example
.tmp/
fixtures/*.gif
fixtures/*.mp4
/wheel.gif
*.log
+37 -5
View File
@@ -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.
+8
View File
@@ -37,3 +37,11 @@ Send `Authorization: Bearer <API_TOKEN>` 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.
+20
View File
@@ -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();
}
+23 -4
View File
@@ -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}));
}
+448
View File
@@ -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}) => (
<svg height={size} viewBox="0 0 24 24" width={size}>
<path
d="M12 1.6l3.1 6.6 7.2.9-5.3 5 1.4 7.1L12 17.7l-6.4 3.5 1.4-7.1-5.3-5 7.2-.9z"
fill={starColor}
stroke="#fff6cf"
strokeLinejoin="round"
strokeWidth={0.8}
/>
</svg>
);
/**
* @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 (
<AbsoluteFill style={{background: '#05060f', fontFamily: baseFont, overflow: 'hidden'}}>
{!revealed ? (
<AbsoluteFill
style={{
background: 'linear-gradient(180deg, #070a24 0%, #121a4a 45%, #2a3a7c 80%, #3d4f96 100%)',
}}
>
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 80% 40% at 50% 100%, ${withAlpha('#9bb4ff', 0.35)} 0%, transparent 70%)`,
}}
/>
{skyStars.map((star, index) => (
<div
key={`sky-${index}`}
style={{
background: '#ffffff',
borderRadius: '50%',
height: star.radius * 2 * height,
left: star.x * width,
opacity: 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(seconds * 2.4 + star.phase)),
position: 'absolute',
top: star.y * height,
width: star.radius * 2 * height,
}}
/>
))}
{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 (
<div
key={`trail-${index}`}
style={{
background: `radial-gradient(circle, ${withAlpha(palette.glow, 0.85 * fade ** 1.4)} 0%, transparent 70%)`,
height: radius * 2,
left: point.x - radius,
position: 'absolute',
top: point.y - radius,
width: radius * 2,
}}
/>
);
})
: 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 (
<div
key={`spark-${index}`}
style={{
background: palette.core,
borderRadius: '50%',
boxShadow: `0 0 ${size * 2}px ${palette.glow}`,
height: size,
left: origin.x + spark.driftX * width * age,
opacity: 1 - age,
position: 'absolute',
top: origin.y + spark.driftY * height * age,
width: size,
}}
/>
);
})
: null}
{meteorVisible ? (
<>
<div
style={{
background: `radial-gradient(circle, ${palette.core} 0%, ${palette.core} 18%, ${withAlpha(palette.glow, 0.9)} 34%, ${withAlpha(palette.glow, 0.35)} 58%, transparent 72%)`,
height: headRadius * 4,
left: head.x - headRadius * 2,
position: 'absolute',
top: head.y - headRadius * 2,
width: headRadius * 4,
}}
/>
{props.rarity === 5 ? (
<>
<div
style={{
background: `linear-gradient(90deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
height: Math.max(2, headRadius * 0.12),
left: head.x - headRadius * 5,
position: 'absolute',
top: head.y - Math.max(1, headRadius * 0.06),
width: headRadius * 10,
}}
/>
<div
style={{
background: `linear-gradient(180deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
height: headRadius * 6,
left: head.x - Math.max(1, headRadius * 0.06),
position: 'absolute',
top: head.y - headRadius * 3,
width: Math.max(2, headRadius * 0.12),
}}
/>
</>
) : null}
</>
) : null}
</AbsoluteFill>
) : null}
{revealed ? (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 70% 90% at 66% 47%, ${palette.deep} 0%, #0d0b1f 62%, #05060f 100%)`,
}}
>
<div
style={{
background: `repeating-conic-gradient(from 0deg, ${withAlpha(palette.glow, 0.9)} 0deg 4deg, transparent 4deg 18deg)`,
borderRadius: '50%',
height: width * 1.6,
left: emblemX - width * 0.8,
maskImage: 'radial-gradient(circle, black 8%, transparent 55%)',
opacity: raysOpacity,
position: 'absolute',
top: emblemY - width * 0.8,
transform: `rotate(${seconds * 9}deg)`,
WebkitMaskImage: 'radial-gradient(circle, black 8%, transparent 55%)',
width: width * 1.6,
}}
/>
{revealMotes.map((mote, index) => {
const y = (((mote.y - (seconds - tl.revealStart) * mote.speed) % 1) + 1) % 1;
const size = mote.size * height;
return (
<div
key={`mote-${index}`}
style={{
background: palette.core,
borderRadius: '50%',
boxShadow: `0 0 ${size * 3}px ${palette.glow}`,
height: size,
left: mote.x * width,
opacity: 0.25 + 0.5 * (0.5 + 0.5 * Math.sin(seconds * 3 + mote.phase)),
position: 'absolute',
top: y * height,
width: size,
}}
/>
);
})}
{ringProgress < 1 ? (
<div
style={{
border: `${Math.max(2, height * 0.008)}px solid ${palette.glow}`,
borderRadius: '50%',
boxShadow: `0 0 ${height * 0.04}px ${palette.glow}, inset 0 0 ${height * 0.04}px ${palette.glow}`,
height: emblemSize,
left: emblemX - emblemSize / 2,
opacity: 1 - ringProgress,
position: 'absolute',
top: emblemY - emblemSize / 2,
transform: `scale(${0.4 + ringProgress * 2.6})`,
width: emblemSize,
}}
/>
) : null}
<div
style={{
height: emblemSize,
left: emblemX - emblemSize / 2,
position: 'absolute',
top: emblemY - emblemSize / 2,
transform: `scale(${emblemScale})`,
width: emblemSize,
}}
>
<div
style={{
background: `linear-gradient(135deg, ${palette.emblem})`,
border: `${Math.max(2, height * 0.008)}px solid rgba(255, 255, 255, 0.85)`,
borderRadius: emblemSize * 0.12,
boxShadow: `0 0 ${height * (0.05 + 0.03 * emblemPulse)}px ${palette.glow}, inset 0 0 ${height * 0.04}px rgba(255, 255, 255, 0.55)`,
height: emblemSize * 0.72,
left: emblemSize * 0.14,
overflow: 'hidden',
position: 'absolute',
top: emblemSize * 0.14,
transform: 'rotate(45deg)',
width: emblemSize * 0.72,
}}
>
<div
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 45%)',
inset: 0,
position: 'absolute',
}}
/>
<div style={{background: '#ffffff', inset: 0, opacity: silhouette, position: 'absolute'}} />
</div>
<div
style={{
alignItems: 'center',
color: '#ffffff',
display: 'flex',
fontSize: emblemSize * 0.36,
fontWeight: 800,
inset: 0,
justifyContent: 'center',
opacity: 1 - silhouette,
position: 'absolute',
textShadow: `0 2px ${height * 0.01}px rgba(0, 0, 0, 0.45), 0 0 ${height * 0.03}px ${palette.glow}`,
}}
>
{getEmblemGlyph(props.label)}
</div>
</div>
<div
style={{
left: width * 0.07,
opacity: nameProgress,
position: 'absolute',
top: height * 0.56,
transform: `translateX(${(1 - nameProgress) * -width * 0.06}px)`,
width: width * 0.44,
}}
>
<div
style={{
color: '#ffffff',
display: '-webkit-box',
fontSize: getLabelFontSize(props.label, height),
fontWeight: 800,
lineHeight: 1.12,
overflow: 'hidden',
overflowWrap: 'anywhere',
textShadow: `0 2px ${height * 0.012}px rgba(0, 0, 0, 0.7), 0 0 ${height * 0.035}px ${withAlpha(palette.glow, 0.8)}`,
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
}}
>
{props.label}
</div>
<div style={{display: 'flex', gap: starSize * 0.12, marginTop: height * 0.025}}>
{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 (
<div
key={`star-${index}`}
style={{
height: starSize,
opacity: seconds >= time ? 1 : 0,
position: 'relative',
transform: `scale(${pop})`,
width: starSize,
}}
>
<div
style={{
background: `radial-gradient(circle, ${withAlpha('#fff4c2', 0.9 * flare)} 0%, transparent 65%)`,
height: starSize * 2.4,
left: -starSize * 0.7,
position: 'absolute',
top: -starSize * 0.7,
width: starSize * 2.4,
}}
/>
<StarIcon size={starSize} />
</div>
);
})}
</div>
</div>
</AbsoluteFill>
) : null}
{flashOpacity > 0 ? (
<AbsoluteFill style={{pointerEvents: 'none'}}>
<div
style={{
background: `radial-gradient(circle, #ffffff 0%, #ffffff 35%, ${withAlpha(palette.core, 0)} 70%)`,
height: bloomRadius * 2,
left: impact.x - bloomRadius,
opacity: Math.min(1, flashOpacity * 1.6),
position: 'absolute',
top: impact.y - bloomRadius,
width: bloomRadius * 2,
}}
/>
<AbsoluteFill style={{background: '#ffffff', opacity: flashOpacity}} />
</AbsoluteFill>
) : null}
</AbsoluteFill>
);
};
+47 -10
View File
@@ -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 = () => (
<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}
/>
<>
<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}
/>
<Composition
calculateMetadata={({props}) => getGachaCompositionMetadata(props)}
component={GachaComposition}
defaultProps={defaultGachaProps}
durationInFrames={Math.ceil(gachaTotalSeconds * defaultGachaProps.fps)}
fps={defaultGachaProps.fps}
height={gachaFrameSizes[defaultGachaProps.width]}
id="GachaWish"
width={defaultGachaProps.width}
/>
</>
);
+153
View File
@@ -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<GachaRarity, RarityPalette>} */
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();
+23
View File
@@ -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<RenderGachaResult>}
*/
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,
});
+67
View File
@@ -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();
}
});
};
+40
View File
@@ -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);
+12 -1
View File
@@ -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;
+32
View File
@@ -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();
});
});
+135
View File
@@ -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<import('../src/config.js').AppConfig>} [overrides]
* @param {Parameters<typeof buildServer>[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();
});
});
+64
View File
@@ -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('Ă');
});
});