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 |