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>
This commit is contained in:
Trey t
2026-01-25 12:47:06 -06:00
parent de994d4d52
commit bf2555f360
39 changed files with 5641 additions and 0 deletions

70
CLAUDE.md Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
{
"useTabs": false,
"bracketSpacing": true,
"tabWidth": 2
}

54
feels-promo/README.md Normal file
View 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).

View File

@@ -0,0 +1,3 @@
import { config } from "@remotion/eslint-config-flat";
export default config;

4136
feels-promo/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
feels-promo/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
feels-promo/public/b.mov Normal file

Binary file not shown.

BIN
feels-promo/public/c.mov Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

View 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);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

BIN
screens/b.mov Normal file

Binary file not shown.

BIN
screens/c.mov Normal file

Binary file not shown.

BIN
screens/flow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

BIN
screens/phone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB