padhle - post migration

This commit is contained in:
2026-09-15 04:08:55 -04:00
parent 534b51b41b
commit b2d8109019
509 changed files with 147378 additions and 303 deletions
+118
View File
@@ -0,0 +1,118 @@
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
import { TransitionSeries } from "@remotion/transitions";
import { Panic, PANIC_FRAMES } from "./scenes/Panic";
import { ThePause, THE_PAUSE_FRAMES } from "./scenes/ThePause";
import { ThePromise, THE_PROMISE_FRAMES } from "./scenes/ThePromise";
import { AiReveal, THE_AI_REVEAL_FRAMES } from "./scenes/AiReveal";
import { ChoosingContext, CHOOSING_FRAMES } from "./scenes/ChoosingContext";
import { Asking, ASKING_FRAMES } from "./scenes/Asking";
import { Answer, ANSWER_FRAMES } from "./scenes/Answer";
import { More, MORE_FRAMES } from "./scenes/More";
import { Subjects, SUBJECTS_FRAMES } from "./scenes/Subjects";
import { Relief, RELIEF_FRAMES } from "./scenes/Relief";
import { Cta, CTA_FRAMES } from "./scenes/Cta";
import { EndCard, ENDCARD_FRAMES } from "./scenes/EndCard";
import { COLORS } from "./theme";
/**
* padhle — launch trailer master composition
* ------------------------------------------------------------
* Premium startup commercial, 12 scenes, ~39s @ 60fps, 1080×1920.
* All seams are HARD CUTS — each scene manufactures its own
* entrance/flash (white-out, black-in, pop), so cuts read as
* intentional rhythm, never muddy crossfades.
*
* Full plan: STORYBOARD.md
*/
export const MAIN_FRAMES =
PANIC_FRAMES +
THE_PAUSE_FRAMES +
THE_PROMISE_FRAMES +
THE_AI_REVEAL_FRAMES +
CHOOSING_FRAMES +
ASKING_FRAMES +
ANSWER_FRAMES +
MORE_FRAMES +
SUBJECTS_FRAMES +
RELIEF_FRAMES +
CTA_FRAMES +
ENDCARD_FRAMES; // 660 + 1680 = 2340 = 39.0s
export const MainVideo: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Subtle cinematic drift on the whole frame (never static).
const push = interpolate(frame, [0, 2 * fps], [1.02, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill style={{ backgroundColor: COLORS.surface }}>
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
scale: String(push),
}}
>
<TransitionSeries>
{/* S1 The Panic */}
<TransitionSeries.Sequence durationInFrames={PANIC_FRAMES} name="Panic">
<Panic />
</TransitionSeries.Sequence>
{/* S2 The Pause */}
<TransitionSeries.Sequence durationInFrames={THE_PAUSE_FRAMES} name="ThePause">
<ThePause />
</TransitionSeries.Sequence>
{/* S3 The Promise */}
<TransitionSeries.Sequence durationInFrames={THE_PROMISE_FRAMES} name="ThePromise">
<ThePromise />
</TransitionSeries.Sequence>
{/* S4 The AI Reveal */}
<TransitionSeries.Sequence durationInFrames={THE_AI_REVEAL_FRAMES} name="AiReveal">
<AiReveal />
</TransitionSeries.Sequence>
{/* S5 Choosing Context */}
<TransitionSeries.Sequence durationInFrames={CHOOSING_FRAMES} name="ChoosingContext">
<ChoosingContext />
</TransitionSeries.Sequence>
{/* S6 Asking */}
<TransitionSeries.Sequence durationInFrames={ASKING_FRAMES} name="Asking">
<Asking />
</TransitionSeries.Sequence>
{/* S7 The Answer */}
<TransitionSeries.Sequence durationInFrames={ANSWER_FRAMES} name="Answer">
<Answer />
</TransitionSeries.Sequence>
{/* S8 More Than a Chatbot */}
<TransitionSeries.Sequence durationInFrames={MORE_FRAMES} name="More">
<More />
</TransitionSeries.Sequence>
{/* S9 Subjects */}
<TransitionSeries.Sequence durationInFrames={SUBJECTS_FRAMES} name="Subjects">
<Subjects />
</TransitionSeries.Sequence>
{/* S10 Relief */}
<TransitionSeries.Sequence durationInFrames={RELIEF_FRAMES} name="Relief">
<Relief />
</TransitionSeries.Sequence>
{/* S11 CTA */}
<TransitionSeries.Sequence durationInFrames={CTA_FRAMES} name="Cta">
<Cta />
</TransitionSeries.Sequence>
{/* S12 End Card */}
<TransitionSeries.Sequence durationInFrames={ENDCARD_FRAMES} name="EndCard">
<EndCard />
</TransitionSeries.Sequence>
</TransitionSeries>
</div>
</AbsoluteFill>
);
};
+57
View File
@@ -0,0 +1,57 @@
import React from "react";
import { Composition, Folder } from "remotion";
import "./index.css";
import { FPS, HEIGHT, WIDTH } from "./theme";
import { Panic, PANIC_FRAMES } from "./scenes/Panic";
import { ThePause, THE_PAUSE_FRAMES } from "./scenes/ThePause";
import { ThePromise, THE_PROMISE_FRAMES } from "./scenes/ThePromise";
import { AiReveal, THE_AI_REVEAL_FRAMES } from "./scenes/AiReveal";
import { ChoosingContext, CHOOSING_FRAMES } from "./scenes/ChoosingContext";
import { Asking, ASKING_FRAMES } from "./scenes/Asking";
import { Answer, ANSWER_FRAMES } from "./scenes/Answer";
import { More, MORE_FRAMES } from "./scenes/More";
import { Subjects, SUBJECTS_FRAMES } from "./scenes/Subjects";
import { Relief, RELIEF_FRAMES } from "./scenes/Relief";
import { Cta, CTA_FRAMES } from "./scenes/Cta";
import { EndCard, ENDCARD_FRAMES } from "./scenes/EndCard";
import { MainVideo, MAIN_FRAMES } from "./MainVideo";
/**
* padhle — Remotion root
* Registers each scene as its own composition (editable in Studio)
* and the assembled master. Multi-scene pattern: every scene lives
* in src/scenes/ and is listed here — once as an independent
* composition, once inside MainVideo.
*
* Scene plan: STORYBOARD.md
*/
export const RemotionRoot: React.FC = () => {
return (
<>
<Folder name="Padhle-Scenes">
<Composition id="Panic" component={Panic} durationInFrames={PANIC_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="ThePause" component={ThePause} durationInFrames={THE_PAUSE_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="ThePromise" component={ThePromise} durationInFrames={THE_PROMISE_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="AiReveal" component={AiReveal} durationInFrames={THE_AI_REVEAL_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="ChoosingContext" component={ChoosingContext} durationInFrames={CHOOSING_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="Asking" component={Asking} durationInFrames={ASKING_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="Answer" component={Answer} durationInFrames={ANSWER_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="More" component={More} durationInFrames={MORE_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="Subjects" component={Subjects} durationInFrames={SUBJECTS_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="Relief" component={Relief} durationInFrames={RELIEF_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="Cta" component={Cta} durationInFrames={CTA_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
<Composition id="EndCard" component={EndCard} durationInFrames={ENDCARD_FRAMES} fps={FPS} width={WIDTH} height={HEIGHT} />
</Folder>
<Composition
id="Padhle"
component={MainVideo}
durationInFrames={MAIN_FRAMES}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
</>
);
};
@@ -0,0 +1,59 @@
import React from "react";
import { COLORS } from "../theme";
/**
* padhle book mark
* ------------------------------------------------------------
* Minimal open-book glyph drawn as an SVG, in the app's strictly
* black-on-white language (--color-primary / --color-on-primary).
*
* NOTE: no official logo asset exists in the repo yet — this is a
* tasteful placeholder mark. Drop in the real logo SVG whenever
* the brand asset lands (swap the inner <svg> below).
*/
export const BookGlyph: React.FC<{ color: string; notch: string }> = ({
color,
notch,
}) => (
<svg viewBox="0 0 24 24" fill="none" aria-hidden>
{/* Open book — two pages splayed around a center spine */}
<path d="M5.2 4.6 11.6 6.2 11.6 17.8 5.2 15.4Z" fill={color} />
<path d="M18.8 4.6 12.4 6.2 12.4 17.8 18.8 15.4Z" fill={color} />
{/* Spine notch (negative space between the pages) */}
<path d="M11.6 5.4 12.4 5.4 12.4 18.6 11.6 18.6Z" fill={notch} />
{/* Page lines — left */}
<path d="M7.1 8.9 11 17.2" stroke={color} strokeWidth="1" strokeLinecap="round" />
<path d="M9.4 11.6 11.2 16.4" stroke={color} strokeWidth="1" strokeLinecap="round" />
{/* Page lines — right */}
<path d="M16.9 8.9 13 17.2" stroke={color} strokeWidth="1" strokeLinecap="round" />
<path d="M14.6 11.6 12.8 16.4" stroke={color} strokeWidth="1" strokeLinecap="round" />
</svg>
);
/** Black tile + white book — the primary brand lockup. */
export const BookMark: React.FC<{ size?: number; inverted?: boolean }> = ({
size = 120,
inverted = false,
}) => {
const tile = inverted ? COLORS.onPrimary : COLORS.primary;
const glyph = inverted ? COLORS.primary : COLORS.onPrimary;
return (
<div
style={{
width: size,
height: size,
borderRadius: size * 0.3,
background: tile,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: `0 0 0 1px ${COLORS.outlineVariant}`,
}}
>
<div style={{ width: size * 0.5, height: size * 0.5, display: "flex", alignItems: "center", justifyContent: "center" }}>
<BookGlyph color={glyph} notch={tile} />
</div>
</div>
);
};
+670
View File
@@ -0,0 +1,670 @@
import React from "react";
import { Easing, Interactive, interpolate, useCurrentFrame } from "remotion";
import { COLORS, bodyFont, headingFont, OVERSHOOT, SNAP } from "../theme";
/**
* Shared UI chrome for the padhle app scenes (S5S7, S9).
* Mirrors the real app: white canvas, light-gray chrome (#f9fafb),
* black typography, subtle gray borders (--color-outline/--variant),
* rounded corners, tiny shadows. Everything inline so every value
* is editable in Remotion Studio.
*/
export const floatStyle = (frame: number, amp: number, cycle: number): string =>
`${(Math.sin((frame / 60) * (2 * Math.PI) / cycle) * amp).toFixed(2)}px ${
(Math.sin((frame / 60) * (2 * Math.PI) / (cycle * 0.77)) * amp * 0.6).toFixed(2)
}px`;
// ── Pill (TopNav-grade/subject/chapter chips) ────────────────
export const Pill: React.FC<{
label: string;
active?: boolean;
scale?: number;
dot?: boolean;
}> = ({ label, active = false, scale = 1, dot = true }) => (
<div
style={{
fontFamily: headingFont,
fontSize: 30 * scale,
fontWeight: 700,
color: active ? COLORS.onPrimary : COLORS.onSurface,
background: active ? COLORS.primary : "#ffffff",
border: active ? "none" : `2.5px solid ${COLORS.outline}`,
borderRadius: 999,
padding: `${10 * scale}px ${22 * scale}px`,
display: "flex",
alignItems: "center",
gap: 9 * scale,
whiteSpace: "nowrap",
boxShadow: active ? "0 1px 3px rgba(17,24,39,0.12)" : "none",
}}
>
{dot && (
<span
style={{
width: 9 * scale,
height: 9 * scale,
borderRadius: "50%",
background: active ? COLORS.onPrimary : COLORS.primary,
}}
/>
)}
{label}
</div>
);
// ── Top bar (thin header: brand + pills + search chip) ───────
export const TopBar: React.FC<{
pills: { label: string; active?: boolean }[];
from?: number;
}> = ({ pills, from = 0 }) => {
const frame = useCurrentFrame();
const p = interpolate(frame, [from, from + 14], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<Interactive.Div
name="TopBar"
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: 128,
backgroundColor: COLORS.surface,
borderBottom: `2.5px solid ${COLORS.outlineVariant}`,
translate: `0 ${(1 - p) * -60}px`,
opacity: p,
}}
>
<div style={{ display: "flex", alignItems: "center", padding: "0 40px", gap: 16 }}>
{/* brand */}
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<BookGlyphBox size={40} />
<span style={{ fontFamily: headingFont, fontSize: 30, fontWeight: 700, color: COLORS.onSurface }}>
padhle
</span>
</div>
{/* pills */}
{pills.map((p2) => (
<Pill key={p2.label} label={p2.label} active={p2.active} scale={0.72} />
))}
<div
style={{
marginLeft: "auto",
fontFamily: bodyFont,
fontSize: 22,
color: COLORS.onSurfaceVariant,
backgroundColor: COLORS.surfaceLow,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 999,
padding: "8px 18px",
whiteSpace: "nowrap",
}}
>
Search
</div>
</div>
</Interactive.Div>
);
};
// ── Sidebar rail (light-gray left column) ────────────────────
export const SidebarRail: React.FC<{ from?: number }> = ({ from = 0 }) => {
const frame = useCurrentFrame();
const p = interpolate(frame, [from, from + 16], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const rows = ["New Chat", "Mathematics", "Science", "History", "English", "Geography"];
return (
<Interactive.Div
name="SidebarRail"
style={{
position: "absolute",
left: 0,
top: 128,
bottom: 0,
width: 250,
backgroundColor: COLORS.surfaceLow,
borderRight: `2.5px solid ${COLORS.outlineVariant}`,
translate: `${(1 - p) * -210}px 0`,
opacity: p,
padding: "36px 28px",
}}
>
<div
style={{
fontFamily: headingFont,
fontSize: 28,
fontWeight: 700,
backgroundColor: COLORS.primary,
color: COLORS.onPrimary,
borderRadius: 12,
padding: "14px 20px",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<span style={{ fontSize: 24 }}></span> New Chat
</div>
<div style={{ height: 30 }} />
{rows.map((r, i) => (
<div
key={r}
style={{
fontFamily: bodyFont,
fontSize: 24,
fontWeight: i === 0 ? 700 : 400,
color: i === 0 ? COLORS.onSurface : COLORS.onSurfaceVariant,
padding: "12px 18px",
borderRadius: 10,
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<span
style={{
width: i === 0 ? 10 : 8,
height: i === 0 ? 10 : 8,
borderRadius: "50%",
background: i === 0 ? COLORS.primary : COLORS.outlineVariant,
}}
/>
{r}
</div>
))}
</Interactive.Div>
);
};
// ── Book glyph (small, for chrome) ───────────────────────────
export const BookGlyphBox: React.FC<{ size?: number; color?: string }> = ({
size = 46,
color = COLORS.primary,
}) => (
<div
style={{
width: size,
height: size,
borderRadius: size * 0.32,
background: color,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width={size * 0.56} height={size * 0.56} viewBox="0 0 24 24" fill="none">
<path d="M5.2 4.6 11.6 6.2 11.6 17.8 5.2 15.4Z" fill={COLORS.onPrimary} />
<path d="M18.8 4.6 12.4 6.2 12.4 17.8 18.8 15.4Z" fill={COLORS.onPrimary} />
</svg>
</div>
);
// ── Oversized cursor (the hero pointer) ──────────────────────
export const AppCursor: React.FC<{
x: number;
y: number;
size?: number;
opacity?: number;
press?: number;
rotate?: string;
}> = ({ x, y, size = 74, opacity = 1, press = 0, rotate = "0deg" }) => {
const clickScale = 1 - 0.18 * press;
return (
<div
style={{
position: "absolute",
left: x,
top: y,
opacity,
scale: String(clickScale),
rotate,
transformOrigin: "0% 100%",
}}
>
<svg width={size} height={size * 1.35} viewBox="0 0 30 42" fill="none">
<polygon
points="2,40 2,12 10,12 10,9 15,9 15,3 30,3 30,16 26,16 26,40"
fill="#000"
stroke="#fff"
strokeWidth="2.5"
strokeLinejoin="round"
/>
</svg>
</div>
);
};
// ── Ripple (click feedback ring) ─────────────────────────────
export const Ripple: React.FC<{ at: number; x: number; y: number }> = ({ at, x, y }) => {
const frame = useCurrentFrame();
const r = interpolate(frame, [at, at + 16], [12, 130], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const o = interpolate(frame, [at, at + 2, at + 16], [0, 0.55, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<div
style={{
position: "absolute",
left: x - r,
top: y - r,
width: r * 2,
height: r * 2,
borderRadius: "50%",
border: `3.5px solid ${COLORS.onSurfaceVariant}`,
opacity: o,
}}
/>
);
};
// ── Send button (black circle + paper-plane send) ────────────
export const SendButton: React.FC<{
at: number;
x: number;
y: number;
size?: number;
}> = ({ at, x, y, size = 72 }) => {
const frame = useCurrentFrame();
const grow = interpolate(frame, [at, at + 20], [0.84, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: OVERSHOOT,
output: "perceptual-scale",
});
const glow = interpolate(frame, [at, at + 22], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<div
style={{
position: "absolute",
left: x - size / 2,
top: y - size / 2,
width: size,
height: size,
borderRadius: "50%",
backgroundColor: COLORS.primary,
scale: String(grow.toFixed(4)),
boxShadow: glow > 0.02 ? `0 0 ${(20 * glow).toFixed(1)}px rgba(17,24,39,${(0.3 * glow).toFixed(2)})` : "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width={size * 0.52} height={size * 0.52} viewBox="0 0 28 24">
<path
d="M3 21 13 9 21 5"
stroke={COLORS.onPrimary}
strokeWidth="3.6"
strokeLinecap="round"
fill="none"
/>
<path
d="M14 21 22 13 26 11"
stroke={COLORS.onPrimary}
strokeWidth="3.6"
strokeLinecap="round"
fill="none"
/>
</svg>
</div>
);
};
// ── Input bar (chat composer) ────────────────────────────────
export const InputBar: React.FC<{
placeholder: string;
from?: number;
w?: number;
x?: number;
y?: number;
hasSend?: boolean;
sendValue?: number;
}> = ({
placeholder,
from = 0,
w = 900,
x = 530,
y = 1680,
hasSend = true,
sendValue = 0,
}) => {
const frame = useCurrentFrame();
const p = interpolate(frame, [from, from + 14], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<div
style={{
position: "absolute",
left: x - w / 2,
top: y,
width: w,
height: 96,
opacity: p,
translate: `0 ${(1 - p) * 34}px`,
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 20,
boxShadow: "0 2px 10px rgba(17,24,39,0.08)",
display: "flex",
alignItems: "center",
padding: "0 28px",
gap: 20,
}}
>
<span style={{ fontSize: 30, color: COLORS.onSurfaceVariant }}></span>
<div
style={{
flex: 1,
fontFamily: bodyFont,
fontSize: 30,
color: COLORS.onSurfaceVariant,
whiteSpace: "nowrap",
overflow: "hidden",
}}
>
{placeholder}
</div>
{hasSend && (
<SendButton
at={from}
x={w - 64}
y={48}
size={40 + 32 * Math.max(0, Math.min(1, sendValue))}
/>
)}
</div>
);
};
// ── Chat bubble (real app Message) ───────────────────────────
export const Bubble: React.FC<{
role: "user" | "asst";
children: React.ReactNode;
from?: number;
w?: number;
x?: number;
y?: number;
}> = ({ role, children, from = 0, w = 780, x = 540, y = 500 }) => {
const frame = useCurrentFrame();
const p = interpolate(frame, [from, from + 10], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const isAsst = role === "asst";
return (
<div
style={{
position: "absolute",
left: x - (isAsst ? w / 2 : 120),
top: y,
width: w,
opacity: p,
translate: `0 ${(1 - p) * 26}px`,
fontFamily: bodyFont,
fontSize: 32,
fontWeight: 500,
lineHeight: 1.45,
color: isAsst ? COLORS.onSurface : COLORS.onPrimary,
backgroundColor: isAsst ? COLORS.surface : COLORS.primary,
border: isAsst ? `2.5px solid ${COLORS.outlineVariant}` : "none",
borderRadius: 24,
padding: "22px 30px",
whiteSpace: "pre-wrap",
boxShadow: isAsst ? "0 1px 4px rgba(17,24,39,0.06)" : "none",
}}
>
{children}
</div>
);
};
// ── Source badge ─────────────────────────────────────────────
export const SourceBadge: React.FC<{
label: string;
from?: number;
x?: number;
y?: number;
drawRule?: boolean;
}> = ({ label, from = 0, x = 540, y = 1400, drawRule = true }) => {
const frame = useCurrentFrame();
const p = interpolate(frame, [from, from + 14], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const rule = interpolate(frame, [from + 12, from + 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<div style={{ position: "absolute", left: x - 3, top: y, display: "flex", alignItems: "center", gap: 20, opacity: p }}>
<div
style={{
fontFamily: bodyFont,
fontSize: 26,
fontWeight: 600,
color: COLORS.onSurface,
backgroundColor: COLORS.surfaceLow,
border: `2.5px solid ${COLORS.outline}`,
borderRadius: 14,
padding: "12px 26px",
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<BookGlyphBox size={34} color={COLORS.onSurface} />
{label}
</div>
{drawRule && (
<div
style={{
width: 170,
height: 3.5,
backgroundColor: COLORS.primary,
scale: `${rule.toFixed(3)} 1`,
transformOrigin: "0% 50%",
}}
/>
)}
</div>
);
};
// ── Keyword highlight (draws a rounded box behind a word) ────
export const KeywordHighlight: React.FC<{
children: React.ReactNode;
from: number;
size?: number;
}> = ({ children, from, size = 150 }) => {
const frame = useCurrentFrame();
const sx = interpolate(frame, [from, from + 14], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const o = interpolate(frame, [from, from + 18], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<span style={{ position: "relative" }}>
<span
style={{
position: "absolute",
left: -10,
top: 0,
width: size,
height: 46,
borderRadius: 10,
backgroundColor: COLORS.surfaceHigh,
scale: `${sx.toFixed(3)} 1`,
transformOrigin: "0% 50%",
}}
/>
<span style={{ fontWeight: 700, position: "relative", zIndex: 2, color: COLORS.onSurface }}>
{children}
</span>
<span
style={{
position: "absolute",
left: -10,
top: -8,
width: size,
height: 58,
borderRadius: 10,
backgroundColor: "transparent",
border: `3px solid ${COLORS.onSurfaceVariant}`,
scale: `${sx.toFixed(3)} 1`,
transformOrigin: "0% 50%",
opacity: o,
}}
/>
</span>
);
};
// ── Tiny down-arrow divider (S8) ─────────────────────────────
export const ArrowDown: React.FC<{ y: number; x?: number; from?: number }> = ({
y,
x = 540,
from = 0,
}) => {
const frame = useCurrentFrame();
const p = interpolate(frame, [from, from + 8], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<div
style={{
position: "absolute",
left: x,
top: y,
translate: "-50% 0",
color: COLORS.onSurfaceVariant,
fontSize: 40,
fontWeight: 700,
opacity: p,
}}
>
</div>
);
};
// ── Wireframe student (follows brief: minimal illustration, no real person) ──
export const WireframeStudent: React.FC<{ color?: string; scale?: number }> = ({
color = COLORS.onSurfaceVariant,
scale = 1,
}) => (
<svg width={420 * scale} height={560 * scale} viewBox="0 0 420 560" fill="none">
{/* head */}
<circle cx="210" cy="120" r="62" stroke={color} strokeWidth="5" />
{/* hair hint */}
<path d="M152 92 C 180 64 240 62 268 92" stroke={color} strokeWidth="5" fill="none" strokeLinecap="round" />
{/* torso */}
<path d="M210 190 L210 330" stroke={color} strokeWidth="6" strokeLinecap="round" />
{/* shoulders */}
<path d="M150 200 L270 200" stroke={color} strokeWidth="6" strokeLinecap="round" />
{/* arms */}
<path d="M152 202 L118 290 L118 290 L140 330" stroke={color} strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
{/* arm holding up a book */}
<path d="M268 202 L300 250 L300 250 L272 258" stroke={color} strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
{/* book in hand */}
<path d="M236 232 L292 232 L292 322 L236 322" stroke={color} strokeWidth="4" strokeLinecap="round" />
<path d="M252 248 L252 306" stroke={color} strokeWidth="3" strokeLinecap="round" />
{/* legs (seated) */}
<path d="M210 330 L180 420" stroke={color} strokeWidth="6" strokeLinecap="round" />
<path d="M210 330 L240 420" stroke={color} strokeWidth="6" strokeLinecap="round" />
<path d="M176 420 L300 480" stroke={color} strokeWidth="6" strokeLinecap="round" />
{/* seat line */}
<path d="M120 500 L330 500" stroke={color} strokeWidth="4" strokeLinecap="round" strokeDasharray="10 14" />
</svg>
);
// ── Diagram: chloroplast (the S7 "diagram slides in") ────────
export const ChloroplastDiagram: React.FC<{ size?: number }> = ({ size = 250 }) => (
<svg width={size * 1.4} height={size} viewBox="0 0 280 200" fill="none">
{/* sun */}
<circle cx="36" cy="40" r="18" stroke={COLORS.onSurface} strokeWidth="3" />
<path d="M52 26 84 14" stroke={COLORS.onSurface} strokeWidth="2.5" strokeLinecap="round" />
<path d="M60 40 96 40" stroke={COLORS.onSurface} strokeWidth="2.5" strokeLinecap="round" />
<path d="M52 54 84 66" stroke={COLORS.onSurface} strokeWidth="2.5" strokeLinecap="round" />
{/* chloroplast outer membrane */}
<ellipse cx="170" cy="110" rx="92" ry="68" stroke={COLORS.onSurface} strokeWidth="3.5" />
{/* inner membrane */}
<ellipse cx="170" cy="110" rx="76" ry="52" stroke={COLORS.onSurface} strokeWidth="2.5" />
{/* thylakoid stacks */}
<rect x="150" y="86" width="52" height="16" rx="8" stroke={COLORS.onSurface} strokeWidth="2.5" />
<rect x="136" y="102" width="70" height="16" rx="8" stroke={COLORS.onSurface} strokeWidth="2.5" />
<rect x="150" y="118" width="52" height="16" rx="8" stroke={COLORS.onSurface} strokeWidth="2.5" />
{/* starch dots */}
<circle cx="210" cy="140" r="5" fill={COLORS.onSurface} />
<circle cx="130" cy="146" r="4" fill={COLORS.onSurface} />
{/* caption */}
</svg>
);
// ── Floating notepaper (S10) ─────────────────────────────────
export const NoteCard: React.FC<{
x: number;
y: number;
w?: number;
h?: number;
children: React.ReactNode;
drift?: number;
amp?: number;
rot?: number;
opacity?: number;
}> = ({ x, y, w = 250, h = 190, children, drift = 0, amp = 6, rot = -4, opacity = 1 }) => {
const frame = useCurrentFrame();
const t = floatStyle(frame + drift, amp, 11);
return (
<div
style={{
position: "absolute",
left: x,
top: y,
width: w,
height: h,
rotate: `${rot}deg`,
translate: t,
opacity,
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 14,
boxShadow: "0 2px 8px rgba(17,24,39,0.08)",
padding: 18,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{children}
</div>
);
};
+11
View File
@@ -0,0 +1,11 @@
/* padhle — launch trailer template
Global styles. Motion is driven by inline interpolate() styles,
not CSS transitions (see Remotion markup best practices). */
html,
body {
margin: 0;
background: #ffffff;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
+226
View File
@@ -0,0 +1,226 @@
import React from "react";
import {
AbsoluteFill,
Interactive,
interpolate,
useCurrentFrame,
} from "remotion";
import { COLORS, monoFont, SNAP } from "../theme";
/**
* Scene 4 — The AI Reveal (8.211.0s)
* ------------------------------------------------------------
* The narrative turn: S1S3 were the problem and the promise.
* Now the screen goes dark and the experience is revealed —
* softly, in a pool of light, line by line, like a terminal
* coming alive. This is the first "AI" mention (~9s, per the
* late-reveal direction).
*
* Your AI tutor.
* Built for CBSE.
* Grounded in your textbook.
*
* Strictly monochrome. The soft light is made of blurred white
* ellipses (no gradients / no glassmorphism).
*/
export const THE_AI_REVEAL_FRAMES = 168; // 2.8s @ 60fps
type Line = { text: string; start: number; step: number; size: number; y: number };
const LINES: Line[] = [
{ text: "Your AI tutor.", start: 24, step: 2.1, size: 96, y: 700 },
{ text: "Built for CBSE.", start: 78, step: 2.0, size: 72, y: 952 },
{ text: "Grounded in your textbook.", start: 116, step: 1.5, size: 64, y: 1180 },
];
const X0 = 150; // left margin (gap between caret-block and glyphs)
const monoAdvance = 0.6; // JetBrains Mono advance ratio (~0.6em)
// ── Soft spotlight pool (blurred white ellipses, no gradients) ──
const SpotlightLayer: React.FC<{
w: number;
h: number;
blur: number;
opacity: number;
breathe: number;
}> = ({ w, h, blur, opacity, breathe }) => (
<div
style={{
position: "absolute",
left: 540 - w / 2,
top: 920 - h / 2,
width: w,
height: h,
borderRadius: "50%",
backgroundColor: COLORS.white,
filter: `blur(${blur}px)`,
opacity,
scale: String((1 + 0.012 * breathe).toFixed(4)),
}}
/>
);
// ── Terminal caret (blinks after the last typed line) ─────────
const Caret: React.FC<{ x: number; y: number; size: number; blink: boolean }> = ({
x,
y,
size,
blink,
}) => {
const frame = useCurrentFrame();
const on = !blink || (frame % 40) < 24;
return (
<div
style={{
position: "absolute",
left: x,
top: y,
width: Math.max(10, size * 0.2),
height: size * 0.86,
backgroundColor: COLORS.white,
opacity: on ? 1 : 0.06,
}}
/>
);
};
export const AiReveal: React.FC = () => {
const frame = useCurrentFrame();
// Spotlight breathing + ambient drift
const breathe = Math.sin((frame / 60) * (2 * Math.PI) / 9);
const driftX = Math.sin((frame / 60) * (2 * Math.PI) / 13) * 7;
const driftY = Math.cos((frame / 60) * (2 * Math.PI) / 17) * 5;
const push = interpolate(frame, [0, 120], [1, 1.03], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// White out at the end → clean seam into Scene 5 (white UI)
const out = interpolate(frame, [158, 166], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// Active line (the one most recently typed)
let active: Line | null = null;
for (const l of LINES) {
if (frame >= l.start) active = l;
}
const activeCount = active
? Math.min(active.text.length, Math.max(0, Math.floor((frame - active.start) / active.step) + 1))
: 0;
const activeDone = active ? activeCount >= active.text.length : false;
const caretX = active ? X0 + activeCount * active.size * monoAdvance : X0;
const caretY = active ? active.y : 700;
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.black }}>
<Interactive.Div
name="Drift"
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
translate: `${driftX.toFixed(2)}px ${driftY.toFixed(2)}px`,
scale: String(push),
}}
>
{/* Spotlight pool */}
<Interactive.Div name="Spotlight">
<SpotlightLayer w={1650} h={1050} blur={120} opacity={0.05} breathe={breathe} />
<SpotlightLayer w={1180} h={700} blur={72} opacity={0.06} breathe={breathe} />
<SpotlightLayer w={800} h={440} blur={34} opacity={0.09} breathe={breathe} />
</Interactive.Div>
{/* Dust motes in the light */}
<Interactive.Div name="Dust">
{[
{ x: 300, y: 1180, r: 7, d: 90 },
{ x: 760, y: 940, r: 5, d: 120 },
{ x: 420, y: 1330, r: 4, d: 150 },
].map((m, i) => {
const rise = ((frame + m.d) % 220) / 220;
return (
<div
key={i}
style={{
position: "absolute",
left: m.x + Math.sin((frame + i * 40) / 30) * 30,
top: m.y - rise * 420,
width: m.r * 2,
height: m.r * 2,
borderRadius: "50%",
backgroundColor: COLORS.white,
filter: "blur(3px)",
opacity: 0.16,
}}
/>
);
})}
</Interactive.Div>
{/* Typewriter lines */}
<Interactive.Div name="Typewriter">
{LINES.map((line, li) => {
const visible = Math.min(
line.text.length,
Math.max(0, Math.floor((frame - line.start) / line.step) + 1),
);
return (
<Interactive.Div
key={li}
name={`Line-${li}`}
style={{
position: "absolute",
left: X0 + 18,
top: line.y,
whiteSpace: "nowrap",
fontFamily: monoFont,
fontSize: line.size,
fontWeight: 500,
color: COLORS.white,
letterSpacing: "0.02em",
}}
>
{line.text.split("").map((ch, i) => (
<span
key={i}
style={{
opacity: i < visible ? 1 : 0,
}}
>
{ch === " " ? "\u00A0" : ch}
</span>
))}
</Interactive.Div>
);
})}
<Caret
x={caretX}
y={caretY}
size={active?.size ?? 96}
blink={activeDone}
/>
</Interactive.Div>
</Interactive.Div>
{/* White-out exit */}
<Interactive.Div
name="RevealOut"
style={{
position: "absolute",
inset: 0,
backgroundColor: COLORS.white,
opacity: out,
}}
/>
</AbsoluteFill>
);
};
+274
View File
@@ -0,0 +1,274 @@
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import {
Bubble,
ChloroplastDiagram,
KeywordHighlight,
TopBar,
floatStyle,
} from "../components/ui";
import { COLORS, bodyFont, headingFont, SNAP } from "../theme";
/**
* Scene 7 — The Answer (17.120.6s)
* ------------------------------------------------------------
* The payoff. The assistant bubble pops in and the answer builds
* naturally — line by line, the keyword "Photosynthesis" gets a
* highlight box drawn around it, a diagram slides in, and the
* source badge (the grounded-textbook hook) appears with a rule
* drawing under it.
*
* Layout: bubble (avatar + answer lines) → diagram → badge →
* underline bar. All floats are at SCENE coordinates (a previous
* version nested them in the auto-sized bubble, pushing the badge
* off-screen — fixed).
*/
export const ANSWER_FRAMES = 210; // 3.5s @ 60fps
export const Answer: React.FC = () => {
const frame = useCurrentFrame();
// bubble pop
const pop = interpolate(frame, [12, 26], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// lines build (word-count driven)
const LINES = [
{ text: "Photosynthesis is how plants make", start: 30, step: 3.4 },
{ text: "their own food from sunlight.", start: 66, step: 3.4 },
{ text: "In simple words: plants make food", start: 102, step: 3.4 },
{ text: "— glucose — using light.", start: 138, step: 3.4 },
];
// diagram slides in (scene coords)
const diagramFrom = 130;
const diagramIn = interpolate(frame, [diagramFrom, diagramFrom + 22], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// source badge (scene coords)
const badgeFrom = 162;
const badgeIn = interpolate(frame, [badgeFrom, badgeFrom + 14], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const ruleIn = interpolate(frame, [badgeFrom + 8, badgeFrom + 26], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.background }}>
<TopBar
from={2}
pills={[
{ label: "Grade 10", active: true },
{ label: "Science", active: true },
{ label: "Ch 2", active: true },
]}
/>
{/* ask bubble (small, user) */}
<Bubble role="user" from={6} y={400} w={600} x={540}>
Explain photosynthesis in simple words
</Bubble>
{/* assistant bubble — avatar + answer lines only */}
<div
style={{
position: "absolute",
left: 540,
top: 560,
translate: "-50% 0",
width: 780,
opacity: pop,
scale: String((1.05 - 0.05 * pop).toFixed(4)),
transformOrigin: "50% 0%",
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 26,
boxShadow: "0 3px 14px rgba(17,24,39,0.10)",
padding: "30px 38px",
}}
>
{/* avatar row */}
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 24 }}>
<div
style={{
width: 46,
height: 46,
borderRadius: 14,
backgroundColor: COLORS.primary,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width={26} height={26} viewBox="0 0 24 24" fill="none">
<path d="M5.2 4.6 11.6 6.2 11.6 17.8 5.2 15.4Z" fill={COLORS.onPrimary} />
<path d="M18.8 4.6 12.4 6.2 12.4 17.8 18.8 15.4Z" fill={COLORS.onPrimary} />
</svg>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: headingFont, fontSize: 28, fontWeight: 700, color: COLORS.onSurface }}>
padhle
</div>
</div>
<div
style={{
fontFamily: bodyFont,
fontSize: 22,
color: COLORS.onSurfaceVariant,
backgroundColor: COLORS.surfaceLow,
border: `2px solid ${COLORS.outlineVariant}`,
borderRadius: 999,
padding: "6px 18px",
}}
>
answering from Ch 2
</div>
</div>
{/* answer lines */}
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
{LINES.map((ln, i) => {
const words = ln.text.split(" ");
const visible = Math.max(
0,
Math.min(words.length, Math.floor((frame - ln.start) / ln.step) + 1),
);
return (
<div
key={i}
style={{
fontFamily: bodyFont,
fontSize: 34,
fontWeight: 500,
lineHeight: 1.4,
color: COLORS.onSurface,
}}
>
{words.map((wd, j) => {
const isKeyword = i === 0 && j === 0;
return (
<span key={j} style={{ opacity: j < visible ? 1 : 0.12 }}>
{isKeyword ? (
<KeywordHighlight from={54} size={300}>
{wd}
</KeywordHighlight>
) : (
wd
)}
{"\u00A0"}
</span>
);
})}
</div>
);
})}
</div>
</div>
{/* diagram — scene coords, slides in from the left */}
<div
style={{
position: "absolute",
left: 540,
top: 1020,
width: 470,
opacity: diagramIn,
rotate: `${((1 - diagramIn) * -6).toFixed(2)}deg`,
translate: `${((1 - diagramIn) * -300).toFixed(0)}px 0`,
transformOrigin: "50% 50%",
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 20,
boxShadow: "0 2px 10px rgba(17,24,39,0.08)",
padding: 24,
}}
>
<ChloroplastDiagram size={230} />
<div
style={{
fontFamily: bodyFont,
fontSize: 24,
color: COLORS.onSurfaceVariant,
marginTop: 10,
textAlign: "center",
}}
>
A leaf cell making food
</div>
</div>
{/* source badge — scene coords */}
{frame >= badgeFrom && (
<div
style={{
position: "absolute",
left: 540,
top: 1410,
translate: "-50% 0",
opacity: badgeIn,
fontFamily: bodyFont,
fontSize: 26,
fontWeight: 600,
color: COLORS.onSurface,
backgroundColor: COLORS.surfaceLow,
border: `2.5px solid ${COLORS.outline}`,
borderRadius: 14,
padding: "12px 26px",
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<svg width={24} height={24} viewBox="0 0 24 24" fill="none">
<path d="M5.2 4.6 11.6 6.2 11.6 17.8 5.2 15.4Z" fill={COLORS.onSurface} />
<path d="M18.8 4.6 12.4 6.2 12.4 17.8 18.8 15.4Z" fill={COLORS.onSurface} />
</svg>
Source · Ch 2 · Photosynthesis
</div>
)}
{/* underline bar — separate, centered under the badge */}
{frame >= badgeFrom + 6 && (
<div
style={{
position: "absolute",
left: 540 - 200,
top: 1466,
width: 400,
height: 4,
backgroundColor: COLORS.primary,
opacity: badgeIn,
scale: `${ruleIn.toFixed(4)} 1`,
transformOrigin: "0% 50%",
}}
/>
)}
{/* ambient drift overlay (nothing static) */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
translate: floatStyle(frame, 4, 14),
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
+194
View File
@@ -0,0 +1,194 @@
import React from "react";
import { AbsoluteFill, Interactive, interpolate, useCurrentFrame } from "remotion";
import { AppCursor, TopBar } from "../components/ui";
import { COLORS, bodyFont, HIT, SNAP } from "../theme";
/**
* Scene 6 — Asking (14.617.1s)
* ------------------------------------------------------------
* Chat close-up. The question types itself into a user bubble
* with a blinking caret, the send button grows and glows as the
* message completes, then the cursor clicks send → speed-ramp
* into Scene 7.
*/
export const ASKING_FRAMES = 150; // 2.5s @ 60fps
const QUESTION = "Explain photosynthesis in simple words";
const TYPE_START = 18;
const TYPE_STEP = 2.1;
const SEND_READY = TYPE_START + QUESTION.length * TYPE_STEP + 8; // ~56+8 = ~98
const CLICK = 118;
export const Asking: React.FC = () => {
const frame = useCurrentFrame();
const typed = Math.max(
0,
Math.min(QUESTION.length, Math.floor((frame - TYPE_START) / TYPE_STEP) + 1),
);
const typingDone = typed >= QUESTION.length;
// caret blinks while typing, solid when done (until send)
const caretOn = frame < CLICK && ((frame % 30) < 18 || typingDone);
// send readiness (grows + glows once the question is complete)
const sendVal = interpolate(frame, [SEND_READY, SEND_READY + 18], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// send click press
const sendPress = interpolate(frame, [CLICK, CLICK + 7], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const whoosh = interpolate(frame, [CLICK + 2, CLICK + 16], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: HIT,
});
// send button center (input bar: x 62..1018, y 1480..1600; button right:28 top:24 ~72px)
const SEND_X = 954;
const SEND_Y = 1540;
// cursor path: enters, hovers the send button, clicks it
let cx = 540, cy = 1200, cp = 0;
if (frame < CLICK - 22) {
cx = 1180 - (1180 - SEND_X) * interpolate(frame, [TYPE_START - 10, TYPE_START + 18], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT });
cy = 900 + (SEND_Y - 900) * interpolate(frame, [TYPE_START, TYPE_START + 34], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT });
} else if (frame < CLICK) {
cx = SEND_X;
cy = SEND_Y;
} else if (frame < CLICK + 8) {
cx = SEND_X;
cy = SEND_Y;
cp = sendPress;
} else {
cx = SEND_X + whoosh * 820;
cy = SEND_Y - whoosh * 320;
cp = 0;
}
const cursorOpacity = interpolate(frame, [TYPE_START, TYPE_START + 12], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.background }}>
<TopBar from={2} pills={[{ label: "Grade 10", active: true }, { label: "Science", active: true }, { label: "Ch 2", active: true }]} />
{/* chat area */}
<Interactive.Div name="ChatArea" style={{ position: "absolute", left: 0, top: 128, width: 1080, height: 1792, backgroundColor: COLORS.background }}>
{/* user bubble typing */}
<div
style={{
position: "absolute",
left: 540,
top: 640,
translate: "-50% 0",
maxWidth: 860,
fontFamily: bodyFont,
fontSize: 34,
fontWeight: 600,
color: COLORS.onPrimary,
backgroundColor: COLORS.primary,
borderRadius: 26,
padding: "26px 36px",
whiteSpace: "pre-wrap",
}}
>
{QUESTION.split("").map((ch, i) => (
<span
key={i}
style={{
opacity: i < typed ? 1 : 0.14,
}}
>
{ch === " " ? "\u00A0" : ch}
</span>
))}
{frame < CLICK && (
<span
style={{
color: COLORS.onPrimary,
width: 4,
height: 40,
backgroundColor: COLORS.onPrimary,
display: "inline-block",
opacity: caretOn ? 1 : 0,
}}
>
{"\u00A0"}
</span>
)}
</div>
{/* input bar with growing send */}
<div
style={{
position: "absolute",
left: 62,
top: 1480,
width: 956,
height: 120,
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 22,
boxShadow: "0 2px 10px rgba(17,24,39,0.08)",
display: "flex",
alignItems: "center",
padding: "0 34px",
gap: 22,
}}
>
<span style={{ fontSize: 32, color: COLORS.onSurfaceVariant }}></span>
<div
style={{
flex: 1,
fontFamily: bodyFont,
fontSize: 30,
fontWeight: 600,
color: COLORS.onSurface,
}}
>
{QUESTION.slice(0, typed)}
<span style={{ color: COLORS.onSurfaceVariant }}>
{QUESTION.slice(typed)}
</span>
</div>
{/* growing send button */}
<div
style={{
position: "absolute",
right: 28,
top: 24,
width: 72 * (0.84 + 0.16 * sendVal),
height: 72 * (0.84 + 0.16 * sendVal),
borderRadius: "50%",
backgroundColor: COLORS.primary,
scale: String((1 - 0.22 * sendPress).toFixed(4)),
boxShadow: sendVal > 0.03 ? `0 0 ${(22 * sendVal).toFixed(1)}px rgba(17,24,39,${(0.32 * sendVal).toFixed(2)})` : "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width="34" height="30" viewBox="0 0 28 24">
<path d="M3 21 13 9 21 5" stroke={COLORS.onPrimary} strokeWidth="3.4" strokeLinecap="round" fill="none" />
<path d="M14 21 22 13 26 11" stroke={COLORS.onPrimary} strokeWidth="3.4" strokeLinecap="round" fill="none" />
</svg>
</div>
</div>
</Interactive.Div>
{/* cursor */}
<AppCursor x={cx} y={cy} press={cp} opacity={cursorOpacity * (1 - 0.4 * whoosh)} />
</AbsoluteFill>
);
};
@@ -0,0 +1,249 @@
import React from "react";
import { AbsoluteFill, Interactive, interpolate, useCurrentFrame } from "remotion";
import {
AppCursor,
Ripple,
TopBar,
} from "../components/ui";
import { COLORS, bodyFont, headingFont, HIT, SNAP } from "../theme";
/**
* Scene 5 — Choosing Context (10.614.6s)
* ------------------------------------------------------------
* First UI moment. The app, in close-up: top bar drops in, the
* context selector card unfolds, and the oversized cursor walks
* three clicks — Grade 10 → Science → Chapter 4 · Photosynthesis.
* Each click: ripple ring + pill flips black + micro-bounce +
* subtle camera push. Then a confirmation row locks the context.
*/
export const CHOOSING_FRAMES = 240; // 4.0s @ 60fps
type StepType = "grade" | "subject" | "chapter";
const GRADE_OPTIONS = ["Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12"];
const SUBJECT_OPTIONS = ["Mathematics", "Science", "History", "Language Arts", "English", "Geography"];
const CHAPTER_OPTIONS = ["Ch 1 · Nutrition in Plants", "Ch 2 · Photosynthesis", "Ch 3 · Respiration", "Ch 4 · Human Body", "Ch 5 · Food Chains", "Ch 6 · Adaptations"];
// beat frames
const CARD_IN = 10;
const CLICK1 = 78;
const CLICK2 = 126;
const CLICK3 = 174;
const CONFIRM = 214;
export const ChoosingContext: React.FC = () => {
const frame = useCurrentFrame();
const step: StepType =
frame < CLICK1 ? "grade" : frame < CLICK2 ? "subject" : frame < CLICK3 ? "chapter" : "chapter";
const options =
step === "grade" ? GRADE_OPTIONS : step === "subject" ? SUBJECT_OPTIONS : CHAPTER_OPTIONS;
const pickIdx = step === "grade" ? 4 : step === "subject" ? 1 : 1; // Grade 10 / Science / Ch2
// camera push-in per click (pulses)
const push =
1 +
interpolate(frame, [CLICK1, CLICK1 + 10], [0, 0.016], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP }) +
interpolate(frame, [CLICK2, CLICK2 + 10], [0, 0.02], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP }) +
interpolate(frame, [CLICK3, CLICK3 + 10], [0, 0.024], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP });
// card entrance
const cardIn = interpolate(frame, [CARD_IN, CARD_IN + 16], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const cardY = 560 + (1 - cardIn) * 46;
// Simple cursor path: move to target, click, move to next target
const curClick = (from: number, to: number, at: number) => {
const t = interpolate(frame, [at, at + 16], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: HIT,
});
return from + (to - from) * t;
};
const press = (at: number) =>
interpolate(frame, [at, at + 6], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// cursor position timeline
let cx = 640,
cy = 760,
cp = 0;
if (frame < 56) {
cx = 1180 - (1180 - 476) * interpolate(frame, [24, 56], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT });
cy = 900;
} else if (frame < CLICK1) {
cx = curClick(476, 476, 56);
cy = 732;
} else if (frame < CLICK1 + 8) {
cx = 470;
cy = 728;
cp = press(CLICK1);
} else if (frame < CLICK2 - 12) {
cx = curClick(470, 460, CLICK1 + 8);
cy = 728 + (880 - 728) * interpolate(frame, [CLICK1 + 8, CLICK2 - 12], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT });
} else if (frame < CLICK2 + 8) {
cx = 460;
cy = 876;
cp = press(CLICK2);
} else if (frame < CLICK3 - 14) {
cx = curClick(460, 480, CLICK2 + 8);
cy = 876 + (1030 - 876) * interpolate(frame, [CLICK2 + 8, CLICK3 - 14], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT });
} else if (frame < CLICK3 + 10) {
cx = 480;
cy = 1026;
cp = press(CLICK3);
} else {
cx = curClick(480, 300, CLICK3 + 10);
cy = 1026 + (620 - 1026) * interpolate(frame, [CLICK3 + 10, CLICK3 + 26], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT });
}
const cursorOpacity = interpolate(frame, [24, 40], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// ripple activations
const ripples = [
<Ripple at={CLICK1} x={470} y={745} />,
<Ripple at={CLICK2} x={460} y={895} />,
<Ripple at={CLICK3} x={480} y={1045} />,
];
// confirm row
const confirmIn = interpolate(frame, [CONFIRM, CONFIRM + 16], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.background }}>
<Interactive.Div name="CameraPush" style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%", scale: String(push.toFixed(4)) }}>
<TopBar
from={6}
pills={[
{ label: "Grade 10", active: frame >= CLICK1 },
{ label: "Science", active: frame >= CLICK2 },
{ label: "Ch 2", active: frame >= CLICK3 },
]}
/>
{/* selector card */}
<div
style={{
position: "absolute",
left: 540 - 410,
top: cardY,
width: 820,
height: 880,
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 24,
boxShadow: "0 4px 18px rgba(17,24,39,0.10)",
opacity: cardIn,
padding: "40px 48px",
}}
>
<div
style={{
fontFamily: headingFont,
fontSize: 44,
fontWeight: 800,
color: COLORS.onSurface,
}}
>
{step === "grade" ? "Choose your standard" : step === "subject" ? "Choose your subject" : "Choose your chapter"}
</div>
<div
style={{
fontFamily: bodyFont,
fontSize: 26,
color: COLORS.onSurfaceVariant,
marginTop: 10,
}}
>
{step === "grade"
? "For CBSE · Grades 612"
: step === "subject"
? "For Grade 10"
: "Grounded in your textbook"}
</div>
{/* options list (3-col grid for grades, list for subject/chapter) */}
<div style={{ marginTop: 40, display: "flex", flexDirection: "column", gap: 18 }}>
{options.map((opt, i) => {
const isPick = i === pickIdx;
const lit = isPick && frame >= (step === "grade" ? CLICK1 : step === "subject" ? CLICK2 : CLICK3);
return (
<div
key={`${step}-${opt}`}
style={{
display: "flex",
alignItems: "center",
height: 84,
fontFamily: bodyFont,
fontSize: 30,
fontWeight: 600,
color: lit ? COLORS.onPrimary : COLORS.onSurface,
backgroundColor: lit ? COLORS.primary : COLORS.surface,
border: lit ? "none" : `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 16,
padding: "0 26px",
scale: String((isPick ? 1 + 0.02 : 1).toFixed(4)),
}}
>
<span style={{ width: 16, height: 16, borderRadius: "50%", border: `2.5px solid ${lit ? COLORS.onPrimary : COLORS.outline}`, marginRight: 18 }} />
{opt}
</div>
);
})}
</div>
</div>
{/* cursor + ripples */}
<AppCursor x={cx} y={cy} opacity={cursorOpacity} press={cp} />
{ripples}
{/* confirm bar */}
{frame >= CONFIRM && (
<div
style={{
position: "absolute",
left: 540,
top: 1620,
translate: "-50% 0",
opacity: confirmIn,
scale: String((1.06 - 0.06 * confirmIn).toFixed(4)),
fontFamily: bodyFont,
fontSize: 30,
fontWeight: 600,
color: COLORS.onSurface,
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.primary}`,
borderRadius: 18,
padding: "18px 34px",
display: "flex",
alignItems: "center",
gap: 16,
}}
>
<span style={{ fontSize: 30, color: COLORS.primary }}></span>
Grade 10 · Science · Chapter 2 · Photosynthesis
</div>
)}
</Interactive.Div>
</AbsoluteFill>
);
};
// (Ripple is imported from ../components/ui)
+125
View File
@@ -0,0 +1,125 @@
import React from "react";
import { AbsoluteFill, Interactive, interpolate, useCurrentFrame } from "remotion";
import { BookMark } from "../components/BookMark";
import { COLORS, headingFont, OVERSHOOT, SNAP } from "../theme";
/**
* Scene 11 — CTA (32.335.8s)
* ------------------------------------------------------------
* The close: the book mark grows, the promise condenses to
* "Your textbooks. Finally make sense.", and the start button
* fades in with a tiny bounce + a sliding arrow.
*/
export const CTA_FRAMES = 210; // 3.5s @ 60fps
export const Cta: React.FC = () => {
const frame = useCurrentFrame();
const markIn = interpolate(frame, [10, 34], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: OVERSHOOT,
output: "perceptual-scale",
});
const markRot = interpolate(frame, [10, 40], ["-6deg", "0deg"], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: OVERSHOOT,
});
const line1 = interpolate(frame, [40, 60], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
const line2 = interpolate(frame, [84, 104], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
const btn = interpolate(frame, [136, 158], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: OVERSHOOT, output: "perceptual-scale",
});
// arrow slides as button appears
const arr = interpolate(frame, [150, 172], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.surface }}>
<Interactive.Div name="Lockup" style={{ position: "absolute", left: 540, top: 480, translate: "-50% 0" }}>
<Interactive.Div name="Logo" style={{ scale: String(markIn.toFixed(4)), rotate: markRot }}>
<BookMark size={150} />
</Interactive.Div>
</Interactive.Div>
<Interactive.Div
name="Text1"
style={{
position: "absolute",
left: 540,
top: 900,
translate: "-50% 0",
fontFamily: headingFont,
fontSize: 96,
fontWeight: 900,
color: COLORS.onSurface,
opacity: line1,
scale: String((1.08 - 0.08 * line1).toFixed(4)),
}}
>
Your textbooks.
</Interactive.Div>
<Interactive.Div
name="Text2"
style={{
position: "absolute",
left: 540,
top: 1120,
translate: `-50% ${((1 - line2) * 30).toFixed(1)}px`,
fontFamily: headingFont,
fontSize: 96,
fontWeight: 800,
color: COLORS.onSurface,
opacity: line2,
}}
>
Finally make sense.
</Interactive.Div>
{/* start button */}
<Interactive.Div
name="StartBtn"
style={{
position: "absolute",
left: 540,
top: 1500,
translate: "-50% 0",
scale: String(btn.toFixed(4)),
opacity: btn > 0.05 ? Math.min(1, btn * 1.4) : 0,
fontFamily: headingFont,
fontSize: 56,
fontWeight: 800,
color: COLORS.onPrimary,
backgroundColor: COLORS.primary,
borderRadius: 999,
padding: "30px 76px",
boxShadow: "0 8px 28px rgba(17,24,39,0.22)",
display: "flex",
alignItems: "center",
gap: 24,
}}
>
Start Learning
<span
style={{
fontSize: 52,
translate: `${(arr * 26).toFixed(1)}px 0`,
opacity: arr,
}}
>
</span>
</Interactive.Div>
</AbsoluteFill>
);
};
+107
View File
@@ -0,0 +1,107 @@
import React from "react";
import { AbsoluteFill, Easing, Interactive, interpolate, useCurrentFrame } from "remotion";
import { BookMark } from "../components/BookMark";
import { COLORS, bodyFont, headingFont, SNAP } from "../theme";
/**
* Scene 12 — End Card (35.838.6s)
* ------------------------------------------------------------
* Minimal. Logo lockup, black typography on white, everything
* fades in gently and fades out at the very end (clean white cut
* for the platform loop).
*/
export const ENDCARD_FRAMES = 168; // 2.8s @ 60fps
export const EndCard: React.FC = () => {
const frame = useCurrentFrame();
const lockup = interpolate(frame, [12, 40], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
const sub = interpolate(frame, [36, 62], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
const fade = interpolate(frame, [140, 166], [1, 0], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.quad),
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.surface }}>
<Interactive.Div
name="EndLockup"
style={{
position: "absolute",
left: 540,
top: 700,
translate: "-50% 0",
opacity: lockup * fade,
scale: String((1.1 - 0.1 * lockup).toFixed(4)),
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 40 }}>
<BookMark size={128} />
<div
style={{
fontFamily: headingFont,
fontSize: 168,
fontWeight: 900,
color: COLORS.onSurface,
letterSpacing: "-0.01em",
}}
>
Padhle
</div>
</div>
</Interactive.Div>
<Interactive.Div
name="Subline"
style={{
position: "absolute",
left: 540,
top: 1180,
translate: `-50% ${((1 - sub) * 20).toFixed(1)}px`,
fontFamily: bodyFont,
fontSize: 54,
fontWeight: 600,
color: COLORS.onSurfaceVariant,
opacity: sub * fade,
}}
>
AI Tutor for CBSE Students
</Interactive.Div>
<Interactive.Div
name="Grades"
style={{
position: "absolute",
left: 540,
top: 1320,
translate: "-50% 0",
fontFamily: bodyFont,
fontSize: 42,
fontWeight: 500,
letterSpacing: "0.3em",
color: COLORS.onSurfaceVariant,
opacity: sub * fade,
}}
>
GRADES 612
</Interactive.Div>
{/* soft vignette fade at the very end */}
<Interactive.Div
name="EndFade"
style={{
position: "absolute",
inset: 0,
backgroundColor: COLORS.surface,
opacity: interpolate(frame, [156, 167], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
}),
}}
/>
</AbsoluteFill>
);
};
+147
View File
@@ -0,0 +1,147 @@
import React from "react";
import { AbsoluteFill, Interactive, interpolate, useCurrentFrame } from "remotion";
import { ArrowDown } from "../components/ui";
import { COLORS, headingFont, HIT, OVERSHOOT, SNAP, SPRING } from "../theme";
/**
* Scene 8 — More Than a Chatbot (20.625.1s)
* ------------------------------------------------------------
* Five capability cards fly in one-by-one — each with its own
* entrance (slide / rotate / scale-bounce / drop / slide-right),
* never repetitive — connected by tiny down arrows. Monochrome,
* black type, light-gray chrome.
*/
export const MORE_FRAMES = 270; // 4.5s @ 60fps
const CARDS = [
{ label: "Learn", n: "01", enter: 16, kind: "slide" },
{ label: "Practice", n: "02", enter: 74, kind: "rotate" },
{ label: "Quiz", n: "03", enter: 128, kind: "scale" },
{ label: "Revise", n: "04", enter: 182, kind: "drop" },
{ label: "Summarize", n: "05", enter: 230, kind: "slideright" },
];
const CARD_W = 620;
const CARD_H = 118;
const cardY = (i: number) => 470 + i * 148;
export const More: React.FC = () => {
const frame = useCurrentFrame();
const header = interpolate(frame, [8, 22], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.background }}>
{/* header */}
<Interactive.Div
name="Header"
style={{
position: "absolute",
left: 540,
top: 230,
translate: `-50% ${(1 - header) * -26}px`,
fontFamily: headingFont,
fontSize: 64,
fontWeight: 800,
color: COLORS.onSurface,
letterSpacing: "-0.01em",
opacity: header,
}}
>
More than a chatbot.
</Interactive.Div>
{/* cards + arrows */}
{CARDS.map((c, i) => {
const y = cardY(i);
let style: React.CSSProperties;
const from = c.enter;
if (c.kind === "slide") {
const t = interpolate(frame, [from, from + 16], ["-520px 0px", "0px 0px"], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT,
});
const o = interpolate(frame, [from, from + 6], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
style = { translate: t, opacity: o };
} else if (c.kind === "rotate") {
const t = interpolate(frame, [from, from + 20], ["0px 420px", "0px 0px"], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT,
});
const r = interpolate(frame, [from, from + 22], ["10deg", "0deg"], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: OVERSHOOT,
});
const o = interpolate(frame, [from, from + 8], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
style = { translate: t, rotate: r, opacity: o };
} else if (c.kind === "scale") {
const s = interpolate(frame, [from, from + 20], [0.2, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SPRING, output: "perceptual-scale",
});
const o = interpolate(frame, [from, from + 6], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
style = { scale: String(s.toFixed(4)), opacity: o };
} else if (c.kind === "drop") {
const t = interpolate(frame, [from, from + 20], ["0px -360px", "0px 0px"], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT,
});
const o = interpolate(frame, [from, from + 8], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
style = { translate: t, opacity: o };
} else {
const t = interpolate(frame, [from, from + 16], ["520px 0px", "0px 0px"], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: HIT,
});
const o = interpolate(frame, [from, from + 6], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
style = { translate: t, opacity: o };
}
return (
<React.Fragment key={c.label}>
{i > 0 && (
<ArrowDown y={y - 36} from={CARDS[i - 1].enter + 12} />
)}
<div
style={{
position: "absolute",
left: 540 - CARD_W / 2,
top: y,
width: CARD_W,
height: CARD_H,
fontFamily: headingFont,
fontSize: 46,
fontWeight: 800,
color: COLORS.onSurface,
backgroundColor: COLORS.surface,
border: `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 22,
boxShadow: "0 2px 10px rgba(17,24,39,0.08)",
display: "flex",
alignItems: "center",
padding: "0 32px",
gap: 16,
...style,
}}
>
<span style={{ fontFamily: "inherit", fontSize: 28, fontWeight: 700, color: COLORS.onSurfaceVariant, width: 60 }}>
{c.n}
</span>
<span style={{ flex: 1 }}>{c.label}</span>
<span style={{ fontSize: 34, color: COLORS.onSurfaceVariant }}></span>
</div>
</React.Fragment>
);
})}
</AbsoluteFill>
);
};
+414
View File
@@ -0,0 +1,414 @@
import React from "react";
import {
AbsoluteFill,
Easing,
Interactive,
interpolate,
useCurrentFrame,
} from "remotion";
import { COLORS, bodyFont, headingFont, monoFont, HIT, SNAP } from "../theme";
/**
* Scene 1 — The Panic (opening hook, 03s)
* ------------------------------------------------------------
* Black screen. Racing heartbeat. A storm of notebook pages —
* equations, biology diagrams, chemistry formulas, exam dates,
* homework — flying at the camera. Camera shake. Words slam in:
*
* EXAMS. 0.4s
* Too many chapters. 0.5s
* Too little time. 0.5s
*
* Each slam is CUT cleanly (fades out as the cut-flash masks it) —
* no ghost text behind the next beat. The paper storm clears before
* the first slam; only fast shrapnel streaks fly during the words,
* so the slams always read on near-black.
*/
export const PANIC_FRAMES = 3 * 60; // 3.0s @ 60fps
// ── Deterministic camera shake ───────────────────────────────
const shake = (frame: number, amp: number) => ({
x: Math.sin(frame * 1.7) * Math.sin(frame * 0.37) * amp,
y: Math.cos(frame * 1.31) * Math.sin(frame * 0.29) * amp * 0.75,
});
const flashAt = (frame: number, at: number, peak = 0.9) =>
interpolate(frame, [at, at + 2, at + 6], [0, peak, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const pulseAt = (frame: number, at: number, peak = 0.2) =>
interpolate(frame, [at, at + 4, at + 10], [0, peak, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// ── Flying notebook papers ───────────────────────────────────
type PaperKind = "math" | "bio" | "chem" | "exam" | "hw";
type Paper = {
kind: PaperKind;
from: number;
dur: number;
x0: number;
y0: number;
sx0: number;
sx1: number;
rot: number;
spin: number;
blur: number;
opacity?: number;
};
// Big readable storm — first second, then clears before the slams.
const STORM: Paper[] = [
{ kind: "math", from: 12, dur: 44, x0: -140, y0: 760, sx0: 0.24, sx1: 2.6, rot: -13, spin: 0.05, blur: 6 },
{ kind: "chem", from: 18, dur: 42, x0: 1220, y0: 900, sx0: 0.26, sx1: 2.4, rot: 11, spin: -0.06, blur: 6 },
{ kind: "bio", from: 26, dur: 40, x0: 940, y0: 150, sx0: 0.24, sx1: 2.7, rot: -8, spin: 0.05, blur: 7 },
{ kind: "exam", from: 34, dur: 34, x0: 160, y0: -70, sx0: 0.27, sx1: 2.3, rot: 15, spin: -0.04, blur: 6 },
];
// Fast shrapnel streaks — fly DURING the slams, off the text.
const SHARDS: Paper[] = [
{ kind: "chem", from: 74, dur: 26, x0: -60, y0: 300, sx0: 0.3, sx1: 1.4, rot: 22, spin: 0.4, blur: 8, opacity: 0.85 },
{ kind: "hw", from: 106, dur: 22, x0: 1140, y0: 470, sx0: 0.3, sx1: 1.35, rot: -18, spin: -0.45, blur: 8, opacity: 0.85 },
{ kind: "bio", from: 144, dur: 22, x0: -40, y0: 1420, sx0: 0.3, sx1: 1.3, rot: 12, spin: 0.5, blur: 8, opacity: 0.82 },
];
// ── Paper content (monochrome "notes") ───────────────────────
const PaperContent: React.FC<{ kind: PaperKind }> = ({ kind }) => {
const note = { fontSize: 40, fontFamily: monoFont, color: COLORS.black };
const faint = { fontSize: 24, fontFamily: monoFont, color: COLORS.onSurfaceVariant };
switch (kind) {
case "math":
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={faint}>algebra</div>
<div style={note}>
x<sup>2</sup> + 5x + 6 = 0
</div>
<div style={{ ...note, fontSize: 30 }}>
(x + 2)(x + 3) = 0
</div>
<div style={{ ...note, fontSize: 30 }}>
Δ = b<sup>2</sup> 4ac
</div>
<svg width="150" height="90" viewBox="0 0 150 90" fill="none">
<path d="M20 70 C 55 25 95 25 130 70" stroke={COLORS.black} strokeWidth="3" strokeLinecap="round" />
</svg>
</div>
);
case "bio":
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ ...faint, fontFamily: bodyFont, fontWeight: 700 }}>PHOTOSYNTHESIS</div>
<svg width="180" height="150" viewBox="0 0 180 150" fill="none">
<circle cx="90" cy="82" r="62" stroke={COLORS.black} strokeWidth="3" />
<circle cx="90" cy="82" r="20" stroke={COLORS.black} strokeWidth="3" />
<circle cx="62" cy="70" r="9" fill={COLORS.black} />
<circle cx="122" cy="98" r="7" fill={COLORS.black} />
<path d="M72 26 108 26" stroke={COLORS.onSurfaceVariant} strokeWidth="2" strokeLinecap="round" />
</svg>
<div style={faint}>chloroplast · cell</div>
</div>
);
case "chem":
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={faint}>chemistry</div>
<div style={{ ...note, fontSize: 34 }}>
2H<sub>2</sub> + O<sub>2</sub> 2H<sub>2</sub>O
</div>
<div style={{ ...note, fontSize: 30 }}>
C<sub>6</sub>H<sub>12</sub>O<sub>6</sub> <span style={{ ...faint, fontSize: 24 }}>+ energy</span>
</div>
</div>
);
case "exam":
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontFamily: headingFont, fontSize: 88, fontWeight: 800, color: COLORS.black }}>EXAM</div>
<div style={{ ...note, fontSize: 34 }}>12 March · 9:00 am</div>
<div style={{ ...faint, fontSize: 26 }}>BOARDS 2026</div>
</div>
);
case "hw":
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={faint}>homework</div>
<div style={note}>HW · Ch 14</div>
<div style={{ ...note, fontSize: 30 }}>Worksheet 7</div>
<div style={{ ...faint, fontStyle: "italic" }}>due tomorrow</div>
</div>
);
}
};
const PaperCard: React.FC<{ p: Paper }> = ({ p }) => {
const frame = useCurrentFrame();
const prog = interpolate(frame, [p.from, p.from + p.dur], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.in(Easing.quad),
});
const x = p.x0 + (540 - p.x0) * prog;
const y = p.y0 + (880 - p.y0) * prog;
const scale = p.sx0 + (p.sx1 - p.sx0) * prog;
const rotate = p.rot + p.spin * (frame - p.from);
const opacity =
interpolate(frame, [p.from, p.from + 7], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
}) *
interpolate(frame, [p.from + p.dur - 8, p.from + p.dur + 2], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
}) *
(p.opacity ?? 1);
const blur = Math.max(0, (prog - 0.82) / 0.18) * p.blur;
return (
<Interactive.Div
name={`Paper-${p.kind}-${p.from}`}
style={{
position: "absolute",
left: x,
top: y,
width: 480,
height: 330,
scale: String(scale.toFixed(3)),
rotate: `${rotate.toFixed(2)}deg`,
opacity,
filter: blur > 0.1 ? `blur(${blur.toFixed(1)}px)` : "none",
backgroundColor: COLORS.white,
borderRadius: 18,
border: `3px solid ${COLORS.outlineVariant}`,
boxShadow: "0 2px 12px rgba(17,24,39,0.16)",
padding: 32,
transformOrigin: "50% 50%",
}}
>
<div
style={{
position: "absolute",
left: 14,
top: 34,
display: "flex",
flexDirection: "column",
gap: 96,
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
width: 16,
height: 16,
borderRadius: "50%",
border: `3px solid ${COLORS.onSurfaceVariant}`,
}}
/>
))}
</div>
<PaperContent kind={p.kind} />
</Interactive.Div>
);
};
// ── Heartbeat ring ───────────────────────────────────────────
const Thump: React.FC<{ at: number }> = ({ at }) => {
const frame = useCurrentFrame();
const r = interpolate(frame, [at, at + 18], [46, 760], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const o = interpolate(frame, [at, at + 2, at + 18], [0.45, 0.9, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<div
style={{
position: "absolute",
left: 540 - r,
top: 960 - r,
width: r * 2,
height: r * 2,
borderRadius: "50%",
border: `8px solid ${COLORS.white}`,
opacity: o,
}}
/>
);
};
// ── Word slams (each CUT cleanly at its out + cut flash) ─────
const SLAMS = [
{ text: "EXAMS.", from: 72, out: 96, size: 176, weight: 900 },
{ text: "Too many chapters.", from: 104, out: 134, size: 92, weight: 800 },
{ text: "Too little time.", from: 142, out: 172, size: 92, weight: 800 },
];
// Cut-flash for each slam (masks the hard cut to black)
const CUTS = [96, 134, 172];
export const Panic: React.FC = () => {
const frame = useCurrentFrame();
// Camera shake envelope: ramp in through the storm, ease out before freeze
const shakeEnv = interpolate(frame, [8, 18, 128, 168], [0, 1, 0.6, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const s = shake(frame, 12 * shakeEnv);
const push = interpolate(frame, [0, 90], [1, 1.045], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// Stable overlays
const heartbeatPulse =
Math.max(pulseAt(frame, 3, 0.22), pulseAt(frame, 15, 0.16)) *
interpolate(frame, [28, 42], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const cutFlash = Math.max(
...CUTS.map((c) => flashAt(frame, c - 4, 0.92)),
);
const darkIn = interpolate(frame, [172, 178], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const exitWhite = interpolate(frame, [178, 180], [0, 0.95], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.black }}>
{/* Heartbeat pulse + rings (first ~0.4s) */}
<Interactive.Div
name="HeartbeatPulse"
style={{
position: "absolute",
inset: 0,
backgroundColor: COLORS.white,
opacity: heartbeatPulse,
}}
/>
<Thump at={2} />
<Thump at={14} />
{/* Shaken world: paper storm + word slams */}
<Interactive.Div
name="ShakenWorld"
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
translate: `${s.x.toFixed(2)}px ${s.y.toFixed(2)}px`,
rotate: `${(Math.sin(frame * 2.3) * 0.5 * shakeEnv).toFixed(3)}deg`,
scale: String(push),
}}
>
{STORM.map((p) => (
<PaperCard key={`storm-${p.kind}`} p={p} />
))}
{SHARDS.map((p) => (
<PaperCard key={`shard-${p.kind}`} p={p} />
))}
{SLAMS.map((w) => {
const inSlam = interpolate(frame, [w.from, w.from + 10], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: HIT,
output: "perceptual-scale",
});
const fadeIn = interpolate(frame, [w.from, w.from + 3], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const fadeOut = interpolate(frame, [w.out, w.out + 5], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const visible = Math.min(fadeIn, fadeOut);
const scale = 1.55 - 0.55 * inSlam;
const rotate = 2.2 * (1 - inSlam);
const tracking = 0.05 - 0.07 * inSlam;
return (
<Interactive.Div
key={`slam-${w.from}`}
name={`Slam-${w.from}`}
style={{
position: "absolute",
left: 540,
top: 880,
translate: "-50% -50%",
color: COLORS.white,
fontFamily: headingFont,
fontSize: w.size,
fontWeight: w.weight,
letterSpacing: `${tracking.toFixed(3)}em`,
whiteSpace: "nowrap",
scale: String(scale.toFixed(4)),
rotate: `${rotate.toFixed(2)}deg`,
opacity: visible,
transformOrigin: "50% 50%",
}}
>
{w.text}
</Interactive.Div>
);
})}
</Interactive.Div>
{/* Cut flashes + exit */}
<Interactive.Div
name="CutFlash"
style={{
position: "absolute",
inset: 0,
backgroundColor: COLORS.white,
opacity: cutFlash,
}}
/>
<Interactive.Div
name="DarkEnd"
style={{
position: "absolute",
inset: 0,
backgroundColor: COLORS.black,
opacity: darkIn,
}}
/>
<Interactive.Div
name="ExitWhite"
style={{
position: "absolute",
inset: 0,
backgroundColor: COLORS.white,
opacity: exitWhite,
}}
/>
</AbsoluteFill>
);
};
+169
View File
@@ -0,0 +1,169 @@
import React from "react";
import { AbsoluteFill, Interactive, interpolate, useCurrentFrame } from "remotion";
import { NoteCard, WireframeStudent } from "../components/ui";
import { COLORS, bodyFont, headingFont, monoFont, SNAP } from "../theme";
/**
* Scene 10 — Relief (28.332.3s)
* ------------------------------------------------------------
* The emotion pay-off. A wireframe student sits calmly with a
* book, surrounded by floating notebooks/equations/pencils —
* no real people, per the brief. The promise words return,
* easing the tension: Understand faster. Study smarter.
* Stress less.
*/
export const RELIEF_FRAMES = 240; // 4.0s @ 60fps
export const Relief: React.FC = () => {
const frame = useCurrentFrame();
// student arrives gently
const studIn = interpolate(frame, [10, 40], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// floating notes drift in
const noteIn1 = interpolate(frame, [16, 36], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
const noteIn2 = interpolate(frame, [22, 42], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
const noteIn3 = interpolate(frame, [30, 50], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
// promise words
const words = [
{ text: "Understand faster.", from: 88 },
{ text: "Study smarter.", from: 138 },
{ text: "Stress less.", from: 184 },
];
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.background }}>
{/* wireframe student */}
<Interactive.Div
name="Student"
style={{
position: "absolute",
left: 540 - 210,
top: 480,
opacity: studIn,
translate: `0 ${(1 - studIn) * 60}px`,
scale: String((0.9 + 0.1 * studIn).toFixed(4)),
}}
>
<WireframeStudent scale={1.02} />
</Interactive.Div>
{/* floating knowledge */}
<Interactive.Div name="FloatingNotes">
<div style={{ position: "absolute", left: 150, top: 420, opacity: noteIn1 }}>
<NoteCard x={0} y={0} rot={-9} drift={0}>
<div style={{ fontFamily: monoFont, fontSize: 30, color: COLORS.onSurface }}>
x<sup>2</sup> 5x + 6 = 0
</div>
<div style={{ fontFamily: monoFont, fontSize: 26, color: COLORS.onSurfaceVariant }}>
(x2)(x3)
</div>
<div style={{ fontFamily: bodyFont, fontSize: 22, color: COLORS.onSurfaceVariant }}>
solved
</div>
</NoteCard>
</div>
<div style={{ position: "absolute", left: 690, top: 1180, opacity: noteIn2 }}>
<NoteCard x={0} y={0} rot={8} drift={40}>
<div style={{ fontFamily: monoFont, fontSize: 26, color: COLORS.onSurface }}>
2H<sub>2</sub> + O<sub>2</sub> 2H<sub>2</sub>O
</div>
<div style={{ fontFamily: bodyFont, fontSize: 22, color: COLORS.onSurfaceVariant }}>
chapter done
</div>
</NoteCard>
</div>
<div style={{ position: "absolute", left: 760, top: 380, opacity: noteIn3 }}>
<NoteCard x={0} y={0} rot={-7} drift={80} w={210} h={150}>
<div style={{ fontFamily: monoFont, fontSize: 30, color: COLORS.onSurface }}>
Δ = b<sup>2</sup> 4ac
</div>
<div style={{ fontFamily: bodyFont, fontSize: 22, color: COLORS.onSurfaceVariant }}>
ready for the test
</div>
</NoteCard>
</div>
{/* pencil */}
<div
style={{
position: "absolute",
left: 240,
top: 1220,
rotate: "26deg",
opacity: interpolate(frame, [26, 46], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP }),
}}
>
<svg width="150" height="90" viewBox="0 0 150 90">
<rect x="30" y="20" width="100" height="16" rx="7" fill={COLORS.onSurface} />
<path d="M130 28 L150 34 L150 24 Z" fill={COLORS.onSurface} />
<rect x="40" y="22" width="18" height="12" fill={COLORS.surfaceHigh} />
</svg>
</div>
{/* school bag */}
<div
style={{
position: "absolute",
left: 820,
top: 1340,
opacity: interpolate(frame, [30, 52], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP }),
}}
>
<svg width="150" height="130" viewBox="0 0 150 130">
<path d="M30 30 36 12 84 12 90 30 90 104 84 122 30 122 36 104" fill="none" stroke={COLORS.onSurfaceVariant} strokeWidth="5" strokeLinejoin="round" />
<rect x="40" y="40" width="60" height="66" rx="14" fill="none" stroke={COLORS.onSurface} strokeWidth="5" />
<path d="M70 40 70 106" stroke={COLORS.onSurfaceVariant} strokeWidth="4" strokeDasharray="8 8" />
</svg>
</div>
</Interactive.Div>
{/* promise words — the relief */}
<Interactive.Div
name="PromiseWords"
style={{
position: "absolute",
left: 540,
top: 1410,
translate: "-50% 0",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 30,
}}
>
{words.map((w) => {
const p = interpolate(frame, [w.from, w.from + 18], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP,
});
return (
<div
key={w.text}
style={{
fontFamily: headingFont,
fontSize: 54,
fontWeight: 800,
color: COLORS.onSurface,
opacity: p,
scale: String((1.12 - 0.12 * p).toFixed(4)),
translate: `0 ${((1 - p) * 26).toFixed(1)}px`,
}}
>
{w.text}
</div>
);
})}
</Interactive.Div>
</AbsoluteFill>
);
};
+196
View File
@@ -0,0 +1,196 @@
import React from "react";
import { AbsoluteFill, Interactive, interpolate, useCurrentFrame } from "remotion";
import { BookGlyphBox } from "../components/ui";
import { COLORS, bodyFont, headingFont, SNAP } from "../theme";
/**
* Scene 9 — Subjects (25.128.3s)
* ------------------------------------------------------------
* The app's real subject list rotates through a carousel —
* Mathematics → Science → History → Language Arts → English →
* Geography. The centered card is the hero (black card, lifted,
* tiny dot indicator — the ONLY hint of selection). A small gray
* caption chip under the title states the class context so the
* carousel stays the hero.
*/
export const SUBJECTS_FRAMES = 192; // 3.2s @ 60fps
const SUBJECTS = [
"Mathematics",
"Science",
"History",
"Language Arts",
"English",
"Geography",
];
const STEP = 44; // frames per rotation
const START = 26;
export const Subjects: React.FC = () => {
const frame = useCurrentFrame();
const elapsed = Math.max(0, frame - START);
const idx = Math.floor(elapsed / STEP);
const active = idx % SUBJECTS.length;
const t = (elapsed % STEP) / STEP; // 0..1 within step
const eased = interpolate(t * 100, [0, 100], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// render slots: -3..+3 around active
const slot = (i: number) => {
const label = SUBJECTS[(active + i + SUBJECTS.length) % SUBJECTS.length];
const isHero = i === 0;
const off = isHero ? 0 : i;
const x = 540 + off * 280;
const y = 900 - Math.abs(off) * 40 - (isHero ? 0 : 22);
const scale = isHero
? 1 + (1 - eased) * 0.045
: Math.max(0.55, 1 - Math.abs(off) * 0.13 - 0.08 * Math.max(0, Math.abs(off) - 1));
const opacity = isHero ? 1 : Math.max(0, 0.74 - Math.abs(off) * 0.26);
const rot = isHero ? 0 : (off > 0 ? -1 : 1) * 5;
const heroY = isHero ? y - (1 - eased) * 26 : y;
return (
<React.Fragment key={`${label}-${i}`}>
{/* indicator dot for the hero */}
{isHero && (
<div
style={{
position: "absolute",
left: 540,
top: 620,
translate: "-50% 0",
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: COLORS.primary,
scale: String((0.3 + 0.7 * eased).toFixed(4)),
}}
/>
)}
<div
style={{
position: "absolute",
left: x,
top: heroY,
translate: "-50% -50%",
width: 340,
height: 178,
fontFamily: headingFont,
fontSize: 32,
fontWeight: 800,
color: isHero ? COLORS.onPrimary : COLORS.onSurface,
backgroundColor: isHero ? COLORS.primary : COLORS.surface,
border: isHero ? "none" : `2.5px solid ${COLORS.outlineVariant}`,
borderRadius: 22,
boxShadow: isHero ? "0 6px 22px rgba(17,24,39,0.18)" : "0 1px 4px rgba(17,24,39,0.05)",
scale: String(scale.toFixed(4)),
rotate: `${rot}deg`,
opacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
>
<BookGlyphBox size={34} color={isHero ? COLORS.onSurface : COLORS.primary} />
<span style={{ fontSize: 32, fontWeight: 800, whiteSpace: "nowrap" }}>{label}</span>
</div>
</React.Fragment>
);
};
// title + caption chip
const title = interpolate(frame, [START - 14, START], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const chip = interpolate(frame, [START - 6, START + 8], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.background }}>
{/* title */}
<Interactive.Div
name="Title"
style={{
position: "absolute",
left: 540,
top: 300,
translate: "-50% 0",
fontFamily: headingFont,
fontSize: 52,
fontWeight: 800,
color: COLORS.onSurface,
opacity: title,
scale: String((1.06 - 0.06 * title).toFixed(4)),
whiteSpace: "nowrap",
}}
>
All your subjects. One tutor.
</Interactive.Div>
{/* caption chip — small gray eyebrow under the title */}
<Interactive.Div
name="Caption"
style={{
position: "absolute",
left: 540,
top: 428,
translate: "-50% 0",
fontFamily: bodyFont,
fontSize: 26,
fontWeight: 600,
letterSpacing: "0.16em",
color: COLORS.onSurfaceVariant,
backgroundColor: COLORS.surfaceLow,
border: `2px solid ${COLORS.outlineVariant}`,
borderRadius: 999,
padding: "8px 22px",
opacity: chip,
whiteSpace: "nowrap",
}}
>
GRADE 10 · CBSE
</Interactive.Div>
{/* carousel */}
<Interactive.Div name="Rail" style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}>
{[-3, -2, -1, 0, 1, 2, 3].map((i) => slot(i))}
</Interactive.Div>
{/* progress strip */}
<div
style={{
position: "absolute",
left: 540,
top: 1560,
translate: "-50% 0",
display: "flex",
gap: 12,
}}
>
{SUBJECTS.map((s, idx) => (
<div
key={s}
style={{
width: idx === active ? 44 : 16,
height: 8,
borderRadius: 4,
backgroundColor: idx === active ? COLORS.primary : COLORS.outlineVariant,
}}
/>
))}
</div>
</AbsoluteFill>
);
};
+259
View File
@@ -0,0 +1,259 @@
import React from "react";
import {
AbsoluteFill,
Easing,
Interactive,
interpolate,
useCurrentFrame,
} from "remotion";
import { BookMark } from "../components/BookMark";
import { COLORS, bodyFont, headingFont, SNAP } from "../theme";
/**
* Scene 2 — The Pause (3.05.2s)
* ------------------------------------------------------------
* Everything freezes. White flash. Silence. The logo slowly appears —
* the calm after the panic. Tiny rotation. Elegant fade. Soft particles.
*
* meet
* [book mark]
* Padhle.
* CBSE · Grades 612
*
* Motion here is the OPPOSITE of Scene 1: slow, smooth, gentle.
* No AI mention yet (that reveal lands in Scene 4).
*/
export const THE_PAUSE_FRAMES = 132; // 2.2s @ 60fps
// Gentle, premium settle — no slam, no violent overshoot
const SERENE = Easing.out(Easing.quad);
const float = (frame: number, amp: number, cycle: number): string =>
`${(Math.sin((frame / 60) * (2 * Math.PI) / cycle) * amp).toFixed(2)}px ${
(Math.sin((frame / 60) * (2 * Math.PI) / (cycle * 0.77)) * amp * 0.6).toFixed(2)
}px`;
const Particle: React.FC<{
x: number;
y: number;
r: number;
delay: number;
drift: number;
}> = ({ x, y, r, delay, drift }) => {
const frame = useCurrentFrame();
const yDrift = ((frame + delay * 60) % (drift * 60)) / (drift * 60);
const opacity =
interpolate(frame, [18 + delay * 60, 18 + delay * 60 + 24], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
}) * 0.32;
return (
<div
style={{
position: "absolute",
left: x,
top: y + yDrift * 220 - 110,
width: r * 2,
height: r * 2,
borderRadius: "50%",
background: COLORS.onSurfaceVariant,
opacity,
}}
/>
);
};
export const ThePause: React.FC = () => {
const frame = useCurrentFrame();
// White flash decay — the Panic's white exit bleeds into this scene
const flash = interpolate(frame, [0, 14], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// "meet" overline
const meetIn = interpolate(frame, [14, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
// Book mark — elegant scale + tiny rotation
const markIn = interpolate(frame, [20, 52], [0.45, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SERENE,
output: "perceptual-scale",
});
const markRot = interpolate(frame, [20, 56], ["-5deg", "0deg"], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SERENE,
});
// "Padhle." — letters stagger in, gentle settle
const letterStart = 42;
const letterGap = 5;
const WORD = "Padhle.";
const tracking = interpolate(frame, [42, 74], ["0.10em", "0.02em"], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SERENE,
});
// Tagline
const tagIn = interpolate(frame, [74, 94], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.surface }}>
{/* White flash overlay */}
<Interactive.Div
name="Flash"
style={{
position: "absolute",
inset: 0,
backgroundColor: COLORS.white,
opacity: flash,
}}
/>
{/* Subtle gray hairline frame (brief: subtle borders, rounded corners) */}
<Interactive.Div
name="HairlineFrame"
style={{
position: "absolute",
left: 60,
right: 60,
top: 80,
bottom: 80,
borderRadius: 28,
border: `3px solid ${COLORS.outlineVariant}`,
opacity: 0.1,
}}
/>
{/* Ambient particles */}
<Interactive.Div name="Particles">
{[
{ x: 180, y: 420, r: 3, delay: 0.4, drift: 16 },
{ x: 860, y: 520, r: 4, delay: 0.1, drift: 18 },
{ x: 150, y: 1460, r: 3, delay: 0.7, drift: 15 },
{ x: 890, y: 1320, r: 2.5, delay: 0.3, drift: 17 },
].map((p) => (
<Particle key={`${p.x}-${p.y}`} {...p} />
))}
</Interactive.Div>
{/* Lockup — slow ambient drift, nothing static */}
<Interactive.Div name="Lockup" style={{ translate: float(frame, 6, 12) }}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 0,
position: "absolute",
left: 540,
top: 900,
translate: "-50% -50%",
}}
>
{/* overline */}
<Interactive.Div
name="Overline"
style={{
fontFamily: bodyFont,
fontSize: 44,
fontWeight: 600,
letterSpacing: "0.42em",
color: COLORS.onSurfaceVariant,
opacity: meetIn,
translate: `0 ${(1 - meetIn) * -22}px`,
}}
>
meet
</Interactive.Div>
<div style={{ height: 60 }} />
{/* book mark */}
<Interactive.Div
name="Logo"
style={{
scale: markIn,
rotate: markRot,
translate: float(frame, 4, 9),
}}
>
<BookMark size={150} />
</Interactive.Div>
<div style={{ height: 72 }} />
{/* wordmark */}
<Interactive.Div
name="Wordmark"
style={{
fontFamily: headingFont,
fontSize: 164,
fontWeight: 900,
color: COLORS.primary,
letterSpacing: tracking,
}}
>
{WORD.split("").map((ch, i) => {
const p = interpolate(
frame,
[letterStart + i * letterGap, letterStart + i * letterGap + 13],
[0, 1],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
},
);
return (
<Interactive.Span
key={i}
name={`Letter-${i}`}
style={{
opacity: p,
translate: `0 ${((1 - p) * 18).toFixed(1)}px`,
}}
>
{ch}
</Interactive.Span>
);
})}
</Interactive.Div>
<div style={{ height: 30 }} />
{/* tagline */}
<Interactive.Div
name="Tagline"
style={{
fontFamily: bodyFont,
fontSize: 48,
fontWeight: 500,
letterSpacing: "0.14em",
color: COLORS.onSurfaceVariant,
opacity: tagIn,
translate: `0 ${((1 - tagIn) * 16).toFixed(1)}px`,
}}
>
CBSE · Grades 612
</Interactive.Div>
</div>
</Interactive.Div>
</AbsoluteFill>
);
};
+285
View File
@@ -0,0 +1,285 @@
import React from "react";
import {
AbsoluteFill,
Interactive,
interpolate,
interpolateColors,
useCurrentFrame,
} from "remotion";
import { COLORS, headingFont, HIT, OVERSHOOT, SNAP } from "../theme";
/**
* Scene 3 — The Promise (5.28.2s)
* ------------------------------------------------------------
* Outcome-led typography on white. No product, no AI yet — just the
* promise, uttered with confidence, each phrase entering with its
* own signature (pop / slide / rise). The previous phrase recedes
* to gray as the new one takes the stage — editorial, premium.
*
* Understand faster.
* Study smarter.
* Stress less.
*
* Each phrase's two words animate individually (word-stagger), and
* a 4px rule draws itself beneath as the phrase settles. Rules are
* positioned at SCREEN coordinates (they were shifting off-center
* when nested in the auto-centered wrapper — fixed).
*/
export const THE_PROMISE_FRAMES = 3 * 60; // 3.0s @ 60fps
type EnterKind = "pop" | "slide" | "rise";
const PHASES: {
text: string;
y: number;
enter: number;
kind: EnterKind;
recede: number;
}[] = [
{ text: "Understand faster.", y: 660, enter: 18, kind: "pop", recede: 96 },
{ text: "Study smarter.", y: 900, enter: 80, kind: "slide", recede: 148 },
{ text: "Stress less.", y: 1140, enter: 126, kind: "rise", recede: 999 },
];
const RULE_W = 840;
const RULE_TOP_OFFSET = 112; // below the line baseline
const FONT_SIZE = 96;
const splitWords = (t: string) => {
const i = t.indexOf(" ");
return [t.slice(0, i), t.slice(i + 1)];
};
const float = (frame: number, amp: number, cycle: number): string =>
`${(Math.sin((frame / 60) * (2 * Math.PI) / cycle) * amp).toFixed(2)}px ${
(Math.sin((frame / 60) * (2 * Math.PI) / (cycle * 0.77)) * amp * 0.6).toFixed(2)
}px`;
// Shared per-phrase derivations (used by both the text and the rule)
const phaseColor = (frame: number, p: (typeof PHASES)[0]) =>
interpolateColors(
frame,
[p.recede - 6, p.recede + 22],
[COLORS.onSurface, COLORS.onSurfaceVariant],
{ easing: SNAP },
);
const phaseRecedeOpacity = (frame: number, p: (typeof PHASES)[0]) =>
interpolate(frame, [p.recede - 6, p.recede + 22], [1, 0.82], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const phaseRecedeScale = (frame: number, p: (typeof PHASES)[0]) =>
interpolate(frame, [p.recede - 6, p.recede + 22], [1, 0.94], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const phaseRuleIn = (frame: number, p: (typeof PHASES)[0]) =>
interpolate(frame, [p.enter + 20, p.enter + 36], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: HIT,
});
// Word-level entry transforms for each enter kind
const wordStyle = (
kind: EnterKind,
frame: number,
enter: number,
wordIndex: number,
): { opacity: number; scale?: number; translate?: string; rotate?: string } => {
const from = enter + wordIndex * 10; // word stagger
if (kind === "pop") {
const scale = interpolate(frame, [from, from + 15], [0.42, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: OVERSHOOT,
output: "perceptual-scale",
});
const rotate = interpolate(frame, [from, from + 20], ["-1.6deg", "0deg"], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: OVERSHOOT,
});
const opacity = interpolate(frame, [from, from + 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return { opacity, scale, rotate };
}
if (kind === "slide") {
const translate = interpolate(frame, [from, from + 14], ["-420px 0px", "0px 0px"], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: HIT,
});
const opacity = interpolate(frame, [from, from + 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return { opacity, translate };
}
// rise — handled per-letter in the render below
return { opacity: 1 };
};
const Phrase: React.FC<{ p: (typeof PHASES)[0] }> = ({ p }) => {
const frame = useCurrentFrame();
const [w1, w2] = splitWords(p.text);
const color = phaseColor(frame, p);
const opacity = phaseRecedeOpacity(frame, p);
const scale = phaseRecedeScale(frame, p);
return (
<div
style={{
position: "absolute",
left: 540,
top: p.y,
translate: "-50% 0",
scale: String(scale.toFixed(4)),
opacity,
transformOrigin: "50% 50%",
}}
>
{p.kind === "rise" ? (
<div
style={{
whiteSpace: "nowrap",
fontFamily: headingFont,
fontSize: FONT_SIZE,
fontWeight: 800,
color,
letterSpacing: interpolate(
frame,
[p.enter, p.enter + 22],
["0.14em", "0.01em"],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: SNAP },
),
}}
>
{`${w1} ${w2}`.split("").map((ch, i) => {
const rise = interpolate(frame, [p.enter + i * 3, p.enter + i * 3 + 13], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
const o = interpolate(frame, [p.enter + i * 3, p.enter + i * 3 + 8], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<span key={i} style={{ opacity: o, translate: `0 ${(rise * 240).toFixed(1)}px` }}>
{ch === " " ? "\u00A0" : ch}
</span>
);
})}
</div>
) : (
<div style={{ whiteSpace: "nowrap", display: "flex", gap: 24 }}>
{[w1, w2].map((w, i) => {
const ws = wordStyle(p.kind, frame, p.enter, i);
return (
<span
key={i}
style={{
fontFamily: headingFont,
fontSize: FONT_SIZE,
fontWeight: 800,
color,
opacity: ws.opacity,
...(ws.scale !== undefined ? { scale: String(ws.scale.toFixed(4)) } : {}),
...(ws.translate !== undefined ? { translate: ws.translate } : {}),
...(ws.rotate !== undefined ? { rotate: ws.rotate } : {}),
transformOrigin: "50% 50%",
}}
>
{w}
</span>
);
})}
</div>
)}
</div>
);
};
export const ThePromise: React.FC = () => {
const frame = useCurrentFrame();
// Gentle camera push-in
const push = interpolate(frame, [0, 90], [1, 1.035], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: SNAP,
});
return (
<AbsoluteFill name="Scene" style={{ backgroundColor: COLORS.surface }}>
{/* Subtle hairline frame */}
<Interactive.Div
name="HairlineFrame"
style={{
position: "absolute",
left: 60,
right: 60,
top: 80,
bottom: 80,
borderRadius: 28,
border: `3px solid ${COLORS.outlineVariant}`,
opacity: 0.08,
}}
/>
<Interactive.Div
name="CameraPush"
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
scale: String(push),
}}
>
{/* Phrases (centered) */}
<Interactive.Div name="PromiseStack" style={{ translate: float(frame, 5, 12) }}>
{PHASES.map((p) => (
<Phrase key={p.text} p={p} />
))}
</Interactive.Div>
{/* Rules — SCREEN coordinates, drawn under each phrase */}
<Interactive.Div name="Rules">
{PHASES.map((p) => {
const ruleIn = phaseRuleIn(frame, p);
const color = phaseColor(frame, p);
const opacity = phaseRecedeOpacity(frame, p);
return (
<div
key={`rule-${p.text}`}
style={{
position: "absolute",
left: 540 - RULE_W / 2,
top: p.y + RULE_TOP_OFFSET,
width: RULE_W,
height: 5,
backgroundColor: color,
opacity,
scale: `${ruleIn.toFixed(4)} 1`,
transformOrigin: "0% 50%",
}}
/>
);
})}
</Interactive.Div>
</Interactive.Div>
</AbsoluteFill>
);
};
+74
View File
@@ -0,0 +1,74 @@
import { loadFont as loadMontserrat } from "@remotion/google-fonts/Montserrat";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadJetBrainsMono } from "@remotion/google-fonts/JetBrainsMono";
import { Easing } from "remotion";
/**
* padhle — brand theme
* ------------------------------------------------------------
* Single source of truth for the launch trailer template.
* Colors are the EXACT design tokens from the app
* (frontend/src/styles/global.css → ANALYSIS.md "Global Design Tokens").
*
* Direction: minimal, premium, white + black. No gradients,
* no glassmorphism, no bright accent colors. The logo is a BOOK.
*/
// ── Layout ────────────────────────────────────────────────────
// 9:16 vertical, 60fps (per launch-trailer brief)
export const FPS = 60;
export const WIDTH = 1080;
export const HEIGHT = 1920;
// Safe area (per Remotion video-layout guidance: keep key content
// ≥80px from the sides, ≥100px from top/bottom on a 1080px-wide frame)
export const SAFE = { x: 80, x2: WIDTH - 80, top: 100, bottom: HEIGHT - 100 };
// ── Brand palette (verbatim from global.css) ──────────────────
export const COLORS = {
// Surfaces
background: "#fcf8fa",
surface: "#ffffff",
surfaceLow: "#f9fafb",
surfaceContainer: "#f3f4f6",
surfaceHigh: "#e5e7eb",
// Outlines
outline: "#d1d5db",
outlineVariant: "#e5e5e5",
// Text
onSurface: "#111827", // primary ink
onSurfaceVariant: "#6b7280", // muted
// Primary (black-on-white brand)
primary: "#000000",
onPrimary: "#ffffff",
// Scene blacks/whites (opening hook lives on black)
black: "#000000",
white: "#ffffff",
} as const;
// ── Fonts (Montserrat headings / Inter body per global.css) ───
export const { fontFamily: headingFont } = loadMontserrat("normal", {
weights: ["400", "700", "800", "900"],
subsets: ["latin"],
});
export const { fontFamily: bodyFont } = loadInter("normal", {
weights: ["400", "500", "600", "700"],
subsets: ["latin"],
});
// Notes / equations / formulas (the app's --font-mono token)
export const { fontFamily: monoFont } = loadJetBrainsMono("normal", {
weights: ["400", "500", "700"],
subsets: ["latin"],
});
// ── Motion presets ────────────────────────────────────────────
// Premium "Apple keynote" feel: fast attack, quick settle.
// SNAP snappy settle-in (UI entrances, pops)
// OVERSHOOT the one intended overshoot (titles, hero moments)
// HIT tiny back-bounce for impacts / word slams
export const SNAP = Easing.bezier(0.16, 1, 0.3, 1);
export const OVERSHOOT = Easing.out(Easing.back(1.6));
export const HIT = Easing.out(Easing.back(1.9));
export const SPRING = Easing.spring({ damping: 14, mass: 0.6, stiffness: 260 });