mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
61d0e74709
commit
97d8aeaadf
16 files changed
+1101
-10
No files matched your search
@@ -3,5 +3,6 @@ node_modules/
|
||||
.tmp/
|
||||
coverage/
|
||||
fixtures/*.gif
|
||||
fixtures/*.mp4
|
||||
.env
|
||||
*.log
|
||||
@@ -6,5 +6,6 @@ coverage/
|
||||
!.env.example
|
||||
.tmp/
|
||||
fixtures/*.gif
|
||||
fixtures/*.mp4
|
||||
/wheel.gif
|
||||
*.log
|
||||
+37
-5
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,7 +32,30 @@ 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}
|
||||
@@ -40,4 +66,15 @@ export const RemotionRoot = () => (
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('Ă');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user