diff --git a/renderer/README.md b/renderer/README.md
index 99aba83..337ccc4 100644
--- a/renderer/README.md
+++ b/renderer/README.md
@@ -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:
diff --git a/renderer/scripts/api-smoke.js b/renderer/scripts/api-smoke.js
index a1859c8..137ed73 100644
--- a/renderer/scripts/api-smoke.js
+++ b/renderer/scripts/api-smoke.js
@@ -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();
}
diff --git a/renderer/scripts/render-fixtures.js b/renderer/scripts/render-fixtures.js
index 7f8a96a..a2a2539 100644
--- a/renderer/scripts/render-fixtures.js
+++ b/renderer/scripts/render-fixtures.js
@@ -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();
}
diff --git a/renderer/src/gacha-beta/page/index.html b/renderer/src/gacha/page/index.html
similarity index 93%
rename from renderer/src/gacha-beta/page/index.html
rename to renderer/src/gacha/page/index.html
index d50ac38..4847e8e 100644
--- a/renderer/src/gacha-beta/page/index.html
+++ b/renderer/src/gacha/page/index.html
@@ -2,7 +2,7 @@
- Gacha beta
+ Gacha
diff --git a/renderer/src/gacha-beta/page/page.css b/renderer/src/gacha/page/page.css
similarity index 100%
rename from renderer/src/gacha-beta/page/page.css
rename to renderer/src/gacha/page/page.css
diff --git a/renderer/src/gacha-beta/page/page.js b/renderer/src/gacha/page/page.js
similarity index 98%
rename from renderer/src/gacha-beta/page/page.js
rename to renderer/src/gacha/page/page.js
index 7b5e4e6..00f5bee 100644
--- a/renderer/src/gacha-beta/page/page.js
+++ b/renderer/src/gacha/page/page.js
@@ -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.
diff --git a/renderer/src/gacha-beta/wish-plan.js b/renderer/src/gacha/wish-plan.js
similarity index 72%
rename from renderer/src/gacha-beta/wish-plan.js
rename to renderer/src/gacha/wish-plan.js
index 8e56f7c..6b11ea3 100644
--- a/renderer/src/gacha-beta/wish-plan.js
+++ b/renderer/src/gacha/wish-plan.js
@@ -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}};
};
/**
diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx
deleted file mode 100644
index 58f11a6..0000000
--- a/renderer/src/remotion/GachaComposition.jsx
+++ /dev/null
@@ -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}) => (
-
-);
-
-/**
- * @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 (
-
- {!revealed ? (
-
-
- {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 (
-