From 59f597016bb2be7c916def25333e88b00be206fe Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 23:37:27 +0700 Subject: [PATCH] feat(genshin): serve the Remotion meteor wish on /api/genshin Brings back the Genshin-style meteor wish that /api/gacha rendered before pack-cards, under genshin names. It takes the same request as /api/gacha and returns the 7-second landscape MP4. --- renderer/README.md | 26 +- renderer/docs/miti99bot-integration.md | 3 + renderer/scripts/api-smoke.js | 15 + renderer/scripts/render-fixtures.js | 6 + renderer/src/remotion/GenshinComposition.jsx | 698 +++++++++++++++++++ renderer/src/remotion/Root.jsx | 57 +- renderer/src/remotion/genshin-timeline.js | 274 ++++++++ renderer/src/render/render-genshin.js | 24 + renderer/src/routes/gacha.js | 6 +- renderer/src/server.js | 10 +- renderer/test/gacha-route.test.js | 30 + renderer/test/genshin-timeline.test.js | 110 +++ 12 files changed, 1241 insertions(+), 18 deletions(-) create mode 100644 renderer/src/remotion/GenshinComposition.jsx create mode 100644 renderer/src/remotion/genshin-timeline.js create mode 100644 renderer/src/render/render-genshin.js create mode 100644 renderer/test/genshin-timeline.test.js diff --git a/renderer/README.md b/renderer/README.md index 3898ada..a37dd5b 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -1,7 +1,8 @@ # wheelofnames -Self-hosted API that renders wheel-of-names GIF animations with Remotion and -gacha wish MP4 animations with pack-cards. +Self-hosted API that renders wheel-of-names GIF animations and Genshin-style +meteor wish MP4 animations with Remotion, and card-pack gacha wish MP4 +animations with pack-cards. ## API @@ -65,7 +66,7 @@ picks a random one, so every roll looks different. 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 +with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. All routes share the `MAX_CONCURRENT_RENDERS` slots. No game assets are used. pack-cards animates on the browser clock, so the wish does not use Remotion @@ -83,6 +84,21 @@ to a commit. A moving branch URL would change the tarball's checksum and break `npm ci` against the lockfile, and the Docker image has no `git` for a git dependency. +### Genshin wish + +`POST /api/genshin` takes the same body as `/api/gacha` and returns the same +response, rendering a 7-second landscape wish in the style of a gacha game: a +meteor 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 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/genshin-timeline.js`. `width` is `640` (360 tall) or `854` (480 +tall), and `seed` lays out the twinkling stars and particles. All visuals are +drawn procedurally; no game assets are used. + ## Local Install dependencies and Chromium once: @@ -101,7 +117,7 @@ npm run dev ### Generate GIF files locally Generate the quick smoke fixtures at the git-ignored paths -`fixtures/smoke.gif` and `fixtures/gacha-5-star.mp4`: +`fixtures/smoke.gif`, `fixtures/genshin-5-star.mp4`, and `fixtures/gacha-5-star.mp4`: ```sh npm run render:smoke @@ -109,7 +125,7 @@ npm run render:smoke Generate the complete fixture set at `fixtures/smoke.gif`, `fixtures/vietnamese.gif`, `fixtures/sixteen-options.gif`, and -`fixtures/gacha-{3,4,5}-star.mp4`: +`fixtures/genshin-{3,4,5}-star.mp4`, and `fixtures/gacha-{3,4,5}-star.mp4`: ```sh npm run render:fixtures diff --git a/renderer/docs/miti99bot-integration.md b/renderer/docs/miti99bot-integration.md index 7c13497..830592f 100644 --- a/renderer/docs/miti99bot-integration.md +++ b/renderer/docs/miti99bot-integration.md @@ -46,3 +46,6 @@ 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. + +The unlisted `/genshin` command works the same way against `POST /api/genshin` +and sends the 7-second 640x360 meteor wish. diff --git a/renderer/scripts/api-smoke.js b/renderer/scripts/api-smoke.js index 137ed73..a84e84f 100644 --- a/renderer/scripts/api-smoke.js +++ b/renderer/scripts/api-smoke.js @@ -65,6 +65,21 @@ try { } console.log(`api-smoke gacha: ${gacha.rawPayload.byteLength} bytes`); + + const genshin = await app.inject({ + method: 'POST', + url: '/api/genshin', + headers: { + authorization: 'Bearer smoke-token', + }, + payload: {label: 'Bún bò', rarity: 5}, + }); + + if (genshin.statusCode !== 200 || genshin.rawPayload.subarray(4, 8).toString() !== 'ftyp') { + throw new Error(`Expected genshin MP4, got ${genshin.statusCode}: ${genshin.body.slice(0, 200)}`); + } + + console.log(`api-smoke genshin: ${genshin.rawPayload.byteLength} bytes`); } finally { await app.close(); } diff --git a/renderer/scripts/render-fixtures.js b/renderer/scripts/render-fixtures.js index a2a2539..636c69d 100644 --- a/renderer/scripts/render-fixtures.js +++ b/renderer/scripts/render-fixtures.js @@ -1,6 +1,7 @@ import {mkdir, writeFile} from 'node:fs/promises'; import path from 'node:path'; import {closeGachaBrowser, renderGachaVideo} from '../src/render/render-gacha.js'; +import {renderGenshinVideo} from '../src/render/render-genshin.js'; import {renderWheelGif} from '../src/render/render-gif.js'; const smoke = process.argv.includes('--smoke'); @@ -69,6 +70,11 @@ 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) { + const name = fixture.name.replace('gacha', 'genshin'); + await save(`${name}.mp4`, await renderGenshinVideo(fixture.request, {timeoutInMilliseconds: 30000})); +} + try { for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) { await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 60000})); diff --git a/renderer/src/remotion/GenshinComposition.jsx b/renderer/src/remotion/GenshinComposition.jsx new file mode 100644 index 0000000..465b426 --- /dev/null +++ b/renderer/src/remotion/GenshinComposition.jsx @@ -0,0 +1,698 @@ +import {useMemo} from 'react'; +import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + createRandom, + createStarfield, + genshinTimeline, + getLabelFontSize, + emblemCenter, + getMeteorPoint, + getRankLetter, + getRarityPalette, + getShakeOffset, + getStarRevealTimes, + getTwinkle, + getTierEffects, + starColor, +} from './genshin-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 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 + */ +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. + * + * @param {number} seed + */ +function createRevealMotes(seed) { + const random = createRandom(seed); + return Array.from({length: 56}, () => ({ + x: random(), + y: random(), + speed: 0.03 + random() * 0.07, + size: 0.004 + random() * 0.009, + phase: random() * Math.PI * 2, + })); +} + +/** + * 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(), + speed: 0.25 + random() * 0.35, + 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 + * @param {number} alpha + */ +const withAlpha = (hex, alpha) => { + const value = Number.parseInt(hex.slice(1), 16); + return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`; +}; + +/** + * @param {{size: number}} props + */ +const StarIcon = ({size}) => ( + + + +); + +/** + * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest + */ + +/** + * Wish animation: a meteor coloured by rarity falls across a night sky, lands + * in a white flash, and the result is revealed with its rank letter and stars. + * Every beat scales with the tier's effects so higher rarities are louder. + * + * @param {GachaRenderRequest} props + */ +export const GenshinComposition = (props) => { + const frame = useCurrentFrame(); + const {fps, width, height} = useVideoConfig(); + const seconds = frame / fps; + const palette = getRarityPalette(props.rarity); + const fx = getTierEffects(props.rarity); + const tl = genshinTimeline; + const {skyStars, meteorSparks, revealMotes, rainSparkles} = useMemo(() => createParticles(props.seed), [props.seed]); + + 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) * fx.headScale; + const haloOpacity = fx.halo ? interpolate(meteorProgress, [0.45, 0.8], [0, 1], clamp) : 0; + const skyFlood = interpolate(seconds, [tl.meteorStart + 1.2, tl.meteorEnd], [0, fx.skyFlood], 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 shake = getShakeOffset(seconds, tl.revealStart, fx.shake * height); + const emblemSize = height * 0.52; + const emblemX = width * emblemCenter.x; + const emblemY = height * emblemCenter.y; + const emblemScale = interpolate( + seconds, + [tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled], + [0.72, 1 + 0.04 * fx.headScale, 1], + {...clamp, easing: Easing.out(Easing.cubic)}, + ); + const silhouette = interpolate(seconds, [tl.revealStart + 0.2, tl.emblemSettled], [1, 0], clamp); + const raysOpacity = interpolate(seconds, [tl.revealStart, tl.emblemSettled], [0, fx.raysOpacity], clamp); + const starburstScale = interpolate(seconds, [tl.revealStart, tl.emblemSettled + 0.3], [0.2, 1], { + ...clamp, + easing: Easing.out(Easing.cubic), + }); + const emblemPulse = 0.5 + 0.5 * Math.sin(seconds * 3); + const sheenProgress = ((((seconds - tl.emblemSettled) / 1.8) % 1) + 1) % 1; + const rankLetter = getRankLetter(props.rarity); + const rankLetterStyle = + props.rarity === 5 + ? { + background: 'linear-gradient(180deg, #ffffff 0%, #fff6d0 55%, #ffd36b 100%)', + color: 'transparent', + filter: `drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.03}px ${palette.glow})`, + WebkitBackgroundClip: 'text', + backgroundClip: 'text', + } + : { + color: '#ffffff', + textShadow: `0 2px ${height * 0.01}px rgba(0, 0, 0, 0.45), 0 0 ${height * 0.03}px ${palette.glow}`, + }; + + 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); + const rayMask = 'radial-gradient(circle, black 8%, transparent 55%)'; + + return ( + + {!revealed ? ( + + + {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 ? ( + + ) : null} + + {meteorVisible + ? Array.from({length: fx.trailSamples}, (_, index) => { + const sampleProgress = meteorProgress - index * trailSpacing; + if (sampleProgress <= 0) { + return null; + } + const point = getMeteorPoint(sampleProgress, width, height); + const fade = 1 - index / fx.trailSamples; + const radius = headRadius * (0.25 + 0.75 * fade ** 1.3); + return ( +
+ ); + }) + : null} + + {meteorVisible + ? meteorSparks.slice(0, fx.sparks).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) * fx.headScale; + return ( +
+ ); + }) + : null} + + {meteorVisible && haloOpacity > 0 ? ( +
+ {sunburstLayers.map((layer) => ( +
+ ))} +
+ ) : null} + + {meteorVisible ? ( + <> +
+ {props.rarity >= 4 ? ( + <> +
+
+ + ) : null} + + ) : null} + + ) : null} + + {revealed ? ( + +
+ {fx.counterRays ? ( +
+ ) : null} + + {fx.starburst + ? Array.from({length: starburstSpikes}, (_, index) => { + const long = index % 2 === 0; + const length = height * (long ? 1.1 : 0.7) * starburstScale; + return ( +
+ ); + }) + : null} + + {revealMotes.slice(0, fx.motes).map((mote, index) => { + const y = (((mote.y - (seconds - tl.revealStart) * mote.speed) % 1) + 1) % 1; + const size = mote.size * height; + return ( +
+ ); + })} + + {rainSparkles.slice(0, fx.sparkleRain).map((sparkle, index) => { + const y = ((sparkle.offset + (seconds - tl.revealStart) * sparkle.speed) % 1.2) - 0.1; + const size = sparkle.size * height; + return ( +
+ ); + })} + + {Array.from({length: fx.shockwaves}, (_, index) => { + const start = tl.revealStart + index * 0.16; + const progress = interpolate(seconds, [start, start + 0.9], [0, 1], { + ...clamp, + easing: Easing.out(Easing.quad), + }); + if (progress <= 0 || progress >= 1) { + return null; + } + return ( +
+ ); + })} + +
+
+
+ {fx.sheen && seconds >= tl.emblemSettled ? ( +
+ ) : null} +
+
+
+ {rankLetter} +
+
+ +
+
+ {props.label} +
+
+ {starTimes.map((time, index) => { + const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.6, 1], clamp); + const flare = interpolate(seconds, [time, time + 0.3], [1, 0], clamp); + const flareSize = starSize * 2.4 * fx.starFlare; + return ( +
= time ? 1 : 0, + position: 'relative', + transform: `scale(${pop})`, + width: starSize, + }} + > +
+ +
+ ); + })} +
+
+ + ) : null} + + {flashOpacity > 0 ? ( + +
+ + + ) : null} + + ); +}; diff --git a/renderer/src/remotion/Root.jsx b/renderer/src/remotion/Root.jsx index e54c90f..f0ba263 100644 --- a/renderer/src/remotion/Root.jsx +++ b/renderer/src/remotion/Root.jsx @@ -1,4 +1,6 @@ import {Composition} from 'remotion'; +import {GenshinComposition} from './GenshinComposition.jsx'; +import {genshinFrameSizes, genshinTotalSeconds} from './genshin-timeline.js'; import {WheelComposition} from './WheelComposition.jsx'; /** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */ @@ -29,15 +31,50 @@ export const getCompositionMetadata = (props) => { }; }; +/** @type {import('../schemas/gacha-request.js').GachaRenderRequest} */ +const defaultGenshinProps = { + label: 'Pizza', + rarity: 5, + fps: 24, + width: 640, + seed: 7, +}; + +/** + * @param {{fps: number, width: number}} props + */ +export const getGenshinCompositionMetadata = (props) => { + const fps = props.fps || defaultGenshinProps.fps; + const width = props.width in genshinFrameSizes ? props.width : defaultGenshinProps.width; + return { + durationInFrames: Math.ceil(genshinTotalSeconds * fps), + fps, + height: genshinFrameSizes[/** @type {640 | 854} */ (width)], + width, + }; +}; + export const RemotionRoot = () => ( - getCompositionMetadata(props)} - component={WheelComposition} - defaultProps={defaultProps} - durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)} - fps={defaultProps.fps} - height={defaultProps.size} - id="WheelGif" - width={defaultProps.size} - /> + <> + getCompositionMetadata(props)} + component={WheelComposition} + defaultProps={defaultProps} + durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)} + fps={defaultProps.fps} + height={defaultProps.size} + id="WheelGif" + width={defaultProps.size} + /> + getGenshinCompositionMetadata(props)} + component={GenshinComposition} + defaultProps={defaultGenshinProps} + durationInFrames={Math.ceil(genshinTotalSeconds * defaultGenshinProps.fps)} + fps={defaultGenshinProps.fps} + height={genshinFrameSizes[defaultGenshinProps.width]} + id="GenshinWish" + width={defaultGenshinProps.width} + /> + ); diff --git a/renderer/src/remotion/genshin-timeline.js b/renderer/src/remotion/genshin-timeline.js new file mode 100644 index 0000000..12514b3 --- /dev/null +++ b/renderer/src/remotion/genshin-timeline.js @@ -0,0 +1,274 @@ +/** + * Timing, palette, and geometry for the Genshin-style meteor wish served on + * /api/genshin. Kept free of React so the choreography can be unit tested. + */ + +/** @typedef {3 | 4 | 5} GenshinRarity */ + +/** + * @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 genshinTotalSeconds = 7; + +/** Landscape frame heights for each requested width: 16:9, rounded to even pixels for H.264. */ +export const genshinFrameSizes = Object.freeze({640: 360, 854: 480}); + +/** + * 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 genshinTimeline = 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, +}); + +/** 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'; + +/** @type {Record} */ +const palettes = { + 3: {core: '#f2f8ff', glow: '#6fb6ff', deep: '#163a8a', emblem: '#9fd0ff, #3a6fd8'}, + 4: {core: '#fbf2ff', glow: '#c88cff', deep: '#4a1d8f', emblem: '#e2c2ff, #8a4ae6'}, + 5: {core: '#fffbe8', glow: '#ffd36b', deep: '#7a4a10', emblem: '#fff0b8, #e2a12e'}, +}; + +/** + * @param {GenshinRarity} rarity + * @returns {RarityPalette} + */ +export const getRarityPalette = (rarity) => palettes[rarity]; + +/** + * 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 + * @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 * 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, + 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 {GenshinRarity} rarity + * @returns {number[]} + */ +export const getStarRevealTimes = (rarity) => + Array.from({length: rarity}, (_, index) => genshinTimeline.starsStart + index * genshinTimeline.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. + * @property {number} speed Twinkle speed in radians per second. + * @property {boolean} glint Draws a cross-shaped glint when it flares. + */ + +/** + * @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, + 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. + * + * @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); +}; + +/** Rank letter shown on the emblem for each rarity. */ +const rankLetters = {3: 'B', 4: 'A', 5: 'S'}; + +/** + * @param {GenshinRarity} rarity + * @returns {'B' | 'A' | 'S'} + */ +export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLetters[rarity]); + +/** + * @typedef {object} TierEffects + * @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 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. + * @property {boolean} starburst Long light spikes behind the emblem. + * @property {number} raysOpacity Peak opacity of the rotating light rays. + * @property {boolean} counterRays Second ray layer rotating the other way. + * @property {number} motes Glowing motes floating up behind the reveal. + * @property {number} sparkleRain Sparkles falling across the reveal. + * @property {boolean} sheen Light sweep across the emblem and rank letter. + * @property {number} starFlare Size multiplier of each star's pop flare. + */ + +/** + * How loud each tier is. Every beat exists at every tier; higher tiers get + * more of it, so 4★ clearly beats 3★ and 5★ clearly beats both. + * + * @type {Record} + */ +const tierEffects = { + 3: { + headScale: 1, + trailSamples: 22, + sparks: 12, + halo: false, + skyFlood: 0, + shake: 0, + shockwaves: 1, + starburst: false, + raysOpacity: 0.16, + counterRays: false, + motes: 14, + sparkleRain: 0, + sheen: false, + starFlare: 1, + }, + 4: { + headScale: 1.3, + trailSamples: 34, + sparks: 36, + halo: false, + skyFlood: 0.25, + shake: 0.012, + shockwaves: 2, + starburst: false, + raysOpacity: 0.42, + counterRays: false, + motes: 32, + sparkleRain: 0, + sheen: false, + starFlare: 1.4, + }, + 5: { + headScale: 1.75, + trailSamples: 48, + sparks: 80, + halo: true, + skyFlood: 0.7, + shake: 0.03, + shockwaves: 3, + starburst: true, + raysOpacity: 0.7, + counterRays: true, + motes: 56, + sparkleRain: 40, + sheen: true, + starFlare: 2, + }, +}; + +/** + * @param {GenshinRarity} rarity + * @returns {TierEffects} + */ +export const getTierEffects = (rarity) => tierEffects[rarity]; + +/** + * Decaying impact shake offset in pixels, zero before `start` and after it + * settles. + * + * @param {number} seconds + * @param {number} start + * @param {number} amplitude Pixels at the moment of impact. + * @returns {{x: number, y: number}} + */ +export const getShakeOffset = (seconds, start, amplitude) => { + const elapsed = seconds - start; + if (amplitude <= 0 || elapsed < 0 || elapsed > 0.8) { + return {x: 0, y: 0}; + } + const decay = amplitude * (1 - elapsed / 0.8) ** 2; + return {x: Math.sin(elapsed * 71) * decay, y: Math.cos(elapsed * 53) * decay}; +}; diff --git a/renderer/src/render/render-genshin.js b/renderer/src/render/render-genshin.js new file mode 100644 index 0000000..cd4af1b --- /dev/null +++ b/renderer/src/render/render-genshin.js @@ -0,0 +1,24 @@ +import {renderComposition} from './render-composition.js'; + +/** + * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest + * @typedef {import('./render-composition.js').RenderResult} RenderResult + */ + +/** + * Renders the Genshin-style meteor wish as a silent H.264 MP4, which Telegram + * plays as a looping animation and which keeps the glow gradients free of GIF + * banding. + * + * @param {GachaRenderRequest} inputProps + * @param {{timeoutInMilliseconds: number}} options + * @returns {Promise} + */ +export const renderGenshinVideo = (inputProps, options) => + renderComposition({ + compositionId: 'GenshinWish', + filename: 'genshin.mp4', + inputProps: {...inputProps}, + media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true}, + timeoutInMilliseconds: options.timeoutInMilliseconds, + }); diff --git a/renderer/src/routes/gacha.js b/renderer/src/routes/gacha.js index 30df889..79b8f5e 100644 --- a/renderer/src/routes/gacha.js +++ b/renderer/src/routes/gacha.js @@ -14,6 +14,7 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js'; * @property {AppConfig} config * @property {{tryAcquire: () => boolean, release: () => void}} semaphore * @property {(request: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} renderGacha + * @property {string} [name] Wish style: serves /api/ and names the file .mp4. Defaults to gacha. */ /** @@ -24,7 +25,8 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js'; * @param {GachaRouteDeps} deps */ export const registerGachaRoute = async (app, deps) => { - app.post('/api/gacha', async (request, reply) => { + const name = deps.name ?? 'gacha'; + app.post(`/api/${name}`, async (request, reply) => { if (!isAuthorized(request, deps.config.apiToken)) { return reply.code(401).send({error: 'unauthorized'}); } @@ -52,7 +54,7 @@ export const registerGachaRoute = async (app, deps) => { return reply .header('Content-Type', 'video/mp4') - .header('Content-Disposition', 'inline; filename="gacha.mp4"') + .header('Content-Disposition', `inline; filename="${name}.mp4"`) .header('Cache-Control', 'no-store') .header('X-Gacha-Rarity', String(gachaRequest.rarity)) .header('X-Render-Duration-Ms', String(result.durationMs)) diff --git a/renderer/src/server.js b/renderer/src/server.js index 16cea36..dbba00c 100644 --- a/renderer/src/server.js +++ b/renderer/src/server.js @@ -3,6 +3,7 @@ import {loadConfig} from './config.js'; import {createRenderSemaphore} from './lib/render-semaphore.js'; import {getRemotionServeUrl} from './render/remotion-bundle.js'; import {closeGachaBrowser, renderGachaVideo, warmGachaBrowser} from './render/render-gacha.js'; +import {renderGenshinVideo} from './render/render-genshin.js'; import {renderWheelGif} from './render/render-gif.js'; import {registerGachaRoute} from './routes/gacha.js'; import {registerGifRoute} from './routes/gif.js'; @@ -19,6 +20,7 @@ import {registerHealthRoute} from './routes/health.js'; * @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 {(request: GachaRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGenshin] * @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the gacha browser before serving. */ export const buildServer = async (options = {}) => { @@ -41,7 +43,7 @@ export const buildServer = async (options = {}) => { }); await registerHealthRoute(app); - // One semaphore across both routes: they share the same browser and CPU. + // One semaphore across every route: renders share the CPU. const semaphore = createRenderSemaphore(config.maxConcurrentRenders); await registerGifRoute(app, { config, @@ -53,6 +55,12 @@ export const buildServer = async (options = {}) => { renderGacha: options.renderGacha ?? renderGachaVideo, semaphore, }); + await registerGachaRoute(app, { + config, + name: 'genshin', + renderGacha: options.renderGenshin ?? renderGenshinVideo, + semaphore, + }); // The gacha wish keeps one headless Chrome alive between renders. app.addHook('onClose', closeGachaBrowser); diff --git a/renderer/test/gacha-route.test.js b/renderer/test/gacha-route.test.js index 035e509..9ccafdb 100644 --- a/renderer/test/gacha-route.test.js +++ b/renderer/test/gacha-route.test.js @@ -157,4 +157,34 @@ describe('POST /api/gacha', () => { await app.close(); }); + + test('serves the Genshin-style wish on /api/genshin with the same contract', async () => { + /** @type {string[]} */ + const calls = []; + const app = await build( + {}, + { + renderGacha: async () => { + calls.push('gacha'); + return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}; + }, + renderGenshin: async (request) => { + calls.push(`genshin:${request.label}:${request.rarity}`); + return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}; + }, + }, + ); + + const response = await app.inject({method: 'POST', url: '/api/genshin', payload: {label: 'Bún bò', rarity: 5}}); + const invalid = await app.inject({method: 'POST', url: '/api/genshin', payload: {label: 'Bún bò', rarity: 9}}); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('video/mp4'); + expect(response.headers['content-disposition']).toBe('inline; filename="genshin.mp4"'); + expect(response.headers['x-gacha-rarity']).toBe('5'); + expect(invalid.statusCode).toBe(400); + expect(calls).toEqual(['genshin:Bún bò:5']); + + await app.close(); + }); }); diff --git a/renderer/test/genshin-timeline.test.js b/renderer/test/genshin-timeline.test.js new file mode 100644 index 0000000..f7a1a52 --- /dev/null +++ b/renderer/test/genshin-timeline.test.js @@ -0,0 +1,110 @@ +import {describe, expect, test} from 'vitest'; +import { + createStarfield, + genshinTimeline, + genshinTotalSeconds, + getLabelFontSize, + emblemCenter, + getMeteorPoint, + getRankLetter, + getRarityPalette, + getShakeOffset, + getStarRevealTimes, + getTierEffects, + getTwinkle, +} from '../src/remotion/genshin-timeline.js'; + +describe('genshin timeline', () => { + test('phases run in order and finish before the clip ends', () => { + const tl = genshinTimeline; + 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(genshinTotalSeconds); + }); + + 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(genshinTimeline.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 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).toBeLessThan(0); + expect(start.y).toBeLessThan(0); + 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); + }); + + test('starfield is deterministic per seed', () => { + expect(createStarfield(5, 7)).toEqual(createStarfield(5, 7)); + 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)); + }); + + test('emblem shows B, A, S rank letters by rarity', () => { + expect([3, 4, 5].map((rarity) => getRankLetter(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['B', 'A', 'S']); + }); + + test('every numeric effect grows with rarity and 5★ unlocks the extras', () => { + const [three, four, five] = [3, 4, 5].map((rarity) => getTierEffects(/** @type {3 | 4 | 5} */ (rarity))); + for (const key of /** @type {const} */ ([ + 'headScale', + 'trailSamples', + 'sparks', + 'shockwaves', + 'raysOpacity', + 'motes', + 'starFlare', + ])) { + expect(four?.[key], key).toBeGreaterThan(three?.[key] ?? Infinity); + expect(five?.[key], key).toBeGreaterThan(four?.[key] ?? Infinity); + } + expect(four?.shake).toBeGreaterThan(three?.shake ?? Infinity); + expect(five?.shake).toBeGreaterThan(four?.shake ?? Infinity); + expect(five?.skyFlood).toBeGreaterThan(four?.skyFlood ?? Infinity); + expect([three?.halo, four?.halo, five?.halo]).toEqual([false, false, true]); + expect(five).toMatchObject({starburst: true, counterRays: true, sheen: true}); + expect(three?.sparkleRain).toBe(0); + expect(five?.sparkleRain).toBeGreaterThan(0); + }); + + test('impact shake decays to rest', () => { + expect(getShakeOffset(1, 2, 10)).toEqual({x: 0, y: 0}); + expect(getShakeOffset(3, 2, 10)).toEqual({x: 0, y: 0}); + expect(getShakeOffset(2.1, 2, 0)).toEqual({x: 0, y: 0}); + const early = getShakeOffset(2.05, 2, 10); + const late = getShakeOffset(2.7, 2, 10); + expect(Math.hypot(early.x, early.y)).toBeGreaterThan(Math.hypot(late.x, late.y)); + }); +});