Compare commits
25 Commits
release/26
...
worktree-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ee19f13f | ||
|
|
fa535b1898 | ||
|
|
d9b78958aa | ||
|
|
cbbc9f3391 | ||
|
|
b043f0ae92 | ||
|
|
598292d9bf | ||
|
|
a5d130f5da | ||
|
|
f289ef7fbd | ||
|
|
58a7ef07d8 | ||
|
|
b7eb508cce | ||
|
|
7cccdad1ba | ||
|
|
60fa9bbbcb | ||
|
|
3862c48123 | ||
|
|
4362e4c9ff | ||
|
|
676778b027 | ||
|
|
112e3b73ee | ||
|
|
6e4d3f7869 | ||
|
|
89d89b89c3 | ||
|
|
0bcec82523 | ||
|
|
9199903ca7 | ||
|
|
82cb89caa5 | ||
|
|
59ce349b6b | ||
|
|
5cbc972a7a | ||
|
|
7396f12be6 | ||
|
|
012b0d4984 |
7
my-video/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
out/
|
||||
.playwright-cli/
|
||||
.superpowers/
|
||||
# Temp screenshots from playwright-cli
|
||||
*.png
|
||||
!public/screenshots/*.png
|
||||
1349
my-video/docs/superpowers/plans/2026-04-02-release-video.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Actual Budget v26.4.0 Release Video — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
A 52-second social media teaser video showcasing the most important features in Actual Budget v26.4.0. Built with Remotion (React), using real screen recordings captured via Playwright and synced to a music track at ~139 BPM.
|
||||
|
||||
**Format:** 1280x720, 30fps, ~52 seconds
|
||||
**Style:** Branded & colorful — Actual brand colors, energetic transitions, playful feel
|
||||
**Music:** `public/music.mp3` — upbeat tech track, 139 BPM. Using first 52 seconds only.
|
||||
**Content:** Real screen recordings with bold text overlays (feature name + tagline)
|
||||
|
||||
## Music Analysis
|
||||
|
||||
- **BPM:** ~139 (beat interval: ~0.43s = ~13 frames at 30fps)
|
||||
- **0–4s:** Quiet intro build-up (energy rises from silence to medium)
|
||||
- **4s+:** Full energy drop, sustained loud level throughout
|
||||
- **Track total:** 4:24, but we only use 0–52s
|
||||
|
||||
## Video Timeline
|
||||
|
||||
### Scene 1: Title Card (0.0s – 4.0s | frames 0–120)
|
||||
|
||||
- Actual Budget logo animates in during quiet intro
|
||||
- "v26.4.0" and subtitle fade in on rising energy
|
||||
- Beat drop at 4.0s triggers transition to first feature
|
||||
- Background: dark gradient with brand colors
|
||||
|
||||
### Tier 1: Hero Features (4.0s – 20.0s | frames 120–600)
|
||||
|
||||
Each feature gets ~5.3s (160 frames / 12 beats). Screen recording fills most of the frame in a styled browser mockup. Text overlay appears on the first beat.
|
||||
|
||||
**Feature 1: Drag & Drop Transaction Reordering (4.0s – 9.3s)**
|
||||
- Screen recording: user dragging transactions to reorder within the same day
|
||||
- Text: "Reorder transactions — your way"
|
||||
- Playwright scenario: open an account, drag a transaction up/down within same-day group
|
||||
|
||||
**Feature 2: Concentric Donut Chart (9.3s – 14.7s)**
|
||||
- Screen recording: custom reports page showing the new donut chart visualization
|
||||
- Text: "Beautiful category breakdowns"
|
||||
- Playwright scenario: navigate to custom reports, show a donut chart with category data
|
||||
|
||||
**Feature 3: Payee Locations MVP (14.7s – 20.0s)**
|
||||
- Screen recording: payee management showing location data
|
||||
- Text: "Know where you spend"
|
||||
- Playwright scenario: open payees section, show payee with location info
|
||||
|
||||
### Tier 2: Quick Highlights (20.0s – 44.0s | frames 600–1320)
|
||||
|
||||
Each feature gets ~6s (180 frames / 14 beats). Same layout but slightly faster-paced transitions.
|
||||
|
||||
**Feature 4: Monthly Budget Cell Notes (20.0s – 26.0s)**
|
||||
- Screen recording: adding a note to a monthly budget cell
|
||||
- Text: "Annotate your budget"
|
||||
- Playwright scenario: click a budget cell, add a note, show the note indicator
|
||||
|
||||
**Feature 5: Actual CLI Tool (26.0s – 32.0s)**
|
||||
- Screen recording: terminal showing CLI commands querying budget data
|
||||
- Text: "Your budget, from the command line"
|
||||
- Note: This will be a terminal recording/mockup rather than Playwright, since it's a CLI tool
|
||||
|
||||
**Feature 6: Custom Theme Improvements (32.0s – 38.0s)**
|
||||
- Screen recording: switching themes, showing custom fonts and light/dark options
|
||||
- Text: "Make it yours"
|
||||
- Playwright scenario: open settings, switch between themes, toggle light/dark
|
||||
|
||||
**Feature 7: Import Improvements (38.0s – 44.0s)**
|
||||
- Screen recording: import dialog showing new options
|
||||
- Text: "Smarter imports"
|
||||
- Playwright scenario: open import dialog, show "import since" date filter, swap payee/memo toggle
|
||||
|
||||
### Scene 9: Outro (44.0s – 52.0s | frames 1320–1560)
|
||||
|
||||
- Stats flash in sequence: "4 features · 45 enhancements · 32 bugfixes"
|
||||
- CTA: "Update now — actualbudget.org"
|
||||
- Logo + version badge fade out
|
||||
- Music continues to natural phrase ending
|
||||
|
||||
## Visual Design
|
||||
|
||||
### Color Palette
|
||||
|
||||
- **Background:** Dark (#1a1a2e / #16213e gradient)
|
||||
- **Tier 1 accent:** Cyan (#00d2ff)
|
||||
- **Tier 2 accent:** Coral/Red (#e94560)
|
||||
- **Outro accent:** Gold (#ffd700)
|
||||
- **Text:** White (#ffffff) with subtle shadows
|
||||
- **Brand purple:** Used for logo and accent elements
|
||||
|
||||
### Typography
|
||||
|
||||
- Feature names: Bold, large sans-serif
|
||||
- Taglines: Regular weight, slightly smaller
|
||||
- Stats/CTA: Bold, emphasized with accent color
|
||||
|
||||
### Transitions
|
||||
|
||||
- Scene transitions: slide/zoom synced to beat hits
|
||||
- Screen recordings slide in from right
|
||||
- Text overlays pop in with spring animation on beat
|
||||
- Slight zoom-in on screen recordings during playback for energy
|
||||
|
||||
### Screen Recording Frame
|
||||
|
||||
- Styled browser mockup wrapper (rounded corners, subtle shadow)
|
||||
- Dark chrome to match overall aesthetic
|
||||
- Fills ~80% of frame width, centered
|
||||
|
||||
## Remotion Architecture
|
||||
|
||||
### Composition Structure
|
||||
|
||||
```
|
||||
<MyComposition> (1560 frames, 30fps, 1280x720)
|
||||
<Audio src="music.mp3" />
|
||||
<TitleCard /> {frames 0-120}
|
||||
<FeatureScene /> {frames 120-280} -- Drag & Drop
|
||||
<FeatureScene /> {frames 280-440} -- Donut Chart
|
||||
<FeatureScene /> {frames 440-600} -- Payee Locations
|
||||
<FeatureScene /> {frames 600-780} -- Budget Notes
|
||||
<FeatureScene /> {frames 780-960} -- CLI Tool
|
||||
<FeatureScene /> {frames 960-1140} -- Themes
|
||||
<FeatureScene /> {frames 1140-1320} -- Imports
|
||||
<OutroCard /> {frames 1320-1560}
|
||||
</MyComposition>
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
- **TitleCard:** Animated logo + version text with fade/scale entrance
|
||||
- **FeatureScene:** Reusable component accepting screen recording source, title, tagline, accent color, and frame range. Handles slide-in animation, text overlay timing, and zoom effect.
|
||||
- **OutroCard:** Sequential stat counter animations + CTA
|
||||
- **BrowserFrame:** Decorative wrapper around screen recordings
|
||||
|
||||
### Screen Recordings
|
||||
|
||||
Captured as video files via Playwright (webm/mp4) and placed in `public/recordings/`. Each recording is pre-trimmed to show the key interaction for that feature.
|
||||
|
||||
## Playwright Recording Plan
|
||||
|
||||
Each recording captures a specific user interaction in the running Actual Budget app:
|
||||
|
||||
1. **drag-drop.webm** — Open checking account, drag transaction to reorder
|
||||
2. **donut-chart.webm** — Navigate to reports, create/view donut chart
|
||||
3. **payee-locations.webm** — Open payees, show payee with location
|
||||
4. **budget-notes.webm** — Click budget cell, type a note, save
|
||||
5. **cli-tool.webm** — (Terminal recording, not Playwright)
|
||||
6. **themes.webm** — Settings > Themes, switch themes, toggle dark/light
|
||||
7. **imports.webm** — File > Import, show new import options
|
||||
|
||||
The app needs to be running with demo data (`yarn start` + "View demo" setup) before capturing.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Remotion 4.0.443** (already installed)
|
||||
- **@remotion/player** — for preview
|
||||
- **Tailwind CSS 4** (already installed)
|
||||
- **Playwright** — for screen recordings (project dependency)
|
||||
- **ffmpeg** — for audio trimming if needed
|
||||
3
my-video/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import { config } from "@remotion/eslint-config-flat";
|
||||
|
||||
export default config;
|
||||
4989
my-video/package-lock.json
generated
Normal file
38
my-video/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "my-video",
|
||||
"version": "1.0.0",
|
||||
"description": "My Remotion video",
|
||||
"repository": {},
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.443",
|
||||
"@remotion/google-fonts": "^4.0.443",
|
||||
"@remotion/media": "^4.0.443",
|
||||
"@remotion/tailwind-v4": "4.0.443",
|
||||
"@remotion/transitions": "^4.0.443",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"remotion": "4.0.443",
|
||||
"tailwindcss": "4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@remotion/eslint-config-flat": "4.0.443",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/web": "0.0.166",
|
||||
"eslint": "9.19.0",
|
||||
"playwright": "^1.59.1",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "remotion studio",
|
||||
"build": "remotion bundle",
|
||||
"upgrade": "remotion upgrade",
|
||||
"lint": "eslint src && tsc"
|
||||
},
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
]
|
||||
}
|
||||
4
my-video/public/logo.svg
Executable file
@@ -0,0 +1,4 @@
|
||||
<svg width="30" height="32" viewBox="0 0 30 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.13785 30.4226L14.9372 1.11397C14.99 1.00184 15.1027 0.930283 15.2267 0.930283H15.8318C15.9542 0.930283 16.0659 1.00015 16.1195 1.11023L25.0219 19.3999L27.8131 18.3264C27.978 18.2629 28.1632 18.3452 28.2266 18.5102L28.9695 20.4417C29.033 20.6067 28.9507 20.7918 28.7857 20.8553L26.2121 21.8452L29.3875 28.3689C29.4648 28.5278 29.3987 28.7193 29.2398 28.7967L27.379 29.7024C27.2201 29.7798 27.0286 29.7136 26.9512 29.5547L23.6739 22.8215L1.6943 31.2754C1.52935 31.3389 1.3442 31.2566 1.28075 31.0916C1.28006 31.0898 1.27938 31.088 1.27872 31.0862L1.12666 30.6684C1.09749 30.5883 1.10152 30.4998 1.13785 30.4226ZM15.56 6.1518L5.85065 26.7737L22.4837 20.3762L15.56 6.1518Z" fill="white"/>
|
||||
<path d="M21.7768 14.5682L22.7095 17.1121L1.50597 24.8867C1.34004 24.9476 1.1562 24.8624 1.09536 24.6964L0.382928 22.7534C0.322087 22.5875 0.407278 22.4037 0.573207 22.3428L21.7768 14.5682Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1006 B |
BIN
my-video/public/recordings/budget-notes.webm
Normal file
BIN
my-video/public/recordings/cli-tool.webm
Normal file
BIN
my-video/public/recordings/donut-chart.webm
Normal file
BIN
my-video/public/recordings/drag-drop.webm
Normal file
BIN
my-video/public/recordings/imports.webm
Normal file
BIN
my-video/public/recordings/payee-locations.webm
Normal file
BIN
my-video/public/recordings/themes.webm
Normal file
BIN
my-video/public/screenshots/budget-notes.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
my-video/public/screenshots/cli-tool.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
my-video/public/screenshots/donut-chart.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
my-video/public/screenshots/drag-drop.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
my-video/public/screenshots/imports.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
my-video/public/screenshots/payee-locations.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
my-video/public/screenshots/themes.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
13
my-video/remotion.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Note: When using the Node.JS APIs, the config file
|
||||
* doesn't apply. Instead, pass options directly to the APIs.
|
||||
*
|
||||
* All configuration options: https://remotion.dev/docs/config
|
||||
*/
|
||||
|
||||
import { Config } from "@remotion/cli/config";
|
||||
import { enableTailwind } from '@remotion/tailwind-v4';
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.overrideWebpackConfig(enableTailwind);
|
||||
42
my-video/scripts/cli-mockup.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1e1e2e;
|
||||
color: #f8f8f2;
|
||||
padding: 40px;
|
||||
width: 1024px;
|
||||
height: 576px;
|
||||
}
|
||||
pre {
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
white-space: pre;
|
||||
}
|
||||
.prompt { color: #50fa7b; }
|
||||
.dim { color: #6272a4; }
|
||||
.red { color: #ff5555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<pre>
|
||||
<span class="prompt">$</span> actual-cli accounts list --format table
|
||||
|
||||
<span class="dim"> id name offBudget closed balance_current</span>
|
||||
99e8f789-c982-4618-b686-3b331985374b Bank of America false false 6,929.07
|
||||
cbcec281-9899-4595-9ef3-5e33725555bb Ally Savings false false 3,425.74
|
||||
5f8e1bc3-136a-4669-9e00-6e36088eebc3 Capital One false false 1,388.56
|
||||
713d293e-ec6b-4813-aafd-6c9e63579375 HSBC false false <span class="red">-531.05</span>
|
||||
2ce33e0b-0517-458c-98d6-796c7ede90f7 Vanguard 401k true false 4,399.38
|
||||
6f6d9cb2-25ea-4b62-a6f6-0a4f2bb167ad Mortgage true false <span class="red">-301,380.72</span>
|
||||
7f3ff788-9af8-4109-aef5-b0b2971097df House Asset true false 341,300.00
|
||||
59aec8a4-0c61-4a4a-932e-4ae927f1adb6 Roth IRA true false 3,439.18
|
||||
|
||||
<span class="prompt">$</span> <span style="opacity:0.7">_</span>
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
89
my-video/src/Composition.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { Audio } from "@remotion/media";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { staticFile } from "remotion";
|
||||
import {
|
||||
FPS,
|
||||
TITLE_DURATION,
|
||||
TIER1_SCENE_DURATION,
|
||||
TIER2_SCENE_DURATION,
|
||||
OUTRO_DURATION,
|
||||
TRANSITION_DURATION,
|
||||
TIER1_FEATURES,
|
||||
TIER2_FEATURES,
|
||||
TOTAL_DURATION,
|
||||
} from "./constants";
|
||||
import { TitleCard } from "./components/TitleCard";
|
||||
import { FeatureScene } from "./components/FeatureScene";
|
||||
import { OutroCard } from "./components/OutroCard";
|
||||
|
||||
export function MyComposition() {
|
||||
const fadeOutDuration = 2 * FPS; // 2 seconds fade out
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Audio
|
||||
src={staticFile("music.mp3")}
|
||||
volume={(f) =>
|
||||
interpolate(f, [TOTAL_DURATION - fadeOutDuration, TOTAL_DURATION], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TransitionSeries>
|
||||
{/* Title scene */}
|
||||
<TransitionSeries.Sequence durationInFrames={TITLE_DURATION}>
|
||||
<TitleCard />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
{/* Tier 1 feature scenes */}
|
||||
{TIER1_FEATURES.map((feature) => (
|
||||
<React.Fragment key={feature.screenshot}>
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-right" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
<TransitionSeries.Sequence
|
||||
durationInFrames={TIER1_SCENE_DURATION}
|
||||
premountFor={TRANSITION_DURATION}
|
||||
>
|
||||
<FeatureScene feature={feature} />
|
||||
</TransitionSeries.Sequence>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Tier 2 feature scenes */}
|
||||
{TIER2_FEATURES.map((feature) => (
|
||||
<React.Fragment key={feature.screenshot}>
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-right" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
<TransitionSeries.Sequence
|
||||
durationInFrames={TIER2_SCENE_DURATION}
|
||||
premountFor={TRANSITION_DURATION}
|
||||
>
|
||||
<FeatureScene feature={feature} />
|
||||
</TransitionSeries.Sequence>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Outro */}
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
<TransitionSeries.Sequence
|
||||
durationInFrames={OUTRO_DURATION}
|
||||
premountFor={TRANSITION_DURATION}
|
||||
>
|
||||
<OutroCard />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
19
my-video/src/Root.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import "./index.css";
|
||||
import { Composition } from "remotion";
|
||||
import { MyComposition } from "./Composition";
|
||||
import { FPS, WIDTH, HEIGHT, TOTAL_DURATION } from "./constants";
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="ReleaseVideo"
|
||||
component={MyComposition}
|
||||
durationInFrames={TOTAL_DURATION}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
50
my-video/src/components/AnimatedText.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { type CSSProperties } from "react";
|
||||
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type AnimatedTextProps = {
|
||||
text: string;
|
||||
delay?: number;
|
||||
fontSize?: number;
|
||||
fontWeight?: CSSProperties["fontWeight"];
|
||||
color?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function AnimatedText({
|
||||
text,
|
||||
delay = 0,
|
||||
fontSize = 48,
|
||||
fontWeight = "bold",
|
||||
color = "#ffffff",
|
||||
style,
|
||||
}: AnimatedTextProps) {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const progress = spring({
|
||||
frame: frame - delay,
|
||||
fps,
|
||||
config: {
|
||||
damping: 20,
|
||||
stiffness: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const translateY = interpolate(progress, [0, 1], [30, 0]);
|
||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize,
|
||||
fontWeight,
|
||||
color,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
opacity,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
my-video/src/components/BrowserFrame.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS } from "../constants";
|
||||
|
||||
type BrowserFrameProps = {
|
||||
children: ReactNode;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
export function BrowserFrame({
|
||||
children,
|
||||
accentColor = COLORS.accentCyan,
|
||||
}: BrowserFrameProps) {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Rotate once every 3 seconds
|
||||
const angle = (frame / fps) * 120; // 120 degrees per second
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
{/* Rotating gradient border layer */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: -2,
|
||||
borderRadius: 14,
|
||||
background: `conic-gradient(from ${angle}deg, transparent 0%, transparent 60%, ${accentColor} 75%, ${accentColor}cc 80%, transparent 95%, transparent 100%)`,
|
||||
filter: "blur(4px)",
|
||||
}}
|
||||
/>
|
||||
{/* Subtle static glow underneath */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: -1,
|
||||
borderRadius: 13,
|
||||
border: `1px solid ${accentColor}22`,
|
||||
boxShadow: `0 0 30px ${accentColor}15`,
|
||||
}}
|
||||
/>
|
||||
{/* Main frame content */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div
|
||||
style={{
|
||||
background: "#2d2d3a",
|
||||
height: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Traffic lights */}
|
||||
<div style={{ display: "flex", gap: 8, zIndex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: "#ff5f57",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: "#ffbd2e",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: "#28c840",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Centered title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: 13,
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
Actual Budget
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "#0f0f1a",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
my-video/src/components/FeatureScene.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
import { type Feature, COLORS, FRAMES_PER_BEAT } from "../constants";
|
||||
import { AnimatedText } from "./AnimatedText";
|
||||
import { BrowserFrame } from "./BrowserFrame";
|
||||
|
||||
const { fontFamily } = loadFont();
|
||||
|
||||
type FeatureSceneProps = {
|
||||
feature: Feature;
|
||||
};
|
||||
|
||||
export function FeatureScene({ feature }: FeatureSceneProps) {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Browser frame slides in from right
|
||||
const slideProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: {
|
||||
damping: 20,
|
||||
stiffness: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const translateX = interpolate(slideProgress, [0, 1], [400, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at 30% 50%, ${feature.accentColor}18 0%, ${COLORS.bgDark} 60%)`,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: "60px 80px",
|
||||
gap: 60,
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
{/* Background gradient overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `linear-gradient(135deg, ${COLORS.bgDark} 0%, ${COLORS.bgGradient} 100%)`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accent glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: feature.screenshot ? -100 : "50%",
|
||||
top: "50%",
|
||||
transform: feature.screenshot ? "translateY(-50%)" : "translate(-50%, -50%)",
|
||||
width: 500,
|
||||
height: 500,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse at center, ${feature.accentColor}20 0%, transparent 70%)`,
|
||||
zIndex: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Text area */}
|
||||
<div
|
||||
style={{
|
||||
flex: feature.screenshot ? "0 0 360px" : 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: feature.screenshot ? "flex-start" : "center",
|
||||
justifyContent: feature.screenshot ? "flex-start" : "center",
|
||||
gap: 16,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<AnimatedText
|
||||
text={feature.title}
|
||||
delay={0}
|
||||
fontSize={feature.screenshot ? 42 : 56}
|
||||
fontWeight="bold"
|
||||
color={feature.accentColor}
|
||||
style={{ lineHeight: 1.2, textAlign: feature.screenshot ? "left" : "center" }}
|
||||
/>
|
||||
<AnimatedText
|
||||
text={feature.tagline}
|
||||
delay={FRAMES_PER_BEAT}
|
||||
fontSize={feature.screenshot ? 22 : 28}
|
||||
fontWeight={400}
|
||||
color={COLORS.textSecondary}
|
||||
style={{ lineHeight: 1.5, textAlign: feature.screenshot ? "left" : "center" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Browser frame with screenshot */}
|
||||
{feature.screenshot && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<BrowserFrame accentColor={feature.accentColor}>
|
||||
<Img
|
||||
src={staticFile(`screenshots/${feature.screenshot}`)}
|
||||
style={{ width: "100%", display: "block" }}
|
||||
/>
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
201
my-video/src/components/OutroCard.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
import { COLORS, FRAMES_PER_BEAT, OUTRO_DURATION } from "../constants";
|
||||
|
||||
const { fontFamily } = loadFont();
|
||||
|
||||
const STATS = [
|
||||
{ number: "4", label: "Features" },
|
||||
{ number: "45", label: "Enhancements" },
|
||||
{ number: "32", label: "Bugfixes" },
|
||||
];
|
||||
|
||||
type StatCardProps = {
|
||||
number: string;
|
||||
label: string;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
function StatCard({ number, label, delay }: StatCardProps) {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const progress = spring({
|
||||
frame: frame - delay,
|
||||
fps,
|
||||
config: {
|
||||
damping: 15,
|
||||
stiffness: 120,
|
||||
},
|
||||
});
|
||||
|
||||
const translateY = interpolate(progress, [0, 1], [40, 0]);
|
||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 80,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.accentGold,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: COLORS.textSecondary,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutroCard() {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const statsEndDelay = STATS.length * FRAMES_PER_BEAT * 2;
|
||||
|
||||
// CTA appears after stats
|
||||
const ctaProgress = spring({
|
||||
frame: frame - statsEndDelay,
|
||||
fps,
|
||||
config: {
|
||||
damping: 20,
|
||||
stiffness: 150,
|
||||
},
|
||||
});
|
||||
|
||||
const ctaOpacity = interpolate(ctaProgress, [0, 1], [0, 1]);
|
||||
const ctaTranslateY = interpolate(ctaProgress, [0, 1], [20, 0]);
|
||||
|
||||
// Fade out in last 1 second (30 frames)
|
||||
const fadeOutOpacity = interpolate(
|
||||
frame,
|
||||
[OUTRO_DURATION - 30, OUTRO_DURATION],
|
||||
[1, 0],
|
||||
{
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at center, ${COLORS.bgGradient} 0%, ${COLORS.bgDark} 100%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 48,
|
||||
fontFamily,
|
||||
opacity: fadeOutOpacity,
|
||||
}}
|
||||
>
|
||||
{/* Background glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 700,
|
||||
height: 700,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse at center, ${COLORS.accentGold}10 0%, transparent 70%)`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 80,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{STATS.map((stat, i) => (
|
||||
<StatCard
|
||||
key={stat.label}
|
||||
number={stat.number}
|
||||
label={stat.label}
|
||||
delay={i * FRAMES_PER_BEAT * 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
opacity: ctaOpacity,
|
||||
transform: `translateY(${ctaTranslateY}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.white,
|
||||
}}
|
||||
>
|
||||
Update now
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: COLORS.accentCyan,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
actualbudget.org
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo at bottom */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 40,
|
||||
opacity: 0.3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile("logo.svg")}
|
||||
style={{ width: 36, height: 36 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Actual Budget
|
||||
</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
124
my-video/src/components/TitleCard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
import { COLORS } from "../constants";
|
||||
|
||||
const { fontFamily } = loadFont();
|
||||
|
||||
export function TitleCard() {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Logo springs in with heavy config
|
||||
const logoProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: {
|
||||
damping: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const logoScale = interpolate(logoProgress, [0, 1], [0.5, 1]);
|
||||
const logoOpacity = interpolate(logoProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Version badge fades in at 1-2s (30-60 frames)
|
||||
const badgeOpacity = interpolate(frame, [30, 60], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// "Here's what's new" subtitle springs in at 2s (frame 60)
|
||||
const subtitleProgress = spring({
|
||||
frame: frame - 60,
|
||||
fps,
|
||||
config: {
|
||||
damping: 200,
|
||||
stiffness: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const subtitleTranslateY = interpolate(subtitleProgress, [0, 1], [20, 0]);
|
||||
const subtitleOpacity = interpolate(subtitleProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at center, ${COLORS.bgGradient} 0%, ${COLORS.bgDark} 100%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
{/* Radial glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 600,
|
||||
height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse at center, ${COLORS.accentPurple}22 0%, transparent 70%)`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo + title */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
transform: `scale(${logoScale})`,
|
||||
opacity: logoOpacity,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile("logo.svg")}
|
||||
style={{ width: 120, height: 120 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 64,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.white,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
Actual Budget
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version badge */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 20,
|
||||
opacity: badgeOpacity,
|
||||
background: COLORS.accentPurple,
|
||||
color: COLORS.white,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
padding: "6px 20px",
|
||||
borderRadius: 999,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
v26.4.0
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 32,
|
||||
fontSize: 32,
|
||||
color: COLORS.textSecondary,
|
||||
fontWeight: 400,
|
||||
opacity: subtitleOpacity,
|
||||
transform: `translateY(${subtitleTranslateY}px)`,
|
||||
}}
|
||||
>
|
||||
{"Here's what's new"}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
73
my-video/src/constants.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export const FPS = 30;
|
||||
export const WIDTH = 1280;
|
||||
export const HEIGHT = 720;
|
||||
|
||||
export const BPM = 139;
|
||||
export const FRAMES_PER_BEAT = Math.round((60 / BPM) * FPS); // ~13
|
||||
|
||||
export const TITLE_DURATION = 120;
|
||||
export const TIER1_SCENE_DURATION = 160;
|
||||
export const TIER2_SCENE_DURATION = 180;
|
||||
export const OUTRO_DURATION = 240;
|
||||
export const TRANSITION_DURATION = FRAMES_PER_BEAT;
|
||||
|
||||
export const COLORS = {
|
||||
bgDark: "#1a1a2e",
|
||||
bgGradient: "#16213e",
|
||||
accentCyan: "#00d2ff",
|
||||
accentCoral: "#e94560",
|
||||
accentGold: "#ffd700",
|
||||
accentPurple: "#7c3aed",
|
||||
white: "#ffffff",
|
||||
textSecondary: "#94a3b8",
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
screenshot?: string;
|
||||
accentColor: string;
|
||||
};
|
||||
|
||||
export const TIER1_FEATURES: Feature[] = [
|
||||
{
|
||||
title: "Donut Chart Reports",
|
||||
tagline: "Beautiful category breakdowns",
|
||||
screenshot: "donut-chart.png",
|
||||
accentColor: COLORS.accentCyan,
|
||||
},
|
||||
{
|
||||
title: "Budget Notes",
|
||||
tagline: "Monthly per-category notes",
|
||||
screenshot: "budget-notes.png",
|
||||
accentColor: COLORS.accentCyan,
|
||||
},
|
||||
];
|
||||
|
||||
export const TIER2_FEATURES: Feature[] = [
|
||||
{
|
||||
title: "Drag & Drop Reordering",
|
||||
tagline: "Reorder transactions — your way",
|
||||
screenshot: "drag-drop.png",
|
||||
accentColor: COLORS.accentCyan,
|
||||
},
|
||||
{
|
||||
title: "Actual CLI",
|
||||
tagline: "Your budget, from the command line",
|
||||
screenshot: "cli-tool.png",
|
||||
accentColor: COLORS.accentCyan,
|
||||
},
|
||||
{
|
||||
title: "And much more...",
|
||||
tagline: "Thanks to all the contributors",
|
||||
accentColor: COLORS.accentCoral,
|
||||
},
|
||||
];
|
||||
|
||||
// Total = TITLE + 3*TIER1 + 4*TIER2 + OUTRO - 8*TRANSITION
|
||||
export const TOTAL_DURATION =
|
||||
TITLE_DURATION +
|
||||
TIER1_FEATURES.length * TIER1_SCENE_DURATION +
|
||||
TIER2_FEATURES.length * TIER2_SCENE_DURATION +
|
||||
OUTRO_DURATION -
|
||||
(TIER1_FEATURES.length + TIER2_FEATURES.length + 1) * TRANSITION_DURATION;
|
||||
1
my-video/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
4
my-video/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
15
my-video/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "commonjs",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"lib": ["es2015"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"exclude": ["remotion.config.ts"]
|
||||
}
|
||||