diff --git a/renderer/README.md b/renderer/README.md index 99aba83..337ccc4 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -1,7 +1,7 @@ # wheelofnames -Self-hosted API that renders wheel-of-names GIF animations and gacha wish -MP4 animations with Remotion. +Self-hosted API that renders wheel-of-names GIF animations with Remotion and +gacha wish MP4 animations with pack-cards. ## API @@ -43,57 +43,43 @@ Authorization: Bearer change-me ```json { - "label": "Pizza", + "label": "Bún bò", "rarity": 5, "fps": 24, "width": 640 } ``` -Renders a 7-second wish animation in the style of a gacha game: a meteor -coloured by rarity (blue 3★, purple 4★, gold 5★) 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/gacha-timeline.js`. -`rarity` is required; `fps` is `24` or `30`; `width` is `640` (360 tall) or -`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. +Renders a 6-second portrait wish: a collectible card pack from +[pack-cards](https://github.com/paubineau/pack-cards) is torn open, its card +spins out with a burst of sparkles, and lands showing the request's `label`, +its rank, and its stars under a polychrome rainbow foil. `rarity` (required) +picks the rank (`B` for 3★, `A` for 4★, `S` for 5★), the star count, the +card's material (rare, epic, legendary), and the colour (blue, purple, gold). +`fps` is `24` or `30`. `width` is the long edge of the portrait frame: +`640` renders 360×640 and `854` renders 480×854. Optional `seed` (integer, +`0` to `2147483647`) seeds the page's randomness; when omitted the service +picks a random one, so every roll looks different. The caller chooses the +result and its rarity — the service only draws it. -### Gacha wish beta +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. No game assets are used. -`POST /api/gachabeta` takes the same body and returns the same response as -`/api/gacha`, rendering a 6-second portrait beta style instead: a collectible card pack -from [pack-cards](https://github.com/paubineau/pack-cards) is torn open, and -its card shows the request's `label`, its rank, and its stars. As on -`/api/gacha`, `rarity` picks the rank (`B` for 3★, `A` for 4★, `S` for 5★), -the star count, the card's material (rare, epic, legendary), and the pack's -colour (blue, purple, gold). The video is portrait, with `width` as its long -edge: `640` renders 360×640 and `854` renders 480×854. - -pack-cards animates on the browser clock, so this route does not use Remotion -compositions. `src/render/render-gacha-beta.js` keeps one headless Chrome per +pack-cards animates on the browser clock, so the wish does not use Remotion +compositions. `src/render/render-gacha.js` keeps one headless Chrome per server process in `--deterministic-mode`, steps virtual time one frame at a time, tears the pack with a scripted drag, captures each frame, and encodes -them with Remotion's bundled ffmpeg. The page and the package are served from -disk; the page has no network access. The first render compiles the pack's -WebGL shaders in software, which takes several seconds, so server start-up -runs one throwaway wish first. +them with Remotion's bundled ffmpeg. The page (`src/gacha/page/`) and the +package are served from disk; the page has no network access. The first render +compiles the pack's WebGL shaders in software, which takes several seconds, so +server start-up runs one throwaway wish first. pack-cards has no npm release, so it is installed from a GitHub tarball pinned 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. -Response is a silent H.264 `video/mp4` (Telegram plays it as an animation) -with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. Both routes share the -`MAX_CONCURRENT_RENDERS` slots. All visuals are drawn procedurally; no game -assets are used. - ## Local Install dependencies and Chromium once: diff --git a/renderer/scripts/api-smoke.js b/renderer/scripts/api-smoke.js index a1859c8..137ed73 100644 --- a/renderer/scripts/api-smoke.js +++ b/renderer/scripts/api-smoke.js @@ -52,7 +52,7 @@ try { headers: { authorization: 'Bearer smoke-token', }, - payload: {label: 'Pizza', rarity: 5}, + payload: {label: 'Bún bò', rarity: 5}, }); if (gacha.statusCode !== 200) { @@ -65,21 +65,6 @@ 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 7f8a96a..a2a2539 100644 --- a/renderer/scripts/render-fixtures.js +++ b/renderer/scripts/render-fixtures.js @@ -1,7 +1,6 @@ import {mkdir, writeFile} from 'node:fs/promises'; import path from 'node:path'; -import {closeGachaBetaBrowser, renderGachaBetaVideo} from '../src/render/render-gacha-beta.js'; -import {renderGachaVideo} from '../src/render/render-gacha.js'; +import {closeGachaBrowser, renderGachaVideo} from '../src/render/render-gacha.js'; import {renderWheelGif} from '../src/render/render-gif.js'; const smoke = process.argv.includes('--smoke'); @@ -51,8 +50,8 @@ 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, 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-5-star', request: {label: 'Bún bò', rarity: 5, fps: 24, width: 640, seed: 7}}, + {name: 'gacha-4-star', request: {label: 'Phở bò tái chín', 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}}, ]; @@ -70,19 +69,10 @@ for (const fixture of smoke ? fixtures.slice(0, 1) : fixtures) { await save(`${fixture.name}.gif`, await renderWheelGif(fixture.request, {timeoutInMilliseconds: 30000})); } -for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) { - await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 30000})); -} - -const betaFixture = { - label: 'Bún bò', - rarity: /** @type {const} */ (5), - fps: /** @type {const} */ (24), - width: /** @type {const} */ (640), - seed: 7, -}; try { - await save('gachabeta-5-star.mp4', await renderGachaBetaVideo(betaFixture, {timeoutInMilliseconds: 60000})); + for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) { + await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 60000})); + } } finally { - await closeGachaBetaBrowser(); + await closeGachaBrowser(); } diff --git a/renderer/src/gacha-beta/page/index.html b/renderer/src/gacha/page/index.html similarity index 93% rename from renderer/src/gacha-beta/page/index.html rename to renderer/src/gacha/page/index.html index d50ac38..4847e8e 100644 --- a/renderer/src/gacha-beta/page/index.html +++ b/renderer/src/gacha/page/index.html @@ -2,7 +2,7 @@ - Gacha beta + Gacha diff --git a/renderer/src/gacha-beta/page/page.css b/renderer/src/gacha/page/page.css similarity index 100% rename from renderer/src/gacha-beta/page/page.css rename to renderer/src/gacha/page/page.css diff --git a/renderer/src/gacha-beta/page/page.js b/renderer/src/gacha/page/page.js similarity index 98% rename from renderer/src/gacha-beta/page/page.js rename to renderer/src/gacha/page/page.js index 7b5e4e6..00f5bee 100644 --- a/renderer/src/gacha-beta/page/page.js +++ b/renderer/src/gacha/page/page.js @@ -1,4 +1,4 @@ -// Browser page for the beta wish: one pack-cards pack whose single card shows +// Browser page for the gacha wish: one pack-cards pack whose single card shows // the rolled label, rank, and stars. The renderer injects `window.gachaWish` // before this module runs, tears the pack open with a scripted drag, and // captures each frame. diff --git a/renderer/src/gacha-beta/wish-plan.js b/renderer/src/gacha/wish-plan.js similarity index 72% rename from renderer/src/gacha-beta/wish-plan.js rename to renderer/src/gacha/wish-plan.js index 8e56f7c..6b11ea3 100644 --- a/renderer/src/gacha-beta/wish-plan.js +++ b/renderer/src/gacha/wish-plan.js @@ -1,20 +1,18 @@ /** - * Plan for the beta wish video: what the pack-cards page shows for a request, + * Plan for the gacha wish video: what the pack-cards page shows for a request, * when the scripted drag tears the pack open, and which files the page may * load. Kept free of Chrome so it can be unit tested. */ import path from 'node:path'; -import {getRankLetter, getRarityPalette} from '../remotion/gacha-timeline.js'; -import {gachaFrameSizes} from '../schemas/gacha-request.js'; /** @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest */ -/** @typedef {import('../remotion/gacha-timeline.js').GachaRarity} GachaRarity */ +/** @typedef {GachaRenderRequest['rarity']} GachaRarity */ /** Origin the page is served from; every request to it is answered from disk. */ export const wishOrigin = 'https://gacha.wish'; -export const gachaBetaTotalSeconds = 6; +export const gachaTotalSeconds = 6; /** * Seconds into the clip. The pack waits briefly, then a drag across its seal @@ -26,26 +24,49 @@ export const wishTimeline = Object.freeze({ dragEnd: 1, }); +/** Short edge of the portrait frame for each requested long edge: 9:16, rounded to even pixels for H.264. */ +const gachaFrameSizes = Object.freeze({640: 360, 854: 480}); + /** - * The beta wish is portrait: the request's `width` (640 or 854) is the long - * edge, so the frame is the /api/gacha frame turned upright. + * The wish is portrait: the request's `width` (640 or 854) is the long edge. * * @param {GachaRenderRequest['width']} width * @returns {{width: number, height: number}} */ export const getWishFrameSize = (width) => ({width: gachaFrameSizes[width], height: width}); -/** pack-cards rarity profile and glow preset for each requested rarity. */ +/** + * Per requested rarity: the rank letter, the pack-cards rarity profile and + * glow preset, and the card colours (blue, purple, gold). + */ const rarityLooks = Object.freeze({ - 3: {rarity: 'rare', glow: /** @type {const} */ ('silver')}, - 4: {rarity: 'epic', glow: /** @type {const} */ ('platinum')}, - 5: {rarity: 'legendary', glow: /** @type {const} */ ('gold')}, + 3: { + rank: /** @type {const} */ ('B'), + rarity: 'rare', + glow: /** @type {const} */ ('silver'), + accent: '#6fb6ff', + tint: '#163a8a', + }, + 4: { + rank: /** @type {const} */ ('A'), + rarity: 'epic', + glow: /** @type {const} */ ('platinum'), + accent: '#c88cff', + tint: '#4a1d8f', + }, + 5: { + rank: /** @type {const} */ ('S'), + rarity: 'legendary', + glow: /** @type {const} */ ('gold'), + accent: '#ffd36b', + tint: '#7a4a10', + }, }); /** * @typedef {object} WishPageProps * @property {string} label - * @property {'B' | 'A' | 'S'} rank Same rank letter as /api/gacha. + * @property {'B' | 'A' | 'S'} rank * @property {GachaRarity} stars * @property {string} rarity * @property {'silver' | 'platinum' | 'gold'} glow @@ -57,14 +78,8 @@ const rarityLooks = Object.freeze({ * @returns {WishPageProps} */ export const getWishPageProps = (request) => { - const palette = getRarityPalette(request.rarity); - return { - label: request.label, - rank: getRankLetter(request.rarity), - stars: request.rarity, - ...rarityLooks[request.rarity], - artwork: {accent: palette.glow, tint: palette.deep}, - }; + const {rank, rarity, glow, accent, tint} = rarityLooks[request.rarity]; + return {label: request.label, rank, stars: request.rarity, rarity, glow, artwork: {accent, tint}}; }; /** diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx deleted file mode 100644 index 58f11a6..0000000 --- a/renderer/src/remotion/GachaComposition.jsx +++ /dev/null @@ -1,698 +0,0 @@ -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'; - -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 GachaComposition = (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 = gachaTimeline; - 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 cb5c605..e54c90f 100644 --- a/renderer/src/remotion/Root.jsx +++ b/renderer/src/remotion/Root.jsx @@ -1,8 +1,5 @@ import {Composition} from 'remotion'; -import {GachaComposition} from './GachaComposition.jsx'; -import {gachaTotalSeconds} from './gacha-timeline.js'; import {WheelComposition} from './WheelComposition.jsx'; -import {gachaFrameSizes} from '../schemas/gacha-request.js'; /** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */ const defaultProps = { @@ -32,50 +29,15 @@ export const getCompositionMetadata = (props) => { }; }; -/** @type {import('../schemas/gacha-request.js').GachaRenderRequest} */ -const defaultGachaProps = { - label: 'Pizza', - rarity: 5, - fps: 24, - width: 640, - seed: 7, -}; - -/** - * @param {{fps: number, width: number}} props - */ -export const getGachaCompositionMetadata = (props) => { - const fps = props.fps || defaultGachaProps.fps; - const width = props.width in gachaFrameSizes ? props.width : defaultGachaProps.width; - return { - durationInFrames: Math.ceil(gachaTotalSeconds * fps), - fps, - height: gachaFrameSizes[/** @type {640 | 854} */ (width)], - width, - }; -}; - export const RemotionRoot = () => ( - <> - 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} - /> - getGachaCompositionMetadata(props)} - component={GachaComposition} - defaultProps={defaultGachaProps} - durationInFrames={Math.ceil(gachaTotalSeconds * defaultGachaProps.fps)} - fps={defaultGachaProps.fps} - height={gachaFrameSizes[defaultGachaProps.width]} - id="GachaWish" - width={defaultGachaProps.width} - /> - + 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} + /> ); diff --git a/renderer/src/remotion/gacha-timeline.js b/renderer/src/remotion/gacha-timeline.js deleted file mode 100644 index ea70816..0000000 --- a/renderer/src/remotion/gacha-timeline.js +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Timing, palette, and geometry for the gacha wish animation. Kept free of - * React so the choreography can be unit tested. - */ - -/** @typedef {3 | 4 | 5} GachaRarity */ - -/** - * @typedef {object} RarityPalette - * @property {string} core Meteor head and hottest highlights. - * @property {string} glow Trail, ring burst, and emblem glow. - * @property {string} deep Background tint of the reveal scene. - * @property {string} emblem Emblem fill gradient, light to dark. - */ - -export const gachaTotalSeconds = 7; - -/** - * Seconds from the start of the clip. The meteor lands at `meteorEnd`, the - * white flash peaks and holds, then fades to reveal the result. - */ -export const gachaTimeline = Object.freeze({ - meteorStart: 0.3, - meteorEnd: 2.5, - flashStart: 2.3, - flashPeak: 2.7, - flashHoldEnd: 2.95, - flashEnd: 3.5, - revealStart: 2.95, - emblemSettled: 3.7, - nameIn: 3.6, - nameSettled: 4.1, - starsStart: 4.1, - starStep: 0.18, -}); - -/** 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 {GachaRarity} 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 {GachaRarity} rarity - * @returns {number[]} - */ -export const getStarRevealTimes = (rarity) => - Array.from({length: rarity}, (_, index) => gachaTimeline.starsStart + index * gachaTimeline.starStep); - -/** - * Deterministic pseudo-random generator (mulberry32) so every render of the - * same request draws the same sky. - * - * @param {number} seed - * @returns {() => number} values in [0, 1) - */ -export const createRandom = (seed) => { - let state = seed >>> 0; - return () => { - state = (state + 0x6d2b79f5) >>> 0; - let value = state; - value = Math.imul(value ^ (value >>> 15), value | 1); - value ^= value + Math.imul(value ^ (value >>> 7), value | 61); - return ((value ^ (value >>> 14)) >>> 0) / 4294967296; - }; -}; - -/** - * @typedef {object} SkyStar - * @property {number} x Fraction of width. - * @property {number} y Fraction of height. - * @property {number} radius Fraction of height. - * @property {number} phase Twinkle phase offset in radians. - * @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 {GachaRarity} 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 {GachaRarity} 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-gacha-beta.js b/renderer/src/render/render-gacha-beta.js deleted file mode 100644 index be94193..0000000 --- a/renderer/src/render/render-gacha-beta.js +++ /dev/null @@ -1,367 +0,0 @@ -import {spawn} from 'node:child_process'; -import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -import {ensureBrowser, makeCancelSignal, RenderInternals} from '@remotion/renderer'; -import { - gachaBetaTotalSeconds, - getDragPointer, - getWishFrameSize, - getWishPageProps, - resolveWishAsset, - wishOrigin, -} from '../gacha-beta/wish-plan.js'; -import {RenderTimeoutError} from '../lib/render-errors.js'; -import {cleanupTempDir, createRenderTempDir} from '../lib/tmp-files.js'; -import {createCdpConnection} from './cdp-pipe.js'; - -/** - * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest - * @typedef {import('./render-composition.js').RenderResult} RenderResult - * @typedef {import('./cdp-pipe.js').CdpConnection} CdpConnection - * @typedef {{chrome: import('node:child_process').ChildProcess, cdp: CdpConnection}} WishBrowser - */ - -const roots = { - page: fileURLToPath(new URL('../gacha-beta/page', import.meta.url)), - packCards: path.dirname(fileURLToPath(import.meta.resolve('pack-cards'))), -}; - -/** Virtual milliseconds the page gets to load and mount its WebGL pack before capture starts. */ -const maxLoadMs = 5000; - -/** - * Chrome flags for frame-exact capture: `--deterministic-mode` lets the - * caller issue every BeginFrame, so animations, timers, and WebGL advance - * only with virtual time. WebGL runs on SwiftShader because the server has no - * GPU. - * - * @param {string} userDataDir - */ -const chromeArgs = (userDataDir) => [ - '--deterministic-mode', - '--remote-debugging-pipe', - '--no-sandbox', - '--no-first-run', - '--mute-audio', - '--hide-scrollbars', - '--use-angle=swiftshader', - '--enable-unsafe-swiftshader', - `--user-data-dir=${userDataDir}`, - 'about:blank', -]; - -/** - * Answers every page request from disk and blocks anything else, so the page - * cannot reach the network. - * - * @param {CdpConnection} cdp - */ -const serveWishPages = (cdp) => { - cdp.on('Fetch.requestPaused', (params, sessionId) => { - const asset = resolveWishAsset(params.request.url, roots); - const refuse = () => - cdp.send('Fetch.failRequest', {requestId: params.requestId, errorReason: 'BlockedByClient'}, sessionId); - const answer = asset - ? readFile(asset.file).then( - (body) => - cdp.send( - 'Fetch.fulfillRequest', - { - requestId: params.requestId, - responseCode: 200, - responseHeaders: [{name: 'Content-Type', value: asset.contentType}], - body: body.toString('base64'), - }, - sessionId, - ), - refuse, - ) - : refuse(); - answer.catch(() => {}); - }); -}; - -/** - * One headless Chrome per server process. SwiftShader spends several seconds - * compiling the pack's shaders in the first tab; later tabs in the same - * browser reuse that work. - * - * @type {Promise | null} - */ -let sharedBrowser = null; - -/** @returns {Promise} */ -const launchBrowser = async () => { - const installed = await ensureBrowser({logLevel: 'error'}); - if (!('path' in installed) || !installed.path) { - throw new Error(`No Chrome available for the beta wish (${installed.type})`); - } - const profileDir = await mkdtemp(path.join(os.tmpdir(), 'wheelofnames-chrome-')); - const chrome = spawn(installed.path, chromeArgs(profileDir), {stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']}); - const cdp = createCdpConnection( - /** @type {NodeJS.WritableStream} */ (chrome.stdio[3]), - /** @type {NodeJS.ReadableStream} */ (chrome.stdio[4]), - ); - const killOnExit = () => chrome.kill('SIGKILL'); - process.once('exit', killOnExit); - chrome.once('exit', () => { - process.off('exit', killOnExit); - sharedBrowser = null; - cdp.fail(new Error('Chrome exited during the beta wish render')); - rm(profileDir, {force: true, recursive: true}).catch(() => {}); - }); - serveWishPages(cdp); - await cdp.send('Browser.getVersion'); - return {chrome, cdp}; -}; - -/** @returns {Promise} */ -const getBrowser = () => { - sharedBrowser ??= launchBrowser().catch((error) => { - sharedBrowser = null; - throw error; - }); - return sharedBrowser; -}; - -/** - * Stops the shared browser, if one is running. The server calls this on - * shutdown; scripts call it so their process can exit. - * - * @returns {Promise} - */ -export const closeGachaBetaBrowser = async () => { - const current = sharedBrowser; - if (!current) { - return; - } - const chrome = await current.then( - (browser) => browser.chrome, - () => null, - ); - if (!chrome || chrome.exitCode !== null) { - return; - } - const exited = new Promise((resolve) => chrome.once('exit', resolve)); - chrome.kill('SIGTERM'); - const forced = setTimeout(() => chrome.kill('SIGKILL'), 2000); - await exited; - clearTimeout(forced); -}; - -/** - * Pays the shared browser's one-time shader compilation with a throwaway - * wish, so the first real request renders as fast as later ones. - * - * @returns {Promise} - */ -export const warmGachaBetaBrowser = async () => { - await renderGachaBetaVideo( - {label: 'Warm-up', rarity: 5, fps: 24, width: 640, seed: 1}, - {timeoutInMilliseconds: 120000}, - ); -}; - -/** - * A seeded Math.random so a seed reproduces the same flecks and sparkles. - * - * @param {number} seed - */ -const seededRandomScript = (seed) => `(() => { - let state = ${seed >>> 0}; - Math.random = () => { - 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; - }; -})();`; - -/** - * Captures the wish into JPEG frames in `dir`, one per video frame. - * - * @param {CdpConnection} cdp - * @param {string} sessionId - * @param {GachaRenderRequest} request - * @param {string} dir - * @param {(promise: Promise) => Promise} guard Rejects when the render is abandoned. - */ -const captureFrames = async (cdp, sessionId, request, dir, guard) => { - const interval = 1000 / request.fps; - /** - * @param {string} method - * @param {Record} [params] - */ - const send = (method, params) => guard(cdp.send(method, params, sessionId)); - /** @param {string} expression */ - const evaluate = async (expression) => - (await send('Runtime.evaluate', {expression, returnByValue: true})).result?.value; - - await send('Page.enable'); - await send('Fetch.enable', {patterns: [{urlPattern: '*'}]}); - await send('Emulation.setDeviceMetricsOverride', { - ...getWishFrameSize(request.width), - deviceScaleFactor: 1, - mobile: false, - }); - await send('Page.addScriptToEvaluateOnNewDocument', { - source: `${seededRandomScript(request.seed)}window.gachaWish = ${JSON.stringify(getWishPageProps(request))};`, - }); - const {virtualTimeTicksBase} = await send('Emulation.setVirtualTimePolicy', {policy: 'pause'}); - let elapsed = 0; - - /** - * Advances virtual time by one frame, then draws that frame with a - * BeginFrame stamped on the same clock so requestAnimationFrame, - * performance.now(), and CSS animations agree. - * - * @param {boolean} capture - * @returns {Promise} JPEG data, when captured and changed. - */ - const step = async (capture) => { - const expired = cdp.once('Emulation.virtualTimeBudgetExpired', sessionId); - await send('Emulation.setVirtualTimePolicy', { - policy: 'pauseIfNetworkFetchesPending', - budget: interval, - maxVirtualTimeTaskStarvationCount: 100, - }); - await guard(expired); - elapsed += interval; - const frame = await send('HeadlessExperimental.beginFrame', { - frameTimeTicks: virtualTimeTicksBase + elapsed, - interval, - ...(capture ? {screenshot: {format: 'jpeg', quality: 90}} : {}), - }); - return frame.screenshotData; - }; - - await send('Page.navigate', {url: `${wishOrigin}/index.html`}); - let box = null; - for (let waited = 0; !box && waited < maxLoadMs; waited += interval) { - await step(false); - box = await evaluate(`(() => { - const control = document.querySelector('.recap-webgl-pack [role=button]'); - if (!control) return null; - const r = control.getBoundingClientRect(); - return {x: r.left, y: r.top, w: r.width, h: r.height}; - })()`); - } - if (!box) { - throw new Error('The beta wish pack did not mount'); - } - - let pressed = false; - let last = ''; - let pointer = {x: 0, y: 0}; - const frames = Math.round(gachaBetaTotalSeconds * request.fps); - for (let index = 0; index < frames; index += 1) { - const drag = getDragPointer(index / request.fps); - /** @type {Promise[]} */ - const inputs = []; - /** @param {Record} event */ - const mouse = (event) => inputs.push(send('Input.dispatchMouseEvent', {...pointer, ...event})); - if (drag) { - pointer = {x: box.x + box.w * drag.x, y: box.y + box.h * drag.y}; - if (pressed) { - mouse({type: 'mouseMoved', button: 'left', buttons: 1}); - } else { - mouse({type: 'mouseMoved'}); - mouse({type: 'mousePressed', button: 'left', clickCount: 1}); - pressed = true; - } - } else if (pressed) { - mouse({type: 'mouseReleased', button: 'left', clickCount: 1}); - pressed = false; - } - // Input is acknowledged only once a frame consumes it, so draw before - // awaiting it. BeginFrame omits the screenshot when nothing changed. - last = (await step(true)) ?? last; - await Promise.all(inputs); - await writeFile(path.join(dir, `f${String(index).padStart(4, '0')}.jpg`), Buffer.from(last, 'base64')); - } - if ((await evaluate('document.documentElement.dataset.wish')) !== 'revealed') { - throw new Error('The beta wish card was not revealed'); - } -}; - -/** - * Renders the beta wish: a pack-cards pack torn open by a scripted drag, - * captured frame by frame under virtual time and encoded to a silent H.264 - * MP4. - * - * @param {GachaRenderRequest} request - * @param {{timeoutInMilliseconds: number}} options - * @returns {Promise} - */ -export const renderGachaBetaVideo = async (request, options) => { - const startedAt = Date.now(); - const tempDir = await createRenderTempDir(); - const {cancel, cancelSignal} = makeCancelSignal(); - /** @type {(error: Error) => void} */ - let abandon = () => {}; - /** @type {Promise} */ - const abandoned = new Promise((_, reject) => { - abandon = reject; - }); - abandoned.catch(() => {}); - /** @type {(promise: Promise) => Promise} */ - const guard = (promise) => Promise.race([promise, abandoned]); - const timeoutId = setTimeout(() => { - cancel(); - abandon(new Error('The beta wish render was abandoned')); - }, options.timeoutInMilliseconds); - /** @type {CdpConnection | null} */ - let cdp = null; - let targetId = ''; - - try { - const browser = await guard(getBrowser()); - cdp = browser.cdp; - ({targetId} = await guard(cdp.send('Target.createTarget', {url: 'about:blank', enableBeginFrameControl: true}))); - const {sessionId} = await guard(cdp.send('Target.attachToTarget', {targetId, flatten: true})); - await captureFrames(cdp, sessionId, request, tempDir, guard); - - const outputLocation = path.join(tempDir, 'gacha.mp4'); - // Remotion is pinned to an exact version; its bundled ffmpeg encodes the frames. - await RenderInternals.callFf({ - bin: 'ffmpeg', - args: [ - '-y', - '-framerate', - String(request.fps), - '-i', - path.join(tempDir, 'f%04d.jpg'), - '-c:v', - 'libx264', - '-pix_fmt', - 'yuv420p', - '-crf', - '23', - '-movflags', - '+faststart', - '-an', - outputLocation, - ], - indent: false, - logLevel: 'error', - binariesDirectory: null, - cancelSignal, - }); - const buffer = await readFile(outputLocation); - return {buffer, byteLength: buffer.byteLength, durationMs: Date.now() - startedAt}; - } catch (error) { - if (Date.now() - startedAt >= options.timeoutInMilliseconds) { - throw new RenderTimeoutError(options.timeoutInMilliseconds, {cause: error}); - } - throw error; - } finally { - clearTimeout(timeoutId); - if (cdp && targetId) { - await cdp.send('Target.closeTarget', {targetId}).catch(() => {}); - } - await cleanupTempDir(tempDir); - } -}; diff --git a/renderer/src/render/render-gacha.js b/renderer/src/render/render-gacha.js index fac435f..ae3b2ca 100644 --- a/renderer/src/render/render-gacha.js +++ b/renderer/src/render/render-gacha.js @@ -1,23 +1,364 @@ -import {renderComposition} from './render-composition.js'; +import {spawn} from 'node:child_process'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {ensureBrowser, makeCancelSignal, RenderInternals} from '@remotion/renderer'; +import { + gachaTotalSeconds, + getDragPointer, + getWishFrameSize, + getWishPageProps, + resolveWishAsset, + wishOrigin, +} from '../gacha/wish-plan.js'; +import {RenderTimeoutError} from '../lib/render-errors.js'; +import {cleanupTempDir, createRenderTempDir} from '../lib/tmp-files.js'; +import {createCdpConnection} from './cdp-pipe.js'; /** * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest - * @typedef {import('./render-composition.js').RenderResult} RenderGachaResult + * @typedef {import('./render-composition.js').RenderResult} RenderResult + * @typedef {import('./cdp-pipe.js').CdpConnection} CdpConnection + * @typedef {{chrome: import('node:child_process').ChildProcess, cdp: CdpConnection}} WishBrowser */ +const roots = { + page: fileURLToPath(new URL('../gacha/page', import.meta.url)), + packCards: path.dirname(fileURLToPath(import.meta.resolve('pack-cards'))), +}; + +/** Virtual milliseconds the page gets to load and mount its WebGL pack before capture starts. */ +const maxLoadMs = 5000; + /** - * 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. + * Chrome flags for frame-exact capture: `--deterministic-mode` lets the + * caller issue every BeginFrame, so animations, timers, and WebGL advance + * only with virtual time. WebGL runs on SwiftShader because the server has no + * GPU. * - * @param {GachaRenderRequest} inputProps - * @param {{timeoutInMilliseconds: number}} options - * @returns {Promise} + * @param {string} userDataDir */ -export const renderGachaVideo = (inputProps, options) => - renderComposition({ - compositionId: 'GachaWish', - filename: 'gacha.mp4', - inputProps: {...inputProps}, - media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true}, - timeoutInMilliseconds: options.timeoutInMilliseconds, +const chromeArgs = (userDataDir) => [ + '--deterministic-mode', + '--remote-debugging-pipe', + '--no-sandbox', + '--no-first-run', + '--mute-audio', + '--hide-scrollbars', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + `--user-data-dir=${userDataDir}`, + 'about:blank', +]; + +/** + * Answers every page request from disk and blocks anything else, so the page + * cannot reach the network. + * + * @param {CdpConnection} cdp + */ +const serveWishPages = (cdp) => { + cdp.on('Fetch.requestPaused', (params, sessionId) => { + const asset = resolveWishAsset(params.request.url, roots); + const refuse = () => + cdp.send('Fetch.failRequest', {requestId: params.requestId, errorReason: 'BlockedByClient'}, sessionId); + const answer = asset + ? readFile(asset.file).then( + (body) => + cdp.send( + 'Fetch.fulfillRequest', + { + requestId: params.requestId, + responseCode: 200, + responseHeaders: [{name: 'Content-Type', value: asset.contentType}], + body: body.toString('base64'), + }, + sessionId, + ), + refuse, + ) + : refuse(); + answer.catch(() => {}); }); +}; + +/** + * One headless Chrome per server process. SwiftShader spends several seconds + * compiling the pack's shaders in the first tab; later tabs in the same + * browser reuse that work. + * + * @type {Promise | null} + */ +let sharedBrowser = null; + +/** @returns {Promise} */ +const launchBrowser = async () => { + const installed = await ensureBrowser({logLevel: 'error'}); + if (!('path' in installed) || !installed.path) { + throw new Error(`No Chrome available for the gacha wish (${installed.type})`); + } + const profileDir = await mkdtemp(path.join(os.tmpdir(), 'wheelofnames-chrome-')); + const chrome = spawn(installed.path, chromeArgs(profileDir), {stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']}); + const cdp = createCdpConnection( + /** @type {NodeJS.WritableStream} */ (chrome.stdio[3]), + /** @type {NodeJS.ReadableStream} */ (chrome.stdio[4]), + ); + const killOnExit = () => chrome.kill('SIGKILL'); + process.once('exit', killOnExit); + chrome.once('exit', () => { + process.off('exit', killOnExit); + sharedBrowser = null; + cdp.fail(new Error('Chrome exited during the gacha wish render')); + rm(profileDir, {force: true, recursive: true}).catch(() => {}); + }); + serveWishPages(cdp); + await cdp.send('Browser.getVersion'); + return {chrome, cdp}; +}; + +/** @returns {Promise} */ +const getBrowser = () => { + sharedBrowser ??= launchBrowser().catch((error) => { + sharedBrowser = null; + throw error; + }); + return sharedBrowser; +}; + +/** + * Stops the shared browser, if one is running. The server calls this on + * shutdown; scripts call it so their process can exit. + * + * @returns {Promise} + */ +export const closeGachaBrowser = async () => { + const current = sharedBrowser; + if (!current) { + return; + } + const chrome = await current.then( + (browser) => browser.chrome, + () => null, + ); + if (!chrome || chrome.exitCode !== null) { + return; + } + const exited = new Promise((resolve) => chrome.once('exit', resolve)); + chrome.kill('SIGTERM'); + const forced = setTimeout(() => chrome.kill('SIGKILL'), 2000); + await exited; + clearTimeout(forced); +}; + +/** + * Pays the shared browser's one-time shader compilation with a throwaway + * wish, so the first real request renders as fast as later ones. + * + * @returns {Promise} + */ +export const warmGachaBrowser = async () => { + await renderGachaVideo({label: 'Warm-up', rarity: 5, fps: 24, width: 640, seed: 1}, {timeoutInMilliseconds: 120000}); +}; + +/** + * A seeded Math.random so a seed reproduces the same flecks and sparkles. + * + * @param {number} seed + */ +const seededRandomScript = (seed) => `(() => { + let state = ${seed >>> 0}; + Math.random = () => { + 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; + }; +})();`; + +/** + * Captures the wish into JPEG frames in `dir`, one per video frame. + * + * @param {CdpConnection} cdp + * @param {string} sessionId + * @param {GachaRenderRequest} request + * @param {string} dir + * @param {(promise: Promise) => Promise} guard Rejects when the render is abandoned. + */ +const captureFrames = async (cdp, sessionId, request, dir, guard) => { + const interval = 1000 / request.fps; + /** + * @param {string} method + * @param {Record} [params] + */ + const send = (method, params) => guard(cdp.send(method, params, sessionId)); + /** @param {string} expression */ + const evaluate = async (expression) => + (await send('Runtime.evaluate', {expression, returnByValue: true})).result?.value; + + await send('Page.enable'); + await send('Fetch.enable', {patterns: [{urlPattern: '*'}]}); + await send('Emulation.setDeviceMetricsOverride', { + ...getWishFrameSize(request.width), + deviceScaleFactor: 1, + mobile: false, + }); + await send('Page.addScriptToEvaluateOnNewDocument', { + source: `${seededRandomScript(request.seed)}window.gachaWish = ${JSON.stringify(getWishPageProps(request))};`, + }); + const {virtualTimeTicksBase} = await send('Emulation.setVirtualTimePolicy', {policy: 'pause'}); + let elapsed = 0; + + /** + * Advances virtual time by one frame, then draws that frame with a + * BeginFrame stamped on the same clock so requestAnimationFrame, + * performance.now(), and CSS animations agree. + * + * @param {boolean} capture + * @returns {Promise} JPEG data, when captured and changed. + */ + const step = async (capture) => { + const expired = cdp.once('Emulation.virtualTimeBudgetExpired', sessionId); + await send('Emulation.setVirtualTimePolicy', { + policy: 'pauseIfNetworkFetchesPending', + budget: interval, + maxVirtualTimeTaskStarvationCount: 100, + }); + await guard(expired); + elapsed += interval; + const frame = await send('HeadlessExperimental.beginFrame', { + frameTimeTicks: virtualTimeTicksBase + elapsed, + interval, + ...(capture ? {screenshot: {format: 'jpeg', quality: 90}} : {}), + }); + return frame.screenshotData; + }; + + await send('Page.navigate', {url: `${wishOrigin}/index.html`}); + let box = null; + for (let waited = 0; !box && waited < maxLoadMs; waited += interval) { + await step(false); + box = await evaluate(`(() => { + const control = document.querySelector('.recap-webgl-pack [role=button]'); + if (!control) return null; + const r = control.getBoundingClientRect(); + return {x: r.left, y: r.top, w: r.width, h: r.height}; + })()`); + } + if (!box) { + throw new Error('The gacha wish pack did not mount'); + } + + let pressed = false; + let last = ''; + let pointer = {x: 0, y: 0}; + const frames = Math.round(gachaTotalSeconds * request.fps); + for (let index = 0; index < frames; index += 1) { + const drag = getDragPointer(index / request.fps); + /** @type {Promise[]} */ + const inputs = []; + /** @param {Record} event */ + const mouse = (event) => inputs.push(send('Input.dispatchMouseEvent', {...pointer, ...event})); + if (drag) { + pointer = {x: box.x + box.w * drag.x, y: box.y + box.h * drag.y}; + if (pressed) { + mouse({type: 'mouseMoved', button: 'left', buttons: 1}); + } else { + mouse({type: 'mouseMoved'}); + mouse({type: 'mousePressed', button: 'left', clickCount: 1}); + pressed = true; + } + } else if (pressed) { + mouse({type: 'mouseReleased', button: 'left', clickCount: 1}); + pressed = false; + } + // Input is acknowledged only once a frame consumes it, so draw before + // awaiting it. BeginFrame omits the screenshot when nothing changed. + last = (await step(true)) ?? last; + await Promise.all(inputs); + await writeFile(path.join(dir, `f${String(index).padStart(4, '0')}.jpg`), Buffer.from(last, 'base64')); + } + if ((await evaluate('document.documentElement.dataset.wish')) !== 'revealed') { + throw new Error('The gacha wish card was not revealed'); + } +}; + +/** + * Renders the gacha wish: a pack-cards pack torn open by a scripted drag, + * captured frame by frame under virtual time and encoded to a silent H.264 + * MP4. + * + * @param {GachaRenderRequest} request + * @param {{timeoutInMilliseconds: number}} options + * @returns {Promise} + */ +export const renderGachaVideo = async (request, options) => { + const startedAt = Date.now(); + const tempDir = await createRenderTempDir(); + const {cancel, cancelSignal} = makeCancelSignal(); + /** @type {(error: Error) => void} */ + let abandon = () => {}; + /** @type {Promise} */ + const abandoned = new Promise((_, reject) => { + abandon = reject; + }); + abandoned.catch(() => {}); + /** @type {(promise: Promise) => Promise} */ + const guard = (promise) => Promise.race([promise, abandoned]); + const timeoutId = setTimeout(() => { + cancel(); + abandon(new Error('The gacha wish render was abandoned')); + }, options.timeoutInMilliseconds); + /** @type {CdpConnection | null} */ + let cdp = null; + let targetId = ''; + + try { + const browser = await guard(getBrowser()); + cdp = browser.cdp; + ({targetId} = await guard(cdp.send('Target.createTarget', {url: 'about:blank', enableBeginFrameControl: true}))); + const {sessionId} = await guard(cdp.send('Target.attachToTarget', {targetId, flatten: true})); + await captureFrames(cdp, sessionId, request, tempDir, guard); + + const outputLocation = path.join(tempDir, 'gacha.mp4'); + // Remotion is pinned to an exact version; its bundled ffmpeg encodes the frames. + await RenderInternals.callFf({ + bin: 'ffmpeg', + args: [ + '-y', + '-framerate', + String(request.fps), + '-i', + path.join(tempDir, 'f%04d.jpg'), + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-crf', + '23', + '-movflags', + '+faststart', + '-an', + outputLocation, + ], + indent: false, + logLevel: 'error', + binariesDirectory: null, + cancelSignal, + }); + const buffer = await readFile(outputLocation); + return {buffer, byteLength: buffer.byteLength, durationMs: Date.now() - startedAt}; + } catch (error) { + if (Date.now() - startedAt >= options.timeoutInMilliseconds) { + throw new RenderTimeoutError(options.timeoutInMilliseconds, {cause: error}); + } + throw error; + } finally { + clearTimeout(timeoutId); + if (cdp && targetId) { + await cdp.send('Target.closeTarget', {targetId}).catch(() => {}); + } + await cleanupTempDir(tempDir); + } +}; diff --git a/renderer/src/routes/gacha.js b/renderer/src/routes/gacha.js index 1d24251..30df889 100644 --- a/renderer/src/routes/gacha.js +++ b/renderer/src/routes/gacha.js @@ -14,7 +14,6 @@ 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. */ /** @@ -25,7 +24,7 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js'; * @param {GachaRouteDeps} deps */ export const registerGachaRoute = async (app, deps) => { - app.post(deps.path ?? '/api/gacha', async (request, reply) => { + app.post('/api/gacha', async (request, reply) => { if (!isAuthorized(request, deps.config.apiToken)) { return reply.code(401).send({error: 'unauthorized'}); } diff --git a/renderer/src/schemas/gacha-request.js b/renderer/src/schemas/gacha-request.js index f90bcfc..7ad874f 100644 --- a/renderer/src/schemas/gacha-request.js +++ b/renderer/src/schemas/gacha-request.js @@ -2,9 +2,6 @@ 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}); - /** * @typedef {object} GachaRequestLimits * @property {number} maxOptionChars @@ -15,8 +12,8 @@ export const gachaFrameSizes = Object.freeze({640: 360, 854: 480}); * @property {string} label * @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. + * @property {640 | 854} width Long edge of the portrait frame. + * @property {number} seed Seeds the page's randomness, so a seed replays the same flecks and sparkles. */ /** @@ -38,7 +35,7 @@ export const createGachaRequestSchema = (limits) => /** * Parses a request, picking a fresh seed when the caller sends none so every - * roll draws a different sky. + * roll looks different. * * @param {unknown} input * @param {GachaRequestLimits} limits diff --git a/renderer/src/server.js b/renderer/src/server.js index 32584a1..16cea36 100644 --- a/renderer/src/server.js +++ b/renderer/src/server.js @@ -2,8 +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 {closeGachaBetaBrowser, renderGachaBetaVideo, warmGachaBetaBrowser} from './render/render-gacha-beta.js'; -import {renderGachaVideo} from './render/render-gacha.js'; +import {closeGachaBrowser, renderGachaVideo, warmGachaBrowser} from './render/render-gacha.js'; import {renderWheelGif} from './render/render-gif.js'; import {registerGachaRoute} from './routes/gacha.js'; import {registerGifRoute} from './routes/gif.js'; @@ -20,8 +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.warmRenderers] Prepare the Remotion bundle and the beta wish browser before serving. + * @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the gacha browser before serving. */ export const buildServer = async (options = {}) => { const config = options.config ?? loadConfig(); @@ -29,7 +27,7 @@ export const buildServer = async (options = {}) => { if (warmRenderers) { await getRemotionServeUrl(); - await warmGachaBetaBrowser(); + await warmGachaBrowser(); } const app = Fastify({ @@ -55,14 +53,8 @@ export const buildServer = async (options = {}) => { renderGacha: options.renderGacha ?? renderGachaVideo, semaphore, }); - await registerGachaRoute(app, { - config, - path: '/api/gachabeta', - renderGacha: options.renderGachaBeta ?? renderGachaBetaVideo, - semaphore, - }); - // The beta wish keeps one headless Chrome alive between renders. - app.addHook('onClose', closeGachaBetaBrowser); + // The gacha wish keeps one headless Chrome alive between renders. + app.addHook('onClose', closeGachaBrowser); return app; }; diff --git a/renderer/test/gacha-route.test.js b/renderer/test/gacha-route.test.js index e88f822..0014695 100644 --- a/renderer/test/gacha-route.test.js +++ b/renderer/test/gacha-route.test.js @@ -158,31 +158,12 @@ 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}; - }, - }, - ); + test('the retired beta path is gone', async () => { + const app = await build(); - 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}}); + const response = await app.inject({method: 'POST', url: '/api/gachabeta', 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(invalid.statusCode).toBe(400); - expect(calls).toEqual(['beta:Bún bò:5']); + expect(response.statusCode).toBe(404); await app.close(); }); diff --git a/renderer/test/gacha-timeline.test.js b/renderer/test/gacha-timeline.test.js deleted file mode 100644 index 0e74dbd..0000000 --- a/renderer/test/gacha-timeline.test.js +++ /dev/null @@ -1,110 +0,0 @@ -import {describe, expect, test} from 'vitest'; -import { - createStarfield, - gachaTimeline, - gachaTotalSeconds, - getLabelFontSize, - emblemCenter, - getMeteorPoint, - getRankLetter, - getRarityPalette, - getShakeOffset, - getStarRevealTimes, - getTierEffects, - getTwinkle, -} 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 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)); - }); -}); diff --git a/renderer/test/gacha-beta-wish-plan.test.js b/renderer/test/gacha-wish-plan.test.js similarity index 91% rename from renderer/test/gacha-beta-wish-plan.test.js rename to renderer/test/gacha-wish-plan.test.js index 82ff3f5..5e9c9a6 100644 --- a/renderer/test/gacha-beta-wish-plan.test.js +++ b/renderer/test/gacha-wish-plan.test.js @@ -1,19 +1,19 @@ import path from 'node:path'; import {describe, expect, test} from 'vitest'; import { - gachaBetaTotalSeconds, + gachaTotalSeconds, getDragPointer, getWishFrameSize, getWishPageProps, resolveWishAsset, wishOrigin, wishTimeline, -} from '../src/gacha-beta/wish-plan.js'; +} from '../src/gacha/wish-plan.js'; const roots = {page: path.resolve('/srv/page'), packCards: path.resolve('/srv/pack-cards')}; -describe('gacha beta wish plan', () => { - test('the page shows the request label with the same rank and stars as /api/gacha', () => { +describe('gacha wish plan', () => { + test('the page shows the request label with its rank and stars', () => { const base = {label: 'Bún bò', fps: /** @type {const} */ (24), width: /** @type {const} */ (640), seed: 1}; expect(getWishPageProps({...base, rarity: 3})).toMatchObject({ label: 'Bún bò', @@ -43,7 +43,7 @@ describe('gacha beta wish plan', () => { expect(end.x).toBeLessThanOrEqual(1); expect(end.y).toBe(start.y); } - expect(dragEnd + 2).toBeLessThan(gachaBetaTotalSeconds); + expect(dragEnd + 2).toBeLessThan(gachaTotalSeconds); }); test('page requests map to the page directory and the installed package', () => {