mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
dccd524f9d
commit
d2f09ecb33
11 files changed
+934
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -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];
|
||||
@@ -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');
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user