diff --git a/renderer/README.md b/renderer/README.md
index 91aa41d..8d6ba5f 100644
--- a/renderer/README.md
+++ b/renderer/README.md
@@ -64,6 +64,16 @@ 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.
+### Gacha wish beta
+
+`POST /api/gachabeta` takes the same body and returns the same response as
+`/api/gacha`, rendering an 8-second beta style instead: a cosmic, astrology
+themed night sky in toon shading (flat colour bands, no outlines) seen through
+a perspective action camera. The camera dollies toward a hero cloud, a comet
+lights its rim from behind and bursts through it, the camera chases the comet
+as its flare builds, and after the burst the label appears with a rank
+(`S`, `SS`, `SSS` for rarity 3, 4, 5).
+
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
diff --git a/renderer/scripts/api-smoke.js b/renderer/scripts/api-smoke.js
index d00c7ca..af39e0b 100644
--- a/renderer/scripts/api-smoke.js
+++ b/renderer/scripts/api-smoke.js
@@ -65,6 +65,21 @@ 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 95b76b2..28ef8a7 100644
--- a/renderer/scripts/render-fixtures.js
+++ b/renderer/scripts/render-fixtures.js
@@ -1,6 +1,6 @@
import {mkdir, writeFile} from 'node:fs/promises';
import path from 'node:path';
-import {renderGachaVideo} from '../src/render/render-gacha.js';
+import {renderGachaBetaVideo, renderGachaVideo} from '../src/render/render-gacha.js';
import {renderWheelGif} from '../src/render/render-gif.js';
const smoke = process.argv.includes('--smoke');
@@ -72,3 +72,12 @@ for (const fixture of smoke ? fixtures.slice(0, 1) : fixtures) {
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,
+};
+await save('gachabeta-5-star.mp4', await renderGachaBetaVideo(betaFixture, {timeoutInMilliseconds: 30000}));
diff --git a/renderer/src/remotion/GachaBetaComposition.jsx b/renderer/src/remotion/GachaBetaComposition.jsx
new file mode 100644
index 0000000..e6fefed
--- /dev/null
+++ b/renderer/src/remotion/GachaBetaComposition.jsx
@@ -0,0 +1,531 @@
+import {useMemo} from 'react';
+import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
+import {
+ gachaBetaTimeline,
+ getBetaRank,
+ getBetaShake,
+ getCamera,
+ getCometGlow,
+ getCometPosition,
+ heroCloud,
+ pierceTime,
+ project,
+} from './gacha-beta-scene.js';
+import {createRandom, createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} 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'});
+
+/** Toon shading: flat colour bands with hard edges and no outline. */
+const skyBands =
+ 'linear-gradient(180deg, #0a0c2c 0% 28%, #15185a 28% 52%, #262a80 52% 72%, #3a44a6 72% 88%, #5664c4 88%)';
+const cloudShade = 'radial-gradient(circle at 38% 32%, #f6f4ff 0% 44%, #cdd1f6 44% 70%, #959cdb 70%)';
+const heroShade = 'radial-gradient(circle at 38% 30%, #ffffff 0% 46%, #d8dcfb 46% 72%, #a0a8e4 72%)';
+
+const trailSamples = 44;
+const trailStep = 0.01;
+
+/**
+ * Puffs that make one toon cloud, in units of the cloud radius.
+ *
+ * @param {() => number} random
+ * @param {number} count
+ */
+const createPuffs = (random, count) =>
+ Array.from({length: count}, (_, index) => {
+ const angle = (index / count) * Math.PI * 2 + random() * 0.6;
+ const reach = index === 0 ? 0 : 0.35 + random() * 0.45;
+ return {
+ dx: Math.cos(angle) * reach,
+ dy: Math.sin(angle) * reach * 0.45 + (random() - 0.3) * 0.15,
+ r: 0.38 + random() * 0.3,
+ };
+ });
+
+/**
+ * Every seeded layout for one roll: stars, constellation links, and the
+ * clouds scattered through the sky.
+ *
+ * @param {number} seed
+ */
+const createBetaScene = (seed) => {
+ const random = createRandom(seed + 101);
+ const stars = createStarfield(80, seed);
+ const links = Array.from({length: 7}, () => {
+ const a = Math.floor(random() * 24);
+ return [a, a + 1 + Math.floor(random() * 3)];
+ });
+ const clouds = Array.from({length: 14}, () => ({
+ x: (random() - 0.5) * 3200,
+ y: 80 + random() * 420,
+ z: 1100 + random() * 5200,
+ radius: 160 + random() * 220,
+ puffs: createPuffs(random, 6),
+ })).sort((a, b) => b.z - a.z);
+ const heroPuffs = createPuffs(random, 13);
+ const confetti = Array.from({length: 34}, () => ({
+ x: random(),
+ offset: random(),
+ speed: 0.18 + random() * 0.3,
+ size: 0.02 + random() * 0.025,
+ spin: (random() - 0.5) * 400,
+ hue: Math.floor(random() * 4),
+ }));
+ return {stars, links, clouds, heroPuffs, confetti};
+};
+
+const confettiColors = ['#ffd36b', '#ff8cd8', '#8cff9a', '#8fd0ff'];
+
+/**
+ * @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, fill: string, shade: string}} props
+ */
+const ToonStar = ({size, fill, shade}) => (
+
+);
+
+/**
+ * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
+ */
+
+/**
+ * Beta wish: a cosmic, astrology-themed night sky in toon shading. The
+ * camera dollies toward a hero cloud, a comet bursts through it, the camera
+ * chases the comet as it flares, and after the burst the result appears with
+ * its rank.
+ *
+ * @param {GachaRenderRequest} props
+ */
+export const GachaBetaComposition = (props) => {
+ const frame = useCurrentFrame();
+ const {fps, width, height} = useVideoConfig();
+ const seconds = frame / fps;
+ const tl = gachaBetaTimeline;
+ const palette = getRarityPalette(props.rarity);
+ const scene = useMemo(() => createBetaScene(props.seed), [props.seed]);
+
+ const camera = getCamera(seconds);
+ const shake = getBetaShake(seconds, height);
+ const comet = getCometPosition(seconds);
+ const cometScreen = project(comet, camera, width, height);
+ const glow = getCometGlow(seconds);
+ const cometVisible = seconds >= tl.cometAppear && seconds < tl.flashPeak;
+ const burst = interpolate(seconds, [tl.flightEnd - 0.1, tl.flashPeak], [0, 1], clamp);
+ const flash = interpolate(seconds, [tl.flightEnd, tl.flashPeak, tl.flashEnd], [0, 1, 0], clamp);
+ const cardVisible = seconds >= tl.cardIn - 0.2;
+ const pierceBurst = interpolate(seconds, [pierceTime, pierceTime + 0.9], [0, 1], {
+ ...clamp,
+ easing: Easing.out(Easing.cubic),
+ });
+ const speedLines = interpolate(
+ seconds,
+ [tl.pierceEnd, tl.pierceEnd + 0.4, tl.flightEnd - 0.6, tl.flightEnd],
+ [0, 1, 1, 0],
+ clamp,
+ );
+
+ /** @type {{depth: number, node: import('react').ReactNode}[]} */
+ const drawables = [];
+
+ for (const [index, cloud] of scene.clouds.entries()) {
+ const screen = project(cloud, camera, width, height);
+ if (!screen) {
+ continue;
+ }
+ const radius = cloud.radius * screen.scale;
+ drawables.push({
+ depth: screen.depth,
+ node: (
+
+ {cloud.puffs.map((puff, puffIndex) => (
+
+ ))}
+
+ ),
+ });
+ }
+
+ const hero = project(heroCloud, camera, width, height);
+ if (hero) {
+ const radius = heroCloud.radius * hero.scale;
+ drawables.push({
+ depth: hero.depth,
+ node: (
+
+ {scene.heroPuffs.map((puff, index) => {
+ const distance = Math.hypot(puff.dx, puff.dy) || 1;
+ const push = pierceBurst * (0.6 + (1 - Math.min(1, distance)) * 1.4);
+ const ox = (puff.dx / distance) * push;
+ const oy = (puff.dy / distance) * push;
+ const opacity = 1 - pierceBurst * (distance < 0.5 ? 0.95 : 0.55);
+ return (
+
+ );
+ })}
+
+ ),
+ });
+ }
+
+ if (cometVisible && cometScreen) {
+ const trail = [];
+ for (let index = trailSamples - 1; index >= 1; index -= 1) {
+ const past = seconds - index * trailStep;
+ if (past < tl.cometAppear) {
+ continue;
+ }
+ const point = project(getCometPosition(past), camera, width, height);
+ if (!point) {
+ continue;
+ }
+ const fade = 1 - index / trailSamples;
+ const radius = (14 + 26 * glow) * point.scale * fade;
+ trail.push(
+ ,
+ );
+ }
+ const headSize = (34 + 60 * glow + 90 * burst) * cometScreen.scale;
+ const flare = headSize * (1.4 + glow * 3);
+ drawables.push({
+ depth: cometScreen.depth,
+ node: (
+
+ {trail}
+
+ {[0, 90, 45, 135].map((angle, index) => (
+
+ ))}
+
+
+
+ {burst > 0 ? (
+
+ ) : null}
+
+ ),
+ });
+ }
+
+ // While the comet hides behind the hero cloud, its light spills around the
+ // cloud's rim so it visibly appears from behind before it breaks through.
+ const backlight = interpolate(seconds, [tl.cometAppear, tl.pierceStart, pierceTime], [0, 1, 0.6], clamp);
+ if (backlight > 0 && seconds < pierceTime) {
+ const light = project({...comet, y: heroCloud.y}, camera, width, height);
+ if (light) {
+ const radius = heroCloud.radius * 1.7 * light.scale;
+ drawables.push({
+ depth: light.depth + 1,
+ node: (
+
+ ),
+ });
+ }
+ }
+
+ drawables.sort((a, b) => b.depth - a.depth);
+
+ const rank = getBetaRank(props.rarity);
+ const cardPop = interpolate(seconds, [tl.cardIn, tl.cardIn + 0.35, tl.cardIn + 0.55], [0.6, 1.08, 1], {
+ ...clamp,
+ easing: Easing.out(Easing.cubic),
+ });
+ const rankPop = interpolate(seconds, [tl.rankIn, tl.rankIn + 0.18, tl.rankIn + 0.36], [0, 1.35, 1], clamp);
+
+ return (
+
+
+
+
+
+
+ {scene.stars.map((star, index) => {
+ const twinkle = getTwinkle(seconds, star);
+ const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle);
+ return (
+ 0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined,
+ height: size,
+ left: star.x * width - camera.x * 0.02,
+ opacity: 0.25 + 0.75 * twinkle,
+ position: 'absolute',
+ top: star.y * height * 0.7,
+ width: size,
+ }}
+ />
+ );
+ })}
+
+ {drawables.map((drawable) => drawable.node)}
+
+ {speedLines > 0 && cometScreen
+ ? Array.from({length: 18}, (_, index) => {
+ const angle = (index / 18) * 360 + ((index * 37) % 11);
+ const reach = ((seconds * 2.2 + index * 0.137) % 1) * width * 0.7;
+ return (
+
+ );
+ })
+ : null}
+
+
+ {cardVisible ? (
+
+
+ {scene.confetti.map((piece, index) => {
+ const y = ((piece.offset + (seconds - tl.cardIn) * piece.speed) % 1.2) - 0.1;
+ const size = piece.size * height;
+ return (
+
+
+
+ );
+ })}
+
+
0 ? 1 : 0,
+ paddingRight: height * 0.03,
+ transform: `scale(${rankPop})`,
+ WebkitBackgroundClip: 'text',
+ backgroundClip: 'text',
+ filter: `drop-shadow(0 ${height * 0.012}px 0 #8a4b00) drop-shadow(0 0 ${height * 0.04}px ${palette.glow})`,
+ }}
+ >
+ {rank}
+
+
+ {props.label}
+
+
+
+ ) : null}
+
+ {flash > 0 ?
: null}
+
+ );
+};
diff --git a/renderer/src/remotion/Root.jsx b/renderer/src/remotion/Root.jsx
index cb5c605..6e414f0 100644
--- a/renderer/src/remotion/Root.jsx
+++ b/renderer/src/remotion/Root.jsx
@@ -1,5 +1,7 @@
import {Composition} from 'remotion';
+import {GachaBetaComposition} from './GachaBetaComposition.jsx';
import {GachaComposition} from './GachaComposition.jsx';
+import {gachaBetaTotalSeconds} from './gacha-beta-scene.js';
import {gachaTotalSeconds} from './gacha-timeline.js';
import {WheelComposition} from './WheelComposition.jsx';
import {gachaFrameSizes} from '../schemas/gacha-request.js';
@@ -43,12 +45,13 @@ const defaultGachaProps = {
/**
* @param {{fps: number, width: number}} props
+ * @param {number} [totalSeconds]
*/
-export const getGachaCompositionMetadata = (props) => {
+export const getGachaCompositionMetadata = (props, totalSeconds = gachaTotalSeconds) => {
const fps = props.fps || defaultGachaProps.fps;
const width = props.width in gachaFrameSizes ? props.width : defaultGachaProps.width;
return {
- durationInFrames: Math.ceil(gachaTotalSeconds * fps),
+ durationInFrames: Math.ceil(totalSeconds * fps),
fps,
height: gachaFrameSizes[/** @type {640 | 854} */ (width)],
width,
@@ -77,5 +80,15 @@ export const RemotionRoot = () => (
id="GachaWish"
width={defaultGachaProps.width}
/>
+
getGachaCompositionMetadata(props, gachaBetaTotalSeconds)}
+ component={GachaBetaComposition}
+ defaultProps={defaultGachaProps}
+ durationInFrames={Math.ceil(gachaBetaTotalSeconds * defaultGachaProps.fps)}
+ fps={defaultGachaProps.fps}
+ height={gachaFrameSizes[defaultGachaProps.width]}
+ id="GachaBetaWish"
+ width={defaultGachaProps.width}
+ />
>
);
diff --git a/renderer/src/remotion/gacha-beta-scene.js b/renderer/src/remotion/gacha-beta-scene.js
new file mode 100644
index 0000000..d0e0ea2
--- /dev/null
+++ b/renderer/src/remotion/gacha-beta-scene.js
@@ -0,0 +1,212 @@
+/**
+ * Scene math for the beta wish: a toon-shaded night sky viewed through a
+ * perspective camera that dollies toward a hero cloud, follows a comet that
+ * pierces it, and holds on the comet as it flares and bursts. Kept free of
+ * React so the choreography can be unit tested.
+ */
+
+/** @typedef {3 | 4 | 5} GachaRarity */
+
+/**
+ * @typedef {object} Vec3
+ * @property {number} x
+ * @property {number} y
+ * @property {number} z
+ */
+
+/**
+ * @typedef {object} Camera
+ * @property {number} x
+ * @property {number} y
+ * @property {number} z
+ * @property {number} roll Screen roll in degrees.
+ */
+
+/**
+ * @typedef {object} Projection
+ * @property {number} x Screen pixels.
+ * @property {number} y Screen pixels.
+ * @property {number} scale Pixels per world unit at that depth.
+ * @property {number} depth World units in front of the camera.
+ */
+
+export const gachaBetaTotalSeconds = 8;
+
+/** Seconds from the start of the clip for each beat of the three phases. */
+export const gachaBetaTimeline = Object.freeze({
+ dollyEnd: 2,
+ cometAppear: 1.2,
+ pierceStart: 1.9,
+ pierceEnd: 2.6,
+ flightEnd: 4.8,
+ flashPeak: 5.05,
+ flashEnd: 5.45,
+ cardIn: 5.2,
+ rankIn: 5.6,
+});
+
+/** The cloud the camera dollies toward and the comet pierces. */
+export const heroCloud = Object.freeze({x: 0, y: 30, z: 800, radius: 170});
+
+/** Where the comet waits behind the hero cloud, then where it exits in front. */
+const cometHidden = Object.freeze({x: 60, y: 10, z: 1500});
+const cometPierceFrom = Object.freeze({x: 40, y: 20, z: 1300});
+const cometPierceTo = Object.freeze({x: 0, y: 30, z: 500});
+
+/** The camera trails the comet from behind and slightly above it. */
+const cameraFollowOffset = Object.freeze({x: 0, y: -30, z: -420});
+
+/**
+ * Seconds at which the comet crosses the hero cloud's plane. The pierce moves
+ * linearly in depth, so the crossing is a fixed fraction of the segment.
+ */
+export const pierceTime =
+ gachaBetaTimeline.pierceStart +
+ ((gachaBetaTimeline.pierceEnd - gachaBetaTimeline.pierceStart) * (cometPierceFrom.z - heroCloud.z)) /
+ (cometPierceFrom.z - cometPierceTo.z);
+
+/**
+ * @param {number} value
+ * @returns {number}
+ */
+const clamp01 = (value) => Math.min(1, Math.max(0, value));
+
+/**
+ * @param {number} t
+ * @returns {number}
+ */
+const easeInOutSine = (t) => -(Math.cos(Math.PI * clamp01(t)) - 1) / 2;
+
+/**
+ * @param {number} t
+ * @returns {number}
+ */
+const easeOutCubic = (t) => 1 - (1 - clamp01(t)) ** 3;
+
+/**
+ * @param {Vec3} a
+ * @param {Vec3} b
+ * @param {number} t
+ * @returns {Vec3}
+ */
+const lerp3 = (a, b, t) => ({x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: a.z + (b.z - a.z) * t});
+
+/**
+ * Comet position in world units. It waits behind the hero cloud, punches
+ * straight through it toward the camera, then flies off on a rising arc and
+ * slows as it gathers light for the burst.
+ *
+ * @param {number} seconds
+ * @returns {Vec3}
+ */
+export const getCometPosition = (seconds) => {
+ const tl = gachaBetaTimeline;
+ if (seconds < tl.pierceStart) {
+ const t = clamp01((seconds - tl.cometAppear) / (tl.pierceStart - tl.cometAppear));
+ return lerp3(cometHidden, cometPierceFrom, t);
+ }
+ if (seconds < tl.pierceEnd) {
+ return lerp3(cometPierceFrom, cometPierceTo, (seconds - tl.pierceStart) / (tl.pierceEnd - tl.pierceStart));
+ }
+ const u = easeOutCubic((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd));
+ return {
+ x: cometPierceTo.x + 600 * u,
+ y: cometPierceTo.y - 300 * u + 120 * Math.sin(Math.PI * u),
+ z: cometPierceTo.z + 2600 * u,
+ };
+};
+
+/**
+ * Action camera: a slow dolly toward the hero cloud, then a chase that locks
+ * onto the comet from behind with a little lag and banks into its turn.
+ *
+ * @param {number} seconds
+ * @returns {Camera}
+ */
+export const getCamera = (seconds) => {
+ const tl = gachaBetaTimeline;
+ const dolly = {x: 0, y: 0, z: 250 * easeInOutSine(seconds / tl.dollyEnd)};
+ const lagged = getCometPosition(Math.max(tl.pierceEnd, seconds - 0.12));
+ const chase = {
+ x: lagged.x + cameraFollowOffset.x,
+ y: lagged.y + cameraFollowOffset.y,
+ z: lagged.z + cameraFollowOffset.z,
+ };
+ const follow = easeInOutSine((seconds - pierceTime) / (tl.pierceEnd + 0.4 - pierceTime));
+ const position = lerp3(dolly, {...chase, z: Math.max(dolly.z, chase.z)}, follow);
+ const bank = seconds > tl.pierceEnd ? 7 * Math.sin(Math.PI * clamp01((seconds - tl.pierceEnd) / 1.6)) : 0;
+ return {...position, roll: bank * follow};
+};
+
+/**
+ * Perspective projection onto the frame. Returns null for points behind or
+ * too close to the camera.
+ *
+ * @param {Vec3} point
+ * @param {Camera} camera
+ * @param {number} width
+ * @param {number} height
+ * @returns {Projection | null}
+ */
+export const project = (point, camera, width, height) => {
+ const depth = point.z - camera.z;
+ if (depth < 30) {
+ return null;
+ }
+ const focal = height * 1.2;
+ const scale = focal / depth;
+ return {
+ x: width / 2 + (point.x - camera.x) * scale,
+ y: height / 2 + (point.y - camera.y) * scale,
+ scale,
+ depth,
+ };
+};
+
+/**
+ * Comet glow from 0 to 1: it ignites behind the cloud, then builds steadily
+ * through the flight until the burst.
+ *
+ * @param {number} seconds
+ * @returns {number}
+ */
+export const getCometGlow = (seconds) => {
+ const tl = gachaBetaTimeline;
+ if (seconds < tl.pierceEnd) {
+ return 0.35 * clamp01((seconds - tl.cometAppear) / 0.4);
+ }
+ return 0.35 + 0.65 * clamp01((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd)) ** 2;
+};
+
+/**
+ * Decaying shake in pixels for the pierce and the burst.
+ *
+ * @param {number} seconds
+ * @param {number} height
+ * @returns {{x: number, y: number}}
+ */
+export const getBetaShake = (seconds, height) => {
+ let x = 0;
+ let y = 0;
+ for (const [start, amplitude] of [
+ [pierceTime, 0.02],
+ [gachaBetaTimeline.flightEnd, 0.035],
+ ]) {
+ const elapsed = seconds - (start ?? 0);
+ if (elapsed >= 0 && elapsed < 0.6) {
+ const decay = (amplitude ?? 0) * height * (1 - elapsed / 0.6) ** 2;
+ x += Math.sin(elapsed * 77) * decay;
+ y += Math.cos(elapsed * 59) * decay;
+ }
+ }
+ return {x, y};
+};
+
+/** @type {Record} */
+const betaRanks = {3: 'S', 4: 'SS', 5: 'SSS'};
+
+/**
+ * @param {GachaRarity} rarity
+ * @returns {'S' | 'SS' | 'SSS'}
+ */
+export const getBetaRank = (rarity) => betaRanks[rarity];
diff --git a/renderer/src/render/render-gacha.js b/renderer/src/render/render-gacha.js
index fac435f..a7e5e76 100644
--- a/renderer/src/render/render-gacha.js
+++ b/renderer/src/render/render-gacha.js
@@ -6,18 +6,23 @@ import {renderComposition} from './render-composition.js';
*/
/**
- * 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.
+ * Builds a renderer for one wish composition. Output is a silent H.264 MP4,
+ * which Telegram plays as a looping animation and which keeps the glow
+ * gradients free of GIF banding.
*
- * @param {GachaRenderRequest} inputProps
- * @param {{timeoutInMilliseconds: number}} options
- * @returns {Promise}
+ * @param {string} compositionId
+ * @returns {(inputProps: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise}
*/
-export const renderGachaVideo = (inputProps, options) =>
+const createGachaRenderer = (compositionId) => (inputProps, options) =>
renderComposition({
- compositionId: 'GachaWish',
+ compositionId,
filename: 'gacha.mp4',
inputProps: {...inputProps},
media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true},
timeoutInMilliseconds: options.timeoutInMilliseconds,
});
+
+export const renderGachaVideo = createGachaRenderer('GachaWish');
+
+/** The beta wish: toon-shaded 3D-perspective sky, comet through a cloud, SSS reveal. */
+export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish');
diff --git a/renderer/src/routes/gacha.js b/renderer/src/routes/gacha.js
index 30df889..1d24251 100644
--- a/renderer/src/routes/gacha.js
+++ b/renderer/src/routes/gacha.js
@@ -14,6 +14,7 @@ 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.
*/
/**
@@ -24,7 +25,7 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
* @param {GachaRouteDeps} deps
*/
export const registerGachaRoute = async (app, deps) => {
- app.post('/api/gacha', async (request, reply) => {
+ app.post(deps.path ?? '/api/gacha', async (request, reply) => {
if (!isAuthorized(request, deps.config.apiToken)) {
return reply.code(401).send({error: 'unauthorized'});
}
diff --git a/renderer/src/server.js b/renderer/src/server.js
index 137ff6f..852e22b 100644
--- a/renderer/src/server.js
+++ b/renderer/src/server.js
@@ -2,7 +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 {renderGachaVideo} from './render/render-gacha.js';
+import {renderGachaBetaVideo, renderGachaVideo} from './render/render-gacha.js';
import {renderWheelGif} from './render/render-gif.js';
import {registerGachaRoute} from './routes/gacha.js';
import {registerGifRoute} from './routes/gif.js';
@@ -19,6 +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.warmRemotionBundle]
*/
export const buildServer = async (options = {}) => {
@@ -52,6 +53,12 @@ export const buildServer = async (options = {}) => {
renderGacha: options.renderGacha ?? renderGachaVideo,
semaphore,
});
+ await registerGachaRoute(app, {
+ config,
+ path: '/api/gachabeta',
+ renderGacha: options.renderGachaBeta ?? renderGachaBetaVideo,
+ semaphore,
+ });
return app;
};
diff --git a/renderer/test/gacha-beta-scene.test.js b/renderer/test/gacha-beta-scene.test.js
new file mode 100644
index 0000000..4742d8a
--- /dev/null
+++ b/renderer/test/gacha-beta-scene.test.js
@@ -0,0 +1,90 @@
+import {describe, expect, test} from 'vitest';
+import {
+ gachaBetaTimeline,
+ gachaBetaTotalSeconds,
+ getBetaRank,
+ getBetaShake,
+ getCamera,
+ getCometGlow,
+ getCometPosition,
+ heroCloud,
+ pierceTime,
+ project,
+} from '../src/remotion/gacha-beta-scene.js';
+
+describe('gacha beta scene', () => {
+ test('phases run in order inside the clip', () => {
+ const tl = gachaBetaTimeline;
+ expect(tl.cometAppear).toBeLessThan(tl.pierceStart);
+ expect(tl.pierceStart).toBeLessThan(pierceTime);
+ expect(pierceTime).toBeLessThan(tl.pierceEnd);
+ expect(tl.pierceEnd).toBeLessThan(tl.flightEnd);
+ expect(tl.flightEnd).toBeLessThan(tl.flashPeak);
+ expect(tl.cardIn).toBeLessThan(tl.flashEnd);
+ expect(tl.rankIn + 1).toBeLessThan(gachaBetaTotalSeconds);
+ });
+
+ test('comet waits behind the hero cloud, then crosses its plane at the pierce', () => {
+ expect(getCometPosition(gachaBetaTimeline.cometAppear).z).toBeGreaterThan(heroCloud.z);
+ expect(getCometPosition(pierceTime).z).toBeCloseTo(heroCloud.z);
+ expect(getCometPosition(gachaBetaTimeline.pierceEnd).z).toBeLessThan(heroCloud.z);
+ });
+
+ test('comet is hidden behind the cloud: projected inside its silhouette', () => {
+ const seconds = gachaBetaTimeline.pierceStart;
+ const camera = getCamera(seconds);
+ const comet = project(getCometPosition(seconds), camera, 640, 360);
+ const cloud = project(heroCloud, camera, 640, 360);
+ expect(comet && cloud).toBeTruthy();
+ if (comet && cloud) {
+ expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeLessThan(heroCloud.radius * cloud.scale);
+ expect(comet.depth).toBeGreaterThan(cloud.depth);
+ }
+ });
+
+ test('camera dollies toward the cloud, then chases the comet and keeps it in frame', () => {
+ expect(getCamera(1).z).toBeGreaterThan(getCamera(0).z);
+ for (const seconds of [3, 3.5, 4, 4.5]) {
+ const camera = getCamera(seconds);
+ const comet = project(getCometPosition(seconds), camera, 640, 360);
+ expect(comet).not.toBeNull();
+ if (comet) {
+ expect(comet.x).toBeGreaterThan(640 * 0.2);
+ expect(comet.x).toBeLessThan(640 * 0.8);
+ expect(comet.y).toBeGreaterThan(360 * 0.2);
+ expect(comet.y).toBeLessThan(360 * 0.8);
+ }
+ }
+ });
+
+ test('camera never moves backward', () => {
+ let previous = -Infinity;
+ for (let seconds = 0; seconds <= gachaBetaTimeline.flightEnd; seconds += 0.05) {
+ const {z} = getCamera(seconds);
+ expect(z).toBeGreaterThanOrEqual(previous - 1e-6);
+ previous = z;
+ }
+ });
+
+ test('projection drops points behind the camera', () => {
+ expect(project({x: 0, y: 0, z: 10}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360)).toBeNull();
+ const centre = project({x: 0, y: 0, z: 500}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360);
+ expect(centre).toMatchObject({x: 320, y: 180});
+ });
+
+ test('comet glow builds to full by the burst', () => {
+ expect(getCometGlow(0)).toBe(0);
+ expect(getCometGlow(3)).toBeGreaterThan(getCometGlow(2));
+ expect(getCometGlow(gachaBetaTimeline.flightEnd)).toBeCloseTo(1);
+ });
+
+ test('shake fires at the pierce and the burst only', () => {
+ expect(getBetaShake(1, 360)).toEqual({x: 0, y: 0});
+ expect(Math.hypot(...Object.values(getBetaShake(pierceTime + 0.05, 360)))).toBeGreaterThan(0);
+ expect(Math.hypot(...Object.values(getBetaShake(gachaBetaTimeline.flightEnd + 0.05, 360)))).toBeGreaterThan(0);
+ });
+
+ test('ranks map rarity to S, SS, SSS', () => {
+ expect([3, 4, 5].map((rarity) => getBetaRank(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['S', 'SS', 'SSS']);
+ });
+});
diff --git a/renderer/test/gacha-route.test.js b/renderer/test/gacha-route.test.js
index 035e509..e88f822 100644
--- a/renderer/test/gacha-route.test.js
+++ b/renderer/test/gacha-route.test.js
@@ -157,4 +157,33 @@ 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};
+ },
+ },
+ );
+
+ 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}});
+
+ 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']);
+
+ await app.close();
+ });
});