diff --git a/renderer/README.md b/renderer/README.md index ad3ae36..fa41c9c 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -67,19 +67,15 @@ only draws it. ### Gacha wish beta `POST /api/gachabeta` takes the same body and returns the same response as -`/api/gacha`, rendering an 8-second beta style instead: a cosmic, astrology -themed night sky with toon-shaded clouds and stars, without outlines, seen -through a perspective action camera: - -1. The sky fades in around a main cloud. A comet approaches from behind, - lights the cloud's rim, then punches through it with scattered puffs and an impact ring. -2. The camera follows the flying star as its light and lens flare intensify, - ending in a starburst and white flash. -3. The food label appears inside an astrology ring with rank `SSS`. +`/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`. The caller randomly chooses the food and sends it as `label` (for example, -`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` still chooses the -comet's blue, purple, or gold palette. `/api/gacha` keeps its existing ranks. +`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` only chooses the hero +meteor's colour. `/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 4ae1ab2..1190749 100644 --- a/renderer/src/remotion/GachaBetaComposition.jsx +++ b/renderer/src/remotion/GachaBetaComposition.jsx @@ -1,83 +1,23 @@ import {useMemo} from 'react'; -import {Star} from '@remotion/shapes'; import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion'; import { gachaBetaTimeline, getBetaRank, - getBetaShake, - getCamera, - getCometGlow, - getCometPosition, - heroCloud, - pierceTime, - project, + getBurnGlint, + getHeroMeteor, + getSkyMeteorHead, + meteorAngleDegrees, + revealAnchor, + skyMeteors, } from './gacha-beta-scene.js'; -import {createRandom, createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} from './gacha-timeline.js'; +import {createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} from './gacha-timeline.js'; -const baseFont = +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 clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); - -/** A luminous night sky behind flat, outline-free cloud shading. */ -const skyBackground = - 'radial-gradient(ellipse at 50% 105%, #7886ce 0%, #353f83 32%, #171b49 65%, #090e29 100%)'; -const cloudShade = 'radial-gradient(circle at 38% 32%, #f6f4ff 0% 44%, #cdd1f6 44% 70%, #959cdb 70%)'; -const heroShade = 'radial-gradient(circle at 38% 30%, #ffffff 0% 46%, #d8dcfb 46% 72%, #a0a8e4 72%)'; - -const trailSamples = 44; -const trailStep = 0.01; - -/** - * Puffs that make one toon cloud, in units of the cloud radius. - * - * @param {() => number} random - * @param {number} count - */ -const createPuffs = (random, count) => - Array.from({length: count}, (_, index) => { - const angle = (index / count) * Math.PI * 2 + random() * 0.6; - const reach = index === 0 ? 0 : 0.35 + random() * 0.45; - return { - dx: Math.cos(angle) * reach, - dy: Math.sin(angle) * reach * 0.45 + (random() - 0.3) * 0.15, - r: 0.38 + random() * 0.3, - }; - }); - -/** - * Every seeded layout for one roll: stars, constellation links, and the - * clouds scattered through the sky. - * - * @param {number} seed - */ -const createBetaScene = (seed) => { - const random = createRandom(seed + 101); - const stars = createStarfield(80, seed); - const links = Array.from({length: 7}, () => { - const a = Math.floor(random() * 24); - return [a, a + 1 + Math.floor(random() * 3)]; - }); - const clouds = Array.from({length: 14}, () => ({ - x: (random() - 0.5) * 3200, - y: 80 + random() * 420, - z: 1100 + random() * 5200, - radius: 160 + random() * 220, - puffs: createPuffs(random, 6), - })).sort((a, b) => b.z - a.z); - const heroPuffs = createPuffs(random, 13); - const confetti = Array.from({length: 34}, () => ({ - x: random(), - offset: random(), - speed: 0.18 + random() * 0.3, - size: 0.02 + random() * 0.025, - spin: (random() - 0.5) * 400, - hue: Math.floor(random() * 4), - })); - return {stars, links, clouds, heroPuffs, confetti}; -}; - -const confettiColors = ['#ffd36b', '#ff8cd8', '#8cff9a', '#8fd0ff']; +const easeOut = /** @type {const} */ ({...clamp, easing: Easing.out(Easing.cubic)}); /** * @param {string} hex @@ -88,43 +28,116 @@ 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 {{size: number, fill: string, shade: string}} props + * 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 */ -const ToonStar = ({size, fill, shade}) => ( -
- ( +
+
+
+
+
-
- -
); /** - * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest + * Hairline flourish beside the rank, ending in a dot, after the source + * video's title card. + * + * @param {{height: number, side: -1 | 1, spread: 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, + }} + /> +
+
+); /** - * Beta wish: a cosmic, astrology-themed night sky in toon shading. The - * camera dollies toward a hero cloud, a comet bursts through it, the camera - * chases the comet as it flares, and after the burst the result appears with - * its rank. + * A snow-rimmed ridge drawn as a huge ellipse, so its crest curves like the + * source shot's wide lens. * - * @param {GachaRenderRequest} props + * @param {{cx: number, cy: number, rx: number, ry: number, rotate: number, top: string, base: string}} props + */ +const Ridge = ({cx, cy, rx, ry, rotate, top, base}) => ( +
+); + +/** + * @param {import('../schemas/gacha-request.js').GachaRenderRequest} props */ export const GachaBetaComposition = (props) => { const frame = useCurrentFrame(); @@ -132,473 +145,181 @@ export const GachaBetaComposition = (props) => { const seconds = frame / fps; const tl = gachaBetaTimeline; const palette = getRarityPalette(props.rarity); - const scene = useMemo(() => createBetaScene(props.seed), [props.seed]); + const stars = useMemo(() => createStarfield(110, props.seed), [props.seed]); - const camera = getCamera(seconds); - const shake = getBetaShake(seconds, height); - const comet = getCometPosition(seconds); - const cometScreen = project(comet, camera, width, height); - const glow = getCometGlow(seconds); - const cometVisible = seconds >= tl.cometAppear && seconds < tl.flashPeak; - const burst = interpolate(seconds, [tl.flightEnd - 0.1, tl.flashPeak], [0, 1], clamp); - const flash = interpolate(seconds, [tl.flightEnd, tl.flashPeak, tl.flashEnd], [0, 1, 0], clamp); - const cardVisible = seconds >= tl.cardIn; - const skyIn = interpolate(seconds, [0, 0.65], [0, 1], clamp); - const revealIn = interpolate(seconds, [tl.cardIn, tl.flashEnd + 0.2], [0, 1], clamp); - const pierceBurst = interpolate(seconds, [pierceTime, pierceTime + 0.9], [0, 1], { - ...clamp, - easing: Easing.out(Easing.cubic), - }); - const speedLines = interpolate( - seconds, - [tl.pierceEnd, tl.pierceEnd + 0.4, tl.flightEnd - 0.6, tl.flightEnd], - [0, 1, 1, 0], - clamp, - ); - - /** @type {{depth: number, node: import('react').ReactNode}[]} */ - const drawables = []; - - for (const [index, cloud] of scene.clouds.entries()) { - const screen = project(cloud, camera, width, height); - if (!screen) { - continue; - } - const radius = cloud.radius * screen.scale; - drawables.push({ - depth: screen.depth, - node: ( -
- {cloud.puffs.map((puff, puffIndex) => ( -
- ))} -
- ), - }); - } - - const hero = project(heroCloud, camera, width, height); - if (hero) { - const radius = heroCloud.radius * hero.scale; - drawables.push({ - depth: hero.depth, - node: ( -
- {scene.heroPuffs.map((puff, index) => { - const distance = Math.hypot(puff.dx, puff.dy) || 1; - const push = pierceBurst * (0.6 + (1 - Math.min(1, distance)) * 1.4); - const angle = (index / scene.heroPuffs.length) * Math.PI * 2; - const ox = Math.cos(angle) * push; - const oy = Math.sin(angle) * push; - const opacity = 1 - pierceBurst * (distance < 0.5 ? 1 : 0.65); - return ( -
- ); - })} -
- ), - }); - } - - if (cometVisible && cometScreen) { - const trail = []; - for (let index = trailSamples - 1; index >= 1; index -= 1) { - const past = seconds - index * trailStep; - if (past < tl.cometAppear) { - continue; - } - const point = project(getCometPosition(past), camera, width, height); - if (!point) { - continue; - } - const fade = 1 - index / trailSamples; - const radius = (14 + 26 * glow) * point.scale * fade; - trail.push( -
, - ); - } - const headSize = (34 + 60 * glow + 90 * burst) * cometScreen.scale; - const flare = headSize * (1.4 + glow * 3); - drawables.push({ - depth: cometScreen.depth, - node: ( -
- {trail} -
- {[0, 90, 45, 135].map((angle, index) => ( -
- ))} -
- -
- {burst > 0 ? ( -
- ) : null} -
- ), - }); - } - - // While the comet hides behind the hero cloud, its light spills around the - // cloud's rim so it visibly appears from behind before it breaks through. - const backlight = interpolate(seconds, [tl.cometAppear, tl.pierceStart, pierceTime], [0, 1, 0.6], clamp); - if (backlight > 0 && seconds < pierceTime) { - const light = project({...comet, y: heroCloud.y}, camera, width, height); - if (light) { - const radius = heroCloud.radius * 1.7 * light.scale; - drawables.push({ - depth: light.depth + 1, - node: ( -
- ), - }); - } - } - - if (hero && seconds >= pierceTime && seconds < pierceTime + 0.65) { - const impact = (seconds - pierceTime) / 0.65; - const diameter = heroCloud.radius * hero.scale * (0.4 + impact * 3.5); - drawables.push({ - depth: hero.depth - 1, - node: ( -
- ), - }); - } - - drawables.sort((a, b) => b.depth - a.depth); - - const rank = getBetaRank(); - const cardPop = interpolate(seconds, [tl.cardIn, tl.cardIn + 0.35, tl.cardIn + 0.55], [0.6, 1.08, 1], { - ...clamp, - easing: Easing.out(Easing.cubic), - }); - const rankPop = interpolate(seconds, [tl.rankIn, tl.rankIn + 0.18, tl.rankIn + 0.36], [0, 1.35, 1], clamp); + 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}; return ( - + - - - - - - - {Array.from({length: 12}, (_, index) => { - const angle = (index / 12) * Math.PI * 2; - return ( - - - - - ); - })} - - {scene.links.map(([a, b], index) => { - const from = scene.stars[a ?? 0]; - const to = scene.stars[b ?? 0]; - if (!from || !to) { - return null; - } - return ( - - ); - })} - - - {scene.stars.map((star, index) => { - const twinkle = getTwinkle(seconds, star); - const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle); + {stars.map((star, index) => { + const size = star.radius * height * 0.8; return (
0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined, height: size, - left: star.x * width - camera.x * 0.02, - opacity: 0.25 + 0.75 * twinkle, + left: star.x * width, + opacity: 0.15 + 0.55 * getTwinkle(seconds, star), position: 'absolute', - top: star.y * height * 0.7, + top: star.y * height * 0.95, width: size, }} /> ); })} - {drawables.map((drawable) => drawable.node)} + {skyMeteors.map((meteor, index) => { + const head = getSkyMeteorHead(meteor, seconds, width, height); + return head ? ( + + ) : null; + })} - {speedLines > 0 && cometScreen - ? Array.from({length: 18}, (_, index) => { - const angle = (index / 18) * 360 + ((index * 37) % 11); - const reach = ((seconds * 2.2 + index * 0.137) % 1) * width * 0.7; - return ( -
- ); - }) - : null} - - - {cardVisible ? ( - -
0 ? ( + - {scene.confetti.map((piece, index) => { - const y = ((piece.offset + (seconds - tl.cardIn) * piece.speed) % 1.2) - 0.1; - const size = piece.size * height; - return ( + ) : null} + + + + + + + {glint > 0 ? ( + <> +
+ {[0, 90, 45, 135].map((rayAngle, index) => (
- -
- ); - })} -
- {Array.from({length: 12}, (_, index) => ( -
))} -
+ + ) : null} + + {labelIn > 0 ? (
0 ? 1 : 0, - paddingRight: height * 0.03, - transform: `scale(${rankPop})`, - WebkitBackgroundClip: 'text', - backgroundClip: 'text', - filter: `drop-shadow(0 0 ${height * 0.04}px ${palette.glow})`, + alignItems: 'center', + display: 'flex', + gap: height * 0.02, + opacity: rankIn, + transform: `scale(${0.9 + 0.1 * rankIn})`, }} > - {rank} + +
+ {getBetaRank()} +
+
{props.label}
- - ) : null} - - {flash > 0 ? : null} + ) : null} + ); }; diff --git a/renderer/src/remotion/gacha-beta-scene.js b/renderer/src/remotion/gacha-beta-scene.js index c1e67f9..064aaa5 100644 --- a/renderer/src/remotion/gacha-beta-scene.js +++ b/renderer/src/remotion/gacha-beta-scene.js @@ -1,67 +1,29 @@ /** - * Scene math for the beta wish: a toon-shaded night sky viewed through a - * perspective camera that dollies toward a hero cloud, follows a comet that - * pierces it, and holds on the comet as it flares and bursts. Kept free of - * React so the choreography can be unit tested. - */ - -/** - * @typedef {object} Vec3 - * @property {number} x - * @property {number} y - * @property {number} z - */ - -/** - * @typedef {object} Camera - * @property {number} x - * @property {number} y - * @property {number} z - * @property {number} roll Screen roll in degrees. - */ - -/** - * @typedef {object} Projection - * @property {number} x Screen pixels. - * @property {number} y Screen pixels. - * @property {number} scale Pixels per world unit at that depth. - * @property {number} depth World units in front of the camera. + * 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. */ export const gachaBetaTotalSeconds = 8; -/** Seconds from the start of the clip for each beat of the three phases. */ +/** Seconds from the start of the clip for each beat. */ export const gachaBetaTimeline = Object.freeze({ - dollyEnd: 2, - cometAppear: 1.2, - pierceStart: 1.9, - pierceEnd: 2.6, - flightEnd: 4.8, - flashPeak: 5.05, - flashEnd: 5.45, - cardIn: 5.2, - rankIn: 5.6, + fadeInEnd: 0.4, + heroStart: 1.1, + heroBurn: 4.4, + labelIn: 4.5, + rankIn: 4.9, }); -/** The cloud the camera dollies toward and the comet pierces. */ -export const heroCloud = Object.freeze({x: 0, y: 30, z: 800, radius: 170}); +/** Where the hero meteor burns out and the result is centred, as frame fractions. */ +export const revealAnchor = Object.freeze({x: 0.5, y: 0.44}); -/** The approach behind the cloud, the entry point, and the exit in front. */ -const cometHidden = Object.freeze({x: -1050, y: -430, z: 1500}); -const cometPierceFrom = Object.freeze({x: 40, y: 20, z: 1300}); -const cometPierceTo = Object.freeze({x: 0, y: 30, z: 500}); +/** Every meteor travels down and to the right at this angle, as in the source shot. */ +export const meteorAngleDegrees = 32; -/** The camera trails the comet from behind and slightly above it. */ -const cameraFollowOffset = Object.freeze({x: 0, y: -30, z: -420}); - -/** - * Seconds at which the comet crosses the hero cloud's plane. The pierce moves - * linearly in depth, so the crossing is a fixed fraction of the segment. - */ -export const pierceTime = - gachaBetaTimeline.pierceStart + - ((gachaBetaTimeline.pierceEnd - gachaBetaTimeline.pierceStart) * (cometPierceFrom.z - heroCloud.z)) / - (cometPierceFrom.z - cometPierceTo.z); +const angle = (meteorAngleDegrees * Math.PI) / 180; +const direction = Object.freeze({x: Math.cos(angle), y: Math.sin(angle)}); /** * @param {number} value @@ -73,132 +35,96 @@ const clamp01 = (value) => Math.min(1, Math.max(0, value)); * @param {number} t * @returns {number} */ -const easeInOutSine = (t) => -(Math.cos(Math.PI * clamp01(t)) - 1) / 2; - -/** - * @param {number} t - * @returns {number} - */ -const easeOutCubic = (t) => 1 - (1 - clamp01(t)) ** 3; - -/** - * @param {Vec3} a - * @param {Vec3} b - * @param {number} t - * @returns {Vec3} - */ -const lerp3 = (a, b, t) => ({x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: a.z + (b.z - a.z) * t}); - -/** - * Comet position in world units. It approaches behind the hero cloud, punches - * straight through it toward the camera, then flies off on a rising arc and - * slows as it gathers light for the burst. - * - * @param {number} seconds - * @returns {Vec3} - */ -export const getCometPosition = (seconds) => { - const tl = gachaBetaTimeline; - if (seconds < tl.pierceStart) { - const t = clamp01((seconds - tl.cometAppear) / (tl.pierceStart - tl.cometAppear)); - return lerp3(cometHidden, cometPierceFrom, t); - } - if (seconds < tl.pierceEnd) { - return lerp3(cometPierceFrom, cometPierceTo, (seconds - tl.pierceStart) / (tl.pierceEnd - tl.pierceStart)); - } - const u = easeOutCubic((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd)); - return { - x: cometPierceTo.x + 600 * u, - y: cometPierceTo.y - 300 * u + 120 * Math.sin(Math.PI * u), - z: cometPierceTo.z + 2600 * u, - }; +const smoothstep = (t) => { + const x = clamp01(t); + return x * x * (3 - 2 * x); }; /** - * Action camera: a slow dolly toward the hero cloud, then a chase that locks - * onto the comet from behind with a little lag and banks into its turn. - * - * @param {number} seconds - * @returns {Camera} + * @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. */ -export const getCamera = (seconds) => { - const tl = gachaBetaTimeline; - const dolly = {x: 0, y: 0, z: 250 * easeInOutSine(seconds / tl.dollyEnd)}; - const lagged = getCometPosition(Math.max(tl.pierceEnd, seconds - 0.12)); - const chase = { - x: lagged.x + cameraFollowOffset.x, - y: lagged.y + cameraFollowOffset.y, - z: lagged.z + cameraFollowOffset.z, - }; - const follow = easeInOutSine((seconds - pierceTime) / (tl.pierceEnd + 0.4 - pierceTime)); - const position = lerp3(dolly, {...chase, z: Math.max(dolly.z, chase.z)}, follow); - const bank = seconds > tl.pierceEnd ? 7 * Math.sin(Math.PI * clamp01((seconds - tl.pierceEnd) / 1.6)) : 0; - return {...position, roll: bank * follow}; -}; /** - * Perspective projection onto the frame. Returns null for points behind or - * too close to the camera. + * 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 {Vec3} point - * @param {Camera} camera + * @param {SkyMeteor} meteor + * @param {number} seconds * @param {number} width * @param {number} height - * @returns {Projection | null} + * @returns {{x: number, y: number} | null} */ -export const project = (point, camera, width, height) => { - const depth = point.z - camera.z; - if (depth < 30) { +export const getSkyMeteorHead = (meteor, seconds, width, height) => { + if (seconds < meteor.start) { return null; } - const focal = height * 1.2; - const scale = focal / depth; + const travel = (seconds - meteor.start) * meteor.speed * height; + return {x: meteor.x * width + direction.x * travel, y: meteor.y * height + direction.y * travel}; +}; + +/** 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. + * + * @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. + */ +export const getHeroMeteor = (seconds, width, height) => { + const tl = gachaBetaTimeline; + const t = clamp01((seconds - tl.heroStart) / (tl.heroBurn - tl.heroStart)); + const remaining = heroReach * height * (1 - t) ** 2.4; return { - x: width / 2 + (point.x - camera.x) * scale, - y: height / 2 + (point.y - camera.y) * scale, - scale, - depth, + 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, }; }; /** - * Comet glow from 0 to 1: it ignites behind the cloud, then builds steadily - * through the flight until the burst. + * Brightness of the burn-out glint where the hero meteor dies, peaking at the + * burn and fading as the result settles. * * @param {number} seconds * @returns {number} */ -export const getCometGlow = (seconds) => { - const tl = gachaBetaTimeline; - if (seconds < tl.pierceEnd) { - return 0.35 * clamp01((seconds - tl.cometAppear) / 0.4); +export const getBurnGlint = (seconds) => { + const elapsed = seconds - gachaBetaTimeline.heroBurn; + if (elapsed < -0.2 || elapsed > 1) { + return 0; } - return 0.35 + 0.65 * clamp01((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd)) ** 2; + return elapsed < 0 ? 1 + elapsed / 0.2 : (1 - elapsed) ** 2; }; -/** - * Decaying shake in pixels for the pierce and the burst. - * - * @param {number} seconds - * @param {number} height - * @returns {{x: number, y: number}} - */ -export const getBetaShake = (seconds, height) => { - let x = 0; - let y = 0; - for (const [start, amplitude] of [ - [pierceTime, 0.02], - [gachaBetaTimeline.flightEnd, 0.035], - ]) { - const elapsed = seconds - (start ?? 0); - if (elapsed >= 0 && elapsed < 0.6) { - const decay = (amplitude ?? 0) * height * (1 - elapsed / 0.6) ** 2; - x += Math.sin(elapsed * 77) * decay; - y += Math.cos(elapsed * 59) * decay; - } - } - return {x, y}; -}; - -/** The beta food wish always reveals SSS; rarity still controls its light palette. */ +/** The beta food wish always reveals SSS; rarity still controls the hero meteor's colour. */ export const getBetaRank = () => 'SSS'; diff --git a/renderer/src/render/render-gacha.js b/renderer/src/render/render-gacha.js index a7e5e76..72c2a12 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: toon-shaded 3D-perspective sky, comet through a cloud, SSS reveal. */ +/** The beta wish: a meteor shower over a mountain ridge, ending in an SSS reveal. */ export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish'); diff --git a/renderer/test/gacha-beta-scene.test.js b/renderer/test/gacha-beta-scene.test.js index 47b4cf8..63dd059 100644 --- a/renderer/test/gacha-beta-scene.test.js +++ b/renderer/test/gacha-beta-scene.test.js @@ -3,101 +3,67 @@ import { gachaBetaTimeline, gachaBetaTotalSeconds, getBetaRank, - getBetaShake, - getCamera, - getCometGlow, - getCometPosition, - heroCloud, - pierceTime, - project, + getBurnGlint, + getHeroMeteor, + getSkyMeteorHead, + meteorAngleDegrees, + revealAnchor, + skyMeteors, } from '../src/remotion/gacha-beta-scene.js'; -describe('gacha beta scene', () => { - test('phases run in order inside the clip', () => { +const width = 640; +const height = 360; + +describe('gacha beta meteor scene', () => { + test('beats run in order and the result holds before the clip ends', () => { const tl = gachaBetaTimeline; - expect(tl.cometAppear).toBeLessThan(tl.pierceStart); - expect(tl.pierceStart).toBeLessThan(pierceTime); - expect(pierceTime).toBeLessThan(tl.pierceEnd); - expect(tl.pierceEnd).toBeLessThan(tl.flightEnd); - expect(tl.flightEnd).toBeLessThan(tl.flashPeak); - expect(tl.cardIn).toBeLessThan(tl.flashEnd); - expect(tl.rankIn + 1).toBeLessThan(gachaBetaTotalSeconds); + 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); }); - test('comet waits behind the hero cloud, then crosses its plane at the pierce', () => { - expect(getCometPosition(gachaBetaTimeline.cometAppear).z).toBeGreaterThan(heroCloud.z); - expect(getCometPosition(pierceTime).z).toBeCloseTo(heroCloud.z); - expect(getCometPosition(gachaBetaTimeline.pierceEnd).z).toBeLessThan(heroCloud.z); - }); - - test('comet is hidden behind the cloud: projected inside its silhouette', () => { - const seconds = gachaBetaTimeline.pierceStart; - const camera = getCamera(seconds); - const comet = project(getCometPosition(seconds), camera, 640, 360); - const cloud = project(heroCloud, camera, 640, 360); - expect(comet && cloud).toBeTruthy(); - if (comet && cloud) { - expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeLessThan(heroCloud.radius * cloud.scale); - expect(comet.depth).toBeGreaterThan(cloud.depth); - } - }); - - test('approaching comet is visible beside the cloud before passing behind it', () => { - const seconds = 1.4; - const camera = getCamera(seconds); - const comet = project(getCometPosition(seconds), camera, 640, 360); - const cloud = project(heroCloud, camera, 640, 360); - expect(comet && cloud).toBeTruthy(); - if (comet && cloud) { - expect(comet.x).toBeGreaterThan(0); - expect(comet.x).toBeLessThan(640); - expect(comet.y).toBeGreaterThan(0); - expect(comet.y).toBeLessThan(360); - expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeGreaterThan(heroCloud.radius * cloud.scale); - expect(comet.depth).toBeGreaterThan(cloud.depth); - } - }); - - test('camera dollies toward the cloud, then chases the comet and keeps it in frame', () => { - expect(getCamera(1).z).toBeGreaterThan(getCamera(0).z); - for (const seconds of [3, 3.5, 4, 4.5]) { - const camera = getCamera(seconds); - const comet = project(getCometPosition(seconds), camera, 640, 360); - expect(comet).not.toBeNull(); - if (comet) { - expect(comet.x).toBeGreaterThan(640 * 0.2); - expect(comet.x).toBeLessThan(640 * 0.8); - expect(comet.y).toBeGreaterThan(360 * 0.2); - expect(comet.y).toBeLessThan(360 * 0.8); + test('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('camera never moves backward', () => { - let previous = -Infinity; - for (let seconds = 0; seconds <= gachaBetaTimeline.flightEnd; seconds += 0.05) { - const {z} = getCamera(seconds); - expect(z).toBeGreaterThanOrEqual(previous - 1e-6); - previous = z; + test('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('projection drops points behind the camera', () => { - expect(project({x: 0, y: 0, z: 10}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360)).toBeNull(); - const centre = project({x: 0, y: 0, z: 500}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360); - expect(centre).toMatchObject({x: 320, y: 180}); + test('hero meteor is hidden before its cue, then slows to burn out on the anchor', () => { + 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); }); - test('comet glow builds to full by the burst', () => { - expect(getCometGlow(0)).toBe(0); - expect(getCometGlow(3)).toBeGreaterThan(getCometGlow(2)); - expect(getCometGlow(gachaBetaTimeline.flightEnd)).toBeCloseTo(1); - }); - - test('shake fires at the pierce and the burst only', () => { - expect(getBetaShake(1, 360)).toEqual({x: 0, y: 0}); - expect(Math.hypot(...Object.values(getBetaShake(pierceTime + 0.05, 360)))).toBeGreaterThan(0); - expect(Math.hypot(...Object.values(getBetaShake(gachaBetaTimeline.flightEnd + 0.05, 360)))).toBeGreaterThan(0); + test('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('beta food reveal displays SSS', () => {