diff --git a/renderer/README.md b/renderer/README.md index fa41c9c..92187a1 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -67,15 +67,23 @@ only draws it. ### Gacha wish beta `POST /api/gachabeta` takes the same body and returns the same response as -`/api/gacha`, rendering an 8-second beta style instead, modelled on the meteor -shot of a music video: white, teal, green, red, and blue meteors glide slowly -down a starry night sky over a snow-rimmed mountain ridge. A hero meteor in the -rarity colour (blue 3★, purple 4★, gold 5★) joins the volley, slows, and burns -out in a glint at the centre, where the food label appears under rank `SSS`. +`/api/gacha`, rendering a 10.5-second beta style instead, cut like an anime +gacha game's six-star wish: + +1. The camera dives through a ring of painterly clouds into a swirling eye, + then a beam rushes past. +2. A meteor slows to a stop over a sea of clouds inside a rainbow halo, + sheds sparkles, and bursts into a starburst under a red flash. +3. Crystal comets fall through the daytime sky while a red star joins them + and strikes in a white flash. +4. A black silhouette of the rank emblem sheds shards on a red disc, then the + splash card shows the emblem, a name plate with the label and `Rank SSS`, + and six stars popping in. The caller randomly chooses the food and sends it as `label` (for example, -`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` only chooses the hero -meteor's colour. `/api/gacha` keeps its existing ranks. +`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` tints the meteor +head and the splash card's emblem (blue 3★, purple 4★, gold 5★). `/api/gacha` +keeps its existing ranks. Response is a silent H.264 `video/mp4` (Telegram plays it as an animation) with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. Both routes share the diff --git a/renderer/src/remotion/GachaBetaComposition.jsx b/renderer/src/remotion/GachaBetaComposition.jsx index 1190749..3aecb24 100644 --- a/renderer/src/remotion/GachaBetaComposition.jsx +++ b/renderer/src/remotion/GachaBetaComposition.jsx @@ -1,23 +1,34 @@ import {useMemo} from 'react'; -import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; +import {Star} from '@remotion/shapes'; +import {AbsoluteFill, Easing, Img, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; +import {bakeCloudLayer} from './gacha-beta-paint.js'; import { + createShards, gachaBetaTimeline, getBetaRank, - getBurnGlint, - getHeroMeteor, - getSkyMeteorHead, - meteorAngleDegrees, - revealAnchor, - skyMeteors, + getBurstState, + getFlash, + getHaloState, + getMeteorHead, + getRedMeteor, + getShardOffset, + getShot, + getStarRevealTimes, + getVolleyCometHead, + getVortexState, + progress, + volleyComets, } from './gacha-beta-scene.js'; -import {createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} from './gacha-timeline.js'; +import {createRandom, createStarfield, getRarityPalette, getTwinkle, starColor} from './gacha-timeline.js'; const sansFont = 'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; -const serifFont = '"Noto Serif", "Cormorant Garamond", Georgia, "Times New Roman", serif'; +const serifFont = '"Noto Serif", Georgia, "Times New Roman", serif'; -const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); -const easeOut = /** @type {const} */ ({...clamp, easing: Easing.out(Easing.cubic)}); +const clamp = /** @type {const} */ ({ + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', +}); /** * @param {string} hex @@ -28,113 +39,880 @@ const withAlpha = (hex, alpha) => { return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`; }; -/** Narrows from full thickness at the head to a point at the end of the tail. */ -const taper = 'polygon(0 0, 100% 46%, 100% 54%, 0 100%)'; +/** + * @param {number} value + * @returns {number} + */ +const clamp01 = (value) => Math.min(1, Math.max(0, value)); + +const rainbow = + 'circle closest-side, transparent 0%, transparent 78%, rgba(255,70,70,0.75) 80%, rgba(255,170,40,0.75) 83%, rgba(255,240,80,0.7) 86%, rgba(80,230,120,0.7) 89%, rgba(70,160,255,0.75) 92%, rgba(150,90,255,0.7) 95%, transparent 98%'; +const confettiColors = ['#ffb36b', '#ff7fb8', '#9ff0ff', '#fff2a0', '#c9a0ff']; + +/** @typedef {import('./gacha-beta-paint.js').Puff} Puff */ + +/** Half the side of the baked vortex ring, in frame heights; it spans past the frame so it can rotate. */ +const ringReach = 1.45; + +/** The cloud banks only fill the lower frame, and are baked wider than it so they can drift. */ +const cloudBandTop = 0.42; +const cloudBandHeight = 0.62; +const cloudBandWidth = 1.2; /** - * One meteor: a white-hot head inside a coloured halo, a bright tapered core - * trail, and a wider, softer wisp around it. Drawn at the head and rotated so - * the tail points back up the travel diagonal. - * - * @param {{x: number, y: number, length: number, thickness: number, color: string, core: string, opacity: number}} props + * @param {() => number} random + * @param {number} count + * @returns {Puff[]} */ -const Meteor = ({x, y, length, thickness, color, core, opacity}) => ( -
+const createPuffs = (random, count) => + Array.from({length: count}, (_, index) => ({ + dx: (index / (count - 1) - 0.5) * 1.6 + (random() - 0.5) * 0.2, + dy: -Math.sin((index / (count - 1)) * Math.PI) * 0.35 + (random() - 0.5) * 0.12, + r: 0.3 + random() * 0.28, + })); + +/** + * Every seeded layout for one roll, so each wish draws a different sky. The + * cloud layers are baked here once and only moved per frame. + * + * @param {number} seed + * @param {number} width + * @param {number} height + */ +const createBetaLayout = (seed, width, height) => { + const random = createRandom(seed + 307); + const reach = ringReach * height; + const ringClusters = Array.from({length: 22}, (_, index) => { + const radians = (((index / 22) * 360 + random() * 12) * Math.PI) / 180; + const distance = (0.5 + random() * 0.22) * height; + return { + x: reach + Math.cos(radians) * distance * 1.25, + y: reach + Math.sin(radians) * distance, + size: (0.22 + random() * 0.16) * height, + puffs: createPuffs(random, 7), + }; + }); + const bankClusters = Array.from({length: 19}, (_, index) => { + const x = (index / 18) * cloudBandWidth; + return { + x: x * width, + y: (0.93 - Math.max(0, Math.min(x, 1) - 0.55) * 0.55 - cloudBandTop) * height, + size: (0.16 + random() * 0.12) * height, + puffs: createPuffs(random, 7), + }; + }); + const paint = {seed: seed + 41, frequency: 18 / height, strength: height * 0.022}; + const slivers = Array.from({length: 22}, () => ({ + along: random(), + side: (random() - 0.5) * 2, + length: 0.03 + random() * 0.07, + phase: random(), + })); + const confetti = Array.from({length: 40}, (_, index) => ({ + angle: 200 + random() * 120, + reach: 0.15 + random() * 0.45, + size: 0.006 + random() * 0.01, + delay: random() * 0.5, + color: confettiColors[index % confettiColors.length] ?? '#ffffff', + })); + const shards = createShards(random, 34); + const chains = Array.from({length: 5}, () => ({ + x: 0.3 + random() * 0.7, + y: random() * 0.5, + angle: -50 + random() * 100, + count: 6 + Math.floor(random() * 8), + drift: (random() - 0.5) * 0.04, + })); + const panels = Array.from({length: 9}, () => ({ + x: 0.25 + random() * 0.75, + y: random() * 0.85, + w: 0.02 + random() * 0.05, + h: 0.03 + random() * 0.07, + tilt: (random() - 0.5) * 40, + phase: random() * Math.PI * 2, + })); + return { + ringImage: bakeCloudLayer({...paint, width: reach * 2, height: reach * 2, clusters: ringClusters}), + bankImage: bakeCloudLayer({ + ...paint, + width: width * cloudBandWidth, + height: height * cloudBandHeight, + clusters: bankClusters, + }), + slivers, + confetti, + shards, + chains, + panels, + stars: createStarfield(60, seed), + }; +}; + +/** @typedef {ReturnType} BetaLayout */ +/** @typedef {{seconds: number, width: number, height: number, layout: BetaLayout, palette: import('./gacha-timeline.js').RarityPalette}} ShotProps */ + +/** + * Four-point star glint with a soft core, used for meteor heads. + * + * @param {{x: number, y: number, size: number, color: string, core?: string, rays?: number, spin?: number}} props + */ +const Glint = ({x, y, size, color, core = '#ffffff', rays = 4, spin = 0}) => ( + <>
+ {Array.from({length: rays}, (_, index) => { + const long = index % 2 === 0 ? 1.6 : 0.9; + return ( +
+ ); + })} + +); + +/** @param {ShotProps} props */ +const VortexShot = ({seconds, width, height, layout}) => { + const {zoom, spin} = getVortexState(seconds); + const cx = width / 2; + const cy = height * 0.36; + const eye = height * 0.34 * zoom; + return ( + + +
+ + +
+ + + + + ); +}; + +/** @param {ShotProps} props */ +const BeamShot = ({seconds, width, height}) => { + const t = progress(seconds, gachaBetaTimeline.vortexEnd, gachaBetaTimeline.beamEnd); + return ( + + {[ + {offset: 0, size: 0.34, alpha: 1}, + {offset: 0.18, size: 0.12, alpha: 0.7}, + ].map((beam, index) => ( +
+ ))} + + ); +}; + +/** + * Low cloud banks over the horizon: flat sea on the left, a rising bank on the right. + * + * @param {{width: number, height: number, layout: BetaLayout, drift: number, drop?: number}} props + * `drift` and `drop` shift the bank left and down, in frame fractions. + */ +const CloudSea = ({width, height, layout, drift, drop = 0}) => ( + <>
-
-
-
+ ); /** - * Hairline flourish beside the rank, ending in a dot, after the source - * video's title card. + * Turbulence displacement that twists the vortex eye's streaks into wisps. * - * @param {{height: number, side: -1 | 1, spread: number}} props + * @param {{height: number, seed: number}} props */ -const Swash = ({height, side, spread}) => ( -
0 ? 'row' : 'row-reverse'}}> -
0 ? 90 : 270}deg, #ffffff, rgba(255,255,255,0.15))`, - height: 1.5, - width: height * 0.22 * spread, - }} - /> -
-
+const PaintFilters = ({height, seed}) => ( + + + + + + + + ); +/** @param {ShotProps} props */ +const SkyShot = ({seconds, width, height, layout, palette}) => { + const tl = gachaBetaTimeline; + const head = getMeteorHead(seconds); + const halo = getHaloState(seconds); + const burst = getBurstState(seconds); + const hx = head.x * width; + const hy = head.y * height; + const trailAngle = 180 + (Math.atan2(0.44 * height, 0.54 * width) * 180) / Math.PI; + const sinceStop = Math.max(0, seconds - tl.meteorStop); + const core = burst.tint > 0.3 ? '#ffe8f4' : '#ffffff'; + const headColor = halo.warmth > 0.5 ? '#ff8fb4' : palette.glow; + return ( + + {layout.stars.map((star, index) => ( +
+ ))} + +
+
+
+ {layout.slivers.map((sliver, index) => { + const flow = (sliver.phase + seconds * (0.6 + head.speed)) % 1; + return ( +
+ ); + })} +
+ {sinceStop > 0 + ? layout.confetti.map((piece, index) => { + const t = clamp01((sinceStop - piece.delay) / 1.2); + if (t <= 0) { + return null; + } + const radians = (piece.angle * Math.PI) / 180; + const reach = piece.reach * height * (1 - (1 - t) ** 2); + const size = piece.size * height; + return ( +
+ ); + }) + : null} + {halo.opacity > 0 ? ( +
+ ) : null} + {burst.flare > 0 ? ( + <> +
+ {Array.from({length: 16}, (_, index) => ( +
+ ))} + + ) : null} + 0 ? 8 : 4} + size={height * (0.1 + 0.12 * halo.opacity + 0.5 * burst.glow)} + spin={sinceStop * 40} + x={hx} + y={hy} + /> + + + ); +}; + +/** @param {ShotProps} props */ +const VolleyShot = ({seconds, width, height, layout}) => { + const red = getRedMeteor(seconds); + const tilt = (Math.atan(0.12) * 180) / Math.PI; + return ( + + {volleyComets.map((comet, index) => { + const head = getVolleyCometHead(comet, seconds); + const size = comet.size * height; + const x = head.x * width; + const y = head.y * height; + return ( +
+
+
+
+
+ ); + })} + {red.flare > 0 ? ( + <> + {red.falling > 0 ? ( +
+ ) : null} +
+ + + ) : null} + + + ); +}; + /** - * A snow-rimmed ridge drawn as a huge ellipse, so its crest curves like the - * source shot's wide lens. + * The rank emblem: a faceted rhombus crystal around the rank letters. Drawn + * solid black for the silhouette and in the rarity colours on the card. * - * @param {{cx: number, cy: number, rx: number, ry: number, rotate: number, top: string, base: string}} props + * @param {{size: number, silhouette: boolean, palette: import('./gacha-timeline.js').RarityPalette}} props */ -const Ridge = ({cx, cy, rx, ry, rotate, top, base}) => ( -
-); +const Emblem = ({size, silhouette, palette}) => { + const fill = silhouette ? '#050305' : `linear-gradient(135deg, #ffffff 0%, ${palette.emblem} 100%)`; + return ( +
+
+ {silhouette ? null : ( +
+ )} +
+ {getBetaRank()} +
+
+ ); +}; + +/** @param {ShotProps} props */ +const SilhouetteShot = ({seconds, width, height, layout, palette}) => { + const tl = gachaBetaTimeline; + const t = progress(seconds, tl.volleyEnd, tl.silhouetteEnd); + const cx = width / 2; + const cy = height / 2; + const disc = height * (0.46 + 0.04 * t); + const emblem = height * (0.62 + 0.06 * t); + return ( + +
+
+ {[ + {w: 1.5, h: 0.75, turn: -20, thick: 0.06}, + {w: 1.2, h: 0.95, turn: 150, thick: 0.045}, + {w: 1.8, h: 0.6, turn: 200, thick: 0.035}, + {w: 1.0, h: 0.7, turn: 40, thick: 0.03}, + ].map((ribbon, index) => ( +
+ ))} + {layout.shards.map((shard, index) => { + const offset = getShardOffset(shard, seconds); + const size = shard.size * height; + return ( +
+ ); + })} +
+ +
+ + ); +}; + +/** + * @param {{seconds: number, width: number, height: number, layout: BetaLayout, palette: import('./gacha-timeline.js').RarityPalette, label: string}} props + */ +const RevealShot = ({seconds, width, height, layout, palette, label}) => { + const tl = gachaBetaTimeline; + const settle = progress(seconds, tl.silhouetteEnd, gachaBetaTimeline.silhouetteEnd + 2.5); + const plateIn = interpolate(seconds, [tl.plateIn, tl.plateIn + 0.4], [0, 1], { + ...clamp, + easing: Easing.out(Easing.cubic), + }); + const emblem = height * 0.62; + const labelLength = Array.from(label).length; + const nameSize = height * (labelLength <= 10 ? 0.085 : labelLength <= 20 ? 0.065 : 0.05); + const starSize = height * 0.042; + return ( + + + {layout.chains.map((chain, index) => ( +
+ {Array.from({length: chain.count}, (_, link) => ( +
+ ))} +
+ ))} + {layout.panels.map((panel, index) => ( +
+ ))} +
+
+ +
+ +
+
+
+
+
+ {label} +
+
+ {`Rank ${getBetaRank()}`} +
+
+
+
+ {getStarRevealTimes().map((time, index) => { + const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.4, 1], clamp); + return ( +
0 ? 1 : 0, + transform: `scale(${pop})`, + width: starSize, + }} + > + +
+ ); + })} +
+
+ + ); +}; /** * @param {import('../schemas/gacha-request.js').GachaRenderRequest} props @@ -143,183 +921,37 @@ export const GachaBetaComposition = (props) => { const frame = useCurrentFrame(); const {fps, width, height} = useVideoConfig(); const seconds = frame / fps; - const tl = gachaBetaTimeline; + const layout = useMemo(() => createBetaLayout(props.seed, width, height), [props.seed, width, height]); const palette = getRarityPalette(props.rarity); - const stars = useMemo(() => createStarfield(110, props.seed), [props.seed]); - - const fadeIn = interpolate(seconds, [0, tl.fadeInEnd], [0, 1], clamp); - const hero = getHeroMeteor(seconds, width, height); - const glint = getBurnGlint(seconds); - const labelIn = interpolate(seconds, [tl.labelIn, tl.labelIn + 0.6], [0, 1], easeOut); - const rankIn = interpolate(seconds, [tl.rankIn, tl.rankIn + 0.5], [0, 1], easeOut); - const anchor = {x: revealAnchor.x * width, y: revealAnchor.y * height}; + const shot = getShot(seconds); + const flash = getFlash(seconds); + const shared = {seconds, width, height, layout, palette}; return ( - - - {stars.map((star, index) => { - const size = star.radius * height * 0.8; - return ( -
- ); - })} - - {skyMeteors.map((meteor, index) => { - const head = getSkyMeteorHead(meteor, seconds, width, height); - return head ? ( - - ) : null; - })} - - {hero.visible > 0 ? ( - - ) : null} - - - + + {shot === 'vortex' ? : null} + {shot === 'beam' ? : null} + {shot === 'sky' ? : null} + {shot === 'volley' ? : null} + {shot === 'silhouette' ? : null} + {shot === 'reveal' ? : null} + {flash.opacity > 0 ? : null} + {flash.opacity > 0 && flash.color !== '#ffffff' && shot === 'sky' ? ( + - - - - {glint > 0 ? ( - <> -
- {[0, 90, 45, 135].map((rayAngle, index) => ( -
- ))} - - ) : null} - - {labelIn > 0 ? ( -
-
- -
- {getBetaRank()} -
- -
-
- {props.label} -
-
- ) : null} - + ) : null} ); }; diff --git a/renderer/src/remotion/gacha-beta-paint.js b/renderer/src/remotion/gacha-beta-paint.js new file mode 100644 index 0000000..1ce6941 --- /dev/null +++ b/renderer/src/remotion/gacha-beta-paint.js @@ -0,0 +1,144 @@ +/** + * Bakes the beta wish's cloud layers once per roll: puffs are painted with + * radial gradients on a canvas, then torn into painterly edges by a seeded + * noise displacement. Each frame then only moves the baked image, which keeps + * the clouds' look without paying for a live SVG filter on every frame. + */ + +import {createRandom} from './gacha-timeline.js'; + +/** + * @typedef {object} Puff + * @property {number} dx Offset in cluster units. + * @property {number} dy + * @property {number} r Radius in cluster units. + */ + +/** + * @typedef {object} CloudCluster + * @property {number} x Centre in canvas pixels. + * @property {number} y + * @property {number} size Cluster size in pixels. + * @property {Puff[]} puffs + */ + +/** + * Smooth value noise in [0, 1] over a seeded lattice, summed over octaves. + * + * @param {number} seed + * @param {number} frequency Lattice cells per pixel at the first octave. + * @param {number} octaves + * @returns {(x: number, y: number) => number} + */ +export const createValueNoise = (seed, frequency, octaves) => { + const random = createRandom(seed); + const size = 256; + const lattice = Float32Array.from({length: size * size}, () => random()); + /** @param {number} t */ + const fade = (t) => t * t * (3 - 2 * t); + /** + * @param {number} x + * @param {number} y + */ + const sample = (x, y) => { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const tx = fade(x - x0); + const ty = fade(y - y0); + const at = (/** @type {number} */ ix, /** @type {number} */ iy) => + lattice[(((iy % size) + size) % size) * size + (((ix % size) + size) % size)] ?? 0; + const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * tx; + const bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * tx; + return top + (bottom - top) * ty; + }; + return (x, y) => { + let total = 0; + let weight = 0; + let amplitude = 1; + let scale = frequency; + for (let octave = 0; octave < octaves; octave += 1) { + total += sample(x * scale, y * scale) * amplitude; + weight += amplitude; + amplitude /= 2; + scale *= 2; + } + return total / weight; + }; +}; + +/** + * Draws one cluster: a blue-shadowed base, then sunlit puffs on top. + * + * @param {CanvasRenderingContext2D} context + * @param {CloudCluster} cluster + */ +const paintCluster = (context, {x, y, size, puffs}) => { + const base = context.createRadialGradient(x, y + size * 0.1, 0, x, y + size * 0.3, size * 1.05); + base.addColorStop(0, 'rgba(170,198,240,0.95)'); + base.addColorStop(0.55, 'rgba(120,156,220,0.6)'); + base.addColorStop(1, 'rgba(120,156,220,0)'); + context.fillStyle = base; + context.beginPath(); + context.ellipse(x, y + size * 0.3, size * 1.05, size * 0.35, 0, 0, Math.PI * 2); + context.fill(); + for (const puff of puffs) { + const cx = x + puff.dx * size; + const cy = y + puff.dy * size; + const r = puff.r * size; + const shade = context.createRadialGradient(cx - r * 0.08, cy - r * 0.32, 0, cx - r * 0.08, cy - r * 0.32, r * 1.3); + shade.addColorStop(0, '#ffffff'); + shade.addColorStop(0.3, '#f5f9ff'); + shade.addColorStop(0.52, '#d6e5fa'); + shade.addColorStop(0.68, 'rgba(150,182,232,0.9)'); + shade.addColorStop(0.8, 'rgba(150,182,232,0)'); + context.fillStyle = shade; + context.beginPath(); + context.arc(cx, cy, r, 0, Math.PI * 2); + context.fill(); + } +}; + +/** + * Paints clusters onto a canvas of the given size, displaces the result with + * two noise fields for torn, painterly edges, and returns it as a PNG data + * URL. Returns an empty string outside a browser. + * + * @param {{width: number, height: number, clusters: CloudCluster[], seed: number, frequency: number, strength: number}} options + * `frequency` is noise cells per pixel; `strength` is the displacement in pixels. + * @returns {string} + */ +export const bakeCloudLayer = ({width, height, clusters, seed, frequency, strength}) => { + if (typeof document === 'undefined') { + return ''; + } + const w = Math.ceil(width); + const h = Math.ceil(height); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const context = canvas.getContext('2d'); + if (!context) { + return ''; + } + for (const cluster of clusters) { + paintCluster(context, cluster); + } + const source = context.getImageData(0, 0, w, h); + const output = context.createImageData(w, h); + const noiseX = createValueNoise(seed, frequency, 2); + const noiseY = createValueNoise(seed + 1, frequency, 2); + for (let y = 0; y < h; y += 1) { + for (let x = 0; x < w; x += 1) { + const sx = Math.min(w - 1, Math.max(0, Math.round(x + (noiseX(x, y) - 0.5) * 2 * strength))); + const sy = Math.min(h - 1, Math.max(0, Math.round(y + (noiseY(x, y) - 0.5) * 2 * strength))); + const from = (sy * w + sx) * 4; + const to = (y * w + x) * 4; + output.data[to] = source.data[from] ?? 0; + output.data[to + 1] = source.data[from + 1] ?? 0; + output.data[to + 2] = source.data[from + 2] ?? 0; + output.data[to + 3] = source.data[from + 3] ?? 0; + } + } + context.putImageData(output, 0, 0); + return canvas.toDataURL('image/png'); +}; diff --git a/renderer/src/remotion/gacha-beta-scene.js b/renderer/src/remotion/gacha-beta-scene.js index 064aaa5..f7a5edc 100644 --- a/renderer/src/remotion/gacha-beta-scene.js +++ b/renderer/src/remotion/gacha-beta-scene.js @@ -1,29 +1,35 @@ /** - * Choreography for the beta wish: coloured meteors glide down a night sky over - * a mountain ridge, a hero meteor in the rarity colour slows and burns out at - * the centre, and the result appears where it died. Kept free of React so the - * motion can be unit tested. + * Shot timing and motion for the beta wish, cut like the six-star wish + * sequence of an anime gacha game: a dive through a cloud vortex, a beam + * pass, a meteor that slows over a sea of clouds inside a rainbow halo and + * bursts, a red flash, a volley of falling crystal comets joined by a red + * one, a black silhouette on a red disc that sheds shards, and the splash + * card with its name plate and six stars. Kept free of React so the + * choreography can be unit tested. */ -export const gachaBetaTotalSeconds = 8; +export const gachaBetaTotalSeconds = 10.5; -/** Seconds from the start of the clip for each beat. */ +/** Seconds from the start of the clip for each cut and beat. */ export const gachaBetaTimeline = Object.freeze({ - fadeInEnd: 0.4, - heroStart: 1.1, - heroBurn: 4.4, - labelIn: 4.5, - rankIn: 4.9, + vortexEnd: 1.1, + beamEnd: 1.45, + meteorStop: 2.9, + haloEnd: 3.7, + burstEnd: 4.5, + redFlashEnd: 4.8, + volleyEnd: 6.45, + redStarIn: 5.4, + silhouetteEnd: 7.7, + plateIn: 7.8, + starsStart: 8.2, + starStep: 0.14, }); -/** Where the hero meteor burns out and the result is centred, as frame fractions. */ -export const revealAnchor = Object.freeze({x: 0.5, y: 0.44}); +/** The beta always reveals six stars and rank SSS. */ +export const betaStarCount = 6; -/** Every meteor travels down and to the right at this angle, as in the source shot. */ -export const meteorAngleDegrees = 32; - -const angle = (meteorAngleDegrees * Math.PI) / 180; -const direction = Object.freeze({x: Math.cos(angle), y: Math.sin(angle)}); +/** @typedef {'vortex' | 'beam' | 'sky' | 'volley' | 'silhouette' | 'reveal'} BetaShot */ /** * @param {number} value @@ -41,90 +47,238 @@ const smoothstep = (t) => { }; /** - * @typedef {object} SkyMeteor - * @property {number} start Seconds at which the head is at its entry point. - * @property {number} x Entry point, fraction of width. - * @property {number} y Entry point, fraction of height. - * @property {number} speed Frame heights per second along the travel direction. - * @property {number} length Tail length in frame heights. - * @property {number} width Tail thickness in frame heights. - * @property {string} color Halo and tail colour; every head burns white. - */ - -/** - * Background meteors in the source shot's colours. They enter from the upper - * left in a loose staggered volley and keep gliding slowly for the whole clip. - */ -export const skyMeteors = /** @type {readonly SkyMeteor[]} */ ( - Object.freeze([ - {start: 0, x: 0.36, y: -0.08, speed: 0.1, length: 0.5, width: 0.011, color: '#eef4ff'}, - {start: 0, x: 0.1, y: -0.02, speed: 0.11, length: 0.34, width: 0.014, color: '#52e07a'}, - {start: 0.2, x: -0.04, y: 0.12, speed: 0.12, length: 0.42, width: 0.018, color: '#9ff5e4'}, - {start: 0.9, x: -0.06, y: -0.05, speed: 0.12, length: 0.46, width: 0.016, color: '#f4f8ff'}, - {start: 1.3, x: 0.17, y: -0.09, speed: 0.1, length: 0.32, width: 0.012, color: '#ff8a7a'}, - {start: 1.6, x: -0.1, y: 0.06, speed: 0.13, length: 0.5, width: 0.015, color: '#6f9cff'}, - {start: 2.6, x: 0.55, y: -0.1, speed: 0.11, length: 0.4, width: 0.01, color: '#ffe6a8'}, - {start: 3.2, x: -0.08, y: -0.08, speed: 0.12, length: 0.38, width: 0.012, color: '#5fe8d0'}, - ]) -); - -/** - * Head position of a background meteor in pixels, or null before it enters. - * - * @param {SkyMeteor} meteor * @param {number} seconds - * @param {number} width - * @param {number} height - * @returns {{x: number, y: number} | null} + * @param {number} start + * @param {number} end + * @returns {number} */ -export const getSkyMeteorHead = (meteor, seconds, width, height) => { - if (seconds < meteor.start) { - return null; +export const progress = (seconds, start, end) => clamp01((seconds - start) / (end - start)); + +/** + * The shot on screen. Every change is a hard cut; flashes cover the + * louder ones. + * + * @param {number} seconds + * @returns {BetaShot} + */ +export const getShot = (seconds) => { + const tl = gachaBetaTimeline; + if (seconds < tl.vortexEnd) { + return 'vortex'; } - const travel = (seconds - meteor.start) * meteor.speed * height; - return {x: meteor.x * width + direction.x * travel, y: meteor.y * height + direction.y * travel}; + if (seconds < tl.beamEnd) { + return 'beam'; + } + if (seconds < tl.redFlashEnd) { + return 'sky'; + } + if (seconds < tl.volleyEnd) { + return 'volley'; + } + if (seconds < tl.silhouetteEnd) { + return 'silhouette'; + } + return 'reveal'; }; -/** How far up the diagonal the hero meteor enters, in frame heights from the anchor. */ -const heroReach = 0.72; - /** - * The hero meteor streaks down the same diagonal as the volley, slowing until - * it burns out on the reveal anchor. + * Full-frame flash colour and opacity: white out of the opening, red then + * white after the burst, white on the red meteor's impact, and a pale pink + * wash that hands the silhouette over to the splash card. * * @param {number} seconds - * @param {number} width - * @param {number} height - * @returns {{x: number, y: number, visible: number, tail: number}} - * `visible` fades the meteor out as it burns; `tail` shrinks its trail as - * it slows. + * @returns {{color: string, opacity: number}} */ -export const getHeroMeteor = (seconds, width, height) => { +export const getFlash = (seconds) => { const tl = gachaBetaTimeline; - const t = clamp01((seconds - tl.heroStart) / (tl.heroBurn - tl.heroStart)); - const remaining = heroReach * height * (1 - t) ** 2.4; + /** @type {Array<[number, number, number, string, number?]>} start, peak, end, colour, peak opacity */ + const flashes = [ + [-1, 0, 0.45, '#ffffff'], + [tl.burstEnd - 0.2, tl.burstEnd, tl.burstEnd + 0.12, '#ff5a6e', 0.8], + [tl.burstEnd + 0.1, tl.redFlashEnd, tl.redFlashEnd + 0.3, '#ffffff'], + [tl.volleyEnd - 0.12, tl.volleyEnd, tl.volleyEnd + 0.3, '#ffffff'], + [tl.silhouetteEnd - 0.3, tl.silhouetteEnd, tl.silhouetteEnd + 0.45, '#ffe6f0'], + ]; + for (const [start, peak, end, color, strength = 1] of flashes) { + if (seconds >= start && seconds <= end) { + const shape = + seconds <= peak ? smoothstep(progress(seconds, start, peak)) : 1 - smoothstep(progress(seconds, peak, end)); + return {color, opacity: shape * strength}; + } + } + return {color: '#ffffff', opacity: 0}; +}; + +/** + * Camera dive into the cloud vortex: the ring of clouds swells past the + * frame while the dark eye opens up. + * + * @param {number} seconds + * @returns {{zoom: number, spin: number}} + */ +export const getVortexState = (seconds) => { + const t = progress(seconds, 0, gachaBetaTimeline.vortexEnd); + return {zoom: 1 + 1.4 * t ** 2.2, spin: 40 * t}; +}; + +/** + * Meteor head over the sea of clouds, as frame fractions. It enters from the + * upper left and decelerates to a stop just right of centre, where the halo + * and burst happen. + * + * @param {number} seconds + * @returns {{x: number, y: number, speed: number}} + * `speed` is 1 on entry and 0 once the meteor has stopped. + */ +export const getMeteorHead = (seconds) => { + const tl = gachaBetaTimeline; + const t = progress(seconds, tl.beamEnd, tl.meteorStop); + const glide = 1 - (1 - t) ** 3; return { - x: revealAnchor.x * width - direction.x * remaining, - y: revealAnchor.y * height - direction.y * remaining, - visible: seconds < tl.heroStart ? 0 : 1 - smoothstep((seconds - (tl.heroBurn - 0.15)) / 0.3), - tail: 1 - 0.75 * t, + x: 0.02 + 0.54 * glide, + y: 0.12 + 0.44 * glide, + speed: (1 - t) ** 2, }; }; /** - * Brightness of the burn-out glint where the hero meteor dies, peaking at the - * burn and fading as the result settles. + * Rainbow halo around the stopped meteor: it blooms large, then contracts + * while the head warms from white to pink. * * @param {number} seconds - * @returns {number} + * @returns {{radius: number, opacity: number, warmth: number}} + * `radius` is in frame heights. */ -export const getBurnGlint = (seconds) => { - const elapsed = seconds - gachaBetaTimeline.heroBurn; - if (elapsed < -0.2 || elapsed > 1) { - return 0; - } - return elapsed < 0 ? 1 + elapsed / 0.2 : (1 - elapsed) ** 2; +export const getHaloState = (seconds) => { + const tl = gachaBetaTimeline; + const grow = smoothstep(progress(seconds, tl.meteorStop - 0.3, tl.meteorStop + 0.25)); + const shrink = smoothstep(progress(seconds, tl.meteorStop + 0.4, tl.haloEnd)); + return { + radius: 0.06 + 0.3 * grow - 0.24 * shrink, + opacity: grow * (1 - shrink), + warmth: smoothstep(progress(seconds, tl.meteorStop, tl.haloEnd)), + }; }; -/** The beta food wish always reveals SSS; rarity still controls the hero meteor's colour. */ +/** + * The burst that follows the halo: a swelling starburst and anamorphic flare + * while the sky tints violet. + * + * @param {number} seconds + * @returns {{glow: number, flare: number, tint: number}} + */ +export const getBurstState = (seconds) => { + const tl = gachaBetaTimeline; + const t = progress(seconds, tl.haloEnd - 0.2, tl.burstEnd); + return {glow: t ** 1.6, flare: smoothstep(t * 1.4), tint: smoothstep(t)}; +}; + +/** + * @typedef {object} VolleyComet + * @property {number} x Head x as a frame fraction. + * @property {number} start Seconds at which the head enters from the top. + * @property {number} fall Frame heights travelled until the volley ends. + * @property {number} size Head size in frame heights. + */ + +/** Crystal comets falling nearly straight down after the red flash. */ +export const volleyComets = /** @type {readonly VolleyComet[]} */ ( + Object.freeze([ + {x: 0.04, start: 4.7, fall: 0.95, size: 0.1}, + {x: 0.24, start: 4.78, fall: 1.05, size: 0.12}, + {x: 0.43, start: 4.74, fall: 0.85, size: 0.09}, + {x: 0.62, start: 4.82, fall: 1.0, size: 0.11}, + ]) +); + +/** The volley drifts right as it falls, as in the source shot. */ +const volleyDrift = 0.12; + +/** + * Head of a volley comet in frame fractions. It falls fast, then eases. + * + * @param {VolleyComet} comet + * @param {number} seconds + * @returns {{x: number, y: number}} + */ +export const getVolleyCometHead = (comet, seconds) => { + const t = progress(seconds, comet.start, gachaBetaTimeline.volleyEnd); + const fall = comet.fall * (1 - (1 - t) ** 2); + return {x: comet.x + volleyDrift * fall, y: -0.1 + fall}; +}; + +/** + * The red six-star meteor: a red star flares in the upper right, then drops + * and strikes just below centre as the volley ends. + * + * @param {number} seconds + * @returns {{x: number, y: number, flare: number, falling: number}} + * `flare` scales the star glint, `falling` runs from 0 to 1 over the drop. + */ +export const getRedMeteor = (seconds) => { + const tl = gachaBetaTimeline; + const appear = smoothstep(progress(seconds, tl.redStarIn, tl.redStarIn + 0.3)); + const falling = progress(seconds, tl.redStarIn + 0.35, tl.volleyEnd) ** 1.8; + return { + x: 0.68 - 0.2 * falling, + y: 0.08 + 0.55 * falling, + flare: appear * (1 + 1.5 * falling), + falling, + }; +}; + +/** + * @typedef {object} Shard + * @property {number} angle Flight direction in degrees. + * @property {number} reach Distance travelled in frame heights. + * @property {number} size Edge length in frame heights. + * @property {number} spin Rotation in degrees over the shot. + * @property {number} start Delay in seconds after the silhouette appears. + */ + +/** + * Seeded shards that burst off the silhouette and drift outward. + * + * @param {() => number} random + * @param {number} count + * @returns {Shard[]} + */ +export const createShards = (random, count) => + Array.from({length: count}, () => ({ + angle: random() * 360, + reach: 0.25 + random() * 0.55, + size: 0.015 + random() * 0.05, + spin: (random() - 0.5) * 540, + start: random() * 0.35, + })); + +/** + * Offset of a shard from the silhouette centre, in frame heights. + * + * @param {Shard} shard + * @param {number} seconds + * @returns {{dx: number, dy: number, rotate: number, opacity: number}} + */ +export const getShardOffset = (shard, seconds) => { + const tl = gachaBetaTimeline; + const t = progress(seconds, tl.volleyEnd + 0.15 + shard.start, tl.silhouetteEnd); + const travel = shard.reach * (1 - (1 - t) ** 3); + const radians = (shard.angle * Math.PI) / 180; + return { + dx: Math.cos(radians) * travel, + dy: Math.sin(radians) * travel, + rotate: shard.spin * t, + opacity: t > 0 ? 1 : 0, + }; +}; + +/** + * Seconds at which each reveal star pops in, left to right. + * + * @returns {number[]} + */ +export const getStarRevealTimes = () => + Array.from({length: betaStarCount}, (_, index) => gachaBetaTimeline.starsStart + index * gachaBetaTimeline.starStep); + +/** The beta food wish always reveals SSS; rarity tints the meteor and splash. */ export const getBetaRank = () => 'SSS'; diff --git a/renderer/src/render/render-gacha.js b/renderer/src/render/render-gacha.js index 72c2a12..89750d0 100644 --- a/renderer/src/render/render-gacha.js +++ b/renderer/src/render/render-gacha.js @@ -24,5 +24,5 @@ const createGachaRenderer = (compositionId) => (inputProps, options) => export const renderGachaVideo = createGachaRenderer('GachaWish'); -/** The beta wish: a meteor shower over a mountain ridge, ending in an SSS reveal. */ +/** The beta wish: a six-star style wish sequence ending in an SSS splash card. */ export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish'); diff --git a/renderer/test/gacha-beta-scene.test.js b/renderer/test/gacha-beta-scene.test.js index 63dd059..91e71aa 100644 --- a/renderer/test/gacha-beta-scene.test.js +++ b/renderer/test/gacha-beta-scene.test.js @@ -1,69 +1,106 @@ import {describe, expect, test} from 'vitest'; import { + betaStarCount, + createShards, gachaBetaTimeline, gachaBetaTotalSeconds, getBetaRank, - getBurnGlint, - getHeroMeteor, - getSkyMeteorHead, - meteorAngleDegrees, - revealAnchor, - skyMeteors, + getFlash, + getHaloState, + getMeteorHead, + getRedMeteor, + getShardOffset, + getShot, + getStarRevealTimes, + getVolleyCometHead, + getVortexState, + volleyComets, } from '../src/remotion/gacha-beta-scene.js'; +import {createRandom} from '../src/remotion/gacha-timeline.js'; -const width = 640; -const height = 360; - -describe('gacha beta meteor scene', () => { - test('beats run in order and the result holds before the clip ends', () => { +describe('gacha beta wish scene', () => { + test('shots cut in order: vortex, beam, sky, volley, silhouette, reveal', () => { const tl = gachaBetaTimeline; - expect(tl.fadeInEnd).toBeLessThan(tl.heroStart); - expect(tl.heroStart).toBeLessThan(tl.heroBurn); - expect(tl.heroBurn).toBeLessThan(tl.labelIn); - expect(tl.labelIn).toBeLessThan(tl.rankIn); - expect(tl.rankIn + 2).toBeLessThan(gachaBetaTotalSeconds); + /** @type {string[]} */ + const order = []; + for (let seconds = 0; seconds < gachaBetaTotalSeconds; seconds += 0.05) { + const shot = getShot(seconds); + if (order.at(-1) !== shot) { + order.push(shot); + } + } + expect(order).toEqual(['vortex', 'beam', 'sky', 'volley', 'silhouette', 'reveal']); + expect(tl.meteorStop).toBeLessThan(tl.haloEnd); + expect(tl.haloEnd).toBeLessThan(tl.burstEnd); + expect(tl.redStarIn).toBeLessThan(tl.volleyEnd); }); - test('background meteors appear on their cue and glide down to the right', () => { - for (const meteor of skyMeteors) { - expect(getSkyMeteorHead(meteor, meteor.start - 0.01, width, height)).toBeNull(); - const a = getSkyMeteorHead(meteor, meteor.start + 0.5, width, height); - const b = getSkyMeteorHead(meteor, meteor.start + 1.5, width, height); - expect(a && b).toBeTruthy(); - if (a && b) { - expect(b.x).toBeGreaterThan(a.x); - expect(b.y).toBeGreaterThan(a.y); - expect((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI).toBeCloseTo(meteorAngleDegrees); - } + test('the six stars finish popping with time to hold the card', () => { + const times = getStarRevealTimes(); + expect(times).toHaveLength(betaStarCount); + expect(betaStarCount).toBe(6); + expect(times[0]).toBeGreaterThan(gachaBetaTimeline.plateIn); + expect((times.at(-1) ?? Infinity) + 1).toBeLessThan(gachaBetaTotalSeconds); + }); + + test('flashes open the clip, cover the burst in red, and hand over at each loud cut', () => { + const tl = gachaBetaTimeline; + expect(getFlash(0).opacity).toBe(1); + expect(getFlash(tl.burstEnd)).toMatchObject({color: '#ff5a6e'}); + expect(getFlash(tl.redFlashEnd).opacity).toBe(1); + expect(getFlash(tl.volleyEnd).opacity).toBe(1); + expect(getFlash(tl.silhouetteEnd).opacity).toBe(1); + expect(getFlash(2).opacity).toBe(0); + expect(getFlash(9.5).opacity).toBe(0); + }); + + test('the camera dives into the vortex', () => { + expect(getVortexState(gachaBetaTimeline.vortexEnd).zoom).toBeGreaterThan(getVortexState(0).zoom); + }); + + test('the meteor decelerates to a stop and holds through the halo', () => { + const tl = gachaBetaTimeline; + const a = getMeteorHead(tl.beamEnd); + const b = getMeteorHead(tl.beamEnd + 0.3); + const c = getMeteorHead(tl.meteorStop - 0.3); + const stop = getMeteorHead(tl.meteorStop); + expect(b.x - a.x).toBeGreaterThan(stop.x - c.x); + expect(stop.speed).toBe(0); + expect(getMeteorHead(tl.haloEnd)).toEqual(stop); + }); + + test('the rainbow halo blooms after the stop and closes before the burst', () => { + const tl = gachaBetaTimeline; + expect(getHaloState(tl.beamEnd).opacity).toBe(0); + expect(getHaloState(tl.meteorStop + 0.3).opacity).toBeGreaterThan(0.8); + expect(getHaloState(tl.haloEnd).opacity).toBe(0); + }); + + test('volley comets fall and drift right', () => { + for (const comet of volleyComets) { + const early = getVolleyCometHead(comet, comet.start + 0.2); + const late = getVolleyCometHead(comet, gachaBetaTimeline.volleyEnd); + expect(late.y).toBeGreaterThan(early.y); + expect(late.x).toBeGreaterThan(early.x); } }); - test('the volley stays in the upper sky so the result area is clear at the reveal', () => { - for (const meteor of skyMeteors) { - const head = getSkyMeteorHead(meteor, gachaBetaTimeline.labelIn, width, height); - if (head) { - expect(head.y).toBeLessThan(height * 0.62); - } - } - }); - - test('hero meteor is hidden before its cue, then slows to burn out on the anchor', () => { + test('the red meteor flares, then drops toward the centre by the impact', () => { const tl = gachaBetaTimeline; - expect(getHeroMeteor(tl.heroStart - 0.1, width, height).visible).toBe(0); - const early = getHeroMeteor(tl.heroStart + 0.5, width, height); - const mid = getHeroMeteor(tl.heroStart + 1, width, height); - const late = getHeroMeteor(tl.heroBurn - 0.5, width, height); - const late2 = getHeroMeteor(tl.heroBurn, width, height); - expect(Math.hypot(mid.x - early.x, mid.y - early.y)).toBeGreaterThan(Math.hypot(late2.x - late.x, late2.y - late.y)); - expect(late2.x).toBeCloseTo(revealAnchor.x * width); - expect(late2.y).toBeCloseTo(revealAnchor.y * height); - expect(getHeroMeteor(tl.heroBurn + 0.2, width, height).visible).toBe(0); + expect(getRedMeteor(tl.redStarIn).flare).toBe(0); + const impact = getRedMeteor(tl.volleyEnd); + expect(impact.falling).toBe(1); + expect(impact.y).toBeGreaterThan(getRedMeteor(tl.redStarIn + 0.3).y); }); - test('burn glint fires only around the burn-out', () => { - expect(getBurnGlint(gachaBetaTimeline.heroBurn - 1)).toBe(0); - expect(getBurnGlint(gachaBetaTimeline.heroBurn)).toBe(1); - expect(getBurnGlint(gachaBetaTimeline.heroBurn + 2)).toBe(0); + test('silhouette shards burst outward from the centre', () => { + const shards = createShards(createRandom(3), 12); + for (const shard of shards) { + const mid = getShardOffset(shard, gachaBetaTimeline.volleyEnd + 0.7); + const end = getShardOffset(shard, gachaBetaTimeline.silhouetteEnd); + expect(Math.hypot(end.dx, end.dy)).toBeGreaterThanOrEqual(Math.hypot(mid.dx, mid.dy)); + expect(Math.hypot(end.dx, end.dy)).toBeCloseTo(shard.reach); + } }); test('beta food reveal displays SSS', () => {