From 8c06fdf4985ec0b03784688d7d2e2af17f6a8366 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 18:22:30 +0700 Subject: [PATCH] feat(gacha): render the beta wish as a pack-cards pack opening /api/gachabeta now tears open a pack-cards collectible pack whose card shows the request's label, the same B/A/S rank as /api/gacha, and one star per rarity level; rarity also picks the card material and the pack colour. pack-cards animates on the browser clock, so the route drives a shared headless Chrome in deterministic mode over a debugging pipe, steps virtual time frame by frame, tears the pack with a scripted drag, and encodes the captured frames with Remotion's bundled ffmpeg. The page and package are served from disk with all other requests blocked. The browser stays alive per process and is warmed at start-up, because the first render compiles the pack's WebGL shaders in software. The Remotion beta composition and its scene code are removed. pack-cards is installed from a GitHub tarball pinned to a commit, since it has no npm release and a moving URL would break npm ci. --- renderer/README.md | 33 +- renderer/package-lock.json | 10 + renderer/package.json | 1 + renderer/scripts/api-smoke.js | 2 +- renderer/scripts/render-fixtures.js | 9 +- renderer/src/gacha-beta/page/index.html | 17 + renderer/src/gacha-beta/page/page.css | 68 ++ renderer/src/gacha-beta/page/page.js | 67 ++ renderer/src/gacha-beta/wish-plan.js | 122 +++ .../src/remotion/GachaBetaComposition.jsx | 971 ------------------ renderer/src/remotion/Root.jsx | 17 +- renderer/src/remotion/gacha-beta-paint.js | 144 --- renderer/src/remotion/gacha-beta-scene.js | 282 ----- renderer/src/render/cdp-pipe.js | 98 ++ renderer/src/render/render-gacha-beta.js | 364 +++++++ renderer/src/render/render-gacha.js | 19 +- renderer/src/server.js | 12 +- renderer/test/gacha-beta-scene.test.js | 104 -- renderer/test/gacha-beta-wish-plan.test.js | 67 ++ 19 files changed, 857 insertions(+), 1550 deletions(-) create mode 100644 renderer/src/gacha-beta/page/index.html create mode 100644 renderer/src/gacha-beta/page/page.css create mode 100644 renderer/src/gacha-beta/page/page.js create mode 100644 renderer/src/gacha-beta/wish-plan.js delete mode 100644 renderer/src/remotion/GachaBetaComposition.jsx delete mode 100644 renderer/src/remotion/gacha-beta-paint.js delete mode 100644 renderer/src/remotion/gacha-beta-scene.js create mode 100644 renderer/src/render/cdp-pipe.js create mode 100644 renderer/src/render/render-gacha-beta.js delete mode 100644 renderer/test/gacha-beta-scene.test.js create mode 100644 renderer/test/gacha-beta-wish-plan.test.js diff --git a/renderer/README.md b/renderer/README.md index b101928..aa844ad 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -67,23 +67,26 @@ only draws it. ### Gacha wish beta `POST /api/gachabeta` takes the same body and returns the same response as -`/api/gacha`, rendering a 10.5-second beta style instead, cut like an anime -gacha game's six-star wish: +`/api/gacha`, rendering a 6-second 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). -1. The camera dives through a ring of painterly clouds into a swirling eye, - then a beam rushes past. -2. A meteor slows to a stop over a sea of clouds inside a rainbow halo, - sheds sparkles, and bursts into a starburst under a red flash. -3. Crystal comets fall through the daytime sky while a red star joins them - and strikes in a white flash. -4. A black silhouette of the rank emblem sheds shards on a red disc, then the - splash card shows the emblem, a name plate with the request's `label` and - rank, and one star per rarity level popping in. +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 +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. -The caller randomly chooses the food and sends it as `label` (for example, -`"Bún bò"`). As on `/api/gacha`, `rarity` picks the rank (`B` for 3★, `A` for -4★, `S` for 5★), the star count, and the tint of the meteor head and emblem -(blue, purple, gold). +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 diff --git a/renderer/package-lock.json b/renderer/package-lock.json index acbc00d..a3295e1 100644 --- a/renderer/package-lock.json +++ b/renderer/package-lock.json @@ -12,6 +12,7 @@ "@remotion/renderer": "4.0.485", "@remotion/shapes": "4.0.485", "fastify": "5.10.0", + "pack-cards": "https://codeload.github.com/paubineau/pack-cards/tar.gz/83e8fbb8237537194e40cb306bd310d8cc6a316b", "react": "19.2.7", "react-dom": "19.2.7", "remotion": "4.0.485", @@ -4039,6 +4040,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pack-cards": { + "version": "0.2.0", + "resolved": "https://codeload.github.com/paubineau/pack-cards/tar.gz/83e8fbb8237537194e40cb306bd310d8cc6a316b", + "integrity": "sha512-qVpoovPOuoA08oHL1XSmCsVJT9rKd3+Z7hy44ApGvQRy3dIcTYTZmRsU2bjXhPYujbw0AQaQTi6EhVcPxDd56A==", + "license": "MIT", + "engines": { + "node": ">=22.12" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", diff --git a/renderer/package.json b/renderer/package.json index c54d1be..b92d336 100644 --- a/renderer/package.json +++ b/renderer/package.json @@ -23,6 +23,7 @@ "@remotion/renderer": "4.0.485", "@remotion/shapes": "4.0.485", "fastify": "5.10.0", + "pack-cards": "https://codeload.github.com/paubineau/pack-cards/tar.gz/83e8fbb8237537194e40cb306bd310d8cc6a316b", "react": "19.2.7", "react-dom": "19.2.7", "remotion": "4.0.485", diff --git a/renderer/scripts/api-smoke.js b/renderer/scripts/api-smoke.js index af39e0b..a1859c8 100644 --- a/renderer/scripts/api-smoke.js +++ b/renderer/scripts/api-smoke.js @@ -11,7 +11,7 @@ const config = { renderTimeoutMs: 30000, }; -const app = await buildServer({config, warmRemotionBundle: true}); +const app = await buildServer({config, warmRenderers: true}); try { const response = await app.inject({ diff --git a/renderer/scripts/render-fixtures.js b/renderer/scripts/render-fixtures.js index 28ef8a7..7f8a96a 100644 --- a/renderer/scripts/render-fixtures.js +++ b/renderer/scripts/render-fixtures.js @@ -1,6 +1,7 @@ import {mkdir, writeFile} from 'node:fs/promises'; import path from 'node:path'; -import {renderGachaBetaVideo, renderGachaVideo} from '../src/render/render-gacha.js'; +import {closeGachaBetaBrowser, renderGachaBetaVideo} from '../src/render/render-gacha-beta.js'; +import {renderGachaVideo} from '../src/render/render-gacha.js'; import {renderWheelGif} from '../src/render/render-gif.js'; const smoke = process.argv.includes('--smoke'); @@ -80,4 +81,8 @@ const betaFixture = { width: /** @type {const} */ (640), seed: 7, }; -await save('gachabeta-5-star.mp4', await renderGachaBetaVideo(betaFixture, {timeoutInMilliseconds: 30000})); +try { + await save('gachabeta-5-star.mp4', await renderGachaBetaVideo(betaFixture, {timeoutInMilliseconds: 60000})); +} finally { + await closeGachaBetaBrowser(); +} diff --git a/renderer/src/gacha-beta/page/index.html b/renderer/src/gacha-beta/page/index.html new file mode 100644 index 0000000..d50ac38 --- /dev/null +++ b/renderer/src/gacha-beta/page/index.html @@ -0,0 +1,17 @@ + + + + + Gacha beta + + + + + + + +
+ + diff --git a/renderer/src/gacha-beta/page/page.css b/renderer/src/gacha-beta/page/page.css new file mode 100644 index 0000000..dce7611 --- /dev/null +++ b/renderer/src/gacha-beta/page/page.css @@ -0,0 +1,68 @@ +html, +body { + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + background: #05070f; + color: #edf1f8; + font-family: 'Noto Sans', 'Noto Sans Vietnamese', system-ui, sans-serif; +} + +#stage { + align-items: center; + background: radial-gradient(ellipse at 50% 45%, var(--wish-tint) 0%, #070a16 72%); + display: flex; + height: 100%; + justify-content: center; +} + +#stage .recap-deck { + width: min(52vh, 78vw); +} + +#stage .recap-pack-scene { + gap: 0; + padding: 0; +} + +/* Sit the pack below centre so the card rising out of it stays in frame. */ +#stage .recap-pack-stack { + margin: 24vh 0 0; + width: min(36vh, 60vw); +} + +/* A video has no buttons or prompts to read: hide the interactive captions. */ +#stage .recap-pack-scene > .eyebrow, +#stage .recap-pack-title, +#stage .recap-pack-instruction, +#stage .recap-pack-skip, +#stage .recap-deck-stage .recap-card-actions { + display: none; +} + +.wish-card .recap-card-content { + justify-content: center; + text-align: center; +} + +.wish-rank { + color: var(--accent); + font: italic 700 13vh/1.1 'Noto Serif', Georgia, serif; +} + +.wish-label { + font-size: 5.5vh; + line-height: 1.15; + margin: 1vh 0; + overflow-wrap: anywhere; +} + +.wish-stars { + color: #ffcc33; + font-size: 3.6vh; + letter-spacing: 0.3vh; + margin: 0; +} diff --git a/renderer/src/gacha-beta/page/page.js b/renderer/src/gacha-beta/page/page.js new file mode 100644 index 0000000..e776eba --- /dev/null +++ b/renderer/src/gacha-beta/page/page.js @@ -0,0 +1,67 @@ +// Browser page for the beta 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. +import {createPackView} from 'pack-cards'; + +/** + * @typedef {object} GachaWishPage + * @property {string} label + * @property {string} rank Rank letter, as on /api/gacha. + * @property {number} stars + * @property {string} rarity pack-cards rarity profile. + * @property {'silver' | 'platinum' | 'gold'} glow pack-cards glow preset. + * @property {{accent: string, tint: string}} artwork + */ + +const wish = /** @type {GachaWishPage} */ (/** @type {any} */ (window).gachaWish); +const stage = /** @type {HTMLElement} */ (document.getElementById('stage')); +stage.style.setProperty('--wish-accent', wish.artwork.accent); +stage.style.setProperty('--wish-tint', wish.artwork.tint); + +/** + * @param {string} tag + * @param {string} className + * @param {string} [text] + */ +const el = (tag, className, text) => { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) { + node.textContent = text; + } + return node; +}; + +const createFace = () => { + const face = el('article', 'wish-card'); + const header = el('header', 'recap-card-header'); + header.append(el('span', '', 'GACHA'), el('span', '', `RANK ${wish.rank}`)); + const content = el('div', 'recap-card-content'); + const title = el('h2', 'wish-label', wish.label); + title.setAttribute('data-foil-text', ''); + content.append(el('span', 'wish-rank', wish.rank), title, el('p', 'wish-stars', '★'.repeat(wish.stars))); + const footer = el('footer', 'recap-card-footer'); + footer.append(el('span', '', `${wish.stars}★`), el('span', '', '01 / 01')); + face.append(header, content, footer); + return face; +}; + +const view = createPackView(stage, { + label: 'Gacha', + artwork: wish.artwork, + labels: {title: 'Gacha', swipe: '', tap: ''}, + appearance: {opening: 'animated', motion: 'interactive', glow: wish.glow, pack_zoom: true}, +}); + +view.showPack({ + count: 1, + renderCard: () => ({face: createFace(), identity: wish.label, rarity: wish.rarity}), + onReveal: () => { + document.documentElement.dataset.wish = 'revealed'; + }, + onError: (error) => { + document.documentElement.dataset.wish = 'failed'; + console.error(error); + }, +}); diff --git a/renderer/src/gacha-beta/wish-plan.js b/renderer/src/gacha-beta/wish-plan.js new file mode 100644 index 0000000..f8ebe15 --- /dev/null +++ b/renderer/src/gacha-beta/wish-plan.js @@ -0,0 +1,122 @@ +/** + * Plan for the beta 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'; + +/** @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest */ +/** @typedef {import('../remotion/gacha-timeline.js').GachaRarity} 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; + +/** + * Seconds into the clip. The pack waits briefly, then a drag across its seal + * tears it open; pack-cards plays the rest of the opening and the card's + * arrival on its own. + */ +export const wishTimeline = Object.freeze({ + dragStart: 0.5, + dragEnd: 1, +}); + +/** pack-cards rarity profile and glow preset for each requested rarity. */ +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')}, +}); + +/** + * @typedef {object} WishPageProps + * @property {string} label + * @property {'B' | 'A' | 'S'} rank Same rank letter as /api/gacha. + * @property {GachaRarity} stars + * @property {string} rarity + * @property {'silver' | 'platinum' | 'gold'} glow + * @property {{accent: string, tint: string}} artwork Rarity colours: blue, purple, gold. + */ + +/** + * @param {GachaRenderRequest} request + * @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}, + }; +}; + +/** + * Pointer position for a frame during the drag, as fractions of the pack + * control's box, or null outside it. The button is held while the pointer + * sweeps across the seal. + * + * @param {number} seconds + * @returns {{x: number, y: number} | null} + */ +export const getDragPointer = (seconds) => { + const {dragStart, dragEnd} = wishTimeline; + if (seconds < dragStart || seconds > dragEnd) { + return null; + } + return {x: 0.08 + 0.9 * ((seconds - dragStart) / (dragEnd - dragStart)), y: 0.12}; +}; + +const contentTypes = Object.freeze({ + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.webp': 'image/webp', + '.json': 'application/json; charset=utf-8', + '.woff2': 'font/woff2', +}); + +/** + * Maps a page request to a file inside one of the allowed roots. Requests + * under `/pack-cards/` read the installed package; everything else reads the + * page directory. Returns null for other origins, unknown file types, and any + * path that would leave its root. + * + * @param {string} url + * @param {{page: string, packCards: string}} roots Absolute directories. + * @returns {{file: string, contentType: string} | null} + */ +export const resolveWishAsset = (url, roots) => { + let parsed; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.origin !== wishOrigin) { + return null; + } + let pathname; + try { + pathname = decodeURIComponent(parsed.pathname); + } catch { + return null; + } + const [root, relative] = pathname.startsWith('/pack-cards/') + ? [roots.packCards, pathname.slice('/pack-cards/'.length)] + : [roots.page, pathname.slice(1)]; + const file = path.resolve(root, relative || 'index.html'); + const contentType = contentTypes[/** @type {keyof typeof contentTypes} */ (path.extname(file))]; + if (!file.startsWith(root + path.sep) || !contentType) { + return null; + } + return {file, contentType}; +}; diff --git a/renderer/src/remotion/GachaBetaComposition.jsx b/renderer/src/remotion/GachaBetaComposition.jsx deleted file mode 100644 index 000145c..0000000 --- a/renderer/src/remotion/GachaBetaComposition.jsx +++ /dev/null @@ -1,971 +0,0 @@ -import {useMemo} from 'react'; -import {Star} from '@remotion/shapes'; -import {AbsoluteFill, Easing, Img, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; -import {bakeCloudLayer} from './gacha-beta-paint.js'; -import { - createShards, - gachaBetaTimeline, - getBurstState, - getFlash, - getHaloState, - getMeteorHead, - getRedMeteor, - getShardOffset, - getShot, - getStarRevealTimes, - getVolleyCometHead, - getVortexState, - progress, - volleyComets, -} from './gacha-beta-scene.js'; -import { - createRandom, - createStarfield, - getRankLetter, - getRarityPalette, - getTwinkle, - starColor, -} from './gacha-timeline.js'; - -const sansFont = - 'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; -const serifFont = '"Noto Serif", Georgia, "Times New Roman", serif'; - -const clamp = /** @type {const} */ ({ - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', -}); - -/** - * @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 {number} value - * @returns {number} - */ -const clamp01 = (value) => Math.min(1, Math.max(0, value)); - -const rainbow = - 'circle closest-side, transparent 0%, transparent 78%, rgba(255,70,70,0.75) 80%, rgba(255,170,40,0.75) 83%, rgba(255,240,80,0.7) 86%, rgba(80,230,120,0.7) 89%, rgba(70,160,255,0.75) 92%, rgba(150,90,255,0.7) 95%, transparent 98%'; -const confettiColors = ['#ffb36b', '#ff7fb8', '#9ff0ff', '#fff2a0', '#c9a0ff']; - -/** @typedef {import('./gacha-beta-paint.js').Puff} Puff */ - -/** Half the side of the baked vortex ring, in frame heights; it spans past the frame so it can rotate. */ -const ringReach = 1.45; - -/** The cloud banks only fill the lower frame, and are baked wider than it so they can drift. */ -const cloudBandTop = 0.42; -const cloudBandHeight = 0.62; -const cloudBandWidth = 1.2; - -/** - * @param {() => number} random - * @param {number} count - * @returns {Puff[]} - */ -const createPuffs = (random, count) => - Array.from({length: count}, (_, index) => ({ - dx: (index / (count - 1) - 0.5) * 1.6 + (random() - 0.5) * 0.2, - dy: -Math.sin((index / (count - 1)) * Math.PI) * 0.35 + (random() - 0.5) * 0.12, - r: 0.3 + random() * 0.28, - })); - -/** - * Every seeded layout for one roll, so each wish draws a different sky. The - * cloud layers are baked here once and only moved per frame. - * - * @param {number} seed - * @param {number} width - * @param {number} height - */ -const createBetaLayout = (seed, width, height) => { - const random = createRandom(seed + 307); - const reach = ringReach * height; - const ringClusters = Array.from({length: 22}, (_, index) => { - const radians = (((index / 22) * 360 + random() * 12) * Math.PI) / 180; - const distance = (0.5 + random() * 0.22) * height; - return { - x: reach + Math.cos(radians) * distance * 1.25, - y: reach + Math.sin(radians) * distance, - size: (0.22 + random() * 0.16) * height, - puffs: createPuffs(random, 7), - }; - }); - const bankClusters = Array.from({length: 19}, (_, index) => { - const x = (index / 18) * cloudBandWidth; - return { - x: x * width, - y: (0.93 - Math.max(0, Math.min(x, 1) - 0.55) * 0.55 - cloudBandTop) * height, - size: (0.16 + random() * 0.12) * height, - puffs: createPuffs(random, 7), - }; - }); - const paint = {seed: seed + 41, frequency: 18 / height, strength: height * 0.022}; - const slivers = Array.from({length: 22}, () => ({ - along: random(), - side: (random() - 0.5) * 2, - length: 0.03 + random() * 0.07, - phase: random(), - })); - const confetti = Array.from({length: 40}, (_, index) => ({ - angle: 200 + random() * 120, - reach: 0.15 + random() * 0.45, - size: 0.006 + random() * 0.01, - delay: random() * 0.5, - color: confettiColors[index % confettiColors.length] ?? '#ffffff', - })); - const shards = createShards(random, 34); - const chains = Array.from({length: 5}, () => ({ - x: 0.3 + random() * 0.7, - y: random() * 0.5, - angle: -50 + random() * 100, - count: 6 + Math.floor(random() * 8), - drift: (random() - 0.5) * 0.04, - })); - const panels = Array.from({length: 9}, () => ({ - x: 0.25 + random() * 0.75, - y: random() * 0.85, - w: 0.02 + random() * 0.05, - h: 0.03 + random() * 0.07, - tilt: (random() - 0.5) * 40, - phase: random() * Math.PI * 2, - })); - return { - ringImage: bakeCloudLayer({...paint, width: reach * 2, height: reach * 2, clusters: ringClusters}), - bankImage: bakeCloudLayer({ - ...paint, - width: width * cloudBandWidth, - height: height * cloudBandHeight, - clusters: bankClusters, - }), - slivers, - confetti, - shards, - chains, - panels, - stars: createStarfield(60, seed), - }; -}; - -/** @typedef {ReturnType} BetaLayout */ -/** - * @typedef {object} ShotProps - * @property {number} seconds - * @property {number} width - * @property {number} height - * @property {BetaLayout} layout - * @property {import('./gacha-timeline.js').RarityPalette} palette - * @property {'B' | 'A' | 'S'} rank Rank for the request's rarity, as on /api/gacha. - */ - -/** - * Four-point star glint with a soft core, used for meteor heads. - * - * @param {{x: number, y: number, size: number, color: string, core?: string, rays?: number, spin?: number}} props - */ -const Glint = ({x, y, size, color, core = '#ffffff', rays = 4, spin = 0}) => ( - <> -
- {Array.from({length: rays}, (_, index) => { - const long = index % 2 === 0 ? 1.6 : 0.9; - return ( -
- ); - })} - -); - -/** @param {ShotProps} props */ -const VortexShot = ({seconds, width, height, layout}) => { - const {zoom, spin} = getVortexState(seconds); - const cx = width / 2; - const cy = height * 0.36; - const eye = height * 0.34 * zoom; - return ( - - -
- - -
- - - - - ); -}; - -/** @param {ShotProps} props */ -const BeamShot = ({seconds, width, height}) => { - const t = progress(seconds, gachaBetaTimeline.vortexEnd, gachaBetaTimeline.beamEnd); - return ( - - {[ - {offset: 0, size: 0.34, alpha: 1}, - {offset: 0.18, size: 0.12, alpha: 0.7}, - ].map((beam, index) => ( -
- ))} - - ); -}; - -/** - * Low cloud banks over the horizon: flat sea on the left, a rising bank on the right. - * - * @param {{width: number, height: number, layout: BetaLayout, drift: number, drop?: number}} props - * `drift` and `drop` shift the bank left and down, in frame fractions. - */ -const CloudSea = ({width, height, layout, drift, drop = 0}) => ( - <> -
- - -); - -/** - * Turbulence displacement that twists the vortex eye's streaks into wisps. - * - * @param {{height: number, seed: number}} props - */ -const PaintFilters = ({height, seed}) => ( - - - - - - - - -); - -/** @param {ShotProps} props */ -const SkyShot = ({seconds, width, height, layout, palette}) => { - const tl = gachaBetaTimeline; - const head = getMeteorHead(seconds); - const halo = getHaloState(seconds); - const burst = getBurstState(seconds); - const hx = head.x * width; - const hy = head.y * height; - const trailAngle = 180 + (Math.atan2(0.44 * height, 0.54 * width) * 180) / Math.PI; - const sinceStop = Math.max(0, seconds - tl.meteorStop); - const core = burst.tint > 0.3 ? '#ffe8f4' : '#ffffff'; - const headColor = halo.warmth > 0.5 ? '#ff8fb4' : palette.glow; - return ( - - {layout.stars.map((star, index) => ( -
- ))} - -
-
-
- {layout.slivers.map((sliver, index) => { - const flow = (sliver.phase + seconds * (0.6 + head.speed)) % 1; - return ( -
- ); - })} -
- {sinceStop > 0 - ? layout.confetti.map((piece, index) => { - const t = clamp01((sinceStop - piece.delay) / 1.2); - if (t <= 0) { - return null; - } - const radians = (piece.angle * Math.PI) / 180; - const reach = piece.reach * height * (1 - (1 - t) ** 2); - const size = piece.size * height; - return ( -
- ); - }) - : null} - {halo.opacity > 0 ? ( -
- ) : null} - {burst.flare > 0 ? ( - <> -
- {Array.from({length: 16}, (_, index) => ( -
- ))} - - ) : null} - 0 ? 8 : 4} - size={height * (0.1 + 0.12 * halo.opacity + 0.5 * burst.glow)} - spin={sinceStop * 40} - x={hx} - y={hy} - /> - - - ); -}; - -/** @param {ShotProps} props */ -const VolleyShot = ({seconds, width, height, layout}) => { - const red = getRedMeteor(seconds); - const tilt = (Math.atan(0.12) * 180) / Math.PI; - return ( - - {volleyComets.map((comet, index) => { - const head = getVolleyCometHead(comet, seconds); - const size = comet.size * height; - const x = head.x * width; - const y = head.y * height; - return ( -
-
-
-
-
- ); - })} - {red.flare > 0 ? ( - <> - {red.falling > 0 ? ( -
- ) : null} -
- - - ) : null} - - - ); -}; - -/** - * The rank emblem: a faceted rhombus crystal around the rank letters. Drawn - * solid black for the silhouette and in the rarity colours on the card. - * - * @param {{size: number, silhouette: boolean, palette: import('./gacha-timeline.js').RarityPalette, rank: string}} props - */ -const Emblem = ({size, silhouette, palette, rank}) => { - const fill = silhouette ? '#050305' : `linear-gradient(135deg, #ffffff 0%, ${palette.emblem} 100%)`; - return ( -
-
- {silhouette ? null : ( -
- )} -
- {rank} -
-
- ); -}; - -/** @param {ShotProps} props */ -const SilhouetteShot = ({seconds, width, height, layout, palette, rank}) => { - const tl = gachaBetaTimeline; - const t = progress(seconds, tl.volleyEnd, tl.silhouetteEnd); - const cx = width / 2; - const cy = height / 2; - const disc = height * (0.46 + 0.04 * t); - const emblem = height * (0.62 + 0.06 * t); - return ( - -
-
- {[ - {w: 1.5, h: 0.75, turn: -20, thick: 0.06}, - {w: 1.2, h: 0.95, turn: 150, thick: 0.045}, - {w: 1.8, h: 0.6, turn: 200, thick: 0.035}, - {w: 1.0, h: 0.7, turn: 40, thick: 0.03}, - ].map((ribbon, index) => ( -
- ))} - {layout.shards.map((shard, index) => { - const offset = getShardOffset(shard, seconds); - const size = shard.size * height; - return ( -
- ); - })} -
- -
- - ); -}; - -/** - * @param {ShotProps & {label: string, rarity: import('./gacha-timeline.js').GachaRarity}} props - */ -const RevealShot = ({seconds, width, height, layout, palette, rank, label, rarity}) => { - const tl = gachaBetaTimeline; - const settle = progress(seconds, tl.silhouetteEnd, gachaBetaTimeline.silhouetteEnd + 2.5); - const plateIn = interpolate(seconds, [tl.plateIn, tl.plateIn + 0.4], [0, 1], { - ...clamp, - easing: Easing.out(Easing.cubic), - }); - const emblem = height * 0.62; - const labelLength = Array.from(label).length; - const nameSize = height * (labelLength <= 10 ? 0.085 : labelLength <= 20 ? 0.065 : 0.05); - const starSize = height * 0.042; - return ( - - - {layout.chains.map((chain, index) => ( -
- {Array.from({length: chain.count}, (_, link) => ( -
- ))} -
- ))} - {layout.panels.map((panel, index) => ( -
- ))} -
-
- -
- -
-
-
-
-
- {label} -
-
- {`Rank ${rank}`} -
-
-
-
- {getStarRevealTimes(rarity).map((time, index) => { - const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.4, 1], clamp); - return ( -
0 ? 1 : 0, - transform: `scale(${pop})`, - width: starSize, - }} - > - -
- ); - })} -
-
- - ); -}; - -/** - * @param {import('../schemas/gacha-request.js').GachaRenderRequest} props - */ -export const GachaBetaComposition = (props) => { - const frame = useCurrentFrame(); - const {fps, width, height} = useVideoConfig(); - const seconds = frame / fps; - const layout = useMemo(() => createBetaLayout(props.seed, width, height), [props.seed, width, height]); - const palette = getRarityPalette(props.rarity); - const shot = getShot(seconds); - const flash = getFlash(seconds); - const shared = {seconds, width, height, layout, palette, rank: getRankLetter(props.rarity)}; - - return ( - - - {shot === 'vortex' ? : null} - {shot === 'beam' ? : null} - {shot === 'sky' ? : null} - {shot === 'volley' ? : null} - {shot === 'silhouette' ? : null} - {shot === 'reveal' ? : null} - {flash.opacity > 0 ? : null} - {flash.opacity > 0 && flash.color !== '#ffffff' && shot === 'sky' ? ( - - ) : null} - - ); -}; diff --git a/renderer/src/remotion/Root.jsx b/renderer/src/remotion/Root.jsx index 6e414f0..cb5c605 100644 --- a/renderer/src/remotion/Root.jsx +++ b/renderer/src/remotion/Root.jsx @@ -1,7 +1,5 @@ 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'; @@ -45,13 +43,12 @@ const defaultGachaProps = { /** * @param {{fps: number, width: number}} props - * @param {number} [totalSeconds] */ -export const getGachaCompositionMetadata = (props, totalSeconds = gachaTotalSeconds) => { +export const getGachaCompositionMetadata = (props) => { const fps = props.fps || defaultGachaProps.fps; const width = props.width in gachaFrameSizes ? props.width : defaultGachaProps.width; return { - durationInFrames: Math.ceil(totalSeconds * fps), + durationInFrames: Math.ceil(gachaTotalSeconds * fps), fps, height: gachaFrameSizes[/** @type {640 | 854} */ (width)], width, @@ -80,15 +77,5 @@ 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-paint.js b/renderer/src/remotion/gacha-beta-paint.js deleted file mode 100644 index 1ce6941..0000000 --- a/renderer/src/remotion/gacha-beta-paint.js +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Bakes the beta wish's cloud layers once per roll: puffs are painted with - * radial gradients on a canvas, then torn into painterly edges by a seeded - * noise displacement. Each frame then only moves the baked image, which keeps - * the clouds' look without paying for a live SVG filter on every frame. - */ - -import {createRandom} from './gacha-timeline.js'; - -/** - * @typedef {object} Puff - * @property {number} dx Offset in cluster units. - * @property {number} dy - * @property {number} r Radius in cluster units. - */ - -/** - * @typedef {object} CloudCluster - * @property {number} x Centre in canvas pixels. - * @property {number} y - * @property {number} size Cluster size in pixels. - * @property {Puff[]} puffs - */ - -/** - * Smooth value noise in [0, 1] over a seeded lattice, summed over octaves. - * - * @param {number} seed - * @param {number} frequency Lattice cells per pixel at the first octave. - * @param {number} octaves - * @returns {(x: number, y: number) => number} - */ -export const createValueNoise = (seed, frequency, octaves) => { - const random = createRandom(seed); - const size = 256; - const lattice = Float32Array.from({length: size * size}, () => random()); - /** @param {number} t */ - const fade = (t) => t * t * (3 - 2 * t); - /** - * @param {number} x - * @param {number} y - */ - const sample = (x, y) => { - const x0 = Math.floor(x); - const y0 = Math.floor(y); - const tx = fade(x - x0); - const ty = fade(y - y0); - const at = (/** @type {number} */ ix, /** @type {number} */ iy) => - lattice[(((iy % size) + size) % size) * size + (((ix % size) + size) % size)] ?? 0; - const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * tx; - const bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * tx; - return top + (bottom - top) * ty; - }; - return (x, y) => { - let total = 0; - let weight = 0; - let amplitude = 1; - let scale = frequency; - for (let octave = 0; octave < octaves; octave += 1) { - total += sample(x * scale, y * scale) * amplitude; - weight += amplitude; - amplitude /= 2; - scale *= 2; - } - return total / weight; - }; -}; - -/** - * Draws one cluster: a blue-shadowed base, then sunlit puffs on top. - * - * @param {CanvasRenderingContext2D} context - * @param {CloudCluster} cluster - */ -const paintCluster = (context, {x, y, size, puffs}) => { - const base = context.createRadialGradient(x, y + size * 0.1, 0, x, y + size * 0.3, size * 1.05); - base.addColorStop(0, 'rgba(170,198,240,0.95)'); - base.addColorStop(0.55, 'rgba(120,156,220,0.6)'); - base.addColorStop(1, 'rgba(120,156,220,0)'); - context.fillStyle = base; - context.beginPath(); - context.ellipse(x, y + size * 0.3, size * 1.05, size * 0.35, 0, 0, Math.PI * 2); - context.fill(); - for (const puff of puffs) { - const cx = x + puff.dx * size; - const cy = y + puff.dy * size; - const r = puff.r * size; - const shade = context.createRadialGradient(cx - r * 0.08, cy - r * 0.32, 0, cx - r * 0.08, cy - r * 0.32, r * 1.3); - shade.addColorStop(0, '#ffffff'); - shade.addColorStop(0.3, '#f5f9ff'); - shade.addColorStop(0.52, '#d6e5fa'); - shade.addColorStop(0.68, 'rgba(150,182,232,0.9)'); - shade.addColorStop(0.8, 'rgba(150,182,232,0)'); - context.fillStyle = shade; - context.beginPath(); - context.arc(cx, cy, r, 0, Math.PI * 2); - context.fill(); - } -}; - -/** - * Paints clusters onto a canvas of the given size, displaces the result with - * two noise fields for torn, painterly edges, and returns it as a PNG data - * URL. Returns an empty string outside a browser. - * - * @param {{width: number, height: number, clusters: CloudCluster[], seed: number, frequency: number, strength: number}} options - * `frequency` is noise cells per pixel; `strength` is the displacement in pixels. - * @returns {string} - */ -export const bakeCloudLayer = ({width, height, clusters, seed, frequency, strength}) => { - if (typeof document === 'undefined') { - return ''; - } - const w = Math.ceil(width); - const h = Math.ceil(height); - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - const context = canvas.getContext('2d'); - if (!context) { - return ''; - } - for (const cluster of clusters) { - paintCluster(context, cluster); - } - const source = context.getImageData(0, 0, w, h); - const output = context.createImageData(w, h); - const noiseX = createValueNoise(seed, frequency, 2); - const noiseY = createValueNoise(seed + 1, frequency, 2); - for (let y = 0; y < h; y += 1) { - for (let x = 0; x < w; x += 1) { - const sx = Math.min(w - 1, Math.max(0, Math.round(x + (noiseX(x, y) - 0.5) * 2 * strength))); - const sy = Math.min(h - 1, Math.max(0, Math.round(y + (noiseY(x, y) - 0.5) * 2 * strength))); - const from = (sy * w + sx) * 4; - const to = (y * w + x) * 4; - output.data[to] = source.data[from] ?? 0; - output.data[to + 1] = source.data[from + 1] ?? 0; - output.data[to + 2] = source.data[from + 2] ?? 0; - output.data[to + 3] = source.data[from + 3] ?? 0; - } - } - context.putImageData(output, 0, 0); - return canvas.toDataURL('image/png'); -}; diff --git a/renderer/src/remotion/gacha-beta-scene.js b/renderer/src/remotion/gacha-beta-scene.js deleted file mode 100644 index 545f630..0000000 --- a/renderer/src/remotion/gacha-beta-scene.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Shot timing and motion for the beta wish, cut like the six-star wish - * sequence of an anime gacha game: a dive through a cloud vortex, a beam - * pass, a meteor that slows over a sea of clouds inside a rainbow halo and - * bursts, a red flash, a volley of falling crystal comets joined by a red - * one, a black silhouette on a red disc that sheds shards, and the splash - * card with its name plate, rank, and stars. Kept free of React so the - * choreography can be unit tested. - */ - -export const gachaBetaTotalSeconds = 10.5; - -/** Seconds from the start of the clip for each cut and beat. */ -export const gachaBetaTimeline = Object.freeze({ - vortexEnd: 1.1, - beamEnd: 1.45, - meteorStop: 2.9, - haloEnd: 3.7, - burstEnd: 4.5, - redFlashEnd: 4.8, - volleyEnd: 6.45, - redStarIn: 5.4, - silhouetteEnd: 7.7, - plateIn: 7.8, - starsStart: 8.2, - starStep: 0.14, -}); - -/** @typedef {import('./gacha-timeline.js').GachaRarity} GachaRarity */ - -/** @typedef {'vortex' | 'beam' | 'sky' | 'volley' | 'silhouette' | 'reveal'} BetaShot */ - -/** - * @param {number} value - * @returns {number} - */ -const clamp01 = (value) => Math.min(1, Math.max(0, value)); - -/** - * @param {number} t - * @returns {number} - */ -const smoothstep = (t) => { - const x = clamp01(t); - return x * x * (3 - 2 * x); -}; - -/** - * @param {number} seconds - * @param {number} start - * @param {number} end - * @returns {number} - */ -export const progress = (seconds, start, end) => clamp01((seconds - start) / (end - start)); - -/** - * The shot on screen. Every change is a hard cut; flashes cover the - * louder ones. - * - * @param {number} seconds - * @returns {BetaShot} - */ -export const getShot = (seconds) => { - const tl = gachaBetaTimeline; - if (seconds < tl.vortexEnd) { - return 'vortex'; - } - if (seconds < tl.beamEnd) { - return 'beam'; - } - if (seconds < tl.redFlashEnd) { - return 'sky'; - } - if (seconds < tl.volleyEnd) { - return 'volley'; - } - if (seconds < tl.silhouetteEnd) { - return 'silhouette'; - } - return 'reveal'; -}; - -/** - * Full-frame flash colour and opacity: white out of the opening, red then - * white after the burst, white on the red meteor's impact, and a pale pink - * wash that hands the silhouette over to the splash card. - * - * @param {number} seconds - * @returns {{color: string, opacity: number}} - */ -export const getFlash = (seconds) => { - const tl = gachaBetaTimeline; - /** @type {Array<[number, number, number, string, number?]>} start, peak, end, colour, peak opacity */ - const flashes = [ - [-1, 0, 0.45, '#ffffff'], - [tl.burstEnd - 0.2, tl.burstEnd, tl.burstEnd + 0.12, '#ff5a6e', 0.8], - [tl.burstEnd + 0.1, tl.redFlashEnd, tl.redFlashEnd + 0.3, '#ffffff'], - [tl.volleyEnd - 0.12, tl.volleyEnd, tl.volleyEnd + 0.3, '#ffffff'], - [tl.silhouetteEnd - 0.3, tl.silhouetteEnd, tl.silhouetteEnd + 0.45, '#ffe6f0'], - ]; - for (const [start, peak, end, color, strength = 1] of flashes) { - if (seconds >= start && seconds <= end) { - const shape = - seconds <= peak ? smoothstep(progress(seconds, start, peak)) : 1 - smoothstep(progress(seconds, peak, end)); - return {color, opacity: shape * strength}; - } - } - return {color: '#ffffff', opacity: 0}; -}; - -/** - * Camera dive into the cloud vortex: the ring of clouds swells past the - * frame while the dark eye opens up. - * - * @param {number} seconds - * @returns {{zoom: number, spin: number}} - */ -export const getVortexState = (seconds) => { - const t = progress(seconds, 0, gachaBetaTimeline.vortexEnd); - return {zoom: 1 + 1.4 * t ** 2.2, spin: 40 * t}; -}; - -/** - * Meteor head over the sea of clouds, as frame fractions. It enters from the - * upper left and decelerates to a stop just right of centre, where the halo - * and burst happen. - * - * @param {number} seconds - * @returns {{x: number, y: number, speed: number}} - * `speed` is 1 on entry and 0 once the meteor has stopped. - */ -export const getMeteorHead = (seconds) => { - const tl = gachaBetaTimeline; - const t = progress(seconds, tl.beamEnd, tl.meteorStop); - const glide = 1 - (1 - t) ** 3; - return { - x: 0.02 + 0.54 * glide, - y: 0.12 + 0.44 * glide, - speed: (1 - t) ** 2, - }; -}; - -/** - * Rainbow halo around the stopped meteor: it blooms large, then contracts - * while the head warms from white to pink. - * - * @param {number} seconds - * @returns {{radius: number, opacity: number, warmth: number}} - * `radius` is in frame heights. - */ -export const getHaloState = (seconds) => { - const tl = gachaBetaTimeline; - const grow = smoothstep(progress(seconds, tl.meteorStop - 0.3, tl.meteorStop + 0.25)); - const shrink = smoothstep(progress(seconds, tl.meteorStop + 0.4, tl.haloEnd)); - return { - radius: 0.06 + 0.3 * grow - 0.24 * shrink, - opacity: grow * (1 - shrink), - warmth: smoothstep(progress(seconds, tl.meteorStop, tl.haloEnd)), - }; -}; - -/** - * The burst that follows the halo: a swelling starburst and anamorphic flare - * while the sky tints violet. - * - * @param {number} seconds - * @returns {{glow: number, flare: number, tint: number}} - */ -export const getBurstState = (seconds) => { - const tl = gachaBetaTimeline; - const t = progress(seconds, tl.haloEnd - 0.2, tl.burstEnd); - return {glow: t ** 1.6, flare: smoothstep(t * 1.4), tint: smoothstep(t)}; -}; - -/** - * @typedef {object} VolleyComet - * @property {number} x Head x as a frame fraction. - * @property {number} start Seconds at which the head enters from the top. - * @property {number} fall Frame heights travelled until the volley ends. - * @property {number} size Head size in frame heights. - */ - -/** Crystal comets falling nearly straight down after the red flash. */ -export const volleyComets = /** @type {readonly VolleyComet[]} */ ( - Object.freeze([ - {x: 0.04, start: 4.7, fall: 0.95, size: 0.1}, - {x: 0.24, start: 4.78, fall: 1.05, size: 0.12}, - {x: 0.43, start: 4.74, fall: 0.85, size: 0.09}, - {x: 0.62, start: 4.82, fall: 1.0, size: 0.11}, - ]) -); - -/** The volley drifts right as it falls, as in the source shot. */ -const volleyDrift = 0.12; - -/** - * Head of a volley comet in frame fractions. It falls fast, then eases. - * - * @param {VolleyComet} comet - * @param {number} seconds - * @returns {{x: number, y: number}} - */ -export const getVolleyCometHead = (comet, seconds) => { - const t = progress(seconds, comet.start, gachaBetaTimeline.volleyEnd); - const fall = comet.fall * (1 - (1 - t) ** 2); - return {x: comet.x + volleyDrift * fall, y: -0.1 + fall}; -}; - -/** - * The red six-star meteor: a red star flares in the upper right, then drops - * and strikes just below centre as the volley ends. - * - * @param {number} seconds - * @returns {{x: number, y: number, flare: number, falling: number}} - * `flare` scales the star glint, `falling` runs from 0 to 1 over the drop. - */ -export const getRedMeteor = (seconds) => { - const tl = gachaBetaTimeline; - const appear = smoothstep(progress(seconds, tl.redStarIn, tl.redStarIn + 0.3)); - const falling = progress(seconds, tl.redStarIn + 0.35, tl.volleyEnd) ** 1.8; - return { - x: 0.68 - 0.2 * falling, - y: 0.08 + 0.55 * falling, - flare: appear * (1 + 1.5 * falling), - falling, - }; -}; - -/** - * @typedef {object} Shard - * @property {number} angle Flight direction in degrees. - * @property {number} reach Distance travelled in frame heights. - * @property {number} size Edge length in frame heights. - * @property {number} spin Rotation in degrees over the shot. - * @property {number} start Delay in seconds after the silhouette appears. - */ - -/** - * Seeded shards that burst off the silhouette and drift outward. - * - * @param {() => number} random - * @param {number} count - * @returns {Shard[]} - */ -export const createShards = (random, count) => - Array.from({length: count}, () => ({ - angle: random() * 360, - reach: 0.25 + random() * 0.55, - size: 0.015 + random() * 0.05, - spin: (random() - 0.5) * 540, - start: random() * 0.35, - })); - -/** - * Offset of a shard from the silhouette centre, in frame heights. - * - * @param {Shard} shard - * @param {number} seconds - * @returns {{dx: number, dy: number, rotate: number, opacity: number}} - */ -export const getShardOffset = (shard, seconds) => { - const tl = gachaBetaTimeline; - const t = progress(seconds, tl.volleyEnd + 0.15 + shard.start, tl.silhouetteEnd); - const travel = shard.reach * (1 - (1 - t) ** 3); - const radians = (shard.angle * Math.PI) / 180; - return { - dx: Math.cos(radians) * travel, - dy: Math.sin(radians) * travel, - rotate: shard.spin * t, - opacity: t > 0 ? 1 : 0, - }; -}; - -/** - * Seconds at which each reveal star pops in, left to right: one star per - * rarity level. - * - * @param {GachaRarity} rarity - * @returns {number[]} - */ -export const getStarRevealTimes = (rarity) => - Array.from({length: rarity}, (_, index) => gachaBetaTimeline.starsStart + index * gachaBetaTimeline.starStep); diff --git a/renderer/src/render/cdp-pipe.js b/renderer/src/render/cdp-pipe.js new file mode 100644 index 0000000..ab3a44d --- /dev/null +++ b/renderer/src/render/cdp-pipe.js @@ -0,0 +1,98 @@ +/** + * Minimal Chrome DevTools Protocol client over `--remote-debugging-pipe`. + * Chrome reads NUL-terminated JSON messages on file descriptor 3 and writes + * them on 4, so no debugging port is opened. + */ + +/** + * @typedef {object} CdpMessage + * @property {number} [id] + * @property {string} [method] + * @property {string} [sessionId] + * @property {Record} [params] + * @property {Record} [result] + * @property {{message: string}} [error] + */ + +/** + * @typedef {(params: Record, sessionId: string | undefined) => void} CdpListener + */ + +/** + * @typedef {object} CdpConnection + * @property {(method: string, params?: Record, sessionId?: string) => Promise>} send + * @property {(method: string, listener: CdpListener) => () => void} on Returns a function that removes the listener. + * @property {(method: string, sessionId: string) => Promise>} once Next event with this name from one session. + * @property {(error: Error) => void} fail Rejects every pending call, for example when Chrome exits. + */ + +/** + * @param {NodeJS.WritableStream} input Chrome's fd 3. + * @param {NodeJS.ReadableStream} output Chrome's fd 4. + * @returns {CdpConnection} + */ +export const createCdpConnection = (input, output) => { + let nextId = 0; + /** @type {Map) => void, reject: (error: Error) => void, method: string}>} */ + const pending = new Map(); + /** @type {Map>} */ + const listeners = new Map(); + let buffered = ''; + + output.setEncoding?.('utf8'); + output.on('data', (/** @type {string} */ chunk) => { + buffered += chunk; + let end = buffered.indexOf('\0'); + while (end !== -1) { + const message = /** @type {CdpMessage} */ (JSON.parse(buffered.slice(0, end))); + buffered = buffered.slice(end + 1); + end = buffered.indexOf('\0'); + if (message.id !== undefined) { + const call = pending.get(message.id); + pending.delete(message.id); + if (message.error) { + call?.reject(new Error(`${call.method}: ${message.error.message}`)); + } else { + call?.resolve(message.result ?? {}); + } + } else if (message.method) { + for (const listener of [...(listeners.get(message.method) ?? [])]) { + listener(message.params ?? {}, message.sessionId); + } + } + } + }); + + /** @type {CdpConnection['on']} */ + const on = (method, listener) => { + const set = listeners.get(method) ?? new Set(); + set.add(listener); + listeners.set(method, set); + return () => set.delete(listener); + }; + + return { + send: (method, params = {}, sessionId) => + new Promise((resolve, reject) => { + nextId += 1; + pending.set(nextId, {resolve, reject, method}); + input.write(`${JSON.stringify({id: nextId, method, params, ...(sessionId ? {sessionId} : {})})}\0`); + }), + on, + once: (method, sessionId) => + new Promise((resolve) => { + const off = on(method, (params, from) => { + if (from === sessionId) { + off(); + resolve(params); + } + }); + }), + fail: (error) => { + for (const call of pending.values()) { + call.reject(error); + } + pending.clear(); + }, + }; +}; diff --git a/renderer/src/render/render-gacha-beta.js b/renderer/src/render/render-gacha-beta.js new file mode 100644 index 0000000..0e796cf --- /dev/null +++ b/renderer/src/render/render-gacha-beta.js @@ -0,0 +1,364 @@ +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, + 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 {gachaFrameSizes} from '../schemas/gacha-request.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', { + width: request.width, + height: gachaFrameSizes[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 477d55a..fac435f 100644 --- a/renderer/src/render/render-gacha.js +++ b/renderer/src/render/render-gacha.js @@ -6,23 +6,18 @@ import {renderComposition} from './render-composition.js'; */ /** - * 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. + * 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. * - * @param {string} compositionId - * @returns {(inputProps: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise} + * @param {GachaRenderRequest} inputProps + * @param {{timeoutInMilliseconds: number}} options + * @returns {Promise} */ -const createGachaRenderer = (compositionId) => (inputProps, options) => +export const renderGachaVideo = (inputProps, options) => renderComposition({ - compositionId, + compositionId: 'GachaWish', 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: a six-star style wish sequence ending in a ranked splash card. */ -export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish'); diff --git a/renderer/src/server.js b/renderer/src/server.js index 852e22b..32584a1 100644 --- a/renderer/src/server.js +++ b/renderer/src/server.js @@ -2,7 +2,8 @@ import Fastify from 'fastify'; import {loadConfig} from './config.js'; import {createRenderSemaphore} from './lib/render-semaphore.js'; import {getRemotionServeUrl} from './render/remotion-bundle.js'; -import {renderGachaBetaVideo, renderGachaVideo} from './render/render-gacha.js'; +import {closeGachaBetaBrowser, renderGachaBetaVideo, warmGachaBetaBrowser} from './render/render-gacha-beta.js'; +import {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'; @@ -20,14 +21,15 @@ import {registerHealthRoute} from './routes/health.js'; * @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] + * @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the beta wish browser before serving. */ export const buildServer = async (options = {}) => { const config = options.config ?? loadConfig(); - const warmRemotionBundle = options.warmRemotionBundle ?? process.env.NODE_ENV !== 'test'; + const warmRenderers = options.warmRenderers ?? process.env.NODE_ENV !== 'test'; - if (warmRemotionBundle) { + if (warmRenderers) { await getRemotionServeUrl(); + await warmGachaBetaBrowser(); } const app = Fastify({ @@ -59,6 +61,8 @@ export const buildServer = async (options = {}) => { renderGacha: options.renderGachaBeta ?? renderGachaBetaVideo, semaphore, }); + // The beta wish keeps one headless Chrome alive between renders. + app.addHook('onClose', closeGachaBetaBrowser); return app; }; diff --git a/renderer/test/gacha-beta-scene.test.js b/renderer/test/gacha-beta-scene.test.js deleted file mode 100644 index 63c573a..0000000 --- a/renderer/test/gacha-beta-scene.test.js +++ /dev/null @@ -1,104 +0,0 @@ -import {describe, expect, test} from 'vitest'; -import { - createShards, - gachaBetaTimeline, - gachaBetaTotalSeconds, - getFlash, - getHaloState, - getMeteorHead, - getRedMeteor, - getShardOffset, - getShot, - getStarRevealTimes, - getVolleyCometHead, - getVortexState, - volleyComets, -} from '../src/remotion/gacha-beta-scene.js'; -import {createRandom} from '../src/remotion/gacha-timeline.js'; - -describe('gacha beta wish scene', () => { - test('shots cut in order: vortex, beam, sky, volley, silhouette, reveal', () => { - const tl = gachaBetaTimeline; - /** @type {string[]} */ - const order = []; - for (let seconds = 0; seconds < gachaBetaTotalSeconds; seconds += 0.05) { - const shot = getShot(seconds); - if (order.at(-1) !== shot) { - order.push(shot); - } - } - expect(order).toEqual(['vortex', 'beam', 'sky', 'volley', 'silhouette', 'reveal']); - expect(tl.meteorStop).toBeLessThan(tl.haloEnd); - expect(tl.haloEnd).toBeLessThan(tl.burstEnd); - expect(tl.redStarIn).toBeLessThan(tl.volleyEnd); - }); - - test('one star per rarity level, finishing with time to hold the card', () => { - for (const rarity of /** @type {const} */ ([3, 4, 5])) { - const times = getStarRevealTimes(rarity); - expect(times).toHaveLength(rarity); - expect(times[0]).toBeGreaterThan(gachaBetaTimeline.plateIn); - expect((times.at(-1) ?? Infinity) + 1).toBeLessThan(gachaBetaTotalSeconds); - } - }); - - test('flashes open the clip, cover the burst in red, and hand over at each loud cut', () => { - const tl = gachaBetaTimeline; - expect(getFlash(0).opacity).toBe(1); - expect(getFlash(tl.burstEnd)).toMatchObject({color: '#ff5a6e'}); - expect(getFlash(tl.redFlashEnd).opacity).toBe(1); - expect(getFlash(tl.volleyEnd).opacity).toBe(1); - expect(getFlash(tl.silhouetteEnd).opacity).toBe(1); - expect(getFlash(2).opacity).toBe(0); - expect(getFlash(9.5).opacity).toBe(0); - }); - - test('the camera dives into the vortex', () => { - expect(getVortexState(gachaBetaTimeline.vortexEnd).zoom).toBeGreaterThan(getVortexState(0).zoom); - }); - - test('the meteor decelerates to a stop and holds through the halo', () => { - const tl = gachaBetaTimeline; - const a = getMeteorHead(tl.beamEnd); - const b = getMeteorHead(tl.beamEnd + 0.3); - const c = getMeteorHead(tl.meteorStop - 0.3); - const stop = getMeteorHead(tl.meteorStop); - expect(b.x - a.x).toBeGreaterThan(stop.x - c.x); - expect(stop.speed).toBe(0); - expect(getMeteorHead(tl.haloEnd)).toEqual(stop); - }); - - test('the rainbow halo blooms after the stop and closes before the burst', () => { - const tl = gachaBetaTimeline; - expect(getHaloState(tl.beamEnd).opacity).toBe(0); - expect(getHaloState(tl.meteorStop + 0.3).opacity).toBeGreaterThan(0.8); - expect(getHaloState(tl.haloEnd).opacity).toBe(0); - }); - - test('volley comets fall and drift right', () => { - for (const comet of volleyComets) { - const early = getVolleyCometHead(comet, comet.start + 0.2); - const late = getVolleyCometHead(comet, gachaBetaTimeline.volleyEnd); - expect(late.y).toBeGreaterThan(early.y); - expect(late.x).toBeGreaterThan(early.x); - } - }); - - test('the red meteor flares, then drops toward the centre by the impact', () => { - const tl = gachaBetaTimeline; - expect(getRedMeteor(tl.redStarIn).flare).toBe(0); - const impact = getRedMeteor(tl.volleyEnd); - expect(impact.falling).toBe(1); - expect(impact.y).toBeGreaterThan(getRedMeteor(tl.redStarIn + 0.3).y); - }); - - test('silhouette shards burst outward from the centre', () => { - const shards = createShards(createRandom(3), 12); - for (const shard of shards) { - const mid = getShardOffset(shard, gachaBetaTimeline.volleyEnd + 0.7); - const end = getShardOffset(shard, gachaBetaTimeline.silhouetteEnd); - expect(Math.hypot(end.dx, end.dy)).toBeGreaterThanOrEqual(Math.hypot(mid.dx, mid.dy)); - expect(Math.hypot(end.dx, end.dy)).toBeCloseTo(shard.reach); - } - }); -}); diff --git a/renderer/test/gacha-beta-wish-plan.test.js b/renderer/test/gacha-beta-wish-plan.test.js new file mode 100644 index 0000000..6f49ffa --- /dev/null +++ b/renderer/test/gacha-beta-wish-plan.test.js @@ -0,0 +1,67 @@ +import path from 'node:path'; +import {describe, expect, test} from 'vitest'; +import { + gachaBetaTotalSeconds, + getDragPointer, + getWishPageProps, + resolveWishAsset, + wishOrigin, + wishTimeline, +} from '../src/gacha-beta/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', () => { + 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ò', rank: 'B', stars: 3, rarity: 'rare'}); + expect(getWishPageProps({...base, rarity: 4})).toMatchObject({rank: 'A', stars: 4, rarity: 'epic'}); + expect(getWishPageProps({...base, rarity: 5})).toMatchObject({rank: 'S', stars: 5, rarity: 'legendary'}); + expect(getWishPageProps({...base, rarity: 5}).artwork).toEqual({accent: '#ffd36b', tint: '#7a4a10'}); + }); + + test('the drag sweeps left to right across the seal, then lets go', () => { + const {dragStart, dragEnd} = wishTimeline; + expect(getDragPointer(dragStart - 0.01)).toBeNull(); + expect(getDragPointer(dragEnd + 0.01)).toBeNull(); + const start = getDragPointer(dragStart); + const end = getDragPointer(dragEnd); + expect(start && end).toBeTruthy(); + if (start && end) { + expect(end.x).toBeGreaterThan(start.x); + expect(end.x).toBeLessThanOrEqual(1); + expect(end.y).toBe(start.y); + } + expect(dragEnd + 2).toBeLessThan(gachaBetaTotalSeconds); + }); + + test('page requests map to the page directory and the installed package', () => { + expect(resolveWishAsset(`${wishOrigin}/index.html`, roots)).toEqual({ + file: path.join(roots.page, 'index.html'), + contentType: 'text/html; charset=utf-8', + }); + expect(resolveWishAsset(`${wishOrigin}/pack-cards/presentation/pack.js`, roots)).toEqual({ + file: path.join(roots.packCards, 'presentation', 'pack.js'), + contentType: 'text/javascript; charset=utf-8', + }); + }); + + test('requests outside the page, other origins, and unknown file types are refused', () => { + for (const url of [ + `${wishOrigin}/..%2fsecret.js`, + `${wishOrigin}/%2e%2e%2fsecret.js`, + `${wishOrigin}/pack-cards/..%2f..%2fpage%2fpage.js`, + `${wishOrigin}/page.sh`, + `${wishOrigin}/%E0%A4%A`, + 'https://example.com/index.html', + 'not a url', + ]) { + expect(resolveWishAsset(url, roots), url).toBeNull(); + } + }); + + test('dot segments are resolved by the URL before mapping, so they stay inside a root', () => { + const asset = resolveWishAsset(`${wishOrigin}/pack-cards/%2e%2e/%2e%2e/etc/passwd.js`, roots); + expect(asset?.file).toBe(path.join(roots.page, 'etc', 'passwd.js')); + }); +});