diff --git a/remotion/src/Mascot/black-MascotCelebrate.tsx b/remotion/src/Mascot/black-MascotCelebrate.tsx
new file mode 100644
index 000000000..8fc884417
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotCelebrate.tsx
@@ -0,0 +1,356 @@
+import React from "react";
+import {
+ AbsoluteFill,
+ Easing,
+ interpolate,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+
+export const BlackMascotCelebrate: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const p = (k: string) => `bmcl-${k}`;
+
+ // ── Body bob — energetic 2 Hz bounce ────────────────────────────────────
+ const bob = Math.sin((frame / fps) * Math.PI * 2.0) * 16;
+
+ // ── Head drift + squash ──────────────────────────────────────────────────
+ const dotPhase = (frame / fps) * Math.PI;
+ const headDx = Math.sin(dotPhase * 0.7) * 6;
+ const headDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const headSquashY = 1 - 0.09 * press;
+ const headSquashX = 1 + 0.06 * press;
+
+ // ── Hat wobble ───────────────────────────────────────────────────────────
+ const hatWobble = Math.sin((frame / fps) * Math.PI * 1.4) * 3.5;
+
+ // ── Left arm — enthusiastic wave ─────────────────────────────────────────
+ const leftArmAngle =
+ -50 + Math.sin((frame / fps) * Math.PI * 3.0) * 18;
+
+ // ── Right arm + horn — wave together ─────────────────────────────────────
+ const hornWave = Math.sin((frame / fps) * Math.PI * 2.5 + 0.5) * 8;
+
+ // ── Confetti sparkle pulses ───────────────────────────────────────────────
+ const sp = (phase: number) =>
+ 0.62 + Math.sin((frame / fps) * Math.PI * 1.8 + phase) * 0.32;
+
+ // ── Falling confetti particles ────────────────────────────────────────────
+ const fallingDefs = [
+ { delay: 0, x: 130, drift: 22, color: "#3C5FAD", w: 12, h: 8 },
+ { delay: 8, x: 255, drift: -17, color: "#FBD387", w: 9, h: 9 },
+ { delay: 3, x: 375, drift: 25, color: "#B1D37E", w: 11, h: 7 },
+ { delay: 14, x: 495, drift: -20, color: "#929ED3", w: 8, h: 10 },
+ { delay: 1, x: 630, drift: 18, color: "#F5A29A", w: 10, h: 8 },
+ { delay: 17, x: 750, drift: -26, color: "#EDB371", w: 9, h: 11 },
+ { delay: 6, x: 850, drift: 15, color: "#B2ACD2", w: 12, h: 7 },
+ { delay: 11, x: 190, drift: 30, color: "#FDB1AF", w: 8, h: 9 },
+ { delay: 2, x: 695, drift: -12, color: "#3C5FAD", w: 11, h: 8 },
+ { delay: 19, x: 440, drift: 23, color: "#FBD387", w: 9, h: 10 },
+ ];
+ const fallPeriod = Math.round(fps * 2.2);
+ const getFall = (delay: number, startX: number, driftX: number) => {
+ if (frame < delay) return { x: startX, y: -80, rotation: 0, opacity: 0 };
+ const c = (frame - delay) % fallPeriod;
+ const t = c / fallPeriod;
+ return {
+ x: startX + driftX * t + Math.sin(t * Math.PI * 5) * 13,
+ y: -80 + 1100 * t,
+ rotation: t * 540,
+ opacity: interpolate(t, [0, 0.04, 0.82, 1], [0, 1, 0.88, 0], {
+ extrapolateLeft: "clamp", extrapolateRight: "clamp",
+ }),
+ };
+ };
+
+ const size = Math.min(width, height) * 0.82;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotCrying.tsx b/remotion/src/Mascot/black-MascotCrying.tsx
new file mode 100644
index 000000000..20b5aa246
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotCrying.tsx
@@ -0,0 +1,364 @@
+import React from "react";
+import {
+ AbsoluteFill,
+ Easing,
+ interpolate,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+
+export const BlackMascotCrying: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const p = (k: string) => `bmcry-${k}`;
+
+ // ── Cry transition ─────────────────────────────────────────────────────────
+ const cryProgress = interpolate(frame, [60, 90], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: Easing.inOut(Easing.cubic),
+ });
+ const normalFaceOpacity = 1 - cryProgress;
+ const cryFaceOpacity = cryProgress;
+
+ // ── Body bob — calm idle blends into faster sob shudder ───────────────────
+ const idleBob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+ const sobBob =
+ Math.sin((frame / fps) * Math.PI * 2.8) * 10 +
+ Math.sin((frame / fps) * Math.PI * 5.5 + 0.7) * 3;
+ const bob = idleBob * (1 - cryProgress) + sobBob * cryProgress;
+
+ // ── Head drift + squash ───────────────────────────────────────────────────
+ const dotPhase = (frame / fps) * Math.PI;
+ const driftScale = 1 - cryProgress * 0.65;
+ const headDx = Math.sin(dotPhase * 0.7) * 6 * driftScale;
+ const headDy = Math.sin(dotPhase) * 9 * driftScale;
+ const press = Math.max(0, Math.sin(dotPhase)) * driftScale;
+ const headSquashY = 1 - 0.08 * press;
+ const headSquashX = 1 + 0.05 * press;
+
+ // ── Arms — gentle idle sway, droop down when crying ──────────────────────
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.3) * 7;
+ const rightSway = Math.sin((frame / fps) * Math.PI * 1.3 + 1.0) * 6;
+ const leftArmAngle = leftSway + cryProgress * 14;
+ const rightArmAngle = rightSway + cryProgress * 14;
+
+ // ── Blink — only during idle phase ───────────────────────────────────────
+ const blinkPeriod = Math.round(fps * 2.6);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = cryProgress < 0.15 && (frame + blinkOffset) % blinkPeriod < 6;
+ const eyeScaleNormal = inBlink ? 0.12 : 1;
+
+ // ── Cheeks — flush more as crying intensifies ─────────────────────────────
+ const cheekOpacity =
+ 0.82 + cryProgress * 0.16 + Math.sin((frame / fps) * Math.PI * 1.1) * 0.05;
+
+ // ── Tears ─────────────────────────────────────────────────────────────────
+ const tearPeriod = Math.round(fps * 1.6);
+ const getTear = (delayFrames: number, eyeX: number, eyeStartY: number) => {
+ const startAt = 90 + delayFrames;
+ if (frame < startAt) return { x: eyeX, y: eyeStartY, opacity: 0 };
+ const cycleFrame = (frame - startAt) % tearPeriod;
+ const t = cycleFrame / tearPeriod;
+ const y = eyeStartY + t * 170;
+ const opacity =
+ interpolate(t, [0, 0.07, 0.68, 1.0], [0, 0.9, 0.75, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ }) * cryProgress;
+ return { x: eyeX, y, opacity };
+ };
+
+ const tL1 = getTear(0, 395, 485);
+ const tL2 = getTear(Math.round(fps * 0.55), 408, 485);
+ const tR1 = getTear(Math.round(fps * 0.22), 592, 485);
+ const tR2 = getTear(Math.round(fps * 0.80), 603, 485);
+
+ const size = Math.min(width, height) * 0.82;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotHatWithBag.tsx b/remotion/src/Mascot/black-MascotHatWithBag.tsx
new file mode 100644
index 000000000..27f5b50e8
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotHatWithBag.tsx
@@ -0,0 +1,347 @@
+import React from "react";
+import {
+ AbsoluteFill,
+ interpolate,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+
+export const BlackMascotHatWithBag: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const p = (k: string) => `bmhb-${k}`;
+
+ // ── Body bob ─────────────────────────────────────────────────────────────
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 10;
+
+ // ── Head drift + squash ─────────────────────────────────────────────────
+ const dotPhase = (frame / fps) * Math.PI;
+ const headDx = Math.sin(dotPhase * 0.7) * 5;
+ const headDy = Math.sin(dotPhase) * 7;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const headSquashY = 1 - 0.08 * press;
+ const headSquashX = 1 + 0.05 * press;
+
+ // ── Left arm — gentle idle sway ─────────────────────────────────────────
+ const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.8) * 8;
+
+ // ── Right arm — opposite phase ──────────────────────────────────────────
+ const rightArmAngle = Math.sin((frame / fps) * Math.PI * 0.8 + Math.PI) * 8;
+
+ // ── Bag pendulum ────────────────────────────────────────────────────────
+ const bagSwing = Math.sin((frame / fps) * Math.PI * 1.0 + 0.45) * 3;
+
+ // ── Blink ───────────────────────────────────────────────────────────────
+ const blinkPeriod = Math.round(fps * 3.5);
+ const blinkDur = Math.round(fps * 0.13);
+ const blinkPhase = frame % blinkPeriod;
+ const eyeScaleY =
+ blinkPhase < blinkDur
+ ? interpolate(
+ blinkPhase,
+ [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur],
+ [1, 0.06, 0.06, 1],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ )
+ : 1;
+
+ const size = Math.min(width, height) * 0.82;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotIdle.tsx b/remotion/src/Mascot/black-MascotIdle.tsx
new file mode 100644
index 000000000..ffcda42f2
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotIdle.tsx
@@ -0,0 +1,286 @@
+import React from "react";
+import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black idle mascot — uses exact paths and filters from BlackIdelmascot.svg
+ * with the same bob, head-drift, arm-sway, and blink animations as the yellow idle.
+ */
+export const BlackMascotIdle: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Left arm gentle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+ // Steady right arm sway — mirrors left arm with slight phase offset.
+ const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6;
+
+ // Blink every ~2.6s for ~6 frames.
+ const blinkPeriod = Math.round(fps * 2.6);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = (frame + blinkOffset) % blinkPeriod < 6;
+ const eyeScale = inBlink ? 0.12 : 1;
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotLaughing.tsx b/remotion/src/Mascot/black-MascotLaughing.tsx
new file mode 100644
index 000000000..3c5f568ec
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotLaughing.tsx
@@ -0,0 +1,236 @@
+import React from "react";
+import {
+ AbsoluteFill,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+
+/**
+ * BlackMascotLaughing — black variant of YellowMascotLaughing.
+ *
+ * Both arms wave out-of-phase with laughter.
+ * Body bounces rapidly + shakes horizontally.
+ * Head tilts side-to-side.
+ * Happy ^^ eyes, open mouth + tongue.
+ * Filter matrices from BlackIdelmascot.svg.
+ */
+export const BlackMascotLaughing: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const p = (k: string) => `bmla-${k}`;
+
+ // ── Body bounce — rapid 3 Hz laughter bounce ────────────────────────────
+ const bob = Math.sin((frame / fps) * Math.PI * 3.0) * 18;
+
+ // ── Horizontal body wobble — small 5 Hz side shake ──────────────────────
+ const wobble = Math.sin((frame / fps) * Math.PI * 5.0) * 5;
+
+ // ── Head drift + squash ─────────────────────────────────────────────────
+ const dotPhase = (frame / fps) * Math.PI;
+ const headDx = Math.sin(dotPhase * 2.5) * 8 + wobble * 0.5;
+ const headDy = Math.sin(dotPhase * 3.0) * 9;
+ const press = Math.max(0, Math.sin(dotPhase * 3.0));
+ const headSquashY = 1 - 0.1 * press;
+ const headSquashX = 1 + 0.07 * press;
+
+ // ── Head tilt — side-to-side laugh ──────────────────────────────────────
+ const headTilt = Math.sin((frame / fps) * Math.PI * 2.0) * 9;
+
+ // ── Both arms shake with laughter (opposite phases) ─────────────────────
+ const leftArmAngle = -15 + Math.sin((frame / fps) * Math.PI * 3.5) * 25;
+ const rightArmAngle = 15 + Math.sin((frame / fps) * Math.PI * 3.5 + 1.1) * 25;
+
+ const size = Math.min(width, height) * 0.82;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotListening.tsx b/remotion/src/Mascot/black-MascotListening.tsx
new file mode 100644
index 000000000..fcc7afb86
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotListening.tsx
@@ -0,0 +1,306 @@
+import React from "react";
+import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black listening mascot — uses exact paths and filters from BlackIdelmascot.svg.
+ * Replicates yellow-MascotListening:
+ * • Prominent head tilt toward the raised side (primary listening cue)
+ * • Right arm held outward with gentle continuous sway
+ * • Left arm gentle idle sway
+ * • Slow body bob (calm, attentive breathing)
+ * • Slow blink (focused/listening)
+ * • Cheek warmth pulse
+ */
+export const BlackMascotListening: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const p = (k: string) => `bmli-${k}`;
+
+ // Body bob — slow, calm breathing rhythm.
+ const bob = Math.sin((frame / fps) * Math.PI * 0.9) * 9;
+
+ // Head tilt — prominent attentive listening tilt to the right.
+ const headTiltBase = 11;
+ const headTiltOscillate = Math.sin((frame / fps) * Math.PI * 0.35) * 3;
+ const headTilt = headTiltBase + headTiltOscillate;
+
+ // Subtle head nod while tilted.
+ const headNodY = Math.sin((frame / fps) * Math.PI * 0.55) * 6;
+ const headNodX = Math.sin((frame / fps) * Math.PI * 0.28) * 3;
+
+ // Left arm — gentle idle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.1) * 6;
+
+ // Right arm — held outward, gentle continuous sway.
+ const armSway = Math.sin((frame / fps) * Math.PI * 0.75) * 5;
+ const rightArmAngle = -62 + armSway;
+
+ // Blink — slower, focused (attentive listener).
+ const blinkPeriod = Math.round(fps * 3.5);
+ const blinkOffset = Math.round(blinkPeriod * 0.3);
+ const inBlink = (frame + blinkOffset) % blinkPeriod < 5;
+ const eyeScale = inBlink ? 0.1 : 1;
+
+ // Cheek warmth pulse.
+ const cheekOpacity = 0.78 + Math.sin((frame / fps) * Math.PI * 0.85 + 0.6) * 0.09;
+
+ const size = Math.min(width, height) * 0.82;
+
+ // Head tilt pivot: bottom of head circle = neck joint.
+ const headPivotX = 493;
+ const headPivotY = 255; // cy(145) + r(110)
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotLoading.tsx b/remotion/src/Mascot/black-MascotLoading.tsx
new file mode 100644
index 000000000..80c3ca410
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotLoading.tsx
@@ -0,0 +1,197 @@
+import React from "react";
+import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
+import { LoadingFace } from "../Ghosty/lib/LoadingFace";
+
+/**
+ * Black loading mascot — uses exact paths and filters from BlackIdelmascot.svg
+ * with the same bob, head-drift, arm-sway animations as the black idle,
+ * but replaces the face with a spinning loading ring.
+ */
+export const BlackMascotLoading: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Left arm gentle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+ // Steady right arm sway — mirrors left arm with slight phase offset.
+ const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6;
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotLove.tsx b/remotion/src/Mascot/black-MascotLove.tsx
new file mode 100644
index 000000000..2d751a866
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotLove.tsx
@@ -0,0 +1,370 @@
+import React from "react";
+import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black love mascot — uses exact paths and filters from BlackIdelmascot.svg.
+ * Replicates yellow-MascotLove:
+ * 0 – 89 : normal idle (bob, head drift, arm sway, blink)
+ * 90 – 120 : heart eyes fade IN
+ * 120 – 210: heart eyes pulse, cheeks flush, mini hearts float up
+ * 210 – 240: heart eyes fade OUT
+ * 240 – 270: normal idle again → clean loop
+ */
+export const BlackMascotLove: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const p = (k: string) => `bmlv-${k}`;
+
+ // Heart transition.
+ const heartProgress = interpolate(
+ frame,
+ [90, 120, 210, 240],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) }
+ );
+ const normalEyeOpacity = 1 - heartProgress;
+ const heartEyeOpacity = heartProgress;
+
+ // Heart pulse: 2 beats/s, amplitude grows with heartProgress.
+ const heartBeat = 1 + Math.sin((frame / fps) * Math.PI * 4) * 0.09 * heartProgress;
+
+ // Body bob.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head drift + squash.
+ const dotPhase = (frame / fps) * Math.PI;
+ const headDx = Math.sin(dotPhase * 0.7) * 6;
+ const headDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const headSqY = 1 - 0.07 * press;
+ const headSqX = 1 + 0.04 * press;
+
+ // Arms — gentle idle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.3) * 7;
+ const rightSway = Math.sin((frame / fps) * Math.PI * 1.3 + 1.0) * 6;
+
+ // Blink — only during normal eye phase.
+ const blinkPeriod = Math.round(fps * 2.8);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = heartProgress < 0.1 && (frame + blinkOffset) % blinkPeriod < 5;
+ const eyeScaleNormal = inBlink ? 0.1 : 1;
+
+ // Cheek — flushes more during heart phase.
+ const cheekOpacity =
+ 0.82 + heartProgress * 0.15 +
+ Math.sin((frame / fps) * Math.PI * 1.1 + 1.0) * 0.06;
+
+ // Floating mini hearts.
+ const floatHeart = (startF: number, x: number, baseY: number, sz: number) => {
+ const prog = interpolate(frame, [startF, startF + 48], [0, 1], {
+ extrapolateLeft: "clamp", extrapolateRight: "clamp",
+ });
+ const y = baseY - 72 * prog + bob;
+ const op = interpolate(
+ frame,
+ [startF, startF + 6, startF + 38, startF + 48],
+ [0, 0.9, 0.9, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ ) * heartProgress;
+ const sc = sz * interpolate(frame, [startF, startF + 8], [0.5, 1], {
+ extrapolateLeft: "clamp", extrapolateRight: "clamp",
+ });
+ return { x, y, op, sc };
+ };
+ const hA = floatHeart(124, 415, 388, 0.9);
+ const hB = floatHeart(152, 568, 382, 0.8);
+ const hC = floatHeart(176, 490, 358, 1.0);
+
+ const size = Math.min(width, height) * 0.82;
+
+ // Heart-eye SVG → idelMascot coordinate shift (same as yellow).
+ const HX = 113.386;
+ const HY = 31;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotPickup.tsx b/remotion/src/Mascot/black-MascotPickup.tsx
new file mode 100644
index 000000000..965cdd440
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotPickup.tsx
@@ -0,0 +1,310 @@
+import React from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black pickup mascot — uses exact paths and filters from BlackIdelmascot.svg
+ * with the same bouncy squash-and-stretch animation as yellow-MascotPickup,
+ * plus the idle bob, head-drift, arm-sway, and blink.
+ */
+export const BlackMascotPickup: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const t = frame / fps;
+
+ // Three bounces with decreasing squash + a small upward hop each peak.
+ const times = [0, 0.18, 0.36, 0.54, 0.72, 0.90, 1.08, 4.0];
+ const sx = interpolate(t, times, [1, 1.18, 1, 1.12, 1, 1.06, 1, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const sy = interpolate(t, times, [1, 0.74, 1, 0.82, 1, 0.91, 1, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const ly = interpolate(t, times, [0, 0, -90, 0, -50, 0, -20, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Left arm gentle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+ // Steady right arm sway — mirrors left arm with slight phase offset.
+ const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6;
+
+ // Blink every ~2.6s for ~6 frames.
+ const blinkPeriod = Math.round(fps * 2.6);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = (frame + blinkOffset) % blinkPeriod < 6;
+ const eyeScale = inBlink ? 0.12 : 1;
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotRecording.tsx b/remotion/src/Mascot/black-MascotRecording.tsx
new file mode 100644
index 000000000..9074e5fd0
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotRecording.tsx
@@ -0,0 +1,197 @@
+import React from "react";
+import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
+import { RecordingFace } from "../Ghosty/lib/RecordingFace";
+
+/**
+ * Black recording mascot — uses exact paths and filters from BlackIdelmascot.svg
+ * with the same bob, head-drift, arm-sway animations as the black idle,
+ * but replaces the face with a pulsing red recording dot.
+ */
+export const BlackMascotRecording: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Left arm gentle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+ // Steady right arm sway — mirrors left arm with slight phase offset.
+ const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6;
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotSleep.tsx b/remotion/src/Mascot/black-MascotSleep.tsx
new file mode 100644
index 000000000..94febc680
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotSleep.tsx
@@ -0,0 +1,345 @@
+import React from "react";
+import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black sleep mascot — uses exact paths and filters from BlackIdelmascot.svg.
+ * Replicates yellow-MascotSleep: blinks normally, then eyes slowly droop closed,
+ * then sleep-arc eyes appear and Zzz letters float upward.
+ */
+export const BlackMascotSleep: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Left arm gentle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+ // Steady right arm sway.
+ const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6;
+
+ // Normal blink every ~2.6s for ~6 frames.
+ const blinkPeriod = Math.round(fps * 2.6);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = (frame + blinkOffset) % blinkPeriod < 6;
+ const blinkScale = inBlink ? 0.12 : 1;
+
+ // Sleep animation — slow eye-close then floating Zzz.
+ const sleepStartFrame = Math.round(fps * 2.5);
+ const sleepFullFrame = Math.round(fps * 4.0);
+ const inSleepTransition = frame >= sleepStartFrame;
+ const sleepProgress = interpolate(frame, [sleepStartFrame, sleepFullFrame], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: Easing.inOut(Easing.cubic),
+ });
+ const isAsleep = frame >= sleepFullFrame;
+
+ // Eye openness: normal blink while awake, slow droop during sleep transition.
+ const eyeScale = inSleepTransition ? Math.max(0, 1 - sleepProgress) : blinkScale;
+ // Suppress blink highlights mid-droop so pupils don't pop on/off.
+ const effectiveInBlink = inSleepTransition ? false : inBlink;
+ // Switch to sleep-arc eyes once eyelids have closed.
+ const showSleepEyes = eyeScale <= 0.06;
+
+ // Floating Z letters — staggered, drift up and fade out.
+ const zPeriod = Math.round(fps * 2.2);
+ const zBaseStart = sleepFullFrame + Math.round(fps * 0.4);
+ const getZ = (delay: number, baseX: number, fontSize: number) => {
+ const startAt = zBaseStart + delay;
+ if (!isAsleep || frame < startAt) return { x: baseX, y: 220, opacity: 0, fontSize };
+ const cycleFrame = (frame - startAt) % zPeriod;
+ const t = cycleFrame / zPeriod;
+ return {
+ x: baseX + t * 20,
+ y: 220 - t * 120,
+ opacity: interpolate(t, [0, 0.1, 0.72, 1], [0, 1, 0.85, 0]),
+ fontSize,
+ };
+ };
+ const z1 = getZ(0, 605, 40);
+ const z2 = getZ(Math.round(fps * 0.72), 624, 56);
+ const z3 = getZ(Math.round(fps * 1.44), 643, 76);
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotTalking.tsx b/remotion/src/Mascot/black-MascotTalking.tsx
new file mode 100644
index 000000000..7491ea9c0
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotTalking.tsx
@@ -0,0 +1,300 @@
+import React from "react";
+import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black talking mascot — uses exact paths and filters from BlackIdelmascot.svg
+ * with the same bob, head-drift, arm-sway, and blink as the black idle,
+ * but replaces the static mouth with a lip-sync jaw-drop animation.
+ */
+export const BlackMascotTalking: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Left arm gentle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+ // Steady right arm sway — mirrors left arm with slight phase offset.
+ const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6;
+
+ // Blink every ~2.6s for ~6 frames.
+ const blinkPeriod = Math.round(fps * 2.6);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = (frame + blinkOffset) % blinkPeriod < 6;
+ const eyeScale = inBlink ? 0.12 : 1;
+
+ // Lip sync — ~1.5–2.3 Hz for natural speech pace.
+ const talkA = Math.abs(Math.sin((frame / fps) * Math.PI * 3.0));
+ const talkB = Math.abs(Math.sin((frame / fps) * Math.PI * 4.6 + 1.2));
+ const mouthOpen = Math.max(talkA, talkB * 0.8);
+ // Tongue fades in only when mouth is open enough.
+ const tongueOpacity = Math.min(1, Math.max(0, (mouthOpen - 0.15) / 0.35));
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotThinking.tsx b/remotion/src/Mascot/black-MascotThinking.tsx
new file mode 100644
index 000000000..5e1249550
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotThinking.tsx
@@ -0,0 +1,330 @@
+import React from "react";
+import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black thinking mascot — uses exact paths and filters from BlackIdelmascot.svg.
+ * Replicates yellow-MascotThinking: starts idle then transitions into thinking pose —
+ * left arm raises toward chin, head tilts, eyes look up-left, smile becomes "hmm".
+ */
+export const BlackMascotThinking: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Left arm gentle sway (idle baseline).
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+ // Steady right arm sway.
+ const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6;
+
+ // Blink every ~2.6s for ~6 frames.
+ const blinkPeriod = Math.round(fps * 2.6);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = (frame + blinkOffset) % blinkPeriod < 6;
+ const eyeScale = inBlink ? 0.12 : 1;
+
+ // Thinking transition — starts at 1s, fully in at 2s.
+ const thinkStartFrame = Math.round(fps * 1.0);
+ const thinkFullFrame = Math.round(fps * 2.0);
+ const thinkProgress = interpolate(frame, [thinkStartFrame, thinkFullFrame], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: Easing.inOut(Easing.cubic),
+ });
+ const isThinking = thinkProgress >= 1;
+
+ // Left arm raises toward body/chin, then gently oscillates.
+ const thinkArmOscillate = isThinking ? Math.sin((frame / fps) * Math.PI * 0.5) * 2 : 0;
+ const effectiveLeftSway = interpolate(thinkProgress, [0, 1], [leftSway, -128]) + thinkArmOscillate;
+
+ // Head tilts slightly toward raised arm.
+ const headTilt = isThinking
+ ? -4.5 + Math.sin((frame / fps) * Math.PI * 0.38) * 1.8
+ : interpolate(thinkProgress, [0, 1], [0, -4.5]);
+
+ // Eyes drift up-left.
+ const thinkEyeX = thinkProgress * -6;
+ const thinkEyeY = thinkProgress * -9;
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotWave.tsx b/remotion/src/Mascot/black-MascotWave.tsx
new file mode 100644
index 000000000..dfc41facc
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotWave.tsx
@@ -0,0 +1,297 @@
+import React from "react";
+import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+
+/**
+ * Black wave mascot — uses exact paths and filters from BlackIdelmascot.svg
+ * for the body, head, and left arm. The right waving arm uses the same path as
+ * yellow-MascotWave2 with #3A3A3A fill and black-tuned inner shadow filter.
+ * Same keyframe hi-wave animation: 3 swings then a rest pause, loops every 2.4s.
+ */
+export const BlackMascotWave: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+
+ // Gentle bob for the whole character.
+ const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14;
+
+ // Head dot drifts independently and squashes when pressing into the body.
+ const dotPhase = (frame / fps) * Math.PI * 1.0;
+ const dotDx = Math.sin(dotPhase * 0.7) * 6;
+ const dotDy = Math.sin(dotPhase) * 9;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const dotSquashY = 1 - 0.08 * press;
+ const dotSquashX = 1 + 0.05 * press;
+
+ // Right arm wave — 3 swings then rest, loops every 2.4s.
+ const easeInOut = Easing.inOut(Easing.cubic);
+ const wavePeriod = Math.round(fps * 2.4);
+ const frameInCycle = frame % wavePeriod;
+ const wave = interpolate(
+ frameInCycle,
+ [0, wavePeriod * 0.12, wavePeriod * 0.25, wavePeriod * 0.38, wavePeriod * 0.50, wavePeriod * 0.62, wavePeriod * 0.75, wavePeriod],
+ [0, -9, 0, -7, 0, -5, 0, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeInOut }
+ );
+
+ // Left arm gentle sway.
+ const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7;
+
+ // Blink every ~2.6s for ~6 frames.
+ const blinkPeriod = Math.round(fps * 2.6);
+ const blinkOffset = Math.round(blinkPeriod / 2);
+ const inBlink = (frame + blinkOffset) % blinkPeriod < 6;
+ const eyeScale = inBlink ? 0.12 : 1;
+
+ const size = Math.min(width, height) * 0.85;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Mascot/black-MascotWink.tsx b/remotion/src/Mascot/black-MascotWink.tsx
new file mode 100644
index 000000000..0cf16a262
--- /dev/null
+++ b/remotion/src/Mascot/black-MascotWink.tsx
@@ -0,0 +1,314 @@
+import React from "react";
+import {
+ AbsoluteFill,
+ Easing,
+ interpolate,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+
+export const BlackMascotWink: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps, width, height } = useVideoConfig();
+ const p = (k: string) => `bmwk-${k}`;
+
+ // ── Relaxed body bob ─────────────────────────────────────────────────────
+ const bob = Math.sin((frame / fps) * Math.PI * 1.0) * 10;
+
+ // ── Head drift + squash ─────────────────────────────────────────────────
+ const dotPhase = (frame / fps) * Math.PI;
+ const headDx = Math.sin(dotPhase * 0.7) * 5;
+ const headDy = Math.sin(dotPhase) * 7;
+ const press = Math.max(0, Math.sin(dotPhase));
+ const headSquashY = 1 - 0.07 * press;
+ const headSquashX = 1 + 0.05 * press;
+
+ // ── Wink transition: right eye open → wink ──────────────────────────────
+ const winkProgress = interpolate(frame, [60, 78], [0, 1], {
+ easing: Easing.inOut(Easing.quad),
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const openRightEyeOpacity = 1 - winkProgress;
+ const winkEyeOpacity = winkProgress;
+
+ // Slight head tilt as wink comes in
+ const headTilt = interpolate(frame, [60, 85], [0, 4], {
+ easing: Easing.out(Easing.quad),
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // ── Left eye blink — only after wink is set (frame 95+) ─────────────────
+ const blinkPeriod = Math.round(fps * 3.5);
+ const blinkDur = Math.round(fps * 0.14);
+ const blinkOffset = frame < 95 ? 0 : (frame - 95) % blinkPeriod;
+ const leftEyeScaleY =
+ blinkOffset < blinkDur
+ ? interpolate(
+ blinkOffset,
+ [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur],
+ [1, 0.06, 0.06, 1],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ )
+ : 1;
+
+ // ── Right arm — waves only after wink is set ────────────────────────────
+ const rightArmWave = interpolate(frame, [70, 95], [0, 1], {
+ easing: Easing.out(Easing.quad),
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const rightArmAngle =
+ (10 + Math.sin((frame / fps) * Math.PI * 2.5) * 22) * rightArmWave;
+
+ // ── Left arm — gentle idle sway ─────────────────────────────────────────
+ const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.9) * 7;
+
+ const size = Math.min(width, height) * 0.82;
+
+ return (
+
+
+
+ );
+};
diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx
index 90eede34a..02bca28da 100644
--- a/remotion/src/Root.tsx
+++ b/remotion/src/Root.tsx
@@ -55,6 +55,21 @@ import { NewMascotCupHolding } from "./Mascot/NewMascotCupHolding";
import { NewMascotBobateaHolding } from "./Mascot/NewMascotBobateaHolding";
import { NewMascotSyicSmile } from "./Mascot/NewMascotSyicSmile";
import { NewMascotSyicSmileSlow } from "./Mascot/NewMascotSyicSmileSlow";
+import { BlackMascotIdle } from "./Mascot/black-MascotIdle";
+import { BlackMascotRecording } from "./Mascot/black-MascotRecording";
+import { BlackMascotLoading } from "./Mascot/black-MascotLoading";
+import { BlackMascotPickup } from "./Mascot/black-MascotPickup";
+import { BlackMascotTalking } from "./Mascot/black-MascotTalking";
+import { BlackMascotThinking } from "./Mascot/black-MascotThinking";
+import { BlackMascotSleep } from "./Mascot/black-MascotSleep";
+import { BlackMascotLove } from "./Mascot/black-MascotLove";
+import { BlackMascotWave } from "./Mascot/black-MascotWave";
+import { BlackMascotListening } from "./Mascot/black-MascotListening";
+import { BlackMascotCrying } from "./Mascot/black-MascotCrying";
+import { BlackMascotWink } from "./Mascot/black-MascotWink";
+import { BlackMascotCelebrate } from "./Mascot/black-MascotCelebrate";
+import { BlackMascotHatWithBag } from "./Mascot/black-MascotHatWithBag";
+import { BlackMascotLaughing } from "./Mascot/black-MascotLaughing";
export const RemotionRoot: React.FC = () => {
const staticFiles = getStaticFiles();
@@ -395,6 +410,141 @@ export const RemotionRoot: React.FC = () => {
height={1080}
defaultProps={{}}
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{timelines.map((storyName) => (