padhle - post migration
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
# Ignore the output video from Git but not videos you import into src/.
|
||||
out
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Padhle — Launch Trailer (Remotion Template)
|
||||
|
||||
Professional, blank template for the **Padhle launch trailer** — an AI-powered
|
||||
tutoring platform for CBSE students, Grades 6–12.
|
||||
|
||||
Built with [Remotion](https://remotion.dev) (React + code-rendered video) in the
|
||||
app's strictly **white & black** brand language. No gradients, no glassmorphism,
|
||||
no accent colors — minimal, premium, Apple-keynote energy.
|
||||
|
||||
## Canvas
|
||||
|
||||
| Setting | Value |
|
||||
| --------- | ------------- |
|
||||
| Aspect | 9:16 vertical |
|
||||
| Size | 1080 × 1920 |
|
||||
| FPS | 60 |
|
||||
| Duration | 39.0 s (12 scenes, per STORYBOARD.md) |
|
||||
| Render | `npx remotion render Padhle -o out/padhle-trailer.mp4` |
|
||||
| Preview | `npm run dev` (Remotion Studio) |
|
||||
| Stills | `shots/` — contact sheets `shots/contact_ui1/2.png`, `shots/s1..s4_contact.png` |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── theme.ts ← ALL brand tokens: colors, fonts, eases, canvas
|
||||
├── components/
|
||||
│ ├── BookMark.tsx ← the book logo (placeholder mark, swap-in ready)
|
||||
│ └── ui.tsx ← app chrome: pills, top bar, cursor, bubbles, diagrams, wireframe student…
|
||||
├── scenes/ ← 12 scenes (Panic … EndCard)
|
||||
├── MainVideo.tsx ← master composition
|
||||
├── Root.tsx ← registers compositions
|
||||
└── index.ts ← registerRoot entry
|
||||
```
|
||||
|
||||
## Theme (single source of truth — `src/theme.ts`)
|
||||
|
||||
Exact design tokens from the app (`frontend/src/styles/global.css`):
|
||||
|
||||
| Token | Value |
|
||||
| ----- | ----- |
|
||||
| `COLORS.background` | `#fcf8fa` |
|
||||
| `COLORS.surface` | `#ffffff` |
|
||||
| `COLORS.onSurface` | `#111827` (ink) |
|
||||
| `COLORS.onSurfaceVariant` | `#6b7280` (muted) |
|
||||
| `COLORS.outline` / `outlineVariant` | `#d1d5db` / `#e5e5e5` |
|
||||
| `COLORS.primary` / `onPrimary` | `#000000` / `#ffffff` |
|
||||
| Heading font | Montserrat (700/800/900) |
|
||||
| Body font | Inter (400–700) |
|
||||
|
||||
Motion presets (`SNAP`, `OVERSHOOT`, `HIT`, `SPRING`) are tuned for the brief's
|
||||
"fast zooms, quick push-ins, micro bounce, overshoot" energy.
|
||||
|
||||
## Storyboard
|
||||
|
||||
The full narrative plan — emotional arc, per-scene beats, copy, timing — lives
|
||||
in [`STORYBOARD.md`](STORYBOARD.md). Follow the review loop it defines:
|
||||
storyboard → scene stills → motion previews → final animation → sound → render.
|
||||
|
||||
The trailer is **fully built** (all 12 scenes). Seams are hard cuts; every scene
|
||||
manufactures its own entrance/flash, so cuts read as rhythm, not crossfade mud.
|
||||
Audio is the remaining production pass (currently silent).
|
||||
|
||||
## Adding a scene
|
||||
|
||||
Each scene becomes a file in `src/scenes/`, registered in `Root.tsx`, and
|
||||
slotted into `MainVideo.tsx`:
|
||||
|
||||
1. **The Panic** — black, heartbeat, paper storm, `EXAMS.` slams
|
||||
2. **The Pause** — freeze → white → `meet` / `Padhle.`
|
||||
3. **The Promise** — `Understand faster.` / `Study smarter.` / `Stress less.`
|
||||
4. **The AI Reveal** — `Your AI tutor.` (first AI mention, ~8–10s)
|
||||
5. **Choosing Context** — Grade 10 → Science → Chapter 4 clicks
|
||||
6. **Asking** — typing animation, send
|
||||
7. **The Answer** — line-by-line answer, source badge
|
||||
8. **More Than a Chatbot** — Learn / Practice / Quiz / Revise / Summarize
|
||||
9. **Subjects** — Mathematics / Science / History / Language Arts / English / Geography
|
||||
10. **Relief** — silhouette + floating books, success words
|
||||
11. **CTA** — `Your textbooks.` `Finally make sense.` `Start Learning →`
|
||||
12. **End Card** — minimal logo lockup
|
||||
|
||||
**Recipe** — mirror `scenes/Panic.tsx`: export `<Name>Frames = N * FPS`,
|
||||
use `useCurrentFrame()` + inline `interpolate()` for everything animated
|
||||
(no CSS transitions — they don't render in Remotion), keep key text ≥80px from
|
||||
the sides and ≥100px from top/bottom (9:16 safe area), register the scene once
|
||||
in `Root.tsx` and once inside `TransitionSeries` in `MainVideo.tsx`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The book mark in `BookMark.tsx` is a placeholder drawn to the brand's
|
||||
black/white language — drop the official logo SVG over it when available.
|
||||
- Scenes use inline `interpolate()` styles so every tween is editable in
|
||||
Remotion Studio (click any element).
|
||||
- Scene seams use `@remotion/transitions` (installed) — swap `fade()` for
|
||||
whip/slide/zoom presentations as the edit takes shape.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Padhle — Launch Trailer Storyboard
|
||||
|
||||
> Premium startup commercial, **not** a software demo. Emotion over explanation.
|
||||
> The goal: a CBSE student stops scrolling and thinks *"That looks sick. I need to try it."*
|
||||
> Review gate: storyboard → scene stills → motion previews → final animation → sound → render.
|
||||
> **Edit:** this storyboard supersedes the original 12-scene map; the emotional arc and the
|
||||
> late-AI reveal are the new law. Audio is a second production pass — animate first.
|
||||
|
||||
## Narrative law
|
||||
|
||||
- **No AI mention before ~8–10s.** Lead with the problem and the outcome
|
||||
(*Understand faster. Study smarter.*), then reveal it's powered by AI.
|
||||
- **The product is the hero.** Never show a full dashboard for more than 1s —
|
||||
Apple-iPhone detail shots: cursors, dropdowns, typing, badges, micro-hovers.
|
||||
- **No real people, no stock footage.** Wireframe silhouettes, vector students,
|
||||
floating notebooks, books, pencils, school bags. Product stays the hero.
|
||||
- **Strictly monochrome.** Black / white / light gray. The only hint of color is a
|
||||
tiny indicator on the currently selected subject (scene 9). No green accent.
|
||||
- **Nothing static >1s.** Every shot has camera motion (push / pull / whip / drift).
|
||||
- **Shot length 0.7–2s.** Constant rhythm alternation: close-up, wide UI, typography, UI detail.
|
||||
|
||||
## Emotion curve
|
||||
|
||||
```
|
||||
😰 Stress → 🤔 Curiosity → 😲 "Whoa." → 😊 Relief → 🤯 Excitement → 🚀 "I want this."
|
||||
S1 S2 S3–S4 S5–S7 S8–S10 S11–S12
|
||||
```
|
||||
|
||||
## Scene plan (built: ~39s @ 60fps, all seams hard cuts)
|
||||
|
||||
| # | Scene | File | Frames | Start (s) | Beats | Copy |
|
||||
|---|-------|------|--------|-----------|---|------|
|
||||
| 1 | **The Panic** | Panic.tsx | 180 | 0.0 | Heartbeat, paper storm (equations/diagrams/formulas/EXAM), camera shake, word slams | `EXAMS.` → `Too many chapters.` → `Too little time.` |
|
||||
| 2 | **The Pause** | ThePause.tsx | 132 | 3.0 | White flash, silent lockup, tiny rotation, ambient particles | `meet` `Padhle.` `CBSE · Grades 6–12` |
|
||||
| 3 | **The Promise** | ThePromise.tsx | 180 | 5.2 | Typography, three entries (pop / slide / rise), previous recedes to gray | `Understand faster.` `Study smarter.` `Stress less.` |
|
||||
| 4 | **The AI Reveal** | AiReveal.tsx | 168 | 8.2 | Hard cut to black, spotlight pool, typewriter, terminal caret | `Your AI tutor.` `Built for CBSE.` `Grounded in your textbook.` |
|
||||
| 5 | **Choosing Context** | ChoosingContext.tsx | 240 | 11.0 | Top bar drops, selector unfolds, cursor clicks ×3 (ripple + black pill + push) | `Grade 10` → `Science` → `Ch 2 · Photosynthesis` |
|
||||
| 6 | **Asking** | Asking.tsx | 150 | 15.0 | Question types with caret, send button grows + glows, click | `Explain photosynthesis in simple words` |
|
||||
| 7 | **The Answer** | Answer.tsx | 210 | 17.5 | Answer builds line-by-line, keyword highlight, diagram slides in, source badge | `Photosynthesis is how plants make…` `Source · Ch 2 · Photosynthesis` |
|
||||
| 8 | **More Than a Chatbot** | More.tsx | 270 | 21.0 | Five cards, each enters differently (slide / rotate / scale / drop / slide-r), down arrows | `Learn` `Practice` `Quiz` `Revise` `Summarize` |
|
||||
| 9 | **Subjects** | Subjects.tsx | 192 | 25.5 | Carousel of the 6 real subjects, hero card + tiny dot indicator + progress strip | `Mathematics` `Science` `History` `Language Arts` `English` `Geography` |
|
||||
| 10 | **Relief** | Relief.tsx | 240 | 28.7 | Wireframe student, floating notes/pencil/bag, promise words return | `Understand faster.` `Study smarter.` `Stress less.` |
|
||||
| 11 | **CTA** | Cta.tsx | 210 | 32.7 | Logo grows, text, start button fades with tiny bounce, arrow slides | `Your textbooks.` `Finally make sense.` `Start Learning →` |
|
||||
| 12 | **End Card** | EndCard.tsx | 168 | 36.2 | Minimal lockup, gentle fade | `Padhle` `AI Tutor for CBSE Students` `Grades 6–12` |
|
||||
|
||||
Seams: 0.2s fades between scenes (whip/slide presentations can replace later).
|
||||
|
||||
## Scene 1 — The Panic (detailed beats)
|
||||
|
||||
```
|
||||
0.00–0.35s Black screen, heartbeat: two quick white thumps (ring + screen pulse).
|
||||
0.20–1.10s Paper storm: notebook cards fly at camera (math, biology, chemistry,
|
||||
exam dates, homework). Deterministic camera shake, accelerating scale,
|
||||
motion blur as cards pass the lens. Storm clears before the first slam.
|
||||
1.20s `EXAMS.` slams (176px, Montserrat 900) → cut flash at 1.6s (0.4s up).
|
||||
1.73s `Too many chapters.` slams (0.5s up) → cut flash at 2.23s.
|
||||
2.37s `Too little time.` slams (0.5s up).
|
||||
2.87–2.97s Freeze — everything drains to black.
|
||||
2.97–3.00s White exit flash → fades into Scene 2's silence.
|
||||
```
|
||||
|
||||
Each slam is cut cleanly (fades out as the cut-flash masks it) — no ghost text
|
||||
behind the next beat. Only fast, blurred, dimmed shrapnel streaks fly during the
|
||||
words, so the slams always read on near-black.
|
||||
|
||||
Typography: white on black, slammed in with back-overshoot (HIT easing), slight
|
||||
rotation drift, letter-spacing snap. Camera shake is deterministic (sum of sines)
|
||||
so renders are repeatable.
|
||||
|
||||
## Review flow — DONE (all 12 scenes built and rendered)
|
||||
|
||||
1. ✅ Storyboard (this file)
|
||||
2. ✅ Scene 1 — Panic → stills + `out/s1-panic.mp4`
|
||||
3. ✅ Scene 2 — ThePause → stills + `out/s2-pause.mp4`
|
||||
4. ✅ Scene 3 — ThePromise → stills + `out/s3-promise.mp4` (recede-to-gray verified)
|
||||
5. ✅ Scene 4 — AiReveal → stills + `out/s4-ai.mp4` (typewriter + spotlight)
|
||||
6. ✅ Scenes 5–12 — each in `src/scenes/`, preview at `out/s-<Scene>.mp4`
|
||||
7. ✅ Master — `out/padhle-trailer.mp4` (39.0s @ 60fps, 1080×1920)
|
||||
8. ⏸ **Next** — review the trailer; audio/sound design is the second production pass
|
||||
(currently silent — every scene was animated with motion-first in mind).
|
||||
@@ -0,0 +1,3 @@
|
||||
import { config } from "@remotion/eslint-config-flat";
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "remotion-testing",
|
||||
"version": "1.0.0",
|
||||
"description": "My Remotion video",
|
||||
"repository": {},
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.522",
|
||||
"@remotion/google-fonts": "4.0.522",
|
||||
"@remotion/transitions": "4.0.522",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"remotion": "4.0.522"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remotion/eslint-config-flat": "4.0.522",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/web": "0.0.166",
|
||||
"eslint": "9.19.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "remotion studio",
|
||||
"build": "remotion bundle",
|
||||
"upgrade": "remotion upgrade",
|
||||
"lint": "eslint src && tsc"
|
||||
},
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Note: When using the Node.JS APIs, the config file
|
||||
* doesn't apply. Instead, pass options directly to the APIs.
|
||||
*
|
||||
* All configuration options: https://www.remotion.dev/docs/config
|
||||
*/
|
||||
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setRspack(true);
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setOverwriteOutput(true);
|
||||
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 846 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 (S5–S7, 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -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.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>
|
||||
);
|
||||
};
|
||||
@@ -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.1–20.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>
|
||||
);
|
||||
};
|
||||
@@ -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.6–17.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.6–14.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 6–12"
|
||||
: 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)
|
||||
@@ -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.3–35.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>
|
||||
);
|
||||
};
|
||||
@@ -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.8–38.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 6–12
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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.6–25.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>
|
||||
);
|
||||
};
|
||||
@@ -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, 0–3s)
|
||||
* ------------------------------------------------------------
|
||||
* 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 1–4</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>
|
||||
);
|
||||
};
|
||||
@@ -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.3–32.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 }}>
|
||||
(x−2)(x−3)
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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.1–28.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>
|
||||
);
|
||||
};
|
||||
@@ -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.0–5.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 6–12
|
||||
*
|
||||
* 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 6–12
|
||||
</Interactive.Div>
|
||||
</div>
|
||||
</Interactive.Div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -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.2–8.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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"lib": ["es2015"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"exclude": ["remotion.config.ts"]
|
||||
}
|
||||