feat(genshin): serve the Remotion meteor wish on /api/genshin

Brings back the Genshin-style meteor wish that /api/gacha rendered before pack-cards, under genshin names. It takes the same request as /api/gacha and returns the 7-second landscape MP4.
This commit is contained in:
tiennm99 committed 2026-10-01 23:37:27 +07:00
1 parent b7a4336a7b
commit 59f597016b
12 files changed
+1231 -8

No files matched your search

+21 -5
View File
@@ -1,7 +1,8 @@
# wheelofnames
Self-hosted API that renders wheel-of-names GIF animations with Remotion and
gacha wish MP4 animations with pack-cards.
Self-hosted API that renders wheel-of-names GIF animations and Genshin-style
meteor wish MP4 animations with Remotion, and card-pack gacha wish MP4
animations with pack-cards.
## API
@@ -65,7 +66,7 @@ picks a random one, so every roll looks different. The caller chooses the
result and its rarity — the service only draws it.
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
with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. All routes share the
`MAX_CONCURRENT_RENDERS` slots. No game assets are used.
pack-cards animates on the browser clock, so the wish does not use Remotion
@@ -83,6 +84,21 @@ to a commit. A moving branch URL would change the tarball's checksum and break
`npm ci` against the lockfile, and the Docker image has no `git` for a git
dependency.
### Genshin wish
`POST /api/genshin` takes the same body as `/api/gacha` and returns the same
response, rendering a 7-second landscape wish in the style of a gacha game: a
meteor coloured by rarity (blue 3★, purple 4★, gold 5★) flies in from the left
across a night sky and bursts in a white flash where the rank emblem appears,
and the label is revealed beside a rank emblem (`B`, `A`, `S`) with its stars
popping in. Each tier is louder than the one below: 4★ adds a bigger meteor, a
lens flare, impact shake, and a double shockwave; 5★ adds a rainbow sunburst
before landing, a gold sky flood, a starburst, counter rotating rays, falling
sparkles, and a sheen across the emblem. The per-tier table lives in
`src/remotion/genshin-timeline.js`. `width` is `640` (360 tall) or `854` (480
tall), and `seed` lays out the twinkling stars and particles. All visuals are
drawn procedurally; no game assets are used.
## Local
Install dependencies and Chromium once:
@@ -101,7 +117,7 @@ npm run dev
### Generate GIF files locally
Generate the quick smoke fixtures at the git-ignored paths
`fixtures/smoke.gif` and `fixtures/gacha-5-star.mp4`:
`fixtures/smoke.gif`, `fixtures/genshin-5-star.mp4`, and `fixtures/gacha-5-star.mp4`:
```sh
npm run render:smoke
@@ -109,7 +125,7 @@ npm run render:smoke
Generate the complete fixture set at `fixtures/smoke.gif`,
`fixtures/vietnamese.gif`, `fixtures/sixteen-options.gif`, and
`fixtures/gacha-{3,4,5}-star.mp4`:
`fixtures/genshin-{3,4,5}-star.mp4`, and `fixtures/gacha-{3,4,5}-star.mp4`:
```sh
npm run render:fixtures
+3
View File
@@ -46,3 +46,6 @@ The bot derives the endpoint from `WHEELOFNAMES_API_URL` by replacing its last
path segment (`/api/gif` becomes `/api/gacha`), picks the result and rarity
itself, and sends the MP4 with `sendAnimation`. On any failure it falls back to
a text reply.
The unlisted `/genshin` command works the same way against `POST /api/genshin`
and sends the 7-second 640x360 meteor wish.
+15
View File
@@ -65,6 +65,21 @@ try {
}
console.log(`api-smoke gacha: ${gacha.rawPayload.byteLength} bytes`);
const genshin = await app.inject({
method: 'POST',
url: '/api/genshin',
headers: {
authorization: 'Bearer smoke-token',
},
payload: {label: 'Bún bò', rarity: 5},
});
if (genshin.statusCode !== 200 || genshin.rawPayload.subarray(4, 8).toString() !== 'ftyp') {
throw new Error(`Expected genshin MP4, got ${genshin.statusCode}: ${genshin.body.slice(0, 200)}`);
}
console.log(`api-smoke genshin: ${genshin.rawPayload.byteLength} bytes`);
} finally {
await app.close();
}
+6
View File
@@ -1,6 +1,7 @@
import {mkdir, writeFile} from 'node:fs/promises';
import path from 'node:path';
import {closeGachaBrowser, renderGachaVideo} from '../src/render/render-gacha.js';
import {renderGenshinVideo} from '../src/render/render-genshin.js';
import {renderWheelGif} from '../src/render/render-gif.js';
const smoke = process.argv.includes('--smoke');
@@ -69,6 +70,11 @@ for (const fixture of smoke ? fixtures.slice(0, 1) : fixtures) {
await save(`${fixture.name}.gif`, await renderWheelGif(fixture.request, {timeoutInMilliseconds: 30000}));
}
for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) {
const name = fixture.name.replace('gacha', 'genshin');
await save(`${name}.mp4`, await renderGenshinVideo(fixture.request, {timeoutInMilliseconds: 30000}));
}
try {
for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) {
await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 60000}));
@@ -0,0 +1,698 @@
import {useMemo} from 'react';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {
createRandom,
createStarfield,
genshinTimeline,
getLabelFontSize,
emblemCenter,
getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
getStarRevealTimes,
getTwinkle,
getTierEffects,
starColor,
} from './genshin-timeline.js';
const baseFont =
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const trailSpacing = 0.011;
const starburstSpikes = 10;
/**
* Every decorative particle for one roll. The request seed picks the layout,
* so each roll scatters its stars differently while every frame of one
* render stays consistent.
*
* @param {number} seed
*/
const createParticles = (seed) => ({
skyStars: createStarfield(70, seed),
meteorSparks: createMeteorSparks(seed + 1),
revealMotes: createRevealMotes(seed + 2),
rainSparkles: createRainSparkles(seed + 3),
});
/**
* Sparks shed by the meteor. Each is born at a point along the fall and
* drifts away from it while fading. Tiers draw a prefix of this pool.
*
* @param {number} seed
*/
function createMeteorSparks(seed) {
const random = createRandom(seed);
return Array.from({length: 80}, () => ({
born: random(),
driftX: (random() - 0.5) * 0.1,
driftY: (random() - 0.2) * 0.1,
size: 0.006 + random() * 0.012,
}));
}
/**
* Motes that float upward behind the revealed item.
*
* @param {number} seed
*/
function createRevealMotes(seed) {
const random = createRandom(seed);
return Array.from({length: 56}, () => ({
x: random(),
y: random(),
speed: 0.03 + random() * 0.07,
size: 0.004 + random() * 0.009,
phase: random() * Math.PI * 2,
}));
}
/**
* Sparkles that rain down across the 5★ reveal.
*
* @param {number} seed
*/
function createRainSparkles(seed) {
const random = createRandom(seed);
return Array.from({length: 40}, () => ({
x: random(),
offset: random(),
speed: 0.25 + random() * 0.35,
size: 0.025 + random() * 0.03,
spin: (random() - 0.5) * 360,
}));
}
const rainbowConic = 'conic-gradient(from 0deg, #ff6b6b, #ffd36b, #8cff9a, #6bd6ff, #b48cff, #ff8cd8, #ff6b6b)';
/** Fades the 5★ rainbow sunburst from a bright core out to its ray tips. */
const sunburstFade = 'radial-gradient(circle, black 0%, black 18%, rgba(0, 0, 0, 0.6) 40%, transparent 70%)';
/**
* Two ray layers turning in opposite directions; each ray takes the rainbow
* hue of the angle it points at.
*/
const sunburstLayers = [
{
rays: 16,
spin: 40,
opacity: 1,
mask: 'repeating-conic-gradient(from 0deg, black 0deg 6deg, transparent 6deg 22.5deg)',
},
{
rays: 24,
spin: -60,
opacity: 0.7,
mask: 'repeating-conic-gradient(from 4deg, black 0deg 2deg, transparent 2deg 15deg)',
},
];
/**
* @param {string} hex
* @param {number} alpha
*/
const withAlpha = (hex, alpha) => {
const value = Number.parseInt(hex.slice(1), 16);
return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
};
/**
* @param {{size: number}} props
*/
const StarIcon = ({size}) => (
<svg height={size} viewBox="0 0 24 24" width={size}>
<path
d="M12 1.6l3.1 6.6 7.2.9-5.3 5 1.4 7.1L12 17.7l-6.4 3.5 1.4-7.1-5.3-5 7.2-.9z"
fill={starColor}
stroke="#fff6cf"
strokeLinejoin="round"
strokeWidth={0.8}
/>
</svg>
);
/**
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
*/
/**
* Wish animation: a meteor coloured by rarity falls across a night sky, lands
* in a white flash, and the result is revealed with its rank letter and stars.
* Every beat scales with the tier's effects so higher rarities are louder.
*
* @param {GachaRenderRequest} props
*/
export const GenshinComposition = (props) => {
const frame = useCurrentFrame();
const {fps, width, height} = useVideoConfig();
const seconds = frame / fps;
const palette = getRarityPalette(props.rarity);
const fx = getTierEffects(props.rarity);
const tl = genshinTimeline;
const {skyStars, meteorSparks, revealMotes, rainSparkles} = useMemo(() => createParticles(props.seed), [props.seed]);
const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], {
...clamp,
easing: Easing.in(Easing.quad),
});
const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd;
const head = getMeteorPoint(meteorProgress, width, height);
const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp) * fx.headScale;
const haloOpacity = fx.halo ? interpolate(meteorProgress, [0.45, 0.8], [0, 1], clamp) : 0;
const skyFlood = interpolate(seconds, [tl.meteorStart + 1.2, tl.meteorEnd], [0, fx.skyFlood], clamp);
const flashOpacity = interpolate(seconds, [tl.flashStart, tl.flashPeak, tl.flashHoldEnd, tl.flashEnd], [0, 1, 1, 0], {
...clamp,
easing: Easing.inOut(Easing.quad),
});
const impact = getMeteorPoint(1, width, height);
const bloomRadius = interpolate(seconds, [tl.flashStart, tl.flashPeak], [0.1, 2.2], clamp) * width;
const revealed = seconds >= tl.revealStart;
const shake = getShakeOffset(seconds, tl.revealStart, fx.shake * height);
const emblemSize = height * 0.52;
const emblemX = width * emblemCenter.x;
const emblemY = height * emblemCenter.y;
const emblemScale = interpolate(
seconds,
[tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled],
[0.72, 1 + 0.04 * fx.headScale, 1],
{...clamp, easing: Easing.out(Easing.cubic)},
);
const silhouette = interpolate(seconds, [tl.revealStart + 0.2, tl.emblemSettled], [1, 0], clamp);
const raysOpacity = interpolate(seconds, [tl.revealStart, tl.emblemSettled], [0, fx.raysOpacity], clamp);
const starburstScale = interpolate(seconds, [tl.revealStart, tl.emblemSettled + 0.3], [0.2, 1], {
...clamp,
easing: Easing.out(Easing.cubic),
});
const emblemPulse = 0.5 + 0.5 * Math.sin(seconds * 3);
const sheenProgress = ((((seconds - tl.emblemSettled) / 1.8) % 1) + 1) % 1;
const rankLetter = getRankLetter(props.rarity);
const rankLetterStyle =
props.rarity === 5
? {
background: 'linear-gradient(180deg, #ffffff 0%, #fff6d0 55%, #ffd36b 100%)',
color: 'transparent',
filter: `drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.03}px ${palette.glow})`,
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
}
: {
color: '#ffffff',
textShadow: `0 2px ${height * 0.01}px rgba(0, 0, 0, 0.45), 0 0 ${height * 0.03}px ${palette.glow}`,
};
const nameProgress = interpolate(seconds, [tl.nameIn, tl.nameSettled], [0, 1], {
...clamp,
easing: Easing.out(Easing.cubic),
});
const starSize = height * 0.09;
const starTimes = getStarRevealTimes(props.rarity);
const rayMask = 'radial-gradient(circle, black 8%, transparent 55%)';
return (
<AbsoluteFill style={{background: '#05060f', fontFamily: baseFont, overflow: 'hidden'}}>
{!revealed ? (
<AbsoluteFill
style={{
background: 'linear-gradient(180deg, #070a24 0%, #121a4a 45%, #2a3a7c 80%, #3d4f96 100%)',
}}
>
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 80% 40% at 50% 100%, ${withAlpha('#9bb4ff', 0.35)} 0%, transparent 70%)`,
}}
/>
{skyStars.map((star, index) => {
const twinkle = getTwinkle(seconds, star);
const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle);
const glint = star.glint ? size * 6 * twinkle : 0;
return (
<div
key={`sky-${index}`}
style={{
height: 0,
left: star.x * width,
opacity: 0.2 + 0.8 * twinkle,
position: 'absolute',
top: star.y * height,
width: 0,
}}
>
<div
style={{
background: '#ffffff',
borderRadius: '50%',
boxShadow: twinkle > 0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined,
height: size,
left: -size / 2,
position: 'absolute',
top: -size / 2,
width: size,
}}
/>
{glint > 1 ? (
<>
<div
style={{
background: 'linear-gradient(90deg, transparent, #ffffff, transparent)',
height: 1,
left: -glint / 2,
position: 'absolute',
top: -0.5,
width: glint,
}}
/>
<div
style={{
background: 'linear-gradient(180deg, transparent, #ffffff, transparent)',
height: glint,
left: -0.5,
position: 'absolute',
top: -glint / 2,
width: 1,
}}
/>
</>
) : null}
</div>
);
})}
{skyFlood > 0 ? (
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${(impact.x / width) * 100}% ${(impact.y / height) * 100}%, ${withAlpha(palette.glow, 0.95)} 0%, ${withAlpha(palette.glow, 0.4)} 40%, transparent 80%)`,
mixBlendMode: 'screen',
opacity: skyFlood,
}}
/>
) : null}
{meteorVisible
? Array.from({length: fx.trailSamples}, (_, index) => {
const sampleProgress = meteorProgress - index * trailSpacing;
if (sampleProgress <= 0) {
return null;
}
const point = getMeteorPoint(sampleProgress, width, height);
const fade = 1 - index / fx.trailSamples;
const radius = headRadius * (0.25 + 0.75 * fade ** 1.3);
return (
<div
key={`trail-${index}`}
style={{
background: `radial-gradient(circle, ${withAlpha(palette.glow, 0.85 * fade ** 1.4)} 0%, transparent 70%)`,
height: radius * 2,
left: point.x - radius,
position: 'absolute',
top: point.y - radius,
width: radius * 2,
}}
/>
);
})
: null}
{meteorVisible
? meteorSparks.slice(0, fx.sparks).map((spark, index) => {
if (spark.born > meteorProgress) {
return null;
}
const age = (meteorProgress - spark.born) * 4;
if (age >= 1) {
return null;
}
const origin = getMeteorPoint(spark.born, width, height);
const size = spark.size * height * (1 - age) * fx.headScale;
return (
<div
key={`spark-${index}`}
style={{
background: palette.core,
borderRadius: '50%',
boxShadow: `0 0 ${size * 2}px ${palette.glow}`,
height: size,
left: origin.x + spark.driftX * width * age,
opacity: 1 - age,
position: 'absolute',
top: origin.y + spark.driftY * height * age,
width: size,
}}
/>
);
})
: null}
{meteorVisible && haloOpacity > 0 ? (
<div
style={{
height: headRadius * 9,
left: head.x - headRadius * 4.5,
maskImage: sunburstFade,
opacity: haloOpacity,
position: 'absolute',
top: head.y - headRadius * 4.5,
transform: `scale(${0.5 + 0.5 * haloOpacity})`,
WebkitMaskImage: sunburstFade,
width: headRadius * 9,
}}
>
{sunburstLayers.map((layer) => (
<div
key={layer.rays}
style={{
background: rainbowConic,
borderRadius: '50%',
inset: 0,
maskImage: layer.mask,
opacity: layer.opacity,
position: 'absolute',
transform: `rotate(${seconds * layer.spin}deg)`,
WebkitMaskImage: layer.mask,
}}
/>
))}
</div>
) : null}
{meteorVisible ? (
<>
<div
style={{
background: `radial-gradient(circle, ${palette.core} 0%, ${palette.core} 18%, ${withAlpha(palette.glow, 0.9)} 34%, ${withAlpha(palette.glow, 0.35)} 58%, transparent 72%)`,
height: headRadius * 4,
left: head.x - headRadius * 2,
position: 'absolute',
top: head.y - headRadius * 2,
width: headRadius * 4,
}}
/>
{props.rarity >= 4 ? (
<>
<div
style={{
background: `linear-gradient(90deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
height: Math.max(2, headRadius * 0.12),
left: head.x - headRadius * (props.rarity === 5 ? 6 : 3.5),
position: 'absolute',
top: head.y - Math.max(1, headRadius * 0.06),
width: headRadius * (props.rarity === 5 ? 12 : 7),
}}
/>
<div
style={{
background: `linear-gradient(180deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
height: headRadius * (props.rarity === 5 ? 8 : 4.5),
left: head.x - Math.max(1, headRadius * 0.06),
position: 'absolute',
top: head.y - headRadius * (props.rarity === 5 ? 4 : 2.25),
width: Math.max(2, headRadius * 0.12),
}}
/>
</>
) : null}
</>
) : null}
</AbsoluteFill>
) : null}
{revealed ? (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 70% 90% at 66% 47%, ${palette.deep} 0%, #0d0b1f 62%, #05060f 100%)`,
transform: `translate(${shake.x}px, ${shake.y}px) scale(1.04)`,
}}
>
<div
style={{
background: `repeating-conic-gradient(from 0deg, ${withAlpha(palette.glow, 0.9)} 0deg 4deg, transparent 4deg 18deg)`,
borderRadius: '50%',
height: width * 1.6,
left: emblemX - width * 0.8,
maskImage: rayMask,
opacity: raysOpacity,
position: 'absolute',
top: emblemY - width * 0.8,
transform: `rotate(${seconds * 9}deg)`,
WebkitMaskImage: rayMask,
width: width * 1.6,
}}
/>
{fx.counterRays ? (
<div
style={{
background: `repeating-conic-gradient(from 9deg, ${withAlpha(palette.core, 0.8)} 0deg 2deg, transparent 2deg 24deg)`,
borderRadius: '50%',
height: width * 1.6,
left: emblemX - width * 0.8,
maskImage: rayMask,
opacity: raysOpacity * 0.8,
position: 'absolute',
top: emblemY - width * 0.8,
transform: `rotate(${-seconds * 14}deg)`,
WebkitMaskImage: rayMask,
width: width * 1.6,
}}
/>
) : null}
{fx.starburst
? Array.from({length: starburstSpikes}, (_, index) => {
const long = index % 2 === 0;
const length = height * (long ? 1.1 : 0.7) * starburstScale;
return (
<div
key={`spike-${index}`}
style={{
background: `linear-gradient(90deg, ${withAlpha(palette.core, 0.95)}, ${withAlpha(palette.glow, 0.5)} 40%, transparent)`,
height: Math.max(2, height * (long ? 0.012 : 0.007)),
left: emblemX,
position: 'absolute',
top: emblemY,
transform: `rotate(${(360 / starburstSpikes) * index + seconds * 6}deg)`,
transformOrigin: '0 50%',
width: length,
}}
/>
);
})
: null}
{revealMotes.slice(0, fx.motes).map((mote, index) => {
const y = (((mote.y - (seconds - tl.revealStart) * mote.speed) % 1) + 1) % 1;
const size = mote.size * height;
return (
<div
key={`mote-${index}`}
style={{
background: palette.core,
borderRadius: '50%',
boxShadow: `0 0 ${size * 3}px ${palette.glow}`,
height: size,
left: mote.x * width,
opacity: 0.25 + 0.5 * (0.5 + 0.5 * Math.sin(seconds * 3 + mote.phase)),
position: 'absolute',
top: y * height,
width: size,
}}
/>
);
})}
{rainSparkles.slice(0, fx.sparkleRain).map((sparkle, index) => {
const y = ((sparkle.offset + (seconds - tl.revealStart) * sparkle.speed) % 1.2) - 0.1;
const size = sparkle.size * height;
return (
<div
key={`rain-${index}`}
style={{
background: `linear-gradient(135deg, #fffbe6, ${palette.glow})`,
boxShadow: `0 0 ${size}px ${palette.glow}`,
height: size,
left: sparkle.x * width,
opacity: 0.85,
position: 'absolute',
top: y * height,
clipPath: 'polygon(50% 0%, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0% 50%, 39% 39%)',
transform: `rotate(${seconds * sparkle.spin}deg)`,
width: size,
}}
/>
);
})}
{Array.from({length: fx.shockwaves}, (_, index) => {
const start = tl.revealStart + index * 0.16;
const progress = interpolate(seconds, [start, start + 0.9], [0, 1], {
...clamp,
easing: Easing.out(Easing.quad),
});
if (progress <= 0 || progress >= 1) {
return null;
}
return (
<div
key={`wave-${index}`}
style={{
border: `${Math.max(2, height * (0.01 - index * 0.002))}px solid ${index === 0 ? palette.glow : palette.core}`,
borderRadius: '50%',
boxShadow: `0 0 ${height * 0.04}px ${palette.glow}, inset 0 0 ${height * 0.04}px ${palette.glow}`,
height: emblemSize,
left: emblemX - emblemSize / 2,
opacity: 1 - progress,
position: 'absolute',
top: emblemY - emblemSize / 2,
transform: `scale(${0.4 + progress * (2.4 + index * 0.8)})`,
width: emblemSize,
}}
/>
);
})}
<div
style={{
height: emblemSize,
left: emblemX - emblemSize / 2,
position: 'absolute',
top: emblemY - emblemSize / 2,
transform: `scale(${emblemScale})`,
width: emblemSize,
}}
>
<div
style={{
background: `linear-gradient(135deg, ${palette.emblem})`,
border: `${Math.max(2, height * (props.rarity === 5 ? 0.014 : 0.008))}px solid ${props.rarity === 5 ? '#fff0b8' : 'rgba(255, 255, 255, 0.85)'}`,
borderRadius: emblemSize * 0.12,
boxShadow: `0 0 ${height * (0.04 + 0.03 * fx.headScale * emblemPulse)}px ${palette.glow}, inset 0 0 ${height * 0.04}px rgba(255, 255, 255, 0.55)`,
height: emblemSize * 0.72,
left: emblemSize * 0.14,
overflow: 'hidden',
position: 'absolute',
top: emblemSize * 0.14,
transform: 'rotate(45deg)',
width: emblemSize * 0.72,
}}
>
<div
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 45%)',
inset: 0,
position: 'absolute',
}}
/>
{fx.sheen && seconds >= tl.emblemSettled ? (
<div
style={{
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.75), transparent)',
height: '100%',
left: `${-60 + sheenProgress * 220}%`,
position: 'absolute',
top: 0,
width: '40%',
}}
/>
) : null}
<div style={{background: '#ffffff', inset: 0, opacity: silhouette, position: 'absolute'}} />
</div>
<div
style={{
alignItems: 'center',
display: 'flex',
fontSize: emblemSize * 0.42,
fontStyle: 'italic',
fontWeight: 900,
inset: 0,
justifyContent: 'center',
opacity: 1 - silhouette,
position: 'absolute',
}}
>
<span style={{paddingRight: emblemSize * 0.03, ...rankLetterStyle}}>{rankLetter}</span>
</div>
</div>
<div
style={{
left: width * 0.07,
opacity: nameProgress,
position: 'absolute',
top: height * 0.56,
transform: `translateX(${(1 - nameProgress) * -width * 0.06}px)`,
width: width * 0.44,
}}
>
<div
style={{
color: '#ffffff',
display: '-webkit-box',
fontSize: getLabelFontSize(props.label, height),
fontWeight: 800,
lineHeight: 1.12,
overflow: 'hidden',
overflowWrap: 'anywhere',
textShadow: `0 2px ${height * 0.012}px rgba(0, 0, 0, 0.7), 0 0 ${height * 0.035}px ${withAlpha(palette.glow, 0.8)}`,
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
}}
>
{props.label}
</div>
<div style={{display: 'flex', gap: starSize * 0.12, marginTop: height * 0.025}}>
{starTimes.map((time, index) => {
const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.6, 1], clamp);
const flare = interpolate(seconds, [time, time + 0.3], [1, 0], clamp);
const flareSize = starSize * 2.4 * fx.starFlare;
return (
<div
key={`star-${index}`}
style={{
height: starSize,
opacity: seconds >= time ? 1 : 0,
position: 'relative',
transform: `scale(${pop})`,
width: starSize,
}}
>
<div
style={{
background: `radial-gradient(circle, ${withAlpha('#fff4c2', 0.9 * flare)} 0%, transparent 65%)`,
height: flareSize,
left: (starSize - flareSize) / 2,
position: 'absolute',
top: (starSize - flareSize) / 2,
width: flareSize,
}}
/>
<StarIcon size={starSize} />
</div>
);
})}
</div>
</div>
</AbsoluteFill>
) : null}
{flashOpacity > 0 ? (
<AbsoluteFill style={{pointerEvents: 'none'}}>
<div
style={{
background: `radial-gradient(circle, #ffffff 0%, #ffffff 35%, ${withAlpha(palette.core, 0)} 70%)`,
height: bloomRadius * 2,
left: impact.x - bloomRadius,
opacity: Math.min(1, flashOpacity * 1.6),
position: 'absolute',
top: impact.y - bloomRadius,
width: bloomRadius * 2,
}}
/>
<AbsoluteFill style={{background: props.rarity === 5 ? '#fff6dc' : '#ffffff', opacity: flashOpacity}} />
</AbsoluteFill>
) : null}
</AbsoluteFill>
);
};
+37
View File
@@ -1,4 +1,6 @@
import {Composition} from 'remotion';
import {GenshinComposition} from './GenshinComposition.jsx';
import {genshinFrameSizes, genshinTotalSeconds} from './genshin-timeline.js';
import {WheelComposition} from './WheelComposition.jsx';
/** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */
@@ -29,7 +31,31 @@ export const getCompositionMetadata = (props) => {
};
};
/** @type {import('../schemas/gacha-request.js').GachaRenderRequest} */
const defaultGenshinProps = {
label: 'Pizza',
rarity: 5,
fps: 24,
width: 640,
seed: 7,
};
/**
* @param {{fps: number, width: number}} props
*/
export const getGenshinCompositionMetadata = (props) => {
const fps = props.fps || defaultGenshinProps.fps;
const width = props.width in genshinFrameSizes ? props.width : defaultGenshinProps.width;
return {
durationInFrames: Math.ceil(genshinTotalSeconds * fps),
fps,
height: genshinFrameSizes[/** @type {640 | 854} */ (width)],
width,
};
};
export const RemotionRoot = () => (
<>
<Composition
calculateMetadata={({props}) => getCompositionMetadata(props)}
component={WheelComposition}
@@ -40,4 +66,15 @@ export const RemotionRoot = () => (
id="WheelGif"
width={defaultProps.size}
/>
<Composition
calculateMetadata={({props}) => getGenshinCompositionMetadata(props)}
component={GenshinComposition}
defaultProps={defaultGenshinProps}
durationInFrames={Math.ceil(genshinTotalSeconds * defaultGenshinProps.fps)}
fps={defaultGenshinProps.fps}
height={genshinFrameSizes[defaultGenshinProps.width]}
id="GenshinWish"
width={defaultGenshinProps.width}
/>
</>
);
+274
View File
@@ -0,0 +1,274 @@
/**
* Timing, palette, and geometry for the Genshin-style meteor wish served on
* /api/genshin. Kept free of React so the choreography can be unit tested.
*/
/** @typedef {3 | 4 | 5} GenshinRarity */
/**
* @typedef {object} RarityPalette
* @property {string} core Meteor head and hottest highlights.
* @property {string} glow Trail, ring burst, and emblem glow.
* @property {string} deep Background tint of the reveal scene.
* @property {string} emblem Emblem fill gradient, light to dark.
*/
export const genshinTotalSeconds = 7;
/** Landscape frame heights for each requested width: 16:9, rounded to even pixels for H.264. */
export const genshinFrameSizes = Object.freeze({640: 360, 854: 480});
/**
* Seconds from the start of the clip. The meteor lands at `meteorEnd`, the
* white flash peaks and holds, then fades to reveal the result.
*/
export const genshinTimeline = Object.freeze({
meteorStart: 0.3,
meteorEnd: 2.5,
flashStart: 2.3,
flashPeak: 2.7,
flashHoldEnd: 2.95,
flashEnd: 3.5,
revealStart: 2.95,
emblemSettled: 3.7,
nameIn: 3.6,
nameSettled: 4.1,
starsStart: 4.1,
starStep: 0.18,
});
/** Centre of the rank emblem on the reveal, as fractions of the frame. */
export const emblemCenter = Object.freeze({x: 0.66, y: 0.47});
/** Every rarity reveals its stars in the same gold, as in the source game. */
export const starColor = '#ffcc33';
/** @type {Record<GenshinRarity, RarityPalette>} */
const palettes = {
3: {core: '#f2f8ff', glow: '#6fb6ff', deep: '#163a8a', emblem: '#9fd0ff, #3a6fd8'},
4: {core: '#fbf2ff', glow: '#c88cff', deep: '#4a1d8f', emblem: '#e2c2ff, #8a4ae6'},
5: {core: '#fffbe8', glow: '#ffd36b', deep: '#7a4a10', emblem: '#fff0b8, #e2a12e'},
};
/**
* @param {GenshinRarity} rarity
* @returns {RarityPalette}
*/
export const getRarityPalette = (rarity) => palettes[rarity];
/**
* Point on the meteor's curved flight from the upper left toward the rank
* emblem on the right, as a quadratic Bézier. It lands exactly on the emblem,
* so the impact burst becomes the badge reveal.
*
* @param {number} progress 0 at launch, 1 at landing; values outside clamp.
* @param {number} width
* @param {number} height
* @returns {{x: number, y: number}}
*/
export const getMeteorPoint = (progress, width, height) => {
const t = Math.min(1, Math.max(0, progress));
const start = {x: -width * 0.08, y: -height * 0.18};
const control = {x: width * 0.22, y: height * 0.38};
const end = {x: width * emblemCenter.x, y: height * emblemCenter.y};
const u = 1 - t;
return {
x: u * u * start.x + 2 * u * t * control.x + t * t * end.x,
y: u * u * start.y + 2 * u * t * control.y + t * t * end.y,
};
};
/**
* Seconds at which each reveal star pops in, one per rarity level.
*
* @param {GenshinRarity} rarity
* @returns {number[]}
*/
export const getStarRevealTimes = (rarity) =>
Array.from({length: rarity}, (_, index) => genshinTimeline.starsStart + index * genshinTimeline.starStep);
/**
* Deterministic pseudo-random generator (mulberry32) so every render of the
* same request draws the same sky.
*
* @param {number} seed
* @returns {() => number} values in [0, 1)
*/
export const createRandom = (seed) => {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
};
/**
* @typedef {object} SkyStar
* @property {number} x Fraction of width.
* @property {number} y Fraction of height.
* @property {number} radius Fraction of height.
* @property {number} phase Twinkle phase offset in radians.
* @property {number} speed Twinkle speed in radians per second.
* @property {boolean} glint Draws a cross-shaped glint when it flares.
*/
/**
* @param {number} count
* @param {number} seed
* @returns {SkyStar[]}
*/
export const createStarfield = (count, seed) => {
const random = createRandom(seed);
return Array.from({length: count}, () => ({
x: random(),
y: random() * 0.8,
radius: 0.002 + random() * 0.004,
phase: random() * Math.PI * 2,
speed: 2 + random() * 5,
glint: random() < 0.18,
}));
};
/**
* Twinkle brightness in [0, 1]. Cubing the sine keeps a star dim most of the
* time with short bright flares, which reads as twinkling rather than a slow
* pulse.
*
* @param {number} seconds
* @param {SkyStar} star
* @returns {number}
*/
export const getTwinkle = (seconds, star) => (0.5 + 0.5 * Math.sin(seconds * star.speed + star.phase)) ** 3;
/**
* Font size for the revealed name, shrinking long labels so they fit within
* two lines on the left half of the frame.
*
* @param {string} label
* @param {number} height
* @returns {number}
*/
export const getLabelFontSize = (label, height) => {
const length = Array.from(label).length;
if (length <= 8) {
return Math.round(height * 0.12);
}
if (length <= 16) {
return Math.round(height * 0.09);
}
if (length <= 28) {
return Math.round(height * 0.07);
}
return Math.round(height * 0.055);
};
/** Rank letter shown on the emblem for each rarity. */
const rankLetters = {3: 'B', 4: 'A', 5: 'S'};
/**
* @param {GenshinRarity} rarity
* @returns {'B' | 'A' | 'S'}
*/
export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLetters[rarity]);
/**
* @typedef {object} TierEffects
* @property {number} headScale Meteor head size multiplier.
* @property {number} trailSamples Glow blobs drawn along the meteor trail.
* @property {number} sparks Sparks shed by the meteor.
* @property {boolean} halo Rainbow sunburst bursting from the meteor before landing.
* @property {number} skyFlood Peak opacity of the sky tint as the meteor lands.
* @property {number} shake Impact camera shake amplitude, as a fraction of height.
* @property {number} shockwaves Rings bursting from the emblem on reveal.
* @property {boolean} starburst Long light spikes behind the emblem.
* @property {number} raysOpacity Peak opacity of the rotating light rays.
* @property {boolean} counterRays Second ray layer rotating the other way.
* @property {number} motes Glowing motes floating up behind the reveal.
* @property {number} sparkleRain Sparkles falling across the reveal.
* @property {boolean} sheen Light sweep across the emblem and rank letter.
* @property {number} starFlare Size multiplier of each star's pop flare.
*/
/**
* How loud each tier is. Every beat exists at every tier; higher tiers get
* more of it, so 4★ clearly beats 3★ and 5★ clearly beats both.
*
* @type {Record<GenshinRarity, TierEffects>}
*/
const tierEffects = {
3: {
headScale: 1,
trailSamples: 22,
sparks: 12,
halo: false,
skyFlood: 0,
shake: 0,
shockwaves: 1,
starburst: false,
raysOpacity: 0.16,
counterRays: false,
motes: 14,
sparkleRain: 0,
sheen: false,
starFlare: 1,
},
4: {
headScale: 1.3,
trailSamples: 34,
sparks: 36,
halo: false,
skyFlood: 0.25,
shake: 0.012,
shockwaves: 2,
starburst: false,
raysOpacity: 0.42,
counterRays: false,
motes: 32,
sparkleRain: 0,
sheen: false,
starFlare: 1.4,
},
5: {
headScale: 1.75,
trailSamples: 48,
sparks: 80,
halo: true,
skyFlood: 0.7,
shake: 0.03,
shockwaves: 3,
starburst: true,
raysOpacity: 0.7,
counterRays: true,
motes: 56,
sparkleRain: 40,
sheen: true,
starFlare: 2,
},
};
/**
* @param {GenshinRarity} rarity
* @returns {TierEffects}
*/
export const getTierEffects = (rarity) => tierEffects[rarity];
/**
* Decaying impact shake offset in pixels, zero before `start` and after it
* settles.
*
* @param {number} seconds
* @param {number} start
* @param {number} amplitude Pixels at the moment of impact.
* @returns {{x: number, y: number}}
*/
export const getShakeOffset = (seconds, start, amplitude) => {
const elapsed = seconds - start;
if (amplitude <= 0 || elapsed < 0 || elapsed > 0.8) {
return {x: 0, y: 0};
}
const decay = amplitude * (1 - elapsed / 0.8) ** 2;
return {x: Math.sin(elapsed * 71) * decay, y: Math.cos(elapsed * 53) * decay};
};
+24
View File
@@ -0,0 +1,24 @@
import {renderComposition} from './render-composition.js';
/**
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
* @typedef {import('./render-composition.js').RenderResult} RenderResult
*/
/**
* Renders the Genshin-style meteor wish as 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<RenderResult>}
*/
export const renderGenshinVideo = (inputProps, options) =>
renderComposition({
compositionId: 'GenshinWish',
filename: 'genshin.mp4',
inputProps: {...inputProps},
media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true},
timeoutInMilliseconds: options.timeoutInMilliseconds,
});
+4 -2
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} [name] Wish style: serves /api/<name> and names the file <name>.mp4. Defaults to gacha.
*/
/**
@@ -24,7 +25,8 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
* @param {GachaRouteDeps} deps
*/
export const registerGachaRoute = async (app, deps) => {
app.post('/api/gacha', async (request, reply) => {
const name = deps.name ?? 'gacha';
app.post(`/api/${name}`, async (request, reply) => {
if (!isAuthorized(request, deps.config.apiToken)) {
return reply.code(401).send({error: 'unauthorized'});
}
@@ -52,7 +54,7 @@ export const registerGachaRoute = async (app, deps) => {
return reply
.header('Content-Type', 'video/mp4')
.header('Content-Disposition', 'inline; filename="gacha.mp4"')
.header('Content-Disposition', `inline; filename="${name}.mp4"`)
.header('Cache-Control', 'no-store')
.header('X-Gacha-Rarity', String(gachaRequest.rarity))
.header('X-Render-Duration-Ms', String(result.durationMs))
+9 -1
View File
@@ -3,6 +3,7 @@ import {loadConfig} from './config.js';
import {createRenderSemaphore} from './lib/render-semaphore.js';
import {getRemotionServeUrl} from './render/remotion-bundle.js';
import {closeGachaBrowser, renderGachaVideo, warmGachaBrowser} from './render/render-gacha.js';
import {renderGenshinVideo} from './render/render-genshin.js';
import {renderWheelGif} from './render/render-gif.js';
import {registerGachaRoute} from './routes/gacha.js';
import {registerGifRoute} from './routes/gif.js';
@@ -19,6 +20,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.renderGenshin]
* @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the gacha browser before serving.
*/
export const buildServer = async (options = {}) => {
@@ -41,7 +43,7 @@ export const buildServer = async (options = {}) => {
});
await registerHealthRoute(app);
// One semaphore across both routes: they share the same browser and CPU.
// One semaphore across every route: renders share the CPU.
const semaphore = createRenderSemaphore(config.maxConcurrentRenders);
await registerGifRoute(app, {
config,
@@ -53,6 +55,12 @@ export const buildServer = async (options = {}) => {
renderGacha: options.renderGacha ?? renderGachaVideo,
semaphore,
});
await registerGachaRoute(app, {
config,
name: 'genshin',
renderGacha: options.renderGenshin ?? renderGenshinVideo,
semaphore,
});
// The gacha wish keeps one headless Chrome alive between renders.
app.addHook('onClose', closeGachaBrowser);
+30
View File
@@ -157,4 +157,34 @@ describe('POST /api/gacha', () => {
await app.close();
});
test('serves the Genshin-style wish on /api/genshin 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};
},
renderGenshin: async (request) => {
calls.push(`genshin:${request.label}:${request.rarity}`);
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
},
);
const response = await app.inject({method: 'POST', url: '/api/genshin', payload: {label: 'Bún bò', rarity: 5}});
const invalid = await app.inject({method: 'POST', url: '/api/genshin', payload: {label: 'Bún bò', rarity: 9}});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('video/mp4');
expect(response.headers['content-disposition']).toBe('inline; filename="genshin.mp4"');
expect(response.headers['x-gacha-rarity']).toBe('5');
expect(invalid.statusCode).toBe(400);
expect(calls).toEqual(['genshin:Bún bò:5']);
await app.close();
});
});
+110
View File
@@ -0,0 +1,110 @@
import {describe, expect, test} from 'vitest';
import {
createStarfield,
genshinTimeline,
genshinTotalSeconds,
getLabelFontSize,
emblemCenter,
getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
getStarRevealTimes,
getTierEffects,
getTwinkle,
} from '../src/remotion/genshin-timeline.js';
describe('genshin timeline', () => {
test('phases run in order and finish before the clip ends', () => {
const tl = genshinTimeline;
expect(tl.meteorStart).toBeLessThan(tl.meteorEnd);
expect(tl.flashStart).toBeLessThan(tl.flashPeak);
expect(tl.meteorEnd).toBeLessThanOrEqual(tl.flashPeak);
expect(tl.flashPeak).toBeLessThan(tl.flashHoldEnd);
expect(tl.revealStart).toBeLessThanOrEqual(tl.flashHoldEnd);
expect(tl.flashHoldEnd).toBeLessThan(tl.flashEnd);
expect(tl.nameIn).toBeLessThan(tl.nameSettled);
const lastStar = getStarRevealTimes(5).at(-1) ?? 0;
expect(lastStar + 1).toBeLessThan(genshinTotalSeconds);
});
test.each([3, 4, 5])('reveals one star per rarity level for %i stars', (rarity) => {
const times = getStarRevealTimes(/** @type {3 | 4 | 5} */ (rarity));
expect(times).toHaveLength(rarity);
expect(times[0]).toBe(genshinTimeline.starsStart);
expect([...times].sort((a, b) => a - b)).toEqual(times);
});
test('each rarity has a distinct meteor colour', () => {
const glows = [3, 4, 5].map((rarity) => getRarityPalette(/** @type {3 | 4 | 5} */ (rarity)).glow);
expect(new Set(glows).size).toBe(3);
});
test('meteor flies in from off-screen upper left and lands on the rank emblem', () => {
const start = getMeteorPoint(0, 640, 360);
const end = getMeteorPoint(1, 640, 360);
expect(start.x).toBeLessThan(0);
expect(start.y).toBeLessThan(0);
expect(end.x).toBeCloseTo(640 * emblemCenter.x);
expect(end.y).toBeCloseTo(360 * emblemCenter.y);
expect(getMeteorPoint(2, 640, 360)).toEqual(end);
expect(getMeteorPoint(-1, 640, 360)).toEqual(start);
});
test('starfield is deterministic per seed', () => {
expect(createStarfield(5, 7)).toEqual(createStarfield(5, 7));
expect(createStarfield(5, 7)).not.toEqual(createStarfield(5, 8));
});
test('stars twinkle with short bright flares', () => {
const [star] = createStarfield(1, 3);
if (!star) {
throw new Error('expected a star');
}
const samples = Array.from({length: 200}, (_, index) => getTwinkle(index / 50, star));
expect(Math.max(...samples)).toBeGreaterThan(0.95);
expect(Math.min(...samples)).toBeLessThan(0.05);
const bright = samples.filter((value) => value > 0.5).length / samples.length;
expect(bright).toBeLessThan(0.4);
});
test('long labels get smaller text', () => {
expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360));
});
test('emblem shows B, A, S rank letters by rarity', () => {
expect([3, 4, 5].map((rarity) => getRankLetter(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['B', 'A', 'S']);
});
test('every numeric effect grows with rarity and 5★ unlocks the extras', () => {
const [three, four, five] = [3, 4, 5].map((rarity) => getTierEffects(/** @type {3 | 4 | 5} */ (rarity)));
for (const key of /** @type {const} */ ([
'headScale',
'trailSamples',
'sparks',
'shockwaves',
'raysOpacity',
'motes',
'starFlare',
])) {
expect(four?.[key], key).toBeGreaterThan(three?.[key] ?? Infinity);
expect(five?.[key], key).toBeGreaterThan(four?.[key] ?? Infinity);
}
expect(four?.shake).toBeGreaterThan(three?.shake ?? Infinity);
expect(five?.shake).toBeGreaterThan(four?.shake ?? Infinity);
expect(five?.skyFlood).toBeGreaterThan(four?.skyFlood ?? Infinity);
expect([three?.halo, four?.halo, five?.halo]).toEqual([false, false, true]);
expect(five).toMatchObject({starburst: true, counterRays: true, sheen: true});
expect(three?.sparkleRain).toBe(0);
expect(five?.sparkleRain).toBeGreaterThan(0);
});
test('impact shake decays to rest', () => {
expect(getShakeOffset(1, 2, 10)).toEqual({x: 0, y: 0});
expect(getShakeOffset(3, 2, 10)).toEqual({x: 0, y: 0});
expect(getShakeOffset(2.1, 2, 0)).toEqual({x: 0, y: 0});
const early = getShakeOffset(2.05, 2, 10);
const late = getShakeOffset(2.7, 2, 10);
expect(Math.hypot(early.x, early.y)).toBeGreaterThan(Math.hypot(late.x, late.y));
});
});