// AP2 Fraction Opening Movie — clean center-only
// Geometric motion leading to the AP2 FRACTION logo. 8-second loop.
// No kicker, no HUD, no subhead, no index — just the reveal.
const CC = {
base: '#F4F1EA',
baseDim:'rgba(244,241,234,0.55)',
baseFaint:'rgba(244,241,234,0.12)',
ink: '#0A0A09',
ink2: '#141311',
green: '#1F3A2E',
greenL: '#2E5142',
brass: '#A08256',
brassL: '#C4A172',
};
const TAU = Math.PI * 2;
const lerp = (a, b, t) => a + (b - a) * t;
// ───────────────────────────────────────────────
// Scene (8s loop):
// 0.0 – 1.2 grid fades in; scan line sweeps; central axis draws
// 1.2 – 3.0 particles rise from horizon, align to a ring
// 3.0 – 4.8 ring forms; brass orbit hairline rotates; ticks pulse
// 4.8 – 6.0 ring splits vertically, halves slide apart
// 5.2 – 6.8 logo emerges between the halves; green rule draws
// 6.8 – 7.1 logo flash / punch
// 7.1 – 7.9 BURST — ring & logo shatter into particles flying outward
// 7.9 – 8.0 black, ready to loop
// ───────────────────────────────────────────────
function Scene({ isPortrait = false }) {
const t = useTime();
const { duration } = useTimeline();
const W = 1920, H = 1080;
const cx = W / 2, cy = H / 2;
// Portrait-mode sizing overrides — stage is still 1920×1080 but the host
// scales & crops sides, so we shrink the circle & enlarge the text in
// stage-space so content reads correctly on phones.
const R = isPortrait ? 130 : 280;
const splitMaxX = isPortrait ? 130 : 280;
const logoWidthPct = isPortrait ? '14%' : '22%';
const subFontSize = isPortrait ? 16 : 24;
const subLetterSpacing = isPortrait ? 4 : 10;
const subMarginTop = isPortrait ? 36 : 56;
// global fade: very gradual outro so ending never feels abrupt
const inFade = clamp(t / 0.6, 0, 1);
const outFade = 1 - Easing.easeInOutCubic(clamp((t - 7.8) / 2.0, 0, 1));
const globalA = Math.min(inFade, outFade);
const gridA = interpolate([0.0, 1.2, 6.2, 7.0], [0, 0.08, 0.08, 0], Easing.easeOutCubic)(t);
const scanY = interpolate([0.2, 1.2], [-40, H + 40], Easing.easeInOutCubic)(t);
const scanA = interpolate([0.2, 0.6, 1.2], [0, 0.7, 0], Easing.linear)(t);
const partIn = clamp((t - 0.6) / 1.4, 0, 1);
const partSettle = clamp((t - 1.6) / 1.4, 0, 1);
const partOut = 1 - clamp((t - 4.6) / 0.4, 0, 1);
// Ambient stars (static) — breathing in with soft delay
const starsA = interpolate([0.0, 1.6, 5.5, 7.0], [0, 0.9, 0.9, 0], Easing.easeInOutCubic)(t);
const breathe = 0.85 + 0.15 * Math.sin(t * TAU * 0.25);
const N = 72;
// Ring draws slowly from the start — no particles, just pure circle drawing
const ringDraw = Easing.easeInOutCubic(clamp(t / 3.2, 0, 1));
const ringA = Easing.easeOutCubic(clamp(t / 0.4, 0, 1));
const splitP = Easing.easeInOutCubic(clamp((t - 3.2) / 1.0, 0, 1));
const splitX = splitP * splitMaxX;
// ring + split halves vanish at burst moment
const splitA = 1 - clamp((t - 6.9) / 0.08, 0, 1);
// Both monogram (A/P) and wordmark (AP2 | FRACTION) use the same smooth left-to-right sweep
// Slower than before for a more elegant pen-trace feel
const monoA = Easing.easeInOutCubic(clamp((t - 3.6) / 1.4, 0, 1));
const wordA = Easing.easeInOutCubic(clamp((t - 5.0) / 1.8, 0, 1));
const h1A = Math.max(monoA, wordA); // used elsewhere for fade gating
const h1Y = 0; // logo doesn't move — stays still until burst
// ─── Burst phase ───
// 6.85–7.05: anticipation punch-up
// 7.05: flash
// 7.05–9.55: SLOW shatter — 2.5s so pieces are clearly visible
const BURST_START = 6.9;
const BURST_DUR = 2.5;
const burstT = clamp((t - BURST_START) / BURST_DUR, 0, 1);
const burstE = Easing.easeOutCubic(burstT);
// Logo stays at scale 1 until burst moment, then snaps to 0 (it has already shattered into pieces)
const logoScale = 1;
const h1Out = 1 - clamp((t - BURST_START) / 0.05, 0, 1);
const ruleW = Easing.easeInOutCubic(clamp((t - 5.9) / 0.8, 0, 1));
const ruleOut = 1 - clamp((t - 6.85) / 0.1, 0, 1);
const axisW = Easing.easeOutCubic(clamp((t - 0.1) / 0.8, 0, 1));
return (
{!isPortrait && (
)}
{/* Center logo — two-stage pen-trace reveal */}
{/* Upper band: A→P monogram (top 67% of image — includes full monogram, stops before divider), left→right wipe driven by monoA */}

{/* Pen cursor for monogram */}
{monoA > 0 && monoA < 1 && (
)}
{/* Lower band: vertical divider + AP2 | FRACTION wordmark (below monogram), left→right wipe driven by wordA */}

{/* Pen cursor for wordmark */}
{wordA > 0 && wordA < 1 && (
)}
{/* Logo shatter layer — only visible during burst */}
{burstT > 0 && burstT < 1 && (() => {
const time = burstT * BURST_DUR;
const fadeT = clamp((burstT - 0.6) / 0.4, 0, 1);
const pieceA = 1 - fadeT;
if (pieceA <= 0.01) return null;
const rand = (n) => ((n * 2654435761) >>> 0) / 4294967296;
// Logo box dims (matches the rendered
![]()
above)
const logoW = (isPortrait ? 0.14 : 0.22) * W;
const logoH = logoW * (741 / 891);
const logoL = cx - logoW / 2;
const logoT = cy - logoH / 2;
// Grid subdivision — balanced for visual density vs perf
const COLS = isPortrait ? 24 : 32;
const ROWS = isPortrait ? 18 : 22;
const cellW = logoW / COLS;
const cellH = logoH / ROWS;
const GRAVITY = 380;
const WIND_X = 520;
const WIND_Y = -180;
const windRamp = Easing.easeOutCubic(clamp(time / 0.6, 0, 1));
const windBase = 0.5 * WIND_X * time * time * windRamp;
const windBaseY = 0.5 * WIND_Y * time * time * windRamp;
const gravTerm = 0.5 * GRAVITY * time * time;
const pieces = [];
const bgSize = `${logoW}px ${logoH}px`;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const idx = r * COLS + c;
const seed = rand(idx * 13 + 7);
const seed2 = rand(idx * 41 + 19);
// Piece original center in scene coords
const px = logoL + c * cellW + cellW / 2;
const py = logoT + r * cellH + cellH / 2;
// Direction outward from logo center, with jitter
const dx0 = px - cx;
const dy0 = py - cy;
const baseAng = Math.atan2(dy0, dx0);
const ang = baseAng + (seed - 0.5) * 0.8;
const speed = 180 + seed * 320;
const wm = 0.55 + seed2 * 0.9 + Math.sin(time * 3 + seed2 * 10) * 0.15;
const dx = Math.cos(ang) * speed * time + windBase * wm;
const dy = Math.sin(ang) * speed * time - 60 * time + gravTerm + windBaseY * wm;
const spin = (seed2 - 0.5) * 720 * time * (1 + seed);
pieces.push(
);
}
}
return
{pieces}
;
})()}
);
}
Object.assign(window, { Scene });