diff --git a/.gitignore b/.gitignore index 3ecb537..133e301 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ htmlcov/ # Internal planning docs (ce:plan output) — keep local, don't publish docs/plans/ + +# Marketing video build artifacts +marketing/v3.1-launch/node_modules/ +marketing/v3.1-launch/out/ +marketing/*/node_modules/ +marketing/*/out/ diff --git a/docs/plans/2026-04-22-007-marketing-v3-1-launch-video-plan.md b/docs/plans/2026-04-22-007-marketing-v3-1-launch-video-plan.md new file mode 100644 index 0000000..a66f2c8 --- /dev/null +++ b/docs/plans/2026-04-22-007-marketing-v3-1-launch-video-plan.md @@ -0,0 +1,396 @@ +--- +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) +├── +├── +├── +├── +├── +└── +``` + +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 `` 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. diff --git a/marketing/v3.1-launch/README.md b/marketing/v3.1-launch/README.md new file mode 100644 index 0000000..63f1da1 --- /dev/null +++ b/marketing/v3.1-launch/README.md @@ -0,0 +1,37 @@ +# /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. diff --git a/marketing/v3.1-launch/package.json b/marketing/v3.1-launch/package.json new file mode 100644 index 0000000..ee76d66 --- /dev/null +++ b/marketing/v3.1-launch/package.json @@ -0,0 +1,23 @@ +{ + "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" + } +} diff --git a/marketing/v3.1-launch/remotion.config.ts b/marketing/v3.1-launch/remotion.config.ts new file mode 100644 index 0000000..ba4cd8f --- /dev/null +++ b/marketing/v3.1-launch/remotion.config.ts @@ -0,0 +1,6 @@ +import { Config } from "@remotion/cli/config"; + +Config.setVideoImageFormat("jpeg"); +Config.setOverwriteOutput(true); +Config.setConcurrency(null); +Config.setCodec("h264"); diff --git a/marketing/v3.1-launch/src/LaunchVideo.tsx b/marketing/v3.1-launch/src/LaunchVideo.tsx new file mode 100644 index 0000000..366ed53 --- /dev/null +++ b/marketing/v3.1-launch/src/LaunchVideo.tsx @@ -0,0 +1,35 @@ +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 ( + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/marketing/v3.1-launch/src/Root.tsx b/marketing/v3.1-launch/src/Root.tsx new file mode 100644 index 0000000..9c42082 --- /dev/null +++ b/marketing/v3.1-launch/src/Root.tsx @@ -0,0 +1,19 @@ +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 ( + <> + + + ); +}; diff --git a/marketing/v3.1-launch/src/components/BadgeBar.tsx b/marketing/v3.1-launch/src/components/BadgeBar.tsx new file mode 100644 index 0000000..30b155a --- /dev/null +++ b/marketing/v3.1-launch/src/components/BadgeBar.tsx @@ -0,0 +1,68 @@ +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 = ({ 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 ( +
+ 🌐 + last30days + + v3.1 + + + · synced 2026-04-22 + +
+ ); +}; diff --git a/marketing/v3.1-launch/src/components/ComparisonTable.tsx b/marketing/v3.1-launch/src/components/ComparisonTable.tsx new file mode 100644 index 0000000..a9b407c --- /dev/null +++ b/marketing/v3.1-launch/src/components/ComparisonTable.tsx @@ -0,0 +1,135 @@ +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 = ({ + 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 ( +
+
+
+ Dimension +
+ {entities.map((entity, idx) => ( +
+ {entity} +
+ ))} +
+ {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 ( +
+
+ {row.dimension} +
+ {row.cells.map((cell, cellIdx) => ( +
+ {cell} +
+ ))} +
+ ); + })} +
+ ); +}; diff --git a/marketing/v3.1-launch/src/components/TerminalWindow.tsx b/marketing/v3.1-launch/src/components/TerminalWindow.tsx new file mode 100644 index 0000000..9a741a3 --- /dev/null +++ b/marketing/v3.1-launch/src/components/TerminalWindow.tsx @@ -0,0 +1,95 @@ +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 = ({ + title = "/last30days", + width = "100%", + height = "100%", + children, + glow = false, +}) => { + return ( +
+
+ + + + + {title} + +
+
+ {children} +
+
+ ); +}; diff --git a/marketing/v3.1-launch/src/components/TypedLine.tsx b/marketing/v3.1-launch/src/components/TypedLine.tsx new file mode 100644 index 0000000..e0b008e --- /dev/null +++ b/marketing/v3.1-launch/src/components/TypedLine.tsx @@ -0,0 +1,71 @@ +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 = ({ + 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 ( +
+ {prefix ? ( + {prefix} + ) : null} + {visible} + {(!done || cursorOn) && ( + + )} +
+ ); +}; diff --git a/marketing/v3.1-launch/src/index.ts b/marketing/v3.1-launch/src/index.ts new file mode 100644 index 0000000..f31c790 --- /dev/null +++ b/marketing/v3.1-launch/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { RemotionRoot } from "./Root"; + +registerRoot(RemotionRoot); diff --git a/marketing/v3.1-launch/src/lib/colors.ts b/marketing/v3.1-launch/src/lib/colors.ts new file mode 100644 index 0000000..84439cf --- /dev/null +++ b/marketing/v3.1-launch/src/lib/colors.ts @@ -0,0 +1,20 @@ +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'; diff --git a/marketing/v3.1-launch/src/lib/timing.ts b/marketing/v3.1-launch/src/lib/timing.ts new file mode 100644 index 0000000..6d562c0 --- /dev/null +++ b/marketing/v3.1-launch/src/lib/timing.ts @@ -0,0 +1,13 @@ +// 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; diff --git a/marketing/v3.1-launch/src/scenes/Scene1Hook.tsx b/marketing/v3.1-launch/src/scenes/Scene1Hook.tsx new file mode 100644 index 0000000..f2a1d9d --- /dev/null +++ b/marketing/v3.1-launch/src/scenes/Scene1Hook.tsx @@ -0,0 +1,61 @@ +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 ( + +
+ +
+
+ What if one search +
+ ran 3 at once? +
+
+ ); +}; diff --git a/marketing/v3.1-launch/src/scenes/Scene2OldWay.tsx b/marketing/v3.1-launch/src/scenes/Scene2OldWay.tsx new file mode 100644 index 0000000..d39df1f --- /dev/null +++ b/marketing/v3.1-launch/src/scenes/Scene2OldWay.tsx @@ -0,0 +1,100 @@ +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 ( + +
+ + +
+
+ ✅ All agents reported back! +
+
+ ├─ 🟠 Reddit: 14 threads +
+
+ ├─ 🔵 X: 22 posts +
+
+ └─ 🟡 HN: 1 story +
+
+
+
+ +
+ The old way: one topic. +
+
+ ); +}; diff --git a/marketing/v3.1-launch/src/scenes/Scene3FanOut.tsx b/marketing/v3.1-launch/src/scenes/Scene3FanOut.tsx new file mode 100644 index 0000000..1ed4583 --- /dev/null +++ b/marketing/v3.1-launch/src/scenes/Scene3FanOut.tsx @@ -0,0 +1,221 @@ +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 ( +
+ +
+ {tag} +
+
+ [Competitors] running... +
+ {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 ( +
+ + + ► + + {line.source} + + {dotOn ? "..." : "·"} + +
+ ); + })} +
+
+ ); +}; + +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 ( + +
+ Now it discovers competitors +
+ and runs all 3. +
+ +
+ {/* Single terminal during phase 1, fades out as panes appear */} +
+
+ + + +
+
+ + {/* Three panes fade in starting frame ~30 */} +
+ {ENTITIES.map((entity, idx) => ( + + ))} +
+
+
+ ); +}; diff --git a/marketing/v3.1-launch/src/scenes/Scene4Comparison.tsx b/marketing/v3.1-launch/src/scenes/Scene4Comparison.tsx new file mode 100644 index 0000000..8b0d0a5 --- /dev/null +++ b/marketing/v3.1-launch/src/scenes/Scene4Comparison.tsx @@ -0,0 +1,105 @@ +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 ( + +
+ + 3 full passes. + + + 3 save files. + + + 1 comparison. + +
+
+ +
+
+ ); +}; diff --git a/marketing/v3.1-launch/src/scenes/Scene5HowItWorks.tsx b/marketing/v3.1-launch/src/scenes/Scene5HowItWorks.tsx new file mode 100644 index 0000000..3b22e27 --- /dev/null +++ b/marketing/v3.1-launch/src/scenes/Scene5HowItWorks.tsx @@ -0,0 +1,54 @@ +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 ( + + {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 ( +
+ {line.text} +
+ ); + })} +
+ ); +}; diff --git a/marketing/v3.1-launch/src/scenes/Scene6CTA.tsx b/marketing/v3.1-launch/src/scenes/Scene6CTA.tsx new file mode 100644 index 0000000..10c2f5c --- /dev/null +++ b/marketing/v3.1-launch/src/scenes/Scene6CTA.tsx @@ -0,0 +1,73 @@ +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 ( + + +
+ $ + /last30days {"{topic}"}{" "} + --competitors +
+
+ github.com/mvanhorn/last30days-skill +
+
+ ); +}; diff --git a/marketing/v3.1-launch/tsconfig.json b/marketing/v3.1-launch/tsconfig.json new file mode 100644 index 0000000..4ef45f9 --- /dev/null +++ b/marketing/v3.1-launch/tsconfig.json @@ -0,0 +1,19 @@ +{ + "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/**/*"] +}