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