mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 12:17:10 +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
+1111
-20
No files matched your search
@@ -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