From d2f09ecb33900ed3c27bea94536fe3b9b41f5ee9 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 15:07:56 +0700 Subject: [PATCH] feat(gacha): add /api/gachabeta toon-shaded wish style The beta wish renders an 8-second astrology-themed night sky in toon shading through a perspective action camera: it dollies toward a hero cloud, a comet lights the cloud's rim from behind and bursts through it, the camera chases the comet as its flare builds to a starburst, and the label appears with an S, SS, or SSS rank. It shares the /api/gacha request contract and render slots. --- renderer/README.md | 10 + renderer/scripts/api-smoke.js | 15 + renderer/scripts/render-fixtures.js | 11 +- .../src/remotion/GachaBetaComposition.jsx | 531 ++++++++++++++++++ renderer/src/remotion/Root.jsx | 17 +- renderer/src/remotion/gacha-beta-scene.js | 212 +++++++ renderer/src/render/render-gacha.js | 19 +- renderer/src/routes/gacha.js | 3 +- renderer/src/server.js | 9 +- renderer/test/gacha-beta-scene.test.js | 90 +++ renderer/test/gacha-route.test.js | 29 + 11 files changed, 934 insertions(+), 12 deletions(-) create mode 100644 renderer/src/remotion/GachaBetaComposition.jsx create mode 100644 renderer/src/remotion/gacha-beta-scene.js create mode 100644 renderer/test/gacha-beta-scene.test.js diff --git a/renderer/README.md b/renderer/README.md index 91aa41d..8d6ba5f 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -64,6 +64,16 @@ 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. +### Gacha wish beta + +`POST /api/gachabeta` takes the same body and returns the same response as +`/api/gacha`, rendering an 8-second beta style instead: a cosmic, astrology +themed night sky in toon shading (flat colour bands, no outlines) seen through +a perspective action camera. The camera dollies toward a hero cloud, a comet +lights its rim from behind and bursts through it, the camera chases the comet +as its flare builds, and after the burst the label appears with a rank +(`S`, `SS`, `SSS` for rarity 3, 4, 5). + 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 diff --git a/renderer/scripts/api-smoke.js b/renderer/scripts/api-smoke.js index d00c7ca..af39e0b 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 beta = await app.inject({ + method: 'POST', + url: '/api/gachabeta', + headers: { + authorization: 'Bearer smoke-token', + }, + payload: {label: 'Bún bò', rarity: 5}, + }); + + if (beta.statusCode !== 200 || beta.rawPayload.subarray(4, 8).toString() !== 'ftyp') { + throw new Error(`Expected gachabeta MP4, got ${beta.statusCode}: ${beta.body.slice(0, 200)}`); + } + + console.log(`api-smoke gachabeta: ${beta.rawPayload.byteLength} bytes`); } finally { await app.close(); } diff --git a/renderer/scripts/render-fixtures.js b/renderer/scripts/render-fixtures.js index 95b76b2..28ef8a7 100644 --- a/renderer/scripts/render-fixtures.js +++ b/renderer/scripts/render-fixtures.js @@ -1,6 +1,6 @@ import {mkdir, writeFile} from 'node:fs/promises'; import path from 'node:path'; -import {renderGachaVideo} from '../src/render/render-gacha.js'; +import {renderGachaBetaVideo, renderGachaVideo} from '../src/render/render-gacha.js'; import {renderWheelGif} from '../src/render/render-gif.js'; const smoke = process.argv.includes('--smoke'); @@ -72,3 +72,12 @@ for (const fixture of smoke ? fixtures.slice(0, 1) : fixtures) { for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) { await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 30000})); } + +const betaFixture = { + label: 'Bún bò', + rarity: /** @type {const} */ (5), + fps: /** @type {const} */ (24), + width: /** @type {const} */ (640), + seed: 7, +}; +await save('gachabeta-5-star.mp4', await renderGachaBetaVideo(betaFixture, {timeoutInMilliseconds: 30000})); diff --git a/renderer/src/remotion/GachaBetaComposition.jsx b/renderer/src/remotion/GachaBetaComposition.jsx new file mode 100644 index 0000000..e6fefed --- /dev/null +++ b/renderer/src/remotion/GachaBetaComposition.jsx @@ -0,0 +1,531 @@ +import {useMemo} from 'react'; +import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + gachaBetaTimeline, + getBetaRank, + getBetaShake, + getCamera, + getCometGlow, + getCometPosition, + heroCloud, + pierceTime, + project, +} from './gacha-beta-scene.js'; +import {createRandom, createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} 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'}); + +/** Toon shading: flat colour bands with hard edges and no outline. */ +const skyBands = + 'linear-gradient(180deg, #0a0c2c 0% 28%, #15185a 28% 52%, #262a80 52% 72%, #3a44a6 72% 88%, #5664c4 88%)'; +const cloudShade = 'radial-gradient(circle at 38% 32%, #f6f4ff 0% 44%, #cdd1f6 44% 70%, #959cdb 70%)'; +const heroShade = 'radial-gradient(circle at 38% 30%, #ffffff 0% 46%, #d8dcfb 46% 72%, #a0a8e4 72%)'; + +const trailSamples = 44; +const trailStep = 0.01; + +/** + * Puffs that make one toon cloud, in units of the cloud radius. + * + * @param {() => number} random + * @param {number} count + */ +const createPuffs = (random, count) => + Array.from({length: count}, (_, index) => { + const angle = (index / count) * Math.PI * 2 + random() * 0.6; + const reach = index === 0 ? 0 : 0.35 + random() * 0.45; + return { + dx: Math.cos(angle) * reach, + dy: Math.sin(angle) * reach * 0.45 + (random() - 0.3) * 0.15, + r: 0.38 + random() * 0.3, + }; + }); + +/** + * Every seeded layout for one roll: stars, constellation links, and the + * clouds scattered through the sky. + * + * @param {number} seed + */ +const createBetaScene = (seed) => { + const random = createRandom(seed + 101); + const stars = createStarfield(80, seed); + const links = Array.from({length: 7}, () => { + const a = Math.floor(random() * 24); + return [a, a + 1 + Math.floor(random() * 3)]; + }); + const clouds = Array.from({length: 14}, () => ({ + x: (random() - 0.5) * 3200, + y: 80 + random() * 420, + z: 1100 + random() * 5200, + radius: 160 + random() * 220, + puffs: createPuffs(random, 6), + })).sort((a, b) => b.z - a.z); + const heroPuffs = createPuffs(random, 13); + const confetti = Array.from({length: 34}, () => ({ + x: random(), + offset: random(), + speed: 0.18 + random() * 0.3, + size: 0.02 + random() * 0.025, + spin: (random() - 0.5) * 400, + hue: Math.floor(random() * 4), + })); + return {stars, links, clouds, heroPuffs, confetti}; +}; + +const confettiColors = ['#ffd36b', '#ff8cd8', '#8cff9a', '#8fd0ff']; + +/** + * @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, fill: string, shade: string}} props + */ +const ToonStar = ({size, fill, shade}) => ( + + + + + +); + +/** + * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest + */ + +/** + * Beta wish: a cosmic, astrology-themed night sky in toon shading. The + * camera dollies toward a hero cloud, a comet bursts through it, the camera + * chases the comet as it flares, and after the burst the result appears with + * its rank. + * + * @param {GachaRenderRequest} props + */ +export const GachaBetaComposition = (props) => { + const frame = useCurrentFrame(); + const {fps, width, height} = useVideoConfig(); + const seconds = frame / fps; + const tl = gachaBetaTimeline; + const palette = getRarityPalette(props.rarity); + const scene = useMemo(() => createBetaScene(props.seed), [props.seed]); + + const camera = getCamera(seconds); + const shake = getBetaShake(seconds, height); + const comet = getCometPosition(seconds); + const cometScreen = project(comet, camera, width, height); + const glow = getCometGlow(seconds); + const cometVisible = seconds >= tl.cometAppear && seconds < tl.flashPeak; + const burst = interpolate(seconds, [tl.flightEnd - 0.1, tl.flashPeak], [0, 1], clamp); + const flash = interpolate(seconds, [tl.flightEnd, tl.flashPeak, tl.flashEnd], [0, 1, 0], clamp); + const cardVisible = seconds >= tl.cardIn - 0.2; + const pierceBurst = interpolate(seconds, [pierceTime, pierceTime + 0.9], [0, 1], { + ...clamp, + easing: Easing.out(Easing.cubic), + }); + const speedLines = interpolate( + seconds, + [tl.pierceEnd, tl.pierceEnd + 0.4, tl.flightEnd - 0.6, tl.flightEnd], + [0, 1, 1, 0], + clamp, + ); + + /** @type {{depth: number, node: import('react').ReactNode}[]} */ + const drawables = []; + + for (const [index, cloud] of scene.clouds.entries()) { + const screen = project(cloud, camera, width, height); + if (!screen) { + continue; + } + const radius = cloud.radius * screen.scale; + drawables.push({ + depth: screen.depth, + node: ( +
+ {cloud.puffs.map((puff, puffIndex) => ( +
+ ))} +
+ ), + }); + } + + const hero = project(heroCloud, camera, width, height); + if (hero) { + const radius = heroCloud.radius * hero.scale; + drawables.push({ + depth: hero.depth, + node: ( +
+ {scene.heroPuffs.map((puff, index) => { + const distance = Math.hypot(puff.dx, puff.dy) || 1; + const push = pierceBurst * (0.6 + (1 - Math.min(1, distance)) * 1.4); + const ox = (puff.dx / distance) * push; + const oy = (puff.dy / distance) * push; + const opacity = 1 - pierceBurst * (distance < 0.5 ? 0.95 : 0.55); + return ( +
+ ); + })} +
+ ), + }); + } + + if (cometVisible && cometScreen) { + const trail = []; + for (let index = trailSamples - 1; index >= 1; index -= 1) { + const past = seconds - index * trailStep; + if (past < tl.cometAppear) { + continue; + } + const point = project(getCometPosition(past), camera, width, height); + if (!point) { + continue; + } + const fade = 1 - index / trailSamples; + const radius = (14 + 26 * glow) * point.scale * fade; + trail.push( +
, + ); + } + const headSize = (34 + 60 * glow + 90 * burst) * cometScreen.scale; + const flare = headSize * (1.4 + glow * 3); + drawables.push({ + depth: cometScreen.depth, + node: ( +
+ {trail} +
+ {[0, 90, 45, 135].map((angle, index) => ( +
+ ))} +
+ +
+ {burst > 0 ? ( +
+ ) : null} +
+ ), + }); + } + + // While the comet hides behind the hero cloud, its light spills around the + // cloud's rim so it visibly appears from behind before it breaks through. + const backlight = interpolate(seconds, [tl.cometAppear, tl.pierceStart, pierceTime], [0, 1, 0.6], clamp); + if (backlight > 0 && seconds < pierceTime) { + const light = project({...comet, y: heroCloud.y}, camera, width, height); + if (light) { + const radius = heroCloud.radius * 1.7 * light.scale; + drawables.push({ + depth: light.depth + 1, + node: ( +
+ ), + }); + } + } + + drawables.sort((a, b) => b.depth - a.depth); + + const rank = getBetaRank(props.rarity); + const cardPop = interpolate(seconds, [tl.cardIn, tl.cardIn + 0.35, tl.cardIn + 0.55], [0.6, 1.08, 1], { + ...clamp, + easing: Easing.out(Easing.cubic), + }); + const rankPop = interpolate(seconds, [tl.rankIn, tl.rankIn + 0.18, tl.rankIn + 0.36], [0, 1.35, 1], clamp); + + return ( + + + + + + + + + {Array.from({length: 12}, (_, index) => { + const angle = (index / 12) * Math.PI * 2; + return ( + + + + + ); + })} + + {scene.links.map(([a, b], index) => { + const from = scene.stars[a ?? 0]; + const to = scene.stars[b ?? 0]; + if (!from || !to) { + return null; + } + return ( + + ); + })} + + + {scene.stars.map((star, index) => { + const twinkle = getTwinkle(seconds, star); + const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle); + return ( +
0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined, + height: size, + left: star.x * width - camera.x * 0.02, + opacity: 0.25 + 0.75 * twinkle, + position: 'absolute', + top: star.y * height * 0.7, + width: size, + }} + /> + ); + })} + + {drawables.map((drawable) => drawable.node)} + + {speedLines > 0 && cometScreen + ? Array.from({length: 18}, (_, index) => { + const angle = (index / 18) * 360 + ((index * 37) % 11); + const reach = ((seconds * 2.2 + index * 0.137) % 1) * width * 0.7; + return ( +
+ ); + }) + : null} + + + {cardVisible ? ( + +
+ {scene.confetti.map((piece, index) => { + const y = ((piece.offset + (seconds - tl.cardIn) * piece.speed) % 1.2) - 0.1; + const size = piece.size * height; + return ( +
+ +
+ ); + })} +
+
0 ? 1 : 0, + paddingRight: height * 0.03, + transform: `scale(${rankPop})`, + WebkitBackgroundClip: 'text', + backgroundClip: 'text', + filter: `drop-shadow(0 ${height * 0.012}px 0 #8a4b00) drop-shadow(0 0 ${height * 0.04}px ${palette.glow})`, + }} + > + {rank} +
+
+ {props.label} +
+
+ + ) : null} + + {flash > 0 ? : null} + + ); +}; diff --git a/renderer/src/remotion/Root.jsx b/renderer/src/remotion/Root.jsx index cb5c605..6e414f0 100644 --- a/renderer/src/remotion/Root.jsx +++ b/renderer/src/remotion/Root.jsx @@ -1,5 +1,7 @@ import {Composition} from 'remotion'; +import {GachaBetaComposition} from './GachaBetaComposition.jsx'; import {GachaComposition} from './GachaComposition.jsx'; +import {gachaBetaTotalSeconds} from './gacha-beta-scene.js'; import {gachaTotalSeconds} from './gacha-timeline.js'; import {WheelComposition} from './WheelComposition.jsx'; import {gachaFrameSizes} from '../schemas/gacha-request.js'; @@ -43,12 +45,13 @@ const defaultGachaProps = { /** * @param {{fps: number, width: number}} props + * @param {number} [totalSeconds] */ -export const getGachaCompositionMetadata = (props) => { +export const getGachaCompositionMetadata = (props, totalSeconds = gachaTotalSeconds) => { const fps = props.fps || defaultGachaProps.fps; const width = props.width in gachaFrameSizes ? props.width : defaultGachaProps.width; return { - durationInFrames: Math.ceil(gachaTotalSeconds * fps), + durationInFrames: Math.ceil(totalSeconds * fps), fps, height: gachaFrameSizes[/** @type {640 | 854} */ (width)], width, @@ -77,5 +80,15 @@ export const RemotionRoot = () => ( id="GachaWish" width={defaultGachaProps.width} /> + getGachaCompositionMetadata(props, gachaBetaTotalSeconds)} + component={GachaBetaComposition} + defaultProps={defaultGachaProps} + durationInFrames={Math.ceil(gachaBetaTotalSeconds * defaultGachaProps.fps)} + fps={defaultGachaProps.fps} + height={gachaFrameSizes[defaultGachaProps.width]} + id="GachaBetaWish" + width={defaultGachaProps.width} + /> ); diff --git a/renderer/src/remotion/gacha-beta-scene.js b/renderer/src/remotion/gacha-beta-scene.js new file mode 100644 index 0000000..d0e0ea2 --- /dev/null +++ b/renderer/src/remotion/gacha-beta-scene.js @@ -0,0 +1,212 @@ +/** + * Scene math for the beta wish: a toon-shaded night sky viewed through a + * perspective camera that dollies toward a hero cloud, follows a comet that + * pierces it, and holds on the comet as it flares and bursts. Kept free of + * React so the choreography can be unit tested. + */ + +/** @typedef {3 | 4 | 5} GachaRarity */ + +/** + * @typedef {object} Vec3 + * @property {number} x + * @property {number} y + * @property {number} z + */ + +/** + * @typedef {object} Camera + * @property {number} x + * @property {number} y + * @property {number} z + * @property {number} roll Screen roll in degrees. + */ + +/** + * @typedef {object} Projection + * @property {number} x Screen pixels. + * @property {number} y Screen pixels. + * @property {number} scale Pixels per world unit at that depth. + * @property {number} depth World units in front of the camera. + */ + +export const gachaBetaTotalSeconds = 8; + +/** Seconds from the start of the clip for each beat of the three phases. */ +export const gachaBetaTimeline = Object.freeze({ + dollyEnd: 2, + cometAppear: 1.2, + pierceStart: 1.9, + pierceEnd: 2.6, + flightEnd: 4.8, + flashPeak: 5.05, + flashEnd: 5.45, + cardIn: 5.2, + rankIn: 5.6, +}); + +/** The cloud the camera dollies toward and the comet pierces. */ +export const heroCloud = Object.freeze({x: 0, y: 30, z: 800, radius: 170}); + +/** Where the comet waits behind the hero cloud, then where it exits in front. */ +const cometHidden = Object.freeze({x: 60, y: 10, z: 1500}); +const cometPierceFrom = Object.freeze({x: 40, y: 20, z: 1300}); +const cometPierceTo = Object.freeze({x: 0, y: 30, z: 500}); + +/** The camera trails the comet from behind and slightly above it. */ +const cameraFollowOffset = Object.freeze({x: 0, y: -30, z: -420}); + +/** + * Seconds at which the comet crosses the hero cloud's plane. The pierce moves + * linearly in depth, so the crossing is a fixed fraction of the segment. + */ +export const pierceTime = + gachaBetaTimeline.pierceStart + + ((gachaBetaTimeline.pierceEnd - gachaBetaTimeline.pierceStart) * (cometPierceFrom.z - heroCloud.z)) / + (cometPierceFrom.z - cometPierceTo.z); + +/** + * @param {number} value + * @returns {number} + */ +const clamp01 = (value) => Math.min(1, Math.max(0, value)); + +/** + * @param {number} t + * @returns {number} + */ +const easeInOutSine = (t) => -(Math.cos(Math.PI * clamp01(t)) - 1) / 2; + +/** + * @param {number} t + * @returns {number} + */ +const easeOutCubic = (t) => 1 - (1 - clamp01(t)) ** 3; + +/** + * @param {Vec3} a + * @param {Vec3} b + * @param {number} t + * @returns {Vec3} + */ +const lerp3 = (a, b, t) => ({x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: a.z + (b.z - a.z) * t}); + +/** + * Comet position in world units. It waits behind the hero cloud, punches + * straight through it toward the camera, then flies off on a rising arc and + * slows as it gathers light for the burst. + * + * @param {number} seconds + * @returns {Vec3} + */ +export const getCometPosition = (seconds) => { + const tl = gachaBetaTimeline; + if (seconds < tl.pierceStart) { + const t = clamp01((seconds - tl.cometAppear) / (tl.pierceStart - tl.cometAppear)); + return lerp3(cometHidden, cometPierceFrom, t); + } + if (seconds < tl.pierceEnd) { + return lerp3(cometPierceFrom, cometPierceTo, (seconds - tl.pierceStart) / (tl.pierceEnd - tl.pierceStart)); + } + const u = easeOutCubic((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd)); + return { + x: cometPierceTo.x + 600 * u, + y: cometPierceTo.y - 300 * u + 120 * Math.sin(Math.PI * u), + z: cometPierceTo.z + 2600 * u, + }; +}; + +/** + * Action camera: a slow dolly toward the hero cloud, then a chase that locks + * onto the comet from behind with a little lag and banks into its turn. + * + * @param {number} seconds + * @returns {Camera} + */ +export const getCamera = (seconds) => { + const tl = gachaBetaTimeline; + const dolly = {x: 0, y: 0, z: 250 * easeInOutSine(seconds / tl.dollyEnd)}; + const lagged = getCometPosition(Math.max(tl.pierceEnd, seconds - 0.12)); + const chase = { + x: lagged.x + cameraFollowOffset.x, + y: lagged.y + cameraFollowOffset.y, + z: lagged.z + cameraFollowOffset.z, + }; + const follow = easeInOutSine((seconds - pierceTime) / (tl.pierceEnd + 0.4 - pierceTime)); + const position = lerp3(dolly, {...chase, z: Math.max(dolly.z, chase.z)}, follow); + const bank = seconds > tl.pierceEnd ? 7 * Math.sin(Math.PI * clamp01((seconds - tl.pierceEnd) / 1.6)) : 0; + return {...position, roll: bank * follow}; +}; + +/** + * Perspective projection onto the frame. Returns null for points behind or + * too close to the camera. + * + * @param {Vec3} point + * @param {Camera} camera + * @param {number} width + * @param {number} height + * @returns {Projection | null} + */ +export const project = (point, camera, width, height) => { + const depth = point.z - camera.z; + if (depth < 30) { + return null; + } + const focal = height * 1.2; + const scale = focal / depth; + return { + x: width / 2 + (point.x - camera.x) * scale, + y: height / 2 + (point.y - camera.y) * scale, + scale, + depth, + }; +}; + +/** + * Comet glow from 0 to 1: it ignites behind the cloud, then builds steadily + * through the flight until the burst. + * + * @param {number} seconds + * @returns {number} + */ +export const getCometGlow = (seconds) => { + const tl = gachaBetaTimeline; + if (seconds < tl.pierceEnd) { + return 0.35 * clamp01((seconds - tl.cometAppear) / 0.4); + } + return 0.35 + 0.65 * clamp01((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd)) ** 2; +}; + +/** + * Decaying shake in pixels for the pierce and the burst. + * + * @param {number} seconds + * @param {number} height + * @returns {{x: number, y: number}} + */ +export const getBetaShake = (seconds, height) => { + let x = 0; + let y = 0; + for (const [start, amplitude] of [ + [pierceTime, 0.02], + [gachaBetaTimeline.flightEnd, 0.035], + ]) { + const elapsed = seconds - (start ?? 0); + if (elapsed >= 0 && elapsed < 0.6) { + const decay = (amplitude ?? 0) * height * (1 - elapsed / 0.6) ** 2; + x += Math.sin(elapsed * 77) * decay; + y += Math.cos(elapsed * 59) * decay; + } + } + return {x, y}; +}; + +/** @type {Record} */ +const betaRanks = {3: 'S', 4: 'SS', 5: 'SSS'}; + +/** + * @param {GachaRarity} rarity + * @returns {'S' | 'SS' | 'SSS'} + */ +export const getBetaRank = (rarity) => betaRanks[rarity]; diff --git a/renderer/src/render/render-gacha.js b/renderer/src/render/render-gacha.js index fac435f..a7e5e76 100644 --- a/renderer/src/render/render-gacha.js +++ b/renderer/src/render/render-gacha.js @@ -6,18 +6,23 @@ import {renderComposition} from './render-composition.js'; */ /** - * 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. + * Builds a renderer for one wish composition. Output is 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} + * @param {string} compositionId + * @returns {(inputProps: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise} */ -export const renderGachaVideo = (inputProps, options) => +const createGachaRenderer = (compositionId) => (inputProps, options) => renderComposition({ - compositionId: 'GachaWish', + compositionId, filename: 'gacha.mp4', inputProps: {...inputProps}, media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true}, timeoutInMilliseconds: options.timeoutInMilliseconds, }); + +export const renderGachaVideo = createGachaRenderer('GachaWish'); + +/** The beta wish: toon-shaded 3D-perspective sky, comet through a cloud, SSS reveal. */ +export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish'); diff --git a/renderer/src/routes/gacha.js b/renderer/src/routes/gacha.js index 30df889..1d24251 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} [path] Route path; defaults to /api/gacha. The beta wish shares this contract. */ /** @@ -24,7 +25,7 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js'; * @param {GachaRouteDeps} deps */ export const registerGachaRoute = async (app, deps) => { - app.post('/api/gacha', async (request, reply) => { + app.post(deps.path ?? '/api/gacha', async (request, reply) => { if (!isAuthorized(request, deps.config.apiToken)) { return reply.code(401).send({error: 'unauthorized'}); } diff --git a/renderer/src/server.js b/renderer/src/server.js index 137ff6f..852e22b 100644 --- a/renderer/src/server.js +++ b/renderer/src/server.js @@ -2,7 +2,7 @@ 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 {renderGachaBetaVideo, 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'; @@ -19,6 +19,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.renderGachaBeta] * @param {boolean} [options.warmRemotionBundle] */ export const buildServer = async (options = {}) => { @@ -52,6 +53,12 @@ export const buildServer = async (options = {}) => { renderGacha: options.renderGacha ?? renderGachaVideo, semaphore, }); + await registerGachaRoute(app, { + config, + path: '/api/gachabeta', + renderGacha: options.renderGachaBeta ?? renderGachaBetaVideo, + semaphore, + }); return app; }; diff --git a/renderer/test/gacha-beta-scene.test.js b/renderer/test/gacha-beta-scene.test.js new file mode 100644 index 0000000..4742d8a --- /dev/null +++ b/renderer/test/gacha-beta-scene.test.js @@ -0,0 +1,90 @@ +import {describe, expect, test} from 'vitest'; +import { + gachaBetaTimeline, + gachaBetaTotalSeconds, + getBetaRank, + getBetaShake, + getCamera, + getCometGlow, + getCometPosition, + heroCloud, + pierceTime, + project, +} from '../src/remotion/gacha-beta-scene.js'; + +describe('gacha beta scene', () => { + test('phases run in order inside the clip', () => { + const tl = gachaBetaTimeline; + expect(tl.cometAppear).toBeLessThan(tl.pierceStart); + expect(tl.pierceStart).toBeLessThan(pierceTime); + expect(pierceTime).toBeLessThan(tl.pierceEnd); + expect(tl.pierceEnd).toBeLessThan(tl.flightEnd); + expect(tl.flightEnd).toBeLessThan(tl.flashPeak); + expect(tl.cardIn).toBeLessThan(tl.flashEnd); + expect(tl.rankIn + 1).toBeLessThan(gachaBetaTotalSeconds); + }); + + test('comet waits behind the hero cloud, then crosses its plane at the pierce', () => { + expect(getCometPosition(gachaBetaTimeline.cometAppear).z).toBeGreaterThan(heroCloud.z); + expect(getCometPosition(pierceTime).z).toBeCloseTo(heroCloud.z); + expect(getCometPosition(gachaBetaTimeline.pierceEnd).z).toBeLessThan(heroCloud.z); + }); + + test('comet is hidden behind the cloud: projected inside its silhouette', () => { + const seconds = gachaBetaTimeline.pierceStart; + const camera = getCamera(seconds); + const comet = project(getCometPosition(seconds), camera, 640, 360); + const cloud = project(heroCloud, camera, 640, 360); + expect(comet && cloud).toBeTruthy(); + if (comet && cloud) { + expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeLessThan(heroCloud.radius * cloud.scale); + expect(comet.depth).toBeGreaterThan(cloud.depth); + } + }); + + test('camera dollies toward the cloud, then chases the comet and keeps it in frame', () => { + expect(getCamera(1).z).toBeGreaterThan(getCamera(0).z); + for (const seconds of [3, 3.5, 4, 4.5]) { + const camera = getCamera(seconds); + const comet = project(getCometPosition(seconds), camera, 640, 360); + expect(comet).not.toBeNull(); + if (comet) { + expect(comet.x).toBeGreaterThan(640 * 0.2); + expect(comet.x).toBeLessThan(640 * 0.8); + expect(comet.y).toBeGreaterThan(360 * 0.2); + expect(comet.y).toBeLessThan(360 * 0.8); + } + } + }); + + test('camera never moves backward', () => { + let previous = -Infinity; + for (let seconds = 0; seconds <= gachaBetaTimeline.flightEnd; seconds += 0.05) { + const {z} = getCamera(seconds); + expect(z).toBeGreaterThanOrEqual(previous - 1e-6); + previous = z; + } + }); + + test('projection drops points behind the camera', () => { + expect(project({x: 0, y: 0, z: 10}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360)).toBeNull(); + const centre = project({x: 0, y: 0, z: 500}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360); + expect(centre).toMatchObject({x: 320, y: 180}); + }); + + test('comet glow builds to full by the burst', () => { + expect(getCometGlow(0)).toBe(0); + expect(getCometGlow(3)).toBeGreaterThan(getCometGlow(2)); + expect(getCometGlow(gachaBetaTimeline.flightEnd)).toBeCloseTo(1); + }); + + test('shake fires at the pierce and the burst only', () => { + expect(getBetaShake(1, 360)).toEqual({x: 0, y: 0}); + expect(Math.hypot(...Object.values(getBetaShake(pierceTime + 0.05, 360)))).toBeGreaterThan(0); + expect(Math.hypot(...Object.values(getBetaShake(gachaBetaTimeline.flightEnd + 0.05, 360)))).toBeGreaterThan(0); + }); + + test('ranks map rarity to S, SS, SSS', () => { + expect([3, 4, 5].map((rarity) => getBetaRank(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['S', 'SS', 'SSS']); + }); +}); diff --git a/renderer/test/gacha-route.test.js b/renderer/test/gacha-route.test.js index 035e509..e88f822 100644 --- a/renderer/test/gacha-route.test.js +++ b/renderer/test/gacha-route.test.js @@ -157,4 +157,33 @@ describe('POST /api/gacha', () => { await app.close(); }); + + test('serves the beta wish on /api/gachabeta 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}; + }, + renderGachaBeta: async (request) => { + calls.push(`beta:${request.label}:${request.rarity}`); + return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}; + }, + }, + ); + + const response = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Bún bò', rarity: 5}}); + const invalid = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Bún bò', rarity: 9}}); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('video/mp4'); + expect(response.headers['x-gacha-rarity']).toBe('5'); + expect(invalid.statusCode).toBe(400); + expect(calls).toEqual(['beta:Bún bò:5']); + + await app.close(); + }); });