226 lines
6.6 KiB
TypeScript
226 lines
6.6 KiB
TypeScript
import React from "react";
|
||
import {
|
||
AbsoluteFill,
|
||
Interactive,
|
||
interpolate,
|
||
useCurrentFrame,
|
||
} from "remotion";
|
||
import { COLORS, monoFont, SNAP } from "../theme";
|
||
|
||
/**
|
||
* Scene 4 — The AI Reveal (8.2–11.0s)
|
||
* ------------------------------------------------------------
|
||
* The narrative turn: S1–S3 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>
|
||
);
|
||
}; |