From dccd524f9d656ef8fe03601e655569bd74c22b0a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 14:43:46 +0700 Subject: [PATCH] feat(gacha): fly into the rank emblem with a rainbow sunburst and twinkling sky The meteor now flies in from the upper left and lands on the rank emblem, so the impact burst becomes the badge reveal. The 5-star rainbow ring is replaced by a rainbow sunburst of counter-rotating rays. Sky stars twinkle with short bright flares and cross glints, and each roll draws a fresh layout from a per-request seed that the route picks when the caller sends none. --- renderer/README.md | 10 +- renderer/scripts/render-fixtures.js | 6 +- renderer/src/remotion/GachaComposition.jsx | 185 ++++++++++++++++----- renderer/src/remotion/Root.jsx | 1 + renderer/src/remotion/gacha-timeline.js | 31 +++- renderer/src/routes/gacha.js | 7 +- renderer/src/schemas/gacha-request.js | 13 +- renderer/test/gacha-request.test.js | 14 +- renderer/test/gacha-route.test.js | 27 ++- renderer/test/gacha-timeline.test.js | 22 ++- 10 files changed, 251 insertions(+), 65 deletions(-) diff --git a/renderer/README.md b/renderer/README.md index a2f6f73..91aa41d 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -51,15 +51,17 @@ Authorization: Bearer change-me ``` 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 beside a rank emblem (`B`, +coloured by rarity (blue 3★, purple 4★, gold 5★) flies in from the left across +a night sky and bursts in a white flash where the rank emblem appears, and the label is revealed beside a rank emblem (`B`, `A`, `S`) with its stars popping in. Each tier is louder than the one below: 4★ adds a bigger meteor, a lens flare, impact shake, and a double shockwave; -5★ adds a rainbow halo before landing, a gold sky flood, a starburst, counter +5★ adds a rainbow sunburst before landing, a gold sky flood, a starburst, counter rotating rays, falling sparkles, and a sheen across the emblem. The per-tier table lives in `src/remotion/gacha-timeline.js`. `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 +`854` (480 tall). Optional `seed` (integer, `0` to `2147483647`) lays out the +twinkling stars and particles; when omitted the service picks a random one, so +every roll draws a different sky. 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) diff --git a/renderer/scripts/render-fixtures.js b/renderer/scripts/render-fixtures.js index ec4fd88..95b76b2 100644 --- a/renderer/scripts/render-fixtures.js +++ b/renderer/scripts/render-fixtures.js @@ -50,9 +50,9 @@ await mkdir(fixturesDir, {recursive: true}); /** @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}}, + {name: 'gacha-5-star', request: {label: 'Pizza', rarity: 5, fps: 24, width: 640, seed: 7}}, + {name: 'gacha-4-star', request: {label: 'Phở bò tái nạm gầu', rarity: 4, fps: 24, width: 640, seed: 11}}, + {name: 'gacha-3-star', request: {label: 'Cơm tấm', rarity: 3, fps: 24, width: 854, seed: 23}}, ]; /** diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx index 6f2f5a9..58f11a6 100644 --- a/renderer/src/remotion/GachaComposition.jsx +++ b/renderer/src/remotion/GachaComposition.jsx @@ -1,14 +1,17 @@ +import {useMemo} from 'react'; import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; import { createRandom, createStarfield, gachaTimeline, getLabelFontSize, + emblemCenter, getMeteorPoint, getRankLetter, getRarityPalette, getShakeOffset, getStarRevealTimes, + getTwinkle, getTierEffects, starColor, } from './gacha-timeline.js'; @@ -18,27 +21,46 @@ const baseFont = const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); -const skyStars = createStarfield(70, 7); const trailSpacing = 0.011; const starburstSpikes = 10; +/** + * Every decorative particle for one roll. The request seed picks the layout, + * so each roll scatters its stars differently while every frame of one + * render stays consistent. + * + * @param {number} seed + */ +const createParticles = (seed) => ({ + skyStars: createStarfield(70, seed), + meteorSparks: createMeteorSparks(seed + 1), + revealMotes: createRevealMotes(seed + 2), + rainSparkles: createRainSparkles(seed + 3), +}); + /** * Sparks shed by the meteor. Each is born at a point along the fall and * drifts away from it while fading. Tiers draw a prefix of this pool. + * + * @param {number} seed */ -const meteorSparks = (() => { - const random = createRandom(11); +function createMeteorSparks(seed) { + const random = createRandom(seed); return Array.from({length: 80}, () => ({ born: random(), driftX: (random() - 0.5) * 0.1, driftY: (random() - 0.2) * 0.1, size: 0.006 + random() * 0.012, })); -})(); +} -/** Motes that float upward behind the revealed item. */ -const revealMotes = (() => { - const random = createRandom(23); +/** + * Motes that float upward behind the revealed item. + * + * @param {number} seed + */ +function createRevealMotes(seed) { + const random = createRandom(seed); return Array.from({length: 56}, () => ({ x: random(), y: random(), @@ -46,11 +68,15 @@ const revealMotes = (() => { size: 0.004 + random() * 0.009, phase: random() * Math.PI * 2, })); -})(); +} -/** Sparkles that rain down across the 5★ reveal. */ -const rainSparkles = (() => { - const random = createRandom(31); +/** + * Sparkles that rain down across the 5★ reveal. + * + * @param {number} seed + */ +function createRainSparkles(seed) { + const random = createRandom(seed); return Array.from({length: 40}, () => ({ x: random(), offset: random(), @@ -58,7 +84,31 @@ const rainSparkles = (() => { size: 0.025 + random() * 0.03, spin: (random() - 0.5) * 360, })); -})(); +} + +const rainbowConic = 'conic-gradient(from 0deg, #ff6b6b, #ffd36b, #8cff9a, #6bd6ff, #b48cff, #ff8cd8, #ff6b6b)'; + +/** Fades the 5★ rainbow sunburst from a bright core out to its ray tips. */ +const sunburstFade = 'radial-gradient(circle, black 0%, black 18%, rgba(0, 0, 0, 0.6) 40%, transparent 70%)'; + +/** + * Two ray layers turning in opposite directions; each ray takes the rainbow + * hue of the angle it points at. + */ +const sunburstLayers = [ + { + rays: 16, + spin: 40, + opacity: 1, + mask: 'repeating-conic-gradient(from 0deg, black 0deg 6deg, transparent 6deg 22.5deg)', + }, + { + rays: 24, + spin: -60, + opacity: 0.7, + mask: 'repeating-conic-gradient(from 4deg, black 0deg 2deg, transparent 2deg 15deg)', + }, +]; /** * @param {string} hex @@ -102,6 +152,7 @@ export const GachaComposition = (props) => { const palette = getRarityPalette(props.rarity); const fx = getTierEffects(props.rarity); const tl = gachaTimeline; + const {skyStars, meteorSparks, revealMotes, rainSparkles} = useMemo(() => createParticles(props.seed), [props.seed]); const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], { ...clamp, @@ -123,8 +174,8 @@ export const GachaComposition = (props) => { const revealed = seconds >= tl.revealStart; const shake = getShakeOffset(seconds, tl.revealStart, fx.shake * height); const emblemSize = height * 0.52; - const emblemX = width * 0.66; - const emblemY = height * 0.47; + const emblemX = width * emblemCenter.x; + const emblemY = height * emblemCenter.y; const emblemScale = interpolate( seconds, [tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled], @@ -175,21 +226,61 @@ export const GachaComposition = (props) => { background: `radial-gradient(ellipse 80% 40% at 50% 100%, ${withAlpha('#9bb4ff', 0.35)} 0%, transparent 70%)`, }} /> - {skyStars.map((star, index) => ( -
- ))} + {skyStars.map((star, index) => { + const twinkle = getTwinkle(seconds, star); + const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle); + const glint = star.glint ? size * 6 * twinkle : 0; + return ( +
+
0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined, + height: size, + left: -size / 2, + position: 'absolute', + top: -size / 2, + width: size, + }} + /> + {glint > 1 ? ( + <> +
+
+ + ) : null} +
+ ); + })} {skyFlood > 0 ? ( { {meteorVisible && haloOpacity > 0 ? (
+ > + {sunburstLayers.map((layer) => ( +
+ ))} +
) : null} {meteorVisible ? ( diff --git a/renderer/src/remotion/Root.jsx b/renderer/src/remotion/Root.jsx index 74ff31c..cb5c605 100644 --- a/renderer/src/remotion/Root.jsx +++ b/renderer/src/remotion/Root.jsx @@ -38,6 +38,7 @@ const defaultGachaProps = { rarity: 5, fps: 24, width: 640, + seed: 7, }; /** diff --git a/renderer/src/remotion/gacha-timeline.js b/renderer/src/remotion/gacha-timeline.js index a479e00..ea70816 100644 --- a/renderer/src/remotion/gacha-timeline.js +++ b/renderer/src/remotion/gacha-timeline.js @@ -34,6 +34,9 @@ export const gachaTimeline = Object.freeze({ starStep: 0.18, }); +/** Centre of the rank emblem on the reveal, as fractions of the frame. */ +export const emblemCenter = Object.freeze({x: 0.66, y: 0.47}); + /** Every rarity reveals its stars in the same gold, as in the source game. */ export const starColor = '#ffcc33'; @@ -51,8 +54,9 @@ const palettes = { 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. + * Point on the meteor's curved flight from the upper left toward the rank + * emblem on the right, as a quadratic Bézier. It lands exactly on the emblem, + * so the impact burst becomes the badge reveal. * * @param {number} progress 0 at launch, 1 at landing; values outside clamp. * @param {number} width @@ -61,9 +65,9 @@ export const getRarityPalette = (rarity) => palettes[rarity]; */ 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 start = {x: -width * 0.08, y: -height * 0.18}; + const control = {x: width * 0.22, y: height * 0.38}; + const end = {x: width * emblemCenter.x, y: height * emblemCenter.y}; const u = 1 - t; return { x: u * u * start.x + 2 * u * t * control.x + t * t * end.x, @@ -104,6 +108,8 @@ export const createRandom = (seed) => { * @property {number} y Fraction of height. * @property {number} radius Fraction of height. * @property {number} phase Twinkle phase offset in radians. + * @property {number} speed Twinkle speed in radians per second. + * @property {boolean} glint Draws a cross-shaped glint when it flares. */ /** @@ -118,9 +124,22 @@ export const createStarfield = (count, seed) => { y: random() * 0.8, radius: 0.002 + random() * 0.004, phase: random() * Math.PI * 2, + speed: 2 + random() * 5, + glint: random() < 0.18, })); }; +/** + * Twinkle brightness in [0, 1]. Cubing the sine keeps a star dim most of the + * time with short bright flares, which reads as twinkling rather than a slow + * pulse. + * + * @param {number} seconds + * @param {SkyStar} star + * @returns {number} + */ +export const getTwinkle = (seconds, star) => (0.5 + 0.5 * Math.sin(seconds * star.speed + star.phase)) ** 3; + /** * Font size for the revealed name, shrinking long labels so they fit within * two lines on the left half of the frame. @@ -157,7 +176,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet * @property {number} headScale Meteor head size multiplier. * @property {number} trailSamples Glow blobs drawn along the meteor trail. * @property {number} sparks Sparks shed by the meteor. - * @property {boolean} halo Rainbow ring forming around the meteor before landing. + * @property {boolean} halo Rainbow sunburst bursting from the meteor before landing. * @property {number} skyFlood Peak opacity of the sky tint as the meteor lands. * @property {number} shake Impact camera shake amplitude, as a fraction of height. * @property {number} shockwaves Rings bursting from the emblem on reveal. diff --git a/renderer/src/routes/gacha.js b/renderer/src/routes/gacha.js index 6f1b8a5..30df889 100644 --- a/renderer/src/routes/gacha.js +++ b/renderer/src/routes/gacha.js @@ -1,7 +1,8 @@ +import {randomInt} from 'node:crypto'; 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'; +import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js'; /** * @typedef {import('../config.js').AppConfig} AppConfig @@ -30,7 +31,9 @@ export const registerGachaRoute = async (app, deps) => { let gachaRequest; try { - gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars}); + gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars}, () => + randomInt(maxGachaSeed), + ); } catch (error) { if (error instanceof ZodError) { return reply.code(400).send({error: 'invalid_request', issues: error.issues}); diff --git a/renderer/src/schemas/gacha-request.js b/renderer/src/schemas/gacha-request.js index 14dfc54..f90bcfc 100644 --- a/renderer/src/schemas/gacha-request.js +++ b/renderer/src/schemas/gacha-request.js @@ -1,5 +1,7 @@ import {z} from 'zod'; +export const maxGachaSeed = 2 ** 31 - 1; + /** Landscape frame sizes; heights keep 16:9 rounded to even pixels for H.264. */ export const gachaFrameSizes = Object.freeze({640: 360, 854: 480}); @@ -14,6 +16,7 @@ export const gachaFrameSizes = Object.freeze({640: 360, 854: 480}); * @property {3 | 4 | 5} rarity * @property {24 | 30} fps * @property {640 | 854} width + * @property {number} seed Lays out the stars and particles for this roll. */ /** @@ -29,12 +32,20 @@ export const createGachaRequestSchema = (limits) => 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), + seed: z.number().int().min(0).max(maxGachaSeed).optional(), }) .strict(); /** + * Parses a request, picking a fresh seed when the caller sends none so every + * roll draws a different sky. + * * @param {unknown} input * @param {GachaRequestLimits} limits + * @param {() => number} pickSeed * @returns {GachaRenderRequest} */ -export const parseGachaRequest = (input, limits) => createGachaRequestSchema(limits).parse(input); +export const parseGachaRequest = (input, limits, pickSeed) => { + const parsed = createGachaRequestSchema(limits).parse(input); + return {...parsed, seed: parsed.seed ?? pickSeed()}; +}; diff --git a/renderer/test/gacha-request.test.js b/renderer/test/gacha-request.test.js index 05b6c18..6373480 100644 --- a/renderer/test/gacha-request.test.js +++ b/renderer/test/gacha-request.test.js @@ -5,13 +5,17 @@ const limits = {maxOptionChars: 12}; describe('parseGachaRequest', () => { test('trims the label and applies defaults', () => { - const request = parseGachaRequest({label: ' Pizza ', rarity: 5}, limits); + const request = parseGachaRequest({label: ' Pizza ', rarity: 5}, limits, () => 42); - expect(request).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640}); + expect(request).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640, seed: 42}); + }); + + test('keeps a caller-provided seed', () => { + expect(parseGachaRequest({label: 'Pizza', rarity: 5, seed: 9}, limits, () => 42).seed).toBe(9); }); test('accepts the larger frame and fps', () => { - const request = parseGachaRequest({label: 'Pho', rarity: 4, fps: 30, width: 854}, limits); + const request = parseGachaRequest({label: 'Pho', rarity: 4, fps: 30, width: 854}, limits, () => 1); expect(request.fps).toBe(30); expect(request.width).toBe(854); @@ -26,7 +30,9 @@ describe('parseGachaRequest', () => { {label: 'Pizza', rarity: 3, width: 512}, {label: 'Pizza', rarity: 3, fps: 15}, {label: 'Pizza', rarity: 3, extra: true}, + {label: 'Pizza', rarity: 3, seed: -1}, + {label: 'Pizza', rarity: 3, seed: 1.5}, ])('rejects %o', (input) => { - expect(() => parseGachaRequest(input, limits)).toThrow(); + expect(() => parseGachaRequest(input, limits, () => 0)).toThrow(); }); }); diff --git a/renderer/test/gacha-route.test.js b/renderer/test/gacha-route.test.js index 7614202..035e509 100644 --- a/renderer/test/gacha-route.test.js +++ b/renderer/test/gacha-route.test.js @@ -47,7 +47,32 @@ describe('POST /api/gacha', () => { 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}); + expect(rendered).toMatchObject({label: 'Pizza', rarity: 5, fps: 24, width: 640}); + expect(Number.isInteger(/** @type {{seed: number}} */ (rendered).seed)).toBe(true); + + await app.close(); + }); + + test('picks a different seed per roll unless one is given', async () => { + /** @type {number[]} */ + const seeds = []; + const app = await build( + {}, + { + renderGacha: async (request) => { + seeds.push(request.seed); + return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}; + }, + }, + ); + + for (let index = 0; index < 3; index += 1) { + await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5}}); + } + await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5, seed: 5}}); + + expect(new Set(seeds.slice(0, 3)).size).toBeGreaterThan(1); + expect(seeds[3]).toBe(5); await app.close(); }); diff --git a/renderer/test/gacha-timeline.test.js b/renderer/test/gacha-timeline.test.js index 260b62b..0e74dbd 100644 --- a/renderer/test/gacha-timeline.test.js +++ b/renderer/test/gacha-timeline.test.js @@ -4,12 +4,14 @@ import { gachaTimeline, gachaTotalSeconds, getLabelFontSize, + emblemCenter, getMeteorPoint, getRankLetter, getRarityPalette, getShakeOffset, getStarRevealTimes, getTierEffects, + getTwinkle, } from '../src/remotion/gacha-timeline.js'; describe('gacha timeline', () => { @@ -38,13 +40,13 @@ describe('gacha timeline', () => { expect(new Set(glows).size).toBe(3); }); - test('meteor falls from off-screen upper right toward the lower left', () => { + test('meteor flies in from off-screen upper left and lands on the rank emblem', () => { const start = getMeteorPoint(0, 640, 360); const end = getMeteorPoint(1, 640, 360); - expect(start.x).toBeGreaterThan(640); + expect(start.x).toBeLessThan(0); expect(start.y).toBeLessThan(0); - expect(end.x).toBeLessThan(start.x); - expect(end.y).toBeGreaterThan(start.y); + expect(end.x).toBeCloseTo(640 * emblemCenter.x); + expect(end.y).toBeCloseTo(360 * emblemCenter.y); expect(getMeteorPoint(2, 640, 360)).toEqual(end); expect(getMeteorPoint(-1, 640, 360)).toEqual(start); }); @@ -54,6 +56,18 @@ describe('gacha timeline', () => { expect(createStarfield(5, 7)).not.toEqual(createStarfield(5, 8)); }); + test('stars twinkle with short bright flares', () => { + const [star] = createStarfield(1, 3); + if (!star) { + throw new Error('expected a star'); + } + const samples = Array.from({length: 200}, (_, index) => getTwinkle(index / 50, star)); + expect(Math.max(...samples)).toBeGreaterThan(0.95); + expect(Math.min(...samples)).toBeLessThan(0.05); + const bright = samples.filter((value) => value > 0.5).length / samples.length; + expect(bright).toBeLessThan(0.4); + }); + test('long labels get smaller text', () => { expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360)); });