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}) => (
-
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', () => {