Add Remotion promo video project with 7-scene App Store flow
- Create feels-promo Remotion project for promotional videos - Implement FeelsPromoV1 with scenes matching App Store screenshots: - Hero scene with mood tracking - Widget + Apple Watch scene - Journal notes with photos - AI-powered insights with badge - Privacy & security features - Theme customization - Notification styles - Add screens folder with source assets and flow reference - Include phone frames, widget, and watch frame assets Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
70
CLAUDE.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Feels - Claude Code Context
|
||||||
|
|
||||||
|
## Project Summary
|
||||||
|
|
||||||
|
Feels is an iOS mood tracking app. Users rate their day on a 5-point scale (Horrible → Great) and view patterns via Day, Month, and Year views.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Pattern**: MVVM with SwiftUI
|
||||||
|
- **Data**: Core Data with CloudKit sync
|
||||||
|
- **Monetization**: StoreKit 2 subscriptions (30-day trial, monthly/yearly plans)
|
||||||
|
|
||||||
|
## Key Directories
|
||||||
|
|
||||||
|
```
|
||||||
|
Shared/ # Core app code (Models, Views, Persistence)
|
||||||
|
FeelsWidget2/ # Widget extension
|
||||||
|
Feels Watch App/ # watchOS companion
|
||||||
|
docs/ # ASO and competitive analysis
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Layer
|
||||||
|
|
||||||
|
Core Data operations are split across files in `Shared/Persistence/`:
|
||||||
|
- `Persistence.swift` - Core Data stack setup
|
||||||
|
- `PersistenceGET.swift` - Fetch operations
|
||||||
|
- `PersistenceADD.swift` - Create entries
|
||||||
|
- `PersistenceUPDATE.swift` - Update operations
|
||||||
|
- `PersistenceDELETE.swift` - Delete operations
|
||||||
|
|
||||||
|
## App Groups
|
||||||
|
|
||||||
|
- **Production**: `group.com.88oakapps.ifeel`
|
||||||
|
- **Debug**: `group.com.88oakapps.ifeelDebug`
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the app
|
||||||
|
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mood Values
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum Mood: Int {
|
||||||
|
case horrible = 0
|
||||||
|
case bad = 1
|
||||||
|
case average = 2
|
||||||
|
case good = 3
|
||||||
|
case great = 4
|
||||||
|
case missing = 5 // Unfilled day
|
||||||
|
case placeholder = 6 // System-generated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
- English: `en.lproj/Localizable.strings`
|
||||||
|
- Spanish: `es.lproj/Localizable.strings`
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
1. **Widgets** update via `WidgetCenter.shared.reloadAllTimelines()`
|
||||||
|
2. **Missing dates** are auto-filled by background task (`BGTask.swift`)
|
||||||
|
3. **Entry types** distinguish user entries from system-generated ones
|
||||||
|
4. **Customization** uses protocols: `Themeable`, `MoodTintable`, `MoodImagable`, `PersonalityPackable`
|
||||||
7
feels-promo/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Ignore the output video from Git but not videos you import into src/.
|
||||||
|
out
|
||||||
5
feels-promo/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
54
feels-promo/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Remotion video
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/remotion-dev/logo">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-dark.apng">
|
||||||
|
<img alt="Animated Remotion Logo" src="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-light.gif">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Welcome to your Remotion project!
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
**Install Dependencies**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start Preview**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Render video**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npx remotion render
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upgrade Remotion**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npx remotion upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals).
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
We provide help on our [Discord server](https://discord.gg/6VzzNDwUwV).
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
|
||||||
3
feels-promo/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { config } from "@remotion/eslint-config-flat";
|
||||||
|
|
||||||
|
export default config;
|
||||||
4136
feels-promo/package-lock.json
generated
Normal file
32
feels-promo/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "template-helloworld",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "My Remotion video",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "remotion studio",
|
||||||
|
"build": "remotion bundle",
|
||||||
|
"upgrade": "remotion upgrade",
|
||||||
|
"lint": "eslint src && tsc"
|
||||||
|
},
|
||||||
|
"repository": {},
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"@remotion/cli": "^4.0.0",
|
||||||
|
"@remotion/media": "4.0.409",
|
||||||
|
"@remotion/transitions": "4.0.409",
|
||||||
|
"@remotion/zod-types": "^4.0.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"remotion": "^4.0.0",
|
||||||
|
"zod": "3.22.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@remotion/eslint-config-flat": "^4.0.0",
|
||||||
|
"@types/react": "19.2.7",
|
||||||
|
"@types/web": "0.0.166",
|
||||||
|
"eslint": "9.19.0",
|
||||||
|
"prettier": "3.6.0",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
BIN
feels-promo/public/a.png
Normal file
|
After Width: | Height: | Size: 806 KiB |
BIN
feels-promo/public/app-icon.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
feels-promo/public/b.mov
Normal file
BIN
feels-promo/public/c.mov
Normal file
BIN
feels-promo/public/phone-orange.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
feels-promo/public/phone.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
feels-promo/public/screen1-day.png
Normal file
|
After Width: | Height: | Size: 776 KiB |
BIN
feels-promo/public/screen2-watch.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
feels-promo/public/screen2-widget.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
feels-promo/public/screen3-journal.png
Normal file
|
After Width: | Height: | Size: 873 KiB |
BIN
feels-promo/public/screen4-insights.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
feels-promo/public/screen5-privacy.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
feels-promo/public/screen6-themes.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
feels-promo/public/screen7-notifications.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
feels-promo/public/watch-frame.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
9
feels-promo/remotion.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// See all configuration options: https://remotion.dev/docs/config
|
||||||
|
// Each option also is available as a CLI flag: https://remotion.dev/docs/cli
|
||||||
|
|
||||||
|
// Note: When using the Node.JS APIs, the config file doesn't apply. Instead, pass options directly to the APIs
|
||||||
|
|
||||||
|
import { Config } from "@remotion/cli/config";
|
||||||
|
|
||||||
|
Config.setVideoImageFormat("jpeg");
|
||||||
|
Config.setOverwriteOutput(true);
|
||||||
933
feels-promo/src/FeelsPromo.tsx
Normal file
@@ -0,0 +1,933 @@
|
|||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
Img,
|
||||||
|
staticFile,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
} from "remotion";
|
||||||
|
import {
|
||||||
|
TransitionSeries,
|
||||||
|
linearTiming,
|
||||||
|
} from "@remotion/transitions";
|
||||||
|
import { fade } from "@remotion/transitions/fade";
|
||||||
|
|
||||||
|
// Tiled App Icon Background Component
|
||||||
|
const TiledIconBackground: React.FC<{ color?: string }> = ({
|
||||||
|
color = "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||||
|
}) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { width, height } = useVideoConfig();
|
||||||
|
|
||||||
|
const iconSize = 80;
|
||||||
|
const gap = 40;
|
||||||
|
const cellSize = iconSize + gap;
|
||||||
|
|
||||||
|
const cols = Math.ceil(width / cellSize) + 4;
|
||||||
|
const rows = Math.ceil(height / cellSize) + 4;
|
||||||
|
|
||||||
|
const offsetX = (frame * 0.3) % cellSize;
|
||||||
|
const offsetY = (frame * 0.2) % cellSize;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
background: color,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -cellSize * 2,
|
||||||
|
left: -cellSize * 2,
|
||||||
|
transform: `translate(${offsetX}px, ${offsetY}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[...Array(rows)].map((_, row) =>
|
||||||
|
[...Array(cols)].map((_, col) => {
|
||||||
|
const staggerX = row % 2 === 0 ? 0 : cellSize / 2;
|
||||||
|
return (
|
||||||
|
<Img
|
||||||
|
key={`${row}-${col}`}
|
||||||
|
src={staticFile("app-icon.png")}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: iconSize,
|
||||||
|
height: iconSize,
|
||||||
|
left: col * cellSize + staggerX,
|
||||||
|
top: row * cellSize,
|
||||||
|
opacity: 0.08,
|
||||||
|
borderRadius: iconSize * 0.22,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable Phone Component
|
||||||
|
const PhoneFrame: React.FC<{
|
||||||
|
mediaSrc: string;
|
||||||
|
width?: number;
|
||||||
|
rotation?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}> = ({ mediaSrc, width = 460, rotation = 0, style }) => {
|
||||||
|
const aspectRatio = 2760 / 1350;
|
||||||
|
const height = width * aspectRatio;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
transform: rotation ? `rotate(${rotation}deg)` : undefined,
|
||||||
|
filter: "drop-shadow(0 40px 80px rgba(0,0,0,0.5))",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "2.5%",
|
||||||
|
left: "5.33%",
|
||||||
|
right: "5.33%",
|
||||||
|
bottom: "2.5%",
|
||||||
|
borderRadius: width * 0.083,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img
|
||||||
|
src={staticFile(mediaSrc)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Img
|
||||||
|
src={staticFile("phone.png")}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scene 1: Hero - Your mood. Your journey. Your way.
|
||||||
|
const HeroScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||||
|
const titleY = interpolate(titleProgress, [0, 1], [80, 0]);
|
||||||
|
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
const phoneProgress = spring({
|
||||||
|
frame: frame - 8,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||||
|
const phoneX = interpolate(phoneProgress, [0, 1], [100, 0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<TiledIconBackground color="linear-gradient(180deg, #1a472a 0%, #2d5a3d 100%)" />
|
||||||
|
|
||||||
|
{/* Large title - top left */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 80,
|
||||||
|
left: 50,
|
||||||
|
right: 50,
|
||||||
|
zIndex: 10,
|
||||||
|
transform: `translateY(${-titleY}px)`,
|
||||||
|
opacity: titleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your mood.
|
||||||
|
<br />
|
||||||
|
Your journey.
|
||||||
|
<br />
|
||||||
|
Your way.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone - large, positioned right-center */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: -100,
|
||||||
|
bottom: -200,
|
||||||
|
transform: `scale(${phoneScale}) translateX(${phoneX}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhoneFrame mediaSrc="screen1-day.png" width={680} rotation={-5} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scene 2: Tap. Logged. Done.
|
||||||
|
const WidgetWatchScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||||
|
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||||
|
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
const widgetProgress = spring({
|
||||||
|
frame: frame - 5,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const widgetScale = interpolate(widgetProgress, [0, 1], [0.7, 1]);
|
||||||
|
|
||||||
|
const watchProgress = spring({
|
||||||
|
frame: frame - 12,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const watchScale = interpolate(watchProgress, [0, 1], [0.7, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<TiledIconBackground color="linear-gradient(180deg, #c4a000 0%, #d4b400 100%)" />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
left: 60,
|
||||||
|
right: 60,
|
||||||
|
zIndex: 10,
|
||||||
|
transform: `translateY(${-titleY}px)`,
|
||||||
|
opacity: titleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tap.
|
||||||
|
<br />
|
||||||
|
Logged.
|
||||||
|
<br />
|
||||||
|
Done.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Never miss a day
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widget - large, center-left */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 60,
|
||||||
|
bottom: 180,
|
||||||
|
transform: `scale(${widgetScale})`,
|
||||||
|
filter: "drop-shadow(0 30px 60px rgba(0,0,0,0.4))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img
|
||||||
|
src={staticFile("screen2-widget.png")}
|
||||||
|
style={{
|
||||||
|
width: 580,
|
||||||
|
height: 580,
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 24,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
One-tap widgets
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Watch - right side */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 80,
|
||||||
|
bottom: 120,
|
||||||
|
transform: `scale(${watchScale})`,
|
||||||
|
filter: "drop-shadow(0 30px 60px rgba(0,0,0,0.4))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "22%",
|
||||||
|
left: "15%",
|
||||||
|
width: "70%",
|
||||||
|
height: "42%",
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img
|
||||||
|
src={staticFile("screen2-watch.png")}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Img
|
||||||
|
src={staticFile("watch-frame.png")}
|
||||||
|
style={{
|
||||||
|
width: 360,
|
||||||
|
height: 576,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 24,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Wrist ready
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scene 3: Reflect & Record
|
||||||
|
const JournalScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||||
|
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||||
|
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
const phoneProgress = spring({
|
||||||
|
frame: frame - 8,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<TiledIconBackground color="linear-gradient(180deg, #3d7a4a 0%, #4a8f5a 100%)" />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
left: 60,
|
||||||
|
right: 60,
|
||||||
|
zIndex: 10,
|
||||||
|
transform: `translateY(${-titleY}px)`,
|
||||||
|
opacity: titleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reflect & Record
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add notes & photos to remember why
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone - large, centered, tilted */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
bottom: -180,
|
||||||
|
transform: `translateX(-50%) scale(${phoneScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhoneFrame mediaSrc="screen3-journal.png" width={660} rotation={8} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scene 4: Beautiful Insights
|
||||||
|
const InsightsScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||||
|
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||||
|
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
const phoneProgress = spring({
|
||||||
|
frame: frame - 8,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||||
|
|
||||||
|
const badgeProgress = spring({
|
||||||
|
frame: frame - 20,
|
||||||
|
fps,
|
||||||
|
config: { damping: 15, stiffness: 100 },
|
||||||
|
});
|
||||||
|
const badgeScale = interpolate(badgeProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<TiledIconBackground color="linear-gradient(180deg, #2563eb 0%, #3b82f6 100%)" />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: "center",
|
||||||
|
zIndex: 10,
|
||||||
|
transform: `translateY(${-titleY}px)`,
|
||||||
|
opacity: titleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Beautiful
|
||||||
|
<br />
|
||||||
|
Insights
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone - centered, much larger */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
bottom: -200,
|
||||||
|
transform: `translateX(-50%) scale(${phoneScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhoneFrame mediaSrc="screen4-insights.png" width={660} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apple AI Badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 80,
|
||||||
|
left: "50%",
|
||||||
|
transform: `translateX(-50%) scale(${badgeScale})`,
|
||||||
|
background: "rgba(255,255,255,0.95)",
|
||||||
|
borderRadius: 50,
|
||||||
|
padding: "20px 40px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
boxShadow: "0 10px 40px rgba(0,0,0,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 32 }}>✨</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Powered by Apple AI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scene 5: Private & Secure
|
||||||
|
const PrivacyScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||||
|
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||||
|
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
const phoneProgress = spring({
|
||||||
|
frame: frame - 8,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||||
|
|
||||||
|
const shieldProgress = spring({
|
||||||
|
frame: frame - 15,
|
||||||
|
fps,
|
||||||
|
config: { damping: 10, stiffness: 100 },
|
||||||
|
});
|
||||||
|
const shieldScale = interpolate(shieldProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<TiledIconBackground color="linear-gradient(180deg, #059669 0%, #10b981 100%)" />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: "center",
|
||||||
|
zIndex: 10,
|
||||||
|
transform: `translateY(${-titleY}px)`,
|
||||||
|
opacity: titleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Private
|
||||||
|
<br />
|
||||||
|
& Secure
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Syncs with Apple Health • Locked to you
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shield icon */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 500,
|
||||||
|
left: 100,
|
||||||
|
transform: `scale(${shieldScale})`,
|
||||||
|
fontSize: 160,
|
||||||
|
filter: "drop-shadow(0 10px 30px rgba(0,0,0,0.3))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🛡️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone - right side, much larger */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: -120,
|
||||||
|
bottom: -200,
|
||||||
|
transform: `scale(${phoneScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhoneFrame mediaSrc="screen5-privacy.png" width={680} rotation={-8} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scene 6: Complete Customization
|
||||||
|
const ThemesScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||||
|
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||||
|
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
const phoneProgress = spring({
|
||||||
|
frame: frame - 8,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<TiledIconBackground color="linear-gradient(180deg, #7c3aed 0%, #8b5cf6 100%)" />
|
||||||
|
|
||||||
|
{/* Title - top right aligned */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
right: 60,
|
||||||
|
textAlign: "right",
|
||||||
|
zIndex: 10,
|
||||||
|
transform: `translateY(${-titleY}px)`,
|
||||||
|
opacity: titleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 72,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Complete
|
||||||
|
<br />
|
||||||
|
Customization
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 56,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "rgba(255,255,255,0.95)",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your Style
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(255,255,255,0.85)",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
12 Thoughtful Themes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone - left side, much larger */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: -120,
|
||||||
|
bottom: -200,
|
||||||
|
transform: `scale(${phoneScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhoneFrame mediaSrc="screen6-themes.png" width={680} rotation={5} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scene 7: Guidance that gets you
|
||||||
|
const NotificationsScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||||
|
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||||
|
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
const phoneProgress = spring({
|
||||||
|
frame: frame - 8,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 80 },
|
||||||
|
});
|
||||||
|
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<TiledIconBackground color="linear-gradient(180deg, #0891b2 0%, #06b6d4 100%)" />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
left: 60,
|
||||||
|
right: 60,
|
||||||
|
zIndex: 10,
|
||||||
|
transform: `translateY(${-titleY}px)`,
|
||||||
|
opacity: titleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Guidance that
|
||||||
|
<br />
|
||||||
|
gets you
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone - large, right-center */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: -80,
|
||||||
|
bottom: -200,
|
||||||
|
transform: `scale(${phoneScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhoneFrame mediaSrc="screen7-notifications.png" width={680} rotation={-3} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Outro Scene
|
||||||
|
const OutroScene: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const logoScale = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 10, stiffness: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const textOpacity = spring({
|
||||||
|
frame: frame - 15,
|
||||||
|
fps,
|
||||||
|
config: { damping: 200 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const glowIntensity = interpolate(
|
||||||
|
Math.sin(frame * 0.1),
|
||||||
|
[-1, 1],
|
||||||
|
[0.3, 0.6]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TiledIconBackground />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: 500,
|
||||||
|
height: 500,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: `radial-gradient(circle, rgba(255,255,255,${glowIntensity}) 0%, transparent 70%)`,
|
||||||
|
transform: `scale(${interpolate(logoScale, [0, 1], [0.3, 1])})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
transform: `scale(${interpolate(logoScale, [0, 1], [0.5, 1])})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img
|
||||||
|
src={staticFile("app-icon.png")}
|
||||||
|
style={{
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
borderRadius: 240 * 0.22,
|
||||||
|
marginBottom: 40,
|
||||||
|
filter: `drop-shadow(0 0 60px rgba(255,255,255,${glowIntensity}))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 100,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||||
|
letterSpacing: "-3px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Feels
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
marginTop: 24,
|
||||||
|
opacity: interpolate(textOpacity, [0, 1], [0, 1]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Track your mood. Understand yourself.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FeelsPromoV1: React.FC = () => {
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const sceneDuration = 3.5 * fps;
|
||||||
|
const transitionDuration = Math.round(0.6 * fps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransitionSeries>
|
||||||
|
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||||
|
<HeroScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
|
||||||
|
<TransitionSeries.Transition
|
||||||
|
presentation={fade()}
|
||||||
|
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||||
|
<WidgetWatchScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
|
||||||
|
<TransitionSeries.Transition
|
||||||
|
presentation={fade()}
|
||||||
|
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||||
|
<JournalScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
|
||||||
|
<TransitionSeries.Transition
|
||||||
|
presentation={fade()}
|
||||||
|
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||||
|
<InsightsScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
|
||||||
|
<TransitionSeries.Transition
|
||||||
|
presentation={fade()}
|
||||||
|
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||||
|
<PrivacyScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
|
||||||
|
<TransitionSeries.Transition
|
||||||
|
presentation={fade()}
|
||||||
|
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||||
|
<ThemesScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
|
||||||
|
<TransitionSeries.Transition
|
||||||
|
presentation={fade()}
|
||||||
|
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||||
|
<NotificationsScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
|
||||||
|
<TransitionSeries.Transition
|
||||||
|
presentation={fade()}
|
||||||
|
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransitionSeries.Sequence durationInFrames={Math.round(2.5 * fps)}>
|
||||||
|
<OutroScene />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
</TransitionSeries>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
feels-promo/src/HelloWorld.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { spring } from "remotion";
|
||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
interpolate,
|
||||||
|
Sequence,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
} from "remotion";
|
||||||
|
import { Logo } from "./HelloWorld/Logo";
|
||||||
|
import { Subtitle } from "./HelloWorld/Subtitle";
|
||||||
|
import { Title } from "./HelloWorld/Title";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zColor } from "@remotion/zod-types";
|
||||||
|
|
||||||
|
export const myCompSchema = z.object({
|
||||||
|
titleText: z.string(),
|
||||||
|
titleColor: zColor(),
|
||||||
|
logoColor1: zColor(),
|
||||||
|
logoColor2: zColor(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HelloWorld: React.FC<z.infer<typeof myCompSchema>> = ({
|
||||||
|
titleText: propOne,
|
||||||
|
titleColor: propTwo,
|
||||||
|
logoColor1,
|
||||||
|
logoColor2,
|
||||||
|
}) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { durationInFrames, fps } = useVideoConfig();
|
||||||
|
|
||||||
|
// Animate from 0 to 1 after 25 frames
|
||||||
|
const logoTranslationProgress = spring({
|
||||||
|
frame: frame - 25,
|
||||||
|
fps,
|
||||||
|
config: {
|
||||||
|
damping: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move the logo up by 150 pixels once the transition starts
|
||||||
|
const logoTranslation = interpolate(
|
||||||
|
logoTranslationProgress,
|
||||||
|
[0, 1],
|
||||||
|
[0, -150],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fade out the animation at the end
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[durationInFrames - 25, durationInFrames - 15],
|
||||||
|
[1, 0],
|
||||||
|
{
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// A <AbsoluteFill> is just a absolutely positioned <div>!
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "white" }}>
|
||||||
|
<AbsoluteFill style={{ opacity }}>
|
||||||
|
<AbsoluteFill style={{ transform: `translateY(${logoTranslation}px)` }}>
|
||||||
|
<Logo logoColor1={logoColor1} logoColor2={logoColor2} />
|
||||||
|
</AbsoluteFill>
|
||||||
|
{/* Sequences can shift the time for its children! */}
|
||||||
|
<Sequence from={35}>
|
||||||
|
<Title titleText={propOne} titleColor={propTwo} />
|
||||||
|
</Sequence>
|
||||||
|
{/* The subtitle will only enter on the 75th frame. */}
|
||||||
|
<Sequence from={75}>
|
||||||
|
<Subtitle />
|
||||||
|
</Sequence>
|
||||||
|
</AbsoluteFill>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
feels-promo/src/HelloWorld/Arc.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { random, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
const getCircumferenceOfArc = (rx: number, ry: number) => {
|
||||||
|
return Math.PI * 2 * Math.sqrt((rx * rx + ry * ry) / 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rx = 135;
|
||||||
|
const ry = 300;
|
||||||
|
const cx = 960;
|
||||||
|
const cy = 540;
|
||||||
|
const arcLength = getCircumferenceOfArc(rx, ry);
|
||||||
|
const strokeWidth = 30;
|
||||||
|
|
||||||
|
export const Arc: React.FC<{
|
||||||
|
progress: number;
|
||||||
|
rotation: number;
|
||||||
|
rotateProgress: number;
|
||||||
|
color1: string;
|
||||||
|
color2: string;
|
||||||
|
}> = ({ progress, rotation, rotateProgress, color1, color2 }) => {
|
||||||
|
const { width, height } = useVideoConfig();
|
||||||
|
|
||||||
|
// Each svg Id must be unique to not conflict with each other
|
||||||
|
const [gradientId] = useState(() => String(random(null)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
transform: `rotate(${rotation * rotateProgress}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={color1} />
|
||||||
|
<stop offset="100%" stopColor={color2} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<ellipse
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
rx={rx}
|
||||||
|
ry={ry}
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${gradientId})`}
|
||||||
|
strokeDasharray={arcLength}
|
||||||
|
strokeDashoffset={arcLength - arcLength * progress}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
feels-promo/src/HelloWorld/Atom.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { random, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
export const Atom: React.FC<{
|
||||||
|
scale: number;
|
||||||
|
color1: string;
|
||||||
|
color2: string;
|
||||||
|
}> = ({ scale, color1, color2 }) => {
|
||||||
|
const config = useVideoConfig();
|
||||||
|
|
||||||
|
// Each SVG ID must be unique to not conflict with each other
|
||||||
|
const [gradientId] = useState(() => String(random(null)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${config.width} ${config.height}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor={color1} />
|
||||||
|
<stop offset="100%" stopColor={color2} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle
|
||||||
|
r={70}
|
||||||
|
cx={config.width / 2}
|
||||||
|
cy={config.height / 2}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
87
feels-promo/src/HelloWorld/Logo.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
} from "remotion";
|
||||||
|
import { Arc } from "./Arc";
|
||||||
|
import { Atom } from "./Atom";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zColor } from "@remotion/zod-types";
|
||||||
|
|
||||||
|
export const myCompSchema2 = z.object({
|
||||||
|
logoColor1: zColor(),
|
||||||
|
logoColor2: zColor(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Logo: React.FC<z.infer<typeof myCompSchema2>> = ({
|
||||||
|
logoColor1: color1,
|
||||||
|
logoColor2: color2,
|
||||||
|
}) => {
|
||||||
|
const videoConfig = useVideoConfig();
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const development = spring({
|
||||||
|
config: {
|
||||||
|
damping: 100,
|
||||||
|
mass: 0.5,
|
||||||
|
},
|
||||||
|
fps: videoConfig.fps,
|
||||||
|
frame,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotationDevelopment = spring({
|
||||||
|
config: {
|
||||||
|
damping: 100,
|
||||||
|
mass: 0.5,
|
||||||
|
},
|
||||||
|
fps: videoConfig.fps,
|
||||||
|
frame,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = spring({
|
||||||
|
frame,
|
||||||
|
config: {
|
||||||
|
mass: 0.5,
|
||||||
|
},
|
||||||
|
fps: videoConfig.fps,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoRotation = interpolate(
|
||||||
|
frame,
|
||||||
|
[0, videoConfig.durationInFrames],
|
||||||
|
[0, 360],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
transform: `scale(${scale}) rotate(${logoRotation}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Arc
|
||||||
|
rotateProgress={rotationDevelopment}
|
||||||
|
progress={development}
|
||||||
|
rotation={30}
|
||||||
|
color1={color1}
|
||||||
|
color2={color2}
|
||||||
|
/>
|
||||||
|
<Arc
|
||||||
|
rotateProgress={rotationDevelopment}
|
||||||
|
rotation={90}
|
||||||
|
progress={development}
|
||||||
|
color1={color1}
|
||||||
|
color2={color2}
|
||||||
|
/>
|
||||||
|
<Arc
|
||||||
|
rotateProgress={rotationDevelopment}
|
||||||
|
rotation={-30}
|
||||||
|
progress={development}
|
||||||
|
color1={color1}
|
||||||
|
color2={color2}
|
||||||
|
/>
|
||||||
|
<Atom scale={rotationDevelopment} color1={color1} color2={color2} />
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
feels-promo/src/HelloWorld/Subtitle.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { interpolate, useCurrentFrame } from "remotion";
|
||||||
|
import { COLOR_1, FONT_FAMILY } from "./constants";
|
||||||
|
|
||||||
|
const subtitle: React.CSSProperties = {
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
fontSize: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 140,
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeStyle: React.CSSProperties = {
|
||||||
|
color: COLOR_1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Subtitle: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const opacity = interpolate(frame, [0, 30], [0, 1]);
|
||||||
|
return (
|
||||||
|
<div style={{ ...subtitle, opacity }}>
|
||||||
|
Edit <code style={codeStyle}>src/Root.tsx</code> and save to reload.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
58
feels-promo/src/HelloWorld/Title.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { FONT_FAMILY } from "./constants";
|
||||||
|
|
||||||
|
const title: React.CSSProperties = {
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: 100,
|
||||||
|
textAlign: "center",
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 160,
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
const word: React.CSSProperties = {
|
||||||
|
marginLeft: 10,
|
||||||
|
marginRight: 10,
|
||||||
|
display: "inline-block",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Title: React.FC<{
|
||||||
|
readonly titleText: string;
|
||||||
|
readonly titleColor: string;
|
||||||
|
}> = ({ titleText, titleColor }) => {
|
||||||
|
const videoConfig = useVideoConfig();
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const words = titleText.split(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h1 style={title}>
|
||||||
|
{words.map((t, i) => {
|
||||||
|
const delay = i * 5;
|
||||||
|
|
||||||
|
const scale = spring({
|
||||||
|
fps: videoConfig.fps,
|
||||||
|
frame: frame - delay,
|
||||||
|
config: {
|
||||||
|
damping: 200,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
style={{
|
||||||
|
...word,
|
||||||
|
color: titleColor,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
feels-promo/src/HelloWorld/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Change any of these to update your video live.
|
||||||
|
|
||||||
|
export const COLOR_1 = "#86A8E7";
|
||||||
|
|
||||||
|
export const FONT_FAMILY = "SF Pro Text, Helvetica, Arial, sans-serif";
|
||||||
27
feels-promo/src/Root.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Composition } from "remotion";
|
||||||
|
import { FeelsPromoV1 } from "./FeelsPromo";
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
const fps = 30;
|
||||||
|
const sceneDuration = 3.5 * fps; // 3.5 seconds per scene
|
||||||
|
const transitionDuration = Math.round(0.6 * fps); // 0.6 second transitions
|
||||||
|
const outroDuration = Math.round(2.5 * fps);
|
||||||
|
|
||||||
|
// Calculate total duration accounting for transition overlaps
|
||||||
|
// 7 scenes + outro - 7 transitions
|
||||||
|
const totalDuration =
|
||||||
|
sceneDuration * 7 + outroDuration - transitionDuration * 7;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Composition
|
||||||
|
id="FeelsPromoV1"
|
||||||
|
component={FeelsPromoV1}
|
||||||
|
durationInFrames={totalDuration}
|
||||||
|
fps={fps}
|
||||||
|
width={1080}
|
||||||
|
height={1920}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
feels-promo/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// This is your entry file! Refer to it when you render:
|
||||||
|
// npx remotion render <entry-file> HelloWorld out/video.mp4
|
||||||
|
|
||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
15
feels-promo/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"]
|
||||||
|
}
|
||||||
BIN
screens/a.png
Normal file
|
After Width: | Height: | Size: 806 KiB |
BIN
screens/b.mov
Normal file
BIN
screens/c.mov
Normal file
BIN
screens/flow.png
Normal file
|
After Width: | Height: | Size: 757 KiB |
BIN
screens/phone.png
Normal file
|
After Width: | Height: | Size: 521 KiB |