mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-13 18:16:13 +00:00
feat(gacha): add /api/gacha wish animation rendered as MP4
A meteor coloured by rarity falls across a night sky, lands in a white flash, and the label is revealed with its stars popping in. The caller picks the result and rarity; the route only draws it. Renders as silent H.264 so Telegram plays it as an animation without GIF banding, and shares the render slots with /api/gif.
This commit is contained in:
1 parent
61d0e74709
commit
97d8aeaadf
16 files changed
+1111
-20
No files matched your search
@@ -0,0 +1,448 @@
|
||||
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
import {
|
||||
createRandom,
|
||||
createStarfield,
|
||||
gachaTimeline,
|
||||
getEmblemGlyph,
|
||||
getLabelFontSize,
|
||||
getMeteorPoint,
|
||||
getRarityPalette,
|
||||
getStarRevealTimes,
|
||||
starColor,
|
||||
} from './gacha-timeline.js';
|
||||
|
||||
const baseFont =
|
||||
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
|
||||
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
|
||||
|
||||
const skyStars = createStarfield(70, 7);
|
||||
const trailSamples = 34;
|
||||
const trailSpacing = 0.014;
|
||||
|
||||
/**
|
||||
* Sparks shed by the meteor. Each is born at a point along the fall and
|
||||
* drifts away from it while fading.
|
||||
*/
|
||||
const meteorSparks = (() => {
|
||||
const random = createRandom(11);
|
||||
return Array.from({length: 40}, () => ({
|
||||
born: random(),
|
||||
driftX: (random() - 0.5) * 0.08,
|
||||
driftY: (random() - 0.2) * 0.08,
|
||||
size: 0.006 + random() * 0.01,
|
||||
}));
|
||||
})();
|
||||
|
||||
/** Motes that float upward behind the revealed item. */
|
||||
const revealMotes = (() => {
|
||||
const random = createRandom(23);
|
||||
return Array.from({length: 36}, () => ({
|
||||
x: random(),
|
||||
y: random(),
|
||||
speed: 0.03 + random() * 0.06,
|
||||
size: 0.004 + random() * 0.008,
|
||||
phase: random() * Math.PI * 2,
|
||||
}));
|
||||
})();
|
||||
|
||||
/**
|
||||
* @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 stars popping in.
|
||||
*
|
||||
* @param {GachaRenderRequest} props
|
||||
*/
|
||||
export const GachaComposition = (props) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps, width, height} = useVideoConfig();
|
||||
const seconds = frame / fps;
|
||||
const palette = getRarityPalette(props.rarity);
|
||||
const tl = gachaTimeline;
|
||||
|
||||
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);
|
||||
|
||||
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 emblemSize = height * 0.52;
|
||||
const emblemX = width * 0.66;
|
||||
const emblemY = height * 0.47;
|
||||
const emblemScale = interpolate(seconds, [tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled], [0.72, 1.06, 1], {
|
||||
...clamp,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
const silhouette = interpolate(seconds, [tl.revealStart + 0.2, tl.emblemSettled], [1, 0], clamp);
|
||||
const ringProgress = interpolate(seconds, [tl.revealStart, tl.revealStart + 0.9], [0, 1], {
|
||||
...clamp,
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
const raysOpacity = interpolate(
|
||||
seconds,
|
||||
[tl.revealStart, tl.emblemSettled],
|
||||
[0, props.rarity === 3 ? 0.22 : 0.4],
|
||||
clamp,
|
||||
);
|
||||
const emblemPulse = 0.5 + 0.5 * Math.sin(seconds * 3);
|
||||
|
||||
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);
|
||||
|
||||
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) => (
|
||||
<div
|
||||
key={`sky-${index}`}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '50%',
|
||||
height: star.radius * 2 * height,
|
||||
left: star.x * width,
|
||||
opacity: 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(seconds * 2.4 + star.phase)),
|
||||
position: 'absolute',
|
||||
top: star.y * height,
|
||||
width: star.radius * 2 * height,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{meteorVisible
|
||||
? Array.from({length: trailSamples}, (_, index) => {
|
||||
const sampleProgress = meteorProgress - index * trailSpacing;
|
||||
if (sampleProgress <= 0) {
|
||||
return null;
|
||||
}
|
||||
const point = getMeteorPoint(sampleProgress, width, height);
|
||||
const fade = 1 - index / 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.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);
|
||||
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 ? (
|
||||
<>
|
||||
<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 === 5 ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
|
||||
height: Math.max(2, headRadius * 0.12),
|
||||
left: head.x - headRadius * 5,
|
||||
position: 'absolute',
|
||||
top: head.y - Math.max(1, headRadius * 0.06),
|
||||
width: headRadius * 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
background: `linear-gradient(180deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
|
||||
height: headRadius * 6,
|
||||
left: head.x - Math.max(1, headRadius * 0.06),
|
||||
position: 'absolute',
|
||||
top: head.y - headRadius * 3,
|
||||
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%)`,
|
||||
}}
|
||||
>
|
||||
<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: 'radial-gradient(circle, black 8%, transparent 55%)',
|
||||
opacity: raysOpacity,
|
||||
position: 'absolute',
|
||||
top: emblemY - width * 0.8,
|
||||
transform: `rotate(${seconds * 9}deg)`,
|
||||
WebkitMaskImage: 'radial-gradient(circle, black 8%, transparent 55%)',
|
||||
width: width * 1.6,
|
||||
}}
|
||||
/>
|
||||
|
||||
{revealMotes.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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{ringProgress < 1 ? (
|
||||
<div
|
||||
style={{
|
||||
border: `${Math.max(2, height * 0.008)}px solid ${palette.glow}`,
|
||||
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 - ringProgress,
|
||||
position: 'absolute',
|
||||
top: emblemY - emblemSize / 2,
|
||||
transform: `scale(${0.4 + ringProgress * 2.6})`,
|
||||
width: emblemSize,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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 * 0.008)}px solid rgba(255, 255, 255, 0.85)`,
|
||||
borderRadius: emblemSize * 0.12,
|
||||
boxShadow: `0 0 ${height * (0.05 + 0.03 * 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',
|
||||
}}
|
||||
/>
|
||||
<div style={{background: '#ffffff', inset: 0, opacity: silhouette, position: 'absolute'}} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
color: '#ffffff',
|
||||
display: 'flex',
|
||||
fontSize: emblemSize * 0.36,
|
||||
fontWeight: 800,
|
||||
inset: 0,
|
||||
justifyContent: 'center',
|
||||
opacity: 1 - silhouette,
|
||||
position: 'absolute',
|
||||
textShadow: `0 2px ${height * 0.01}px rgba(0, 0, 0, 0.45), 0 0 ${height * 0.03}px ${palette.glow}`,
|
||||
}}
|
||||
>
|
||||
{getEmblemGlyph(props.label)}
|
||||
</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);
|
||||
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: starSize * 2.4,
|
||||
left: -starSize * 0.7,
|
||||
position: 'absolute',
|
||||
top: -starSize * 0.7,
|
||||
width: starSize * 2.4,
|
||||
}}
|
||||
/>
|
||||
<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: '#ffffff', opacity: flashOpacity}} />
|
||||
</AbsoluteFill>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import {Composition} from 'remotion';
|
||||
import {GachaComposition} from './GachaComposition.jsx';
|
||||
import {gachaTotalSeconds} from './gacha-timeline.js';
|
||||
import {WheelComposition} from './WheelComposition.jsx';
|
||||
import {gachaFrameSizes} from '../schemas/gacha-request.js';
|
||||
|
||||
/** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */
|
||||
const defaultProps = {
|
||||
@@ -29,15 +32,49 @@ export const getCompositionMetadata = (props) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** @type {import('../schemas/gacha-request.js').GachaRenderRequest} */
|
||||
const defaultGachaProps = {
|
||||
label: 'Pizza',
|
||||
rarity: 5,
|
||||
fps: 24,
|
||||
width: 640,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{fps: number, width: number}} props
|
||||
*/
|
||||
export const getGachaCompositionMetadata = (props) => {
|
||||
const fps = props.fps || defaultGachaProps.fps;
|
||||
const width = props.width in gachaFrameSizes ? props.width : defaultGachaProps.width;
|
||||
return {
|
||||
durationInFrames: Math.ceil(gachaTotalSeconds * fps),
|
||||
fps,
|
||||
height: gachaFrameSizes[/** @type {640 | 854} */ (width)],
|
||||
width,
|
||||
};
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
calculateMetadata={({props}) => getCompositionMetadata(props)}
|
||||
component={WheelComposition}
|
||||
defaultProps={defaultProps}
|
||||
durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
|
||||
fps={defaultProps.fps}
|
||||
height={defaultProps.size}
|
||||
id="WheelGif"
|
||||
width={defaultProps.size}
|
||||
/>
|
||||
<>
|
||||
<Composition
|
||||
calculateMetadata={({props}) => getCompositionMetadata(props)}
|
||||
component={WheelComposition}
|
||||
defaultProps={defaultProps}
|
||||
durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
|
||||
fps={defaultProps.fps}
|
||||
height={defaultProps.size}
|
||||
id="WheelGif"
|
||||
width={defaultProps.size}
|
||||
/>
|
||||
<Composition
|
||||
calculateMetadata={({props}) => getGachaCompositionMetadata(props)}
|
||||
component={GachaComposition}
|
||||
defaultProps={defaultGachaProps}
|
||||
durationInFrames={Math.ceil(gachaTotalSeconds * defaultGachaProps.fps)}
|
||||
fps={defaultGachaProps.fps}
|
||||
height={gachaFrameSizes[defaultGachaProps.width]}
|
||||
id="GachaWish"
|
||||
width={defaultGachaProps.width}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Timing, palette, and geometry for the gacha wish animation. Kept free of
|
||||
* React so the choreography can be unit tested.
|
||||
*/
|
||||
|
||||
/** @typedef {3 | 4 | 5} GachaRarity */
|
||||
|
||||
/**
|
||||
* @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 gachaTotalSeconds = 7;
|
||||
|
||||
/**
|
||||
* 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 gachaTimeline = 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,
|
||||
});
|
||||
|
||||
/** Every rarity reveals its stars in the same gold, as in the source game. */
|
||||
export const starColor = '#ffcc33';
|
||||
|
||||
/** @type {Record<GachaRarity, 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 {GachaRarity} rarity
|
||||
* @returns {RarityPalette}
|
||||
*/
|
||||
export const getRarityPalette = (rarity) => palettes[rarity];
|
||||
|
||||
/**
|
||||
* Point on the meteor's curved fall from the upper right toward the lower
|
||||
* left of the frame, as a quadratic Bézier.
|
||||
*
|
||||
* @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 * 1.08, y: -height * 0.18};
|
||||
const control = {x: width * 0.8, y: height * 0.3};
|
||||
const end = {x: width * 0.42, y: height * 0.6};
|
||||
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 {GachaRarity} rarity
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export const getStarRevealTimes = (rarity) =>
|
||||
Array.from({length: rarity}, (_, index) => gachaTimeline.starsStart + index * gachaTimeline.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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
||||
/**
|
||||
* The emblem shows the label's first character, keeping emoji and other
|
||||
* multi-unit code points intact.
|
||||
*
|
||||
* @param {string} label
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getEmblemGlyph = (label) => (Array.from(label.trim())[0] ?? '?').toUpperCase();
|
||||
@@ -0,0 +1,23 @@
|
||||
import {renderComposition} from './render-composition.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
|
||||
* @typedef {import('./render-composition.js').RenderResult} RenderGachaResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders the wish animation as a silent H.264 MP4, which Telegram plays as a
|
||||
* looping animation and which keeps the glow gradients free of GIF banding.
|
||||
*
|
||||
* @param {GachaRenderRequest} inputProps
|
||||
* @param {{timeoutInMilliseconds: number}} options
|
||||
* @returns {Promise<RenderGachaResult>}
|
||||
*/
|
||||
export const renderGachaVideo = (inputProps, options) =>
|
||||
renderComposition({
|
||||
compositionId: 'GachaWish',
|
||||
filename: 'gacha.mp4',
|
||||
inputProps: {...inputProps},
|
||||
media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true},
|
||||
timeoutInMilliseconds: options.timeoutInMilliseconds,
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {ZodError} from 'zod';
|
||||
import {isAuthorized} from '../lib/bearer-auth.js';
|
||||
import {isRenderTimeoutError} from '../lib/render-errors.js';
|
||||
import {parseGachaRequest} from '../schemas/gacha-request.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../config.js').AppConfig} AppConfig
|
||||
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} GachaRouteDeps
|
||||
* @property {AppConfig} config
|
||||
* @property {{tryAcquire: () => boolean, release: () => void}} semaphore
|
||||
* @property {(request: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} renderGacha
|
||||
*/
|
||||
|
||||
/**
|
||||
* The caller picks the result and its rarity; this route only draws the wish
|
||||
* animation for it.
|
||||
*
|
||||
* @param {import('fastify').FastifyInstance} app
|
||||
* @param {GachaRouteDeps} deps
|
||||
*/
|
||||
export const registerGachaRoute = async (app, deps) => {
|
||||
app.post('/api/gacha', async (request, reply) => {
|
||||
if (!isAuthorized(request, deps.config.apiToken)) {
|
||||
return reply.code(401).send({error: 'unauthorized'});
|
||||
}
|
||||
|
||||
let gachaRequest;
|
||||
try {
|
||||
gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({error: 'invalid_request', issues: error.issues});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!deps.semaphore.tryAcquire()) {
|
||||
return reply.code(429).send({error: 'too_many_renders'});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deps.renderGacha(gachaRequest, {
|
||||
timeoutInMilliseconds: deps.config.renderTimeoutMs,
|
||||
});
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'video/mp4')
|
||||
.header('Content-Disposition', 'inline; filename="gacha.mp4"')
|
||||
.header('Cache-Control', 'no-store')
|
||||
.header('X-Gacha-Rarity', String(gachaRequest.rarity))
|
||||
.header('X-Render-Duration-Ms', String(result.durationMs))
|
||||
.send(result.buffer);
|
||||
} catch (error) {
|
||||
if (isRenderTimeoutError(error)) {
|
||||
return reply.code(504).send({error: 'render_timeout'});
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
deps.semaphore.release();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
/** Landscape frame sizes; heights keep 16:9 rounded to even pixels for H.264. */
|
||||
export const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
|
||||
|
||||
/**
|
||||
* @typedef {object} GachaRequestLimits
|
||||
* @property {number} maxOptionChars
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} GachaRenderRequest
|
||||
* @property {string} label
|
||||
* @property {3 | 4 | 5} rarity
|
||||
* @property {24 | 30} fps
|
||||
* @property {640 | 854} width
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {GachaRequestLimits} limits
|
||||
*/
|
||||
export const createGachaRequestSchema = (limits) =>
|
||||
z
|
||||
.object({
|
||||
label: z
|
||||
.string()
|
||||
.transform((label) => label.trim())
|
||||
.pipe(z.string().min(1).max(limits.maxOptionChars)),
|
||||
rarity: z.union([z.literal(3), z.literal(4), z.literal(5)]),
|
||||
fps: z.union([z.literal(24), z.literal(30)]).default(24),
|
||||
width: z.union([z.literal(640), z.literal(854)]).default(640),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* @param {unknown} input
|
||||
* @param {GachaRequestLimits} limits
|
||||
* @returns {GachaRenderRequest}
|
||||
*/
|
||||
export const parseGachaRequest = (input, limits) => createGachaRequestSchema(limits).parse(input);
|
||||
+12
-1
@@ -2,19 +2,23 @@ import Fastify from 'fastify';
|
||||
import {loadConfig} from './config.js';
|
||||
import {createRenderSemaphore} from './lib/render-semaphore.js';
|
||||
import {getRemotionServeUrl} from './render/remotion-bundle.js';
|
||||
import {renderGachaVideo} from './render/render-gacha.js';
|
||||
import {renderWheelGif} from './render/render-gif.js';
|
||||
import {registerGachaRoute} from './routes/gacha.js';
|
||||
import {registerGifRoute} from './routes/gif.js';
|
||||
import {registerHealthRoute} from './routes/health.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./config.js').AppConfig} AppConfig
|
||||
* @typedef {import('./schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
|
||||
* @typedef {import('./schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @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 {boolean} [options.warmRemotionBundle]
|
||||
*/
|
||||
export const buildServer = async (options = {}) => {
|
||||
@@ -36,10 +40,17 @@ export const buildServer = async (options = {}) => {
|
||||
});
|
||||
|
||||
await registerHealthRoute(app);
|
||||
// One semaphore across both routes: they share the same browser and CPU.
|
||||
const semaphore = createRenderSemaphore(config.maxConcurrentRenders);
|
||||
await registerGifRoute(app, {
|
||||
config,
|
||||
renderGif: options.renderGif ?? renderWheelGif,
|
||||
semaphore: createRenderSemaphore(config.maxConcurrentRenders),
|
||||
semaphore,
|
||||
});
|
||||
await registerGachaRoute(app, {
|
||||
config,
|
||||
renderGacha: options.renderGacha ?? renderGachaVideo,
|
||||
semaphore,
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
Reference in new issue
Block a user