feat(gacha)!: render /api/gacha as the pack-cards wish

/api/gacha now renders the 6-second portrait pack-cards opening that was
served on /api/gachabeta. The Remotion meteor wish, its timeline, and the
beta route are removed, and the pack-cards renderer and page take the plain
gacha names.

BREAKING CHANGE: /api/gachabeta is gone, and /api/gacha returns a 6-second
portrait video (360x640 for width 640, 480x854 for width 854) instead of the
7-second landscape one.
This commit is contained in:
tiennm99 committed 2026-10-01 22:12:25 +07:00
1 parent d657c49c83
commit 7ca1468a43
18 files changed
+451 -1649

No files matched your search

+23 -37
View File
@@ -1,7 +1,7 @@
# wheelofnames
Self-hosted API that renders wheel-of-names GIF animations and gacha wish
MP4 animations with Remotion.
Self-hosted API that renders wheel-of-names GIF animations with Remotion and
gacha wish MP4 animations with pack-cards.
## API
@@ -43,57 +43,43 @@ Authorization: Bearer change-me
```json
{
"label": "Pizza",
"label": "Bún bò",
"rarity": 5,
"fps": 24,
"width": 640
}
```
Renders a 7-second wish animation in the style of a gacha game: a meteor
coloured by rarity (blue 3★, purple 4★, gold 5★) flies in from the left across
a night sky and bursts in a white flash where the rank emblem appears, and the label is revealed beside a rank emblem (`B`,
`A`, `S`) with its stars popping in. Each tier is louder than the one below:
4★ adds a bigger meteor, a lens flare, impact shake, and a double shockwave;
5★ adds a rainbow sunburst before landing, a gold sky flood, a starburst, counter
rotating rays, falling sparkles, and a sheen across the emblem. The per-tier
table lives in `src/remotion/gacha-timeline.js`.
`rarity` is required; `fps` is `24` or `30`; `width` is `640` (360 tall) or
`854` (480 tall). Optional `seed` (integer, `0` to `2147483647`) lays out the
twinkling stars and particles; when omitted the service picks a random one, so
every roll draws a different sky. The caller chooses the result and its rarity — the service
only draws it.
Renders a 6-second portrait wish: a collectible card pack from
[pack-cards](https://github.com/paubineau/pack-cards) is torn open, its card
spins out with a burst of sparkles, and lands showing the request's `label`,
its rank, and its stars under a polychrome rainbow foil. `rarity` (required)
picks the rank (`B` for 3★, `A` for 4★, `S` for 5★), the star count, the
card's material (rare, epic, legendary), and the colour (blue, purple, gold).
`fps` is `24` or `30`. `width` is the long edge of the portrait frame:
`640` renders 360×640 and `854` renders 480×854. Optional `seed` (integer,
`0` to `2147483647`) seeds the page's randomness; when omitted the service
picks a random one, so every roll looks different. The caller chooses the
result and its rarity — the service only draws it.
### Gacha wish beta
Response is a silent H.264 `video/mp4` (Telegram plays it as an animation)
with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. Both routes share the
`MAX_CONCURRENT_RENDERS` slots. No game assets are used.
`POST /api/gachabeta` takes the same body and returns the same response as
`/api/gacha`, rendering a 6-second portrait beta style instead: a collectible card pack
from [pack-cards](https://github.com/paubineau/pack-cards) is torn open, and
its card shows the request's `label`, its rank, and its stars. As on
`/api/gacha`, `rarity` picks the rank (`B` for 3★, `A` for 4★, `S` for 5★),
the star count, the card's material (rare, epic, legendary), and the pack's
colour (blue, purple, gold). The video is portrait, with `width` as its long
edge: `640` renders 360×640 and `854` renders 480×854.
pack-cards animates on the browser clock, so this route does not use Remotion
compositions. `src/render/render-gacha-beta.js` keeps one headless Chrome per
pack-cards animates on the browser clock, so the wish does not use Remotion
compositions. `src/render/render-gacha.js` keeps one headless Chrome per
server process in `--deterministic-mode`, steps virtual time one frame at a
time, tears the pack with a scripted drag, captures each frame, and encodes
them with Remotion's bundled ffmpeg. The page and the package are served from
disk; the page has no network access. The first render compiles the pack's
WebGL shaders in software, which takes several seconds, so server start-up
runs one throwaway wish first.
them with Remotion's bundled ffmpeg. The page (`src/gacha/page/`) and the
package are served from disk; the page has no network access. The first render
compiles the pack's WebGL shaders in software, which takes several seconds, so
server start-up runs one throwaway wish first.
pack-cards has no npm release, so it is installed from a GitHub tarball pinned
to a commit. A moving branch URL would change the tarball's checksum and break
`npm ci` against the lockfile, and the Docker image has no `git` for a git
dependency.
Response is a silent H.264 `video/mp4` (Telegram plays it as an animation)
with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. Both routes share the
`MAX_CONCURRENT_RENDERS` slots. All visuals are drawn procedurally; no game
assets are used.
## Local
Install dependencies and Chromium once:
+1 -16
View File
@@ -52,7 +52,7 @@ try {
headers: {
authorization: 'Bearer smoke-token',
},
payload: {label: 'Pizza', rarity: 5},
payload: {label: 'Bún bò', rarity: 5},
});
if (gacha.statusCode !== 200) {
@@ -65,21 +65,6 @@ try {
}
console.log(`api-smoke gacha: ${gacha.rawPayload.byteLength} bytes`);
const beta = await app.inject({
method: 'POST',
url: '/api/gachabeta',
headers: {
authorization: 'Bearer smoke-token',
},
payload: {label: 'Bún bò', rarity: 5},
});
if (beta.statusCode !== 200 || beta.rawPayload.subarray(4, 8).toString() !== 'ftyp') {
throw new Error(`Expected gachabeta MP4, got ${beta.statusCode}: ${beta.body.slice(0, 200)}`);
}
console.log(`api-smoke gachabeta: ${beta.rawPayload.byteLength} bytes`);
} finally {
await app.close();
}
+7 -17
View File
@@ -1,7 +1,6 @@
import {mkdir, writeFile} from 'node:fs/promises';
import path from 'node:path';
import {closeGachaBetaBrowser, renderGachaBetaVideo} from '../src/render/render-gacha-beta.js';
import {renderGachaVideo} from '../src/render/render-gacha.js';
import {closeGachaBrowser, renderGachaVideo} from '../src/render/render-gacha.js';
import {renderWheelGif} from '../src/render/render-gif.js';
const smoke = process.argv.includes('--smoke');
@@ -51,8 +50,8 @@ await mkdir(fixturesDir, {recursive: true});
/** @type {{name: string, request: import('../src/schemas/gacha-request.js').GachaRenderRequest}[]} */
const gachaFixtures = [
{name: 'gacha-5-star', request: {label: 'Pizza', rarity: 5, fps: 24, width: 640, seed: 7}},
{name: 'gacha-4-star', request: {label: 'Phở bò tái nạm gầu', rarity: 4, fps: 24, width: 640, seed: 11}},
{name: 'gacha-5-star', request: {label: 'Bún bò', rarity: 5, fps: 24, width: 640, seed: 7}},
{name: 'gacha-4-star', request: {label: 'Phở bò tái chín', rarity: 4, fps: 24, width: 640, seed: 11}},
{name: 'gacha-3-star', request: {label: 'Cơm tấm', rarity: 3, fps: 24, width: 854, seed: 23}},
];
@@ -70,19 +69,10 @@ for (const fixture of smoke ? fixtures.slice(0, 1) : fixtures) {
await save(`${fixture.name}.gif`, await renderWheelGif(fixture.request, {timeoutInMilliseconds: 30000}));
}
for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) {
await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 30000}));
}
const betaFixture = {
label: 'Bún bò',
rarity: /** @type {const} */ (5),
fps: /** @type {const} */ (24),
width: /** @type {const} */ (640),
seed: 7,
};
try {
await save('gachabeta-5-star.mp4', await renderGachaBetaVideo(betaFixture, {timeoutInMilliseconds: 60000}));
for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) {
await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 60000}));
}
} finally {
await closeGachaBetaBrowser();
await closeGachaBrowser();
}
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Gacha beta</title>
<title>Gacha</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/pack-cards/styles.css" />
<link rel="stylesheet" href="./page.css" />
File renamed without changes.
@@ -1,4 +1,4 @@
// Browser page for the beta wish: one pack-cards pack whose single card shows
// Browser page for the gacha wish: one pack-cards pack whose single card shows
// the rolled label, rank, and stars. The renderer injects `window.gachaWish`
// before this module runs, tears the pack open with a scripted drag, and
// captures each frame.
@@ -1,20 +1,18 @@
/**
* Plan for the beta wish video: what the pack-cards page shows for a request,
* Plan for the gacha wish video: what the pack-cards page shows for a request,
* when the scripted drag tears the pack open, and which files the page may
* load. Kept free of Chrome so it can be unit tested.
*/
import path from 'node:path';
import {getRankLetter, getRarityPalette} from '../remotion/gacha-timeline.js';
import {gachaFrameSizes} from '../schemas/gacha-request.js';
/** @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest */
/** @typedef {import('../remotion/gacha-timeline.js').GachaRarity} GachaRarity */
/** @typedef {GachaRenderRequest['rarity']} GachaRarity */
/** Origin the page is served from; every request to it is answered from disk. */
export const wishOrigin = 'https://gacha.wish';
export const gachaBetaTotalSeconds = 6;
export const gachaTotalSeconds = 6;
/**
* Seconds into the clip. The pack waits briefly, then a drag across its seal
@@ -26,26 +24,49 @@ export const wishTimeline = Object.freeze({
dragEnd: 1,
});
/** Short edge of the portrait frame for each requested long edge: 9:16, rounded to even pixels for H.264. */
const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
/**
* The beta wish is portrait: the request's `width` (640 or 854) is the long
* edge, so the frame is the /api/gacha frame turned upright.
* The wish is portrait: the request's `width` (640 or 854) is the long edge.
*
* @param {GachaRenderRequest['width']} width
* @returns {{width: number, height: number}}
*/
export const getWishFrameSize = (width) => ({width: gachaFrameSizes[width], height: width});
/** pack-cards rarity profile and glow preset for each requested rarity. */
/**
* Per requested rarity: the rank letter, the pack-cards rarity profile and
* glow preset, and the card colours (blue, purple, gold).
*/
const rarityLooks = Object.freeze({
3: {rarity: 'rare', glow: /** @type {const} */ ('silver')},
4: {rarity: 'epic', glow: /** @type {const} */ ('platinum')},
5: {rarity: 'legendary', glow: /** @type {const} */ ('gold')},
3: {
rank: /** @type {const} */ ('B'),
rarity: 'rare',
glow: /** @type {const} */ ('silver'),
accent: '#6fb6ff',
tint: '#163a8a',
},
4: {
rank: /** @type {const} */ ('A'),
rarity: 'epic',
glow: /** @type {const} */ ('platinum'),
accent: '#c88cff',
tint: '#4a1d8f',
},
5: {
rank: /** @type {const} */ ('S'),
rarity: 'legendary',
glow: /** @type {const} */ ('gold'),
accent: '#ffd36b',
tint: '#7a4a10',
},
});
/**
* @typedef {object} WishPageProps
* @property {string} label
* @property {'B' | 'A' | 'S'} rank Same rank letter as /api/gacha.
* @property {'B' | 'A' | 'S'} rank
* @property {GachaRarity} stars
* @property {string} rarity
* @property {'silver' | 'platinum' | 'gold'} glow
@@ -57,14 +78,8 @@ const rarityLooks = Object.freeze({
* @returns {WishPageProps}
*/
export const getWishPageProps = (request) => {
const palette = getRarityPalette(request.rarity);
return {
label: request.label,
rank: getRankLetter(request.rarity),
stars: request.rarity,
...rarityLooks[request.rarity],
artwork: {accent: palette.glow, tint: palette.deep},
};
const {rank, rarity, glow, accent, tint} = rarityLooks[request.rarity];
return {label: request.label, rank, stars: request.rarity, rarity, glow, artwork: {accent, tint}};
};
/**
-698
View File
@@ -1,698 +0,0 @@
import {useMemo} from 'react';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {
createRandom,
createStarfield,
gachaTimeline,
getLabelFontSize,
emblemCenter,
getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
getStarRevealTimes,
getTwinkle,
getTierEffects,
starColor,
} from './gacha-timeline.js';
const baseFont =
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const trailSpacing = 0.011;
const starburstSpikes = 10;
/**
* Every decorative particle for one roll. The request seed picks the layout,
* so each roll scatters its stars differently while every frame of one
* render stays consistent.
*
* @param {number} seed
*/
const createParticles = (seed) => ({
skyStars: createStarfield(70, seed),
meteorSparks: createMeteorSparks(seed + 1),
revealMotes: createRevealMotes(seed + 2),
rainSparkles: createRainSparkles(seed + 3),
});
/**
* Sparks shed by the meteor. Each is born at a point along the fall and
* drifts away from it while fading. Tiers draw a prefix of this pool.
*
* @param {number} seed
*/
function createMeteorSparks(seed) {
const random = createRandom(seed);
return Array.from({length: 80}, () => ({
born: random(),
driftX: (random() - 0.5) * 0.1,
driftY: (random() - 0.2) * 0.1,
size: 0.006 + random() * 0.012,
}));
}
/**
* Motes that float upward behind the revealed item.
*
* @param {number} seed
*/
function createRevealMotes(seed) {
const random = createRandom(seed);
return Array.from({length: 56}, () => ({
x: random(),
y: random(),
speed: 0.03 + random() * 0.07,
size: 0.004 + random() * 0.009,
phase: random() * Math.PI * 2,
}));
}
/**
* Sparkles that rain down across the 5★ reveal.
*
* @param {number} seed
*/
function createRainSparkles(seed) {
const random = createRandom(seed);
return Array.from({length: 40}, () => ({
x: random(),
offset: random(),
speed: 0.25 + random() * 0.35,
size: 0.025 + random() * 0.03,
spin: (random() - 0.5) * 360,
}));
}
const rainbowConic = 'conic-gradient(from 0deg, #ff6b6b, #ffd36b, #8cff9a, #6bd6ff, #b48cff, #ff8cd8, #ff6b6b)';
/** Fades the 5★ rainbow sunburst from a bright core out to its ray tips. */
const sunburstFade = 'radial-gradient(circle, black 0%, black 18%, rgba(0, 0, 0, 0.6) 40%, transparent 70%)';
/**
* Two ray layers turning in opposite directions; each ray takes the rainbow
* hue of the angle it points at.
*/
const sunburstLayers = [
{
rays: 16,
spin: 40,
opacity: 1,
mask: 'repeating-conic-gradient(from 0deg, black 0deg 6deg, transparent 6deg 22.5deg)',
},
{
rays: 24,
spin: -60,
opacity: 0.7,
mask: 'repeating-conic-gradient(from 4deg, black 0deg 2deg, transparent 2deg 15deg)',
},
];
/**
* @param {string} hex
* @param {number} alpha
*/
const withAlpha = (hex, alpha) => {
const value = Number.parseInt(hex.slice(1), 16);
return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
};
/**
* @param {{size: number}} props
*/
const StarIcon = ({size}) => (
<svg height={size} viewBox="0 0 24 24" width={size}>
<path
d="M12 1.6l3.1 6.6 7.2.9-5.3 5 1.4 7.1L12 17.7l-6.4 3.5 1.4-7.1-5.3-5 7.2-.9z"
fill={starColor}
stroke="#fff6cf"
strokeLinejoin="round"
strokeWidth={0.8}
/>
</svg>
);
/**
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
*/
/**
* Wish animation: a meteor coloured by rarity falls across a night sky, lands
* in a white flash, and the result is revealed with its rank letter and stars.
* Every beat scales with the tier's effects so higher rarities are louder.
*
* @param {GachaRenderRequest} props
*/
export const GachaComposition = (props) => {
const frame = useCurrentFrame();
const {fps, width, height} = useVideoConfig();
const seconds = frame / fps;
const palette = getRarityPalette(props.rarity);
const fx = getTierEffects(props.rarity);
const tl = gachaTimeline;
const {skyStars, meteorSparks, revealMotes, rainSparkles} = useMemo(() => createParticles(props.seed), [props.seed]);
const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], {
...clamp,
easing: Easing.in(Easing.quad),
});
const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd;
const head = getMeteorPoint(meteorProgress, width, height);
const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp) * fx.headScale;
const haloOpacity = fx.halo ? interpolate(meteorProgress, [0.45, 0.8], [0, 1], clamp) : 0;
const skyFlood = interpolate(seconds, [tl.meteorStart + 1.2, tl.meteorEnd], [0, fx.skyFlood], clamp);
const flashOpacity = interpolate(seconds, [tl.flashStart, tl.flashPeak, tl.flashHoldEnd, tl.flashEnd], [0, 1, 1, 0], {
...clamp,
easing: Easing.inOut(Easing.quad),
});
const impact = getMeteorPoint(1, width, height);
const bloomRadius = interpolate(seconds, [tl.flashStart, tl.flashPeak], [0.1, 2.2], clamp) * width;
const revealed = seconds >= tl.revealStart;
const shake = getShakeOffset(seconds, tl.revealStart, fx.shake * height);
const emblemSize = height * 0.52;
const emblemX = width * emblemCenter.x;
const emblemY = height * emblemCenter.y;
const emblemScale = interpolate(
seconds,
[tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled],
[0.72, 1 + 0.04 * fx.headScale, 1],
{...clamp, easing: Easing.out(Easing.cubic)},
);
const silhouette = interpolate(seconds, [tl.revealStart + 0.2, tl.emblemSettled], [1, 0], clamp);
const raysOpacity = interpolate(seconds, [tl.revealStart, tl.emblemSettled], [0, fx.raysOpacity], clamp);
const starburstScale = interpolate(seconds, [tl.revealStart, tl.emblemSettled + 0.3], [0.2, 1], {
...clamp,
easing: Easing.out(Easing.cubic),
});
const emblemPulse = 0.5 + 0.5 * Math.sin(seconds * 3);
const sheenProgress = ((((seconds - tl.emblemSettled) / 1.8) % 1) + 1) % 1;
const rankLetter = getRankLetter(props.rarity);
const rankLetterStyle =
props.rarity === 5
? {
background: 'linear-gradient(180deg, #ffffff 0%, #fff6d0 55%, #ffd36b 100%)',
color: 'transparent',
filter: `drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.03}px ${palette.glow})`,
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
}
: {
color: '#ffffff',
textShadow: `0 2px ${height * 0.01}px rgba(0, 0, 0, 0.45), 0 0 ${height * 0.03}px ${palette.glow}`,
};
const nameProgress = interpolate(seconds, [tl.nameIn, tl.nameSettled], [0, 1], {
...clamp,
easing: Easing.out(Easing.cubic),
});
const starSize = height * 0.09;
const starTimes = getStarRevealTimes(props.rarity);
const rayMask = 'radial-gradient(circle, black 8%, transparent 55%)';
return (
<AbsoluteFill style={{background: '#05060f', fontFamily: baseFont, overflow: 'hidden'}}>
{!revealed ? (
<AbsoluteFill
style={{
background: 'linear-gradient(180deg, #070a24 0%, #121a4a 45%, #2a3a7c 80%, #3d4f96 100%)',
}}
>
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 80% 40% at 50% 100%, ${withAlpha('#9bb4ff', 0.35)} 0%, transparent 70%)`,
}}
/>
{skyStars.map((star, index) => {
const twinkle = getTwinkle(seconds, star);
const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle);
const glint = star.glint ? size * 6 * twinkle : 0;
return (
<div
key={`sky-${index}`}
style={{
height: 0,
left: star.x * width,
opacity: 0.2 + 0.8 * twinkle,
position: 'absolute',
top: star.y * height,
width: 0,
}}
>
<div
style={{
background: '#ffffff',
borderRadius: '50%',
boxShadow: twinkle > 0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined,
height: size,
left: -size / 2,
position: 'absolute',
top: -size / 2,
width: size,
}}
/>
{glint > 1 ? (
<>
<div
style={{
background: 'linear-gradient(90deg, transparent, #ffffff, transparent)',
height: 1,
left: -glint / 2,
position: 'absolute',
top: -0.5,
width: glint,
}}
/>
<div
style={{
background: 'linear-gradient(180deg, transparent, #ffffff, transparent)',
height: glint,
left: -0.5,
position: 'absolute',
top: -glint / 2,
width: 1,
}}
/>
</>
) : null}
</div>
);
})}
{skyFlood > 0 ? (
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${(impact.x / width) * 100}% ${(impact.y / height) * 100}%, ${withAlpha(palette.glow, 0.95)} 0%, ${withAlpha(palette.glow, 0.4)} 40%, transparent 80%)`,
mixBlendMode: 'screen',
opacity: skyFlood,
}}
/>
) : null}
{meteorVisible
? Array.from({length: fx.trailSamples}, (_, index) => {
const sampleProgress = meteorProgress - index * trailSpacing;
if (sampleProgress <= 0) {
return null;
}
const point = getMeteorPoint(sampleProgress, width, height);
const fade = 1 - index / fx.trailSamples;
const radius = headRadius * (0.25 + 0.75 * fade ** 1.3);
return (
<div
key={`trail-${index}`}
style={{
background: `radial-gradient(circle, ${withAlpha(palette.glow, 0.85 * fade ** 1.4)} 0%, transparent 70%)`,
height: radius * 2,
left: point.x - radius,
position: 'absolute',
top: point.y - radius,
width: radius * 2,
}}
/>
);
})
: null}
{meteorVisible
? meteorSparks.slice(0, fx.sparks).map((spark, index) => {
if (spark.born > meteorProgress) {
return null;
}
const age = (meteorProgress - spark.born) * 4;
if (age >= 1) {
return null;
}
const origin = getMeteorPoint(spark.born, width, height);
const size = spark.size * height * (1 - age) * fx.headScale;
return (
<div
key={`spark-${index}`}
style={{
background: palette.core,
borderRadius: '50%',
boxShadow: `0 0 ${size * 2}px ${palette.glow}`,
height: size,
left: origin.x + spark.driftX * width * age,
opacity: 1 - age,
position: 'absolute',
top: origin.y + spark.driftY * height * age,
width: size,
}}
/>
);
})
: null}
{meteorVisible && haloOpacity > 0 ? (
<div
style={{
height: headRadius * 9,
left: head.x - headRadius * 4.5,
maskImage: sunburstFade,
opacity: haloOpacity,
position: 'absolute',
top: head.y - headRadius * 4.5,
transform: `scale(${0.5 + 0.5 * haloOpacity})`,
WebkitMaskImage: sunburstFade,
width: headRadius * 9,
}}
>
{sunburstLayers.map((layer) => (
<div
key={layer.rays}
style={{
background: rainbowConic,
borderRadius: '50%',
inset: 0,
maskImage: layer.mask,
opacity: layer.opacity,
position: 'absolute',
transform: `rotate(${seconds * layer.spin}deg)`,
WebkitMaskImage: layer.mask,
}}
/>
))}
</div>
) : null}
{meteorVisible ? (
<>
<div
style={{
background: `radial-gradient(circle, ${palette.core} 0%, ${palette.core} 18%, ${withAlpha(palette.glow, 0.9)} 34%, ${withAlpha(palette.glow, 0.35)} 58%, transparent 72%)`,
height: headRadius * 4,
left: head.x - headRadius * 2,
position: 'absolute',
top: head.y - headRadius * 2,
width: headRadius * 4,
}}
/>
{props.rarity >= 4 ? (
<>
<div
style={{
background: `linear-gradient(90deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
height: Math.max(2, headRadius * 0.12),
left: head.x - headRadius * (props.rarity === 5 ? 6 : 3.5),
position: 'absolute',
top: head.y - Math.max(1, headRadius * 0.06),
width: headRadius * (props.rarity === 5 ? 12 : 7),
}}
/>
<div
style={{
background: `linear-gradient(180deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
height: headRadius * (props.rarity === 5 ? 8 : 4.5),
left: head.x - Math.max(1, headRadius * 0.06),
position: 'absolute',
top: head.y - headRadius * (props.rarity === 5 ? 4 : 2.25),
width: Math.max(2, headRadius * 0.12),
}}
/>
</>
) : null}
</>
) : null}
</AbsoluteFill>
) : null}
{revealed ? (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 70% 90% at 66% 47%, ${palette.deep} 0%, #0d0b1f 62%, #05060f 100%)`,
transform: `translate(${shake.x}px, ${shake.y}px) scale(1.04)`,
}}
>
<div
style={{
background: `repeating-conic-gradient(from 0deg, ${withAlpha(palette.glow, 0.9)} 0deg 4deg, transparent 4deg 18deg)`,
borderRadius: '50%',
height: width * 1.6,
left: emblemX - width * 0.8,
maskImage: rayMask,
opacity: raysOpacity,
position: 'absolute',
top: emblemY - width * 0.8,
transform: `rotate(${seconds * 9}deg)`,
WebkitMaskImage: rayMask,
width: width * 1.6,
}}
/>
{fx.counterRays ? (
<div
style={{
background: `repeating-conic-gradient(from 9deg, ${withAlpha(palette.core, 0.8)} 0deg 2deg, transparent 2deg 24deg)`,
borderRadius: '50%',
height: width * 1.6,
left: emblemX - width * 0.8,
maskImage: rayMask,
opacity: raysOpacity * 0.8,
position: 'absolute',
top: emblemY - width * 0.8,
transform: `rotate(${-seconds * 14}deg)`,
WebkitMaskImage: rayMask,
width: width * 1.6,
}}
/>
) : null}
{fx.starburst
? Array.from({length: starburstSpikes}, (_, index) => {
const long = index % 2 === 0;
const length = height * (long ? 1.1 : 0.7) * starburstScale;
return (
<div
key={`spike-${index}`}
style={{
background: `linear-gradient(90deg, ${withAlpha(palette.core, 0.95)}, ${withAlpha(palette.glow, 0.5)} 40%, transparent)`,
height: Math.max(2, height * (long ? 0.012 : 0.007)),
left: emblemX,
position: 'absolute',
top: emblemY,
transform: `rotate(${(360 / starburstSpikes) * index + seconds * 6}deg)`,
transformOrigin: '0 50%',
width: length,
}}
/>
);
})
: null}
{revealMotes.slice(0, fx.motes).map((mote, index) => {
const y = (((mote.y - (seconds - tl.revealStart) * mote.speed) % 1) + 1) % 1;
const size = mote.size * height;
return (
<div
key={`mote-${index}`}
style={{
background: palette.core,
borderRadius: '50%',
boxShadow: `0 0 ${size * 3}px ${palette.glow}`,
height: size,
left: mote.x * width,
opacity: 0.25 + 0.5 * (0.5 + 0.5 * Math.sin(seconds * 3 + mote.phase)),
position: 'absolute',
top: y * height,
width: size,
}}
/>
);
})}
{rainSparkles.slice(0, fx.sparkleRain).map((sparkle, index) => {
const y = ((sparkle.offset + (seconds - tl.revealStart) * sparkle.speed) % 1.2) - 0.1;
const size = sparkle.size * height;
return (
<div
key={`rain-${index}`}
style={{
background: `linear-gradient(135deg, #fffbe6, ${palette.glow})`,
boxShadow: `0 0 ${size}px ${palette.glow}`,
height: size,
left: sparkle.x * width,
opacity: 0.85,
position: 'absolute',
top: y * height,
clipPath: 'polygon(50% 0%, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0% 50%, 39% 39%)',
transform: `rotate(${seconds * sparkle.spin}deg)`,
width: size,
}}
/>
);
})}
{Array.from({length: fx.shockwaves}, (_, index) => {
const start = tl.revealStart + index * 0.16;
const progress = interpolate(seconds, [start, start + 0.9], [0, 1], {
...clamp,
easing: Easing.out(Easing.quad),
});
if (progress <= 0 || progress >= 1) {
return null;
}
return (
<div
key={`wave-${index}`}
style={{
border: `${Math.max(2, height * (0.01 - index * 0.002))}px solid ${index === 0 ? palette.glow : palette.core}`,
borderRadius: '50%',
boxShadow: `0 0 ${height * 0.04}px ${palette.glow}, inset 0 0 ${height * 0.04}px ${palette.glow}`,
height: emblemSize,
left: emblemX - emblemSize / 2,
opacity: 1 - progress,
position: 'absolute',
top: emblemY - emblemSize / 2,
transform: `scale(${0.4 + progress * (2.4 + index * 0.8)})`,
width: emblemSize,
}}
/>
);
})}
<div
style={{
height: emblemSize,
left: emblemX - emblemSize / 2,
position: 'absolute',
top: emblemY - emblemSize / 2,
transform: `scale(${emblemScale})`,
width: emblemSize,
}}
>
<div
style={{
background: `linear-gradient(135deg, ${palette.emblem})`,
border: `${Math.max(2, height * (props.rarity === 5 ? 0.014 : 0.008))}px solid ${props.rarity === 5 ? '#fff0b8' : 'rgba(255, 255, 255, 0.85)'}`,
borderRadius: emblemSize * 0.12,
boxShadow: `0 0 ${height * (0.04 + 0.03 * fx.headScale * emblemPulse)}px ${palette.glow}, inset 0 0 ${height * 0.04}px rgba(255, 255, 255, 0.55)`,
height: emblemSize * 0.72,
left: emblemSize * 0.14,
overflow: 'hidden',
position: 'absolute',
top: emblemSize * 0.14,
transform: 'rotate(45deg)',
width: emblemSize * 0.72,
}}
>
<div
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 45%)',
inset: 0,
position: 'absolute',
}}
/>
{fx.sheen && seconds >= tl.emblemSettled ? (
<div
style={{
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.75), transparent)',
height: '100%',
left: `${-60 + sheenProgress * 220}%`,
position: 'absolute',
top: 0,
width: '40%',
}}
/>
) : null}
<div style={{background: '#ffffff', inset: 0, opacity: silhouette, position: 'absolute'}} />
</div>
<div
style={{
alignItems: 'center',
display: 'flex',
fontSize: emblemSize * 0.42,
fontStyle: 'italic',
fontWeight: 900,
inset: 0,
justifyContent: 'center',
opacity: 1 - silhouette,
position: 'absolute',
}}
>
<span style={{paddingRight: emblemSize * 0.03, ...rankLetterStyle}}>{rankLetter}</span>
</div>
</div>
<div
style={{
left: width * 0.07,
opacity: nameProgress,
position: 'absolute',
top: height * 0.56,
transform: `translateX(${(1 - nameProgress) * -width * 0.06}px)`,
width: width * 0.44,
}}
>
<div
style={{
color: '#ffffff',
display: '-webkit-box',
fontSize: getLabelFontSize(props.label, height),
fontWeight: 800,
lineHeight: 1.12,
overflow: 'hidden',
overflowWrap: 'anywhere',
textShadow: `0 2px ${height * 0.012}px rgba(0, 0, 0, 0.7), 0 0 ${height * 0.035}px ${withAlpha(palette.glow, 0.8)}`,
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
}}
>
{props.label}
</div>
<div style={{display: 'flex', gap: starSize * 0.12, marginTop: height * 0.025}}>
{starTimes.map((time, index) => {
const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.6, 1], clamp);
const flare = interpolate(seconds, [time, time + 0.3], [1, 0], clamp);
const flareSize = starSize * 2.4 * fx.starFlare;
return (
<div
key={`star-${index}`}
style={{
height: starSize,
opacity: seconds >= time ? 1 : 0,
position: 'relative',
transform: `scale(${pop})`,
width: starSize,
}}
>
<div
style={{
background: `radial-gradient(circle, ${withAlpha('#fff4c2', 0.9 * flare)} 0%, transparent 65%)`,
height: flareSize,
left: (starSize - flareSize) / 2,
position: 'absolute',
top: (starSize - flareSize) / 2,
width: flareSize,
}}
/>
<StarIcon size={starSize} />
</div>
);
})}
</div>
</div>
</AbsoluteFill>
) : null}
{flashOpacity > 0 ? (
<AbsoluteFill style={{pointerEvents: 'none'}}>
<div
style={{
background: `radial-gradient(circle, #ffffff 0%, #ffffff 35%, ${withAlpha(palette.core, 0)} 70%)`,
height: bloomRadius * 2,
left: impact.x - bloomRadius,
opacity: Math.min(1, flashOpacity * 1.6),
position: 'absolute',
top: impact.y - bloomRadius,
width: bloomRadius * 2,
}}
/>
<AbsoluteFill style={{background: props.rarity === 5 ? '#fff6dc' : '#ffffff', opacity: flashOpacity}} />
</AbsoluteFill>
) : null}
</AbsoluteFill>
);
};
+10 -48
View File
@@ -1,8 +1,5 @@
import {Composition} from 'remotion';
import {GachaComposition} from './GachaComposition.jsx';
import {gachaTotalSeconds} from './gacha-timeline.js';
import {WheelComposition} from './WheelComposition.jsx';
import {gachaFrameSizes} from '../schemas/gacha-request.js';
/** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */
const defaultProps = {
@@ -32,50 +29,15 @@ export const getCompositionMetadata = (props) => {
};
};
/** @type {import('../schemas/gacha-request.js').GachaRenderRequest} */
const defaultGachaProps = {
label: 'Pizza',
rarity: 5,
fps: 24,
width: 640,
seed: 7,
};
/**
* @param {{fps: number, width: number}} props
*/
export const getGachaCompositionMetadata = (props) => {
const fps = props.fps || defaultGachaProps.fps;
const width = props.width in gachaFrameSizes ? props.width : defaultGachaProps.width;
return {
durationInFrames: Math.ceil(gachaTotalSeconds * fps),
fps,
height: gachaFrameSizes[/** @type {640 | 854} */ (width)],
width,
};
};
export const RemotionRoot = () => (
<>
<Composition
calculateMetadata={({props}) => getCompositionMetadata(props)}
component={WheelComposition}
defaultProps={defaultProps}
durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
fps={defaultProps.fps}
height={defaultProps.size}
id="WheelGif"
width={defaultProps.size}
/>
<Composition
calculateMetadata={({props}) => getGachaCompositionMetadata(props)}
component={GachaComposition}
defaultProps={defaultGachaProps}
durationInFrames={Math.ceil(gachaTotalSeconds * defaultGachaProps.fps)}
fps={defaultGachaProps.fps}
height={gachaFrameSizes[defaultGachaProps.width]}
id="GachaWish"
width={defaultGachaProps.width}
/>
</>
<Composition
calculateMetadata={({props}) => getCompositionMetadata(props)}
component={WheelComposition}
defaultProps={defaultProps}
durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
fps={defaultProps.fps}
height={defaultProps.size}
id="WheelGif"
width={defaultProps.size}
/>
);
-271
View File
@@ -1,271 +0,0 @@
/**
* Timing, palette, and geometry for the gacha wish animation. Kept free of
* React so the choreography can be unit tested.
*/
/** @typedef {3 | 4 | 5} GachaRarity */
/**
* @typedef {object} RarityPalette
* @property {string} core Meteor head and hottest highlights.
* @property {string} glow Trail, ring burst, and emblem glow.
* @property {string} deep Background tint of the reveal scene.
* @property {string} emblem Emblem fill gradient, light to dark.
*/
export const gachaTotalSeconds = 7;
/**
* Seconds from the start of the clip. The meteor lands at `meteorEnd`, the
* white flash peaks and holds, then fades to reveal the result.
*/
export const gachaTimeline = Object.freeze({
meteorStart: 0.3,
meteorEnd: 2.5,
flashStart: 2.3,
flashPeak: 2.7,
flashHoldEnd: 2.95,
flashEnd: 3.5,
revealStart: 2.95,
emblemSettled: 3.7,
nameIn: 3.6,
nameSettled: 4.1,
starsStart: 4.1,
starStep: 0.18,
});
/** Centre of the rank emblem on the reveal, as fractions of the frame. */
export const emblemCenter = Object.freeze({x: 0.66, y: 0.47});
/** Every rarity reveals its stars in the same gold, as in the source game. */
export const starColor = '#ffcc33';
/** @type {Record<GachaRarity, RarityPalette>} */
const palettes = {
3: {core: '#f2f8ff', glow: '#6fb6ff', deep: '#163a8a', emblem: '#9fd0ff, #3a6fd8'},
4: {core: '#fbf2ff', glow: '#c88cff', deep: '#4a1d8f', emblem: '#e2c2ff, #8a4ae6'},
5: {core: '#fffbe8', glow: '#ffd36b', deep: '#7a4a10', emblem: '#fff0b8, #e2a12e'},
};
/**
* @param {GachaRarity} rarity
* @returns {RarityPalette}
*/
export const getRarityPalette = (rarity) => palettes[rarity];
/**
* Point on the meteor's curved flight from the upper left toward the rank
* emblem on the right, as a quadratic Bézier. It lands exactly on the emblem,
* so the impact burst becomes the badge reveal.
*
* @param {number} progress 0 at launch, 1 at landing; values outside clamp.
* @param {number} width
* @param {number} height
* @returns {{x: number, y: number}}
*/
export const getMeteorPoint = (progress, width, height) => {
const t = Math.min(1, Math.max(0, progress));
const start = {x: -width * 0.08, y: -height * 0.18};
const control = {x: width * 0.22, y: height * 0.38};
const end = {x: width * emblemCenter.x, y: height * emblemCenter.y};
const u = 1 - t;
return {
x: u * u * start.x + 2 * u * t * control.x + t * t * end.x,
y: u * u * start.y + 2 * u * t * control.y + t * t * end.y,
};
};
/**
* Seconds at which each reveal star pops in, one per rarity level.
*
* @param {GachaRarity} rarity
* @returns {number[]}
*/
export const getStarRevealTimes = (rarity) =>
Array.from({length: rarity}, (_, index) => gachaTimeline.starsStart + index * gachaTimeline.starStep);
/**
* Deterministic pseudo-random generator (mulberry32) so every render of the
* same request draws the same sky.
*
* @param {number} seed
* @returns {() => number} values in [0, 1)
*/
export const createRandom = (seed) => {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
};
/**
* @typedef {object} SkyStar
* @property {number} x Fraction of width.
* @property {number} y Fraction of height.
* @property {number} radius Fraction of height.
* @property {number} phase Twinkle phase offset in radians.
* @property {number} speed Twinkle speed in radians per second.
* @property {boolean} glint Draws a cross-shaped glint when it flares.
*/
/**
* @param {number} count
* @param {number} seed
* @returns {SkyStar[]}
*/
export const createStarfield = (count, seed) => {
const random = createRandom(seed);
return Array.from({length: count}, () => ({
x: random(),
y: random() * 0.8,
radius: 0.002 + random() * 0.004,
phase: random() * Math.PI * 2,
speed: 2 + random() * 5,
glint: random() < 0.18,
}));
};
/**
* Twinkle brightness in [0, 1]. Cubing the sine keeps a star dim most of the
* time with short bright flares, which reads as twinkling rather than a slow
* pulse.
*
* @param {number} seconds
* @param {SkyStar} star
* @returns {number}
*/
export const getTwinkle = (seconds, star) => (0.5 + 0.5 * Math.sin(seconds * star.speed + star.phase)) ** 3;
/**
* Font size for the revealed name, shrinking long labels so they fit within
* two lines on the left half of the frame.
*
* @param {string} label
* @param {number} height
* @returns {number}
*/
export const getLabelFontSize = (label, height) => {
const length = Array.from(label).length;
if (length <= 8) {
return Math.round(height * 0.12);
}
if (length <= 16) {
return Math.round(height * 0.09);
}
if (length <= 28) {
return Math.round(height * 0.07);
}
return Math.round(height * 0.055);
};
/** Rank letter shown on the emblem for each rarity. */
const rankLetters = {3: 'B', 4: 'A', 5: 'S'};
/**
* @param {GachaRarity} rarity
* @returns {'B' | 'A' | 'S'}
*/
export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLetters[rarity]);
/**
* @typedef {object} TierEffects
* @property {number} headScale Meteor head size multiplier.
* @property {number} trailSamples Glow blobs drawn along the meteor trail.
* @property {number} sparks Sparks shed by the meteor.
* @property {boolean} halo Rainbow sunburst bursting from the meteor before landing.
* @property {number} skyFlood Peak opacity of the sky tint as the meteor lands.
* @property {number} shake Impact camera shake amplitude, as a fraction of height.
* @property {number} shockwaves Rings bursting from the emblem on reveal.
* @property {boolean} starburst Long light spikes behind the emblem.
* @property {number} raysOpacity Peak opacity of the rotating light rays.
* @property {boolean} counterRays Second ray layer rotating the other way.
* @property {number} motes Glowing motes floating up behind the reveal.
* @property {number} sparkleRain Sparkles falling across the reveal.
* @property {boolean} sheen Light sweep across the emblem and rank letter.
* @property {number} starFlare Size multiplier of each star's pop flare.
*/
/**
* How loud each tier is. Every beat exists at every tier; higher tiers get
* more of it, so 4★ clearly beats 3★ and 5★ clearly beats both.
*
* @type {Record<GachaRarity, TierEffects>}
*/
const tierEffects = {
3: {
headScale: 1,
trailSamples: 22,
sparks: 12,
halo: false,
skyFlood: 0,
shake: 0,
shockwaves: 1,
starburst: false,
raysOpacity: 0.16,
counterRays: false,
motes: 14,
sparkleRain: 0,
sheen: false,
starFlare: 1,
},
4: {
headScale: 1.3,
trailSamples: 34,
sparks: 36,
halo: false,
skyFlood: 0.25,
shake: 0.012,
shockwaves: 2,
starburst: false,
raysOpacity: 0.42,
counterRays: false,
motes: 32,
sparkleRain: 0,
sheen: false,
starFlare: 1.4,
},
5: {
headScale: 1.75,
trailSamples: 48,
sparks: 80,
halo: true,
skyFlood: 0.7,
shake: 0.03,
shockwaves: 3,
starburst: true,
raysOpacity: 0.7,
counterRays: true,
motes: 56,
sparkleRain: 40,
sheen: true,
starFlare: 2,
},
};
/**
* @param {GachaRarity} rarity
* @returns {TierEffects}
*/
export const getTierEffects = (rarity) => tierEffects[rarity];
/**
* Decaying impact shake offset in pixels, zero before `start` and after it
* settles.
*
* @param {number} seconds
* @param {number} start
* @param {number} amplitude Pixels at the moment of impact.
* @returns {{x: number, y: number}}
*/
export const getShakeOffset = (seconds, start, amplitude) => {
const elapsed = seconds - start;
if (amplitude <= 0 || elapsed < 0 || elapsed > 0.8) {
return {x: 0, y: 0};
}
const decay = amplitude * (1 - elapsed / 0.8) ** 2;
return {x: Math.sin(elapsed * 71) * decay, y: Math.cos(elapsed * 53) * decay};
};
-367
View File
@@ -1,367 +0,0 @@
import {spawn} from 'node:child_process';
import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {ensureBrowser, makeCancelSignal, RenderInternals} from '@remotion/renderer';
import {
gachaBetaTotalSeconds,
getDragPointer,
getWishFrameSize,
getWishPageProps,
resolveWishAsset,
wishOrigin,
} from '../gacha-beta/wish-plan.js';
import {RenderTimeoutError} from '../lib/render-errors.js';
import {cleanupTempDir, createRenderTempDir} from '../lib/tmp-files.js';
import {createCdpConnection} from './cdp-pipe.js';
/**
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
* @typedef {import('./render-composition.js').RenderResult} RenderResult
* @typedef {import('./cdp-pipe.js').CdpConnection} CdpConnection
* @typedef {{chrome: import('node:child_process').ChildProcess, cdp: CdpConnection}} WishBrowser
*/
const roots = {
page: fileURLToPath(new URL('../gacha-beta/page', import.meta.url)),
packCards: path.dirname(fileURLToPath(import.meta.resolve('pack-cards'))),
};
/** Virtual milliseconds the page gets to load and mount its WebGL pack before capture starts. */
const maxLoadMs = 5000;
/**
* Chrome flags for frame-exact capture: `--deterministic-mode` lets the
* caller issue every BeginFrame, so animations, timers, and WebGL advance
* only with virtual time. WebGL runs on SwiftShader because the server has no
* GPU.
*
* @param {string} userDataDir
*/
const chromeArgs = (userDataDir) => [
'--deterministic-mode',
'--remote-debugging-pipe',
'--no-sandbox',
'--no-first-run',
'--mute-audio',
'--hide-scrollbars',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
`--user-data-dir=${userDataDir}`,
'about:blank',
];
/**
* Answers every page request from disk and blocks anything else, so the page
* cannot reach the network.
*
* @param {CdpConnection} cdp
*/
const serveWishPages = (cdp) => {
cdp.on('Fetch.requestPaused', (params, sessionId) => {
const asset = resolveWishAsset(params.request.url, roots);
const refuse = () =>
cdp.send('Fetch.failRequest', {requestId: params.requestId, errorReason: 'BlockedByClient'}, sessionId);
const answer = asset
? readFile(asset.file).then(
(body) =>
cdp.send(
'Fetch.fulfillRequest',
{
requestId: params.requestId,
responseCode: 200,
responseHeaders: [{name: 'Content-Type', value: asset.contentType}],
body: body.toString('base64'),
},
sessionId,
),
refuse,
)
: refuse();
answer.catch(() => {});
});
};
/**
* One headless Chrome per server process. SwiftShader spends several seconds
* compiling the pack's shaders in the first tab; later tabs in the same
* browser reuse that work.
*
* @type {Promise<WishBrowser> | null}
*/
let sharedBrowser = null;
/** @returns {Promise<WishBrowser>} */
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<WishBrowser>} */
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<void>}
*/
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<void>}
*/
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 {<T>(promise: Promise<T>) => Promise<T>} 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<string, unknown>} [params]
*/
const send = (method, params) => guard(cdp.send(method, params, sessionId));
/** @param {string} expression */
const evaluate = async (expression) =>
(await send('Runtime.evaluate', {expression, returnByValue: true})).result?.value;
await send('Page.enable');
await send('Fetch.enable', {patterns: [{urlPattern: '*'}]});
await send('Emulation.setDeviceMetricsOverride', {
...getWishFrameSize(request.width),
deviceScaleFactor: 1,
mobile: false,
});
await send('Page.addScriptToEvaluateOnNewDocument', {
source: `${seededRandomScript(request.seed)}window.gachaWish = ${JSON.stringify(getWishPageProps(request))};`,
});
const {virtualTimeTicksBase} = await send('Emulation.setVirtualTimePolicy', {policy: 'pause'});
let elapsed = 0;
/**
* Advances virtual time by one frame, then draws that frame with a
* BeginFrame stamped on the same clock so requestAnimationFrame,
* performance.now(), and CSS animations agree.
*
* @param {boolean} capture
* @returns {Promise<string | undefined>} 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<unknown>[]} */
const inputs = [];
/** @param {Record<string, unknown>} 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<RenderResult>}
*/
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<never>} */
const abandoned = new Promise((_, reject) => {
abandon = reject;
});
abandoned.catch(() => {});
/** @type {<T>(promise: Promise<T>) => Promise<T>} */
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);
}
};
+355 -14
View File
@@ -1,23 +1,364 @@
import {renderComposition} from './render-composition.js';
import {spawn} from 'node:child_process';
import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {ensureBrowser, makeCancelSignal, RenderInternals} from '@remotion/renderer';
import {
gachaTotalSeconds,
getDragPointer,
getWishFrameSize,
getWishPageProps,
resolveWishAsset,
wishOrigin,
} from '../gacha/wish-plan.js';
import {RenderTimeoutError} from '../lib/render-errors.js';
import {cleanupTempDir, createRenderTempDir} from '../lib/tmp-files.js';
import {createCdpConnection} from './cdp-pipe.js';
/**
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
* @typedef {import('./render-composition.js').RenderResult} RenderGachaResult
* @typedef {import('./render-composition.js').RenderResult} RenderResult
* @typedef {import('./cdp-pipe.js').CdpConnection} CdpConnection
* @typedef {{chrome: import('node:child_process').ChildProcess, cdp: CdpConnection}} WishBrowser
*/
const roots = {
page: fileURLToPath(new URL('../gacha/page', import.meta.url)),
packCards: path.dirname(fileURLToPath(import.meta.resolve('pack-cards'))),
};
/** Virtual milliseconds the page gets to load and mount its WebGL pack before capture starts. */
const maxLoadMs = 5000;
/**
* Renders the wish animation as a silent H.264 MP4, which Telegram plays as a
* looping animation and which keeps the glow gradients free of GIF banding.
* Chrome flags for frame-exact capture: `--deterministic-mode` lets the
* caller issue every BeginFrame, so animations, timers, and WebGL advance
* only with virtual time. WebGL runs on SwiftShader because the server has no
* GPU.
*
* @param {GachaRenderRequest} inputProps
* @param {{timeoutInMilliseconds: number}} options
* @returns {Promise<RenderGachaResult>}
* @param {string} userDataDir
*/
export const renderGachaVideo = (inputProps, options) =>
renderComposition({
compositionId: 'GachaWish',
filename: 'gacha.mp4',
inputProps: {...inputProps},
media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true},
timeoutInMilliseconds: options.timeoutInMilliseconds,
const chromeArgs = (userDataDir) => [
'--deterministic-mode',
'--remote-debugging-pipe',
'--no-sandbox',
'--no-first-run',
'--mute-audio',
'--hide-scrollbars',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
`--user-data-dir=${userDataDir}`,
'about:blank',
];
/**
* Answers every page request from disk and blocks anything else, so the page
* cannot reach the network.
*
* @param {CdpConnection} cdp
*/
const serveWishPages = (cdp) => {
cdp.on('Fetch.requestPaused', (params, sessionId) => {
const asset = resolveWishAsset(params.request.url, roots);
const refuse = () =>
cdp.send('Fetch.failRequest', {requestId: params.requestId, errorReason: 'BlockedByClient'}, sessionId);
const answer = asset
? readFile(asset.file).then(
(body) =>
cdp.send(
'Fetch.fulfillRequest',
{
requestId: params.requestId,
responseCode: 200,
responseHeaders: [{name: 'Content-Type', value: asset.contentType}],
body: body.toString('base64'),
},
sessionId,
),
refuse,
)
: refuse();
answer.catch(() => {});
});
};
/**
* One headless Chrome per server process. SwiftShader spends several seconds
* compiling the pack's shaders in the first tab; later tabs in the same
* browser reuse that work.
*
* @type {Promise<WishBrowser> | null}
*/
let sharedBrowser = null;
/** @returns {Promise<WishBrowser>} */
const launchBrowser = async () => {
const installed = await ensureBrowser({logLevel: 'error'});
if (!('path' in installed) || !installed.path) {
throw new Error(`No Chrome available for the gacha wish (${installed.type})`);
}
const profileDir = await mkdtemp(path.join(os.tmpdir(), 'wheelofnames-chrome-'));
const chrome = spawn(installed.path, chromeArgs(profileDir), {stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']});
const cdp = createCdpConnection(
/** @type {NodeJS.WritableStream} */ (chrome.stdio[3]),
/** @type {NodeJS.ReadableStream} */ (chrome.stdio[4]),
);
const killOnExit = () => chrome.kill('SIGKILL');
process.once('exit', killOnExit);
chrome.once('exit', () => {
process.off('exit', killOnExit);
sharedBrowser = null;
cdp.fail(new Error('Chrome exited during the gacha wish render'));
rm(profileDir, {force: true, recursive: true}).catch(() => {});
});
serveWishPages(cdp);
await cdp.send('Browser.getVersion');
return {chrome, cdp};
};
/** @returns {Promise<WishBrowser>} */
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<void>}
*/
export const closeGachaBrowser = async () => {
const current = sharedBrowser;
if (!current) {
return;
}
const chrome = await current.then(
(browser) => browser.chrome,
() => null,
);
if (!chrome || chrome.exitCode !== null) {
return;
}
const exited = new Promise((resolve) => chrome.once('exit', resolve));
chrome.kill('SIGTERM');
const forced = setTimeout(() => chrome.kill('SIGKILL'), 2000);
await exited;
clearTimeout(forced);
};
/**
* Pays the shared browser's one-time shader compilation with a throwaway
* wish, so the first real request renders as fast as later ones.
*
* @returns {Promise<void>}
*/
export const warmGachaBrowser = async () => {
await renderGachaVideo({label: 'Warm-up', rarity: 5, fps: 24, width: 640, seed: 1}, {timeoutInMilliseconds: 120000});
};
/**
* A seeded Math.random so a seed reproduces the same flecks and sparkles.
*
* @param {number} seed
*/
const seededRandomScript = (seed) => `(() => {
let state = ${seed >>> 0};
Math.random = () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
})();`;
/**
* Captures the wish into JPEG frames in `dir`, one per video frame.
*
* @param {CdpConnection} cdp
* @param {string} sessionId
* @param {GachaRenderRequest} request
* @param {string} dir
* @param {<T>(promise: Promise<T>) => Promise<T>} 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<string, unknown>} [params]
*/
const send = (method, params) => guard(cdp.send(method, params, sessionId));
/** @param {string} expression */
const evaluate = async (expression) =>
(await send('Runtime.evaluate', {expression, returnByValue: true})).result?.value;
await send('Page.enable');
await send('Fetch.enable', {patterns: [{urlPattern: '*'}]});
await send('Emulation.setDeviceMetricsOverride', {
...getWishFrameSize(request.width),
deviceScaleFactor: 1,
mobile: false,
});
await send('Page.addScriptToEvaluateOnNewDocument', {
source: `${seededRandomScript(request.seed)}window.gachaWish = ${JSON.stringify(getWishPageProps(request))};`,
});
const {virtualTimeTicksBase} = await send('Emulation.setVirtualTimePolicy', {policy: 'pause'});
let elapsed = 0;
/**
* Advances virtual time by one frame, then draws that frame with a
* BeginFrame stamped on the same clock so requestAnimationFrame,
* performance.now(), and CSS animations agree.
*
* @param {boolean} capture
* @returns {Promise<string | undefined>} JPEG data, when captured and changed.
*/
const step = async (capture) => {
const expired = cdp.once('Emulation.virtualTimeBudgetExpired', sessionId);
await send('Emulation.setVirtualTimePolicy', {
policy: 'pauseIfNetworkFetchesPending',
budget: interval,
maxVirtualTimeTaskStarvationCount: 100,
});
await guard(expired);
elapsed += interval;
const frame = await send('HeadlessExperimental.beginFrame', {
frameTimeTicks: virtualTimeTicksBase + elapsed,
interval,
...(capture ? {screenshot: {format: 'jpeg', quality: 90}} : {}),
});
return frame.screenshotData;
};
await send('Page.navigate', {url: `${wishOrigin}/index.html`});
let box = null;
for (let waited = 0; !box && waited < maxLoadMs; waited += interval) {
await step(false);
box = await evaluate(`(() => {
const control = document.querySelector('.recap-webgl-pack [role=button]');
if (!control) return null;
const r = control.getBoundingClientRect();
return {x: r.left, y: r.top, w: r.width, h: r.height};
})()`);
}
if (!box) {
throw new Error('The gacha wish pack did not mount');
}
let pressed = false;
let last = '';
let pointer = {x: 0, y: 0};
const frames = Math.round(gachaTotalSeconds * request.fps);
for (let index = 0; index < frames; index += 1) {
const drag = getDragPointer(index / request.fps);
/** @type {Promise<unknown>[]} */
const inputs = [];
/** @param {Record<string, unknown>} event */
const mouse = (event) => inputs.push(send('Input.dispatchMouseEvent', {...pointer, ...event}));
if (drag) {
pointer = {x: box.x + box.w * drag.x, y: box.y + box.h * drag.y};
if (pressed) {
mouse({type: 'mouseMoved', button: 'left', buttons: 1});
} else {
mouse({type: 'mouseMoved'});
mouse({type: 'mousePressed', button: 'left', clickCount: 1});
pressed = true;
}
} else if (pressed) {
mouse({type: 'mouseReleased', button: 'left', clickCount: 1});
pressed = false;
}
// Input is acknowledged only once a frame consumes it, so draw before
// awaiting it. BeginFrame omits the screenshot when nothing changed.
last = (await step(true)) ?? last;
await Promise.all(inputs);
await writeFile(path.join(dir, `f${String(index).padStart(4, '0')}.jpg`), Buffer.from(last, 'base64'));
}
if ((await evaluate('document.documentElement.dataset.wish')) !== 'revealed') {
throw new Error('The gacha wish card was not revealed');
}
};
/**
* Renders the gacha wish: a pack-cards pack torn open by a scripted drag,
* captured frame by frame under virtual time and encoded to a silent H.264
* MP4.
*
* @param {GachaRenderRequest} request
* @param {{timeoutInMilliseconds: number}} options
* @returns {Promise<RenderResult>}
*/
export const renderGachaVideo = async (request, options) => {
const startedAt = Date.now();
const tempDir = await createRenderTempDir();
const {cancel, cancelSignal} = makeCancelSignal();
/** @type {(error: Error) => void} */
let abandon = () => {};
/** @type {Promise<never>} */
const abandoned = new Promise((_, reject) => {
abandon = reject;
});
abandoned.catch(() => {});
/** @type {<T>(promise: Promise<T>) => Promise<T>} */
const guard = (promise) => Promise.race([promise, abandoned]);
const timeoutId = setTimeout(() => {
cancel();
abandon(new Error('The gacha wish render was abandoned'));
}, options.timeoutInMilliseconds);
/** @type {CdpConnection | null} */
let cdp = null;
let targetId = '';
try {
const browser = await guard(getBrowser());
cdp = browser.cdp;
({targetId} = await guard(cdp.send('Target.createTarget', {url: 'about:blank', enableBeginFrameControl: true})));
const {sessionId} = await guard(cdp.send('Target.attachToTarget', {targetId, flatten: true}));
await captureFrames(cdp, sessionId, request, tempDir, guard);
const outputLocation = path.join(tempDir, 'gacha.mp4');
// Remotion is pinned to an exact version; its bundled ffmpeg encodes the frames.
await RenderInternals.callFf({
bin: 'ffmpeg',
args: [
'-y',
'-framerate',
String(request.fps),
'-i',
path.join(tempDir, 'f%04d.jpg'),
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-crf',
'23',
'-movflags',
'+faststart',
'-an',
outputLocation,
],
indent: false,
logLevel: 'error',
binariesDirectory: null,
cancelSignal,
});
const buffer = await readFile(outputLocation);
return {buffer, byteLength: buffer.byteLength, durationMs: Date.now() - startedAt};
} catch (error) {
if (Date.now() - startedAt >= options.timeoutInMilliseconds) {
throw new RenderTimeoutError(options.timeoutInMilliseconds, {cause: error});
}
throw error;
} finally {
clearTimeout(timeoutId);
if (cdp && targetId) {
await cdp.send('Target.closeTarget', {targetId}).catch(() => {});
}
await cleanupTempDir(tempDir);
}
};
+1 -2
View File
@@ -14,7 +14,6 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
* @property {AppConfig} config
* @property {{tryAcquire: () => boolean, release: () => void}} semaphore
* @property {(request: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} renderGacha
* @property {string} [path] Route path; defaults to /api/gacha. The beta wish shares this contract.
*/
/**
@@ -25,7 +24,7 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
* @param {GachaRouteDeps} deps
*/
export const registerGachaRoute = async (app, deps) => {
app.post(deps.path ?? '/api/gacha', async (request, reply) => {
app.post('/api/gacha', async (request, reply) => {
if (!isAuthorized(request, deps.config.apiToken)) {
return reply.code(401).send({error: 'unauthorized'});
}
+3 -6
View File
@@ -2,9 +2,6 @@ import {z} from 'zod';
export const maxGachaSeed = 2 ** 31 - 1;
/** Landscape frame sizes; heights keep 16:9 rounded to even pixels for H.264. */
export const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
/**
* @typedef {object} GachaRequestLimits
* @property {number} maxOptionChars
@@ -15,8 +12,8 @@ export const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
* @property {string} label
* @property {3 | 4 | 5} rarity
* @property {24 | 30} fps
* @property {640 | 854} width
* @property {number} seed Lays out the stars and particles for this roll.
* @property {640 | 854} width Long edge of the portrait frame.
* @property {number} seed Seeds the page's randomness, so a seed replays the same flecks and sparkles.
*/
/**
@@ -38,7 +35,7 @@ export const createGachaRequestSchema = (limits) =>
/**
* Parses a request, picking a fresh seed when the caller sends none so every
* roll draws a different sky.
* roll looks different.
*
* @param {unknown} input
* @param {GachaRequestLimits} limits
+5 -13
View File
@@ -2,8 +2,7 @@ import Fastify from 'fastify';
import {loadConfig} from './config.js';
import {createRenderSemaphore} from './lib/render-semaphore.js';
import {getRemotionServeUrl} from './render/remotion-bundle.js';
import {closeGachaBetaBrowser, renderGachaBetaVideo, warmGachaBetaBrowser} from './render/render-gacha-beta.js';
import {renderGachaVideo} from './render/render-gacha.js';
import {closeGachaBrowser, renderGachaVideo, warmGachaBrowser} from './render/render-gacha.js';
import {renderWheelGif} from './render/render-gif.js';
import {registerGachaRoute} from './routes/gacha.js';
import {registerGifRoute} from './routes/gif.js';
@@ -20,8 +19,7 @@ import {registerHealthRoute} from './routes/health.js';
* @param {AppConfig} [options.config]
* @param {(request: WheelRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGif]
* @param {(request: GachaRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGacha]
* @param {(request: GachaRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGachaBeta]
* @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the beta wish browser before serving.
* @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the gacha browser before serving.
*/
export const buildServer = async (options = {}) => {
const config = options.config ?? loadConfig();
@@ -29,7 +27,7 @@ export const buildServer = async (options = {}) => {
if (warmRenderers) {
await getRemotionServeUrl();
await warmGachaBetaBrowser();
await warmGachaBrowser();
}
const app = Fastify({
@@ -55,14 +53,8 @@ export const buildServer = async (options = {}) => {
renderGacha: options.renderGacha ?? renderGachaVideo,
semaphore,
});
await registerGachaRoute(app, {
config,
path: '/api/gachabeta',
renderGacha: options.renderGachaBeta ?? renderGachaBetaVideo,
semaphore,
});
// The beta wish keeps one headless Chrome alive between renders.
app.addHook('onClose', closeGachaBetaBrowser);
// The gacha wish keeps one headless Chrome alive between renders.
app.addHook('onClose', closeGachaBrowser);
return app;
};
+4 -23
View File
@@ -158,31 +158,12 @@ describe('POST /api/gacha', () => {
await app.close();
});
test('serves the beta wish on /api/gachabeta with the same contract', async () => {
/** @type {string[]} */
const calls = [];
const app = await build(
{},
{
renderGacha: async () => {
calls.push('gacha');
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
renderGachaBeta: async (request) => {
calls.push(`beta:${request.label}:${request.rarity}`);
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
},
);
test('the retired beta path is gone', async () => {
const app = await build();
const response = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Bún bò', rarity: 5}});
const invalid = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Bún bò', rarity: 9}});
const response = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Pizza', rarity: 5}});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('video/mp4');
expect(response.headers['x-gacha-rarity']).toBe('5');
expect(invalid.statusCode).toBe(400);
expect(calls).toEqual(['beta:Bún bò:5']);
expect(response.statusCode).toBe(404);
await app.close();
});
-110
View File
@@ -1,110 +0,0 @@
import {describe, expect, test} from 'vitest';
import {
createStarfield,
gachaTimeline,
gachaTotalSeconds,
getLabelFontSize,
emblemCenter,
getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
getStarRevealTimes,
getTierEffects,
getTwinkle,
} from '../src/remotion/gacha-timeline.js';
describe('gacha timeline', () => {
test('phases run in order and finish before the clip ends', () => {
const tl = gachaTimeline;
expect(tl.meteorStart).toBeLessThan(tl.meteorEnd);
expect(tl.flashStart).toBeLessThan(tl.flashPeak);
expect(tl.meteorEnd).toBeLessThanOrEqual(tl.flashPeak);
expect(tl.flashPeak).toBeLessThan(tl.flashHoldEnd);
expect(tl.revealStart).toBeLessThanOrEqual(tl.flashHoldEnd);
expect(tl.flashHoldEnd).toBeLessThan(tl.flashEnd);
expect(tl.nameIn).toBeLessThan(tl.nameSettled);
const lastStar = getStarRevealTimes(5).at(-1) ?? 0;
expect(lastStar + 1).toBeLessThan(gachaTotalSeconds);
});
test.each([3, 4, 5])('reveals one star per rarity level for %i stars', (rarity) => {
const times = getStarRevealTimes(/** @type {3 | 4 | 5} */ (rarity));
expect(times).toHaveLength(rarity);
expect(times[0]).toBe(gachaTimeline.starsStart);
expect([...times].sort((a, b) => a - b)).toEqual(times);
});
test('each rarity has a distinct meteor colour', () => {
const glows = [3, 4, 5].map((rarity) => getRarityPalette(/** @type {3 | 4 | 5} */ (rarity)).glow);
expect(new Set(glows).size).toBe(3);
});
test('meteor flies in from off-screen upper left and lands on the rank emblem', () => {
const start = getMeteorPoint(0, 640, 360);
const end = getMeteorPoint(1, 640, 360);
expect(start.x).toBeLessThan(0);
expect(start.y).toBeLessThan(0);
expect(end.x).toBeCloseTo(640 * emblemCenter.x);
expect(end.y).toBeCloseTo(360 * emblemCenter.y);
expect(getMeteorPoint(2, 640, 360)).toEqual(end);
expect(getMeteorPoint(-1, 640, 360)).toEqual(start);
});
test('starfield is deterministic per seed', () => {
expect(createStarfield(5, 7)).toEqual(createStarfield(5, 7));
expect(createStarfield(5, 7)).not.toEqual(createStarfield(5, 8));
});
test('stars twinkle with short bright flares', () => {
const [star] = createStarfield(1, 3);
if (!star) {
throw new Error('expected a star');
}
const samples = Array.from({length: 200}, (_, index) => getTwinkle(index / 50, star));
expect(Math.max(...samples)).toBeGreaterThan(0.95);
expect(Math.min(...samples)).toBeLessThan(0.05);
const bright = samples.filter((value) => value > 0.5).length / samples.length;
expect(bright).toBeLessThan(0.4);
});
test('long labels get smaller text', () => {
expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360));
});
test('emblem shows B, A, S rank letters by rarity', () => {
expect([3, 4, 5].map((rarity) => getRankLetter(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['B', 'A', 'S']);
});
test('every numeric effect grows with rarity and 5★ unlocks the extras', () => {
const [three, four, five] = [3, 4, 5].map((rarity) => getTierEffects(/** @type {3 | 4 | 5} */ (rarity)));
for (const key of /** @type {const} */ ([
'headScale',
'trailSamples',
'sparks',
'shockwaves',
'raysOpacity',
'motes',
'starFlare',
])) {
expect(four?.[key], key).toBeGreaterThan(three?.[key] ?? Infinity);
expect(five?.[key], key).toBeGreaterThan(four?.[key] ?? Infinity);
}
expect(four?.shake).toBeGreaterThan(three?.shake ?? Infinity);
expect(five?.shake).toBeGreaterThan(four?.shake ?? Infinity);
expect(five?.skyFlood).toBeGreaterThan(four?.skyFlood ?? Infinity);
expect([three?.halo, four?.halo, five?.halo]).toEqual([false, false, true]);
expect(five).toMatchObject({starburst: true, counterRays: true, sheen: true});
expect(three?.sparkleRain).toBe(0);
expect(five?.sparkleRain).toBeGreaterThan(0);
});
test('impact shake decays to rest', () => {
expect(getShakeOffset(1, 2, 10)).toEqual({x: 0, y: 0});
expect(getShakeOffset(3, 2, 10)).toEqual({x: 0, y: 0});
expect(getShakeOffset(2.1, 2, 0)).toEqual({x: 0, y: 0});
const early = getShakeOffset(2.05, 2, 10);
const late = getShakeOffset(2.7, 2, 10);
expect(Math.hypot(early.x, early.y)).toBeGreaterThan(Math.hypot(late.x, late.y));
});
});
@@ -1,19 +1,19 @@
import path from 'node:path';
import {describe, expect, test} from 'vitest';
import {
gachaBetaTotalSeconds,
gachaTotalSeconds,
getDragPointer,
getWishFrameSize,
getWishPageProps,
resolveWishAsset,
wishOrigin,
wishTimeline,
} from '../src/gacha-beta/wish-plan.js';
} from '../src/gacha/wish-plan.js';
const roots = {page: path.resolve('/srv/page'), packCards: path.resolve('/srv/pack-cards')};
describe('gacha beta wish plan', () => {
test('the page shows the request label with the same rank and stars as /api/gacha', () => {
describe('gacha wish plan', () => {
test('the page shows the request label with its rank and stars', () => {
const base = {label: '<b>Bún bò</b>', fps: /** @type {const} */ (24), width: /** @type {const} */ (640), seed: 1};
expect(getWishPageProps({...base, rarity: 3})).toMatchObject({
label: '<b>Bún bò</b>',
@@ -43,7 +43,7 @@ describe('gacha beta wish plan', () => {
expect(end.x).toBeLessThanOrEqual(1);
expect(end.y).toBe(start.y);
}
expect(dragEnd + 2).toBeLessThan(gachaBetaTotalSeconds);
expect(dragEnd + 2).toBeLessThan(gachaTotalSeconds);
});
test('page requests map to the page directory and the installed package', () => {