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')); + }); +});