feat(gacha): rebuild the beta wish as a meteor shower reveal

The beta wish now renders an 8-second night sky over a snow-rimmed
mountain ridge, where coloured meteors glide down the diagonal. A hero
meteor in the rarity colour slows and burns out at the centre, where the
label appears under rank SSS. The toon cloud, comet, and perspective
camera are removed. The /api/gachabeta contract is unchanged.
This commit is contained in:
tiennm99 committed 2026-10-01 16:07:46 +07:00
1 parent adea684a2f
commit b8d5544fd6
5 files changed
+370 -761

No files matched your search

+7 -11
View File
@@ -67,19 +67,15 @@ 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 with toon-shaded clouds and stars, without outlines, seen
through a perspective action camera:
1. The sky fades in around a main cloud. A comet approaches from behind,
lights the cloud's rim, then punches through it with scattered puffs and an impact ring.
2. The camera follows the flying star as its light and lens flare intensify,
ending in a starburst and white flash.
3. The food label appears inside an astrology ring with rank `SSS`.
`/api/gacha`, rendering an 8-second beta style instead, modelled on the meteor
shot of a music video: white, teal, green, red, and blue meteors glide slowly
down a starry night sky over a snow-rimmed mountain ridge. A hero meteor in the
rarity colour (blue 3★, purple 4★, gold 5★) joins the volley, slows, and burns
out in a glint at the centre, where the food label appears under rank `SSS`.
The caller randomly chooses the food and sends it as `label` (for example,
`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` still chooses the
comet's blue, purple, or gold palette. `/api/gacha` keeps its existing ranks.
`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` only chooses the hero
meteor's colour. `/api/gacha` keeps its existing ranks.
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
+231 -510
View File
@@ -1,83 +1,23 @@
import {useMemo} from 'react';
import {Star} from '@remotion/shapes';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {
gachaBetaTimeline,
getBetaRank,
getBetaShake,
getCamera,
getCometGlow,
getCometPosition,
heroCloud,
pierceTime,
project,
getBurnGlint,
getHeroMeteor,
getSkyMeteorHead,
meteorAngleDegrees,
revealAnchor,
skyMeteors,
} from './gacha-beta-scene.js';
import {createRandom, createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} from './gacha-timeline.js';
import {createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} from './gacha-timeline.js';
const baseFont =
const sansFont =
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const serifFont = '"Noto Serif", "Cormorant Garamond", Georgia, "Times New Roman", serif';
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
/** A luminous night sky behind flat, outline-free cloud shading. */
const skyBackground =
'radial-gradient(ellipse at 50% 105%, #7886ce 0%, #353f83 32%, #171b49 65%, #090e29 100%)';
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'];
const easeOut = /** @type {const} */ ({...clamp, easing: Easing.out(Easing.cubic)});
/**
* @param {string} hex
@@ -88,43 +28,116 @@ const withAlpha = (hex, alpha) => {
return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
};
/** Narrows from full thickness at the head to a point at the end of the tail. */
const taper = 'polygon(0 0, 100% 46%, 100% 54%, 0 100%)';
/**
* @param {{size: number, fill: string, shade: string}} props
* One meteor: a white-hot head inside a coloured halo, a bright tapered core
* trail, and a wider, softer wisp around it. Drawn at the head and rotated so
* the tail points back up the travel diagonal.
*
* @param {{x: number, y: number, length: number, thickness: number, color: string, core: string, opacity: number}} props
*/
const ToonStar = ({size, fill, shade}) => (
<div style={{height: size, position: 'relative', width: size}}>
<Star
cornerRadius={size * 0.035}
fill={shade}
innerRadius={size * 0.23}
outerRadius={size / 2}
points={5}
style={{height: size, position: 'absolute', width: size}}
const Meteor = ({x, y, length, thickness, color, core, opacity}) => (
<div
style={{
left: x,
opacity,
position: 'absolute',
top: y,
transform: `rotate(${meteorAngleDegrees + 180}deg)`,
transformOrigin: '0 0',
}}
>
<div
style={{
background: `radial-gradient(circle, ${withAlpha(color, 0.35)} 0%, ${withAlpha(color, 0.12)} 40%, transparent 70%)`,
borderRadius: '50%',
height: thickness * 10,
left: -thickness * 5,
position: 'absolute',
top: -thickness * 5,
width: thickness * 10,
}}
/>
<div
style={{
background: `linear-gradient(90deg, ${withAlpha(color, 0.55)}, ${withAlpha(color, 0.18)} 45%, ${withAlpha(color, 0)})`,
clipPath: taper,
filter: `blur(${thickness * 0.5}px)`,
height: thickness * 3.2,
position: 'absolute',
top: -thickness * 1.6,
width: length * 1.1,
}}
/>
<div
style={{
background: `linear-gradient(90deg, ${core} 0%, ${color} 18%, ${withAlpha(color, 0.45)} 55%, ${withAlpha(color, 0)} 100%)`,
clipPath: taper,
height: thickness,
position: 'absolute',
top: -thickness / 2,
width: length,
}}
/>
<div
style={{
background: core,
borderRadius: '50%',
boxShadow: `0 0 ${thickness * 1.5}px ${thickness * 0.5}px ${color}, 0 0 ${thickness * 4}px ${withAlpha(color, 0.6)}`,
height: thickness * 1.15,
left: -thickness * 0.6,
position: 'absolute',
top: -thickness * 0.575,
width: thickness * 3,
}}
/>
<div style={{clipPath: 'polygon(0 0, 100% 0, 100% 42%, 50% 62%, 0 42%)', inset: 0, position: 'absolute'}}>
<Star
cornerRadius={size * 0.035}
fill={fill}
innerRadius={size * 0.23}
outerRadius={size / 2}
points={5}
style={{height: size, width: size}}
/>
</div>
</div>
);
/**
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
* Hairline flourish beside the rank, ending in a dot, after the source
* video's title card.
*
* @param {{height: number, side: -1 | 1, spread: number}} props
*/
const Swash = ({height, side, spread}) => (
<div style={{alignItems: 'center', display: 'flex', flexDirection: side > 0 ? 'row' : 'row-reverse'}}>
<div
style={{
background: `linear-gradient(${side > 0 ? 90 : 270}deg, #ffffff, rgba(255,255,255,0.15))`,
height: 1.5,
width: height * 0.22 * spread,
}}
/>
<div style={{background: '#ffffff', borderRadius: '50%', height: height * 0.012, width: height * 0.012}} />
</div>
);
/**
* 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.
* A snow-rimmed ridge drawn as a huge ellipse, so its crest curves like the
* source shot's wide lens.
*
* @param {GachaRenderRequest} props
* @param {{cx: number, cy: number, rx: number, ry: number, rotate: number, top: string, base: string}} props
*/
const Ridge = ({cx, cy, rx, ry, rotate, top, base}) => (
<div
style={{
background: `linear-gradient(180deg, ${top} 0%, ${base} 14%, ${base} 100%)`,
borderRadius: '50%',
height: ry * 2,
left: cx - rx,
position: 'absolute',
top: cy - ry,
transform: `rotate(${rotate}deg)`,
width: rx * 2,
}}
/>
);
/**
* @param {import('../schemas/gacha-request.js').GachaRenderRequest} props
*/
export const GachaBetaComposition = (props) => {
const frame = useCurrentFrame();
@@ -132,473 +145,181 @@ export const GachaBetaComposition = (props) => {
const seconds = frame / fps;
const tl = gachaBetaTimeline;
const palette = getRarityPalette(props.rarity);
const scene = useMemo(() => createBetaScene(props.seed), [props.seed]);
const stars = useMemo(() => createStarfield(110, 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;
const skyIn = interpolate(seconds, [0, 0.65], [0, 1], clamp);
const revealIn = interpolate(seconds, [tl.cardIn, tl.flashEnd + 0.2], [0, 1], clamp);
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, opacity: skyIn, 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 angle = (index / scene.heroPuffs.length) * Math.PI * 2;
const ox = Math.cos(angle) * push;
const oy = Math.sin(angle) * push;
const opacity = 1 - pierceBurst * (distance < 0.5 ? 1 : 0.65);
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: `rotate(${puff.dx * 12}deg) scale(${1 + pierceBurst * 0.3}, 0.78)`,
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: `perspective(${height}px) rotateY(${Math.sin(seconds * 4) * 32}deg) rotateZ(${seconds * 100}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,
}}
/>
),
});
}
}
if (hero && seconds >= pierceTime && seconds < pierceTime + 0.65) {
const impact = (seconds - pierceTime) / 0.65;
const diameter = heroCloud.radius * hero.scale * (0.4 + impact * 3.5);
drawables.push({
depth: hero.depth - 1,
node: (
<div
key="cloud-impact"
style={{
border: `${Math.max(1, 8 * (1 - impact))}px solid ${palette.core}`,
borderRadius: '50%',
boxShadow: `0 0 ${height * 0.04}px ${palette.glow}`,
height: diameter,
left: hero.x - diameter / 2,
opacity: 1 - impact,
position: 'absolute',
top: hero.y - diameter / 2,
transform: 'rotateX(48deg)',
width: diameter,
}}
/>
),
});
}
drawables.sort((a, b) => b.depth - a.depth);
const rank = getBetaRank();
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);
const fadeIn = interpolate(seconds, [0, tl.fadeInEnd], [0, 1], clamp);
const hero = getHeroMeteor(seconds, width, height);
const glint = getBurnGlint(seconds);
const labelIn = interpolate(seconds, [tl.labelIn, tl.labelIn + 0.6], [0, 1], easeOut);
const rankIn = interpolate(seconds, [tl.rankIn, tl.rankIn + 0.5], [0, 1], easeOut);
const anchor = {x: revealAnchor.x * width, y: revealAnchor.y * height};
return (
<AbsoluteFill style={{background: '#0a0c2c', fontFamily: baseFont, overflow: 'hidden'}}>
<AbsoluteFill style={{background: '#000000', fontFamily: sansFont, overflow: 'hidden'}}>
<AbsoluteFill
style={{
opacity: skyIn,
transform: `translate(${shake.x}px, ${shake.y}px) rotate(${camera.roll}deg) scale(1.18)`,
background: 'linear-gradient(180deg, #040a26 0%, #0a1a46 42%, #1b3a6c 78%, #2f5684 100%)',
opacity: fadeIn,
}}
>
<AbsoluteFill style={{background: skyBackground}} />
<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);
{stars.map((star, index) => {
const size = star.radius * height * 0.8;
return (
<div
key={`star-${index}`}
style={{
background: '#ffffff',
background: '#dfe8ff',
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,
left: star.x * width,
opacity: 0.15 + 0.55 * getTwinkle(seconds, star),
position: 'absolute',
top: star.y * height * 0.7,
top: star.y * height * 0.95,
width: size,
}}
/>
);
})}
{drawables.map((drawable) => drawable.node)}
{skyMeteors.map((meteor, index) => {
const head = getSkyMeteorHead(meteor, seconds, width, height);
return head ? (
<Meteor
color={meteor.color}
core="#ffffff"
key={`meteor-${index}`}
length={meteor.length * height}
opacity={1 - 0.7 * labelIn}
thickness={meteor.width * height}
x={head.x}
y={head.y}
/>
) : null;
})}
{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(ellipse, ${withAlpha(palette.deep, 0.9)}, #0a0c2cee)`, opacity: revealIn}}
>
<div
style={{
background: `repeating-conic-gradient(from ${seconds * 4}deg, ${withAlpha(palette.glow, 0.16)} 0deg 5deg, transparent 5deg 30deg)`,
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,
}}
{hero.visible > 0 ? (
<Meteor
color={palette.glow}
core={palette.core}
length={height * 0.75 * hero.tail}
opacity={hero.visible}
thickness={height * 0.024}
x={hero.x}
y={hero.y}
/>
{scene.confetti.map((piece, index) => {
const y = ((piece.offset + (seconds - tl.cardIn) * piece.speed) % 1.2) - 0.1;
const size = piece.size * height;
return (
) : null}
<Ridge base="#0d1d3d" cx={width * 0.5} cy={height * 1.62} rotate={0} rx={width * 0.75} ry={height * 0.66} top="#1c3560" />
<Ridge
base="#1b3762"
cx={-width * 0.04}
cy={height * 1.1}
rotate={-8}
rx={width * 0.32}
ry={height * 0.32}
top="#8fb0d6"
/>
<Ridge
base="#1a3560"
cx={width * 1.06}
cy={height * 1.14}
rotate={10}
rx={width * 0.32}
ry={height * 0.32}
top="#88a9d0"
/>
<AbsoluteFill style={{background: 'radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(2,6,22,0.55) 100%)'}} />
{glint > 0 ? (
<>
<div
style={{
background: `radial-gradient(circle, ${palette.core} 0%, ${withAlpha(palette.glow, 0.65)} 22%, transparent 65%)`,
borderRadius: '50%',
height: height * 0.5,
left: anchor.x - height * 0.25,
opacity: glint,
position: 'absolute',
top: anchor.y - height * 0.25,
width: height * 0.5,
}}
/>
{[0, 90, 45, 135].map((rayAngle, index) => (
<div
key={`confetti-${index}`}
key={`glint-${rayAngle}`}
style={{
left: piece.x * width,
background: `linear-gradient(90deg, transparent, ${palette.core}, transparent)`,
height: 2,
left: anchor.x - height * 0.4,
opacity: glint * (index < 2 ? 1 : 0.5),
position: 'absolute',
top: y * height,
transform: `rotate(${seconds * piece.spin}deg)`,
}}
>
<ToonStar fill="#ffffff" shade={confettiColors[piece.hue] ?? '#ffd36b'} size={size} />
</div>
);
})}
<div
style={{
border: `1px solid ${withAlpha(palette.core, 0.35)}`,
borderRadius: '50%',
height: height * 0.8,
left: width / 2 - height * 0.4,
position: 'absolute',
top: height * 0.5 - height * 0.4,
transform: `rotate(${seconds * 5}deg)`,
width: height * 0.8,
}}
>
{Array.from({length: 12}, (_, index) => (
<div
key={index}
style={{
background: palette.core,
height: height * 0.018,
left: '50%',
opacity: 0.65,
position: 'absolute',
top: '50%',
transform: `rotate(${index * 30}deg) translateY(${-height * 0.4}px)`,
width: 2,
top: anchor.y - 1,
transform: `rotate(${rayAngle}deg) scaleX(${index < 2 ? 1 : 0.5})`,
width: height * 0.8,
}}
/>
))}
</div>
</>
) : null}
{labelIn > 0 ? (
<div
style={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
inset: 0,
justifyContent: 'center',
left: 0,
opacity: labelIn,
position: 'absolute',
transform: `scale(${cardPop})`,
right: 0,
top: anchor.y - height * 0.18,
transform: `translateY(${(1 - labelIn) * height * 0.03}px)`,
}}
>
<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 0 ${height * 0.04}px ${palette.glow})`,
alignItems: 'center',
display: 'flex',
gap: height * 0.02,
opacity: rankIn,
transform: `scale(${0.9 + 0.1 * rankIn})`,
}}
>
{rank}
<Swash height={height} side={-1} spread={rankIn} />
<div
style={{
color: '#ffffff',
fontFamily: serifFont,
fontSize: height * 0.15,
fontStyle: 'italic',
fontWeight: 300,
letterSpacing: height * 0.012,
lineHeight: 1,
textShadow: `0 0 ${height * 0.03}px ${withAlpha(palette.glow, 0.9)}`,
}}
>
{getBetaRank()}
</div>
<Swash height={height} side={1} spread={rankIn} />
</div>
<div
style={{
color: '#ffffff',
fontSize: getLabelFontSize(props.label, height) * 1.1,
fontWeight: 900,
fontSize: getLabelFontSize(props.label, height),
fontWeight: 800,
lineHeight: 1.15,
marginTop: height * 0.03,
marginTop: height * 0.035,
maxWidth: width * 0.8,
overflowWrap: 'anywhere',
textAlign: 'center',
textShadow: `0 0 ${height * 0.03}px ${withAlpha(palette.glow, 0.8)}`,
textShadow: `0 0 ${height * 0.025}px ${withAlpha(palette.glow, 0.85)}, 0 ${height * 0.006}px ${height * 0.02}px #040a26`,
}}
>
{props.label}
</div>
</div>
</AbsoluteFill>
) : null}
{flash > 0 ? <AbsoluteFill style={{background: '#ffffff', opacity: flash}} /> : null}
) : null}
</AbsoluteFill>
</AbsoluteFill>
);
};
+84 -158
View File
@@ -1,67 +1,29 @@
/**
* 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 {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.
* Choreography for the beta wish: coloured meteors glide down a night sky over
* a mountain ridge, a hero meteor in the rarity colour slows and burns out at
* the centre, and the result appears where it died. Kept free of React so the
* motion can be unit tested.
*/
export const gachaBetaTotalSeconds = 8;
/** Seconds from the start of the clip for each beat of the three phases. */
/** Seconds from the start of the clip for each beat. */
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,
fadeInEnd: 0.4,
heroStart: 1.1,
heroBurn: 4.4,
labelIn: 4.5,
rankIn: 4.9,
});
/** 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 hero meteor burns out and the result is centred, as frame fractions. */
export const revealAnchor = Object.freeze({x: 0.5, y: 0.44});
/** The approach behind the cloud, the entry point, and the exit in front. */
const cometHidden = Object.freeze({x: -1050, y: -430, z: 1500});
const cometPierceFrom = Object.freeze({x: 40, y: 20, z: 1300});
const cometPierceTo = Object.freeze({x: 0, y: 30, z: 500});
/** Every meteor travels down and to the right at this angle, as in the source shot. */
export const meteorAngleDegrees = 32;
/** 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);
const angle = (meteorAngleDegrees * Math.PI) / 180;
const direction = Object.freeze({x: Math.cos(angle), y: Math.sin(angle)});
/**
* @param {number} value
@@ -73,132 +35,96 @@ 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 approaches 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,
};
const smoothstep = (t) => {
const x = clamp01(t);
return x * x * (3 - 2 * x);
};
/**
* 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}
* @typedef {object} SkyMeteor
* @property {number} start Seconds at which the head is at its entry point.
* @property {number} x Entry point, fraction of width.
* @property {number} y Entry point, fraction of height.
* @property {number} speed Frame heights per second along the travel direction.
* @property {number} length Tail length in frame heights.
* @property {number} width Tail thickness in frame heights.
* @property {string} color Halo and tail colour; every head burns white.
*/
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.
* Background meteors in the source shot's colours. They enter from the upper
* left in a loose staggered volley and keep gliding slowly for the whole clip.
*/
export const skyMeteors = /** @type {readonly SkyMeteor[]} */ (
Object.freeze([
{start: 0, x: 0.36, y: -0.08, speed: 0.1, length: 0.5, width: 0.011, color: '#eef4ff'},
{start: 0, x: 0.1, y: -0.02, speed: 0.11, length: 0.34, width: 0.014, color: '#52e07a'},
{start: 0.2, x: -0.04, y: 0.12, speed: 0.12, length: 0.42, width: 0.018, color: '#9ff5e4'},
{start: 0.9, x: -0.06, y: -0.05, speed: 0.12, length: 0.46, width: 0.016, color: '#f4f8ff'},
{start: 1.3, x: 0.17, y: -0.09, speed: 0.1, length: 0.32, width: 0.012, color: '#ff8a7a'},
{start: 1.6, x: -0.1, y: 0.06, speed: 0.13, length: 0.5, width: 0.015, color: '#6f9cff'},
{start: 2.6, x: 0.55, y: -0.1, speed: 0.11, length: 0.4, width: 0.01, color: '#ffe6a8'},
{start: 3.2, x: -0.08, y: -0.08, speed: 0.12, length: 0.38, width: 0.012, color: '#5fe8d0'},
])
);
/**
* Head position of a background meteor in pixels, or null before it enters.
*
* @param {Vec3} point
* @param {Camera} camera
* @param {SkyMeteor} meteor
* @param {number} seconds
* @param {number} width
* @param {number} height
* @returns {Projection | null}
* @returns {{x: number, y: number} | null}
*/
export const project = (point, camera, width, height) => {
const depth = point.z - camera.z;
if (depth < 30) {
export const getSkyMeteorHead = (meteor, seconds, width, height) => {
if (seconds < meteor.start) {
return null;
}
const focal = height * 1.2;
const scale = focal / depth;
const travel = (seconds - meteor.start) * meteor.speed * height;
return {x: meteor.x * width + direction.x * travel, y: meteor.y * height + direction.y * travel};
};
/** How far up the diagonal the hero meteor enters, in frame heights from the anchor. */
const heroReach = 0.72;
/**
* The hero meteor streaks down the same diagonal as the volley, slowing until
* it burns out on the reveal anchor.
*
* @param {number} seconds
* @param {number} width
* @param {number} height
* @returns {{x: number, y: number, visible: number, tail: number}}
* `visible` fades the meteor out as it burns; `tail` shrinks its trail as
* it slows.
*/
export const getHeroMeteor = (seconds, width, height) => {
const tl = gachaBetaTimeline;
const t = clamp01((seconds - tl.heroStart) / (tl.heroBurn - tl.heroStart));
const remaining = heroReach * height * (1 - t) ** 2.4;
return {
x: width / 2 + (point.x - camera.x) * scale,
y: height / 2 + (point.y - camera.y) * scale,
scale,
depth,
x: revealAnchor.x * width - direction.x * remaining,
y: revealAnchor.y * height - direction.y * remaining,
visible: seconds < tl.heroStart ? 0 : 1 - smoothstep((seconds - (tl.heroBurn - 0.15)) / 0.3),
tail: 1 - 0.75 * t,
};
};
/**
* Comet glow from 0 to 1: it ignites behind the cloud, then builds steadily
* through the flight until the burst.
* Brightness of the burn-out glint where the hero meteor dies, peaking at the
* burn and fading as the result settles.
*
* @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);
export const getBurnGlint = (seconds) => {
const elapsed = seconds - gachaBetaTimeline.heroBurn;
if (elapsed < -0.2 || elapsed > 1) {
return 0;
}
return 0.35 + 0.65 * clamp01((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd)) ** 2;
return elapsed < 0 ? 1 + elapsed / 0.2 : (1 - elapsed) ** 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};
};
/** The beta food wish always reveals SSS; rarity still controls its light palette. */
/** The beta food wish always reveals SSS; rarity still controls the hero meteor's colour. */
export const getBetaRank = () => 'SSS';
+1 -1
View File
@@ -24,5 +24,5 @@ const createGachaRenderer = (compositionId) => (inputProps, options) =>
export const renderGachaVideo = createGachaRenderer('GachaWish');
/** The beta wish: toon-shaded 3D-perspective sky, comet through a cloud, SSS reveal. */
/** The beta wish: a meteor shower over a mountain ridge, ending in an SSS reveal. */
export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish');
+47 -81
View File
@@ -3,101 +3,67 @@ import {
gachaBetaTimeline,
gachaBetaTotalSeconds,
getBetaRank,
getBetaShake,
getCamera,
getCometGlow,
getCometPosition,
heroCloud,
pierceTime,
project,
getBurnGlint,
getHeroMeteor,
getSkyMeteorHead,
meteorAngleDegrees,
revealAnchor,
skyMeteors,
} from '../src/remotion/gacha-beta-scene.js';
describe('gacha beta scene', () => {
test('phases run in order inside the clip', () => {
const width = 640;
const height = 360;
describe('gacha beta meteor scene', () => {
test('beats run in order and the result holds before the clip ends', () => {
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);
expect(tl.fadeInEnd).toBeLessThan(tl.heroStart);
expect(tl.heroStart).toBeLessThan(tl.heroBurn);
expect(tl.heroBurn).toBeLessThan(tl.labelIn);
expect(tl.labelIn).toBeLessThan(tl.rankIn);
expect(tl.rankIn + 2).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('approaching comet is visible beside the cloud before passing behind it', () => {
const seconds = 1.4;
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(comet.x).toBeGreaterThan(0);
expect(comet.x).toBeLessThan(640);
expect(comet.y).toBeGreaterThan(0);
expect(comet.y).toBeLessThan(360);
expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeGreaterThan(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('background meteors appear on their cue and glide down to the right', () => {
for (const meteor of skyMeteors) {
expect(getSkyMeteorHead(meteor, meteor.start - 0.01, width, height)).toBeNull();
const a = getSkyMeteorHead(meteor, meteor.start + 0.5, width, height);
const b = getSkyMeteorHead(meteor, meteor.start + 1.5, width, height);
expect(a && b).toBeTruthy();
if (a && b) {
expect(b.x).toBeGreaterThan(a.x);
expect(b.y).toBeGreaterThan(a.y);
expect((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI).toBeCloseTo(meteorAngleDegrees);
}
}
});
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('the volley stays in the upper sky so the result area is clear at the reveal', () => {
for (const meteor of skyMeteors) {
const head = getSkyMeteorHead(meteor, gachaBetaTimeline.labelIn, width, height);
if (head) {
expect(head.y).toBeLessThan(height * 0.62);
}
}
});
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('hero meteor is hidden before its cue, then slows to burn out on the anchor', () => {
const tl = gachaBetaTimeline;
expect(getHeroMeteor(tl.heroStart - 0.1, width, height).visible).toBe(0);
const early = getHeroMeteor(tl.heroStart + 0.5, width, height);
const mid = getHeroMeteor(tl.heroStart + 1, width, height);
const late = getHeroMeteor(tl.heroBurn - 0.5, width, height);
const late2 = getHeroMeteor(tl.heroBurn, width, height);
expect(Math.hypot(mid.x - early.x, mid.y - early.y)).toBeGreaterThan(Math.hypot(late2.x - late.x, late2.y - late.y));
expect(late2.x).toBeCloseTo(revealAnchor.x * width);
expect(late2.y).toBeCloseTo(revealAnchor.y * height);
expect(getHeroMeteor(tl.heroBurn + 0.2, width, height).visible).toBe(0);
});
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('burn glint fires only around the burn-out', () => {
expect(getBurnGlint(gachaBetaTimeline.heroBurn - 1)).toBe(0);
expect(getBurnGlint(gachaBetaTimeline.heroBurn)).toBe(1);
expect(getBurnGlint(gachaBetaTimeline.heroBurn + 2)).toBe(0);
});
test('beta food reveal displays SSS', () => {