Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f7e85a03f |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"version": "3.0.14",
|
"version": "3.1.0",
|
||||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
|
|||||||
@@ -28,9 +28,3 @@ htmlcov/
|
|||||||
|
|
||||||
# Internal planning docs (ce:plan output) — keep local, don't publish
|
# Internal planning docs (ce:plan output) — keep local, don't publish
|
||||||
docs/plans/
|
docs/plans/
|
||||||
|
|
||||||
# Marketing video build artifacts
|
|
||||||
marketing/v3.1-launch/node_modules/
|
|
||||||
marketing/v3.1-launch/out/
|
|
||||||
marketing/*/node_modules/
|
|
||||||
marketing/*/out/
|
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-04-22
|
||||||
|
|
||||||
|
Consolidates the 3.0.10 to 3.0.14 dev cycle (commenter handles, `--competitors`, per-entity Step 0.55, vs-mode N passes, comparison title attribution) and republishes the OpenClaw bundle, which had been frozen on ClawHub at `3.0.0-open` since April 8.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OpenClaw republish.** `clawhub install last30days-official` now resolves to `3.1.0-open`, matching current main. Closes [#307](https://github.com/mvanhorn/last30days-skill/issues/307), [#195](https://github.com/mvanhorn/last30days-skill/issues/195), [#236](https://github.com/mvanhorn/last30days-skill/issues/236). The ClawHub bundle had shipped a broken `env.py get_config()` and stale SKILL.md path references since April; both are fixed at source on main and the republish carries the fixes to installers.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Claude Code plugin manifest path-escape.** The `.claude-plugin/plugin.json` `skills` key was removed in commit `93fbed2` but never shipped in a tagged release. Installing via `/plugin install last30days-skill` could hit `/doctor`'s `Path escapes plugin directory: ./ (skills)` error. This release ships the fix. Closes [#306](https://github.com/mvanhorn/last30days-skill/issues/306).
|
||||||
|
- **Broken README link.** The README's "source of truth" link pointed at `skills/last30days/SKILL.md`, a path that does not exist. Fixed to point at root `SKILL.md`.
|
||||||
|
|
||||||
|
### Dev cycle journal (3.0.10 - 3.0.14, not separately tagged)
|
||||||
|
|
||||||
|
Individual changelog entries for 3.0.10 through 3.0.14 below document the incremental work consolidated into this release.
|
||||||
|
|
||||||
## [3.0.14] - 2026-04-22
|
## [3.0.14] - 2026-04-22
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
|
**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
|
||||||
|
|
||||||
This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days/SKILL.md](skills/last30days/SKILL.md), which is the source of truth for the latest command and setup behavior.
|
This README tracks the current v3 pipeline. The runtime skill spec lives in [SKILL.md](SKILL.md), which is the source of truth for the latest command and setup behavior.
|
||||||
|
|
||||||
Claude Code:
|
Claude Code:
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
---
|
|
||||||
title: "marketing: v3.1 launch video — 30s Remotion piece for X"
|
|
||||||
type: feat
|
|
||||||
status: active
|
|
||||||
date: 2026-04-22
|
|
||||||
---
|
|
||||||
|
|
||||||
# marketing: v3.1 launch video — 30s Remotion piece for X
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Build a 30-second Remotion-rendered MP4 marketing video announcing **v3.1: Competitors mode** for `/last30days`. Posts to X. Frames the new vs-mode-with-auto-discovery as the headline — three full passes, three save files, one comparison — without burying it in CLI minutiae.
|
|
||||||
|
|
||||||
Marketing release tag is **v3.1** (rebrands the 3.0.11-3.0.14 bundle into one shippable narrative for the launch tweet). Engine version stays 3.0.14 — `3.1` is the marketing version, not a code version bump.
|
|
||||||
|
|
||||||
## Script (60 frames per second × 30 seconds = 900 frames; this version assumes 30fps × 30s = 900 frames at 30fps)
|
|
||||||
|
|
||||||
Total runtime: 30.0 seconds @ 30fps = 900 frames. Six scenes, on-screen text only (silent autoplay-friendly).
|
|
||||||
|
|
||||||
### Scene 1 — Hook (0:00 – 0:03 | frames 0-90)
|
|
||||||
|
|
||||||
**Visual:** Black background. The `/last30days` badge animates in (the literal `🌐 last30days v3.1 · synced 2026-04-22` line) with the spring scale-in used in slick devtool intros.
|
|
||||||
|
|
||||||
**Caption (overlay, large):**
|
|
||||||
```
|
|
||||||
What if one search
|
|
||||||
ran 3 at once?
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scene 2 — Set the world (0:03 – 0:08 | frames 90-240)
|
|
||||||
|
|
||||||
**Visual:** Single mac terminal window center-stage. Type-on animation:
|
|
||||||
```
|
|
||||||
$ /last30days OpenAI
|
|
||||||
```
|
|
||||||
Below it, a simple result card stub appears (Reddit upvote count + X likes), then static.
|
|
||||||
|
|
||||||
**Caption (small bottom-left):**
|
|
||||||
```
|
|
||||||
The old way: one topic.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scene 3 — The reveal (0:08 – 0:14 | frames 240-420)
|
|
||||||
|
|
||||||
**Visual:** The terminal types one more flag:
|
|
||||||
```
|
|
||||||
$ /last30days OpenAI --competitors
|
|
||||||
```
|
|
||||||
Hard cut → the single terminal **splits into 3 panes** side by side. Each pane shows a different topic header animating in, in this order:
|
|
||||||
- Left: `OpenAI`
|
|
||||||
- Middle: `vs Anthropic`
|
|
||||||
- Right: `vs xAI`
|
|
||||||
|
|
||||||
Pane content scrolls fake "search progress" lines (Reddit, X, YouTube indicators) in parallel, like a live fan-out.
|
|
||||||
|
|
||||||
**Caption (top center):**
|
|
||||||
```
|
|
||||||
Now it discovers competitors
|
|
||||||
and runs all 3.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scene 4 — Result reveal (0:14 – 0:21 | frames 420-630)
|
|
||||||
|
|
||||||
**Visual:** The 3 panes collapse into a single comparison surface — the `## Head-to-Head` table from the actual engine output, with rows fading in one by one (What it is, Streams, Best for, Trajectory). Each entity column lights up as its row populates.
|
|
||||||
|
|
||||||
**Caption (bottom):**
|
|
||||||
```
|
|
||||||
3 full passes. 3 save files.
|
|
||||||
1 comparison.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scene 5 — How it's special (0:21 – 0:26 | frames 630-780)
|
|
||||||
|
|
||||||
**Visual:** Cut to a clean text card, large mono font:
|
|
||||||
```
|
|
||||||
You pick the topic.
|
|
||||||
The agent picks the peers.
|
|
||||||
The engine fans out.
|
|
||||||
```
|
|
||||||
Each line fades in 1.5s apart.
|
|
||||||
|
|
||||||
### Scene 6 — CTA (0:26 – 0:30 | frames 780-900)
|
|
||||||
|
|
||||||
**Visual:** Black background. Centered:
|
|
||||||
- Top: `🌐 last30days v3.1`
|
|
||||||
- Middle: `/last30days {topic} --competitors`
|
|
||||||
- Bottom: `github.com/mvanhorn/last30days-skill`
|
|
||||||
|
|
||||||
Subtle pulse on the install line.
|
|
||||||
|
|
||||||
**End frame holds for ~0.5s.**
|
|
||||||
|
|
||||||
## Problem Frame
|
|
||||||
|
|
||||||
The 3.0.11-3.0.14 release bundle ships a transformative feature (per-entity vs-mode fanout + `--competitors` shortcut + per-entity save files), but the value lands flat in a tweet thread or screenshot. A 30s video does what static text cannot: shows the fan-out happening in real time and the 3 → 1 collapse into a comparison. Higher tweet engagement, easier to RT/QT.
|
|
||||||
|
|
||||||
## Requirements Trace
|
|
||||||
|
|
||||||
- R1. Final artifact: a single MP4 file, 1920×1080, 30fps, ~30 seconds (±0.5s), under 30MB, suitable for direct X upload.
|
|
||||||
- R2. Six-scene script as defined above, with text/visuals/timing matching to within 5 frames.
|
|
||||||
- R3. Branded look: matches the `🌐 last30days v3.1` badge style (terminal aesthetic, mono font, dark background).
|
|
||||||
- R4. Silent — no voiceover, no music in v1. Captions baked in. Designed for autoplay-muted feeds.
|
|
||||||
- R5. Reproducible: another contributor (or a future-me) can re-render with one command. Project lives in-repo so the source is versioned.
|
|
||||||
|
|
||||||
## Scope Boundaries
|
|
||||||
|
|
||||||
- No voiceover. Text-on-screen only. (Voice can be a v1.1 if engagement is high.)
|
|
||||||
- No background music in the rendered MP4. (Music can be added in post via QuickTime/iMovie if desired before posting.)
|
|
||||||
- No localization. English captions only.
|
|
||||||
- No A/B test variants. One video.
|
|
||||||
- No 9:16 vertical version. 16:9 only. (Vertical can be a separate render after launch validates the format.)
|
|
||||||
|
|
||||||
### Deferred to Separate Tasks
|
|
||||||
|
|
||||||
- Voiceover variant: defer to follow-up if v1 lands well.
|
|
||||||
- 9:16 mobile cut: defer; same source compositions can re-render at 1080×1920 in a follow-up.
|
|
||||||
- Animated GIF for embedding in README.md: defer; can be ffmpeg-extracted from the MP4.
|
|
||||||
|
|
||||||
## Context & Research
|
|
||||||
|
|
||||||
### Relevant Code and Patterns
|
|
||||||
|
|
||||||
- `SKILL.md` — the comparison render scaffold (`## Head-to-Head` table) is the visual reference for Scene 4's table look.
|
|
||||||
- `scripts/lib/render.py` `_render_comparison_scaffold` — emits the 9-axis table whose visual style we're recreating in a more polished form.
|
|
||||||
- `CHANGELOG.md` 3.0.11-3.0.14 entries — the prose source for the script's beats.
|
|
||||||
- `.claude-plugin/plugin.json` version 3.0.14 — current code version (marketing version is 3.1 for the launch).
|
|
||||||
|
|
||||||
### External References
|
|
||||||
|
|
||||||
- Remotion 4.x docs (https://www.remotion.dev/docs/) — current API for compositions, sequences, springs, and render CLI.
|
|
||||||
- X video specs 2026: max 2 min 20 s, ≤512MB, MP4 with H.264 + AAC, recommended 1920×1080 for landscape autoplay.
|
|
||||||
|
|
||||||
### Institutional Learnings
|
|
||||||
|
|
||||||
- No prior `marketing/` dir or video plans in `docs/plans/`. This is a greenfield asset directory.
|
|
||||||
|
|
||||||
## Key Technical Decisions
|
|
||||||
|
|
||||||
- **Remotion, not ffmpeg-only.** Remotion's React-based compositions handle the typed-on terminal effect, spring-animated badges, and scene transitions far more cleanly than raw ffmpeg filtergraphs. Render output is still MP4 via Remotion's bundled ffmpeg.
|
|
||||||
- **In-repo asset directory at `marketing/v3.1-launch/`.** Lives with the product so future versions can fork the project. Adds `marketing/` to `.gitignore` exceptions only for source files; rendered MP4 stays out of git (uploaded separately).
|
|
||||||
- **16:9 1920×1080 @ 30fps.** Best fit for X landscape autoplay on desktop and mobile feed. 30fps is plenty for typed-text + UI animation; 60fps doubles render time without obvious quality gain.
|
|
||||||
- **Silent + captions.** X autoplay defaults to muted. Sound-off is the realistic viewing condition. Captions baked into the visual.
|
|
||||||
- **Six scenes, one composition.** Single Remotion composition with sequenced child compositions per scene. Easier to re-time than scene-files. Frame-numbered timing in the script enables precise edits.
|
|
||||||
- **Mono font (JetBrains Mono or Geist Mono).** Matches the terminal aesthetic of the actual `/last30days` output. Available via Google Fonts or @remotion/google-fonts.
|
|
||||||
- **Marketing version 3.1 ≠ engine version 3.0.14.** `3.1` is the launch label. Engine stays 3.0.14. Avoids confusion in CHANGELOG.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
### Resolved During Planning
|
|
||||||
|
|
||||||
- **Aspect ratio?** 16:9 1080p. Best X autoplay format; vertical can be a follow-up.
|
|
||||||
- **Voiceover or silent?** Silent + on-screen captions. Autoplay-muted is the realistic condition.
|
|
||||||
- **In-repo or separate repo?** In-repo at `marketing/v3.1-launch/`. Rendered MP4 not committed; source compositions are.
|
|
||||||
- **Length?** Exactly 30s (900 frames @ 30fps). No flex.
|
|
||||||
- **Marketing version label?** `v3.1`. Engine code version stays 3.0.14.
|
|
||||||
|
|
||||||
### Deferred to Implementation
|
|
||||||
|
|
||||||
- Exact animation easing curves per scene — pick during build via Remotion preview iteration.
|
|
||||||
- Whether the comparison-table mock in Scene 4 uses canned text or pulls from the actual saved `*-raw.md` files. Probably canned for visual control.
|
|
||||||
- Whether Scene 3's "search progress" lines are typed individually or use a marquee scroll. Pick during preview.
|
|
||||||
- Exact accent color palette beyond "terminal dark." Iterate against preview.
|
|
||||||
|
|
||||||
## Output Structure
|
|
||||||
|
|
||||||
marketing/
|
|
||||||
v3.1-launch/
|
|
||||||
package.json # Remotion dependency manifest
|
|
||||||
tsconfig.json # TypeScript config
|
|
||||||
remotion.config.ts # Remotion render config (codec, fps, resolution)
|
|
||||||
src/
|
|
||||||
index.ts # Remotion entry — registers compositions
|
|
||||||
Root.tsx # Root composition definition
|
|
||||||
LaunchVideo.tsx # Main 30s composition that sequences scenes
|
|
||||||
scenes/
|
|
||||||
Scene1Hook.tsx
|
|
||||||
Scene2OldWay.tsx
|
|
||||||
Scene3FanOut.tsx
|
|
||||||
Scene4Comparison.tsx
|
|
||||||
Scene5HowItWorks.tsx
|
|
||||||
Scene6CTA.tsx
|
|
||||||
components/
|
|
||||||
TerminalWindow.tsx # Reusable mac-style terminal frame
|
|
||||||
TypedLine.tsx # Type-on animation primitive
|
|
||||||
ComparisonTable.tsx # The Head-to-Head table mock
|
|
||||||
BadgeBar.tsx # The 🌐 last30days v3.1 badge
|
|
||||||
lib/
|
|
||||||
timing.ts # Frame ranges per scene (single source of truth)
|
|
||||||
colors.ts # Brand palette
|
|
||||||
public/ # Static assets (logo, fonts if local)
|
|
||||||
out/ # Rendered MP4 lives here (gitignored)
|
|
||||||
README.md # How to preview/render
|
|
||||||
|
|
||||||
## High-Level Technical Design
|
|
||||||
|
|
||||||
> *Directional guidance for review — not implementation specification.*
|
|
||||||
|
|
||||||
```
|
|
||||||
LaunchVideo (durationInFrames = 900)
|
|
||||||
├── <Sequence from={0} durationInFrames={90}> <Scene1Hook />
|
|
||||||
├── <Sequence from={90} durationInFrames={150}> <Scene2OldWay />
|
|
||||||
├── <Sequence from={240} durationInFrames={180}> <Scene3FanOut />
|
|
||||||
├── <Sequence from={420} durationInFrames={210}> <Scene4Comparison />
|
|
||||||
├── <Sequence from={630} durationInFrames={150}> <Scene5HowItWorks />
|
|
||||||
└── <Sequence from={780} durationInFrames={120}> <Scene6CTA />
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-scene components use `useCurrentFrame()` + `interpolate()` + `spring()` for timing. `TerminalWindow` is the dominant motif across scenes 2-4.
|
|
||||||
|
|
||||||
## Implementation Units
|
|
||||||
|
|
||||||
- [ ] **Unit 1: Remotion project scaffold**
|
|
||||||
|
|
||||||
**Goal:** Spin up a working Remotion project at `marketing/v3.1-launch/` that previews a blank composition and renders to MP4.
|
|
||||||
|
|
||||||
**Requirements:** R1, R5
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `marketing/v3.1-launch/package.json`
|
|
||||||
- Create: `marketing/v3.1-launch/tsconfig.json`
|
|
||||||
- Create: `marketing/v3.1-launch/remotion.config.ts`
|
|
||||||
- Create: `marketing/v3.1-launch/src/index.ts`
|
|
||||||
- Create: `marketing/v3.1-launch/src/Root.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/README.md`
|
|
||||||
- Modify: `.gitignore` (add `marketing/v3.1-launch/out/`, `marketing/v3.1-launch/node_modules/`)
|
|
||||||
|
|
||||||
**Approach:**
|
|
||||||
- Use `npx create-video@latest --blank` (Remotion 4.x scaffolding) targeting `marketing/v3.1-launch/`. Strip the demo composition.
|
|
||||||
- Configure: 1920×1080, 30fps, H.264, AAC (audio codec needed even for silent — empty track).
|
|
||||||
- README documents `npm install`, `npm run preview` (Remotion Studio), `npm run render` (one-command MP4).
|
|
||||||
|
|
||||||
**Test scenarios:**
|
|
||||||
- Test expectation: none for scaffold, but verification = `npm run preview` opens Remotion Studio with a blank 30s composition; `npm run render` produces a black MP4 at the right resolution.
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- Studio loads at localhost:3000 with the empty `LaunchVideo` composition listed.
|
|
||||||
- A render produces `out/launch-video.mp4` at 1920×1080, 30s, valid MP4.
|
|
||||||
|
|
||||||
- [ ] **Unit 2: Reusable components (Terminal, TypedLine, BadgeBar)**
|
|
||||||
|
|
||||||
**Goal:** Build the three primitive components scenes 2-6 will compose. Each is independently previewable.
|
|
||||||
|
|
||||||
**Requirements:** R3, R5
|
|
||||||
|
|
||||||
**Dependencies:** Unit 1
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `marketing/v3.1-launch/src/components/TerminalWindow.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/components/TypedLine.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/components/BadgeBar.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/lib/colors.ts`
|
|
||||||
- Create: `marketing/v3.1-launch/src/lib/timing.ts`
|
|
||||||
|
|
||||||
**Approach:**
|
|
||||||
- `TerminalWindow`: mac-style traffic-light header, dark gradient background, mono content area. Accepts children.
|
|
||||||
- `TypedLine`: takes a string and a `startFrame`, renders character-by-character at ~30 chars/sec. Reuses Remotion's `interpolate(useCurrentFrame() - startFrame, [0, lengthFrames], [0, text.length])` clamped.
|
|
||||||
- `BadgeBar`: renders the literal `🌐 last30days v3.1 · synced 2026-04-22` line in mono with the same gradient color treatment as the engine's compact emit.
|
|
||||||
- `colors.ts`: 5-7 brand colors (terminal-bg, terminal-fg, accent-cyan, accent-magenta, muted, success-green, warning-amber).
|
|
||||||
- `timing.ts`: exports the scene frame ranges as named constants. Single source of truth for any retiming.
|
|
||||||
|
|
||||||
**Test scenarios:**
|
|
||||||
- Test expectation: none — visual components verified in Remotion Studio.
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- Each component renders standalone in Studio when wrapped in a temporary preview composition.
|
|
||||||
- TypedLine animates character-by-character without flicker.
|
|
||||||
|
|
||||||
- [ ] **Unit 3: Scenes 1-3 (Hook, Old way, Fan-out reveal)**
|
|
||||||
|
|
||||||
**Goal:** Build the first half of the video (frames 0-420). The narrative arc up through the visual fan-out.
|
|
||||||
|
|
||||||
**Requirements:** R2
|
|
||||||
|
|
||||||
**Dependencies:** Unit 2
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `marketing/v3.1-launch/src/scenes/Scene1Hook.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/scenes/Scene2OldWay.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/scenes/Scene3FanOut.tsx`
|
|
||||||
- Modify: `marketing/v3.1-launch/src/Root.tsx` (register sequences)
|
|
||||||
|
|
||||||
**Approach:**
|
|
||||||
- Scene 1: spring-in BadgeBar, large overlay caption, 3-second hold.
|
|
||||||
- Scene 2: TerminalWindow with TypedLine (`$ /last30days OpenAI`), then a single result-card mock fading in.
|
|
||||||
- Scene 3: typing animation appends `--competitors`, hard cut, three TerminalWindow components arranged in a row with staggered fade-in. Each pane shows a different entity header + scrolling progress lines.
|
|
||||||
|
|
||||||
**Test scenarios:**
|
|
||||||
- Test expectation: none — verified visually in Studio.
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- Scrub the 0-14s range in Studio; visuals match the script timing within 5 frames.
|
|
||||||
- The fan-out moment (frame 240) lands cleanly; no jank in the transition from 1 → 3 panes.
|
|
||||||
|
|
||||||
- [ ] **Unit 4: Scenes 4-6 (Comparison reveal, How it works, CTA)**
|
|
||||||
|
|
||||||
**Goal:** Build the back half of the video (frames 420-900). Resolution + payoff + call to action.
|
|
||||||
|
|
||||||
**Requirements:** R2
|
|
||||||
|
|
||||||
**Dependencies:** Unit 2
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `marketing/v3.1-launch/src/scenes/Scene4Comparison.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/scenes/Scene5HowItWorks.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/scenes/Scene6CTA.tsx`
|
|
||||||
- Create: `marketing/v3.1-launch/src/components/ComparisonTable.tsx`
|
|
||||||
|
|
||||||
**Approach:**
|
|
||||||
- Scene 4: ComparisonTable component renders 3-column markdown-style table; rows fade in one by one (stagger 15-20 frames). Uses canned data — OpenAI / Anthropic / xAI with believable cell content drawn from real 3.0.13 outputs.
|
|
||||||
- Scene 5: three-line text card; lines fade in 45 frames apart.
|
|
||||||
- Scene 6: three centered text blocks; install line gets a 1Hz subtle opacity pulse for emphasis.
|
|
||||||
|
|
||||||
**Test scenarios:**
|
|
||||||
- Test expectation: none — verified visually.
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- Scrub 14-30s; table reveal feels paced (not too slow, not strobed); CTA holds long enough to read (~3-4s).
|
|
||||||
- ComparisonTable cells are legible at 1920×1080 (mono font ≥ 28px).
|
|
||||||
|
|
||||||
- [ ] **Unit 5: Final composition wiring + render**
|
|
||||||
|
|
||||||
**Goal:** Wire the six scenes into the master `LaunchVideo` composition, render to MP4, verify against X upload constraints.
|
|
||||||
|
|
||||||
**Requirements:** R1, R2, R5
|
|
||||||
|
|
||||||
**Dependencies:** Units 3, 4
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `marketing/v3.1-launch/src/LaunchVideo.tsx` (sequence all 6 scenes)
|
|
||||||
- Modify: `marketing/v3.1-launch/README.md` (add render command + verification checklist)
|
|
||||||
|
|
||||||
**Approach:**
|
|
||||||
- `LaunchVideo` is a single Composition that imports `Scene1Hook` … `Scene6CTA` and wraps each in `<Sequence from=… durationInFrames=…>` matching `lib/timing.ts`.
|
|
||||||
- Run `npx remotion render LaunchVideo out/last30days-v3.1-launch.mp4 --codec=h264 --crf=18`.
|
|
||||||
- Verify output: 30.0s ±0.1s, 1920×1080, file size <30MB, opens in QuickTime, plays without dropped frames.
|
|
||||||
|
|
||||||
**Test scenarios:**
|
|
||||||
- Test expectation: none — verification is the rendered MP4 itself.
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- `ffprobe out/last30days-v3.1-launch.mp4` reports 1920×1080, 30fps, ~30.0s, h264, faststart-friendly.
|
|
||||||
- Manual play-through end-to-end in QuickTime feels coherent and on-pace.
|
|
||||||
- File <30MB so X upload is instant.
|
|
||||||
|
|
||||||
- [ ] **Unit 6: Polish pass + ship**
|
|
||||||
|
|
||||||
**Goal:** Watch the full render, fix obvious jank, do a second render, and stage for X posting.
|
|
||||||
|
|
||||||
**Requirements:** R1, R2, R3
|
|
||||||
|
|
||||||
**Dependencies:** Unit 5
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Possibly modify: any scene file based on watch-through findings.
|
|
||||||
|
|
||||||
**Approach:**
|
|
||||||
- Watch the rendered MP4 at full size. Note: timing felt off, transitions too fast, captions overflow, color clash, anything visibly broken.
|
|
||||||
- Iterate: edit scene component → re-preview in Studio → re-render full MP4.
|
|
||||||
- Cap at 2 polish passes; ship the better of the two renders.
|
|
||||||
- Final MP4 sits at `marketing/v3.1-launch/out/last30days-v3.1-launch.mp4` ready for X upload.
|
|
||||||
|
|
||||||
**Test scenarios:**
|
|
||||||
- Test expectation: none — pure subjective polish.
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
- User watches the final render and approves.
|
|
||||||
- No glaring visual bugs (overflowing text, frozen frames, color clashes).
|
|
||||||
|
|
||||||
## System-Wide Impact
|
|
||||||
|
|
||||||
- **Interaction graph:** None — this is a standalone marketing artifact. Doesn't touch the Python engine, doesn't change any user-facing behavior.
|
|
||||||
- **State lifecycle risks:** None.
|
|
||||||
- **API surface parity:** N/A.
|
|
||||||
- **Unchanged invariants:** The shipped 3.0.14 engine is untouched.
|
|
||||||
|
|
||||||
## Risks & Dependencies
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|------|------------|
|
|
||||||
| Remotion install pulls 200MB+ of node_modules. | `marketing/v3.1-launch/node_modules/` in `.gitignore`; checked-in source stays small. |
|
|
||||||
| Render time blows past patience (>5 min for 30s @ 1080p30). | Bun-based render or `--concurrency` flag. Default Remotion is fast enough on M-series Macs. If slow, lower preview to 720p, render final at 1080p. |
|
|
||||||
| Captions overflow the 1920px width on certain fonts. | Use a known mono font with a measured per-character width; cap caption lines at 36 chars. |
|
|
||||||
| The "fan-out" visual in Scene 3 looks confusing instead of magical. | Polish pass (Unit 6) is the safety net; if still bad, fall back to a simpler "1 → 3 panes wipe" instead of typed split. |
|
|
||||||
| File size >30MB hits X upload friction. | Use `--crf=18` (high quality, reasonable size); fall back to `--crf=23` if over. 30s @ 1080p30 H.264 is normally 5-15MB. |
|
|
||||||
|
|
||||||
## Documentation / Operational Notes
|
|
||||||
|
|
||||||
- README at `marketing/v3.1-launch/README.md` documents preview / render commands.
|
|
||||||
- After render, the MP4 is uploaded directly to X. Tweet copy is the user's call (this plan stops at the rendered file).
|
|
||||||
|
|
||||||
## Sources & References
|
|
||||||
|
|
||||||
- Related code: `scripts/lib/render.py` (`_render_comparison_scaffold` is the visual model for Scene 4); `SKILL.md` Competitor mode section (the narrative source).
|
|
||||||
- Related PRs: #308, #311, #312 (the 3.0.11 → 3.0.14 release bundle this video markets as "v3.1").
|
|
||||||
- External docs: https://www.remotion.dev/docs/ (Remotion 4.x API).
|
|
||||||
- X video specs: https://help.x.com/en/using-x/twitter-videos.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# /last30days v3.1 Launch Video
|
|
||||||
|
|
||||||
30-second Remotion-rendered MP4 announcing **v3.1: Competitors mode** for posting on X.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd marketing/v3.1-launch
|
|
||||||
npm install # one-time, ~200MB of node_modules
|
|
||||||
npm run preview # opens Remotion Studio at localhost:3000 to scrub frames
|
|
||||||
npm run render # writes out/last30days-v3.1-launch.mp4 (high quality, CRF 18)
|
|
||||||
npm run render:fast # writes a CRF 23 preview for fast iteration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Specs
|
|
||||||
|
|
||||||
- 1920×1080, 30fps, 30 seconds (900 frames)
|
|
||||||
- H.264 / MP4
|
|
||||||
- Silent (autoplay-muted-friendly; captions baked in)
|
|
||||||
- Marketing label `v3.1` (engine code version stays 3.0.14)
|
|
||||||
|
|
||||||
## Scene timing (single source of truth: `src/lib/timing.ts`)
|
|
||||||
|
|
||||||
| Scene | Frames | Time | What |
|
|
||||||
|-------|--------|------|------|
|
|
||||||
| 1. Hook | 0-89 | 0.0-3.0s | Badge animates in + "What if one search ran 3 at once?" |
|
|
||||||
| 2. Old way | 90-239 | 3.0-8.0s | Single terminal: `/last30days OpenAI` |
|
|
||||||
| 3. Fan-out | 240-419 | 8.0-14.0s | `--competitors` types in → splits into 3 panes |
|
|
||||||
| 4. Comparison | 420-629 | 14.0-21.0s | 3 panes collapse into Head-to-Head table |
|
|
||||||
| 5. How | 630-779 | 21.0-26.0s | 3-line text card |
|
|
||||||
| 6. CTA | 780-899 | 26.0-30.0s | Install command + repo URL |
|
|
||||||
|
|
||||||
Edit `src/lib/timing.ts` to retime scenes; the `LaunchVideo` composition reads from there.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Rendered MP4 lives at `out/last30days-v3.1-launch.mp4` (gitignored). Upload directly to X.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "last30days-v3-1-launch-video",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "30s Remotion launch video for /last30days v3.1 (competitors mode).",
|
|
||||||
"scripts": {
|
|
||||||
"preview": "remotion studio src/index.ts",
|
|
||||||
"render": "remotion render src/index.ts LaunchVideo out/last30days-v3.1-launch.mp4 --codec=h264 --crf=18",
|
|
||||||
"render:fast": "remotion render src/index.ts LaunchVideo out/last30days-v3.1-launch-preview.mp4 --codec=h264 --crf=23"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "19.0.0",
|
|
||||||
"react-dom": "19.0.0",
|
|
||||||
"remotion": "4.0.250",
|
|
||||||
"@remotion/cli": "4.0.250",
|
|
||||||
"@remotion/google-fonts": "4.0.250"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "19.0.0",
|
|
||||||
"@types/node": "22.10.0",
|
|
||||||
"typescript": "5.6.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { Config } from "@remotion/cli/config";
|
|
||||||
|
|
||||||
Config.setVideoImageFormat("jpeg");
|
|
||||||
Config.setOverwriteOutput(true);
|
|
||||||
Config.setConcurrency(null);
|
|
||||||
Config.setCodec("h264");
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, Sequence } from "remotion";
|
|
||||||
import { SCENES } from "./lib/timing";
|
|
||||||
import { Scene1Hook } from "./scenes/Scene1Hook";
|
|
||||||
import { Scene2OldWay } from "./scenes/Scene2OldWay";
|
|
||||||
import { Scene3FanOut } from "./scenes/Scene3FanOut";
|
|
||||||
import { Scene4Comparison } from "./scenes/Scene4Comparison";
|
|
||||||
import { Scene5HowItWorks } from "./scenes/Scene5HowItWorks";
|
|
||||||
import { Scene6CTA } from "./scenes/Scene6CTA";
|
|
||||||
import { COLORS } from "./lib/colors";
|
|
||||||
|
|
||||||
export const LaunchVideo: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<AbsoluteFill style={{ background: COLORS.bgDeep }}>
|
|
||||||
<Sequence from={SCENES.hook.from} durationInFrames={SCENES.hook.durationInFrames}>
|
|
||||||
<Scene1Hook />
|
|
||||||
</Sequence>
|
|
||||||
<Sequence from={SCENES.oldWay.from} durationInFrames={SCENES.oldWay.durationInFrames}>
|
|
||||||
<Scene2OldWay />
|
|
||||||
</Sequence>
|
|
||||||
<Sequence from={SCENES.fanOut.from} durationInFrames={SCENES.fanOut.durationInFrames}>
|
|
||||||
<Scene3FanOut />
|
|
||||||
</Sequence>
|
|
||||||
<Sequence from={SCENES.comparison.from} durationInFrames={SCENES.comparison.durationInFrames}>
|
|
||||||
<Scene4Comparison />
|
|
||||||
</Sequence>
|
|
||||||
<Sequence from={SCENES.howItWorks.from} durationInFrames={SCENES.howItWorks.durationInFrames}>
|
|
||||||
<Scene5HowItWorks />
|
|
||||||
</Sequence>
|
|
||||||
<Sequence from={SCENES.cta.from} durationInFrames={SCENES.cta.durationInFrames}>
|
|
||||||
<Scene6CTA />
|
|
||||||
</Sequence>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Composition } from "remotion";
|
|
||||||
import { LaunchVideo } from "./LaunchVideo";
|
|
||||||
import { FPS, TOTAL_FRAMES } from "./lib/timing";
|
|
||||||
|
|
||||||
export const RemotionRoot: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Composition
|
|
||||||
id="LaunchVideo"
|
|
||||||
component={LaunchVideo}
|
|
||||||
durationInFrames={TOTAL_FRAMES}
|
|
||||||
fps={FPS}
|
|
||||||
width={1920}
|
|
||||||
height={1080}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
||||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
startFrame?: number;
|
|
||||||
size?: "small" | "large";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BadgeBar: React.FC<Props> = ({ startFrame = 0, size = "large" }) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
const elapsed = Math.max(0, frame - startFrame);
|
|
||||||
|
|
||||||
const scale = spring({
|
|
||||||
frame: elapsed,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 90 },
|
|
||||||
from: 0.85,
|
|
||||||
to: 1,
|
|
||||||
});
|
|
||||||
const opacity = spring({
|
|
||||||
frame: elapsed,
|
|
||||||
fps,
|
|
||||||
config: { damping: 20 },
|
|
||||||
from: 0,
|
|
||||||
to: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fontSize = size === "large" ? 56 : 28;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
fontSize,
|
|
||||||
color: COLORS.fgPrimary,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: fontSize * 1.1, marginRight: 16 }}>🌐</span>
|
|
||||||
<span>last30days</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 14,
|
|
||||||
color: COLORS.accentCyan,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
v3.1
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 16,
|
|
||||||
color: COLORS.fgDim,
|
|
||||||
fontSize: fontSize * 0.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
· synced 2026-04-22
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { interpolate, useCurrentFrame } from "remotion";
|
|
||||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
|
||||||
|
|
||||||
type Row = {
|
|
||||||
dimension: string;
|
|
||||||
cells: [string, string, string];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
startFrame: number;
|
|
||||||
entities: [string, string, string];
|
|
||||||
rows: Row[];
|
|
||||||
rowStaggerFrames?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComparisonTable: React.FC<Props> = ({
|
|
||||||
startFrame,
|
|
||||||
entities,
|
|
||||||
rows,
|
|
||||||
rowStaggerFrames = 18,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
|
|
||||||
const headerOpacity = interpolate(
|
|
||||||
frame - startFrame,
|
|
||||||
[0, 12],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const colTemplate = "1.4fr 1fr 1fr 1fr";
|
|
||||||
const cellPad = "16px 22px";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
background: COLORS.bgPanel,
|
|
||||||
borderRadius: 16,
|
|
||||||
border: `1px solid ${COLORS.border}`,
|
|
||||||
overflow: "hidden",
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
boxShadow: "0 24px 60px rgba(0,0,0,0.6)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: colTemplate,
|
|
||||||
background: COLORS.bgPanelSoft,
|
|
||||||
borderBottom: `1px solid ${COLORS.border}`,
|
|
||||||
opacity: headerOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: cellPad,
|
|
||||||
color: COLORS.fgMuted,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Dimension
|
|
||||||
</div>
|
|
||||||
{entities.map((entity, idx) => (
|
|
||||||
<div
|
|
||||||
key={entity}
|
|
||||||
style={{
|
|
||||||
padding: cellPad,
|
|
||||||
color: idx === 0 ? COLORS.accentCyan : COLORS.fgPrimary,
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: 600,
|
|
||||||
borderLeft: `1px solid ${COLORS.border}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entity}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{rows.map((row, idx) => {
|
|
||||||
const rowStart = startFrame + 12 + idx * rowStaggerFrames;
|
|
||||||
const rowOpacity = interpolate(
|
|
||||||
frame - rowStart,
|
|
||||||
[0, 14],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
);
|
|
||||||
const rowSlide = interpolate(
|
|
||||||
frame - rowStart,
|
|
||||||
[0, 14],
|
|
||||||
[12, 0],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={row.dimension}
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: colTemplate,
|
|
||||||
borderBottom:
|
|
||||||
idx === rows.length - 1 ? "none" : `1px solid ${COLORS.border}`,
|
|
||||||
opacity: rowOpacity,
|
|
||||||
transform: `translateY(${rowSlide}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: cellPad,
|
|
||||||
color: COLORS.fgMuted,
|
|
||||||
fontSize: 22,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.dimension}
|
|
||||||
</div>
|
|
||||||
{row.cells.map((cell, cellIdx) => (
|
|
||||||
<div
|
|
||||||
key={cellIdx}
|
|
||||||
style={{
|
|
||||||
padding: cellPad,
|
|
||||||
color: COLORS.fgPrimary,
|
|
||||||
fontSize: 22,
|
|
||||||
borderLeft: `1px solid ${COLORS.border}`,
|
|
||||||
lineHeight: 1.35,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cell}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
title?: string;
|
|
||||||
width?: number | string;
|
|
||||||
height?: number | string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
glow?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TerminalWindow: React.FC<Props> = ({
|
|
||||||
title = "/last30days",
|
|
||||||
width = "100%",
|
|
||||||
height = "100%",
|
|
||||||
children,
|
|
||||||
glow = false,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
background: COLORS.bgPanel,
|
|
||||||
borderRadius: 16,
|
|
||||||
border: `1px solid ${COLORS.border}`,
|
|
||||||
boxShadow: glow
|
|
||||||
? `0 0 60px ${COLORS.accentCyan}33, 0 24px 60px rgba(0,0,0,0.6)`
|
|
||||||
: "0 24px 60px rgba(0,0,0,0.6)",
|
|
||||||
overflow: "hidden",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 36,
|
|
||||||
background: COLORS.bgPanelSoft,
|
|
||||||
borderBottom: `1px solid ${COLORS.border}`,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "0 16px",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
background: COLORS.trafficRed,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
background: COLORS.trafficYellow,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
background: COLORS.trafficGreen,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 16,
|
|
||||||
color: COLORS.fgMuted,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "20px 28px",
|
|
||||||
color: COLORS.fgPrimary,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { interpolate, useCurrentFrame } from "remotion";
|
|
||||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
text: string;
|
|
||||||
startFrame: number;
|
|
||||||
charsPerSec?: number;
|
|
||||||
fontSize?: number;
|
|
||||||
color?: string;
|
|
||||||
prefix?: string;
|
|
||||||
prefixColor?: string;
|
|
||||||
showCursor?: boolean;
|
|
||||||
fps?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TypedLine: React.FC<Props> = ({
|
|
||||||
text,
|
|
||||||
startFrame,
|
|
||||||
charsPerSec = 28,
|
|
||||||
fontSize = 32,
|
|
||||||
color = COLORS.fgPrimary,
|
|
||||||
prefix,
|
|
||||||
prefixColor = COLORS.accentGreen,
|
|
||||||
showCursor = true,
|
|
||||||
fps = 30,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const elapsed = Math.max(0, frame - startFrame);
|
|
||||||
const totalChars = text.length;
|
|
||||||
const lengthFrames = Math.ceil((totalChars / charsPerSec) * fps);
|
|
||||||
const visibleChars = Math.round(
|
|
||||||
interpolate(elapsed, [0, lengthFrames], [0, totalChars], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const visible = text.slice(0, visibleChars);
|
|
||||||
const done = visibleChars >= totalChars;
|
|
||||||
const cursorOn = showCursor && Math.floor(frame / 15) % 2 === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
fontSize,
|
|
||||||
color,
|
|
||||||
whiteSpace: "pre",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{prefix ? (
|
|
||||||
<span style={{ color: prefixColor, marginRight: 12 }}>{prefix}</span>
|
|
||||||
) : null}
|
|
||||||
<span>{visible}</span>
|
|
||||||
{(!done || cursorOn) && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: fontSize * 0.55,
|
|
||||||
height: fontSize * 0.95,
|
|
||||||
background: color,
|
|
||||||
verticalAlign: "text-bottom",
|
|
||||||
marginLeft: 2,
|
|
||||||
opacity: cursorOn ? 0.85 : 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { registerRoot } from "remotion";
|
|
||||||
import { RemotionRoot } from "./Root";
|
|
||||||
|
|
||||||
registerRoot(RemotionRoot);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export const COLORS = {
|
|
||||||
bgDeep: "#0a0e14",
|
|
||||||
bgPanel: "#11161d",
|
|
||||||
bgPanelSoft: "#161c25",
|
|
||||||
fgPrimary: "#e6e6e6",
|
|
||||||
fgMuted: "#8a93a3",
|
|
||||||
fgDim: "#5b6573",
|
|
||||||
border: "#2a3340",
|
|
||||||
accentCyan: "#36d6f7",
|
|
||||||
accentMagenta: "#ff55a3",
|
|
||||||
accentGreen: "#5fff9f",
|
|
||||||
accentAmber: "#ffc857",
|
|
||||||
trafficRed: "#ff5f57",
|
|
||||||
trafficYellow: "#febc2e",
|
|
||||||
trafficGreen: "#28c840",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const FONT_MONO = '"JetBrains Mono", "SF Mono", "Menlo", monospace';
|
|
||||||
export const FONT_SANS =
|
|
||||||
'"Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif';
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// Single source of truth for scene frame ranges.
|
|
||||||
// 30fps × 30s = 900 frames total.
|
|
||||||
export const FPS = 30;
|
|
||||||
export const TOTAL_FRAMES = 900;
|
|
||||||
|
|
||||||
export const SCENES = {
|
|
||||||
hook: { from: 0, durationInFrames: 90 },
|
|
||||||
oldWay: { from: 90, durationInFrames: 150 },
|
|
||||||
fanOut: { from: 240, durationInFrames: 180 },
|
|
||||||
comparison: { from: 420, durationInFrames: 210 },
|
|
||||||
howItWorks: { from: 630, durationInFrames: 150 },
|
|
||||||
cta: { from: 780, durationInFrames: 120 },
|
|
||||||
} as const;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
||||||
import { BadgeBar } from "../components/BadgeBar";
|
|
||||||
import { COLORS, FONT_SANS } from "../lib/colors";
|
|
||||||
|
|
||||||
export const Scene1Hook: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const captionOpacity = interpolate(frame, [20, 35, 75, 90], [0, 1, 1, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const captionLift = spring({
|
|
||||||
frame: frame - 20,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 70 },
|
|
||||||
from: 16,
|
|
||||||
to: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const badgeFadeOut = interpolate(frame, [70, 90], [1, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `radial-gradient(circle at 50% 40%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 60%)`,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 64,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ opacity: badgeFadeOut }}>
|
|
||||||
<BadgeBar />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: captionOpacity,
|
|
||||||
transform: `translateY(${captionLift}px)`,
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: 96,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: COLORS.fgPrimary,
|
|
||||||
textAlign: "center",
|
|
||||||
letterSpacing: -1.5,
|
|
||||||
lineHeight: 1.1,
|
|
||||||
maxWidth: 1400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
What if one search
|
|
||||||
<br />
|
|
||||||
ran <span style={{ color: COLORS.accentCyan }}>3 at once?</span>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
|
||||||
import { TerminalWindow } from "../components/TerminalWindow";
|
|
||||||
import { TypedLine } from "../components/TypedLine";
|
|
||||||
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
|
||||||
|
|
||||||
export const Scene2OldWay: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
|
|
||||||
const enter = interpolate(frame, [0, 20], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const slide = interpolate(frame, [0, 20], [40, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Result card fades in after type completes (~70 frames)
|
|
||||||
const resultFade = interpolate(frame, [70, 95], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Caption appears late
|
|
||||||
const captionFade = interpolate(frame, [110, 130, 150], [0, 1, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: COLORS.bgDeep,
|
|
||||||
padding: 80,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 1400,
|
|
||||||
height: 520,
|
|
||||||
opacity: enter,
|
|
||||||
transform: `translateY(${slide}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalWindow title="bash">
|
|
||||||
<TypedLine
|
|
||||||
text="/last30days OpenAI"
|
|
||||||
startFrame={20}
|
|
||||||
prefix="$"
|
|
||||||
fontSize={42}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: resultFade,
|
|
||||||
marginTop: 36,
|
|
||||||
padding: "20px 24px",
|
|
||||||
background: COLORS.bgPanelSoft,
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${COLORS.border}`,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
fontSize: 26,
|
|
||||||
color: COLORS.fgPrimary,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: COLORS.accentGreen }}>
|
|
||||||
✅ All agents reported back!
|
|
||||||
</div>
|
|
||||||
<div style={{ color: COLORS.fgMuted, marginTop: 6 }}>
|
|
||||||
├─ 🟠 Reddit: 14 threads
|
|
||||||
</div>
|
|
||||||
<div style={{ color: COLORS.fgMuted }}>
|
|
||||||
├─ 🔵 X: 22 posts
|
|
||||||
</div>
|
|
||||||
<div style={{ color: COLORS.fgMuted }}>
|
|
||||||
└─ 🟡 HN: 1 story
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TerminalWindow>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: captionFade,
|
|
||||||
marginTop: 60,
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: 38,
|
|
||||||
color: COLORS.fgMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
The old way: <span style={{ color: COLORS.fgPrimary }}>one topic.</span>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
||||||
import { TerminalWindow } from "../components/TerminalWindow";
|
|
||||||
import { TypedLine } from "../components/TypedLine";
|
|
||||||
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
|
||||||
|
|
||||||
const PROGRESS_LINES = [
|
|
||||||
{ source: "Reddit", color: "#ff6a3d" },
|
|
||||||
{ source: "X", color: "#36d6f7" },
|
|
||||||
{ source: "YouTube", color: "#ff5757" },
|
|
||||||
{ source: "TikTok", color: "#5fff9f" },
|
|
||||||
{ source: "Instagram", color: "#ff55a3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ENTITIES: { label: string; tag: string; accent: string }[] = [
|
|
||||||
{ label: "OpenAI", tag: "$ /last30days OpenAI", accent: COLORS.accentCyan },
|
|
||||||
{ label: "Anthropic", tag: "$ /last30days Anthropic", accent: COLORS.accentMagenta },
|
|
||||||
{ label: "xAI", tag: "$ /last30days xAI", accent: COLORS.accentAmber },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FanPane: React.FC<{
|
|
||||||
label: string;
|
|
||||||
tag: string;
|
|
||||||
accent: string;
|
|
||||||
panelStart: number;
|
|
||||||
}> = ({ label, tag, accent, panelStart }) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
const localFrame = Math.max(0, frame - panelStart);
|
|
||||||
|
|
||||||
const enter = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 18, stiffness: 80 },
|
|
||||||
from: 0,
|
|
||||||
to: 1,
|
|
||||||
});
|
|
||||||
const slide = interpolate(localFrame, [0, 20], [40, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
opacity: enter,
|
|
||||||
transform: `translateY(${slide}px)`,
|
|
||||||
height: 480,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalWindow title={label} glow>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: accent,
|
|
||||||
fontSize: 18,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
marginBottom: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 22,
|
|
||||||
color: COLORS.accentGreen,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
[Competitors] running...
|
|
||||||
</div>
|
|
||||||
{PROGRESS_LINES.map((line, idx) => {
|
|
||||||
const lineStart = panelStart + 16 + idx * 6;
|
|
||||||
const lineFade = interpolate(
|
|
||||||
frame - lineStart,
|
|
||||||
[0, 8],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
);
|
|
||||||
// pulse the in-progress dot
|
|
||||||
const dotOn = Math.floor((frame - lineStart) / 6) % 2 === 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={line.source}
|
|
||||||
style={{
|
|
||||||
opacity: lineFade,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
fontSize: 20,
|
|
||||||
color: COLORS.fgMuted,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: dotOn ? line.color : COLORS.bgPanelSoft,
|
|
||||||
marginRight: 12,
|
|
||||||
boxShadow: dotOn ? `0 0 10px ${line.color}` : "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ color: line.color, marginRight: 8 }}>
|
|
||||||
►
|
|
||||||
</span>
|
|
||||||
<span>{line.source}</span>
|
|
||||||
<span style={{ marginLeft: "auto", color: COLORS.fgDim }}>
|
|
||||||
{dotOn ? "..." : "·"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TerminalWindow>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Scene3FanOut: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
|
|
||||||
// Phase 1 (0-30 frames): single terminal types --competitors flag
|
|
||||||
// Phase 2 (30+): split into 3 panes
|
|
||||||
const splitProgress = interpolate(frame, [30, 50], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
const singleOpacity = interpolate(frame, [0, 8, 30, 45], [0, 1, 1, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Caption fades in shortly after panes settle so it has time to read.
|
|
||||||
const captionFade = interpolate(frame, [60, 80], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: COLORS.bgDeep,
|
|
||||||
padding: 60,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: captionFade,
|
|
||||||
textAlign: "center",
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: 42,
|
|
||||||
color: COLORS.fgPrimary,
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Now it discovers competitors
|
|
||||||
<br />
|
|
||||||
<span style={{ color: COLORS.accentCyan }}>and runs all 3.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Single terminal during phase 1, fades out as panes appear */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: singleOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: 1200, height: 380 }}>
|
|
||||||
<TerminalWindow title="bash">
|
|
||||||
<TypedLine
|
|
||||||
text="/last30days OpenAI --competitors"
|
|
||||||
startFrame={0}
|
|
||||||
prefix="$"
|
|
||||||
fontSize={42}
|
|
||||||
/>
|
|
||||||
</TerminalWindow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Three panes fade in starting frame ~30 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 24,
|
|
||||||
opacity: splitProgress,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ENTITIES.map((entity, idx) => (
|
|
||||||
<FanPane
|
|
||||||
key={entity.label}
|
|
||||||
label={entity.label}
|
|
||||||
tag={entity.tag}
|
|
||||||
accent={entity.accent}
|
|
||||||
panelStart={45 + idx * 8}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
|
||||||
import { ComparisonTable } from "../components/ComparisonTable";
|
|
||||||
import { COLORS, FONT_SANS } from "../lib/colors";
|
|
||||||
|
|
||||||
const ROWS = [
|
|
||||||
{
|
|
||||||
dimension: "What it is",
|
|
||||||
cells: [
|
|
||||||
"GPT-5 leader, Plus + API",
|
|
||||||
"Claude 4, safety-first",
|
|
||||||
"Grok, X-native, fast",
|
|
||||||
] as [string, string, string],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dimension: "30-day momentum",
|
|
||||||
cells: [
|
|
||||||
"GPT-5 launch wave",
|
|
||||||
"Claude 4.7 1M context",
|
|
||||||
"Grok 5 reveal",
|
|
||||||
] as [string, string, string],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dimension: "Community vibe",
|
|
||||||
cells: [
|
|
||||||
"Defensive but deep",
|
|
||||||
"Quiet, devs-only",
|
|
||||||
"Loud, meme-rich",
|
|
||||||
] as [string, string, string],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dimension: "Best for",
|
|
||||||
cells: [
|
|
||||||
"Mainstream + tools",
|
|
||||||
"Long-context coding",
|
|
||||||
"Live X intel",
|
|
||||||
] as [string, string, string],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Scene4Comparison: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
|
|
||||||
const captionFade = interpolate(frame, [0, 12, 180, 210], [0, 1, 1, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const captionLift = interpolate(frame, [0, 14], [16, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
const tableFade = interpolate(frame, [16, 32], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: COLORS.bgDeep,
|
|
||||||
padding: "48px 80px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: captionFade,
|
|
||||||
transform: `translateY(${captionLift}px)`,
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: 38,
|
|
||||||
color: COLORS.fgMuted,
|
|
||||||
textAlign: "center",
|
|
||||||
marginBottom: 36,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: COLORS.accentCyan, fontWeight: 600 }}>
|
|
||||||
3 full passes.
|
|
||||||
</span>
|
|
||||||
<span style={{ marginLeft: 18, color: COLORS.accentMagenta, fontWeight: 600 }}>
|
|
||||||
3 save files.
|
|
||||||
</span>
|
|
||||||
<span style={{ marginLeft: 18, color: COLORS.fgPrimary, fontWeight: 600 }}>
|
|
||||||
1 comparison.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 1640,
|
|
||||||
opacity: tableFade,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ComparisonTable
|
|
||||||
startFrame={20}
|
|
||||||
entities={["OpenAI", "Anthropic", "xAI"]}
|
|
||||||
rows={ROWS}
|
|
||||||
rowStaggerFrames={22}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
|
||||||
import { COLORS, FONT_SANS } from "../lib/colors";
|
|
||||||
|
|
||||||
const LINES = [
|
|
||||||
{ text: "You pick the topic.", color: COLORS.fgPrimary },
|
|
||||||
{ text: "The agent picks the peers.", color: COLORS.accentCyan },
|
|
||||||
{ text: "The engine fans out.", color: COLORS.accentMagenta },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Scene5HowItWorks: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `radial-gradient(circle at 50% 60%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 70%)`,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 48,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{LINES.map((line, idx) => {
|
|
||||||
const start = 10 + idx * 28;
|
|
||||||
const fade = interpolate(frame, [start, start + 14], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const slide = interpolate(frame, [start, start + 18], [24, 0], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={line.text}
|
|
||||||
style={{
|
|
||||||
opacity: fade,
|
|
||||||
transform: `translateY(${slide}px)`,
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: 78,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: line.color,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{line.text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
||||||
import { BadgeBar } from "../components/BadgeBar";
|
|
||||||
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
|
||||||
|
|
||||||
export const Scene6CTA: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const installFade = interpolate(frame, [20, 40], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
// Stronger 1Hz pulse on the install line for end-of-video emphasis
|
|
||||||
const pulse = 0.8 + 0.2 * Math.sin((frame / fps) * 2 * Math.PI);
|
|
||||||
const glowPulse = 0.4 + 0.4 * Math.sin((frame / fps) * 2 * Math.PI);
|
|
||||||
|
|
||||||
const repoFade = interpolate(frame, [50, 70], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
const enterScale = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 18, stiffness: 90 },
|
|
||||||
from: 0.95,
|
|
||||||
to: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `radial-gradient(circle at 50% 50%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 70%)`,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 56,
|
|
||||||
transform: `scale(${enterScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BadgeBar />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: installFade * pulse,
|
|
||||||
padding: "18px 36px",
|
|
||||||
background: COLORS.bgPanel,
|
|
||||||
border: `1px solid ${COLORS.accentCyan}`,
|
|
||||||
borderRadius: 14,
|
|
||||||
boxShadow: `0 0 ${40 + glowPulse * 60}px ${COLORS.accentCyan}${Math.round(40 + glowPulse * 80).toString(16)}`,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
fontSize: 44,
|
|
||||||
color: COLORS.fgPrimary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: COLORS.accentGreen, marginRight: 18 }}>$</span>
|
|
||||||
/last30days <span style={{ color: COLORS.fgMuted }}>{"{topic}"}</span>{" "}
|
|
||||||
<span style={{ color: COLORS.accentCyan }}>--competitors</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: repoFade,
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: 28,
|
|
||||||
color: COLORS.fgMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
github.com/mvanhorn/last30days-skill
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user