mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
b7a4336a7b
commit
59f597016b
12 files changed
+1241
-18
No files matched your search
+21
-5
@@ -1,7 +1,8 @@
|
|||||||
# wheelofnames
|
# wheelofnames
|
||||||
|
|
||||||
Self-hosted API that renders wheel-of-names GIF animations with Remotion and
|
Self-hosted API that renders wheel-of-names GIF animations and Genshin-style
|
||||||
gacha wish MP4 animations with pack-cards.
|
meteor wish MP4 animations with Remotion, and card-pack gacha wish MP4
|
||||||
|
animations with pack-cards.
|
||||||
|
|
||||||
## API
|
## 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.
|
result and its rarity — the service only draws it.
|
||||||
|
|
||||||
Response is a silent H.264 `video/mp4` (Telegram plays it as an animation)
|
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.
|
`MAX_CONCURRENT_RENDERS` slots. No game assets are used.
|
||||||
|
|
||||||
pack-cards animates on the browser clock, so the wish does not use Remotion
|
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
|
`npm ci` against the lockfile, and the Docker image has no `git` for a git
|
||||||
dependency.
|
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
|
## Local
|
||||||
|
|
||||||
Install dependencies and Chromium once:
|
Install dependencies and Chromium once:
|
||||||
@@ -101,7 +117,7 @@ npm run dev
|
|||||||
### Generate GIF files locally
|
### Generate GIF files locally
|
||||||
|
|
||||||
Generate the quick smoke fixtures at the git-ignored paths
|
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
|
```sh
|
||||||
npm run render:smoke
|
npm run render:smoke
|
||||||
@@ -109,7 +125,7 @@ npm run render:smoke
|
|||||||
|
|
||||||
Generate the complete fixture set at `fixtures/smoke.gif`,
|
Generate the complete fixture set at `fixtures/smoke.gif`,
|
||||||
`fixtures/vietnamese.gif`, `fixtures/sixteen-options.gif`, and
|
`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
|
```sh
|
||||||
npm run render:fixtures
|
npm run render:fixtures
|
||||||
|
|||||||
@@ -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
|
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
|
itself, and sends the MP4 with `sendAnimation`. On any failure it falls back to
|
||||||
a text reply.
|
a text reply.
|
||||||
|
|
||||||
|
The unlisted `/genshin` command works the same way against `POST /api/genshin`
|
||||||
|
and sends the 7-second 640x360 meteor wish.
|
||||||
@@ -65,6 +65,21 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`api-smoke gacha: ${gacha.rawPayload.byteLength} bytes`);
|
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 {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {mkdir, writeFile} from 'node:fs/promises';
|
import {mkdir, writeFile} from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {closeGachaBrowser, renderGachaVideo} from '../src/render/render-gacha.js';
|
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';
|
import {renderWheelGif} from '../src/render/render-gif.js';
|
||||||
|
|
||||||
const smoke = process.argv.includes('--smoke');
|
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}));
|
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 {
|
try {
|
||||||
for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) {
|
for (const fixture of smoke ? gachaFixtures.slice(0, 1) : gachaFixtures) {
|
||||||
await save(`${fixture.name}.mp4`, await renderGachaVideo(fixture.request, {timeoutInMilliseconds: 60000}));
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import {Composition} from 'remotion';
|
import {Composition} from 'remotion';
|
||||||
|
import {GenshinComposition} from './GenshinComposition.jsx';
|
||||||
|
import {genshinFrameSizes, genshinTotalSeconds} from './genshin-timeline.js';
|
||||||
import {WheelComposition} from './WheelComposition.jsx';
|
import {WheelComposition} from './WheelComposition.jsx';
|
||||||
|
|
||||||
/** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */
|
/** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */
|
||||||
@@ -29,15 +31,50 @@ 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 = () => (
|
export const RemotionRoot = () => (
|
||||||
<Composition
|
<>
|
||||||
calculateMetadata={({props}) => getCompositionMetadata(props)}
|
<Composition
|
||||||
component={WheelComposition}
|
calculateMetadata={({props}) => getCompositionMetadata(props)}
|
||||||
defaultProps={defaultProps}
|
component={WheelComposition}
|
||||||
durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
|
defaultProps={defaultProps}
|
||||||
fps={defaultProps.fps}
|
durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
|
||||||
height={defaultProps.size}
|
fps={defaultProps.fps}
|
||||||
id="WheelGif"
|
height={defaultProps.size}
|
||||||
width={defaultProps.size}
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
|
|||||||
* @property {AppConfig} config
|
* @property {AppConfig} config
|
||||||
* @property {{tryAcquire: () => boolean, release: () => void}} semaphore
|
* @property {{tryAcquire: () => boolean, release: () => void}} semaphore
|
||||||
* @property {(request: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} renderGacha
|
* @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
|
* @param {GachaRouteDeps} deps
|
||||||
*/
|
*/
|
||||||
export const registerGachaRoute = async (app, 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)) {
|
if (!isAuthorized(request, deps.config.apiToken)) {
|
||||||
return reply.code(401).send({error: 'unauthorized'});
|
return reply.code(401).send({error: 'unauthorized'});
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,7 @@ export const registerGachaRoute = async (app, deps) => {
|
|||||||
|
|
||||||
return reply
|
return reply
|
||||||
.header('Content-Type', 'video/mp4')
|
.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('Cache-Control', 'no-store')
|
||||||
.header('X-Gacha-Rarity', String(gachaRequest.rarity))
|
.header('X-Gacha-Rarity', String(gachaRequest.rarity))
|
||||||
.header('X-Render-Duration-Ms', String(result.durationMs))
|
.header('X-Render-Duration-Ms', String(result.durationMs))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {loadConfig} from './config.js';
|
|||||||
import {createRenderSemaphore} from './lib/render-semaphore.js';
|
import {createRenderSemaphore} from './lib/render-semaphore.js';
|
||||||
import {getRemotionServeUrl} from './render/remotion-bundle.js';
|
import {getRemotionServeUrl} from './render/remotion-bundle.js';
|
||||||
import {closeGachaBrowser, renderGachaVideo, warmGachaBrowser} from './render/render-gacha.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 {renderWheelGif} from './render/render-gif.js';
|
||||||
import {registerGachaRoute} from './routes/gacha.js';
|
import {registerGachaRoute} from './routes/gacha.js';
|
||||||
import {registerGifRoute} from './routes/gif.js';
|
import {registerGifRoute} from './routes/gif.js';
|
||||||
@@ -19,6 +20,7 @@ import {registerHealthRoute} from './routes/health.js';
|
|||||||
* @param {AppConfig} [options.config]
|
* @param {AppConfig} [options.config]
|
||||||
* @param {(request: WheelRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGif]
|
* @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.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.
|
* @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the gacha browser before serving.
|
||||||
*/
|
*/
|
||||||
export const buildServer = async (options = {}) => {
|
export const buildServer = async (options = {}) => {
|
||||||
@@ -41,7 +43,7 @@ export const buildServer = async (options = {}) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await registerHealthRoute(app);
|
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);
|
const semaphore = createRenderSemaphore(config.maxConcurrentRenders);
|
||||||
await registerGifRoute(app, {
|
await registerGifRoute(app, {
|
||||||
config,
|
config,
|
||||||
@@ -53,6 +55,12 @@ export const buildServer = async (options = {}) => {
|
|||||||
renderGacha: options.renderGacha ?? renderGachaVideo,
|
renderGacha: options.renderGacha ?? renderGachaVideo,
|
||||||
semaphore,
|
semaphore,
|
||||||
});
|
});
|
||||||
|
await registerGachaRoute(app, {
|
||||||
|
config,
|
||||||
|
name: 'genshin',
|
||||||
|
renderGacha: options.renderGenshin ?? renderGenshinVideo,
|
||||||
|
semaphore,
|
||||||
|
});
|
||||||
// The gacha wish keeps one headless Chrome alive between renders.
|
// The gacha wish keeps one headless Chrome alive between renders.
|
||||||
app.addHook('onClose', closeGachaBrowser);
|
app.addHook('onClose', closeGachaBrowser);
|
||||||
|
|
||||||
|
|||||||
@@ -157,4 +157,34 @@ describe('POST /api/gacha', () => {
|
|||||||
|
|
||||||
await app.close();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user