feat(gacha): add /api/gachabeta toon-shaded wish style

The beta wish renders an 8-second astrology-themed night sky in toon
shading through a perspective action camera: it dollies toward a hero
cloud, a comet lights the cloud's rim from behind and bursts through it,
the camera chases the comet as its flare builds to a starburst, and the
label appears with an S, SS, or SSS rank. It shares the /api/gacha request
contract and render slots.
This commit is contained in:
tiennm99 committed 2026-10-01 15:07:56 +07:00
1 parent dccd524f9d
commit d2f09ecb33
11 files changed
+934 -12

No files matched your search

+10
View File
@@ -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
+15
View File
@@ -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();
}
+10 -1
View File
@@ -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}));
@@ -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}) => (
<svg height={size} style={{display: 'block'}} viewBox="0 0 24 24" width={size}>
<path d="M12 0.8l3.2 7.9 8.3.6-6.4 5.3 2.1 8.2L12 18.3l-7.2 4.5 2.1-8.2L0.5 9.3l8.3-.6z" fill={shade} />
<path d="M12 0.8l3.2 7.9 8.3.6-6.4 5.3L12 14.2z" fill={fill} />
<path d="M12 0.8L8.8 8.7l-8.3.6 6.4 5.3L12 14.2z" fill={fill} opacity={0.82} />
</svg>
);
/**
* @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: (
<div
key={`cloud-${index}`}
style={{left: screen.x, opacity: Math.min(1, 900 / screen.depth + 0.25), position: 'absolute', top: screen.y}}
>
{cloud.puffs.map((puff, puffIndex) => (
<div
key={puffIndex}
style={{
background: cloudShade,
borderRadius: '50%',
height: puff.r * radius * 2,
left: puff.dx * radius - puff.r * radius,
position: 'absolute',
top: puff.dy * radius - puff.r * radius,
width: puff.r * radius * 2,
}}
/>
))}
</div>
),
});
}
const hero = project(heroCloud, camera, width, height);
if (hero) {
const radius = heroCloud.radius * hero.scale;
drawables.push({
depth: hero.depth,
node: (
<div key="hero" style={{left: hero.x, position: 'absolute', top: hero.y}}>
{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 (
<div
key={index}
style={{
background: heroShade,
borderRadius: '50%',
height: puff.r * radius * 2,
left: (puff.dx + ox) * radius - puff.r * radius,
opacity,
position: 'absolute',
top: (puff.dy + oy) * radius - puff.r * radius,
transform: `scale(${1 + pierceBurst * 0.3})`,
width: puff.r * radius * 2,
}}
/>
);
})}
</div>
),
});
}
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(
<div
key={`trail-${index}`}
style={{
background: `radial-gradient(circle, ${palette.core} 0% 30%, ${palette.glow} 30% 62%, ${withAlpha(palette.glow, 0.35)} 62% 100%)`,
borderRadius: '50%',
height: radius * 2,
left: point.x - radius,
opacity: fade,
position: 'absolute',
top: point.y - radius,
width: radius * 2,
}}
/>,
);
}
const headSize = (34 + 60 * glow + 90 * burst) * cometScreen.scale;
const flare = headSize * (1.4 + glow * 3);
drawables.push({
depth: cometScreen.depth,
node: (
<div key="comet" style={{inset: 0, position: 'absolute'}}>
{trail}
<div
style={{
background: `radial-gradient(circle, ${withAlpha(palette.glow, 0.7)} 0% 35%, ${withAlpha(palette.glow, 0.3)} 35% 60%, transparent 60%)`,
borderRadius: '50%',
height: headSize * 3.2,
left: cometScreen.x - headSize * 1.6,
position: 'absolute',
top: cometScreen.y - headSize * 1.6,
width: headSize * 3.2,
}}
/>
{[0, 90, 45, 135].map((angle, index) => (
<div
key={`flare-${angle}`}
style={{
background: `linear-gradient(90deg, transparent 0%, ${palette.core} 50%, transparent 100%)`,
clipPath: 'polygon(0% 50%, 50% 0%, 100% 50%, 50% 100%)',
height: Math.max(3, headSize * (index < 2 ? 0.16 : 0.09)),
left: cometScreen.x - (flare * (index < 2 ? 1 : 0.6)) / 2,
opacity: index < 2 ? 1 : glow,
position: 'absolute',
top: cometScreen.y - Math.max(1.5, headSize * (index < 2 ? 0.08 : 0.045)),
transform: `rotate(${angle + seconds * 20}deg)`,
width: flare * (index < 2 ? 1 : 0.6),
}}
/>
))}
<div
style={{
left: cometScreen.x - headSize / 2,
position: 'absolute',
top: cometScreen.y - headSize / 2,
transform: `rotate(${seconds * 140}deg)`,
}}
>
<ToonStar fill="#ffffff" shade={palette.core} size={headSize} />
</div>
{burst > 0 ? (
<div
style={{
background: `repeating-conic-gradient(from ${seconds * 60}deg, #ffffff 0deg 9deg, ${palette.glow} 9deg 22.5deg)`,
clipPath:
'polygon(50% 0%, 58% 34%, 85% 15%, 66% 42%, 100% 50%, 66% 58%, 85% 85%, 58% 66%, 50% 100%, 42% 66%, 15% 85%, 34% 58%, 0% 50%, 34% 42%, 15% 15%, 42% 34%)',
height: height * 2.2 * burst,
left: cometScreen.x - height * 1.1 * burst,
position: 'absolute',
top: cometScreen.y - height * 1.1 * burst,
width: height * 2.2 * burst,
}}
/>
) : null}
</div>
),
});
}
// 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: (
<div
key="backlight"
style={{
background: `radial-gradient(circle, ${withAlpha(palette.core, 0.95)} 0% 40%, ${withAlpha(palette.glow, 0.75)} 40% 62%, ${withAlpha(palette.glow, 0.25)} 62% 80%, transparent 80%)`,
borderRadius: '50%',
height: radius * 2,
left: light.x - radius,
opacity: backlight,
position: 'absolute',
top: light.y - radius,
width: radius * 2,
}}
/>
),
});
}
}
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 (
<AbsoluteFill style={{background: '#0a0c2c', fontFamily: baseFont, overflow: 'hidden'}}>
<AbsoluteFill
style={{
transform: `translate(${shake.x}px, ${shake.y}px) rotate(${camera.roll}deg) scale(1.18)`,
}}
>
<AbsoluteFill style={{background: skyBands}} />
<svg
height={height}
style={{left: 0, opacity: 0.32, position: 'absolute', top: 0}}
viewBox={`0 0 ${width} ${height}`}
width={width}
>
<g transform={`translate(${width / 2 - camera.x * 0.03} ${height * 0.4}) rotate(${seconds * 6})`}>
<circle fill="none" r={height * 0.46} stroke="#aab4ff" strokeWidth={1.5} />
<circle fill="none" r={height * 0.38} stroke="#aab4ff" strokeDasharray="2 6" strokeWidth={1} />
{Array.from({length: 12}, (_, index) => {
const angle = (index / 12) * Math.PI * 2;
return (
<g key={`tick-${index}`}>
<line
stroke="#aab4ff"
strokeWidth={1.5}
x1={Math.cos(angle) * height * 0.38}
x2={Math.cos(angle) * height * 0.46}
y1={Math.sin(angle) * height * 0.38}
y2={Math.sin(angle) * height * 0.46}
/>
<circle
cx={Math.cos(angle + 0.26) * height * 0.42}
cy={Math.sin(angle + 0.26) * height * 0.42}
fill="#d8dcff"
r={2.2}
/>
</g>
);
})}
</g>
{scene.links.map(([a, b], index) => {
const from = scene.stars[a ?? 0];
const to = scene.stars[b ?? 0];
if (!from || !to) {
return null;
}
return (
<line
key={`link-${index}`}
stroke="#c8d0ff"
strokeWidth={1}
x1={from.x * width}
x2={to.x * width}
y1={from.y * height * 0.7}
y2={to.y * height * 0.7}
/>
);
})}
</svg>
{scene.stars.map((star, index) => {
const twinkle = getTwinkle(seconds, star);
const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle);
return (
<div
key={`star-${index}`}
style={{
background: '#ffffff',
borderRadius: '50%',
boxShadow: twinkle > 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 (
<div
key={`speed-${index}`}
style={{
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.75))',
height: 2,
left: cometScreen.x,
opacity: speedLines * 0.7,
position: 'absolute',
top: cometScreen.y,
transform: `rotate(${angle}deg) translateX(${height * 0.25 + reach}px)`,
transformOrigin: '0 50%',
width: width * 0.18,
}}
/>
);
})
: null}
</AbsoluteFill>
{cardVisible ? (
<AbsoluteFill
style={{background: `radial-gradient(circle, ${palette.deep} 0% 30%, #120f3a 30% 60%, #0a0c2c 60%)`}}
>
<div
style={{
background: `repeating-conic-gradient(from ${seconds * 12}deg, ${withAlpha(palette.glow, 0.32)} 0deg 10deg, transparent 10deg 20deg)`,
borderRadius: '50%',
height: width * 1.5,
left: width / 2 - width * 0.75,
position: 'absolute',
top: height * 0.42 - width * 0.75,
width: width * 1.5,
}}
/>
{scene.confetti.map((piece, index) => {
const y = ((piece.offset + (seconds - tl.cardIn) * piece.speed) % 1.2) - 0.1;
const size = piece.size * height;
return (
<div
key={`confetti-${index}`}
style={{
left: piece.x * width,
position: 'absolute',
top: y * height,
transform: `rotate(${seconds * piece.spin}deg)`,
}}
>
<ToonStar fill="#ffffff" shade={confettiColors[piece.hue] ?? '#ffd36b'} size={size} />
</div>
);
})}
<div
style={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
inset: 0,
justifyContent: 'center',
position: 'absolute',
transform: `scale(${cardPop})`,
}}
>
<div
style={{
background: 'linear-gradient(180deg, #fffbe6 0% 40%, #ffd36b 40% 70%, #f0a92c 70%)',
color: 'transparent',
fontSize: height * 0.3,
fontStyle: 'italic',
fontWeight: 900,
letterSpacing: height * 0.005,
lineHeight: 1,
opacity: rankPop > 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}
</div>
<div
style={{
color: '#ffffff',
fontSize: getLabelFontSize(props.label, height) * 1.1,
fontWeight: 900,
lineHeight: 1.15,
marginTop: height * 0.03,
maxWidth: width * 0.8,
overflowWrap: 'anywhere',
textAlign: 'center',
textShadow: `0 ${height * 0.01}px 0 ${palette.deep}, 0 0 ${height * 0.03}px ${withAlpha(palette.glow, 0.8)}`,
}}
>
{props.label}
</div>
</div>
</AbsoluteFill>
) : null}
{flash > 0 ? <AbsoluteFill style={{background: '#ffffff', opacity: flash}} /> : null}
</AbsoluteFill>
);
};
+15 -2
View File
@@ -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}
/>
<Composition
calculateMetadata={({props}) => 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}
/>
</>
);
+212
View File
@@ -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<GachaRarity, 'S' | 'SS' | 'SSS'>} */
const betaRanks = {3: 'S', 4: 'SS', 5: 'SSS'};
/**
* @param {GachaRarity} rarity
* @returns {'S' | 'SS' | 'SSS'}
*/
export const getBetaRank = (rarity) => betaRanks[rarity];
+12 -7
View File
@@ -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<RenderGachaResult>}
* @param {string} compositionId
* @returns {(inputProps: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<RenderGachaResult>}
*/
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');
+2 -1
View File
@@ -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'});
}
+8 -1
View File
@@ -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;
};
+90
View File
@@ -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']);
});
});
+29
View File
@@ -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();
});
});