mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
d657c49c83
commit
7ca1468a43
18 files changed
+451
-1649
No files matched your search
+23
-37
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
Reference in new issue
Block a user