diff --git a/remotion/src/Mascot/Mascot.tsx b/remotion/src/Mascot/Mascot.tsx new file mode 100644 index 000000000..9298f4f68 --- /dev/null +++ b/remotion/src/Mascot/Mascot.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; + +// Variant: waving mascot. +export { mascotSchema }; +export type { MascotProps }; + +export const Mascot: React.FC = (props) => ( + +); diff --git a/remotion/src/Mascot/MascotGreeting.tsx b/remotion/src/Mascot/MascotGreeting.tsx new file mode 100644 index 000000000..e9d85e873 --- /dev/null +++ b/remotion/src/Mascot/MascotGreeting.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { z } from "zod"; +import { MascotCharacter, mascotSchema } from "./lib"; + +export const mascotGreetingSchema = mascotSchema.extend({ + greeting: z.boolean().default(true), +}); +export type MascotGreetingProps = z.infer; + +// Variant: starts idle, right arm rises up, then waves "hi" continuously. +export const MascotGreeting: React.FC = (props) => ( + +); diff --git a/remotion/src/Mascot/lib/MascotCharacter.tsx b/remotion/src/Mascot/lib/MascotCharacter.tsx new file mode 100644 index 000000000..2a4b0aed2 --- /dev/null +++ b/remotion/src/Mascot/lib/MascotCharacter.tsx @@ -0,0 +1,565 @@ +import React from "react"; +import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; +import { z } from "zod"; +import { zColor } from "@remotion/zod-types"; +import { RecordingFace } from "../../Ghosty/lib/RecordingFace"; +import { LoadingFace } from "../../Ghosty/lib/LoadingFace"; + +export const mascotSchema = z.object({ + arm: z.enum(["wave", "none", "steady"]).default("wave"), + face: z.enum(["normal", "recording", "loading"]).default("normal"), + talking: z.boolean().default(false), + sleeping: z.boolean().default(false), + thinking: z.boolean().default(false), + greeting: z.boolean().default(false), + recordingColor: zColor().default("#ff3b30"), + loadingColor: zColor().default("#ffffff"), +}); + +export type MascotProps = z.infer; + +/** + * Mascot character — drives the custom yellow mascot SVG with the same + * animation system as Ghosty: body bob, head-dot drift/squash, arm wave, blink. + * + * Use distinct `idPrefix` values if two instances appear in the same SVG tree + * so filter/gradient IDs don't collide. + */ +export const MascotCharacter: React.FC = ({ + arm = "wave", + face = "normal", + talking = false, + sleeping = false, + thinking = false, + greeting = false, + recordingColor = "#ff3b30", + loadingColor = "#ffffff", + idPrefix = "mascot", +}) => { + 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 — keyframe-based hi-wave: 3 swings then a rest pause, loops every 2.4s. + // Negative rotation = arm tips upward (counterclockwise). Eased for natural feel. + const easeInOut = Easing.inOut(Easing.cubic); + const wavePeriod = Math.round(fps * 2.4); + const frameInCycle = frame % wavePeriod; + const wave = arm === "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 }, + ) + : 0; + + // Left arm gentle sway — slower frequency, smaller amplitude. + 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; + + // Lip sync — slowed to ~1.5–2.3 Hz for natural speech pace (was 2.25–3.55 Hz). + // Phase offset keeps them from closing simultaneously. + 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 = talking ? Math.max(talkA, talkB * 0.8) : 0; + // Tongue fades in only when mouth is open enough — prevents visible tongue during near-closed frames. + const tongueOpacity = talking ? Math.min(1, Math.max(0, (mouthOpen - 0.15) / 0.35)) : 0; + + // 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 = sleeping ? Math.round(fps * 2.5) : 99999; + const sleepFullFrame = sleeping ? Math.round(fps * 4.0) : 99999; + const inSleepTransition = sleeping && frame >= sleepStartFrame; + const sleepProgress = sleeping + ? interpolate(frame, [sleepStartFrame, sleepFullFrame], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.inOut(Easing.cubic), + }) + : 0; + const isAsleep = sleeping && 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 = sleeping && 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 as number, opacity: 0 as number, 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, + }; + }; + // Thinking animation — arm raises, head tilts, eyes shift up, mouth changes. + const thinkStartFrame = thinking ? Math.round(fps * 1.0) : 99999; + const thinkFullFrame = thinking ? Math.round(fps * 2.0) : 99999; + const thinkProgress = thinking + ? interpolate(frame, [thinkStartFrame, thinkFullFrame], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.inOut(Easing.cubic), + }) + : 0; + const isThinking = thinking && thinkProgress >= 1; + + // LEFT arm raises toward body/chin for thinking pose (matches reference: arm on viewer's left side). + // Normal left arm droops at ~127° from +x axis; rotating −128° brings it to ~−1° + // (nearly horizontal, pointing right toward body center — "hand near chin" read). + const thinkArmOscillate = isThinking ? Math.sin((frame / fps) * Math.PI * 0.5) * 2 : 0; + const effectiveLeftSway = thinking + ? interpolate(thinkProgress, [0, 1], [leftSway, -128]) + thinkArmOscillate + : leftSway; + + // Right arm stays in normal steady position while thinking. + const rightSteadyAngle = steadySway; + + // Head tilts slightly toward raised arm (left = negative rotation in SVG). + const headTilt = isThinking + ? -4.5 + Math.sin((frame / fps) * Math.PI * 0.38) * 1.8 + : thinking + ? interpolate(thinkProgress, [0, 1], [0, -4.5]) + : 0; + + // Eyes drift up-left — looking toward the raised arm / into the distance. + const thinkEyeX = thinking ? thinkProgress * -6 : 0; + const thinkEyeY = thinking ? thinkProgress * -9 : 0; + + // Greeting — right arm rises from resting to raised, then waves "hi" in a loop. + const greetStartFrame = greeting ? Math.round(fps * 0.8) : 99999; + const greetRaiseEnd = greeting ? Math.round(fps * 1.6) : 99999; + const isGreeting = greeting && frame >= greetStartFrame; + const greetRaiseProgress = greeting + ? interpolate(frame, [greetStartFrame, greetRaiseEnd], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.out(Easing.cubic), + }) + : 0; + // Raise: wave arm rotates from +52° (arm pointing right/down) up to 0° (arm raised). + const greetRaiseAngle = interpolate(greetRaiseProgress, [0, 1], [52, 0]); + // Hi wave: enthusiastic oscillation after the arm is fully raised. + const greetWavePeriod = Math.round(fps * 1.3); + const greetWaveFrame = (greeting && frame > greetRaiseEnd) + ? (frame - greetRaiseEnd) % greetWavePeriod + : 0; + const greetWaveOscillate = (greeting && frame > greetRaiseEnd) + ? interpolate( + greetWaveFrame, + [0, greetWavePeriod * 0.25, greetWavePeriod * 0.5, greetWavePeriod * 0.75, greetWavePeriod], + [0, -28, -2, -26, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) }, + ) + : 0; + const greetArmAngle = greetRaiseAngle + greetWaveOscillate; + + 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; + const p = (k: string) => `${idPrefix}-${k}`; + + return ( + + + + {/* Ground shadow gradient */} + + + + + + {/* filter0: body — inner shadows + grain texture */} + + + + + + + + + + + + + + + + + + + + + {/* filter1: head circle — inner shadows + grain texture */} + + + + + + + + + + + + + + + + + + + + + {/* filter2: neck shadow 1 — blur */} + + + + + + + {/* filter3: neck shadow 2 — blur */} + + + + + + + {/* filter4: right arm — inner shadows + grain texture */} + + + + + + + + + + + + + + + + + + + + + {/* filter5: left arm — inner shadows + grain texture */} + + + + + + + + + + + + + + + + + + + + + {/* filter6-7: left eye highlights */} + + + + + + + + + + + + {/* filter8-10: right eye highlights */} + + + + + + + + + + + + + + + + + {/* filter13: steady right arm (idle pose) — mirrors left arm, inner shadows + grain */} + + + + + + + + + + + + + + + + + + + + + {/* filter11-12: cheek highlights */} + + + + + + + + + + + + + {/* Ground shadow — scales with bob so it feels grounded. */} + + + + + {/* Everything bobs together. */} + + + {/* Head dot — drifts + squashes independently inside the bob group. */} + + + + + {/* Body */} + + + {/* Waving right arm — normal wave OR greeting raise+hi-wave. */} + {(arm === "wave" || isGreeting) && ( + + + + )} + + {/* Steady right arm — hidden once greeting raise begins. */} + {arm === "steady" && !isGreeting && ( + + + + )} + + {/* Left arm — gentle sway in idle; rotates up toward body center while thinking. */} + + + + + {/* Neck shadow details */} + + + + + + + + {/* Normal face — eyes, cheeks, mouth. + Wrapped in a rotation group for the thinking head-tilt. */} + {face === "normal" && ( + + {/* Sleep eyes — curved closed-lid arcs, visible only when eyeScale ≈ 0 */} + {showSleepEyes && ( + <> + + + + )} + + {/* Left eye — scaleY collapses on blink/sleep; translate shifts gaze while thinking */} + {!showSleepEyes && ( + + + + {!effectiveInBlink && ( + <> + + + + + + + + )} + + + )} + + {/* Right eye — same blink / sleep; translate shifts gaze while thinking */} + {!showSleepEyes && ( + + + + {!effectiveInBlink && ( + <> + + + + + + + + + + + )} + + + )} + + {/* Left cheek */} + + + + + + {/* Right cheek */} + + + + + + {/* Mouth — normal smile fades to a concerned "hmm" when thinking */} + {!talking && ( + <> + {/* Normal closed smile — fades out as thinking kicks in */} + + + + + {/* Thinking / "hmm" mouth — asymmetric slight frown, fades in */} + {thinking && ( + + )} + + )} + + {/* Talking mouth — pivot at top edge (y=508). + Whole group scales downward so mouth opens like a jaw drop. + Tongue is sized to stay within mouth walls at all mouthOpen values: + at cx=495 cy=532 rx=24, the widest point (y=532) sits inside the + ~73px-wide mouth cavity, with ≥8px margin on each side. */} + {talking && ( + + {/* Outer mouth: wide rounded top, deep U-curve bottom */} + + {/* Tongue — centered, safely inside mouth at full open. + Fades in so it's invisible while mouth is nearly closed. */} + + {/* Specular highlight on tongue */} + + + )} + + )} + + {/* Recording face — pulsing dot, centered at (495, 495): 25px lower + 70% scale. + Transform: place at target center → scale → undo RecordingFace's own offset (520,555). */} + {face === "recording" && ( + + + + )} + + {/* Loading face — spinning ring, same center/scale as recording dot (495, 495, 70%). */} + {face === "loading" && ( + + + + )} + + {/* Zzz — floating letters that drift up after mascot falls asleep */} + {isAsleep && ( + <> + Z + Z + Z + + )} + + + + ); +}; diff --git a/remotion/src/Mascot/lib/index.ts b/remotion/src/Mascot/lib/index.ts new file mode 100644 index 000000000..5b24ab9f1 --- /dev/null +++ b/remotion/src/Mascot/lib/index.ts @@ -0,0 +1 @@ +export { MascotCharacter, mascotSchema, type MascotProps } from "./MascotCharacter"; diff --git a/remotion/src/Mascot/yellow-MascotIdle.tsx b/remotion/src/Mascot/yellow-MascotIdle.tsx new file mode 100644 index 000000000..169285a3a --- /dev/null +++ b/remotion/src/Mascot/yellow-MascotIdle.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; + +// Variant: idle mascot (no arm wave). +export const yellowMascotIdleSchema = mascotSchema; +export type YellowMascotIdleProps = MascotProps; + +export const YellowMascotIdle: React.FC = (props) => ( + +); diff --git a/remotion/src/Mascot/yellow-MascotLoading.tsx b/remotion/src/Mascot/yellow-MascotLoading.tsx new file mode 100644 index 000000000..dbcd1888e --- /dev/null +++ b/remotion/src/Mascot/yellow-MascotLoading.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; + +// Variant: mascot with a spinning loading ring instead of a face. +export const yellowMascotLoadingSchema = mascotSchema; +export type YellowMascotLoadingProps = MascotProps; + +export const YellowMascotLoading: React.FC = (props) => ( + +); diff --git a/remotion/src/Mascot/yellow-MascotPickup.tsx b/remotion/src/Mascot/yellow-MascotPickup.tsx new file mode 100644 index 000000000..135bfb537 --- /dev/null +++ b/remotion/src/Mascot/yellow-MascotPickup.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; +import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; + +// Variant: simple bouncy squash-and-stretch in place. +export const yellowMascotPickupSchema = mascotSchema; +export type YellowMascotPickupProps = MascotProps; + +export const YellowMascotPickup: React.FC = (props) => { + const frame = useCurrentFrame(); + const { fps } = 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", + }); + // Slight upward hop at each bounce peak (negative = up). Max 40 px. + const ly = interpolate(t, times, [0, 0, -90, 0, -50, 0, -20, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + return ( + + + + ); +}; diff --git a/remotion/src/Mascot/yellow-MascotRecording.tsx b/remotion/src/Mascot/yellow-MascotRecording.tsx new file mode 100644 index 000000000..3e89e0a83 --- /dev/null +++ b/remotion/src/Mascot/yellow-MascotRecording.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; + +// Variant: mascot with a pulsing red dot instead of a face. +export const yellowMascotRecordingSchema = mascotSchema; +export type YellowMascotRecordingProps = MascotProps; + +export const YellowMascotRecording: React.FC = (props) => ( + +); diff --git a/remotion/src/Mascot/yellow-MascotSleep.tsx b/remotion/src/Mascot/yellow-MascotSleep.tsx new file mode 100644 index 000000000..544af52f4 --- /dev/null +++ b/remotion/src/Mascot/yellow-MascotSleep.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { z } from "zod"; +import { MascotCharacter, mascotSchema } from "./lib"; + +export const yellowMascotSleepSchema = mascotSchema.extend({ + sleeping: z.boolean().default(true), +}); +export type YellowMascotSleepProps = z.infer; + +// Variant: mascot blinks a few times, slowly closes eyes, then floats Zzz. +export const YellowMascotSleep: React.FC = (props) => ( + +); diff --git a/remotion/src/Mascot/yellow-MascotTalking.tsx b/remotion/src/Mascot/yellow-MascotTalking.tsx new file mode 100644 index 000000000..479741e57 --- /dev/null +++ b/remotion/src/Mascot/yellow-MascotTalking.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; + +// Variant: idle mascot (steady arms) with lip-sync mouth animation. +export const yellowMascotTalkingSchema = mascotSchema; +export type YellowMascotTalkingProps = MascotProps; + +export const YellowMascotTalking: React.FC = (props) => ( + +); diff --git a/remotion/src/Mascot/yellow-MascotThinking.tsx b/remotion/src/Mascot/yellow-MascotThinking.tsx new file mode 100644 index 000000000..e9bf45835 --- /dev/null +++ b/remotion/src/Mascot/yellow-MascotThinking.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { z } from "zod"; +import { MascotCharacter, mascotSchema } from "./lib"; + +export const yellowMascotThinkingSchema = mascotSchema.extend({ + thinking: z.boolean().default(true), +}); +export type YellowMascotThinkingProps = z.infer; + +// Variant: starts idle, then transitions into a thinking pose — +// right arm raises, head tilts, eyes look up, smile becomes a thoughtful "hmm". +export const YellowMascotThinking: React.FC = (props) => ( + +); diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index ea5938d55..ea2b41743 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -6,8 +6,17 @@ import { GhostyIdle, ghostyIdleSchema } from "./Ghosty/GhostyIdle"; import { GhostyRecording, ghostyRecordingSchema } from "./Ghosty/GhostyRecording"; import { GhostyLoading, ghostyLoadingSchema } from "./Ghosty/GhostyLoading"; import { GhostyPickup, ghostyPickupSchema } from "./Ghosty/GhostyPickup"; +import { Mascot, mascotSchema } from "./Mascot/Mascot"; +import { YellowMascotIdle, yellowMascotIdleSchema } from "./Mascot/yellow-MascotIdle"; +import { YellowMascotRecording, yellowMascotRecordingSchema } from "./Mascot/yellow-MascotRecording"; +import { YellowMascotLoading, yellowMascotLoadingSchema } from "./Mascot/yellow-MascotLoading"; +import { YellowMascotPickup, yellowMascotPickupSchema } from "./Mascot/yellow-MascotPickup"; +import { YellowMascotTalking, yellowMascotTalkingSchema } from "./Mascot/yellow-MascotTalking"; +import { YellowMascotThinking, yellowMascotThinkingSchema } from "./Mascot/yellow-MascotThinking"; +import { YellowMascotSleep, yellowMascotSleepSchema } from "./Mascot/yellow-MascotSleep"; +import { MascotGreeting, mascotGreetingSchema } from "./Mascot/MascotGreeting"; -// Each is a Ghosty variant rendered with a transparent background. +// Each is a character variant rendered with a transparent background. // Render any of them as alpha MOV via: // pnpm render // e.g. `pnpm render GhostyWave` → out/GhostyWave.mov @@ -18,16 +27,28 @@ const SHARED = { height: 1080, } as const; -const SHARED_DEFAULTS = { +const GHOSTY_DEFAULTS = { bodyColor: "#1a1a1a" as const, blushColor: "#f5a3ad" as const, recordingColor: "#ff3b30" as const, loadingColor: "#ffffff" as const, }; +const YELLOW_DEFAULTS = { + arm: "steady" as const, + face: "normal" as const, + talking: false, + sleeping: false, + thinking: false, + greeting: false, + recordingColor: "#ff3b30" as const, + loadingColor: "#ffffff" as const, +}; + export const RemotionRoot: FC = () => { return ( <> + {/* ── Ghosty ─────────────────────────────────────────────────────────── */} { {...SHARED} schema={ghostySchema} defaultProps={{ - ...SHARED_DEFAULTS, + ...GHOSTY_DEFAULTS, arm: "wave" as const, face: "normal" as const, }} @@ -48,7 +69,7 @@ export const RemotionRoot: FC = () => { {...SHARED} schema={ghostyIdleSchema} defaultProps={{ - ...SHARED_DEFAULTS, + ...GHOSTY_DEFAULTS, arm: "none" as const, face: "normal" as const, }} @@ -61,7 +82,7 @@ export const RemotionRoot: FC = () => { {...SHARED} schema={ghostyRecordingSchema} defaultProps={{ - ...SHARED_DEFAULTS, + ...GHOSTY_DEFAULTS, arm: "none" as const, face: "recording" as const, }} @@ -74,7 +95,7 @@ export const RemotionRoot: FC = () => { {...SHARED} schema={ghostyLoadingSchema} defaultProps={{ - ...SHARED_DEFAULTS, + ...GHOSTY_DEFAULTS, arm: "none" as const, face: "loading" as const, }} @@ -87,11 +108,116 @@ export const RemotionRoot: FC = () => { {...SHARED} schema={ghostyPickupSchema} defaultProps={{ - ...SHARED_DEFAULTS, + ...GHOSTY_DEFAULTS, arm: "none" as const, face: "normal" as const, }} /> + + {/* ── Yellow Mascot ──────────────────────────────────────────────────── */} + + + + + + + + + + + + + + + + + ); };