feat: fix travel placement bug, add theme-based alternate icons, fix animated background crash

- Fix repeat-city travel placement: use stop indices instead of global city name
  matching so Follow Team trips with repeat cities show travel correctly
- Add TravelPlacement helper and regression tests (7 tests)
- Add alternate app icons for each theme, auto-switch on theme change
- Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20)
- Add marketing video configs, engine, and new video components
- Add docs and data exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-06 09:36:34 -06:00
parent fdcecafaa3
commit 8e937a5646
77 changed files with 143400 additions and 83 deletions

View File

@@ -28,7 +28,14 @@
"Bash(timeout 10 npx remotion:*)",
"Bash(npx remotion:*)",
"Bash(xcrun xcresulttool:*)",
"Bash(sips:*)"
"Bash(sips:*)",
"Bash(for:*)",
"Bash(do echo -n \"$f: \")",
"Bash(done)",
"Bash(chmod:*)",
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-monochrome.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-sunset.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-midnight.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)"
]
}
}

1
Game-2026-02-02.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -303,6 +303,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTimeDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
@@ -338,6 +339,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements;
CODE_SIGN_STYLE = Automatic;

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 231 KiB

View File

@@ -24,7 +24,7 @@ struct AnimatedSportsBackground: View {
RouteMapLayer(animate: animate)
// Floating sports icons with gentle glow
ForEach(0..<20, id: \.self) { index in
ForEach(0..<AnimatedSportsIcon.configs.count, id: \.self) { index in
AnimatedSportsIcon(index: index, animate: animate)
}
}
@@ -108,7 +108,7 @@ struct AnimatedSportsIcon: View {
let animate: Bool
@State private var glowOpacity: Double = 0
private let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
static let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
// Edge icons
(0.06, 0.08, "football.fill", -15, 0.85),
(0.94, 0.1, "basketball.fill", 12, 0.8),
@@ -133,7 +133,7 @@ struct AnimatedSportsIcon: View {
]
var body: some View {
let config = configs[index]
let config = Self.configs[index]
GeometryReader { geo in
ZStack {

View File

@@ -34,6 +34,19 @@ enum AppTheme: String, CaseIterable, Identifiable {
}
}
/// The alternate icon name in the asset catalog, or nil for the default (teal).
var alternateIconName: String? {
switch self {
case .teal: return nil
case .orbit: return "AppIcon-orbit"
case .retro: return "AppIcon-retro"
case .clutch: return "AppIcon-clutch"
case .monochrome: return "AppIcon-monochrome"
case .sunset: return "AppIcon-sunset"
case .midnight: return "AppIcon-midnight"
}
}
var previewColors: [Color] {
switch self {
case .teal: return [Color(hex: "4ECDC4"), Color(hex: "1A535C"), Color(hex: "FFE66D")]
@@ -56,9 +69,16 @@ final class ThemeManager {
var currentTheme: AppTheme {
didSet {
UserDefaults.standard.set(currentTheme.rawValue, forKey: "selectedTheme")
updateAppIcon(for: currentTheme)
}
}
private func updateAppIcon(for theme: AppTheme) {
let iconName = theme.alternateIconName
guard UIApplication.shared.alternateIconName != iconName else { return }
UIApplication.shared.setAlternateIconName(iconName)
}
private init() {
if let saved = UserDefaults.standard.string(forKey: "selectedTheme"),
let theme = AppTheme(rawValue: saved) {

View File

@@ -0,0 +1,85 @@
//
// TravelPlacement.swift
// SportsTime
//
// Computes which day number each travel segment should be displayed on.
// Extracted from TripDetailView for testability.
//
import Foundation
enum TravelPlacement {
/// Result of computing travel placement for a trip.
struct Placement {
let day: Int
let segmentIndex: Int
}
/// Computes which day each travel segment belongs to.
///
/// Uses stop indices (not city name matching) so repeat cities work correctly.
/// `trip.travelSegments[i]` connects `trip.stops[i]` to `trip.stops[i+1]`.
///
/// - Parameters:
/// - trip: The trip containing stops and travel segments.
/// - tripDays: Array of dates (one per trip day, start-of-day normalized).
/// - Returns: Dictionary mapping day number (1-based) to TravelSegment.
static func computeTravelByDay(
trip: Trip,
tripDays: [Date]
) -> [Int: TravelSegment] {
var travelByDay: [Int: TravelSegment] = [:]
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let minDay: Int
let maxDay: Int
let defaultDay: Int
if segmentIndex < trip.stops.count - 1 {
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate, in: tripDays)
let toDayNum = dayNumber(for: toStop.arrivalDate, in: tripDays)
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, tripDays.count)
defaultDay = minDay
} else {
minDay = 1
maxDay = tripDays.count
defaultDay = 1
}
let clampedDefault: Int
if minDay <= maxDay {
clampedDefault = max(minDay, min(defaultDay, maxDay))
} else {
clampedDefault = minDay
}
travelByDay[clampedDefault] = segment
}
return travelByDay
}
/// Convert a date to a 1-based day number within the trip days array.
/// Returns 0 if before trip start, tripDays.count + 1 if after trip end.
static func dayNumber(for date: Date, in tripDays: [Date]) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
for (index, tripDay) in tripDays.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
if let firstDay = tripDays.first, target < firstDay {
return 0
}
return tripDays.count + 1
}
}

View File

@@ -821,32 +821,38 @@ struct TripDetailView: View {
var sections: [ItinerarySection] = []
let days = tripDays
// Pre-calculate which day each travel segment belongs to
// Default: day after last game in departure city, or use validated override
// Pre-calculate which day each travel segment belongs to.
// Uses stop indices (not city name matching) so repeat cities work correctly.
// trip.travelSegments[i] connects trip.stops[i] trip.stops[i+1].
var travelByDay: [Int: TravelSegment] = [:]
for segment in trip.travelSegments {
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let travelId = stableTravelAnchorId(segment)
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
// Calculate valid range for this travel
// Travel can only happen AFTER the last game in departure city
let lastGameInFromCity = findLastGameDay(in: fromCity)
let firstGameInToCity = findFirstGameDay(in: toCity)
let minDay = max(lastGameInFromCity + 1, 1)
let maxDay = min(firstGameInToCity, tripDays.count)
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Calculate default day (day after last game in departure city)
// Use stop dates for precise placement (handles repeat cities)
let minDay: Int
let maxDay: Int
let defaultDay: Int
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
defaultDay = lastGameInFromCity + 1
} else if lastGameInFromCity > 0 {
defaultDay = lastGameInFromCity
if segmentIndex < trip.stops.count - 1 {
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate)
let toDayNum = dayNumber(for: toStop.arrivalDate)
// Travel goes after the from stop's last day, up to the to stop's first day
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, days.count)
defaultDay = minDay
} else {
// Fallback: segment doesn't align with stops
minDay = 1
maxDay = days.count
defaultDay = 1
}
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Check for user override - only use if within valid range
if let override = travelOverrides[travelId],
validRange.contains(override.day) {
@@ -928,60 +934,46 @@ struct TripDetailView: View {
}?.city
}
/// Find the last day number that has a game in the given city
private func findLastGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
let days = tripDays
var lastDay = 0
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
lastDay = dayNum
}
}
return lastDay
}
/// Find the first day number that has a game in the given city
private func findFirstGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
/// Convert a date to a 1-based day number within the trip.
/// Returns 0 if the date is before the trip, or tripDays.count + 1 if after.
private func dayNumber(for date: Date) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
let days = tripDays
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
return dayNum
for (index, tripDay) in days.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
return tripDays.count // Default to last day if no games found
// Date is outside the trip range
if let firstDay = days.first, target < firstDay {
return 0
}
return days.count + 1
}
/// Get valid day range for a travel segment
/// Travel can be displayed from the day of last departure game to the day of first arrival game
/// Get valid day range for a travel segment using stop indices.
/// Uses the from/to stop dates so repeat cities don't confuse placement.
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
// Find the segment matching this travel ID
guard let segment = trip.travelSegments.first(where: { stableTravelAnchorId($0) == travelId }) else {
// Find the segment index matching this travel ID
guard let segmentIndex = trip.travelSegments.firstIndex(where: { stableTravelAnchorId($0) == travelId }),
segmentIndex < trip.stops.count - 1 else {
return nil
}
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
// Travel can only happen AFTER the last game in departure city
// So the earliest travel day is the day AFTER the last game
let lastGameInFromCity = findLastGameDay(in: fromCity)
let minDay = max(lastGameInFromCity + 1, 1)
let fromDayNum = dayNumber(for: fromStop.departureDate)
let toDayNum = dayNumber(for: toStop.arrivalDate)
// Travel must happen BEFORE or ON the first game day in arrival city
let firstGameInToCity = findFirstGameDay(in: toCity)
let maxDay = min(firstGameInToCity, tripDays.count)
let minDay = max(fromDayNum + 1, 1)
let maxDay = min(toDayNum, tripDays.count)
// Handle edge case where minDay > maxDay shouldn't happen, but safeguard
if minDay > maxDay {
return minDay...minDay
return nil
}
return minDay...maxDay

View File

@@ -0,0 +1,233 @@
//
// TravelPlacementTests.swift
// SportsTimeTests
//
// Regression tests for travel segment day placement.
// Specifically tests that repeat cities (e.g., Follow Team mode)
// don't cause travel segments to be placed on non-existent days.
//
import XCTest
@testable import SportsTime
final class TravelPlacementTests: XCTestCase {
// MARK: - Helpers
private let calendar = Calendar.current
/// Create a date for May 2026 at a given day number.
private func may(_ day: Int) -> Date {
var components = DateComponents()
components.year = 2026
components.month = 5
components.day = day
return calendar.startOfDay(for: calendar.date(from: components)!)
}
/// Build an array of trip days from start to end (inclusive).
private func tripDays(from start: Date, to end: Date) -> [Date] {
var days: [Date] = []
var current = calendar.startOfDay(for: start)
let endDay = calendar.startOfDay(for: end)
while current <= endDay {
days.append(current)
current = calendar.date(byAdding: .day, value: 1, to: current)!
}
return days
}
private func makeStop(city: String, arrival: Date, departure: Date, games: [String] = []) -> TripStop {
TripStop(
stopNumber: 1,
city: city,
state: "XX",
arrivalDate: arrival,
departureDate: departure,
games: games
)
}
private func makeSegment(from: String, to: String) -> TravelSegment {
TravelSegment(
fromLocation: LocationInput(name: from, coordinate: nil),
toLocation: LocationInput(name: to, coordinate: nil),
travelMode: .drive,
distanceMeters: 500_000,
durationSeconds: 18000
)
}
// MARK: - dayNumber Tests
func test_dayNumber_returnsCorrectDayForDateWithinTrip() {
let days = tripDays(from: may(1), to: may(5))
XCTAssertEqual(TravelPlacement.dayNumber(for: may(1), in: days), 1)
XCTAssertEqual(TravelPlacement.dayNumber(for: may(3), in: days), 3)
XCTAssertEqual(TravelPlacement.dayNumber(for: may(5), in: days), 5)
}
func test_dayNumber_returnsZeroForDateBeforeTrip() {
let days = tripDays(from: may(5), to: may(10))
XCTAssertEqual(TravelPlacement.dayNumber(for: may(3), in: days), 0)
}
func test_dayNumber_returnsCountPlusOneForDateAfterTrip() {
let days = tripDays(from: may(1), to: may(5))
XCTAssertEqual(TravelPlacement.dayNumber(for: may(8), in: days), 6) // count + 1
}
// MARK: - Simple Trip (no repeat cities)
func test_simpleTwoStopTrip_travelPlacedCorrectly() {
// Chicago (May 1-3) Detroit (May 5-6)
// Travel should be on Day 4 (May 4)
let stops = [
makeStop(city: "Chicago", arrival: may(1), departure: may(3)),
makeStop(city: "Detroit", arrival: may(5), departure: may(6))
]
let segments = [makeSegment(from: "Chicago", to: "Detroit")]
let trip = Trip(
name: "Test",
preferences: TripPreferences(),
stops: stops,
travelSegments: segments,
totalGames: 0,
totalDistanceMeters: 0,
totalDrivingSeconds: 0
)
let days = tripDays(from: may(1), to: may(6))
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
XCTAssertEqual(result.count, 1, "Should have 1 travel segment placed")
XCTAssertNotNil(result[4], "Travel should be placed on Day 4 (May 4)")
}
// MARK: - Repeat City Bug (Follow Team regression)
func test_repeatCity_travelPlacedOnCorrectDay_notGlobalLastGameDay() {
// Simulates the Astros May 2026 pattern:
// Houston (May 4-6) Cincinnati (May 8-10) Houston (May 11-17) Chicago (May 22-24) Houston (May 29-31)
//
// BUG: The old code used findLastGameDay("Houston") which returned Day 31,
// causing HoustonCincinnati travel to be placed on Day 32 (doesn't exist).
// FIX: Use stop indices so each segment uses its specific stop's dates.
let stops = [
makeStop(city: "Houston", arrival: may(4), departure: may(6)), // Stop 0
makeStop(city: "Cincinnati", arrival: may(8), departure: may(10)), // Stop 1
makeStop(city: "Houston", arrival: may(11), departure: may(17)), // Stop 2
makeStop(city: "Chicago", arrival: may(22), departure: may(24)), // Stop 3
makeStop(city: "Houston", arrival: may(29), departure: may(31)) // Stop 4
]
let segments = [
makeSegment(from: "Houston", to: "Cincinnati"), // Seg 0: stops[0] stops[1]
makeSegment(from: "Cincinnati", to: "Houston"), // Seg 1: stops[1] stops[2]
makeSegment(from: "Houston", to: "Chicago"), // Seg 2: stops[2] stops[3]
makeSegment(from: "Chicago", to: "Houston") // Seg 3: stops[3] stops[4]
]
let trip = Trip(
name: "Follow Astros",
preferences: TripPreferences(),
stops: stops,
travelSegments: segments,
totalGames: 0,
totalDistanceMeters: 0,
totalDrivingSeconds: 0
)
let days = tripDays(from: may(4), to: may(31))
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
// All 4 segments should be placed within the trip's day range
XCTAssertEqual(result.count, 4, "All 4 travel segments should be placed")
// HoustonCincinnati: departure May 6 (Day 3), arrival May 8 (Day 5) travel on Day 4 (May 7)
XCTAssertNotNil(result[4], "Houston→Cincinnati travel should be on Day 4 (May 7)")
// CincinnatiHouston: departure May 10 (Day 7), arrival May 11 (Day 8) travel on Day 8 (May 11)
XCTAssertNotNil(result[8], "Cincinnati→Houston travel should be on Day 8 (May 11)")
// HoustonChicago: departure May 17 (Day 14), arrival May 22 (Day 19) travel on Day 15 (May 18)
XCTAssertNotNil(result[15], "Houston→Chicago travel should be on Day 15 (May 18)")
// ChicagoHouston: departure May 24 (Day 21), arrival May 29 (Day 26) travel on Day 22 (May 25)
XCTAssertNotNil(result[22], "Chicago→Houston travel should be on Day 22 (May 25)")
// Critical regression check: NO travel should be placed beyond the trip's day range
for (day, _) in result {
XCTAssertGreaterThanOrEqual(day, 1, "Travel day should be >= 1")
XCTAssertLessThanOrEqual(day, days.count, "Travel day should be <= \(days.count), but got \(day)")
}
}
func test_repeatCity_threeVisits_allTravelSegmentsVisible() {
// ABAB pattern (city visited 2x each)
// A (Day 1-2) B (Day 4-5) A (Day 7-8) B (Day 10-11)
let stops = [
makeStop(city: "CityA", arrival: may(1), departure: may(2)),
makeStop(city: "CityB", arrival: may(4), departure: may(5)),
makeStop(city: "CityA", arrival: may(7), departure: may(8)),
makeStop(city: "CityB", arrival: may(10), departure: may(11))
]
let segments = [
makeSegment(from: "CityA", to: "CityB"),
makeSegment(from: "CityB", to: "CityA"),
makeSegment(from: "CityA", to: "CityB")
]
let trip = Trip(
name: "Test Repeat",
preferences: TripPreferences(),
stops: stops,
travelSegments: segments,
totalGames: 0,
totalDistanceMeters: 0,
totalDrivingSeconds: 0
)
let days = tripDays(from: may(1), to: may(11))
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
XCTAssertEqual(result.count, 3, "All 3 travel segments should be placed")
// All within bounds
for (day, _) in result {
XCTAssertGreaterThanOrEqual(day, 1)
XCTAssertLessThanOrEqual(day, 11)
}
}
// MARK: - Consecutive Stops (no gap)
func test_consecutiveStops_travelStillPlaced() {
// Houston (May 1) Chicago (May 2) - back to back, no rest day
let stops = [
makeStop(city: "Houston", arrival: may(1), departure: may(1)),
makeStop(city: "Chicago", arrival: may(2), departure: may(2))
]
let segments = [makeSegment(from: "Houston", to: "Chicago")]
let trip = Trip(
name: "Quick Trip",
preferences: TripPreferences(),
stops: stops,
travelSegments: segments,
totalGames: 0,
totalDistanceMeters: 0,
totalDrivingSeconds: 0
)
let days = tripDays(from: may(1), to: may(2))
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
XCTAssertEqual(result.count, 1, "Travel segment should be placed")
XCTAssertNotNil(result[2], "Travel should be on Day 2 (arrival day)")
}
}

View File

@@ -0,0 +1,136 @@
# SportsTime Monday Growth Review Template
Use this every Monday. Timebox: 20-30 minutes.
## 1) Week Snapshot
`review_date:`
`review_owner:`
`week_range:`
`headline:` one sentence on how the week went.
## 2) KPI Scorecard (This Week vs Last Week vs Goal)
| Metric | This Week | Last Week | Delta | Goal | Status (Green/Yellow/Red) |
|---|---:|---:|---:|---:|---|
| Installs | | | | | |
| Cost (paid spend) | | | | | |
| CPI | | | | | |
| Activation rate (`install -> trip_saved`) | | | | | |
| Paywall view rate | | | | | |
| Paywall conversion (`paywall_view -> purchase_success`) | | | | | |
| New subscribers | | | | | |
| Weekly revenue | | | | | |
| D1 retention | | | | | |
| D7 retention | | | | | |
| D30 retention | | | | | |
| Shares per activated user | | | | | |
## 3) Funnel Breakdown
| Step | Users | Conversion from prior step | Notes |
|---|---:|---:|---|
| Installs | | | |
| First open | | | |
| Onboarding complete | | | |
| Trip options generated | | | |
| Trip saved | | | |
| Paywall viewed | | | |
| Purchase started | | | |
| Purchase success | | | |
`largest_drop_off_step:`
`suspected_cause:`
`fix_to_ship_this_week:`
## 4) Channel Performance
| Channel | Spend | Installs | CPI | Activation rate | Purchase rate | Keep / Improve / Cut |
|---|---:|---:|---:|---:|---:|---|
| App Store Organic | | | | | | |
| Apple Search Ads | | | | | | |
| TikTok Organic | | | | | | |
| TikTok Paid | | | | | | |
| Meta Paid | | | | | | |
| Reddit | | | | | | |
| Creator/Influencer | | | | | | |
| Referral/Invite | | | | | | |
## 5) Experiments Reviewed
### Experiment 1
`name:`
`hypothesis:`
`change_shipped:`
`result:`
`decision:` Ship / Iterate / Kill
### Experiment 2
`name:`
`hypothesis:`
`change_shipped:`
`result:`
`decision:` Ship / Iterate / Kill
### Experiment 3
`name:`
`hypothesis:`
`change_shipped:`
`result:`
`decision:` Ship / Iterate / Kill
## 6) Product + UX Issues
Top friction points from analytics, support, or user feedback:
1.
2.
3.
`highest_priority_fix_for_this_week:`
## 7) Content + Distribution Review
| Content Type | Published | Avg views | Avg CTR to store/site | Top performer | Next action |
|---|---:|---:|---:|---|---|
| TikTok/Reels | | | | | |
| X/Twitter | | | | | |
| Reddit posts/comments | | | | | |
| Email newsletter | | | | | |
| Creator placements | | | | | |
`winning_content_angle:`
`angle_to_drop:`
## 8) Decisions (Write Explicitly)
1.
2.
3.
## 9) This Week Plan (Top 3 Priorities Only)
1.
2.
3.
## 10) Owner + Deadline Grid
| Task | Owner | Deadline | Success metric |
|---|---|---|---|
| | | | |
| | | | |
| | | | |
## 11) Risk Watch
`biggest_risk_next_2_weeks:`
`mitigation:`
## 12) Weekly Close Checklist
- [ ] KPI table filled with real numbers
- [ ] One funnel fix selected and scheduled
- [ ] One distribution channel reduced or scaled based on data
- [ ] Three decisions documented
- [ ] Three priorities set for current week

View File

@@ -0,0 +1,138 @@
# SportsTime Monday Growth Review - Example (Week 1)
Use this as a concrete starter. Date context:
- Review date: February 9, 2026
- Week range: February 2-8, 2026
## 1) Week Snapshot
`review_date:` 2026-02-09
`review_owner:` Trey
`week_range:` 2026-02-02 to 2026-02-08
`headline:` Strong launch-week top-of-funnel; biggest gap is install-to-trip-save activation.
## 2) KPI Scorecard (This Week vs Last Week vs Goal)
| Metric | This Week | Last Week | Delta | Goal | Status (Green/Yellow/Red) |
|---|---:|---:|---:|---:|---|
| Installs | 486 | 0 | +486 | 500 | Yellow |
| Cost (paid spend) | $118 | $0 | +$118 | <= $120 | Green |
| CPI | $2.43 | $0 | n/a | <= $3.00 | Green |
| Activation rate (`install -> trip_saved`) | 16.9% | 0% | +16.9pp | 22% | Red |
| Paywall view rate | 12.4% | 0% | +12.4pp | 15% | Yellow |
| Paywall conversion (`paywall_view -> purchase_success`) | 8.3% | 0% | +8.3pp | 10% | Yellow |
| New subscribers | 5 | 0 | +5 | 8 | Yellow |
| Weekly revenue | $11.85 | $0 | +$11.85 | $20 | Yellow |
| D1 retention | 31.7% | 0% | +31.7pp | 32% | Yellow |
| D7 retention | n/a (insufficient) | 0% | n/a | 14% | Yellow |
| D30 retention | n/a | 0% | n/a | 7% | Yellow |
| Shares per activated user | 0.41 | 0 | +0.41 | 0.50 | Yellow |
## 3) Funnel Breakdown
| Step | Users | Conversion from prior step | Notes |
|---|---:|---:|---|
| Installs | 486 | - | Launch week traffic healthy |
| First open | 459 | 94.4% | Normal app install/open drop |
| Onboarding complete | 365 | 79.5% | Step completion acceptable |
| Trip options generated | 146 | 40.0% | Biggest drop starts here |
| Trip saved | 82 | 56.2% | Save intent good once options seen |
| Paywall viewed | 57 | 69.5% | Trigger points working |
| Purchase started | 12 | 21.1% | CTA can improve |
| Purchase success | 5 | 41.7% | Early conversion acceptable |
`largest_drop_off_step:` onboarding_complete -> trip_options_generated
`suspected_cause:` users not selecting enough required fields in wizard and/or route generation feels heavy before first win
`fix_to_ship_this_week:` add progress hints + "minimum required to generate first route" helper + one-tap defaults
## 4) Channel Performance
| Channel | Spend | Installs | CPI | Activation rate | Purchase rate | Keep / Improve / Cut |
|---|---:|---:|---:|---:|---:|---|
| App Store Organic | $0 | 206 | $0.00 | 18.4% | 1.3% | Improve |
| Apple Search Ads | $86 | 118 | $0.73 | 22.0% | 1.7% | Keep |
| TikTok Organic | $0 | 72 | $0.00 | 11.1% | 0.4% | Improve |
| TikTok Paid | $32 | 54 | $0.59 | 9.3% | 0.0% | Cut |
| Meta Paid | $0 | 0 | $0.00 | - | - | Not run |
| Reddit | $0 | 29 | $0.00 | 20.7% | 1.4% | Keep |
| Creator/Influencer | $0 | 7 | $0.00 | 14.3% | 0.0% | Improve |
| Referral/Invite | $0 | 0 | $0.00 | - | - | Not live yet |
## 5) Experiments Reviewed
### Experiment 1
`name:` Review prompt after second successful session
`hypothesis:` delaying prompt until post-value moment improves rating conversion
`change_shipped:` prompt gate after second session + successful route save
`result:` 9 ratings, average 4.8, no negative spike
`decision:` Ship
### Experiment 2
`name:` Launch paywall on first blocked action only
`hypothesis:` contextual paywall increases purchase start rate vs generic modal
`change_shipped:` paywall shown only on 3 blocked actions
`result:` paywall start rate 21.1%, purchase success 41.7% from starts
`decision:` Iterate (test stronger annual framing)
### Experiment 3
`name:` TikTok "3 games in 4 days" hook
`hypothesis:` challenge framing drives higher installs than static demo
`change_shipped:` 4 videos with challenge overlay
`result:` higher views, low activation quality from paid traffic
`decision:` Iterate for organic; kill paid variant
## 6) Product + UX Issues
Top friction points from analytics + qualitative feedback:
1. Too many required fields before first route output.
2. Users unclear why no routes returned in some date windows.
3. Region selection not obvious to first-time users.
`highest_priority_fix_for_this_week:` simplify first route flow and auto-fill defaults to increase `trip_options_generated`.
## 7) Content + Distribution Review
| Content Type | Published | Avg views | Avg CTR to store/site | Top performer | Next action |
|---|---:|---:|---:|---|---|
| TikTok/Reels | 7 | 2,540 | 1.8% | "Can you hit 3 NBA games in 4 days?" | Repeat challenge format with city-specific hooks |
| X/Twitter | 8 | 740 | 1.2% | Team-first route thread | Post 1 route teardown/day |
| Reddit posts/comments | 3/19 | n/a | n/a | Value post in `r/roadtrip` | Keep value-first, no direct promo tone |
| Email newsletter | 1 | n/a | 5.1% | Launch email | Send weekly route drop |
| Creator placements | 2 | 1,100 | 0.7% | MLB weekend route clip | Send 20 custom creator route DMs |
`winning_content_angle:` "Can you hit X games in Y days from [city]?"
`angle_to_drop:` generic feature walkthrough with no concrete trip scenario
## 8) Decisions (Write Explicitly)
1. Shift remaining paid budget to Apple Search Ads; pause TikTok paid for now.
2. Prioritize activation improvements before scaling top-of-funnel.
3. Launch referral loop in next sprint to increase low-cost installs.
## 9) This Week Plan (Top 3 Priorities Only)
1. Ship onboarding simplification and default route helper.
2. Implement referral/deep-link invite loop for polls and shared routes.
3. Publish 5 short videos using city + league challenge format.
## 10) Owner + Deadline Grid
| Task | Owner | Deadline | Success metric |
|---|---|---|---|
| Onboarding simplification + defaults | Trey | 2026-02-12 | `onboarding_complete -> trip_options_generated` +8pp |
| Referral/deep-link invite MVP | Trey | 2026-02-14 | 25 referred installs in first 7 days |
| Content batch (5 challenge videos) | Trey | 2026-02-15 | >= 10k total views, >= 2% CTR |
## 11) Risk Watch
`biggest_risk_next_2_weeks:` traffic quality can outpace product readiness and suppress ratings/retention
`mitigation:` keep paid spend constrained until activation and D1 retention stabilize above target
## 12) Weekly Close Checklist
- [x] KPI table filled with real numbers
- [x] One funnel fix selected and scheduled
- [x] One distribution channel reduced or scaled based on data
- [x] Three decisions documented
- [x] Three priorities set for current week

363
docs/codexMarketing.md Normal file
View File

@@ -0,0 +1,363 @@
# SportsTime iOS Launch & Growth Plan (90 Days)
Assumptions used for this plan:
- You are pre-launch on TestFlight with no meaningful existing audience.
- Budget is $500/month total.
- You can ship product changes quickly.
- US-only, iPhone-only, sports leagues are US-focused.
- Pricing is currently `$0.99/month` and `$9.99/year` (strongly recommend testing higher price points in Month 2).
Proposed 90-day targets (edit as needed):
- Baseline: `12,000 installs`, `300 paid subscribers`, `D1 32%`, `D7 14%`, `D30 7%`.
- Stretch: `30,000 installs`, `800 paid subscribers`, `D1 35%`, `D7 16%`, `D30 8%`.
- 100k installs in 90 days is only realistic if a viral loop from sharing/polls lands hard; paid media alone cannot do this on $500/mo.
## 1. Positioning & Messaging
- Core hook: “Plan multi-game sports road trips in minutes, not spreadsheets.”
- Positioning statement: SportsTime is the only iPhone app that combines real game schedules, route logic, and stadium tracking into one trip-planning flow.
- Target audience psychology: sports fans with FOMO, completionist behavior (visit all stadiums), social bragging (share routes/progress), and “planning as entertainment” during downtime.
- Why they install: they want an answer to “What games can I realistically hit in one trip?”.
- Why they pay: unlimited saved trips, group planning/polls, and stadium progress tracking are “identity” features for serious fans.
Main competitor set:
- `Roadtrippers` and `Wanderlog` for generic road-trip planning.
- `MLB Ballpark` for in-stadium check-in utility.
- `StadiumTrack` / `Stadium Rover` / `Balltrip` for stadium/fan tracking or lightweight sports-trip planning.
- `SeatGeek` as adjacent discovery/ticket competitor.
Why SportsTime wins:
- Sports-native planning modes (`by dates`, `by games`, `by route`, `follow team`, `by teams`) rather than generic POI routing.
- Cross-league coverage in one planner.
- Built-in “fan progression” layer (stadium tracking + achievements), not just itinerary.
- Group poll collaboration tied to trip options.
Tagline ideas:
- “From Couch to Kickoff.”
- “Build Your Ultimate Stadium Run.”
- “Plan the Trip. Chase the Games.”
- “See More Games. Drive Less.”
- “The Road-Trip Brain for Sports Fans.”
App Store subtitle ideas:
- “Plan Multi-Game Road Trips”
- “Sports Route + Stadium Tracker”
- “Trip Planner for Sports Fans”
- “Find Games, Build the Route”
- “Road Trips for Game Days”
## 2. Pre-Launch Plan (30-60 Days Before)
Landing page strategy:
- One CTA only: “Get your first 3-game route.”
- Inputs on page: favorite team, home city, month, max driving hours/day, email.
- Output: send a personalized sample itinerary by email within 24 hours.
- Add 3 social proof blocks from TestFlight users and one short demo video.
Waitlist strategy:
- Segment by league interest and distance willingness.
- Send weekly “Route Drop” emails: one weekend plan, one 5-day plan, one “follow-team” plan.
- Use scarcity: “Founding 500 get lifetime Founder Badge in app + 1 free pro year.”
Beta/TestFlight plan:
- Recruit 150-250 testers with high intent from fan communities.
- Use a structured mission each week: “Plan and save 1 trip” then “Share 1 trip card” then “Log 1 stadium visit.”
- Track activation target: 60% of testers reach `trip_saved`.
- Run 2 in-app surveys: after first trip save and after first week.
Community building channels:
- Reddit: `r/baseball`, `r/NBA`, `r/nfl`, `r/hockey`, `r/mls`, `r/wnba`, `r/NWSL`, `r/roadtrip`.
- TikTok/Reels: 4-5 short videos/week, each showing real route scenarios.
- X/Twitter: daily “Can this trip work?” route posts with screenshots.
How to collect emails:
- Website route generator.
- “Free printable stadium checklist” gated download.
- “Opening Week Route Pack” gated PDF.
- QR code from social video overlays to waitlist page.
How to seed early users:
- DM 100 fan creators/podcasters with a custom route built for their team.
- Offer “free pro for creators + audience code”.
- Run “First 1,000 Founders” campaign with public counter.
## 3. App Store Optimization (ASO)
Keyword strategy:
- Intent cluster A: `sports trip planner`, `stadium trip planner`, `game day trip`.
- Intent cluster B: `road trip planner`, `multi stop route planner`, `travel itinerary`.
- Intent cluster C: `MLB trip`, `NBA trip`, `NFL trip`, `NHL trip`, `MLS trip`.
- Intent cluster D: `stadium tracker`, `ballpark tracker`, `sports bucket list`.
Title/subtitle structure:
- Title format: `Brand + core intent`.
- Candidate title: `SportsTime: Stadium Trips`.
- Candidate subtitle: `Plan Multi-Game Road Trips`.
Screenshot strategy (8 screens):
- 1: “Plan by dates, games, routes, or teams.”
- 2: “See all games in your trip window.”
- 3: “Get optimized route options with drive time.”
- 4: “Compare itinerary options fast.”
- 5: “Save trips and revisit anytime.”
- 6: “Track stadiums visited across leagues.”
- 7: “Plan with friends using trip polls.”
- 8: “Share polished trip cards and PDFs.”
Preview video plan:
- Video A (15s): instant value, “from dates to route in seconds.”
- Video B (30s): real scenario, “hit 3 games in 4 days.”
- Video C (20s): stadium tracker + achievements + social sharing.
Review/rating strategy for first 100 reviews:
- Trigger `SKStoreReviewController` only after `trip_saved` + second successful session.
- Do not prompt after errors or paywall rejection.
- Send direct asks to top 100 TestFlight users with one-tap review deep link.
- In-app “Founding User” card asking for review after user shares a route.
Localization strategy:
- English (US) first.
- Spanish metadata next for US Hispanic market.
- Localize title/subtitle/keywords/screenshots before localizing full app UI.
## 4. Launch Week Strategy
| Day | Objective | Tactics | KPI |
|---|---|---|---|
| Day 1 (Mon) | Final prep | Freeze build, finalize ASO assets, schedule manual release, set analytics dashboards, queue 7 short videos | Zero blocker bugs |
| Day 2 (Tue) | Public launch burst | App Store live + Product Hunt launch + waitlist email blast + creator DMs (30) + ASA exact-match campaign start | Day-1 installs, CVR |
| Day 3 (Wed) | Reddit credibility | Post 3 value-first route breakdowns (not promo spam), reply to comments for 4 hours, publish one “how I planned this trip” thread | Saves/comments/clicks |
| Day 4 (Thu) | Short-form reach | Publish 3 TikToks/Reels showing team-specific route examples, CTA to free route planner | CTR to App Store |
| Day 5 (Fri) | Press + podcasts | Send 50 personalized pitches to sports newsletters/podcasts/blogs with team-angle hooks and demo links | Replies/bookings |
| Day 6 (Sat) | Community activation | Run “Weekend Stadium Challenge” with hashtag + share card template, repost user submissions | UGC count, shares |
| Day 7 (Sun) | Conversion optimization | Analyze funnel drop-offs, ship onboarding/paywall copy fixes, rotate ad creatives, publish launch recap thread | Activation uplift |
Product Hunt strategy:
- Launch Tuesday 12:01am PT.
- First comment must include one specific use case and roadmap.
- Ask 25 close contacts to engage in first 4 hours.
Reddit strategy:
- 70% value content, 20% discussion, 10% product mention.
- Post local/team-specific trip examples, not generic promo.
TikTok strategy:
- Repeatable content format: “Can you hit X games in Y days from [city]?”
- One visual hook in first 1.5 seconds, route reveal by second 5.
Influencer outreach:
- Target micro-creators in sports travel/fan vlog niche (`5k-100k followers`).
- Offer custom route build + 1-year pro for coverage.
Press outreach:
- Pitch angle by season moment (opening day, playoffs, rivalry weeks).
- Focus on niche sports outlets first, then broader travel tech.
Hacker News:
- Use only if you pitch technical angle: route algorithm + schedule constraints.
Paid ads in launch week:
- Spend `$120` max in week 1 for signal collection.
- Keep campaigns narrow and high-intent.
## 5. Growth Engine (Post-Launch)
Organic loops:
- Poll invite loop: each poll invite sends a deep link; recipient installs to vote.
- Share loop: route cards/PDFs include app watermark + CTA.
- Progress loop: stadium milestones create social posts (“12/30 MLB parks completed”).
Referral system:
- Give 1 month Pro for every referred user who completes first `trip_saved`.
- Cap at 12 months to avoid abuse.
Viral features to prioritize:
- Public “Team Trip Templates” share pages.
- “Weekend Route Generator” for each league.
- “Stadium Chase leaderboard” by city/team fandom.
Content strategy:
- Daily short route content tied to upcoming schedule windows.
- Weekly newsletter: “Best trips this week from [major US cities].”
SEO strategy:
- Create indexed pages: `/routes/[team]/[month]/[origin-city]`.
- Publish “best sports road trips by month” content with real route screenshots.
Community building:
- Launch a Discord with 3 channels only: `route-help`, `trip-recaps`, `feature-vote`.
- Host weekly “route office hours”.
Retention strategy:
- Push notifications on intent:
- Trigger if user planned but did not save in 24h.
- Trigger when favorite team has dense game window.
- Trigger when user is near unvisited stadium.
- Email cadence:
- Day 1: “Your first route + edit tips.”
- Day 3: “3 trips you can do from your city.”
- Day 10: “Track your first stadium visit.”
## 6. Paid Acquisition Plan (If Budget > $0)
Budget recommendation at `$500/month`:
- Apple Search Ads: `$325`
- TikTok Ads: `$125`
- Meta Ads: `$50`
- Creator seed spend: barter/free-pro only unless one creator clearly performs.
Apple Search Ads strategy:
- Campaign groups: brand, competitor, category, discovery.
- Start exact-match with high intent keywords only.
- Use CPT caps tightly; pause anything above target CPI by day 4.
TikTok ads strategy:
- Use native-style UGC clips, not polished promo ads.
- Creative angles: “I planned this Yankees trip in 30 seconds”, “3 games, 4 days challenge”.
Meta ads strategy:
- Interest stack: league fans + road trips + travel planning.
- Run one retargeting ad set once pixel audience exists.
Creator partnerships:
- Prioritize sports trip vloggers and fan podcasters.
- Give creator-specific deep links and offer their audience 30-day pro.
Expected CPI ranges (US, non-gaming guidance + practical inference):
- Apple Search Ads: roughly `$3.5-$6.0` per install.
- TikTok: roughly `$1.5-$4.0`.
- Meta: roughly `$2.0-$6.0`.
Testing plan:
- 2-week sprints.
- 3 creatives per channel minimum.
- Kill rule: pause ad after `3x target CPI` with no activation.
- Scale rule: increase 20% budget/day only on ad sets hitting activation and paywall conversion thresholds.
## 7. Analytics & Tracking Setup
Must-have tools:
- Product analytics: `PostHog` or `Amplitude`.
- Crash/perf: `Firebase Crashlytics`.
- Subscription tracking: StoreKit transaction events + server-side receipt status.
- Attribution at this budget: Apple Ads native attribution + SKAN/AdAttributionKit signals.
North-star metric:
- `Weekly Trip Saves (WTS)` and `Weekly Activated Users (WAU-Activated)`.
Core funnel:
- `install -> first_open -> onboarding_complete -> trip_options_generated -> trip_saved -> paywall_view -> purchase_success -> week1_return`.
Event schema to implement now:
- `app_first_open`
- `onboarding_started`
- `onboarding_completed`
- `planning_mode_selected`
- `sports_selected`
- `date_range_set`
- `regions_selected`
- `games_loaded`
- `trip_options_generated`
- `trip_option_viewed`
- `trip_saved`
- `trip_shared`
- `pdf_export_generated`
- `paywall_viewed`
- `paywall_cta_tapped`
- `purchase_started`
- `purchase_success`
- `purchase_failed`
- `subscription_renewed`
- `subscription_canceled`
- `poll_create_started`
- `poll_created`
- `poll_invite_sent`
- `poll_vote_submitted`
- `stadium_visit_logged`
- `achievement_unlocked`
- `push_opt_in_status_changed`
- `push_opened`
- `deeplink_opened`
- `referral_accepted`
Required event properties:
- `league`, `planning_mode`, `trip_option_count`, `trip_distance`, `trip_days`, `origin_city`, `paywall_trigger`, `product_id`, `price`, `utm_source`, `creative_id`.
Metrics to track weekly:
- Install-to-activation rate.
- Activation-to-paywall rate.
- Paywall view-to-purchase rate.
- D1/D7/D30 retention.
- ARPPU, trial-to-paid (if trials added), refund rate.
- LTV/CAC payback window by channel.
## 8. 90-Day Execution Roadmap
Week 1-2:
- Ship analytics and event taxonomy.
- Finalize ASO metadata and first 8 screenshots.
- Build waitlist landing page and route-lead magnet.
- Recruit first 150 TestFlight users.
- Publish 10 short videos before launch day.
Week 3-4:
- Launch publicly.
- Run launch week playbook.
- Collect first 50 public reviews.
- Ship onboarding and paywall copy improvements from funnel data.
- Start weekly route newsletter.
Month 2:
- Release referral loop and creator deep links.
- Add public route templates for SEO pages.
- Run 2 pricing/paywall experiments.
- Publish 20 additional short videos tied to schedule moments.
- Scale only top-performing acquisition channel.
Month 3:
- Double down on best league-specific growth channel.
- Launch “Stadium Challenge” campaign with leaderboard.
- Expand metadata localization to Spanish (US-focused).
- Add one retention feature tied to favorite teams and upcoming game clusters.
- Prepare playoff-season campaign calendar.
## 9. Aggressive Growth Ideas
1. Build “Weekend Trip Generator” web tool that creates shareable route images instantly.
2. Make first poll free (even on free tier) to maximize invite-based installs.
3. Run “30 Stadiums in 90 Days” public challenge with weekly leaderboard.
4. Auto-generate team-specific social content daily from schedule windows.
5. Offer fan podcasters a custom route segment they can run each week.
6. Launch “Founder handle” badges that appear on shared cards for status.
7. Create local city route packs (“Best Chicago-based sports trips this month”).
8. Ship one-tap “Share to Instagram Stories” templates for every saved trip.
9. Partner with sports betting/travel newsletters for co-branded route drops.
10. Build seasonal event pages around rivalry weeks and playoffs for search capture.
## 10. Biggest Mistakes to Avoid
- Keeping pricing too low for too long; it hurts perceived value and makes paid growth non-viable.
- Gating every social loop behind paywall; you need free-tier virality before monetization pressure.
- Launching without instrumentation; no event-level data means blind iteration.
- Broad targeting (“all sports fans”) instead of high-intent segments by league/team/city.
- Posting self-promo spam in Reddit communities; you will get blocked and lose your best channel.
- Ignoring retention and over-focusing on top-of-funnel installs.
- Shipping with schedule/timezone inaccuracies; trust is the product in planning apps.
- Running too many channels at once as a solo founder; pick 2 organic channels + 1 paid channel.
- Not building reusable creative systems; growth will stall if content production is ad hoc.
- Delaying referral/deep-link loops; with $500/month budget, this is your real growth lever.
## Sources
- Wanderlog App Store listing: <https://apps.apple.com/us/app/wanderlog-travel-planner/id1476732439>
- Roadtrippers App Store listing: <https://apps.apple.com/us/app/roadtrippers-trip-planner/id944060491>
- Balltrip App listing: <https://apps.apple.com/us/app/balltrip-app/id1625957435>
- StadiumTrack listing: <https://apps.apple.com/us/app/stadiumtrack/id6752205919>
- Stadium Rover listing: <https://apps.apple.com/us/app/stadium-rover/id6744362780>
- MLB Ballpark listing: <https://apps.apple.com/us/app/mlb-ballpark/id513135722>
- SeatGeek listing: <https://apps.apple.com/us/app/seatgeek-buy-event-tickets/id582790430>
- TripIt listing: <https://apps.apple.com/us/app/tripit-travel-planner/id311035142>
- AppTweak Apple Ads benchmarks (US CPI reference): <https://www.apptweak.com/en/aso-blog/apple-ads-benchmarks>
- AppsFlyer market trend references:
- <https://www.appsflyer.com/resources/reports/performance-index/>
- <https://www.appsflyer.com/resources/reports/top-5-data-trends-report/>

View File

@@ -0,0 +1,133 @@
# SportsTime 90-Day Growth Operator Checklist
Use this as a weekly execution sheet. Check every item before ending the week.
## KPI Guardrails (Track Weekly)
- [ ] Installs (weekly) hit target
- [ ] `install -> trip_saved` activation rate improves or holds
- [ ] `paywall_view -> purchase_success` conversion improves or holds
- [ ] D1 retention
- [ ] D7 retention
- [ ] Revenue and subscriber count
## Week 1
- [ ] Implement core analytics events (`first_open`, `onboarding_completed`, `trip_options_generated`, `trip_saved`, `paywall_viewed`, `purchase_success`, `trip_shared`, `poll_created`, `stadium_visit_logged`)
- [ ] Build dashboard for activation, paywall conversion, retention, revenue
- [ ] Finalize App Store title, subtitle, keywords
- [ ] Draft 8 screenshot concepts (one value message each)
- [ ] Build launch waitlist landing page with one CTA
- [ ] Add email capture with simple segmentation (league + home city)
- [ ] Create 5 short-form video scripts for launch
## Week 2
- [ ] Produce and queue at least 10 short videos
- [ ] Recruit first 150 TestFlight users (Reddit/X/DM outreach)
- [ ] Run TestFlight mission: every tester plans and saves 1 trip
- [ ] Collect and tag beta feedback (onboarding, route quality, pricing, bugs)
- [ ] Implement review prompt trigger after successful `trip_saved` + second session
- [ ] Prepare press and creator outreach list (minimum 100 contacts total)
## Week 3 (Launch Week)
- [ ] Monday: release readiness check, analytics sanity, content queue ready
- [ ] Tuesday: App Store release + Product Hunt launch
- [ ] Tuesday: send launch email to waitlist
- [ ] Tuesday: start Apple Search Ads exact-match campaign
- [ ] Wednesday: publish 3 value-first Reddit posts
- [ ] Thursday: post 3 short videos (team-specific route examples)
- [ ] Friday: send 50 personalized press/newsletter/podcast pitches
- [ ] Weekend: run user-generated “Stadium Challenge” post format
- [ ] Sunday: funnel review and quick fixes shipped
## Week 4
- [ ] Publish post-launch update with roadmap and fixes
- [ ] Optimize onboarding copy based on Week 3 drop-offs
- [ ] Optimize paywall copy and placement based on Week 3 conversion
- [ ] Refresh App Store screenshots with top-performing messaging angle
- [ ] Collect first 50 App Store reviews
- [ ] Start weekly “Route Drop” newsletter
## Week 5
- [ ] Ship referral flow (`referral_accepted` tracked)
- [ ] Ship creator deep links with source tagging
- [ ] Publish 5 new short videos tied to upcoming game windows
- [ ] Launch one “origin city -> weekend trips” content page
- [ ] Cut any paid channel/ad set above kill threshold CPI
## Week 6
- [ ] Launch first pricing/paywall test (copy, offer framing, or annual emphasis)
- [ ] Add push nudges for unfinished trip planning (24-hour trigger)
- [ ] Publish 5 route-focused short videos
- [ ] Run outreach wave #2 to 30 creators with custom team routes
- [ ] Review retention cohorts by planning mode
## Week 7
- [ ] Launch second paywall/pricing test (alternate variant)
- [ ] Ship one viral sharing improvement (better card CTA, cleaner share template, deep-link recovery)
- [ ] Publish 2 SEO pages for team/month route templates
- [ ] Run Reddit AMA-style value thread (planning workflows, not promo spam)
- [ ] Weekly KPI review and decision log updated
## Week 8
- [ ] Launch “Stadium Progress” social campaign format
- [ ] Add push trigger for favorite-team high-density game window
- [ ] Publish 5 short videos and 1 long-form walkthrough
- [ ] Expand creator program with simple affiliate-like tracking code
- [ ] Rebalance paid budget to top channel only
## Week 9
- [ ] Ship public route template library MVP
- [ ] Publish 3 new city-based route pages for SEO
- [ ] Audit funnel by source (`utm_source`, `creative_id`)
- [ ] Replace lowest-performing screenshot and subtitle variant
- [ ] Gather 10 qualitative user calls or DM interviews
## Week 10
- [ ] Launch “Challenge” feature campaign (e.g., 30 stadiums progress stories)
- [ ] Add one retention feature driven by user feedback
- [ ] Publish 5 short videos with playoff/rivalry angles
- [ ] Outreach wave #3 to niche podcasts/newsletters
- [ ] Validate CAC vs 30-day value trend
## Week 11
- [ ] Localize App Store metadata to Spanish (US-focused)
- [ ] Re-run ASO keyword refresh from actual query data
- [ ] Ship referral optimization (friction reduction + clearer reward copy)
- [ ] Run one focused promo with creator partner
- [ ] Weekly KPI review and decision log updated
## Week 12
- [ ] Prepare playoff-season campaign package (assets, routes, content calendar)
- [ ] Create best-performing content playbook from last 60 days
- [ ] Consolidate winning onboarding/paywall variants as new default
- [ ] Publish retention and win-back email sequence v2
- [ ] Collect net-new review push to pass 100 reviews total
## Week 13 (Day 85-90)
- [ ] Run full 90-day retrospective: installs, activation, retention, revenue, CAC/LTV
- [ ] Decide channel strategy for next 90 days (double down on top 1-2)
- [ ] Lock next-quarter growth bets (3 max)
- [ ] Archive failed experiments and reasons
- [ ] Publish internal growth memo with next-quarter numeric targets
## Weekly Operating Rhythm (Repeat Every Week)
- [ ] Monday: KPI review + top 3 experiments selected
- [ ] Tuesday: ship product changes tied to funnel
- [ ] Wednesday: distribution day (community + creator outreach)
- [ ] Thursday: content day (short-form + newsletter)
- [ ] Friday: experiment readout + budget reallocation
- [ ] Sunday: prepare next week assets and schedule

66
docs/reelRollOut.md Normal file
View File

@@ -0,0 +1,66 @@
# SportsTime Reels Rollout (14 Days)
Timezone: **US Eastern (ET)**
Start date: **Monday, February 9, 2026**
End date: **Sunday, February 22, 2026**
## Hashtag Sets
Use one set per post (rotate to avoid repetitive footprint).
- **Set A (General Sports Travel)**
- `#SportsTime #SportsRoadTrip #GameDay #RoadTrip #SportsFans #TravelTok #iPhoneApps #WeekendTrip`
- **Set B (Follow Team / Away Games)**
- `#SportsTime #AwayGame #FollowYourTeam #NBATok #NFLTok #MLBTok #NHLTok #SoccerTok`
- **Set C (Group Planning / Polls)**
- `#SportsTime #GroupTrip #TripPlanning #FriendGroup #SportsTrip #RoadTripIdeas #SportsTok #BucketList`
- **Set D (Stadium Tracker / Completionist)**
- `#SportsTime #StadiumTracker #StadiumChallenge #SportsBucketList #FanLife #BucketList #TravelGoals #USRoadTrip`
- **Set E (Lifestyle / Vlog)**
- `#SportsTime #WeekendVibes #RoadTripVlog #SportsTravel #CoupleTravel #FriendTrips #TravelReels #SportsReels`
## 14-Day Posting Calendar (Exact Publish Times)
| Date | Day | Time (ET) | Video ID | Title | Caption | Hashtag Set | Hook A | Hook B |
|---|---|---|---|---|---|---|---|---|
| 2026-02-09 | Mon | 12:20 PM | V01 | 3-in-4 Challenge | `3 games, 4 days, one car. No spreadsheet. Want one for your city?` | Set A | `I planned a 3-stadium weekend in 14 seconds.` | `This sports road trip looked impossible until I did this.` |
| 2026-02-09 | Mon | 8:40 PM | V02 | Fan Test | `Hot take: if youve never done an away-game road trip, youre missing out.` | Set B | `If youve never done an away-game road trip… are you even a fan?` | `Real fans do at least one away-game run a season.` |
| 2026-02-10 | Tue | 12:20 PM | V03 | "We Should Do This" | `Every group chat says "we should do a trip." Nobody plans it.` | Set C | `Group chat said “we should do this.” No one planned it.` | `Your group chat has ideas. This is how it actually happens.` |
| 2026-02-10 | Tue | 8:40 PM | V04 | Stadium Count Flex | `My friend said hes a diehard fan. I asked for his stadium count.` | Set D | `My friend: 4 stadiums. Me: 27.` | `If you dont track your stadiums, does it even count?` |
| 2026-02-11 | Wed | 12:20 PM | V06 | Date Range Tutorial | `How to build a sports trip in 3 taps.` | Set A | `How to plan a sports road trip in 3 taps.` | `Takes me less than 20 seconds to build a route now.` |
| 2026-02-11 | Wed | 8:40 PM | V07 | Follow Team Mode | `If youre obsessed with one team, this mode is unfair.` | Set B | `Want to follow your team on the road? Do this.` | `Pick your team, and it maps a real road stretch.` |
| 2026-02-12 | Thu | 12:20 PM | V08 | Route-First Planning | `Already driving? Add games on the way without blowing up the trip.` | Set A | `Driving Dallas to Atlanta? Add games on the way.` | `You can turn any long drive into a game-day run.` |
| 2026-02-12 | Thu | 8:40 PM | V09 | Must-See Games First | `I pick the matchups first. The route can figure itself out.` | Set B | `Pick the games first. Let the route figure itself out.` | `Stop planning around highways. Plan around games.` |
| 2026-02-13 | Fri | 12:20 PM | V16 | The 4 Trip Friends | `Tag your group roles right now 😂` | Set C | `Every sports trip has these 4 people.` | `One of your friends is 100% this person.` |
| 2026-02-13 | Fri | 8:40 PM | V19 | Casual Fan Take | `Hot take Friday: home-games-only fans are missing half the fun.` | Set B | `Hot take: home-games-only fans are missing half the fun.` | `Away-game runs are peak fan experience. Debate me.` |
| 2026-02-14 | Sat | 10:30 AM | V11 | 48-Hour Sports Trip | `Perfect weekend blueprint: 2 stadiums, one route, zero chaos.` | Set E | `This is what a perfect sports weekend looks like.` | `48 hours. 2 stadiums. Best weekend in months.` |
| 2026-02-14 | Sat | 7:30 PM | V12 | Date Night, But 300 Miles | `Our date nights are weird and elite.` | Set E | `Our date night is 2 games in 2 cities.` | `Couples therapy: road trip + live sports.` |
| 2026-02-15 | Sun | 11:00 AM | V13 | Squad Road Run | `4 friends, 1 car, and finally no planning meltdown.` | Set E | `4 friends, 1 car, 3 games, no planning pain.` | `What happens when your friend group actually commits.` |
| 2026-02-16 | Mon | 8:40 PM | V05 | Local Instant Trip | `Drop your city and Ill reply with a weekend game run.` | Set A | `From Chicago this weekend? You can hit these 2 games.` | `Your city has a hidden 2-game weekend route.` |
| 2026-02-17 | Tue | 8:40 PM | V10 | Polls / Group Voting | `This feature ended our 200-message argument instantly.` | Set C | `Your group can vote instead of arguing for days.` | `Group trip deadlocks are solved with one poll.` |
| 2026-02-18 | Wed | 8:40 PM | V14 | Road to All Stadiums | `Completionist fans: whats your count right now?` | Set D | `Trying to hit every stadium before 35.` | `This app turned my fandom into a mission.` |
| 2026-02-19 | Thu | 8:40 PM | V17 | Spreadsheet vs SportsTime | `My old planning method was literally 9 tabs and pain.` | Set A | `How we used to plan trips 🤡` | `Before: spreadsheets. After: one app.` |
| 2026-02-20 | Fri | 8:40 PM | V15 | Sunday Ritual (posted Friday teaser) | `Steal this weekend planning ritual before Sunday.` | Set E | `Bored Sunday? Plan your next sports weekend in 2 minutes.` | `My Sunday reset now includes locking one sports trip.` |
| 2026-02-21 | Sat | 11:00 AM | V18 | "Im Down" Friend Test | `Send this to the flaky "Im down" friend.` | Set C | `“Im down” friend when its time to pick dates…` | `If theyre really down, make them vote.` |
| 2026-02-22 | Sun | 7:30 PM | V20 | Completionist Debate | `Controversial take: no tracker = no bucket list proof.` | Set D | `You cant call it a stadium bucket list if you dont track it.` | `No scoreboard, no flex. Whats your count?` |
## Posting Ops Checklist (Per Post)
- [ ] Publish at listed ET time.
- [ ] Pin a first comment with CTA: `Search SportsTime on App Store`.
- [ ] Reply to first 10 comments within 20 minutes.
- [ ] If city is mentioned in comments, reply with `Ill build your route next.`
- [ ] Save best viewer phrases for next hook variants.
## Hook A/B Testing Rule
- First 30 minutes weak retention (<35% at 3s): re-upload next day with Hook B.
- If Hook A drives comments but weak CTR, keep hook and change caption + first comment CTA.
- Dont change both hook and caption on same retry.
## Caption Formula (Reusable)
- `Pain/identity line` + `proof line` + `comment bait`.
- Example: `Every group chat says “we should do this.” I built 3 real options in one app. Drop your city and Ill do one.`

411
docs/reels.md Normal file
View File

@@ -0,0 +1,411 @@
# SportsTime Short-Form Video Concepts (20)
These are designed for TikTok / Reels / Shorts and mapped to install intent.
## Video IDs
- V01 3-in-4 Challenge
- V02 Fan Test
- V03 "We Should Do This" Group Chat
- V04 Stadium Count Flex
- V05 Local Instant Trip
- V06 Date Range Tutorial
- V07 Follow Team Mode
- V08 Route-First Planning
- V09 Build Around Must-See Games
- V10 Stop Group Trip Deadlocks (Polls)
- V11 48-Hour Sports Trip Montage
- V12 Date Night, But 300 Miles
- V13 Squad Road Run
- V14 Road to All Stadiums
- V15 Sunday Planning Ritual
- V16 The 4 Trip Friends
- V17 Spreadsheet Era vs SportsTime Era
- V18 "Im Down" Friend Test
- V19 Casual Fan Take (Away Games)
- V20 Completionist Debate
## 1) V01 - 3-in-4 Challenge (Viral)
1. Hook: "I planned a 3-stadium weekend in 14 seconds."
2. Concept: Impossible-sounding trip planning proven live.
3. Storyboard:
- Scene 1: Face cam in car, urgent zoom.
- Scene 2: App date range + league selection.
- Scene 3: Route map auto-build.
- Scene 4: Itinerary timeline + drive segments.
- Scene 5: Save + share card preview.
4. On-screen text:
- "3 GAMES. 4 DAYS."
- "No spreadsheet"
- "Real schedules + real route"
5. VO: "Picked dates, picked leagues, and it built the trip for me."
6. Shot list: Face cam + screen recording + quick road b-roll.
7. Length: 14s
8. CTA: "Search SportsTime on App Store."
9. Why it performs: utility + challenge + fan flex.
## 2) V02 - Fan Test (Viral)
1. Hook: "If youve never done an away-game road trip… are you even a fan?"
2. Concept: Identity challenge -> Follow Team demo.
3. Storyboard:
- Scene 1: Bold face cam hot take.
- Scene 2: Follow Team mode selection.
- Scene 3: Road games surfaced.
- Scene 4: Route + itinerary shown.
- Scene 5: "This is your weekend." reaction.
4. On-screen text:
- "HOT TAKE"
- "Follow Team mode"
- "Plan it in seconds"
5. VO: "If youre obsessed, do at least one away-game run this season."
6. Shot list: Face cam + follow team screen recording.
7. Length: 18s
8. CTA: "Search SportsTime and run your teams road stretch."
9. Why it performs: debate comments + identity trigger.
## 3) V03 - "We Should Do This" Group Chat (Viral)
1. Hook: Group chat screenshot + "NOBODY planned it."
2. Concept: Group chat talk -> one person ships plan + poll.
3. Storyboard:
- Scene 1: Chaotic group text overlay.
- Scene 2: Build trip by dates.
- Scene 3: Routes generated.
- Scene 4: Create poll + share code.
- Scene 5: Votes arrive quickly.
4. On-screen text:
- "Every group chat ever"
- "All talk"
- "Poll it. Done."
5. VO: "We talked about this for months. I built it and made everyone vote."
6. Shot list: iMessage overlays + app screen recording.
7. Length: 16s
8. CTA: "If your group chat is all talk, search SportsTime."
9. Why it performs: social pain + collaboration payoff.
## 4) V04 - Stadium Count Flex (Viral)
1. Hook: "My friend: 4 stadiums. Me: 27."
2. Concept: Completionist scoreboard flex.
3. Storyboard:
- Scene 1: Friend vs me split text.
- Scene 2: Open stadium tracker.
- Scene 3: Scroll visited list.
- Scene 4: Next targets on map.
- Scene 5: "catch me at 30".
4. On-screen text:
- "STADIUM FLEX"
- "Visited: 27"
- "Track your run"
5. VO: "Once you track it, it gets addictive."
6. Shot list: tracker screen recording + optional reaction cam.
7. Length: 12s
8. CTA: "Search SportsTime if youre a completionist fan."
9. Why it performs: status + collection psychology.
## 5) V05 - Local Instant Trip (Viral)
1. Hook: "From Chicago this weekend? You can hit these 2 games."
2. Concept: Hyper-local route reveal format.
3. Storyboard:
- Scene 1: City text card.
- Scene 2: Enter start/end city.
- Scene 3: Route with game pins.
- Scene 4: Itinerary timing.
- Scene 5: Save and share.
4. On-screen text:
- "Chicago -> 2 games"
- "Drive-time checked"
- "Build yours"
5. VO: "If youre in [city], heres a legit two-game run."
6. Shot list: map-heavy screen recording + optional city b-roll.
7. Length: 13s
8. CTA: "Comment your city. Search SportsTime."
9. Why it performs: local relevance + comments.
## 6) V06 - Date Range Tutorial (Useful)
1. Hook: "How to plan a sports road trip in 3 taps."
2. Concept: Step-based quick tutorial.
3. Storyboard:
- Scene 1: Pick dates.
- Scene 2: Pick leagues.
- Scene 3: Generate routes.
- Scene 4: Compare options.
- Scene 5: Save trip.
4. On-screen text: "Step 1", "Step 2", "Step 3", "Done".
5. VO: "Dates, leagues, generate. Thats it."
6. Shot list: pure screen recording, tap circles.
7. Length: 15s
8. CTA: "Search SportsTime and test your next free weekend."
9. Why it performs: clarity lowers install friction.
## 7) V07 - Follow Team Mode (Useful)
1. Hook: "Want to follow your team on the road? Do this."
2. Concept: Team-first intent flow.
3. Storyboard:
- Scene 1: Select Follow Team.
- Scene 2: Pick team + dates.
- Scene 3: Road games list.
- Scene 4: Route generated.
- Scene 5: miles/days/games summary.
4. On-screen text:
- "Follow Team"
- "Road games only"
- "Route + schedule synced"
5. VO: "Pick your team, pick dates, get a runnable trip."
6. Shot list: app screen recording + team logo sticker.
7. Length: 17s
8. CTA: "Search SportsTime and chase the road schedule."
9. Why it performs: direct fit for team-obsessed users.
## 8) V08 - Route-First Planning (Useful)
1. Hook: "Driving Dallas -> Atlanta? Add games on the way."
2. Concept: Existing travel plus sports optimization.
3. Storyboard:
- Scene 1: Enter start/end city.
- Scene 2: Set stops and dates.
- Scene 3: Game stops inserted.
- Scene 4: Compare options A/B.
- Scene 5: Save preferred route.
4. On-screen text:
- "Start + End"
- "Add game stops"
- "Compare routes"
5. VO: "If youre already driving, this adds games without chaos."
6. Shot list: map route recording + optional steering wheel shot.
7. Length: 18s
8. CTA: "Search SportsTime before your next long drive."
9. Why it performs: practical utility for road-trippers.
## 9) V09 - Must-See Games First (Useful)
1. Hook: "Pick the games first. Let the route figure itself out."
2. Concept: Matchup-driven planning.
3. Storyboard:
- Scene 1: Open By Games.
- Scene 2: Tap 2 must-see games.
- Scene 3: Date range adapts.
- Scene 4: Route options load.
- Scene 5: Final itinerary timeline.
4. On-screen text:
- "Must-see first"
- "Route auto-built"
- "No headache"
5. VO: "I pick matchups. The app handles logistics."
6. Shot list: game card taps + route output.
7. Length: 16s
8. CTA: "Search SportsTime if you plan around big games."
9. Why it performs: emotionally-driven fan flow.
## 10) V10 - Stop Group Trip Deadlocks (Useful)
1. Hook: "Your group can vote instead of arguing for days."
2. Concept: Poll flow from route options.
3. Storyboard:
- Scene 1: Generate 3 options.
- Scene 2: Create poll.
- Scene 3: Share code to chat.
- Scene 4: Votes come in.
- Scene 5: Winner route selected.
4. On-screen text:
- "3 options"
- "Create poll"
- "Decision made"
5. VO: "This replaced 200 group chat messages."
6. Shot list: app poll + iMessage overlay.
7. Length: 20s
8. CTA: "Search SportsTime and settle your group chat."
9. Why it performs: high social relatability + clear solve.
## 11) V11 - 48-Hour Sports Trip Montage (Lifestyle)
1. Hook: "This is what a perfect sports weekend looks like."
2. Concept: Mini-vlog tied to app plan.
3. Storyboard:
- Scene 1: Packing + itinerary open.
- Scene 2: Highway sunrise + route overlay.
- Scene 3: Stadium #1 clip.
- Scene 4: Night drive + day 2 plan.
- Scene 5: Stadium #2 + completed checklist.
4. On-screen text:
- "FRI -> SUN"
- "2 stadiums"
- "0 chaos"
5. VO: "Friday we planned it, Sunday we checked two stadiums off."
6. Shot list: vlog b-roll + app overlays.
7. Length: 22s
8. CTA: "Build your next weekend in SportsTime."
9. Why it performs: aspiration + proof.
## 12) V12 - Date Night, But 300 Miles (Lifestyle)
1. Hook: "Our date night is 2 games in 2 cities."
2. Concept: Couple sports-trip format.
3. Storyboard:
- Scene 1: Couple selfie in car.
- Scene 2: Itinerary sequence screen.
- Scene 3: Stadium + food shot.
- Scene 4: Hotel + next game card.
- Scene 5: Tracker updates +2.
4. On-screen text:
- "Couple trip"
- "Game + road trip"
- "Itinerary handled"
5. VO: "We just pick dates, SportsTime does the rest."
6. Shot list: couple b-roll + app screen captures.
7. Length: 19s
8. CTA: "Search SportsTime for your next sports weekend."
9. Why it performs: relationship + travel + fandom crossover.
## 13) V13 - Squad Road Run (Lifestyle)
1. Hook: "4 friends, 1 car, 3 games, zero planning pain."
2. Concept: Group road-trip energy.
3. Storyboard:
- Scene 1: Friends pile into car.
- Scene 2: Route on phone mount.
- Scene 3: In-car energy + map progress.
- Scene 4: Stadium arrival.
- Scene 5: Next step planned.
4. On-screen text:
- "No planner friend suffering"
- "Route locked"
- "Squad weekend"
5. VO: "Usually one friend does all the work. Not this time."
6. Shot list: handheld group footage + route screen insert.
7. Length: 20s
8. CTA: "Send this to your group, then search SportsTime."
9. Why it performs: taggable friend dynamic.
## 14) V14 - Road to All Stadiums (Lifestyle)
1. Hook: "Trying to hit every stadium before 35."
2. Concept: Long-term progression narrative.
3. Storyboard:
- Scene 1: Tracker count (12/30 etc).
- Scene 2: Archive clips from past visits.
- Scene 3: Plan next route in app.
- Scene 4: New visit added.
- Scene 5: Updated count.
4. On-screen text:
- "Bucket list goal"
- "12/30 -> 13/30"
- "One by one"
5. VO: "This turned random trips into an actual mission."
6. Shot list: tracker before/after + archive b-roll.
7. Length: 18s
8. CTA: "Search SportsTime if youre on a stadium mission."
9. Why it performs: progress storytelling loop.
## 15) V15 - Sunday Planning Ritual (Lifestyle)
1. Hook: "Bored Sunday? Plan your next sports weekend in 2 minutes."
2. Concept: Planning as ritual content.
3. Storyboard:
- Scene 1: Coffee + couch reset shot.
- Scene 2: Choose dates/leagues.
- Scene 3: Route options generated.
- Scene 4: Share itinerary.
- Scene 5: Add to calendar.
4. On-screen text:
- "Sunday ritual"
- "Pick dates"
- "Weekend secured"
5. VO: "I do this every Sunday now."
6. Shot list: cozy lifestyle + over-shoulder app shots.
7. Length: 15s
8. CTA: "Search SportsTime and make this your Sunday ritual."
9. Why it performs: repeatable weekly behavior.
## 16) V16 - The 4 Trip Friends (Funny)
1. Hook: "Every sports trip has these 4 people."
2. Concept: One-person roleplay + poll solution.
3. Storyboard:
- Scene 1: The hype friend.
- Scene 2: The picky friend.
- Scene 3: The loyalist.
- Scene 4: The confused friend.
- Scene 5: Poll decides route.
4. On-screen text:
- "The hype one"
- "The picky one"
- "The confused one"
- "Poll > arguing"
5. VO: "Instead of debating for days, we voted and moved."
6. Shot list: quick costume swaps + app poll screen.
7. Length: 21s
8. CTA: "Tag your group roles. Search SportsTime."
9. Why it performs: humor + tagging behavior.
## 17) V17 - Spreadsheet Era vs SportsTime Era (Funny)
1. Hook: "How we used to plan trips 🤡"
2. Concept: before/after pain-to-relief format.
3. Storyboard:
- Scene 1: 9 tabs and notes chaos.
- Scene 2: frustrated face.
- Scene 3: hard cut to SportsTime flow.
- Scene 4: instant route and calm reaction.
4. On-screen text:
- "Before: chaos"
- "After: 1 app"
- "Same result"
5. VO: "I used to spend 2 hours; now its 2 minutes."
6. Shot list: laptop chaos + app screen capture.
7. Length: 13s
8. CTA: "Retire the spreadsheet. Search SportsTime."
9. Why it performs: pain-relief contrast.
## 18) V18 - "Im Down" Friend Test (Funny)
1. Hook: "Im down friend when its time to pick dates…"
2. Concept: flaky friend callout + poll accountability.
3. Storyboard:
- Scene 1: "Im down" text.
- Scene 2: excuse texts.
- Scene 3: poll creation in app.
- Scene 4: vote deadline.
- Scene 5: winner route.
4. On-screen text:
- "Im down starter pack"
- "No decisions"
- "Poll > excuses"
5. VO: "If youre really down, vote."
6. Shot list: message overlays + poll screen recording.
7. Length: 14s
8. CTA: "Send this to your flaky friend. Search SportsTime."
9. Why it performs: relatable social friction + taggable joke.
## 19) V19 - Casual Fan Take (Bold)
1. Hook: "Hot take: home-games-only fans are missing half the fun."
2. Concept: away-game opinion + follow-team proof.
3. Storyboard:
- Scene 1: face cam hot take.
- Scene 2: away-game benefits list.
- Scene 3: follow-team route build.
- Scene 4: itinerary with 2 away games.
- Scene 5: "debate me" end card.
4. On-screen text:
- "HOT TAKE"
- "Away games > comfort"
- "Debate below"
5. VO: "If youre serious, do one road stretch this season."
6. Shot list: face cam + follow-team app demo.
7. Length: 17s
8. CTA: "Search SportsTime for your first away-game run."
9. Why it performs: controversy + identity.
## 20) V20 - Completionist Debate (Bold)
1. Hook: "You cant call it a stadium bucket list if you dont track it."
2. Concept: status challenge using tracker proof.
3. Storyboard:
- Scene 1: "prove it" face cam.
- Scene 2: tracker milestones.
- Scene 3: next targets map.
- Scene 4: "drop your count" challenge.
- Scene 5: clean progress UI outro.
4. On-screen text:
- "No tracking = no scoreboard"
- "Drop your number"
5. VO: "Everyone says theyre doing the bucket list. Whats your count?"
6. Shot list: face cam + tracker screen recording.
7. Length: 12s
8. CTA: "Search SportsTime and start your real count."
9. Why it performs: competitive status challenge.
## Batch Production Plan (One Week)
- Day 1: Capture all app screen recordings.
- Day 2: Film all face-cam hooks in one session.
- Day 3: Capture road/stadium/lifestyle b-roll.
- Day 4: Edit and export first 10 videos.
- Day 5: Edit and export final 10 videos.

BIN
icon.pxd

Binary file not shown.

View File

@@ -13,26 +13,26 @@
"render:fantest": "remotion render TheFanTest out/the-fan-test.mp4",
"render:groupchat": "remotion render TheGroupChat out/the-group-chat.mp4",
"render:all-originals": "npm run render:route && npm run render:checklist && npm run render:bucketlist && npm run render:squad && npm run render:handoff && npm run render:fantest && npm run render:groupchat",
"render:V03_H01": "remotion render V03_H01 out/week1/V03_H01.mp4",
"render:V10_H01": "remotion render V10_H01 out/week1/V10_H01.mp4",
"render:V03_H02": "remotion render V03_H02 out/week1/V03_H02.mp4",
"render:V10_H02": "remotion render V10_H02 out/week1/V10_H02.mp4",
"render:V03_H03": "remotion render V03_H03 out/week1/V03_H03.mp4",
"render:V17_H01": "remotion render V17_H01 out/week1/V17_H01.mp4",
"render:V17_H02": "remotion render V17_H02 out/week1/V17_H02.mp4",
"render:V06_H01": "remotion render V06_H01 out/week1/V06_H01.mp4",
"render:V08_H01": "remotion render V08_H01 out/week1/V08_H01.mp4",
"render:V05_LA_01": "remotion render V05_LA_01 out/week1/V05_LA_01.mp4",
"render:V05_NY_01": "remotion render V05_NY_01 out/week1/V05_NY_01.mp4",
"render:V05_TX_01": "remotion render V05_TX_01 out/week1/V05_TX_01.mp4",
"render:V05_CA_01": "remotion render V05_CA_01 out/week1/V05_CA_01.mp4",
"render:V08_LA_01": "remotion render V08_LA_01 out/week1/V08_LA_01.mp4",
"render:V04_H01": "remotion render V04_H01 out/week1/V04_H01.mp4",
"render:V20_H01": "remotion render V20_H01 out/week1/V20_H01.mp4",
"render:V14_H01": "remotion render V14_H01 out/week1/V14_H01.mp4",
"render:V04_H02": "remotion render V04_H02 out/week1/V04_H02.mp4",
"render:V02_H01": "remotion render V02_H01 out/week1/V02_H01.mp4",
"render:V19_H01": "remotion render V19_H01 out/week1/V19_H01.mp4",
"render:V03-H01": "remotion render V03-H01 out/week1/V03-H01.mp4",
"render:V10-H01": "remotion render V10-H01 out/week1/V10-H01.mp4",
"render:V03-H02": "remotion render V03-H02 out/week1/V03-H02.mp4",
"render:V10-H02": "remotion render V10-H02 out/week1/V10-H02.mp4",
"render:V03-H03": "remotion render V03-H03 out/week1/V03-H03.mp4",
"render:V17-H01": "remotion render V17-H01 out/week1/V17-H01.mp4",
"render:V17-H02": "remotion render V17-H02 out/week1/V17-H02.mp4",
"render:V06-H01": "remotion render V06-H01 out/week1/V06-H01.mp4",
"render:V08-H01": "remotion render V08-H01 out/week1/V08-H01.mp4",
"render:V05-LA-01": "remotion render V05-LA-01 out/week1/V05-LA-01.mp4",
"render:V05-NY-01": "remotion render V05-NY-01 out/week1/V05-NY-01.mp4",
"render:V05-TX-01": "remotion render V05-TX-01 out/week1/V05-TX-01.mp4",
"render:V05-CA-01": "remotion render V05-CA-01 out/week1/V05-CA-01.mp4",
"render:V08-LA-01": "remotion render V08-LA-01 out/week1/V08-LA-01.mp4",
"render:V04-H01": "remotion render V04-H01 out/week1/V04-H01.mp4",
"render:V20-H01": "remotion render V20-H01 out/week1/V20-H01.mp4",
"render:V14-H01": "remotion render V14-H01 out/week1/V14-H01.mp4",
"render:V04-H02": "remotion render V04-H02 out/week1/V04-H02.mp4",
"render:V02-H01": "remotion render V02-H01 out/week1/V02-H01.mp4",
"render:V19-H01": "remotion render V19-H01 out/week1/V19-H01.mp4",
"render:week1": "bash scripts/render-week1.sh",
"render:all": "npm run render:all-originals && npm run render:week1"
},

View File

@@ -0,0 +1,39 @@
# Asset Placeholder Directory
Replace these placeholders with real assets before rendering final videos.
## Screen Recordings (`screenrecs/`)
Record these on-device using the SportsTime app:
| File | Description | Screen to Record |
|------|-------------|-----------------|
| `date_range.mp4` | Picking dates in trip wizard | Trip Wizard → Date Range step |
| `follow_team.mp4` | Following a team flow | Trip Wizard → Follow Team mode |
| `by_games.mp4` | Browsing by games | Trip Wizard → By Games mode |
| `route_generated.mp4` | Route result appearing | Trip Detail → after planning |
| `poll_create.mp4` | Creating a group poll | Trip Options → Share/Poll |
| `tracker.mp4` | Stadium tracker/bucket list | Profile → Stadium Tracker |
**Recording tips:**
- Use iPhone screen recording (Settings → Control Center)
- Portrait orientation, 1080x1920 native
- 5-10 seconds per clip
- Clean, smooth taps (no fumbling)
## Overlays (`overlays/`)
| File | Description |
|------|-------------|
| `imessage_bg.png` | iMessage background texture (optional) |
| `chat_bubbles.png` | Chat bubble overlay (optional) |
| `vote_bubbles.png` | Vote notification overlay (optional) |
## B-Roll (`broll/`)
| File | Description | Source |
|------|-------------|--------|
| `highway.mp4` | Highway driving footage | Stock/Pexels |
| `city.mp4` | City skyline/aerial | Stock/Pexels |
| `stadium.mp4` | Stadium exterior | Stock/Pexels |
**Note:** B-roll is optional. Videos render with placeholder scenes when assets are missing.

View File

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# Batch render all 20 Week 1 Reels videos.
# Usage: bash scripts/render-week1.sh [--concurrency N]
#
# Options:
# --concurrency N Number of parallel renders (default: 2)
# --ids ID1,ID2 Comma-separated list of specific IDs to render
#
set -euo pipefail
CONCURRENCY=${1:-2}
OUT_DIR="out/week1"
mkdir -p "$OUT_DIR"
IDS=(
V03-H01
V10-H01
V03-H02
V10-H02
V03-H03
V17-H01
V17-H02
V06-H01
V08-H01
V05-LA-01
V05-NY-01
V05-TX-01
V05-CA-01
V08-LA-01
V04-H01
V20-H01
V14-H01
V04-H02
V02-H01
V19-H01
)
# Parse optional --ids flag
if [[ "${1:-}" == "--ids" ]]; then
IFS=',' read -ra IDS <<< "${2:-}"
shift 2
fi
TOTAL=${#IDS[@]}
DONE=0
FAILED=0
echo ""
echo "=== SportsTime Week 1 Batch Render ==="
echo " Videos: $TOTAL"
echo " Output: $OUT_DIR/"
echo " Concurrency: $CONCURRENCY"
echo ""
render_one() {
local id=$1
local out="$OUT_DIR/${id}.mp4"
echo "[START] $id$out"
if npx remotion render "$id" "$out" --log=error 2>&1; then
echo "[DONE] $id"
return 0
else
echo "[FAIL] $id"
return 1
fi
}
export -f render_one
export OUT_DIR
# Run renders with controlled concurrency using xargs
printf '%s\n' "${IDS[@]}" | xargs -P "$CONCURRENCY" -I {} bash -c 'render_one "$@"' _ {}
echo ""
echo "=== Render Complete ==="
echo " Output: $OUT_DIR/"
ls -lh "$OUT_DIR"/*.mp4 2>/dev/null || echo " (no .mp4 files found - check errors above)"

View File

@@ -8,11 +8,24 @@ import { TheSquad } from "./videos/TheSquad";
import { TheHandoff } from "./videos/TheHandoff";
import { TheFanTest } from "./videos/TheFanTest";
import { TheGroupChat } from "./videos/TheGroupChat";
import { StadiumCountFlex } from "./videos/StadiumCountFlex";
import { GroupChatChaos } from "./videos/GroupChatChaos";
import { LocalCityRoute } from "./videos/LocalCityRoute";
import { SpreadsheetEra } from "./videos/SpreadsheetEra";
import { AwayGameTake } from "./videos/AwayGameTake";
import { VideoFromConfig } from "./engine";
import type { VideoConfig } from "./engine";
import week1Configs from "./configs/week1.json";
/**
* Wrapper component that receives config via defaultProps.
* Avoids inline arrow functions in Composition component prop.
*/
const ConfigVideo: React.FC<{ config: VideoConfig }> = ({ config }) => {
return <VideoFromConfig config={config} />;
};
/**
* SportsTime Marketing Videos
*
@@ -101,13 +114,58 @@ export const RemotionRoot: React.FC = () => {
/>
</Folder>
{/* Hand-crafted TikTok/Reels - unique visual identity per video */}
<Folder name="TikTok-Originals">
<Composition
id="StadiumCountFlex"
component={StadiumCountFlex}
durationInFrames={15 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="GroupChatChaos"
component={GroupChatChaos}
durationInFrames={16 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="LocalCityRoute"
component={LocalCityRoute}
durationInFrames={14 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="SpreadsheetEra"
component={SpreadsheetEra}
durationInFrames={15 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="AwayGameTake"
component={AwayGameTake}
durationInFrames={15 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
</Folder>
{/* Week 1: 20 config-driven TikTok/Reels videos */}
<Folder name="Week1-Reels">
{configs.map((config) => (
<Composition
key={config.id}
id={config.id}
component={() => <VideoFromConfig config={config} />}
component={ConfigVideo}
defaultProps={{ config }}
durationInFrames={Math.round(config.targetLengthSec * FPS)}
fps={FPS}
width={WIDTH}

View File

@@ -0,0 +1,43 @@
import React from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
/**
* Subtle film grain overlay that makes videos feel organic/real.
* Uses deterministic noise per frame for reproducible renders.
*/
export const FilmGrain: React.FC<{ opacity?: number }> = ({
opacity = 0.04,
}) => {
const frame = useCurrentFrame();
// Shift the noise pattern each frame using a CSS trick
const offsetX = ((frame * 73) % 200) - 100;
const offsetY = ((frame * 47) % 200) - 100;
return (
<AbsoluteFill
style={{
pointerEvents: "none",
mixBlendMode: "overlay",
opacity,
}}
>
<svg width="100%" height="100%">
<filter id={`grain-${frame % 10}`}>
<feTurbulence
type="fractalNoise"
baseFrequency="0.65"
numOctaves="3"
seed={frame}
stitchTiles="stitch"
/>
</filter>
<rect
width="100%"
height="100%"
filter={`url(#grain-${frame % 10})`}
transform={`translate(${offsetX}, ${offsetY})`}
/>
</svg>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,306 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "./theme";
/**
* TikTok-native kinetic caption system.
*
* Unlike generic subtitle overlays, each style mimics real TikTok
* caption patterns: punch zooms, word pops, highlight boxes, etc.
*/
type CaptionEntry = {
text: string;
startSec: number;
endSec: number;
style?: "punch" | "highlight" | "stack" | "whisper" | "shake";
};
type TikTokCaptionProps = {
captions: CaptionEntry[];
/** Vertical position from bottom (px) */
bottomOffset?: number;
};
export const TikTokCaption: React.FC<TikTokCaptionProps> = ({
captions,
bottomOffset = 280,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentSec = frame / fps;
const active = captions.find(
(c) => currentSec >= c.startSec && currentSec < c.endSec
);
if (!active) return null;
const startFrame = active.startSec * fps;
const endFrame = active.endSec * fps;
const localFrame = frame - startFrame;
const style = active.style || "punch";
const exitOpacity = interpolate(
frame,
[endFrame - 4, endFrame],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
style={{
position: "absolute",
bottom: bottomOffset,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
opacity: exitOpacity,
zIndex: 100,
}}
>
{style === "punch" && (
<PunchCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "highlight" && (
<HighlightCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "stack" && (
<StackCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "whisper" && (
<WhisperCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "shake" && (
<ShakeCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
</div>
);
};
/** Punch zoom in - text scales from big to normal with impact */
const PunchCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const prog = spring({
frame: localFrame,
fps,
config: { damping: 10, stiffness: 280 },
});
const scale = interpolate(prog, [0, 1], [1.8, 1]);
const opacity = interpolate(prog, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 52,
fontWeight: 900,
color: "white",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: -1,
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
transform: `scale(${scale})`,
opacity,
maxWidth: 900,
lineHeight: 1.15,
}}
>
{text}
</span>
);
};
/** Highlight box - text with colored background box that wipes in */
const HighlightCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const boxProg = spring({
frame: localFrame,
fps,
config: { damping: 15, stiffness: 200 },
});
const textProg = spring({
frame: localFrame - 3,
fps,
config: { damping: 20, stiffness: 180 },
});
return (
<div style={{ position: "relative", display: "inline-block" }}>
<div
style={{
position: "absolute",
inset: "-8px -20px",
background: theme.colors.accent,
borderRadius: 8,
transform: `scaleX(${boxProg})`,
transformOrigin: "left",
}}
/>
<span
style={{
position: "relative",
fontFamily: theme.fonts.display,
fontSize: 46,
fontWeight: 800,
color: "white",
opacity: interpolate(textProg, [0, 1], [0, 1]),
letterSpacing: -0.5,
textShadow: "0 2px 8px rgba(0,0,0,0.3)",
}}
>
{text}
</span>
</div>
);
};
/** Stack - words stack vertically, each popping in */
const StackCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const words = text.split(" ");
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
{words.map((word, i) => {
const delay = i * 2;
const prog = spring({
frame: localFrame - delay,
fps,
config: { damping: 12, stiffness: 250 },
});
return (
<span
key={i}
style={{
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 900,
color: "white",
textTransform: "uppercase",
letterSpacing: 2,
transform: `scale(${interpolate(prog, [0, 1], [0.5, 1])}) translateY(${interpolate(prog, [0, 1], [20, 0])}px)`,
opacity: interpolate(prog, [0, 0.5], [0, 1], {
extrapolateRight: "clamp",
}),
textShadow:
"0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
lineHeight: 1.0,
}}
>
{word}
</span>
);
})}
</div>
);
};
/** Whisper - small italic text that fades in gently */
const WhisperCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const opacity = interpolate(localFrame, [0, fps * 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 32,
fontWeight: 400,
fontStyle: "italic",
color: "rgba(255,255,255,0.8)",
textAlign: "center",
opacity,
letterSpacing: 1,
textShadow: "0 2px 12px rgba(0,0,0,0.6)",
}}
>
{text}
</span>
);
};
/** Shake - text shakes briefly on entrance then settles */
const ShakeCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const prog = spring({
frame: localFrame,
fps,
config: { damping: 8, stiffness: 300 },
});
// Shake offsets that decay over ~5 frames
const shakeIntensity = interpolate(localFrame, [0, 5], [8, 0], {
extrapolateRight: "clamp",
});
const OFFSETS = [
{ x: -1, y: 1 },
{ x: 1, y: -1 },
{ x: -1, y: -1 },
{ x: 1, y: 1 },
{ x: 0, y: -1 },
];
const offset = OFFSETS[localFrame % OFFSETS.length];
const sx = offset.x * shakeIntensity;
const sy = offset.y * shakeIntensity;
return (
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 50,
fontWeight: 900,
color: "white",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: -0.5,
transform: `translate(${sx}px, ${sy}px) scale(${interpolate(prog, [0, 1], [1.3, 1])})`,
opacity: interpolate(prog, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
}),
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
maxWidth: 900,
lineHeight: 1.15,
}}
>
{text}
</span>
);
};
export type { CaptionEntry };

View File

@@ -4,3 +4,6 @@ export { TextReveal, TextRevealMultiline, HighlightText } from "./TextReveal";
export { TapIndicator, SwipeIndicator } from "./TapIndicator";
export { AppScreenshot, MockScreen } from "./AppScreenshot";
export { GradientBackground, GridBackground, GlowBackground } from "./Background";
export { FilmGrain } from "./FilmGrain";
export { TikTokCaption } from "./TikTokCaption";
export type { CaptionEntry } from "./TikTokCaption";

View File

@@ -0,0 +1,430 @@
[
{
"id": "V03-H01",
"base": "V03",
"hook": "NOBODY planned it. So I made a poll.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "Every group chat ever" } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Built the trip in 20 sec" } },
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Dallas", "x": 45, "y": 55 }, { "name": "Houston", "x": 50, "y": 70 }, { "name": "San Antonio", "x": 38, "y": 72 }] } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which trip?", "options": [{ "label": "Texas Triangle", "votes": 4, "emoji": "🤠" }, { "label": "East Coast", "votes": 2, "emoji": "🗽" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Nobody was planning it", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "So I opened SportsTime", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "Built a route in 20 seconds", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Dropped the poll", "startSec": 8.0, "endSec": 9.8, "emphasis": "normal" },
{ "text": "Trip booked by lunch", "startSec": 10.3, "endSec": 12.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Nobody plans it. So I did.",
"targetLengthSec": 15,
"assets": { "screenrec": ["route-generated"], "overlay": ["chat-bubbles", "vote-bubbles"] }
},
{
"id": "V10-H01",
"base": "V10",
"hook": "If your group chat needs 200 messages… use this.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "200 messages later…", "groupName": "Trip Planning 🏈", "groupSize": 5 } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "One app. One route." } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Dallas or Houston first?", "options": [{ "label": "Dallas first", "votes": 3, "emoji": "⭐" }, { "label": "Houston first", "votes": 2, "emoji": "🚀" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Your group chat after someone says 'trip'", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
{ "text": "200 messages… zero plans", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
{ "text": "Or just use this", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Route built. Poll sent.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Trip decided in 3 minutes", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Two hundred messages or one app. Your call.",
"targetLengthSec": 13
},
{
"id": "V03-H02",
"base": "V03",
"hook": "Every sports trip starts with 'we should do this' 🤡",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "🤡" } },
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "The cycle continues…", "messages": [{ "sender": "Jake", "text": "We should do a baseball trip", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "Bro YES", "isMe": false, "delaySec": 0.5 }, { "sender": "Sam", "text": "When?", "isMe": false, "delaySec": 0.8 }, { "sender": "Jake", "text": "Idk someone plan it", "isMe": false, "delaySec": 1.2 }, { "sender": "You", "text": "Fine. I'll do it.", "isMe": true, "delaySec": 1.7 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Picked dates + sports" } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which route?", "options": [{ "label": "LA → SD", "votes": 3, "emoji": "🌴" }, { "label": "NY → BOS", "votes": 2, "emoji": "🗽" }, { "label": "CHI → DET", "votes": 1, "emoji": "🌊" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Every. Single. Time.", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "'Someone plan it'", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Fine. I opened SportsTime.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "3 options. 1 poll.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Trip planned in 2 min", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Every trip starts the same way. End it different.",
"targetLengthSec": 13
},
{
"id": "V10-H02",
"base": "V10",
"hook": "3 trip options. 1 vote. Done.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.0 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Generated 3 routes" } },
{ "type": "POLL", "durationSec": 3.0, "props": { "question": "Pick our trip", "options": [{ "label": "Texas Triangle 🤠", "votes": 4, "emoji": "🤠" }, { "label": "Cali Coast 🌴", "votes": 3, "emoji": "🌴" }, { "label": "Northeast 🗽", "votes": 1, "emoji": "🗽" }] } },
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "Group chat: decided", "messages": [{ "sender": "Jake", "text": "Texas it is!!", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "LETS GO 🤠", "isMe": false, "delaySec": 0.5 }, { "sender": "You", "text": "Booked ✅", "isMe": true, "delaySec": 0.9 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "3 trip options", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
{ "text": "Generated in seconds", "startSec": 2.3, "endSec": 4.3, "emphasis": "normal" },
{ "text": "1 vote to decide", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Done. Trip booked.", "startSec": 7.5, "endSec": 9.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Three options. One vote. Done.",
"targetLengthSec": 13
},
{
"id": "V03-H03",
"base": "V03",
"hook": "All talk → I shipped the itinerary.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "3 months of 'we should'", "messages": [{ "sender": "Jake", "text": "We should do a trip", "isMe": false, "delaySec": 0.15 }, { "sender": "Mike", "text": "For sure", "isMe": false, "delaySec": 0.4 }, { "sender": "Sam", "text": "Down", "isMe": false, "delaySec": 0.6 }] } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "So I just did it" } },
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 54, "y": 30 }, { "name": "Detroit", "x": 62, "y": 33 }] } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "We going?", "options": [{ "label": "YES 🔥", "votes": 4, "emoji": "🔥" }, { "label": "Can't make it", "votes": 0, "emoji": "😢" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "3 months of all talk", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
{ "text": "'We should do a trip' — nobody moves", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
{ "text": "So I opened SportsTime", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Built the route, mapped the games", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Shipped the itinerary", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" },
{ "text": "4-0 vote. We're going.", "startSec": 12.5, "endSec": 14.0, "emphasis": "highlight" }
],
"cta": "Search SportsTime on the App Store",
"vo": "All talk. Until I shipped the itinerary.",
"targetLengthSec": 15
},
{
"id": "V17-H01",
"base": "V17",
"hook": "The spreadsheet era is over.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Pick dates → pick sports" } },
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "No spreadsheet needed.", "variant": "slam" } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Route + games auto-matched" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Stop planning trips in spreadsheets", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "Pick your dates and sports", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "No spreadsheet needed", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Route + games auto-matched", "startSec": 8.0, "endSec": 10.5, "emphasis": "normal" },
{ "text": "Done in 20 seconds", "startSec": 11.0, "endSec": 12.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Spreadsheets are over. This is how you plan now.",
"targetLengthSec": 13
},
{
"id": "V17-H02",
"base": "V17",
"hook": "I used to waste 2 hours. Now it's 20 seconds.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "2 hours → 20 seconds", "variant": "split" } },
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/by-games.mp4", "caption": "The whole flow" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Planning a sports trip used to take hours", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
{ "text": "2 hours → 20 seconds", "startSec": 2.8, "endSec": 4.5, "emphasis": "highlight" },
{ "text": "Pick sports. Pick dates. Get a route.", "startSec": 5.0, "endSec": 7.5, "emphasis": "normal" },
{ "text": "That's literally it", "startSec": 8.0, "endSec": 9.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Two hours of Googling or twenty seconds. You pick.",
"targetLengthSec": 12
},
{
"id": "V06-H01",
"base": "V06",
"hook": "Plan a multi-game weekend in 3 taps.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.0 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Tap 1: Dates" } },
{ "type": "TEXTPUNCH", "durationSec": 1.5, "props": { "text": "3 taps. Multiple games.", "variant": "slam" } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Tap 3: Route" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Multi-game weekend?", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
{ "text": "Tap 1: Pick your dates", "startSec": 2.3, "endSec": 4.0, "emphasis": "normal" },
{ "text": "Tap 2: Pick your sports", "startSec": 4.3, "endSec": 5.5, "emphasis": "normal" },
{ "text": "Tap 3: Route generated", "startSec": 6.0, "endSec": 8.0, "emphasis": "highlight" },
{ "text": "That easy", "startSec": 8.5, "endSec": 10.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Three taps. Multi-game weekend. Planned.",
"targetLengthSec": 12
},
{
"id": "V08-H01",
"base": "V08",
"hook": "Already driving? Add games without chaos.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Add a game stop mid-drive" } },
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Rerouted", "cities": [{ "name": "Start", "x": 30, "y": 40 }, { "name": "Game Stop", "x": 50, "y": 50 }, { "name": "Destination", "x": 70, "y": 40 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Already on the road?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "Add a game stop without the chaos", "startSec": 2.8, "endSec": 5.0, "emphasis": "normal" },
{ "text": "Route adjusts automatically", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Detour = more games", "startSec": 8.0, "endSec": 10.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Mid-drive game stop. No chaos.",
"targetLengthSec": 13
},
{
"id": "V05-LA-01",
"base": "V05",
"hook": "LA this weekend? Here's a REAL 2-game run.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim", "cities": [{ "name": "Dodger Stadium", "x": 40, "y": 45 }, { "name": "Angel Stadium", "x": 55, "y": 60 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Full weekend itinerary" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "LA this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Dodgers Friday → Angels Saturday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "30 min drive between stadiums", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "2-game weekend locked", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "SportsTime planned it all", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "LA two-game weekend. Planned in seconds.",
"targetLengthSec": 13
},
{
"id": "V05-NY-01",
"base": "V05",
"hook": "NYC this weekend? You can hit 2 games.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NYC → Jersey", "cities": [{ "name": "Yankee Stadium", "x": 48, "y": 35 }, { "name": "MetLife Stadium", "x": 42, "y": 42 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Weekend mapped out" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "NYC this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Yankees Saturday → Giants Sunday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Both games. One weekend.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Route optimized automatically", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "That's the move", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "NYC two-gamer. Hit both without the hassle.",
"targetLengthSec": 13
},
{
"id": "V05-TX-01",
"base": "V05",
"hook": "Texas road trip? Dallas → Houston + games.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Texas Triangle", "cities": [{ "name": "Dallas", "x": 45, "y": 38 }, { "name": "Austin", "x": 42, "y": 58 }, { "name": "Houston", "x": 55, "y": 60 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "3 cities. 4 games." } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Texas road trip?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Dallas → Austin → Houston", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "4 games along the route", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Cowboys. Longhorns. Astros. Rockets.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "All planned in one app", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Texas Triangle. Four games. One route.",
"targetLengthSec": 13
},
{
"id": "V05-CA-01",
"base": "V05",
"hook": "California weekend? SF → Sac → Bay games.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NorCal Run", "cities": [{ "name": "San Francisco", "x": 20, "y": 42 }, { "name": "Sacramento", "x": 30, "y": 35 }, { "name": "Oakland", "x": 22, "y": 44 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Bay Area weekend" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "California this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "SF → Sacramento → back to the Bay", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Warriors. Kings. Giants.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "3 games in one weekend", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Route mapped automatically", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "NorCal three-game run. SportsTime planned it.",
"targetLengthSec": 13
},
{
"id": "V08-LA-01",
"base": "V08",
"hook": "LA → San Diego drive? Add a game stop.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Found a game on the way" } },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim → San Diego", "cities": [{ "name": "LA", "x": 25, "y": 35 }, { "name": "Anaheim", "x": 35, "y": 48 }, { "name": "San Diego", "x": 30, "y": 70 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Driving LA to San Diego?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "There's a game on the way", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "Angels game in Anaheim = perfect pit stop", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Then Padres in SD", "startSec": 8.0, "endSec": 9.5, "emphasis": "normal" },
{ "text": "2 games. 1 drive.", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "LA to San Diego. Two games along the way.",
"targetLengthSec": 13
},
{
"id": "V04-H01",
"base": "V04",
"hook": "My friend: 4 stadiums. Me: 27.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 27, "totalStadiums": 120, "caption": "And counting" } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Track every stadium" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "My friend: 4 stadiums", "startSec": 0.3, "endSec": 1.5, "emphasis": "normal" },
{ "text": "Me: 27 and counting", "startSec": 1.8, "endSec": 3.5, "emphasis": "highlight" },
{ "text": "Every stadium tracked", "startSec": 4.0, "endSec": 6.0, "emphasis": "normal" },
{ "text": "MLB. NFL. NBA. NHL.", "startSec": 6.5, "endSec": 8.0, "emphasis": "bold" },
{ "text": "What's your count?", "startSec": 8.5, "endSec": 10.0, "emphasis": "normal" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Four stadiums? That's cute.",
"targetLengthSec": 13
},
{
"id": "V20-H01",
"base": "V20",
"hook": "No tracking = no bucket list.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 15, "totalStadiums": 120, "caption": "Start tracking" } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Every visit logged" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "You've been to how many stadiums?", "startSec": 0.3, "endSec": 2.0, "emphasis": "normal" },
{ "text": "But you're not tracking them?", "startSec": 2.3, "endSec": 4.0, "emphasis": "bold" },
{ "text": "Start your stadium bucket list", "startSec": 4.5, "endSec": 6.5, "emphasis": "highlight" },
{ "text": "Every visit. Every league.", "startSec": 7.0, "endSec": 9.0, "emphasis": "normal" },
{ "text": "How many can you hit?", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "If you're not tracking, it doesn't count.",
"targetLengthSec": 13
},
{
"id": "V14-H01",
"base": "V14",
"hook": "Trying to hit every stadium before 35.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 42, "totalStadiums": 120, "caption": "42 down, 78 to go" } },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Next trip: Midwest", "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 53, "y": 30 }, { "name": "Minneapolis", "x": 45, "y": 22 }, { "name": "Kansas City", "x": 42, "y": 45 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Every stadium before 35", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "42 down. 78 to go.", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Planning the next run", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Midwest loop: 4 new stadiums", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "The bucket list is real", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Every stadium before thirty-five. Clock's ticking.",
"targetLengthSec": 13
},
{
"id": "V04-H02",
"base": "V04",
"hook": "Drop your stadium count. I'll wait.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "👀" } },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 31, "totalStadiums": 120, "caption": "What's yours?" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Drop your stadium count", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
{ "text": "I'll wait 👀", "startSec": 2.0, "endSec": 3.5, "emphasis": "normal" },
{ "text": "Mine: 31 across 4 leagues", "startSec": 3.8, "endSec": 5.5, "emphasis": "highlight" },
{ "text": "Track yours", "startSec": 5.8, "endSec": 7.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Drop your count. I'll wait.",
"targetLengthSec": 12
},
{
"id": "V02-H01",
"base": "V02",
"hook": "If you've never done an away-game trip…",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team on the road" } },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Away game route", "cities": [{ "name": "Home", "x": 35, "y": 40 }, { "name": "Away Game 1", "x": 55, "y": 35 }, { "name": "Away Game 2", "x": 70, "y": 42 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Never done an away-game trip?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "Follow your team on the road", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "SportsTime finds the games + builds the route", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "New city. Your team. Road trip.", "startSec": 8.0, "endSec": 10.0, "emphasis": "normal" },
{ "text": "Start with one trip", "startSec": 10.5, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "If you've never done an away game trip, start here.",
"targetLengthSec": 13
},
{
"id": "V19-H01",
"base": "V19",
"hook": "Hot take: away games are the real fandom.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "Home games are easy mode.", "variant": "slam" } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team anywhere" } },
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Away game road trip", "cities": [{ "name": "Your City", "x": 30, "y": 45 }, { "name": "Rival City", "x": 60, "y": 35 }, { "name": "Another Stop", "x": 75, "y": 50 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Hot take incoming 🔥", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Home games are easy mode", "startSec": 2.5, "endSec": 4.3, "emphasis": "highlight" },
{ "text": "Real fans follow the team on the road", "startSec": 4.8, "endSec": 6.8, "emphasis": "normal" },
{ "text": "Plan the away game trip", "startSec": 7.3, "endSec": 9.0, "emphasis": "normal" },
{ "text": "Route + games + drive times", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" },
{ "text": "Prove you're a real one", "startSec": 11.5, "endSec": 13.0, "emphasis": "highlight" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Away games are the real fandom. Prove it.",
"targetLengthSec": 15
}
]

View File

@@ -0,0 +1,180 @@
import React from "react";
import { AbsoluteFill, useVideoConfig } from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import type { VideoConfig, SceneConfig } from "./types";
import {
HookCard,
ChatScene,
ScreenRecScene,
MapScene,
PollScene,
FlexScene,
TextPunchScene,
CTAEndCard,
KineticCaption,
} from "./scenes";
type VideoFromConfigProps = {
config: VideoConfig;
};
/**
* Config-driven video renderer.
*
* Reads a VideoConfig and renders each scene in order using TransitionSeries.
* Kinetic captions overlay on top of all scenes.
*/
export const VideoFromConfig: React.FC<VideoFromConfigProps> = ({ config }) => {
const { fps } = useVideoConfig();
const TRANSITION_FRAMES = 8; // Snappy transitions for TikTok pace
const renderScene = (scene: SceneConfig, index: number) => {
const props = scene.props || {};
switch (scene.type) {
case "HOOK":
return <HookCard hookText={config.hook} emoji={props.emoji as string} />;
case "CHAT":
return (
<ChatScene
groupName={props.groupName as string}
groupSize={props.groupSize as number}
messages={props.messages as any[]}
overlayText={props.overlayText as string}
/>
);
case "SCREENREC":
return (
<ScreenRecScene
assetKey={props.assetKey as string}
caption={props.caption as string}
showFrame={props.showFrame as boolean}
phoneScale={props.phoneScale as number}
videoStartSec={props.videoStartSec as number}
/>
);
case "MAP":
return (
<MapScene
cities={props.cities as any[]}
caption={props.caption as string}
routeColor={props.routeColor as string}
/>
);
case "POLL":
return (
<PollScene
question={props.question as string}
options={props.options as any[]}
caption={props.caption as string}
/>
);
case "FLEX":
return (
<FlexScene
stadiumCount={props.stadiumCount as number}
totalStadiums={props.totalStadiums as number}
caption={props.caption as string}
leagues={props.leagues as string[]}
/>
);
case "TEXTPUNCH":
return (
<TextPunchScene
text={props.text as string}
subtext={props.subtext as string}
variant={props.variant as "slam" | "typewriter" | "split"}
/>
);
case "CTA":
return (
<CTAEndCard
ctaText={config.cta}
tagline={props.tagline as string}
/>
);
default:
return (
<AbsoluteFill
style={{
background: "#0A0A0A",
justifyContent: "center",
alignItems: "center",
}}
>
<span style={{ color: "red", fontSize: 32 }}>
Unknown scene: {scene.type}
</span>
</AbsoluteFill>
);
}
};
// Choose transition type based on scene pair
const getTransition = (fromType: SceneConfig["type"], toType: SceneConfig["type"]) => {
// Slide in for screen recordings (feels like opening an app)
if (toType === "SCREENREC") {
return slide({ direction: "from-right" });
}
// Slide for map reveals
if (toType === "MAP") {
return slide({ direction: "from-bottom" });
}
// Default: fade
return fade();
};
const scenes = config.scenes;
const numTransitions = scenes.length - 1;
return (
<AbsoluteFill>
{/* Scene layer with transitions */}
<TransitionSeries>
{scenes.map((scene, index) => {
const durationFrames = Math.round(scene.durationSec * fps);
const elements: React.ReactNode[] = [];
elements.push(
<TransitionSeries.Sequence
key={`scene-${index}`}
durationInFrames={durationFrames}
>
{renderScene(scene, index)}
</TransitionSeries.Sequence>
);
// Add transition between scenes (not after the last one)
if (index < numTransitions) {
const nextScene = scenes[index + 1];
elements.push(
<TransitionSeries.Transition
key={`trans-${index}`}
presentation={getTransition(scene.type, nextScene.type)}
timing={linearTiming({ durationInFrames: TRANSITION_FRAMES })}
/>
);
}
return elements;
})}
</TransitionSeries>
{/* Caption overlay on top of everything */}
{config.captions.length > 0 && (
<KineticCaption captions={config.captions} />
)}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,9 @@
export { VideoFromConfig } from "./VideoFromConfig";
export type {
VideoConfig,
SceneConfig,
SceneType,
CaptionLine,
Week1Configs,
} from "./types";
export { ASSET_KEYS } from "./types";

View File

@@ -0,0 +1,273 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type CTAEndCardProps = {
/** CTA text line (should include "Search SportsTime") */
ctaText?: string;
/** Optional tagline above CTA */
tagline?: string;
};
export const CTAEndCard: React.FC<CTAEndCardProps> = ({
ctaText = "Search SportsTime on the App Store",
tagline,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// --- App icon animation (scale + bounce) ---
const iconSpring = spring({
frame,
fps,
config: { damping: 15, stiffness: 100 },
});
const iconScale = interpolate(iconSpring, [0, 1], [0.3, 1]);
const iconOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
// --- Wordmark fades in with the icon ---
const wordmarkOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
// --- Tagline animation (0.4s delay) ---
const taglineDelay = Math.round(0.4 * fps);
const taglineSpring = spring({
frame: frame - taglineDelay,
fps,
config: theme.animation.snappy,
});
const taglineOpacity = interpolate(taglineSpring, [0, 1], [0, 1]);
const taglineTranslateY = interpolate(taglineSpring, [0, 1], [20, 0]);
// --- Search bar animation (0.5s delay) ---
const searchBarDelay = Math.round(0.5 * fps);
const searchBarSpring = spring({
frame: frame - searchBarDelay,
fps,
config: theme.animation.snappy,
});
const searchBarOpacity = interpolate(searchBarSpring, [0, 1], [0, 1]);
const searchBarTranslateY = interpolate(searchBarSpring, [0, 1], [30, 0]);
// --- Pulse/glow on search bar border (breathing orange glow) ---
const pulseSpeed = 1.5; // seconds per cycle
const pulseCycle = (frame / fps) * (1 / pulseSpeed) * Math.PI * 2;
const pulseIntensity = (Math.sin(pulseCycle) + 1) / 2; // 0 to 1
const glowAlpha = interpolate(pulseIntensity, [0, 1], [0.1, 0.5]);
const borderAlpha = interpolate(pulseIntensity, [0, 1], [0.2, 0.5]);
// --- "Available on the App Store" text (0.7s delay) ---
const availableDelay = Math.round(0.7 * fps);
const availableSpring = spring({
frame: frame - availableDelay,
fps,
config: theme.animation.smooth,
});
const availableOpacity = interpolate(availableSpring, [0, 1], [0, 1]);
return (
<AbsoluteFill>
{/* Background gradient */}
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
}}
/>
{/* Center content: icon + wordmark + tagline */}
<AbsoluteFill
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
{/* App icon */}
<div
style={{
width: 180,
height: 180,
borderRadius: 40,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.4)`,
display: "flex",
justifyContent: "center",
alignItems: "center",
transform: `scale(${iconScale})`,
opacity: iconOpacity,
}}
>
{/* Simple stadium SVG icon: ellipse base + arch + flag */}
<svg
width={100}
height={100}
viewBox="0 0 100 100"
fill="none"
>
{/* Stadium base ellipse */}
<ellipse
cx={50}
cy={70}
rx={36}
ry={12}
stroke="white"
strokeWidth={3.5}
fill="none"
/>
{/* Stadium arch */}
<path
d="M 20 70 Q 20 28 50 28 Q 80 28 80 70"
stroke="white"
strokeWidth={3.5}
fill="none"
strokeLinecap="round"
/>
{/* Flag pole */}
<line
x1={50}
y1={28}
x2={50}
y2={12}
stroke="white"
strokeWidth={3}
strokeLinecap="round"
/>
{/* Flag */}
<path
d="M 50 12 L 62 17 L 50 22"
stroke="white"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* "SportsTime" wordmark */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 72,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -2,
marginTop: 32,
opacity: wordmarkOpacity,
}}
>
SportsTime
</div>
{/* Tagline (optional) */}
{tagline && (
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 36,
fontWeight: 500,
color: theme.colors.textSecondary,
marginTop: 16,
opacity: taglineOpacity,
transform: `translateY(${taglineTranslateY}px)`,
}}
>
{tagline}
</div>
)}
</AbsoluteFill>
{/* CTA search bar - positioned near bottom */}
<div
style={{
position: "absolute",
bottom: 200,
left: 0,
right: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
opacity: searchBarOpacity,
transform: `translateY(${searchBarTranslateY}px)`,
}}
>
{/* Search bar container */}
<div
style={{
width: 800,
height: 80,
borderRadius: 40,
background: "rgba(255, 255, 255, 0.1)",
border: `1.5px solid rgba(255, 255, 255, ${borderAlpha})`,
boxShadow: `0 0 ${interpolate(pulseIntensity, [0, 1], [10, 30])}px rgba(255, 107, 53, ${glowAlpha})`,
display: "flex",
alignItems: "center",
paddingLeft: 28,
paddingRight: 28,
gap: 16,
}}
>
{/* Search icon (magnifying glass SVG) */}
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill="none"
style={{ flexShrink: 0 }}
>
<circle
cx={10.5}
cy={10.5}
r={7}
stroke={theme.colors.textSecondary}
strokeWidth={2}
/>
<line
x1={15.5}
y1={15.5}
x2={21}
y2={21}
stroke={theme.colors.textSecondary}
strokeWidth={2}
strokeLinecap="round"
/>
</svg>
{/* CTA text */}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 28,
fontWeight: 500,
color: theme.colors.textSecondary,
whiteSpace: "nowrap",
}}
>
{ctaText}
</span>
</div>
{/* "Available on the App Store" text below search bar */}
<div
style={{
marginTop: 20,
fontFamily: theme.fonts.text,
fontSize: 20,
fontWeight: 400,
color: theme.colors.textMuted,
opacity: availableOpacity,
}}
>
Available on the App Store
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,423 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type ChatMessage = {
sender: string;
text: string;
isMe: boolean;
delaySec: number;
};
type ChatSceneProps = {
/** Group name shown in header */
groupName?: string;
/** Number of people in group */
groupSize?: number;
/** Messages to display */
messages?: ChatMessage[];
/** Overlay caption text shown at top */
overlayText?: string;
};
const DEFAULT_MESSAGES: ChatMessage[] = [
{ sender: "Jake", text: "We should do a baseball trip", isMe: false, delaySec: 0.15 },
{ sender: "Mike", text: "I'm down", isMe: false, delaySec: 0.5 },
{ sender: "Sam", text: "Same", isMe: false, delaySec: 0.75 },
{ sender: "You", text: "Let's goooo", isMe: true, delaySec: 1.0 },
{ sender: "Jake", text: "When tho", isMe: false, delaySec: 1.4 },
{ sender: "Mike", text: "idk lol", isMe: false, delaySec: 1.7 },
{ sender: "Sam", text: "Maybe June?", isMe: false, delaySec: 2.0 },
{ sender: "Jake", text: "Or July", isMe: false, delaySec: 2.25 },
];
const SENDER_COLORS = ["#34C759", "#FF9500", "#AF52DE", "#FF2D55"];
/**
* Assigns a stable color to each unique sender name (excluding "me" messages).
*/
const buildSenderColorMap = (messages: ChatMessage[]): Record<string, string> => {
const map: Record<string, string> = {};
let colorIndex = 0;
for (const msg of messages) {
if (!msg.isMe && !(msg.sender in map)) {
map[msg.sender] = SENDER_COLORS[colorIndex % SENDER_COLORS.length];
colorIndex++;
}
}
return map;
};
/**
* Typing indicator with 3 bouncing dots.
*/
const TypingIndicator: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
const dots = [0, 1, 2];
// Each dot bounces on a ~0.6s cycle, staggered by ~0.15s
const cycleDuration = 0.6 * fps;
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
flexDirection: "column",
}}
>
<div
style={{
background: "#2C2C2E",
borderRadius: 20,
borderBottomLeftRadius: 6,
padding: "14px 20px",
display: "flex",
gap: 6,
alignItems: "center",
}}
>
{dots.map((i) => {
const stagger = i * 0.15 * fps;
const cycleFrame = (frame + stagger) % cycleDuration;
const bounceProgress = Math.sin((cycleFrame / cycleDuration) * Math.PI);
const translateY = interpolate(bounceProgress, [0, 1], [0, -8]);
return (
<div
key={i}
style={{
width: 10,
height: 10,
borderRadius: 5,
background: "rgba(255, 255, 255, 0.4)",
transform: `translateY(${translateY}px)`,
}}
/>
);
})}
</div>
</div>
);
};
export const ChatScene: React.FC<ChatSceneProps> = ({
groupName = "The Boys",
groupSize = 4,
messages = DEFAULT_MESSAGES,
overlayText,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const senderColorMap = buildSenderColorMap(messages);
// Subtle zoom-in over the full scene duration
const chatZoom = interpolate(frame, [0, durationInFrames], [1.0, 1.04], {
extrapolateRight: "clamp",
});
// Determine when the last message appears to show typing indicator after
const lastMessageDelaySec = messages.length > 0
? Math.max(...messages.map((m) => m.delaySec))
: 0;
const typingStartFrame = (lastMessageDelaySec + 0.4) * fps;
const typingProgress = spring({
frame: frame - typingStartFrame,
fps,
config: { damping: 14, stiffness: 200 },
});
const typingScale = interpolate(typingProgress, [0, 1], [0.3, 1]);
const typingOpacity = interpolate(typingProgress, [0, 1], [0, 1]);
// Overlay text animation (springs in at 0.3s)
const overlayDelay = 0.3 * fps;
const overlayProgress = spring({
frame: frame - overlayDelay,
fps,
config: { damping: 14, stiffness: 180 },
});
const overlayOpacity = interpolate(
frame - overlayDelay,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const overlayScale = interpolate(overlayProgress, [0, 1], [0.85, 1]);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* Zoomable chat container */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: `scale(${chatZoom})`,
transformOrigin: "center 40%",
}}
>
{/* Status bar */}
<div
style={{
padding: "16px 32px 12px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: "white",
fontWeight: 600,
}}
>
9:41
</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{/* Signal bars */}
<div style={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
{[6, 8, 10, 12].map((h, i) => (
<div
key={i}
style={{
width: 3,
height: h,
background: "white",
borderRadius: 1,
}}
/>
))}
</div>
{/* Battery icon */}
<div
style={{
width: 24,
height: 12,
border: "1.5px solid white",
borderRadius: 3,
marginLeft: 4,
position: "relative",
}}
>
<div
style={{
width: "70%",
height: "100%",
background: "white",
borderRadius: 1.5,
}}
/>
<div
style={{
position: "absolute",
right: -5,
top: 3,
width: 3,
height: 6,
background: "white",
borderRadius: "0 2px 2px 0",
}}
/>
</div>
</div>
</div>
{/* Chat header */}
<div
style={{
padding: "8px 32px 16px",
borderBottom: "1px solid rgba(255,255,255,0.1)",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{/* Group avatar with gradient */}
<div
style={{
width: 44,
height: 44,
borderRadius: 22,
background: "linear-gradient(135deg, #34C759, #007AFF)",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 18,
fontWeight: 700,
color: "white",
}}
>
{groupSize}
</span>
</div>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: "white",
}}
>
{groupName}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: "rgba(255,255,255,0.5)",
}}
>
{groupSize} people
</div>
</div>
</div>
{/* Messages */}
<div
style={{
padding: "20px 24px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{messages.map((msg, index) => {
const msgDelayFrames = msg.delaySec * fps;
const msgProgress = spring({
frame: frame - msgDelayFrames,
fps,
config: { damping: 14, stiffness: 200 },
});
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
if (frame < msgDelayFrames) return null;
const senderColor = msg.isMe ? "#007AFF" : (senderColorMap[msg.sender] || SENDER_COLORS[0]);
return (
<div
key={index}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.isMe ? "flex-end" : "flex-start",
transform: `scale(${msgScale})`,
opacity: msgOpacity,
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
}}
>
{/* Sender name (only for non-"me" messages) */}
{!msg.isMe && (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: senderColor,
marginBottom: 2,
marginLeft: 12,
}}
>
{msg.sender}
</span>
)}
{/* Message bubble */}
<div
style={{
background: msg.isMe ? "#007AFF" : "#2C2C2E",
borderRadius: 20,
borderBottomRightRadius: msg.isMe ? 6 : 20,
borderBottomLeftRadius: msg.isMe ? 20 : 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: "white",
lineHeight: 1.3,
}}
>
{msg.text}
</span>
</div>
</div>
);
})}
{/* Typing indicator (appears after last message) */}
{frame >= typingStartFrame && (
<div
style={{
transform: `scale(${typingScale})`,
opacity: typingOpacity,
transformOrigin: "left bottom",
}}
>
<TypingIndicator frame={frame} fps={fps} />
</div>
)}
</div>
</div>
{/* Overlay text pill badge */}
{overlayText && (
<div
style={{
position: "absolute",
top: 100,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${overlayScale})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.7)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
padding: "16px 40px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,0.15)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 800,
color: "white",
letterSpacing: -0.5,
}}
>
{overlayText}
</span>
</div>
</div>
)}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,346 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type FlexSceneProps = {
/** Number of stadiums visited */
stadiumCount?: number;
/** Total stadiums possible */
totalStadiums?: number;
/** Caption text */
caption?: string;
/** League labels to show */
leagues?: string[];
};
export const FlexScene: React.FC<FlexSceneProps> = ({
stadiumCount = 27,
totalStadiums = 120,
caption,
leagues = ["MLB", "NFL", "NBA", "NHL"],
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// --- Stadium icon dots (arc above counter) ---
const totalDots = 8;
const visitedDots = Math.round(
(stadiumCount / totalStadiums) * totalDots
);
const dotsAppearFrame = Math.round(0.2 * fps);
const dotsOpacity = interpolate(
frame,
[dotsAppearFrame, dotsAppearFrame + Math.round(0.3 * fps)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// --- Counter animation ---
// Count up over ~1.5s
const countDuration = Math.round(1.5 * fps);
const rawCount = interpolate(frame, [0, countDuration], [0, stadiumCount], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const displayCount = Math.round(rawCount);
// Counter scale spring
const counterScaleProgress = spring({
frame,
fps,
config: theme.animation.bouncy,
});
const counterScale = interpolate(
counterScaleProgress,
[0, 1],
[0.5, 1]
);
// --- Progress bar ---
// Starts animating after counter reaches target
const progressBarDelay = countDuration + Math.round(0.1 * fps);
const progressBarProgress = spring({
frame: frame - progressBarDelay,
fps,
config: theme.animation.smooth,
});
const progressBarWidth = interpolate(
progressBarProgress,
[0, 1],
[0, (stadiumCount / totalStadiums) * 100]
);
// --- League badges ---
// Spring in staggered, starting 0.3s after progress bar delay
const badgesBaseDelay = progressBarDelay + Math.round(0.3 * fps);
const badgeStaggerFrames = Math.round(0.1 * fps);
// --- Caption animation ---
const captionDelay = Math.round(0.15 * fps);
const captionProgress = spring({
frame: frame - captionDelay,
fps,
config: theme.animation.smooth,
});
const captionOpacity = interpolate(
frame - captionDelay,
[0, Math.round(fps * 0.25)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
return (
<AbsoluteFill>
{/* Background */}
<GradientBackground animate />
{/* Subtle gold radial glow behind counter */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
position: "absolute",
width: 800,
height: 600,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.gold}1A 0%, transparent 70%)`,
filter: "blur(60px)",
}}
/>
</AbsoluteFill>
{/* Caption badge at top */}
{caption && (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
paddingTop: 120,
zIndex: 20,
}}
>
<div
style={{
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
background: "rgba(0, 0, 0, 0.55)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
borderRadius: 999,
padding: "14px 36px",
border: "1px solid rgba(255, 255, 255, 0.15)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -0.5,
}}
>
{caption}
</span>
</div>
</AbsoluteFill>
)}
{/* Main content */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 0,
}}
>
{/* Stadium icon dots arc */}
<div
style={{
position: "relative",
width: 400,
height: 60,
marginBottom: 24,
opacity: dotsOpacity,
}}
>
{Array.from({ length: totalDots }).map((_, i) => {
// Arrange in a subtle arc
const angle =
Math.PI + ((i / (totalDots - 1)) * Math.PI);
const arcWidth = 180;
const arcHeight = 30;
const cx = 200 + Math.cos(angle) * arcWidth;
const cy = 50 + Math.sin(angle) * arcHeight;
const isVisited = i < visitedDots;
return (
<div
key={i}
style={{
position: "absolute",
left: cx - 8,
top: cy - 8,
width: 16,
height: 16,
borderRadius: 8,
background: isVisited
? theme.colors.accent
: "transparent",
border: isVisited
? `2px solid ${theme.colors.accent}`
: "2px solid rgba(255, 255, 255, 0.25)",
boxShadow: isVisited
? `0 0 8px ${theme.colors.accent}80`
: "none",
}}
/>
);
})}
</div>
{/* Large counter number */}
<div
style={{
transform: `scale(${counterScale})`,
transformOrigin: "center center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 160,
fontWeight: 900,
color: theme.colors.gold,
textShadow: `0 0 40px ${theme.colors.gold}66, 0 0 80px ${theme.colors.gold}33`,
lineHeight: 1,
display: "block",
textAlign: "center",
letterSpacing: -4,
}}
>
{displayCount}
</span>
</div>
{/* "/ totalStadiums stadiums" subtitle */}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 36,
color: theme.colors.textSecondary,
marginTop: 8,
fontWeight: 500,
letterSpacing: 0.5,
}}
>
/ {totalStadiums} stadiums
</span>
{/* Progress bar */}
<div
style={{
width: 800,
height: 16,
borderRadius: 8,
background: "#2C2C2E",
marginTop: 40,
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
width: `${progressBarWidth}%`,
height: "100%",
borderRadius: 8,
background: `linear-gradient(90deg, ${theme.colors.accent}, ${theme.colors.gold})`,
boxShadow: `0 0 12px ${theme.colors.accent}66`,
transition: "none",
}}
/>
</div>
{/* League badges row */}
<div
style={{
display: "flex",
gap: 16,
marginTop: 40,
justifyContent: "center",
flexWrap: "wrap",
}}
>
{leagues.map((league, index) => {
const badgeDelay = badgesBaseDelay + index * badgeStaggerFrames;
const badgeProgress = spring({
frame: frame - badgeDelay,
fps,
config: theme.animation.snappy,
});
const badgeScale = interpolate(
badgeProgress,
[0, 1],
[0.3, 1]
);
const badgeOpacity = interpolate(
badgeProgress,
[0, 1],
[0, 1]
);
return (
<div
key={league}
style={{
opacity: badgeOpacity,
transform: `scale(${badgeScale})`,
background: "#2C2C2E",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: 20,
padding: "8px 20px",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
fontWeight: 600,
color: theme.colors.text,
}}
>
{league}
</span>
</div>
);
})}
</div>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,180 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type HookCardProps = {
hookText: string;
/** Optional emoji shown above the hook text */
emoji?: string;
};
export const HookCard: React.FC<HookCardProps> = ({ hookText, emoji }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = hookText.split(/\s+/);
const staggerFrames = Math.round(0.06 * fps);
// Calculate when the last word finishes appearing (for the underline timing)
const lastWordStartFrame = (words.length - 1) * staggerFrames;
// Emoji spring - appears at frame 3
const emojiProgress = spring({
frame: frame - 3,
fps,
config: theme.animation.bouncy,
});
const emojiScale = interpolate(emojiProgress, [0, 1], [0.3, 2]);
const emojiOpacity = interpolate(emojiProgress, [0, 1], [0, 1]);
// Underline wipe - starts after all words have appeared, with a small buffer
const underlineDelay = lastWordStartFrame + Math.round(fps * 0.3);
const underlineProgress = spring({
frame: frame - underlineDelay,
fps,
config: theme.animation.snappy,
});
const underlineScaleX = interpolate(underlineProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill>
{/* Dark gradient background */}
<GradientBackground />
{/* Radial orange glow behind text */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
position: "absolute",
width: 900,
height: 600,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent}26 0%, transparent 70%)`,
filter: "blur(40px)",
}}
/>
</AbsoluteFill>
{/* Main content container */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: 900,
padding: "0 40px",
}}
>
{/* Optional emoji */}
{emoji && (
<div
style={{
fontSize: 64,
marginBottom: 32,
transform: `scale(${emojiScale})`,
opacity: emojiOpacity,
}}
>
{emoji}
</div>
)}
{/* Hook text with word-by-word reveal */}
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "0 16px",
lineHeight: 1.25,
maxWidth: 900,
}}
>
{words.map((word, index) => {
const wordDelay = index * staggerFrames;
const wordProgress = spring({
frame: frame - wordDelay,
fps,
config: theme.animation.snappy,
});
const wordOpacity = interpolate(
frame - wordDelay,
[0, Math.round(fps * 0.15)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const wordScale = interpolate(
wordProgress,
[0, 1],
[0.7, 1.0]
);
const wordTranslateY = interpolate(
wordProgress,
[0, 1],
[40, 0]
);
return (
<span
key={index}
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 800,
color: theme.colors.text,
textAlign: "center",
opacity: wordOpacity,
transform: `scale(${wordScale}) translateY(${wordTranslateY}px)`,
display: "inline-block",
letterSpacing: -1,
}}
>
{word}
</span>
);
})}
</div>
{/* Orange underline wipe */}
<div
style={{
width: 300,
height: 5,
marginTop: 20,
borderRadius: 3,
background: theme.colors.accent,
transform: `scaleX(${underlineScaleX})`,
transformOrigin: "left",
opacity: underlineScaleX > 0 ? 1 : 0,
}}
/>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,103 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import type { CaptionLine } from "../types";
type KineticCaptionProps = {
captions: CaptionLine[];
};
/**
* Kinetic caption overlay that renders on top of all scenes.
* Captions appear/disappear based on their startSec/endSec timing.
* Each caption pops in with a spring and has bold/highlight styling options.
*/
export const KineticCaption: React.FC<KineticCaptionProps> = ({ captions }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Find the currently active caption
const currentSec = frame / fps;
const activeCaptions = captions.filter(
(c) => currentSec >= c.startSec && currentSec < c.endSec
);
if (activeCaptions.length === 0) return null;
const caption = activeCaptions[activeCaptions.length - 1];
const captionStartFrame = caption.startSec * fps;
const captionEndFrame = caption.endSec * fps;
const localFrame = frame - captionStartFrame;
// Entrance spring
const enterProgress = spring({
frame: localFrame,
fps,
config: { damping: 14, stiffness: 200 },
});
const scale = interpolate(enterProgress, [0, 1], [0.7, 1]);
const enterOpacity = interpolate(enterProgress, [0, 1], [0, 1]);
// Exit fade (last 5 frames before endSec)
const exitOpacity = interpolate(
frame,
[captionEndFrame - 5, captionEndFrame],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const opacity = Math.min(enterOpacity, exitOpacity);
const isHighlight = caption.emphasis === "highlight";
const isBold = caption.emphasis === "bold" || isHighlight;
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 220,
pointerEvents: "none",
}}
>
<div
style={{
transform: `scale(${scale})`,
opacity,
maxWidth: 900,
padding: "16px 36px",
borderRadius: 16,
background: isHighlight
? "rgba(255, 107, 53, 0.9)"
: "rgba(0, 0, 0, 0.8)",
backdropFilter: "blur(8px)",
border: isHighlight
? "none"
: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 38,
fontWeight: isBold ? 800 : 600,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.3,
letterSpacing: -0.5,
textShadow: "0 2px 8px rgba(0,0,0,0.5)",
}}
>
{caption.text}
</span>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,402 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type MapCity = {
name: string;
x: number; // percentage position 0-100
y: number; // percentage position 0-100
};
type MapSceneProps = {
/** Cities to show on the map */
cities?: MapCity[];
/** Caption text at top */
caption?: string;
/** Route line color override */
routeColor?: string;
};
const DEFAULT_CITIES: MapCity[] = [
{ name: "Dallas", x: 45, y: 55 },
{ name: "Houston", x: 50, y: 70 },
{ name: "San Antonio", x: 40, y: 72 },
];
export const MapScene: React.FC<MapSceneProps> = ({
cities = DEFAULT_CITIES,
caption,
routeColor = theme.colors.mapLine,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Map area: center 80% of screen
const mapLeft = width * 0.1;
const mapTop = height * 0.1;
const mapWidth = width * 0.8;
const mapHeight = height * 0.8;
// Convert percentage coords to pixel coords within the map area
const points = cities.map((city) => ({
name: city.name,
px: mapLeft + (city.x / 100) * mapWidth,
py: mapTop + (city.y / 100) * mapHeight,
}));
// Timing: each segment takes ~0.5s to draw, staggered
const segmentDurationFrames = Math.round(0.5 * fps);
// Calculate total path length and per-segment lengths
const segmentLengths: number[] = [];
let totalPathLength = 0;
for (let i = 1; i < points.length; i++) {
const dx = points[i].px - points[i - 1].px;
const dy = points[i].py - points[i - 1].py;
const len = Math.sqrt(dx * dx + dy * dy);
segmentLengths.push(len);
totalPathLength += len;
}
// Build the SVG path d attribute (straight lines between cities)
const pathD = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.px} ${p.py}`)
.join(" ");
// Calculate cumulative lengths for each segment start
const cumulativeLengths: number[] = [0];
for (let i = 0; i < segmentLengths.length; i++) {
cumulativeLengths.push(cumulativeLengths[i] + segmentLengths[i]);
}
// Route draw animation: each segment draws over segmentDurationFrames, staggered
const getSegmentDrawProgress = (segmentIndex: number): number => {
const segmentStartFrame = segmentIndex * segmentDurationFrames;
return interpolate(
frame,
[segmentStartFrame, segmentStartFrame + segmentDurationFrames],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
};
// Calculate overall drawn length for strokeDashoffset
let drawnLength = 0;
for (let i = 0; i < segmentLengths.length; i++) {
const segProgress = getSegmentDrawProgress(i);
drawnLength += segmentLengths[i] * segProgress;
}
const strokeDashoffset = totalPathLength - drawnLength;
// Determine when each city's route segment completes (for marker appearance)
// City 0 appears when route starts drawing (frame ~0)
// City i appears when segment i-1 finishes drawing
const getCityAppearFrame = (cityIndex: number): number => {
if (cityIndex === 0) return 0;
return cityIndex * segmentDurationFrames;
};
// Traveling dot: appears after entire route is drawn, moves along path
const routeCompleteFrame =
(points.length - 1) * segmentDurationFrames;
const travelDotDelay = routeCompleteFrame + Math.round(fps * 0.3);
const travelDotDuration = Math.round(fps * 1.5);
const travelDotProgress = interpolate(
frame,
[travelDotDelay, travelDotDelay + travelDotDuration],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Calculate traveling dot position along the path
const travelDistance = travelDotProgress * totalPathLength;
let travelDotX = points[0].px;
let travelDotY = points[0].py;
if (travelDotProgress > 0) {
let accumulated = 0;
for (let i = 0; i < segmentLengths.length; i++) {
if (accumulated + segmentLengths[i] >= travelDistance) {
const segFraction =
(travelDistance - accumulated) / segmentLengths[i];
travelDotX =
points[i].px + (points[i + 1].px - points[i].px) * segFraction;
travelDotY =
points[i].py + (points[i + 1].py - points[i].py) * segFraction;
break;
}
accumulated += segmentLengths[i];
}
}
const travelDotOpacity = interpolate(
travelDotProgress,
[0, 0.02, 0.98, 1],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Caption animation
const captionProgress = caption
? spring({
frame: frame - 5,
fps,
config: theme.animation.snappy,
})
: 0;
const captionOpacity = interpolate(captionProgress, [0, 1], [0, 1]);
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
// Pulse animation for markers (repeating)
const pulseFrame = frame % Math.round(fps * 1.5);
const pulseScale = interpolate(
pulseFrame,
[0, Math.round(fps * 1.5)],
[1, 2.5],
{ extrapolateRight: "clamp" }
);
const pulseOpacity = interpolate(
pulseFrame,
[0, Math.round(fps * 1.5)],
[0.6, 0],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
}}
>
{/* Subtle dark grid pattern */}
<AbsoluteFill
style={{
backgroundImage: `
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)
`,
backgroundSize: "60px 60px",
}}
/>
{/* SVG map layer */}
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
style={{ position: "absolute", top: 0, left: 0 }}
>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="8" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Route glow (blurred duplicate path) */}
<path
d={pathD}
fill="none"
stroke={routeColor}
strokeWidth={12}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={totalPathLength}
strokeDashoffset={strokeDashoffset}
opacity={0.2}
filter="url(#glow)"
/>
{/* Route line (dashed, animated) */}
<path
d={pathD}
fill="none"
stroke={routeColor}
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={totalPathLength}
strokeDashoffset={strokeDashoffset}
/>
{/* City markers */}
{points.map((point, index) => {
const appearFrame = getCityAppearFrame(index);
const markerSpring = spring({
frame: frame - appearFrame,
fps,
config: theme.animation.bouncy,
});
const markerScale = interpolate(
markerSpring,
[0, 1],
[0, 1]
);
const markerOpacity = interpolate(
markerSpring,
[0, 0.3],
[0, 1],
{ extrapolateRight: "clamp" }
);
// Label appears slightly after the marker
const labelDelay = appearFrame + Math.round(fps * 0.15);
const labelSpring = spring({
frame: frame - labelDelay,
fps,
config: theme.animation.snappy,
});
const labelOpacity = interpolate(
labelSpring,
[0, 1],
[0, 1]
);
const labelTranslateY = interpolate(
labelSpring,
[0, 1],
[10, 0]
);
// Only show pulse after marker is fully visible
const showPulse = markerSpring > 0.95;
return (
<g key={index}>
{/* Pulse ring (repeating, expanding, fading) */}
{showPulse && (
<circle
cx={point.px}
cy={point.py}
r={24}
fill="none"
stroke={theme.colors.mapMarker}
strokeWidth={2}
opacity={pulseOpacity}
transform={`translate(${point.px * (1 - pulseScale)}, ${point.py * (1 - pulseScale)}) scale(${pulseScale})`}
style={{ transformOrigin: `${point.px}px ${point.py}px` }}
/>
)}
{/* Outer ring */}
<circle
cx={point.px}
cy={point.py}
r={24}
fill="transparent"
stroke={theme.colors.mapMarker}
strokeWidth={3}
opacity={markerOpacity}
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
/>
{/* Inner dot */}
<circle
cx={point.px}
cy={point.py}
r={10}
fill={theme.colors.mapMarker}
opacity={markerOpacity}
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
/>
{/* City name label */}
<text
x={point.px}
y={point.py + 44}
fill={theme.colors.text}
fontSize={28}
fontWeight={700}
fontFamily={theme.fonts.display}
textAnchor="middle"
opacity={labelOpacity}
style={{
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.8))",
}}
>
<tspan dy={labelTranslateY}>{point.name}</tspan>
</text>
</g>
);
})}
{/* Traveling dot along the route */}
{travelDotProgress > 0 && travelDotProgress < 1 && (
<>
{/* Glow behind traveling dot */}
<circle
cx={travelDotX}
cy={travelDotY}
r={18}
fill={routeColor}
opacity={travelDotOpacity * 0.3}
filter="url(#glow)"
/>
{/* Traveling dot */}
<circle
cx={travelDotX}
cy={travelDotY}
r={6}
fill={routeColor}
opacity={travelDotOpacity}
/>
</>
)}
</svg>
{/* Caption pill badge at top */}
{caption && (
<div
style={{
position: "absolute",
top: 80,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.6)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
padding: "14px 36px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: theme.fontSizes.body,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: 0.5,
}}
>
{caption}
</span>
</div>
</div>
)}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,408 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type PollOption = {
label: string;
votes: number;
emoji?: string;
};
type PollSceneProps = {
/** Poll question */
question?: string;
/** Poll options with vote counts */
options?: PollOption[];
/** Caption text at top */
caption?: string;
};
const DEFAULT_QUESTION = "Which trip are we doing?";
const DEFAULT_OPTIONS: PollOption[] = [
{ label: "Dallas \u2192 Houston", votes: 3, emoji: "\uD83E\uDD20" },
{ label: "NYC \u2192 Boston", votes: 2, emoji: "\uD83D\uDDFD" },
{ label: "LA \u2192 San Diego", votes: 1, emoji: "\uD83C\uDF34" },
];
const VOTER_NAMES = ["Jake", "Mike", "Sam", "Alex", "Chris"];
const FILL_COLORS = [
theme.colors.accent,
"#FF8F5E",
"#FFB088",
];
const getFillColor = (index: number): string => {
if (index < FILL_COLORS.length) return FILL_COLORS[index];
return FILL_COLORS[FILL_COLORS.length - 1];
};
export const PollScene: React.FC<PollSceneProps> = ({
question = DEFAULT_QUESTION,
options = DEFAULT_OPTIONS,
caption,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const maxVotes = Math.max(...options.map((o) => o.votes));
// Card entrance - slides up and fades in
const cardEntrance = spring({
frame,
fps,
config: theme.animation.snappy,
});
const cardTranslateY = interpolate(cardEntrance, [0, 1], [80, 0]);
const cardOpacity = interpolate(cardEntrance, [0, 1], [0, 1]);
// Caption entrance
const captionEntrance = spring({
frame: frame - 3,
fps,
config: theme.animation.snappy,
});
const captionOpacity = interpolate(captionEntrance, [0, 1], [0, 1]);
const captionTranslateY = interpolate(captionEntrance, [0, 1], [-20, 0]);
// Question text entrance
const questionEntrance = spring({
frame: frame - Math.round(fps * 0.15),
fps,
config: theme.animation.snappy,
});
const questionOpacity = interpolate(questionEntrance, [0, 1], [0, 1]);
// Bar fill delay base (after card is in)
const barStartFrame = Math.round(fps * 0.4);
// Calculate when all bars are done filling for the "Poll sent" badge
const lastBarStartFrame = barStartFrame + (options.length - 1) * Math.round(fps * 0.2);
const barFillDuration = Math.round(fps * 0.5);
const allBarsDoneFrame = lastBarStartFrame + barFillDuration + Math.round(fps * 0.3);
// "Poll sent" badge
const pollSentEntrance = spring({
frame: frame - allBarsDoneFrame,
fps,
config: theme.animation.snappy,
});
const pollSentOpacity = interpolate(pollSentEntrance, [0, 1], [0, 1]);
const pollSentScale = interpolate(pollSentEntrance, [0, 1], [0.5, 1]);
return (
<AbsoluteFill>
<GradientBackground />
{/* Caption at top */}
{caption && (
<div
style={{
position: "absolute",
top: 120,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
zIndex: 10,
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
}}
>
<div
style={{
padding: "14px 32px",
borderRadius: 100,
background: "rgba(255, 255, 255, 0.08)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255, 255, 255, 0.12)",
fontFamily: theme.fonts.text,
fontSize: 24,
fontWeight: 600,
color: theme.colors.text,
letterSpacing: 0.5,
}}
>
{caption}
</div>
</div>
)}
{/* Poll card centered vertically */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
width: 900,
borderRadius: 24,
padding: 48,
background: "#1C1C1E",
border: "1px solid rgba(255, 255, 255, 0.1)",
opacity: cardOpacity,
transform: `translateY(${cardTranslateY}px)`,
display: "flex",
flexDirection: "column",
gap: 32,
}}
>
{/* Question */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 700,
color: theme.colors.text,
textAlign: "center",
opacity: questionOpacity,
lineHeight: 1.3,
}}
>
{question}
</div>
{/* Options */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 20,
}}
>
{options.map((option, index) => {
const staggerDelay = barStartFrame + index * Math.round(fps * 0.2);
const fillProgress = spring({
frame: frame - staggerDelay,
fps,
config: { damping: 30, stiffness: 120 },
});
const fillWidth = interpolate(
fillProgress,
[0, 1],
[0, (option.votes / maxVotes) * 100]
);
// Vote count fades in after bar fills
const voteCountDelay = staggerDelay + Math.round(fps * 0.3);
const voteCountEntrance = spring({
frame: frame - voteCountDelay,
fps,
config: theme.animation.snappy,
});
const voteCountOpacity = interpolate(
voteCountEntrance,
[0, 1],
[0, 1]
);
const isWinner = option.votes === maxVotes;
const fillColor = getFillColor(index);
return (
<div
key={index}
style={{
position: "relative",
width: "100%",
height: 72,
borderRadius: 16,
background: "#2C2C2E",
overflow: "hidden",
boxShadow: isWinner && fillProgress > 0.8
? `0 0 24px ${fillColor}40`
: "none",
}}
>
{/* Fill bar */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: `${fillWidth}%`,
borderRadius: 16,
background: fillColor,
transition: "none",
}}
/>
{/* Label and emoji */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
padding: "0 24px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{option.emoji && (
<span style={{ fontSize: 28 }}>{option.emoji}</span>
)}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 24,
fontWeight: 600,
color: theme.colors.text,
}}
>
{option.label}
</span>
</div>
{/* Vote count */}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 600,
color: theme.colors.text,
opacity: voteCountOpacity,
whiteSpace: "nowrap",
}}
>
{option.votes} {option.votes === 1 ? "vote" : "votes"}
</span>
</div>
</div>
);
})}
</div>
</div>
{/* "Poll sent" badge below the card */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, ${cardTranslateY + 240}px) scale(${pollSentScale})`,
opacity: pollSentOpacity,
marginTop: options.length * 46 + 60,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 28px",
borderRadius: 100,
background: `${theme.colors.success}20`,
border: `1px solid ${theme.colors.success}40`,
}}
>
<span style={{ fontSize: 20 }}>{"\u2713"}</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 600,
color: theme.colors.success,
}}
>
Poll sent
</span>
</div>
</div>
</AbsoluteFill>
{/* Vote notification pills - bottom right */}
<div
style={{
position: "absolute",
bottom: 160,
right: 60,
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 12,
}}
>
{VOTER_NAMES.map((name, index) => {
const notifStartFrame =
barStartFrame + index * Math.round(fps * 0.3);
const notifEndFrame = notifStartFrame + Math.round(fps * 1.0);
const enterProgress = spring({
frame: frame - notifStartFrame,
fps,
config: theme.animation.snappy,
});
const exitProgress = spring({
frame: frame - notifEndFrame,
fps,
config: theme.animation.smooth,
});
const notifOpacity = interpolate(
enterProgress,
[0, 1],
[0, 1]
) * interpolate(exitProgress, [0, 1], [1, 0]);
const notifTranslateX = interpolate(
enterProgress,
[0, 1],
[60, 0]
);
const notifScale = interpolate(enterProgress, [0, 1], [0.7, 1]);
if (notifOpacity <= 0.01) return null;
return (
<div
key={name}
style={{
padding: "10px 22px",
borderRadius: 100,
background: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255, 255, 255, 0.15)",
fontFamily: theme.fonts.text,
fontSize: 20,
fontWeight: 600,
color: theme.colors.text,
opacity: notifOpacity,
transform: `translateX(${notifTranslateX}px) scale(${notifScale})`,
whiteSpace: "nowrap",
}}
>
{name} voted!
</div>
);
})}
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,318 @@
import React, { useState } from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Video,
staticFile,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type ScreenRecSceneProps = {
/** Asset key for the screen recording, e.g. "screenrecs/date_range.mp4" */
assetKey?: string;
/** Caption text overlay at top */
caption?: string;
/** Whether to show the phone device frame */
showFrame?: boolean;
/** Scale of the phone (default 0.85) */
phoneScale?: number;
/** Optional start time offset in seconds for the video */
videoStartSec?: number;
};
const Placeholder: React.FC = () => (
<div
style={{
width: "100%",
height: "100%",
background: `linear-gradient(135deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 50%, ${theme.colors.accent}22 100%)`,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: 20,
}}
>
{/* Play icon triangle */}
<div
style={{
width: 0,
height: 0,
borderLeft: "40px solid rgba(255, 255, 255, 0.6)",
borderTop: "24px solid transparent",
borderBottom: "24px solid transparent",
marginLeft: 10,
}}
/>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: theme.fontSizes.body,
color: theme.colors.textSecondary,
fontWeight: 500,
letterSpacing: 1,
}}
>
Screen Recording
</span>
</div>
);
const ScreenVideo: React.FC<{
assetKey: string;
startFromFrame: number;
}> = ({ assetKey, startFromFrame }) => {
const [hasError, setHasError] = useState(false);
if (hasError) {
return <Placeholder />;
}
try {
const src = staticFile(assetKey);
return (
<Video
src={src}
startFrom={startFromFrame}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onError={() => setHasError(true)}
/>
);
} catch {
return <Placeholder />;
}
};
export const ScreenRecScene: React.FC<ScreenRecSceneProps> = ({
assetKey,
caption,
showFrame = true,
phoneScale = 0.85,
videoStartSec = 0,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const startFromFrame = Math.round(videoStartSec * fps);
// --- Phone entrance animations ---
// Slide up from below
const slideProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const translateY = interpolate(slideProgress, [0, 1], [200, 0]);
// Scale up
const scaleProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const scaleValue = interpolate(scaleProgress, [0, 1], [0.92, 1.0]) * phoneScale;
// --- Caption animation (0.15s delay) ---
const captionDelayFrames = Math.round(0.15 * fps);
const captionProgress = spring({
frame: frame - captionDelayFrames,
fps,
config: theme.animation.smooth,
});
const captionOpacity = interpolate(
frame - captionDelayFrames,
[0, Math.round(fps * 0.25)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
// --- Phone dimensions ---
const phoneWidth = width * 0.75;
const phoneHeight = height * 0.8;
const cornerRadius = 60;
const bezelWidth = 12;
return (
<AbsoluteFill>
{/* Background */}
<GradientBackground />
{/* Subtle orange glow behind phone */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
position: "absolute",
width: phoneWidth * 1.4,
height: phoneHeight * 0.8,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent}1A 0%, transparent 70%)`,
filter: "blur(60px)",
}}
/>
</AbsoluteFill>
{/* Caption badge */}
{caption && (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
paddingTop: 100,
zIndex: 20,
}}
>
<div
style={{
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
background: "rgba(0, 0, 0, 0.55)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
borderRadius: 24,
padding: "14px 32px",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -0.5,
}}
>
{caption}
</span>
</div>
</AbsoluteFill>
)}
{/* Phone device */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
transform: `translateY(${translateY}px) scale(${scaleValue})`,
}}
>
{showFrame ? (
<div
style={{
position: "relative",
width: phoneWidth,
height: phoneHeight,
background: "#1C1C1E",
borderRadius: cornerRadius,
padding: bezelWidth,
boxShadow: `
0 50px 100px rgba(0, 0, 0, 0.5),
0 20px 40px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1)
`,
}}
>
{/* Dynamic Island */}
<div
style={{
position: "absolute",
top: bezelWidth + 15,
left: "50%",
transform: "translateX(-50%)",
width: 120,
height: 36,
background: "#000",
borderRadius: 18,
zIndex: 10,
}}
/>
{/* Screen content */}
<div
style={{
width: "100%",
height: "100%",
borderRadius: cornerRadius - bezelWidth,
overflow: "hidden",
background: theme.colors.background,
position: "relative",
}}
>
{assetKey ? (
<ScreenVideo
assetKey={assetKey}
startFromFrame={startFromFrame}
/>
) : (
<Placeholder />
)}
</div>
{/* Home indicator */}
<div
style={{
position: "absolute",
bottom: bezelWidth + 10,
left: "50%",
transform: "translateX(-50%)",
width: 140,
height: 5,
background: "rgba(255, 255, 255, 0.3)",
borderRadius: 3,
}}
/>
</div>
) : (
/* No frame - just the screen content */
<div
style={{
width: phoneWidth - 40,
height: phoneHeight - 40,
borderRadius: cornerRadius - 20,
overflow: "hidden",
background: theme.colors.background,
boxShadow: "0 50px 100px rgba(0, 0, 0, 0.5)",
}}
>
{assetKey ? (
<ScreenVideo
assetKey={assetKey}
startFromFrame={startFromFrame}
/>
) : (
<Placeholder />
)}
</div>
)}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,463 @@
import React, { useMemo } from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type TextPunchSceneProps = {
/** The punch line text */
text?: string;
/** Optional secondary smaller text below */
subtext?: string;
/** Style variant */
variant?: "slam" | "typewriter" | "split";
};
// ---------------------------------------------------------------------------
// Deterministic "random" shake offsets so renders are reproducible
// ---------------------------------------------------------------------------
const SHAKE_OFFSETS: { x: number; y: number }[] = [
{ x: -6, y: 4 },
{ x: 8, y: -5 },
{ x: -3, y: 7 },
];
// ---------------------------------------------------------------------------
// Variant: slam
// ---------------------------------------------------------------------------
const SlamVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
text,
frame,
fps,
}) => {
// Main text slam spring
const slamProgress = spring({
frame,
fps,
config: { damping: 12, stiffness: 300 },
});
const textScale = interpolate(slamProgress, [0, 1], [2.0, 1.0]);
const textOpacity = interpolate(slamProgress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Impact frame: the frame at which scale first reaches ~1.0
// With damping 12, stiffness 300 the spring crosses 1.0 around frame 5-6
const impactFrame = 5;
// Screen shake: 3 frames starting at impact
let shakeX = 0;
let shakeY = 0;
const shakeFrame = frame - impactFrame;
if (shakeFrame >= 0 && shakeFrame < SHAKE_OFFSETS.length) {
shakeX = SHAKE_OFFSETS[shakeFrame].x;
shakeY = SHAKE_OFFSETS[shakeFrame].y;
}
// Orange accent line appears after slam lands
const lineDelay = impactFrame + 4;
const lineProgress = spring({
frame: frame - lineDelay,
fps,
config: theme.animation.snappy,
});
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
// Radial orange glow pulse on impact
const glowPeak = impactFrame;
const glowOpacity = interpolate(
frame,
[glowPeak, glowPeak + 4, glowPeak + 14],
[0, 0.45, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return (
<AbsoluteFill
style={{
transform: `translate(${shakeX}px, ${shakeY}px)`,
}}
>
{/* Radial orange glow */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
}}
>
<div
style={{
width: 900,
height: 600,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
opacity: glowOpacity,
filter: "blur(60px)",
}}
/>
</AbsoluteFill>
{/* Text + underline */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 72,
fontWeight: 900,
color: theme.colors.text,
textAlign: "center",
maxWidth: 850,
lineHeight: 1.15,
transform: `scale(${textScale})`,
opacity: textOpacity,
letterSpacing: -1,
}}
>
{text}
</div>
{/* Orange accent line */}
<div
style={{
width: 300,
height: 5,
marginTop: 24,
borderRadius: 3,
background: theme.colors.accent,
transform: `scaleX(${lineScaleX})`,
transformOrigin: "left",
opacity: lineScaleX > 0.01 ? 1 : 0,
}}
/>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Variant: typewriter
// ---------------------------------------------------------------------------
const TypewriterVariant: React.FC<{
text: string;
frame: number;
fps: number;
}> = ({ text, frame, fps }) => {
// ~2 chars per frame
const charsPerFrame = 2;
const visibleChars = Math.min(frame * charsPerFrame, text.length);
const typingComplete = visibleChars >= text.length;
const typingCompleteFrame = Math.ceil(text.length / charsPerFrame);
// Cursor blink: after typing completes, blink 2x then disappear
// Each blink cycle = 8 frames on, 6 frames off
const cursorVisible = (() => {
if (!typingComplete) return true; // solid while typing
const elapsed = frame - typingCompleteFrame;
if (elapsed < 0) return true;
// 2 blink cycles: on 8, off 6, on 8, off 6 = 28 frames total
const blinkCycle = 14; // 8 on + 6 off
if (elapsed >= blinkCycle * 2) return false; // disappeared
const withinCycle = elapsed % blinkCycle;
return withinCycle < 8;
})();
// Scan line effect: thin horizontal lines scrolling down slowly
const scanLineOffset = (frame * 0.5) % 8;
return (
<AbsoluteFill>
{/* Scan line overlay */}
<AbsoluteFill
style={{
backgroundImage: `repeating-linear-gradient(
0deg,
transparent,
transparent 6px,
rgba(255, 255, 255, 0.05) 6px,
rgba(255, 255, 255, 0.05) 8px
)`,
backgroundPosition: `0 ${scanLineOffset}px`,
pointerEvents: "none",
zIndex: 10,
}}
/>
{/* Text container */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
maxWidth: 900,
padding: "0 60px",
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
}}
>
<span
style={{
fontFamily: "SF Mono, Menlo, monospace",
fontSize: 56,
fontWeight: 700,
color: theme.colors.text,
lineHeight: 1.3,
letterSpacing: -0.5,
whiteSpace: "pre-wrap",
}}
>
{text.slice(0, Math.floor(visibleChars))}
</span>
{/* Cursor */}
{cursorVisible && (
<span
style={{
display: "inline-block",
width: 4,
height: 56,
background: theme.colors.accent,
marginLeft: 2,
verticalAlign: "bottom",
borderRadius: 2,
}}
/>
)}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Variant: split
// ---------------------------------------------------------------------------
const SplitVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
text,
frame,
fps,
}) => {
const words = text.split(/\s+/);
const midpoint = Math.ceil(words.length / 2);
const topHalf = words.slice(0, midpoint).join(" ");
const bottomHalf = words.slice(midpoint).join(" ");
// Top half slides from left
const topProgress = spring({
frame,
fps,
config: { damping: 14, stiffness: 160 },
});
const topTranslateX = interpolate(topProgress, [0, 1], [-600, 0]);
const topOpacity = interpolate(topProgress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Bottom half slides from right, slight delay (3 frames)
const bottomProgress = spring({
frame: frame - 3,
fps,
config: { damping: 14, stiffness: 160 },
});
const bottomTranslateX = interpolate(bottomProgress, [0, 1], [600, 0]);
const bottomOpacity = interpolate(bottomProgress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Dividing line appears after both halves have settled
const lineDelay = 12;
const lineProgress = spring({
frame: frame - lineDelay,
fps,
config: theme.animation.snappy,
});
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: 900,
padding: "0 60px",
gap: 0,
}}
>
{/* Top half */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 800,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.2,
transform: `translateX(${topTranslateX}px)`,
opacity: topOpacity,
letterSpacing: -1,
}}
>
{topHalf}
</div>
{/* Orange dividing line */}
<div
style={{
width: 400,
height: 3,
marginTop: 20,
marginBottom: 20,
background: theme.colors.accent,
transform: `scaleX(${lineScaleX})`,
transformOrigin: "center",
opacity: lineScaleX > 0.01 ? 1 : 0,
borderRadius: 2,
}}
/>
{/* Bottom half */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 800,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.2,
transform: `translateX(${bottomTranslateX}px)`,
opacity: bottomOpacity,
letterSpacing: -1,
}}
>
{bottomHalf}
</div>
</div>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export const TextPunchScene: React.FC<TextPunchSceneProps> = ({
text = "The spreadsheet era is over.",
subtext,
variant = "slam",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Subtext fades in 0.4s after the main animation completes
const mainAnimationEndFrame = useMemo(() => {
switch (variant) {
case "slam":
// Slam lands ~5 frames, accent line ~9 frames → settled ~15
return 15;
case "typewriter": {
// Typing at 2 chars/frame + 2 blink cycles (28 frames)
const typingDone = Math.ceil(text.length / 2);
return typingDone;
}
case "split":
// Both halves settle ~15 frames, line at ~24
return 24;
default:
return 15;
}
}, [variant, text]);
const subtextDelay = mainAnimationEndFrame + Math.round(0.4 * fps);
const subtextOpacity = subtext
? interpolate(
frame - subtextDelay,
[0, Math.round(0.25 * fps)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: 0;
const subtextTranslateY = subtext
? interpolate(
frame - subtextDelay,
[0, Math.round(0.25 * fps)],
[12, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: 0;
return (
<AbsoluteFill style={{ background: theme.colors.background }}>
{/* Variant content */}
{variant === "slam" && (
<SlamVariant text={text} frame={frame} fps={fps} />
)}
{variant === "typewriter" && (
<TypewriterVariant text={text} frame={frame} fps={fps} />
)}
{variant === "split" && (
<SplitVariant text={text} frame={frame} fps={fps} />
)}
{/* Subtext (shared across all variants) */}
{subtext && (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
}}
>
<div
style={{
position: "absolute",
bottom: "32%",
fontFamily: theme.fonts.text,
fontSize: 32,
fontWeight: 500,
color: theme.colors.textSecondary,
textAlign: "center",
maxWidth: 800,
padding: "0 60px",
opacity: subtextOpacity,
transform: `translateY(${subtextTranslateY}px)`,
}}
>
{subtext}
</div>
</AbsoluteFill>
)}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,9 @@
export { HookCard } from "./HookCard";
export { ChatScene } from "./ChatScene";
export { ScreenRecScene } from "./ScreenRecScene";
export { MapScene } from "./MapScene";
export { PollScene } from "./PollScene";
export { FlexScene } from "./FlexScene";
export { TextPunchScene } from "./TextPunchScene";
export { CTAEndCard } from "./CTAEndCard";
export { KineticCaption } from "./KineticCaption";

View File

@@ -0,0 +1,89 @@
/**
* Config-driven video engine types.
*
* Each video is defined by a VideoConfig that specifies its scenes,
* captions, CTA, and timing. The VideoFromConfig component reads
* these configs and renders the appropriate scene components.
*/
export type SceneType =
| "HOOK"
| "CHAT"
| "SCREENREC"
| "MAP"
| "POLL"
| "FLEX"
| "TEXTPUNCH"
| "CTA";
export type CaptionLine = {
text: string;
/** Seconds from video start when this caption appears */
startSec: number;
/** Seconds from video start when this caption disappears */
endSec: number;
/** Optional emphasis style */
emphasis?: "normal" | "bold" | "highlight";
};
export type SceneConfig = {
type: SceneType;
/** Duration in seconds for this scene */
durationSec: number;
/** Scene-specific props passed to the scene component */
props?: Record<string, unknown>;
};
export type VideoConfig = {
/** Unique composition ID, e.g. "V03_H01" */
id: string;
/** Base video concept, e.g. "V03" */
base: string;
/** Hook text shown in the first scene */
hook: string;
/** Ordered scene list */
scenes: SceneConfig[];
/** Kinetic caption lines */
captions: CaptionLine[];
/** CTA line (must include "Search SportsTime") */
cta: string;
/** Optional voiceover line */
vo?: string;
/** Target total length in seconds (12-18) */
targetLengthSec: number;
/** Asset keys used by this video */
assets?: {
screenrec?: string[];
overlay?: string[];
broll?: string[];
};
};
export type Week1Configs = VideoConfig[];
/** Asset key registry - maps logical names to file paths in public/ */
export const ASSET_KEYS = {
screenrecs: {
"date-range": "screenrecs/date-range.mp4",
"follow-team": "screenrecs/follow-team.mp4",
"by-games": "screenrecs/by-games.mp4",
"route-generated": "screenrecs/route-generated.mp4",
"poll-create": "screenrecs/poll-create.mp4",
tracker: "screenrecs/tracker.mp4",
},
overlays: {
"imessage-bg": "overlays/imessage-bg.png",
"chat-bubbles": "overlays/chat-bubbles.png",
"vote-bubbles": "overlays/vote-bubbles.png",
},
broll: {
highway: "broll/highway.mp4",
city: "broll/city.mp4",
stadium: "broll/stadium.mp4",
},
} as const;
export type AssetCategory = keyof typeof ASSET_KEYS;
export type ScreenrecKey = keyof typeof ASSET_KEYS.screenrecs;
export type OverlayKey = keyof typeof ASSET_KEYS.overlays;
export type BrollKey = keyof typeof ASSET_KEYS.broll;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,979 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { theme } from "../../components/shared/theme";
import { FilmGrain } from "../../components/shared/FilmGrain";
import { TikTokCaption } from "../../components/shared/TikTokCaption";
import type { CaptionEntry } from "../../components/shared/TikTokCaption";
// ---------------------------------------------------------------------------
// Captions
// ---------------------------------------------------------------------------
const CAPTIONS: CaptionEntry[] = [
{ text: "Be honest.", startSec: 0.2, endSec: 1.2, style: "punch" },
{ text: "How many stadiums?", startSec: 1.3, endSec: 2.3, style: "shake" },
{ text: "47 and counting", startSec: 3.0, endSec: 5.0, style: "highlight" },
{ text: "Every. Single. One.", startSec: 6.0, endSec: 8.0, style: "stack" },
{ text: "Track yours", startSec: 9.0, endSec: 11.0, style: "whisper" },
{ text: "DROP YOUR NUMBER", startSec: 12.0, endSec: 14.5, style: "punch" },
];
// ---------------------------------------------------------------------------
// Scene 1 : HOOK (0 - 2.5s, 75 frames)
// ---------------------------------------------------------------------------
const HookScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const WORDS = [
{ text: "Be honest.", delayFrames: 6 },
{ text: "How many stadiums", delayFrames: 30 },
{ text: "have you actually", delayFrames: 45 },
{ text: "been to?", delayFrames: 55 },
];
// Subtle background shake that decays
const shakeDecay = interpolate(frame, [0, 2 * fps], [1, 0], {
extrapolateRight: "clamp",
});
const SHAKE_OFFSETS = [
{ x: -3, y: 2 },
{ x: 4, y: -3 },
{ x: -2, y: -2 },
{ x: 3, y: 3 },
{ x: -1, y: -1 },
{ x: 2, y: 1 },
];
const shakeIdx = frame % SHAKE_OFFSETS.length;
const currentShake = SHAKE_OFFSETS[shakeIdx];
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: 60,
transform: `translate(${currentShake.x * shakeDecay}px, ${currentShake.y * shakeDecay}px)`,
}}
>
{WORDS.map((word, i) => {
const localFrame = frame - word.delayFrames;
const slamSpring = spring({
frame: Math.max(0, localFrame),
fps,
config: { damping: 10, stiffness: 280 },
});
const scale = localFrame < 0 ? 0 : interpolate(slamSpring, [0, 1], [2, 1]);
const opacity = localFrame < 0
? 0
: interpolate(slamSpring, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<div
key={i}
style={{
fontFamily: theme.fonts.display,
fontSize: 72,
fontWeight: 900,
color: theme.colors.gold,
textAlign: "center",
lineHeight: 1.15,
letterSpacing: -2,
transform: `scale(${scale})`,
opacity,
textShadow: `0 0 40px rgba(255, 215, 0, 0.4), 0 4px 12px rgba(0,0,0,0.8)`,
}}
>
{word.text}
</div>
);
})}
</div>
<FilmGrain opacity={0.06} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 2 : THE COUNTER (2.5 - 5.5s, 90 frames)
// ---------------------------------------------------------------------------
const LEAGUE_PILLS = [
{ label: "MLB", delay: 0 },
{ label: "NFL", delay: 4 },
{ label: "NBA", delay: 8 },
{ label: "NHL", delay: 12 },
];
const CounterScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const TARGET = 47;
const TOTAL = 120;
// Counter ramp: accelerate then decelerate over 2 seconds
const counterProgress = interpolate(frame, [0, 2 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// Ease-out curve
const eased = 1 - Math.pow(1 - counterProgress, 3);
const currentCount = Math.round(eased * TARGET);
// Progress bar fill
const barProgress = interpolate(frame, [0.2 * fps, 2.2 * fps], [0, TARGET / TOTAL], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Gold glow pulse
const glowPulse = interpolate(
Math.sin(frame * 0.08),
[-1, 1],
[0.15, 0.3]
);
// Number glow intensifies as count goes up
const numberGlow = interpolate(counterProgress, [0, 1], [20, 60]);
// Sub-text reveal
const subOpacity = interpolate(frame, [1.5 * fps, 2 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Gold radial glow */}
<div
style={{
position: "absolute",
top: "30%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "120%",
height: "70%",
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 65%)`,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 32,
zIndex: 1,
}}
>
{/* Giant counter */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 220,
fontWeight: 900,
color: theme.colors.gold,
lineHeight: 1,
letterSpacing: -8,
textShadow: `0 0 ${numberGlow}px rgba(255, 215, 0, 0.8), 0 0 ${numberGlow * 2}px rgba(255, 215, 0, 0.3)`,
}}
>
{currentCount}
</div>
{/* Progress bar */}
<div
style={{
width: 900,
height: 12,
borderRadius: 6,
background: "#1A1A1A",
overflow: "hidden",
}}
>
<div
style={{
width: `${barProgress * 100}%`,
height: "100%",
borderRadius: 6,
background: "linear-gradient(90deg, #FFD700 0%, #FFA500 100%)",
boxShadow: "0 0 20px rgba(255, 215, 0, 0.5)",
}}
/>
</div>
{/* Count label */}
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 32,
fontWeight: 500,
color: theme.colors.text,
opacity: subOpacity,
letterSpacing: 2,
}}
>
{currentCount} / {TOTAL} stadiums
</div>
{/* League pills */}
<div
style={{
display: "flex",
gap: 16,
marginTop: 16,
}}
>
{LEAGUE_PILLS.map((pill, i) => {
const pillSpring = spring({
frame: frame - 2 * fps - pill.delay,
fps,
config: { damping: 12, stiffness: 200 },
});
const pillScale = frame - 2 * fps - pill.delay < 0
? 0
: interpolate(pillSpring, [0, 1], [0.5, 1]);
const pillOpacity = frame - 2 * fps - pill.delay < 0
? 0
: interpolate(pillSpring, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<div
key={i}
style={{
background: "rgba(255, 215, 0, 0.1)",
border: "2px solid rgba(255, 215, 0, 0.6)",
borderRadius: 24,
padding: "10px 28px",
transform: `scale(${pillScale})`,
opacity: pillOpacity,
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: theme.colors.gold,
letterSpacing: 2,
}}
>
{pill.label}
</span>
</div>
);
})}
</div>
</div>
<FilmGrain opacity={0.05} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 3 : STADIUM MAP (5.5 - 9s, 105 frames)
// ---------------------------------------------------------------------------
const SPORTS_EMOJI = ["\u26BE", "\uD83C\uDFC8", "\uD83C\uDFC0", "\uD83C\uDFD2"];
const TOTAL_CIRCLES = 30;
const FILLED_COUNT = 24;
const COLS = 5;
const ROWS = 6;
const StadiumMapScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Circles fill one by one, 2 frames apart
const filledSoFar = Math.min(
FILLED_COUNT,
Math.max(0, Math.floor((frame - 0.3 * fps) / 2))
);
// Gold radial glow pulse
const glowPulse = interpolate(
Math.sin(frame * 0.06),
[-1, 1],
[0.12, 0.25]
);
// Running count text
const countSpring = spring({
frame: Math.max(0, frame - 0.3 * fps),
fps,
config: { damping: 30, stiffness: 80 },
});
const countOpacity = interpolate(frame, [0.2 * fps, 0.5 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Gold radial glow */}
<div
style={{
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "120%",
height: "70%",
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 60%)`,
}}
/>
{/* Running count in top-right */}
<div
style={{
position: "absolute",
top: 100,
right: 80,
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 900,
color: theme.colors.gold,
opacity: countOpacity,
textShadow: "0 0 30px rgba(255, 215, 0, 0.6)",
}}
>
{filledSoFar}
</div>
{/* Stadium grid */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 36,
zIndex: 1,
}}
>
{Array.from({ length: ROWS }).map((_, row) => (
<div key={row} style={{ display: "flex", gap: 48 }}>
{Array.from({ length: COLS }).map((_, col) => {
const idx = row * COLS + col;
const isFilled = idx < filledSoFar;
const isLatest = idx === filledSoFar - 1 && filledSoFar > 0;
const emojiChar = SPORTS_EMOJI[idx % SPORTS_EMOJI.length];
// Pop spring for fill
const fillSpring = isFilled
? spring({
frame: Math.max(0, frame - 0.3 * fps - idx * 2),
fps,
config: { damping: 8, stiffness: 300 },
})
: 0;
const circleScale = isFilled
? interpolate(fillSpring, [0, 1], [0.3, 1])
: 1;
// Pulse for the latest filled circle
const pulseScale = isLatest
? 1 + 0.08 * Math.sin(frame * 0.3)
: 1;
return (
<div
key={col}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
}}
>
<div
style={{
width: 72,
height: 72,
borderRadius: 36,
background: isFilled
? theme.colors.gold
: "transparent",
border: isFilled
? "3px solid rgba(255, 215, 0, 0.9)"
: "3px solid rgba(128, 128, 128, 0.3)",
boxShadow: isFilled
? `0 0 ${isLatest ? 28 : 16}px rgba(255, 215, 0, ${isLatest ? 0.8 : 0.4})`
: "none",
transform: `scale(${circleScale * pulseScale})`,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{isFilled && (
<span style={{ fontSize: 28, lineHeight: 1 }}>
{emojiChar}
</span>
)}
</div>
</div>
);
})}
</div>
))}
</div>
<FilmGrain opacity={0.05} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 4 : TRACKER APP (9 - 12s, 90 frames)
// ---------------------------------------------------------------------------
const TRACKER_ROWS = [
{ stadium: "Dodger Stadium", city: "LA", date: "Apr 12" },
{ stadium: "Fenway Park", city: "Boston", date: "May 3" },
{ stadium: "Wrigley Field", city: "Chicago", date: "May 18" },
{ stadium: "Yankee Stadium", city: "NYC", date: "Jun 7" },
{ stadium: "Oracle Park", city: "SF", date: "Jun 22" },
{ stadium: "Coors Field", city: "Denver", date: "Jul 4" },
{ stadium: "PNC Park", city: "Pittsburgh", date: "Jul 19" },
{ stadium: "Camden Yards", city: "Baltimore", date: "Aug 1" },
{ stadium: "Minute Maid Park", city: "Houston", date: "Aug 14" },
{ stadium: "T-Mobile Park", city: "Seattle", date: "Sep 2" },
];
const TrackerAppScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Slow zoom drift on phone 1.0 -> 1.02
const zoomDrift = interpolate(frame, [0, 3 * fps], [1.0, 1.02], {
extrapolateRight: "clamp",
});
// Scroll offset: rows slide up over time
const scrollOffset = interpolate(frame, [0.3 * fps, 2.8 * fps], [0, 280], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Phone entrance
const phoneSpring = spring({
frame,
fps,
config: { damping: 18, stiffness: 120 },
});
const phoneScale = interpolate(phoneSpring, [0, 1], [0.85, 1]);
const phoneOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame */}
<div
style={{
width: 440,
height: 920,
borderRadius: 60,
background: "#1C1C1E",
border: "4px solid #333",
overflow: "hidden",
position: "relative",
transform: `scale(${phoneScale * zoomDrift})`,
opacity: phoneOpacity,
boxShadow: "0 40px 80px rgba(0,0,0,0.6), 0 0 60px rgba(255,215,0,0.08)",
}}
>
{/* Dynamic Island */}
<div
style={{
position: "absolute",
top: 14,
left: "50%",
transform: "translateX(-50%)",
width: 130,
height: 36,
borderRadius: 18,
background: "#000",
zIndex: 10,
}}
/>
{/* Status bar area */}
<div
style={{
height: 70,
background: "rgba(10,10,10,0.95)",
}}
/>
{/* App header */}
<div
style={{
padding: "16px 28px 12px",
background: "rgba(10,10,10,0.95)",
borderBottom: "1px solid rgba(255,215,0,0.15)",
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 26,
fontWeight: 800,
color: theme.colors.gold,
letterSpacing: -0.5,
}}
>
Stadium Tracker
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textSecondary,
marginTop: 4,
}}
>
47 of 120 visited
</div>
</div>
{/* Scrolling list */}
<div
style={{
position: "relative",
overflow: "hidden",
flex: 1,
height: 700,
}}
>
<div
style={{
transform: `translateY(-${scrollOffset}px)`,
padding: "8px 0",
}}
>
{TRACKER_ROWS.map((row, i) => {
const rowDelay = i * 3;
const rowSpring = spring({
frame: Math.max(0, frame - 0.2 * fps - rowDelay),
fps,
config: { damping: 20, stiffness: 180 },
});
const rowOpacity = frame - 0.2 * fps - rowDelay < 0
? 0
: interpolate(rowSpring, [0, 0.4], [0, 1], {
extrapolateRight: "clamp",
});
const rowX = interpolate(rowSpring, [0, 1], [40, 0]);
return (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
padding: "16px 28px",
borderBottom: "1px solid rgba(255,255,255,0.06)",
opacity: rowOpacity,
transform: `translateX(${rowX}px)`,
}}
>
{/* Check badge */}
<div
style={{
width: 36,
height: 36,
borderRadius: 18,
background: "rgba(255,215,0,0.15)",
border: "2px solid rgba(255,215,0,0.5)",
display: "flex",
justifyContent: "center",
alignItems: "center",
marginRight: 16,
flexShrink: 0,
}}
>
<span
style={{
color: theme.colors.gold,
fontSize: 18,
fontWeight: 700,
}}
>
{"\u2713"}
</span>
</div>
{/* Stadium info */}
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 18,
fontWeight: 700,
color: theme.colors.text,
lineHeight: 1.2,
}}
>
{row.stadium}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textSecondary,
marginTop: 2,
}}
>
{row.city}
</div>
</div>
{/* Date */}
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
flexShrink: 0,
}}
>
{row.date}
</div>
</div>
);
})}
</div>
</div>
</div>
<FilmGrain opacity={0.04} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 5 : CTA (12 - 15s, 90 frames)
// ---------------------------------------------------------------------------
const CTAScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "Drop your number." slam
const headlineSlam = spring({
frame,
fps,
config: { damping: 10, stiffness: 260 },
});
const headlineScale = interpolate(headlineSlam, [0, 1], [2, 1]);
const headlineOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// "Search SportsTime" text
const subProgress = spring({
frame: frame - 0.8 * fps,
fps,
config: theme.animation.snappy,
});
const subOpacity = interpolate(
frame - 0.8 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const subY = interpolate(subProgress, [0, 1], [20, 0]);
// App icon
const iconProgress = spring({
frame: frame - 0.4 * fps,
fps,
config: { damping: 14, stiffness: 140 },
});
const iconScale = interpolate(iconProgress, [0, 1], [0.4, 1]);
const iconOpacity = interpolate(
frame - 0.4 * fps,
[0, 0.15 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Subtle golden glow behind everything
const bgGlow = interpolate(frame, [0, 0.5 * fps], [0, 0.15], {
extrapolateRight: "clamp",
});
// Search icon subtle bounce
const searchBounce = frame - 0.8 * fps > 0
? 1 + 0.03 * Math.sin((frame - 0.8 * fps) * 0.15)
: 1;
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Background glow */}
<div
style={{
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "130%",
height: "60%",
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${bgGlow}) 0%, transparent 65%)`,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 40,
padding: 60,
zIndex: 1,
}}
>
{/* App icon */}
<div
style={{
width: 120,
height: 120,
borderRadius: 28,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: "0 16px 48px rgba(255, 107, 53, 0.4)",
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity: iconOpacity,
transform: `scale(${iconScale})`,
}}
>
<svg width="64" height="64" viewBox="0 0 100 100" fill="none">
<ellipse
cx="50"
cy="60"
rx="40"
ry="20"
stroke="white"
strokeWidth="4"
fill="none"
/>
<path
d="M10 60 L10 40 Q50 10 90 40 L90 60"
stroke="white"
strokeWidth="4"
fill="none"
/>
<line
x1="50"
y1="30"
x2="50"
y2="15"
stroke="white"
strokeWidth="3"
/>
<circle cx="50" cy="12" r="4" fill="white" />
</svg>
</div>
{/* "Drop your number." */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 900,
color: theme.colors.gold,
textAlign: "center",
lineHeight: 1.15,
letterSpacing: -2,
transform: `scale(${headlineScale})`,
opacity: headlineOpacity,
textShadow:
"0 0 40px rgba(255, 215, 0, 0.5), 0 4px 16px rgba(0,0,0,0.8)",
}}
>
Drop your number.
</div>
{/* Search CTA */}
<div
style={{
opacity: subOpacity,
transform: `translateY(${subY}px) scale(${searchBounce})`,
display: "flex",
alignItems: "center",
gap: 14,
}}
>
{/* Search icon */}
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,0.7)"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 36,
fontWeight: 500,
color: theme.colors.text,
letterSpacing: 0.5,
}}
>
Search{" "}
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
SportsTime
</span>
</span>
</div>
</div>
<FilmGrain opacity={0.05} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Main Composition
// ---------------------------------------------------------------------------
/**
* VIDEO 1: "Stadium Count Flex"
*
* Competitive flex / brag video for TikTok.
* Gold + dark scoreboard aesthetic. ESPN stat graphics meets TikTok energy.
* 1080x1920, 30fps, 15 seconds (450 frames).
*
* Scene breakdown:
* - 0:00-0:02.5 (0-75): HOOK - "Be honest. How many stadiums have you been to?"
* - 0:02.5-0:05.5 (75-165): THE COUNTER - Number animates 0 -> 47
* - 0:05.5-0:09 (165-270): STADIUM MAP - Grid of circles filling up
* - 0:09-0:12 (270-360): TRACKER APP - Simulated phone screen
* - 0:12-0:15 (360-450): CTA - "Drop your number."
*/
export const StadiumCountFlex: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 10;
const SCENE_DURATIONS = {
hook: Math.round(2.5 * fps), // 75 frames
counter: Math.round(3 * fps), // 90 frames
stadiumMap: Math.round(3.5 * fps), // 105 frames
trackerApp: Math.round(3 * fps), // 90 frames
cta: Math.round(3 * fps), // 90 frames
};
return (
<AbsoluteFill>
<TransitionSeries>
{/* Scene 1: HOOK */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hook}>
<HookScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: THE COUNTER */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.counter}>
<CounterScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-bottom" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: STADIUM MAP */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stadiumMap}>
<StadiumMapScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: TRACKER APP */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.trackerApp}>
<TrackerAppScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: CTA */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.cta}>
<CTAScene />
</TransitionSeries.Sequence>
</TransitionSeries>
{/* TikTok caption overlay on top of everything */}
<TikTokCaption captions={CAPTIONS} bottomOffset={260} />
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,274 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
import { TapIndicator } from "../../components/shared/TapIndicator";
/**
* Scene 2: Follow Team mode selection
*
* Shows the planning mode UI inside a phone frame with "Follow Team" being selected.
* On-screen text: "Follow Team mode"
*/
export const FollowTeamScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "Follow Team mode" on-screen text overlay (outside phone)
const labelBadgeProgress = spring({
frame: frame - 1.2 * fps,
fps,
config: theme.animation.snappy,
});
const labelBadgeOpacity = interpolate(
frame - 1.2 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame with app UI */}
<AppScreenshot delay={0} scale={0.88}>
<MockScreen>
<FollowTeamScreenContent />
</MockScreen>
</AppScreenshot>
{/* On-screen label: "Follow Team mode" - overlaid outside phone */}
<div
style={{
position: "absolute",
bottom: 120,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: labelBadgeOpacity,
transform: `scale(${interpolate(labelBadgeProgress, [0, 1], [0.8, 1])})`,
zIndex: 10,
}}
>
<div
style={{
background: theme.colors.accent,
padding: "14px 36px",
borderRadius: 40,
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 30,
fontWeight: 700,
color: theme.colors.text,
}}
>
Follow Team mode
</span>
</div>
</div>
</AbsoluteFill>
);
};
/** Inner screen content rendered inside the phone frame */
const FollowTeamScreenContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const modes = [
{
name: "Explore Region",
desc: "Discover games in an area",
icon: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
selected: false,
},
{
name: "Follow Team",
desc: "Chase your team on the road",
icon: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
selected: true,
},
{
name: "Custom Trip",
desc: "Build your own route",
icon: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7",
selected: false,
},
];
// Header entrance
const labelProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const labelOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
extrapolateRight: "clamp",
});
const labelY = interpolate(labelProgress, [0, 1], [20, 0]);
return (
<div style={{ padding: 12 }}>
{/* Header */}
<div
style={{
opacity: labelOpacity,
transform: `translateY(${labelY}px)`,
marginBottom: 36,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 34,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 6,
}}
>
Planning Mode
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
}}
>
How do you want to plan?
</div>
</div>
{/* Mode cards */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{modes.map((mode, index) => {
const cardProgress = spring({
frame: frame - (0.2 + index * 0.12) * fps,
fps,
config: theme.animation.smooth,
});
const scale = interpolate(cardProgress, [0, 1], [0.92, 1]);
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
// Follow Team gets selected
const selectProgress = spring({
frame: frame - 0.9 * fps,
fps,
config: theme.animation.snappy,
});
const isSelected = mode.selected && selectProgress > 0.5;
const borderColor = isSelected ? theme.colors.accent : "transparent";
const bgColor = isSelected ? `rgba(255, 107, 53, 0.12)` : "#1C1C1E";
return (
<div
key={mode.name}
style={{
background: bgColor,
borderRadius: 16,
padding: 24,
display: "flex",
alignItems: "center",
gap: 18,
transform: `scale(${scale})`,
opacity,
border: `3px solid ${borderColor}`,
}}
>
{/* Icon */}
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: isSelected
? theme.colors.accent
: "rgba(255,255,255,0.08)",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexShrink: 0,
}}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke={isSelected ? "white" : theme.colors.textSecondary}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={mode.icon} />
</svg>
</div>
{/* Text */}
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 700,
color: isSelected ? theme.colors.accent : theme.colors.text,
marginBottom: 2,
}}
>
{mode.name}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textSecondary,
}}
>
{mode.desc}
</div>
</div>
{/* Selected checkmark */}
{isSelected && (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill={theme.colors.accent} />
<path
d="M7 12l3 3 7-7"
stroke="white"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,175 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 1: Bold "HOT TAKE" hook
*
* Full-screen provocative text that grabs attention instantly.
* "If you've never done an away-game road trip... are you even a fan?"
*/
export const HotTakeScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "HOT TAKE" badge slam
const badgeSlam = spring({
frame,
fps,
config: { damping: 12, stiffness: 200 },
});
const badgeScale = interpolate(badgeSlam, [0, 1], [3, 1]);
const badgeOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// Main text reveal (staggered lines)
const line1Progress = spring({
frame: frame - 0.4 * fps,
fps,
config: theme.animation.smooth,
});
const line1Opacity = interpolate(
frame - 0.4 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const line1Y = interpolate(line1Progress, [0, 1], [40, 0]);
const line2Progress = spring({
frame: frame - 0.7 * fps,
fps,
config: theme.animation.smooth,
});
const line2Opacity = interpolate(
frame - 0.7 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const line2Y = interpolate(line2Progress, [0, 1], [40, 0]);
// Emphasis pulse on "are you even a fan?"
const emphasisPulse = spring({
frame: frame - 1.2 * fps,
fps,
config: { damping: 8, stiffness: 150 },
});
const emphasisScale = interpolate(emphasisPulse, [0, 0.5, 1], [0.8, 1.08, 1]);
// Subtle background pulse
const bgPulse = interpolate(
frame,
[0, 0.15 * fps],
[0, 1],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Red flash on slam */}
<AbsoluteFill
style={{
background: `radial-gradient(ellipse at center, rgba(255, 50, 50, ${0.15 * bgPulse}) 0%, transparent 70%)`,
}}
/>
{/* Content container */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 40,
padding: 60,
}}
>
{/* HOT TAKE badge */}
<div
style={{
opacity: badgeOpacity,
transform: `scale(${badgeScale})`,
}}
>
<div
style={{
background: "#FF2222",
padding: "16px 48px",
borderRadius: 12,
boxShadow: "0 8px 32px rgba(255, 34, 34, 0.5)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 52,
fontWeight: 900,
color: theme.colors.text,
letterSpacing: 6,
textTransform: "uppercase",
}}
>
HOT TAKE
</span>
</div>
</div>
{/* Main provocative text */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
marginTop: 24,
}}
>
<div
style={{
opacity: line1Opacity,
transform: `translateY(${line1Y}px)`,
fontFamily: theme.fonts.display,
fontSize: 46,
fontWeight: 600,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.3,
}}
>
If you've never done an{"\n"}away-game road trip...
</div>
<div
style={{
opacity: line2Opacity,
transform: `translateY(${line2Y}px) scale(${emphasisScale})`,
fontFamily: theme.fonts.display,
fontSize: 54,
fontWeight: 900,
color: theme.colors.accent,
textAlign: "center",
lineHeight: 1.3,
marginTop: 8,
}}
>
are you even a fan?
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,269 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 5: "This is your weekend." reaction + CTA
*
* Emotional payoff moment with the CTA:
* "Search SportsTime and run your team's road stretch."
*/
export const ReactionScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "This is your weekend." text slam
const headlineSlam = spring({
frame,
fps,
config: { damping: 14, stiffness: 160 },
});
const headlineScale = interpolate(headlineSlam, [0, 1], [1.5, 1]);
const headlineOpacity = interpolate(frame, [0, 0.2 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// VO text line
const voProgress = spring({
frame: frame - 0.8 * fps,
fps,
config: theme.animation.smooth,
});
const voOpacity = interpolate(
frame - 0.8 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const voY = interpolate(voProgress, [0, 1], [20, 0]);
// CTA entrance
const ctaProgress = spring({
frame: frame - 1.5 * fps,
fps,
config: theme.animation.snappy,
});
const ctaOpacity = interpolate(
frame - 1.5 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const ctaScale = interpolate(ctaProgress, [0, 1], [0.85, 1]);
// App icon entrance
const iconProgress = spring({
frame: frame - 1.8 * fps,
fps,
config: { damping: 15, stiffness: 100 },
});
const iconScale = interpolate(iconProgress, [0, 1], [0.5, 1]);
const iconOpacity = interpolate(
frame - 1.8 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Wordmark
const wordmarkProgress = spring({
frame: frame - 2.1 * fps,
fps,
config: theme.animation.smooth,
});
const wordmarkOpacity = interpolate(
frame - 2.1 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Background glow
const glowOpacity = interpolate(
frame,
[0, 0.5 * fps],
[0, 0.12],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Background accent glow */}
<div
style={{
position: "absolute",
top: "25%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "140%",
height: "60%",
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
opacity: glowOpacity,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 48,
padding: 60,
}}
>
{/* "This is your weekend." */}
<div
style={{
opacity: headlineOpacity,
transform: `scale(${headlineScale})`,
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 900,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.2,
letterSpacing: -2,
}}
>
This is your{"\n"}weekend.
</div>
{/* VO callout text */}
<div
style={{
opacity: voOpacity,
transform: `translateY(${voY}px)`,
fontFamily: theme.fonts.text,
fontSize: 26,
fontWeight: 500,
color: theme.colors.textSecondary,
textAlign: "center",
lineHeight: 1.5,
maxWidth: 800,
}}
>
Do at least one away-game run{"\n"}this season.
</div>
{/* App icon + wordmark */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 20,
marginTop: 24,
}}
>
{/* App Icon */}
<div
style={{
width: 120,
height: 120,
borderRadius: 28,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: `0 16px 48px rgba(255, 107, 53, 0.4)`,
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity: iconOpacity,
transform: `scale(${iconScale})`,
}}
>
<svg
width="64"
height="64"
viewBox="0 0 100 100"
fill="none"
>
<ellipse
cx="50"
cy="60"
rx="40"
ry="20"
stroke="white"
strokeWidth="4"
fill="none"
/>
<path
d="M10 60 L10 40 Q50 10 90 40 L90 60"
stroke="white"
strokeWidth="4"
fill="none"
/>
<line
x1="50"
y1="30"
x2="50"
y2="15"
stroke="white"
strokeWidth="3"
/>
<circle cx="50" cy="12" r="4" fill="white" />
</svg>
</div>
{/* SportsTime wordmark */}
<div
style={{
opacity: wordmarkOpacity,
transform: `translateY(${interpolate(wordmarkProgress, [0, 1], [10, 0])}px)`,
fontFamily: theme.fonts.display,
fontSize: 48,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -1,
}}
>
SportsTime
</div>
</div>
{/* CTA text */}
<div
style={{
opacity: ctaOpacity,
transform: `scale(${ctaScale})`,
}}
>
<div
style={{
background: "rgba(255, 255, 255, 0.08)",
borderRadius: 16,
padding: "20px 40px",
border: "1px solid rgba(255, 255, 255, 0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 500,
color: theme.colors.textSecondary,
textAlign: "center",
}}
>
Search{" "}
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
SportsTime
</span>{" "}
on the App Store
</span>
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,321 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
/**
* Scene 3: Road games surfaced
*
* Shows upcoming away games inside a phone frame, cards sliding in.
* On-screen text: "Plan it in seconds"
*/
type GameCard = {
opponent: string;
opponentColor: string;
date: string;
venue: string;
city: string;
};
const ROAD_GAMES: GameCard[] = [
{
opponent: "@ Dodgers",
opponentColor: "#005A9C",
date: "Fri, Jun 12",
venue: "Dodger Stadium",
city: "Los Angeles, CA",
},
{
opponent: "@ Giants",
opponentColor: "#FD5A1E",
date: "Sun, Jun 14",
venue: "Oracle Park",
city: "San Francisco, CA",
},
{
opponent: "@ Padres",
opponentColor: "#2F241D",
date: "Tue, Jun 16",
venue: "Petco Park",
city: "San Diego, CA",
},
{
opponent: "@ D-backs",
opponentColor: "#A71930",
date: "Thu, Jun 18",
venue: "Chase Field",
city: "Phoenix, AZ",
},
];
export const RoadGamesScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "Plan it in seconds" label overlay (outside phone)
const planLabelProgress = spring({
frame: frame - 2 * fps,
fps,
config: theme.animation.snappy,
});
const planLabelOpacity = interpolate(
frame - 2 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame with app UI */}
<AppScreenshot delay={0} scale={0.88}>
<MockScreen>
<RoadGamesScreenContent />
</MockScreen>
</AppScreenshot>
{/* "Plan it in seconds" label - overlaid outside phone */}
<div
style={{
position: "absolute",
bottom: 120,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: planLabelOpacity,
transform: `scale(${interpolate(planLabelProgress, [0, 1], [0.8, 1])})`,
zIndex: 10,
}}
>
<div
style={{
background: theme.colors.accent,
padding: "14px 36px",
borderRadius: 40,
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 30,
fontWeight: 700,
color: theme.colors.text,
}}
>
Plan it in seconds
</span>
</div>
</div>
</AbsoluteFill>
);
};
/** Inner screen content rendered inside the phone frame */
const RoadGamesScreenContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Header entrance
const headerProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const headerOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
extrapolateRight: "clamp",
});
const headerY = interpolate(headerProgress, [0, 1], [20, 0]);
return (
<div style={{ padding: 12 }}>
{/* Header */}
<div
style={{
opacity: headerOpacity,
transform: `translateY(${headerY}px)`,
marginBottom: 32,
}}
>
{/* Team badge */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 8,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: "#C9082A",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 16,
fontWeight: 900,
color: "white",
}}
>
ATL
</span>
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.text,
}}
>
Braves Road Games
</div>
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
}}
>
June 2026 away stretch
</div>
</div>
{/* Game cards */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
{ROAD_GAMES.map((game, index) => {
const cardDelay = 0.3 + index * 0.2;
const cardProgress = spring({
frame: frame - cardDelay * fps,
fps,
config: theme.animation.snappy,
});
const translateX = interpolate(cardProgress, [0, 1], [300, 0]);
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
return (
<div
key={game.venue}
style={{
background: "#1C1C1E",
borderRadius: 16,
padding: 22,
display: "flex",
alignItems: "center",
gap: 16,
transform: `translateX(${translateX}px)`,
opacity,
borderLeft: `4px solid ${game.opponentColor}`,
}}
>
{/* Date block */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
minWidth: 60,
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: theme.colors.textMuted,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{game.date.split(", ")[0]}
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 700,
color: theme.colors.text,
}}
>
{game.date.split(" ")[1].replace(",", "")}
</div>
</div>
{/* Divider */}
<div
style={{
width: 2,
height: 40,
background: "rgba(255,255,255,0.1)",
borderRadius: 1,
}}
/>
{/* Game info */}
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 3,
}}
>
{game.opponent}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 15,
color: theme.colors.textSecondary,
}}
>
{game.venue}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: theme.colors.textMuted,
}}
>
{game.city}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,301 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
/**
* Scene 4: Route + itinerary shown
*
* Animated route line connecting cities with itinerary card, inside phone frame.
* Shows the full trip: LA -> SF -> SD -> PHX
*/
type Stop = {
city: string;
abbr: string;
x: number;
y: number;
game: string;
date: string;
};
const STOPS: Stop[] = [
{ city: "Los Angeles", abbr: "LAX", x: 200, y: 340, game: "vs Dodgers", date: "Jun 12" },
{ city: "San Francisco", abbr: "SFO", x: 160, y: 160, game: "vs Giants", date: "Jun 14" },
{ city: "San Diego", abbr: "SAN", x: 280, y: 440, game: "vs Padres", date: "Jun 16" },
{ city: "Phoenix", abbr: "PHX", x: 550, y: 380, game: "vs D-backs", date: "Jun 18" },
];
export const RouteItineraryScene: React.FC = () => {
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame with app UI */}
<AppScreenshot delay={0} scale={0.88}>
<MockScreen>
<RouteScreenContent />
</MockScreen>
</AppScreenshot>
</AbsoluteFill>
);
};
/** Inner screen content rendered inside the phone frame */
const RouteScreenContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Route line draw progress
const lineProgress = interpolate(
frame,
[0.2 * fps, 1.8 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Itinerary card at bottom
const cardProgress = spring({
frame: frame - 2 * fps,
fps,
config: theme.animation.smooth,
});
const cardY = interpolate(cardProgress, [0, 1], [150, 0]);
const cardOpacity = interpolate(
frame - 2 * fps,
[0, 0.3 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Build SVG path segments
const pathSegments: string[] = [];
for (let i = 0; i < STOPS.length - 1; i++) {
const from = STOPS[i];
const to = STOPS[i + 1];
if (i === 0) {
pathSegments.push(`M ${from.x} ${from.y}`);
}
const cx = (from.x + to.x) / 2;
const cy = Math.min(from.y, to.y) - 40;
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
}
const fullPath = pathSegments.join(" ");
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Map area */}
<div
style={{
position: "absolute",
top: 0,
left: 12,
right: 12,
height: "60%",
}}
>
{/* Subtle map grid */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
`,
backgroundSize: "50px 50px",
borderRadius: 16,
}}
/>
{/* Route line SVG */}
<svg
width="100%"
height="100%"
viewBox="0 0 700 550"
style={{ position: "absolute", inset: 0 }}
>
{/* Route line background (dimmed) */}
<path
d={fullPath}
fill="none"
stroke="rgba(255, 107, 53, 0.15)"
strokeWidth="4"
strokeLinecap="round"
/>
{/* Route line animated */}
<path
d={fullPath}
fill="none"
stroke={theme.colors.accent}
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="2000"
strokeDashoffset={2000 * (1 - lineProgress)}
/>
</svg>
{/* Stop markers */}
{STOPS.map((stop, index) => {
const markerDelay = 0.3 + index * 0.4;
const markerProgress = spring({
frame: frame - markerDelay * fps,
fps,
config: { damping: 12, stiffness: 180 },
});
const markerScale = interpolate(markerProgress, [0, 1], [0, 1]);
const markerOpacity = interpolate(markerProgress, [0, 1], [0, 1]);
const leftPct = (stop.x / 700) * 100;
const topPct = (stop.y / 550) * 100;
return (
<div
key={stop.abbr}
style={{
position: "absolute",
left: `${leftPct}%`,
top: `${topPct}%`,
transform: `translate(-50%, -50%) scale(${markerScale})`,
opacity: markerOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
}}
>
{/* Ping ring */}
<div
style={{
position: "absolute",
width: 40,
height: 40,
borderRadius: "50%",
border: `2px solid ${theme.colors.accent}`,
opacity: 0.3,
}}
/>
{/* Marker dot */}
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: theme.colors.secondary,
border: "3px solid white",
boxShadow: `0 4px 12px rgba(78, 205, 196, 0.5)`,
}}
/>
{/* City label */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 15,
fontWeight: 700,
color: theme.colors.text,
textShadow: "0 2px 8px rgba(0,0,0,0.8)",
whiteSpace: "nowrap",
}}
>
{stop.city}
</div>
</div>
);
})}
</div>
{/* Itinerary card at bottom */}
<div
style={{
position: "absolute",
bottom: 20,
left: 16,
right: 16,
opacity: cardOpacity,
transform: `translateY(${cardY}px)`,
}}
>
<div
style={{
background: "#1C1C1E",
borderRadius: 20,
padding: 24,
border: `2px solid rgba(255, 107, 53, 0.3)`,
}}
>
{/* Card header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 700,
color: theme.colors.text,
}}
>
West Coast Run
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 15,
color: theme.colors.textSecondary,
}}
>
7 days
</div>
</div>
{/* Stats row */}
<div style={{ display: "flex", gap: 16 }}>
{[
{ label: "Games", value: "4" },
{ label: "Cities", value: "4" },
{ label: "Miles", value: "1,240" },
].map((stat) => (
<div key={stat.label} style={{ flex: 1, textAlign: "center" }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.accent,
}}
>
{stat.value}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textSecondary,
}}
>
{stat.label}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,97 @@
import React from "react";
import { AbsoluteFill, useVideoConfig } from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { HotTakeScene } from "./HotTakeScene";
import { FollowTeamScene } from "./FollowTeamScene";
import { RoadGamesScene } from "./RoadGamesScene";
import { RouteItineraryScene } from "./RouteItineraryScene";
import { ReactionScene } from "./ReactionScene";
import { GradientBackground } from "../../components/shared";
/**
* V02: "The Fan Test" (Viral)
*
* Hook: "If you've never done an away-game road trip... are you even a fan?"
* Concept: Identity challenge -> Follow Team demo.
* Length: 18 seconds (540 frames at 30fps)
*
* Scene breakdown:
* - 0:00-0:03 (0-90): Bold HOT TAKE hook
* - 0:03-0:06 (90-180): Follow Team mode selection
* - 0:06-0:10 (180-300): Road games surfaced
* - 0:10-0:14 (300-420): Route + itinerary shown
* - 0:14-0:18 (420-540): "This is your weekend." reaction + CTA
*
* On-screen text: "HOT TAKE", "Follow Team mode", "Plan it in seconds"
* CTA: "Search SportsTime and run your team's road stretch."
* Why it performs: debate comments + identity trigger.
*/
export const TheFanTest: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 12;
const SCENE_DURATIONS = {
hotTake: 3 * fps, // 90 frames
followTeam: 3 * fps, // 90 frames
roadGames: 4 * fps, // 120 frames
routeItinerary: 4 * fps, // 120 frames
reaction: 4 * fps, // 120 frames
};
return (
<AbsoluteFill>
<GradientBackground />
<TransitionSeries>
{/* Scene 1: HOT TAKE hook */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hotTake}>
<HotTakeScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: Follow Team mode selection */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.followTeam}>
<FollowTeamScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: Road games surfaced */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.roadGames}>
<RoadGamesScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: Route + itinerary */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.routeItinerary}>
<RouteItineraryScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: Reaction + CTA */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.reaction}>
<ReactionScene />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,189 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 2: "All talk" - the chat dies
*
* Shows the last few messages then silence.
* Typing indicator appears... then vanishes.
* Bold overlay: "All talk."
*/
export const AllTalkScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Typing indicator dots animation
const typingAppear = interpolate(
frame,
[0.2 * fps, 0.4 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const typingDisappear = interpolate(
frame,
[1 * fps, 1.2 * fps],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const typingOpacity = typingAppear * typingDisappear;
// Dot bounce cycle (frame-based, not CSS)
const dotBounce = (dotIndex: number) => {
const cycle = ((frame + dotIndex * 4) % 18) / 18;
return interpolate(cycle, [0, 0.5, 1], [0, -6, 0]);
};
// "All talk." slam
const slamDelay = 1.3 * fps;
const slamProgress = spring({
frame: frame - slamDelay,
fps,
config: { damping: 12, stiffness: 200 },
});
const slamScale = interpolate(slamProgress, [0, 1], [2.5, 1]);
const slamOpacity = interpolate(
frame - slamDelay,
[0, 0.1 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* Stale chat messages (static, from previous scene context) */}
<div
style={{
padding: "120px 24px 20px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{/* Last visible messages */}
<ChatBubble sender="Sam" color="#AF52DE" text="Maybe June?" isMe={false} />
<ChatBubble sender="Jake" color="#34C759" text="Or July" isMe={false} />
<ChatBubble sender="Mike" color="#FF9500" text="Whatever works" isMe={false} />
<ChatBubble sender="" color="#007AFF" text="Ok so..." isMe={true} />
{/* Typing indicator */}
<div
style={{
display: "flex",
alignItems: "flex-start",
opacity: typingOpacity,
marginTop: 8,
}}
>
<div
style={{
background: "#2C2C2E",
borderRadius: 20,
borderBottomLeftRadius: 6,
padding: "14px 20px",
display: "flex",
gap: 5,
alignItems: "center",
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
width: 8,
height: 8,
borderRadius: 4,
background: "rgba(255,255,255,0.4)",
transform: `translateY(${dotBounce(i)}px)`,
}}
/>
))}
</div>
</div>
</div>
{/* "All talk." overlay slam */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity: slamOpacity,
}}
>
<div
style={{
transform: `scale(${slamScale})`,
fontFamily: theme.fonts.display,
fontSize: 80,
fontWeight: 900,
color: "white",
letterSpacing: -3,
textShadow: "0 4px 40px rgba(0,0,0,0.8)",
}}
>
All talk.
</div>
</div>
</AbsoluteFill>
);
};
/** Simple static chat bubble (no animation) */
const ChatBubble: React.FC<{
sender: string;
color: string;
text: string;
isMe: boolean;
}> = ({ sender, color, text, isMe }) => (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: isMe ? "flex-end" : "flex-start",
}}
>
{!isMe && sender && (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color,
marginBottom: 2,
marginLeft: 12,
}}
>
{sender}
</span>
)}
<div
style={{
background: isMe ? "#007AFF" : "#2C2C2E",
borderRadius: 20,
borderBottomRightRadius: isMe ? 6 : 20,
borderBottomLeftRadius: isMe ? 20 : 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: "white",
lineHeight: 1.3,
}}
>
{text}
</span>
</div>
</div>
);

View File

@@ -0,0 +1,215 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 6: CTA ending
*
* Clean branded ending.
* "If your group chat is all talk → SportsTime"
*/
export const CTAScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Text line 1
const line1Progress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const line1Opacity = interpolate(frame, [0, 0.25 * fps], [0, 1], {
extrapolateRight: "clamp",
});
const line1Y = interpolate(line1Progress, [0, 1], [30, 0]);
// Arrow / divider
const arrowProgress = spring({
frame: frame - 0.5 * fps,
fps,
config: theme.animation.snappy,
});
const arrowOpacity = interpolate(
frame - 0.5 * fps,
[0, 0.15 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const arrowScale = interpolate(arrowProgress, [0, 1], [0.5, 1]);
// App icon + name
const brandProgress = spring({
frame: frame - 0.8 * fps,
fps,
config: { damping: 15, stiffness: 100 },
});
const brandScale = interpolate(brandProgress, [0, 1], [0.6, 1]);
const brandOpacity = interpolate(
frame - 0.8 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Search CTA
const ctaProgress = spring({
frame: frame - 1.2 * fps,
fps,
config: theme.animation.smooth,
});
const ctaOpacity = interpolate(
frame - 1.2 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Accent glow */}
<div
style={{
position: "absolute",
top: "30%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "130%",
height: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent}18 0%, transparent 70%)`,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 36,
padding: 60,
}}
>
{/* Line 1: "If your group chat is all talk" */}
<div
style={{
opacity: line1Opacity,
transform: `translateY(${line1Y}px)`,
fontFamily: theme.fonts.display,
fontSize: 42,
fontWeight: 700,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.3,
letterSpacing: -1,
}}
>
If your group chat{"\n"}is all talk
</div>
{/* Arrow */}
<div
style={{
opacity: arrowOpacity,
transform: `scale(${arrowScale})`,
}}
>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path
d="M12 5v14m0 0l-6-6m6 6l6-6"
stroke={theme.colors.accent}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* App icon + name */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
opacity: brandOpacity,
transform: `scale(${brandScale})`,
}}
>
<div
style={{
width: 100,
height: 100,
borderRadius: 24,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: "0 12px 40px rgba(255, 107, 53, 0.4)",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<svg width="56" height="56" viewBox="0 0 100 100" fill="none">
<ellipse cx="50" cy="60" rx="40" ry="20" stroke="white" strokeWidth="4" fill="none" />
<path d="M10 60 L10 40 Q50 10 90 40 L90 60" stroke="white" strokeWidth="4" fill="none" />
<line x1="50" y1="30" x2="50" y2="15" stroke="white" strokeWidth="3" />
<circle cx="50" cy="12" r="4" fill="white" />
</svg>
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 44,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -1,
}}
>
SportsTime
</div>
</div>
{/* CTA */}
<div
style={{
opacity: ctaOpacity,
transform: `translateY(${interpolate(ctaProgress, [0, 1], [15, 0])}px)`,
}}
>
<div
style={{
background: "rgba(255, 255, 255, 0.08)",
borderRadius: 14,
padding: "16px 36px",
border: "1px solid rgba(255, 255, 255, 0.12)",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 500,
color: theme.colors.textSecondary,
}}
>
Search{" "}
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
SportsTime
</span>{" "}
on the App Store
</span>
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,254 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 1: iMessage group chat blowing up
*
* Messages stack in rapid succession with spring pop-ins.
* Overlay: "Every group chat ever"
* Fast zooms on the chat as messages fly in.
*/
type Message = {
sender: string;
color: string;
text: string;
isMe: boolean;
delaySeconds: number;
};
const MESSAGES: Message[] = [
{ sender: "Jake", color: "#34C759", text: "We should do a baseball trip", isMe: false, delaySeconds: 0.15 },
{ sender: "Mike", color: "#FF9500", text: "I'm down", isMe: false, delaySeconds: 0.5 },
{ sender: "Sam", color: "#AF52DE", text: "Same", isMe: false, delaySeconds: 0.75 },
{ sender: "You", color: "#007AFF", text: "Let's goooo", isMe: true, delaySeconds: 1.0 },
{ sender: "Jake", color: "#34C759", text: "When tho", isMe: false, delaySeconds: 1.4 },
{ sender: "Mike", color: "#FF9500", text: "idk", isMe: false, delaySeconds: 1.7 },
{ sender: "Sam", color: "#AF52DE", text: "Maybe June?", isMe: false, delaySeconds: 2.0 },
{ sender: "Jake", color: "#34C759", text: "Or July", isMe: false, delaySeconds: 2.25 },
{ sender: "Mike", color: "#FF9500", text: "Whatever works", isMe: false, delaySeconds: 2.45 },
{ sender: "You", color: "#007AFF", text: "Ok so...", isMe: true, delaySeconds: 2.7 },
];
export const ChatScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Subtle zoom-in on the whole chat
const chatZoom = interpolate(frame, [0, 3.5 * fps], [1, 1.04], {
extrapolateRight: "clamp",
});
// Overlay text: "Every group chat ever"
const overlayProgress = spring({
frame: frame - 0.3 * fps,
fps,
config: { damping: 14, stiffness: 180 },
});
const overlayOpacity = interpolate(
frame - 0.3 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* iMessage-style chat background */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: `scale(${chatZoom})`,
transformOrigin: "center 40%",
}}
>
{/* Status bar */}
<div
style={{
padding: "16px 32px 12px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 16, color: "white" }}>
9:41
</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<div style={{ width: 16, height: 10, border: "1.5px solid white", borderRadius: 2 }}>
<div style={{ width: "70%", height: "100%", background: "white", borderRadius: 1 }} />
</div>
</div>
</div>
{/* Chat header */}
<div
style={{
padding: "8px 32px 16px",
borderBottom: "1px solid rgba(255,255,255,0.1)",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{/* Group avatar */}
<div
style={{
width: 44,
height: 44,
borderRadius: 22,
background: "linear-gradient(135deg, #34C759, #007AFF)",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span style={{ fontSize: 18 }}>4</span>
</div>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: "white",
}}
>
The Boys
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: "rgba(255,255,255,0.5)",
}}
>
4 people
</div>
</div>
</div>
{/* Messages */}
<div
style={{
padding: "20px 24px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{MESSAGES.map((msg, index) => {
const msgDelay = msg.delaySeconds * fps;
const msgProgress = spring({
frame: frame - msgDelay,
fps,
config: { damping: 14, stiffness: 200 },
});
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
if (frame < msgDelay) return null;
return (
<div
key={index}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.isMe ? "flex-end" : "flex-start",
transform: `scale(${msgScale})`,
opacity: msgOpacity,
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
}}
>
{/* Sender name (not for "me") */}
{!msg.isMe && (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: msg.color,
marginBottom: 2,
marginLeft: 12,
}}
>
{msg.sender}
</span>
)}
<div
style={{
background: msg.isMe ? "#007AFF" : "#2C2C2E",
borderRadius: 20,
borderBottomRightRadius: msg.isMe ? 6 : 20,
borderBottomLeftRadius: msg.isMe ? 20 : 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: "white",
lineHeight: 1.3,
}}
>
{msg.text}
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Overlay: "Every group chat ever" */}
<div
style={{
position: "absolute",
top: 100,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.85)",
backdropFilter: "blur(10px)",
padding: "16px 40px",
borderRadius: 16,
border: "1px solid rgba(255,255,255,0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 800,
color: "white",
letterSpacing: -0.5,
}}
>
Every group chat ever
</span>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,332 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 5: Poll it. Done.
*
* First half: poll appears in group chat
* Votes come in rapidly with spring animations
* Overlay: "Poll it. Done."
*/
type Vote = {
name: string;
color: string;
delaySeconds: number;
};
const VOTES: Vote[] = [
{ name: "Jake", color: "#34C759", delaySeconds: 0.6 },
{ name: "Mike", color: "#FF9500", delaySeconds: 0.85 },
{ name: "Sam", color: "#AF52DE", delaySeconds: 1.05 },
];
export const PollScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Poll card entrance
const pollEntrance = spring({
frame,
fps,
config: theme.animation.snappy,
});
const pollScale = interpolate(pollEntrance, [0, 1], [0.7, 1]);
const pollOpacity = interpolate(pollEntrance, [0, 1], [0, 1]);
// Overlay: "Poll it. Done."
const overlayDelay = 1.5 * fps;
const overlayProgress = spring({
frame: frame - overlayDelay,
fps,
config: { damping: 12, stiffness: 200 },
});
const overlayScale = interpolate(overlayProgress, [0, 1], [2, 1]);
const overlayOpacity = interpolate(
frame - overlayDelay,
[0, 0.1 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Vote count animation
const voteCount = VOTES.filter(
(v) => frame >= v.delaySeconds * fps
).length;
const totalVoters = VOTES.length + 1; // +1 for "You"
// Progress bar width
const yesWidth = interpolate(
(voteCount + 1) / totalVoters,
[0, 1],
[0, 100]
);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* Chat context */}
<div style={{ padding: "120px 24px 0" }}>
{/* "You" sent the poll */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
marginBottom: 16,
}}
>
<div
style={{
background: "#007AFF",
borderRadius: 20,
borderBottomRightRadius: 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 20, color: "white" }}>
I made us a trip. Vote
</span>
</div>
</div>
{/* Poll card */}
<div
style={{
transform: `scale(${pollScale})`,
opacity: pollOpacity,
transformOrigin: "left top",
}}
>
<div
style={{
background: "#1C1C1E",
borderRadius: 20,
padding: 28,
border: "1px solid rgba(255,255,255,0.1)",
}}
>
{/* Poll header */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: `linear-gradient(135deg, ${theme.colors.accent}, ${theme.colors.accentDark})`,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="10" rx="1" fill="white" />
<rect x="14" y="7" width="7" height="14" rx="1" fill="white" />
</svg>
</div>
<div>
<div style={{ fontFamily: theme.fonts.display, fontSize: 18, fontWeight: 700, color: theme.colors.text }}>
SportsTime Trip
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 14, color: theme.colors.textSecondary }}>
West Coast Baseball Run
</div>
</div>
</div>
{/* Trip details */}
<div style={{ marginBottom: 20, padding: "14px 16px", background: "rgba(255,255,255,0.04)", borderRadius: 12 }}>
<div style={{ fontFamily: theme.fonts.text, fontSize: 16, color: theme.colors.text, marginBottom: 4 }}>
Jun 1218 &middot; 4 games &middot; 4 cities
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 14, color: theme.colors.textSecondary }}>
LA SF SD Phoenix
</div>
</div>
{/* Vote bar */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<span style={{ fontFamily: theme.fonts.display, fontSize: 18, fontWeight: 700, color: theme.colors.text }}>
I'm in
</span>
<span style={{ fontFamily: theme.fonts.text, fontSize: 16, color: theme.colors.textSecondary }}>
{voteCount + 1}/{totalVoters}
</span>
</div>
<div style={{ height: 8, background: "rgba(255,255,255,0.08)", borderRadius: 4 }}>
<div
style={{
height: "100%",
width: `${yesWidth}%`,
background: theme.colors.success,
borderRadius: 4,
}}
/>
</div>
</div>
{/* Vote avatars */}
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{/* You (always voted) */}
<div
style={{
width: 36,
height: 36,
borderRadius: 18,
background: "#007AFF",
display: "flex",
justifyContent: "center",
alignItems: "center",
border: "2px solid #4CAF50",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 13, fontWeight: 700, color: "white" }}>
You
</span>
</div>
{/* Other votes pop in */}
{VOTES.map((vote) => {
const voteProgress = spring({
frame: frame - vote.delaySeconds * fps,
fps,
config: { damping: 10, stiffness: 200 },
});
const scale = interpolate(voteProgress, [0, 1], [0, 1]);
if (frame < vote.delaySeconds * fps) return null;
return (
<div
key={vote.name}
style={{
width: 36,
height: 36,
borderRadius: 18,
background: vote.color,
display: "flex",
justifyContent: "center",
alignItems: "center",
transform: `scale(${scale})`,
border: "2px solid #4CAF50",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 11, fontWeight: 700, color: "white" }}>
{vote.name.charAt(0)}
</span>
</div>
);
})}
</div>
</div>
</div>
{/* Reaction messages after votes */}
{frame >= 1.2 * fps && (
<div style={{ marginTop: 16 }}>
<ReactionBubble
text="LFG"
color="#34C759"
sender="Jake"
frame={frame}
fps={fps}
delay={1.2}
/>
</div>
)}
{frame >= 1.4 * fps && (
<div style={{ marginTop: 8 }}>
<ReactionBubble
text="\ud83d\udd25\ud83d\udd25\ud83d\udd25"
color="#FF9500"
sender="Mike"
frame={frame}
fps={fps}
delay={1.4}
/>
</div>
)}
</div>
{/* "Poll it. Done." overlay */}
<div
style={{
position: "absolute",
bottom: 180,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
}}
>
<div
style={{
transform: `scale(${overlayScale})`,
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 900,
color: "white",
letterSpacing: -2,
textShadow: "0 4px 30px rgba(0,0,0,0.8)",
}}
>
Poll it. Done.
</div>
</div>
</AbsoluteFill>
);
};
/** Reaction chat bubble */
const ReactionBubble: React.FC<{
text: string;
color: string;
sender: string;
frame: number;
fps: number;
delay: number;
}> = ({ text, color, sender, frame, fps, delay }) => {
const progress = spring({
frame: frame - delay * fps,
fps,
config: { damping: 14, stiffness: 200 },
});
const scale = interpolate(progress, [0, 1], [0.3, 1]);
const opacity = interpolate(progress, [0, 1], [0, 1]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
transform: `scale(${scale})`,
opacity,
transformOrigin: "left bottom",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 13, color, marginBottom: 2, marginLeft: 12 }}>
{sender}
</span>
<div
style={{
background: "#2C2C2E",
borderRadius: 20,
borderBottomLeftRadius: 6,
padding: "12px 18px",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 20, color: "white" }}>{text}</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,290 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
/**
* Scene 4: Routes generated
*
* Route auto-generates inside phone frame.
* Map with animated route line + game cards appear.
* Overlay: "Real route. Real games."
*/
type Stop = {
city: string;
x: number;
y: number;
};
const STOPS: Stop[] = [
{ city: "LA", x: 180, y: 300 },
{ city: "SF", x: 140, y: 130 },
{ city: "SD", x: 240, y: 400 },
{ city: "PHX", x: 480, y: 340 },
];
export const RouteScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Overlay text
const overlayProgress = spring({
frame: frame - 0.3 * fps,
fps,
config: theme.animation.snappy,
});
const overlayOpacity = interpolate(
frame - 0.3 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Overlay: "Real route. Real games." */}
<div
style={{
position: "absolute",
top: 80,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.85)",
padding: "14px 36px",
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 34,
fontWeight: 800,
color: "white",
}}
>
Real route. Real games.
</span>
</div>
</div>
{/* Phone with route */}
<div style={{ marginTop: 40 }}>
<AppScreenshot delay={0} scale={0.82}>
<MockScreen>
<RouteContent />
</MockScreen>
</AppScreenshot>
</div>
</AbsoluteFill>
);
};
/** Route map + game list inside phone */
const RouteContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Route draw
const lineProgress = interpolate(
frame,
[0.15 * fps, 1.5 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const pathSegments: string[] = [];
for (let i = 0; i < STOPS.length - 1; i++) {
const from = STOPS[i];
const to = STOPS[i + 1];
if (i === 0) pathSegments.push(`M ${from.x} ${from.y}`);
const cx = (from.x + to.x) / 2;
const cy = Math.min(from.y, to.y) - 30;
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
}
const fullPath = pathSegments.join(" ");
// Game cards
const games = [
{ team: "@ Dodgers", venue: "Dodger Stadium", date: "Jun 12", color: "#005A9C" },
{ team: "@ Giants", venue: "Oracle Park", date: "Jun 14", color: "#FD5A1E" },
{ team: "@ Padres", venue: "Petco Park", date: "Jun 16", color: "#2F241D" },
{ team: "@ D-backs", venue: "Chase Field", date: "Jun 18", color: "#A71930" },
];
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Map section (top 45%) */}
<div style={{ position: "absolute", top: 0, left: 8, right: 8, height: "45%" }}>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
borderRadius: 12,
}}
/>
<svg width="100%" height="100%" viewBox="0 0 600 480" style={{ position: "absolute", inset: 0 }}>
<path d={fullPath} fill="none" stroke="rgba(255,107,53,0.15)" strokeWidth="3.5" strokeLinecap="round" />
<path
d={fullPath}
fill="none"
stroke={theme.colors.accent}
strokeWidth="3.5"
strokeLinecap="round"
strokeDasharray="1500"
strokeDashoffset={1500 * (1 - lineProgress)}
/>
</svg>
{STOPS.map((stop, index) => {
const markerProgress = spring({
frame: frame - (0.2 + index * 0.35) * fps,
fps,
config: { damping: 12, stiffness: 180 },
});
const leftPct = (stop.x / 600) * 100;
const topPct = (stop.y / 480) * 100;
return (
<div
key={stop.city}
style={{
position: "absolute",
left: `${leftPct}%`,
top: `${topPct}%`,
transform: `translate(-50%, -50%) scale(${interpolate(markerProgress, [0, 1], [0, 1])})`,
opacity: interpolate(markerProgress, [0, 1], [0, 1]),
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<div
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: theme.colors.secondary,
border: "2.5px solid white",
boxShadow: "0 2px 8px rgba(78,205,196,0.5)",
}}
/>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 13,
fontWeight: 700,
color: "white",
textShadow: "0 1px 6px rgba(0,0,0,0.8)",
}}
>
{stop.city}
</span>
</div>
);
})}
</div>
{/* Game cards (bottom 55%) */}
<div
style={{
position: "absolute",
bottom: 12,
left: 12,
right: 12,
top: "46%",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 4,
}}
>
Your Games
</div>
{games.map((game, index) => {
const cardDelay = 1.2 + index * 0.18;
const cardProgress = spring({
frame: frame - cardDelay * fps,
fps,
config: theme.animation.snappy,
});
const translateX = interpolate(cardProgress, [0, 1], [250, 0]);
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
return (
<div
key={game.venue}
style={{
background: "#1C1C1E",
borderRadius: 14,
padding: "14px 18px",
display: "flex",
alignItems: "center",
gap: 14,
transform: `translateX(${translateX}px)`,
opacity,
borderLeft: `4px solid ${game.color}`,
}}
>
<div style={{ minWidth: 50, textAlign: "center" }}>
<div style={{ fontFamily: theme.fonts.display, fontSize: 16, fontWeight: 700, color: theme.colors.text }}>
{game.date.split(" ")[1]}
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 11, color: theme.colors.textMuted }}>
{game.date.split(" ")[0]}
</div>
</div>
<div style={{ width: 1.5, height: 32, background: "rgba(255,255,255,0.08)" }} />
<div>
<div style={{ fontFamily: theme.fonts.display, fontSize: 17, fontWeight: 700, color: theme.colors.text }}>
{game.team}
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 13, color: theme.colors.textSecondary }}>
{game.venue}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,281 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
import { TapIndicator } from "../../components/shared/TapIndicator";
/**
* Scene 3: "So I planned it myself"
*
* User opens SportsTime, builds trip.
* Shows date range + sport selection inside phone frame.
* Overlay: "So I planned it myself"
*/
export const SolutionScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Overlay text entrance
const overlayProgress = spring({
frame: frame - 0.3 * fps,
fps,
config: theme.animation.snappy,
});
const overlayOpacity = interpolate(
frame - 0.3 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Overlay: "So I planned it myself" */}
<div
style={{
position: "absolute",
top: 80,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.85)",
padding: "14px 36px",
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 34,
fontWeight: 800,
color: "white",
}}
>
So I planned it myself
</span>
</div>
</div>
{/* Phone with trip builder */}
<div style={{ marginTop: 40 }}>
<AppScreenshot delay={0} scale={0.82}>
<MockScreen>
<TripBuilderContent />
</MockScreen>
</AppScreenshot>
</div>
{/* Tap on the date range */}
<TapIndicator x={540} y={680} delay={0.8} />
</AbsoluteFill>
);
};
/** Trip builder screen content */
const TripBuilderContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const sports = [
{ name: "MLB", color: "#002D72", emoji: "\u26be" },
{ name: "NBA", color: "#C9082A", emoji: "\ud83c\udfc0" },
{ name: "NFL", color: "#013369", emoji: "\ud83c\udfc8" },
{ name: "NHL", color: "#000000", emoji: "\ud83c\udfd2" },
];
// MLB gets selected
const mlbSelect = spring({
frame: frame - 0.6 * fps,
fps,
config: theme.animation.snappy,
});
const mlbSelected = mlbSelect > 0.5;
// Date range highlight
const dateHighlight = spring({
frame: frame - 1.2 * fps,
fps,
config: theme.animation.smooth,
});
return (
<div style={{ padding: 12 }}>
{/* Header */}
<div style={{ marginBottom: 28 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 4,
}}
>
New Trip
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 17,
color: theme.colors.textSecondary,
}}
>
What do you want to see?
</div>
</div>
{/* Sport chips */}
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
{sports.map((sport, i) => {
const chipProgress = spring({
frame: frame - i * 4,
fps,
config: theme.animation.smooth,
});
const isSelected = sport.name === "MLB" && mlbSelected;
return (
<div
key={sport.name}
style={{
background: isSelected ? sport.color : "#1C1C1E",
borderRadius: 14,
padding: "14px 22px",
display: "flex",
alignItems: "center",
gap: 8,
opacity: interpolate(chipProgress, [0, 1], [0, 1]),
transform: `scale(${interpolate(chipProgress, [0, 1], [0.9, 1])})`,
border: isSelected ? "2px solid white" : "2px solid transparent",
}}
>
<span style={{ fontSize: 20 }}>{sport.emoji}</span>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 18,
fontWeight: 700,
color: "white",
}}
>
{sport.name}
</span>
</div>
);
})}
</div>
{/* Date range section */}
<div
style={{
background: "#1C1C1E",
borderRadius: 16,
padding: 22,
marginBottom: 20,
border: `2px solid ${dateHighlight > 0.5 ? theme.colors.accent : "transparent"}`,
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
marginBottom: 10,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
Travel Dates
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 700,
color: dateHighlight > 0.5 ? theme.colors.accent : theme.colors.text,
}}
>
Jun 12 Jun 18
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 15,
color: theme.colors.textSecondary,
}}
>
7 days
</div>
</div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="18" rx="2" stroke={theme.colors.textSecondary} strokeWidth="2" />
<line x1="16" y1="2" x2="16" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
<line x1="8" y1="2" x2="8" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
<line x1="3" y1="10" x2="21" y2="10" stroke={theme.colors.textSecondary} strokeWidth="2" />
</svg>
</div>
</div>
{/* Region */}
<div
style={{
background: "#1C1C1E",
borderRadius: 16,
padding: 22,
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
marginBottom: 10,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
Region
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 600,
color: theme.colors.text,
}}
>
West Coast
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,107 @@
import React from "react";
import { AbsoluteFill, useVideoConfig } from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { ChatScene } from "./ChatScene";
import { AllTalkScene } from "./AllTalkScene";
import { SolutionScene } from "./SolutionScene";
import { RouteScene } from "./RouteScene";
import { PollScene } from "./PollScene";
import { CTAScene } from "./CTAScene";
/**
* V03: "The Group Chat" (Viral)
*
* Hook: iMessage group chat blowing up with "we should do a trip" energy
* Problem: Nobody actually plans it → "All talk."
* Solution: One person opens SportsTime → plans it → polls the group
* Length: 16 seconds (480 frames at 30fps)
*
* Timing breakdown:
* - 0:00-0:03.5 (0-105): Group chat messages flying in
* - 0:03.5-0:05.5 (105-165): Chat dies, typing stops, "All talk."
* - 0:05.5-0:08.5 (165-255): Opens SportsTime, builds trip, "So I planned it myself"
* - 0:08.5-0:11.5 (255-345): Route generates, games appear, "Real route. Real games."
* - 0:11.5-0:14 (345-420): Poll sent to group, votes flood in, "Poll it. Done."
* - 0:14-0:16 (420-480): CTA: "If your group chat is all talk → SportsTime"
*/
export const TheGroupChat: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION = 10; // 10 frame transitions (snappy for TikTok pace)
// TransitionSeries subtracts transition duration from total.
// 5 transitions × 10 frames = 50 frames overlap.
// Scene sum must be 480 + 50 = 530 to fill 16s composition.
const SCENES = {
chat: 115, // ~3.8s - group chat chaos
allTalk: 70, // ~2.3s - chat dies, "All talk."
solution: 100, // ~3.3s - opens SportsTime, builds trip
route: 95, // ~3.2s - route generates, games appear
poll: 85, // ~2.8s - poll sent, votes flood in
cta: 65, // ~2.2s - CTA ending
}; // Total: 530 - 50 = 480 frames = 16s
return (
<AbsoluteFill>
<TransitionSeries>
{/* Scene 1: Group chat chaos */}
<TransitionSeries.Sequence durationInFrames={SCENES.chat}>
<ChatScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 2: "All talk." */}
<TransitionSeries.Sequence durationInFrames={SCENES.allTalk}>
<AllTalkScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 3: "So I planned it myself" */}
<TransitionSeries.Sequence durationInFrames={SCENES.solution}>
<SolutionScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 4: "Real route. Real games." */}
<TransitionSeries.Sequence durationInFrames={SCENES.route}>
<RouteScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 5: "Poll it. Done." */}
<TransitionSeries.Sequence durationInFrames={SCENES.poll}>
<PollScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 6: CTA */}
<TransitionSeries.Sequence durationInFrames={SCENES.cta}>
<CTAScene />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,380 @@
[
{
"id": "mlb_league",
"sport": "MLB",
"type": "league",
"name": "Major League Baseball",
"abbreviation": "MLB",
"parent_id": null,
"display_order": 0
},
{
"id": "mlb_al",
"sport": "MLB",
"type": "conference",
"name": "American League",
"abbreviation": "AL",
"parent_id": "mlb_league",
"display_order": 1
},
{
"id": "mlb_al_east",
"sport": "MLB",
"type": "division",
"name": "AL East",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 3
},
{
"id": "mlb_al_central",
"sport": "MLB",
"type": "division",
"name": "AL Central",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 4
},
{
"id": "mlb_al_west",
"sport": "MLB",
"type": "division",
"name": "AL West",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 5
},
{
"id": "mlb_nl",
"sport": "MLB",
"type": "conference",
"name": "National League",
"abbreviation": "NL",
"parent_id": "mlb_league",
"display_order": 2
},
{
"id": "mlb_nl_east",
"sport": "MLB",
"type": "division",
"name": "NL East",
"abbreviation": null,
"parent_id": "mlb_nl",
"display_order": 6
},
{
"id": "mlb_nl_central",
"sport": "MLB",
"type": "division",
"name": "NL Central",
"abbreviation": null,
"parent_id": "mlb_nl",
"display_order": 7
},
{
"id": "mlb_nl_west",
"sport": "MLB",
"type": "division",
"name": "NL West",
"abbreviation": null,
"parent_id": "mlb_nl",
"display_order": 8
},
{
"id": "mls_league",
"sport": "MLS",
"type": "league",
"name": "Major League Soccer",
"abbreviation": "MLS",
"parent_id": null,
"display_order": 1
},
{
"id": "mls_eastern",
"sport": "MLS",
"type": "conference",
"name": "Eastern Conference",
"abbreviation": "East",
"parent_id": "mls_league",
"display_order": 38
},
{
"id": "",
"sport": "MLS",
"type": "division",
"name": "Eastern",
"abbreviation": "East",
"parent_id": "mls_eastern",
"display_order": 0
},
{
"id": "mls_western",
"sport": "MLS",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "mls_league",
"display_order": 39
},
{
"id": "nba_league",
"sport": "NBA",
"type": "league",
"name": "National Basketball Association",
"abbreviation": "NBA",
"parent_id": null,
"display_order": 2
},
{
"id": "nba_eastern",
"sport": "NBA",
"type": "conference",
"name": "Eastern Conference",
"abbreviation": "East",
"parent_id": "nba_league",
"display_order": 10
},
{
"id": "nba_atlantic",
"sport": "NBA",
"type": "division",
"name": "Atlantic",
"abbreviation": null,
"parent_id": "nba_eastern",
"display_order": 12
},
{
"id": "nba_central",
"sport": "NBA",
"type": "division",
"name": "Central",
"abbreviation": null,
"parent_id": "nba_eastern",
"display_order": 13
},
{
"id": "nba_southeast",
"sport": "NBA",
"type": "division",
"name": "Southeast",
"abbreviation": null,
"parent_id": "nba_eastern",
"display_order": 14
},
{
"id": "nba_western",
"sport": "NBA",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nba_league",
"display_order": 11
},
{
"id": "nba_northwest",
"sport": "NBA",
"type": "division",
"name": "Northwest",
"abbreviation": null,
"parent_id": "nba_western",
"display_order": 15
},
{
"id": "nba_pacific",
"sport": "NBA",
"type": "division",
"name": "Pacific",
"abbreviation": null,
"parent_id": "nba_western",
"display_order": 16
},
{
"id": "nba_southwest",
"sport": "NBA",
"type": "division",
"name": "Southwest",
"abbreviation": null,
"parent_id": "nba_western",
"display_order": 17
},
{
"id": "nfl_league",
"sport": "NFL",
"type": "league",
"name": "National Football League",
"abbreviation": "NFL",
"parent_id": null,
"display_order": 3
},
{
"id": "nfl_afc",
"sport": "NFL",
"type": "conference",
"name": "American Football Conference",
"abbreviation": "AFC",
"parent_id": "nfl_league",
"display_order": 19
},
{
"id": "nfl_afc_east",
"sport": "NFL",
"type": "division",
"name": "AFC East",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 21
},
{
"id": "nfl_afc_north",
"sport": "NFL",
"type": "division",
"name": "AFC North",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 22
},
{
"id": "nfl_afc_south",
"sport": "NFL",
"type": "division",
"name": "AFC South",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 23
},
{
"id": "nfl_afc_west",
"sport": "NFL",
"type": "division",
"name": "AFC West",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 24
},
{
"id": "nfl_nfc",
"sport": "NFL",
"type": "conference",
"name": "National Football Conference",
"abbreviation": "NFC",
"parent_id": "nfl_league",
"display_order": 20
},
{
"id": "nfl_nfc_east",
"sport": "NFL",
"type": "division",
"name": "NFC East",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 25
},
{
"id": "nfl_nfc_north",
"sport": "NFL",
"type": "division",
"name": "NFC North",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 26
},
{
"id": "nfl_nfc_south",
"sport": "NFL",
"type": "division",
"name": "NFC South",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 27
},
{
"id": "nfl_nfc_west",
"sport": "NFL",
"type": "division",
"name": "NFC West",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 28
},
{
"id": "nhl_league",
"sport": "NHL",
"type": "league",
"name": "National Hockey League",
"abbreviation": "NHL",
"parent_id": null,
"display_order": 4
},
{
"id": "nhl_eastern",
"sport": "NHL",
"type": "conference",
"name": "Eastern Conference",
"abbreviation": "East",
"parent_id": "nhl_league",
"display_order": 30
},
{
"id": "nhl_atlantic",
"sport": "NHL",
"type": "division",
"name": "Atlantic",
"abbreviation": null,
"parent_id": "nhl_eastern",
"display_order": 32
},
{
"id": "nhl_metropolitan",
"sport": "NHL",
"type": "division",
"name": "Metropolitan",
"abbreviation": null,
"parent_id": "nhl_eastern",
"display_order": 33
},
{
"id": "nhl_western",
"sport": "NHL",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nhl_league",
"display_order": 31
},
{
"id": "nhl_central",
"sport": "NHL",
"type": "division",
"name": "Central",
"abbreviation": null,
"parent_id": "nhl_western",
"display_order": 34
},
{
"id": "nhl_pacific",
"sport": "NHL",
"type": "division",
"name": "Pacific",
"abbreviation": null,
"parent_id": "nhl_western",
"display_order": 35
},
{
"id": "nwsl_league",
"sport": "NWSL",
"type": "league",
"name": "National Women's Soccer League",
"abbreviation": "NWSL",
"parent_id": null,
"display_order": 5
},
{
"id": "wnba_league",
"sport": "WNBA",
"type": "league",
"name": "Women's National Basketball Association",
"abbreviation": "WNBA",
"parent_id": null,
"display_order": 6
}
]

View File

@@ -0,0 +1,72 @@
[
{
"sport_id": "MLB",
"abbreviation": "MLB",
"display_name": "Major League Baseball",
"icon_name": "baseball.fill",
"color_hex": "#FF0000",
"season_start_month": 3,
"season_end_month": 10,
"is_active": true
},
{
"sport_id": "MLS",
"abbreviation": "MLS",
"display_name": "Major League Soccer",
"icon_name": "soccerball",
"color_hex": "#34C759",
"season_start_month": 2,
"season_end_month": 12,
"is_active": true
},
{
"sport_id": "NBA",
"abbreviation": "NBA",
"display_name": "National Basketball Association",
"icon_name": "basketball.fill",
"color_hex": "#FF8C00",
"season_start_month": 10,
"season_end_month": 6,
"is_active": true
},
{
"sport_id": "NFL",
"abbreviation": "NFL",
"display_name": "National Football League",
"icon_name": "football.fill",
"color_hex": "#8B4513",
"season_start_month": 9,
"season_end_month": 2,
"is_active": true
},
{
"sport_id": "NHL",
"abbreviation": "NHL",
"display_name": "National Hockey League",
"icon_name": "hockey.puck.fill",
"color_hex": "#007AFF",
"season_start_month": 10,
"season_end_month": 6,
"is_active": true
},
{
"sport_id": "NWSL",
"abbreviation": "NWSL",
"display_name": "National Women's Soccer League",
"icon_name": "soccerball",
"color_hex": "#5AC8FA",
"season_start_month": 3,
"season_end_month": 11,
"is_active": true
},
{
"sport_id": "WNBA",
"abbreviation": "WNBA",
"display_name": "Women's National Basketball Association",
"icon_name": "basketball.fill",
"color_hex": "#AF52DE",
"season_start_month": 5,
"season_end_month": 10,
"is_active": true
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,634 @@
[
{
"id": "alias_mlb_10",
"team_canonical_id": "team_mlb_cle",
"alias_type": "name",
"alias_value": "Cleveland Indians",
"valid_from": "1915-01-01",
"valid_until": "2021-12-31"
},
{
"id": "alias_mlb_23",
"team_canonical_id": "team_mlb_hou",
"alias_type": "name",
"alias_value": "Houston Colt .45s",
"valid_from": "1962-01-01",
"valid_until": "1964-12-31"
},
{
"id": "alias_mlb_14",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "Anaheim Angels",
"valid_from": "1997-01-01",
"valid_until": "2004-12-31"
},
{
"id": "alias_mlb_15",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "Los Angeles Angels of Anaheim",
"valid_from": "2005-01-01",
"valid_until": "2015-12-31"
},
{
"id": "alias_mlb_16",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "California Angels",
"valid_from": "1965-01-01",
"valid_until": "1996-12-31"
},
{
"id": "alias_mlb_12",
"team_canonical_id": "team_mlb_mia",
"alias_type": "name",
"alias_value": "Florida Marlins",
"valid_from": "1993-01-01",
"valid_until": "2011-12-31"
},
{
"id": "alias_mlb_13",
"team_canonical_id": "team_mlb_mia",
"alias_type": "city",
"alias_value": "Florida",
"valid_from": "1993-01-01",
"valid_until": "2011-12-31"
},
{
"id": "alias_mlb_20",
"team_canonical_id": "team_mlb_mil",
"alias_type": "name",
"alias_value": "Seattle Pilots",
"valid_from": "1969-01-01",
"valid_until": "1969-12-31"
},
{
"id": "alias_mlb_21",
"team_canonical_id": "team_mlb_mil",
"alias_type": "abbreviation",
"alias_value": "SEP",
"valid_from": "1969-01-01",
"valid_until": "1969-12-31"
},
{
"id": "alias_mlb_22",
"team_canonical_id": "team_mlb_mil",
"alias_type": "city",
"alias_value": "Seattle",
"valid_from": "1969-01-01",
"valid_until": "1969-12-31"
},
{
"id": "alias_mlb_4",
"team_canonical_id": "team_mlb_oak",
"alias_type": "name",
"alias_value": "Kansas City Athletics",
"valid_from": "1955-01-01",
"valid_until": "1967-12-31"
},
{
"id": "alias_mlb_5",
"team_canonical_id": "team_mlb_oak",
"alias_type": "abbreviation",
"alias_value": "KCA",
"valid_from": "1955-01-01",
"valid_until": "1967-12-31"
},
{
"id": "alias_mlb_6",
"team_canonical_id": "team_mlb_oak",
"alias_type": "city",
"alias_value": "Kansas City",
"valid_from": "1955-01-01",
"valid_until": "1967-12-31"
},
{
"id": "alias_mlb_7",
"team_canonical_id": "team_mlb_oak",
"alias_type": "name",
"alias_value": "Philadelphia Athletics",
"valid_from": "1901-01-01",
"valid_until": "1954-12-31"
},
{
"id": "alias_mlb_8",
"team_canonical_id": "team_mlb_oak",
"alias_type": "abbreviation",
"alias_value": "PHA",
"valid_from": "1901-01-01",
"valid_until": "1954-12-31"
},
{
"id": "alias_mlb_9",
"team_canonical_id": "team_mlb_oak",
"alias_type": "city",
"alias_value": "Philadelphia",
"valid_from": "1901-01-01",
"valid_until": "1954-12-31"
},
{
"id": "alias_mlb_11",
"team_canonical_id": "team_mlb_tbr",
"alias_type": "name",
"alias_value": "Tampa Bay Devil Rays",
"valid_from": "1998-01-01",
"valid_until": "2007-12-31"
},
{
"id": "alias_mlb_17",
"team_canonical_id": "team_mlb_tex",
"alias_type": "name",
"alias_value": "Washington Senators",
"valid_from": "1961-01-01",
"valid_until": "1971-12-31"
},
{
"id": "alias_mlb_18",
"team_canonical_id": "team_mlb_tex",
"alias_type": "abbreviation",
"alias_value": "WS2",
"valid_from": "1961-01-01",
"valid_until": "1971-12-31"
},
{
"id": "alias_mlb_19",
"team_canonical_id": "team_mlb_tex",
"alias_type": "city",
"alias_value": "Washington",
"valid_from": "1961-01-01",
"valid_until": "1971-12-31"
},
{
"id": "alias_mlb_1",
"team_canonical_id": "team_mlb_wsn",
"alias_type": "name",
"alias_value": "Montreal Expos",
"valid_from": "1969-01-01",
"valid_until": "2004-12-31"
},
{
"id": "alias_mlb_2",
"team_canonical_id": "team_mlb_wsn",
"alias_type": "abbreviation",
"alias_value": "MON",
"valid_from": "1969-01-01",
"valid_until": "2004-12-31"
},
{
"id": "alias_mlb_3",
"team_canonical_id": "team_mlb_wsn",
"alias_type": "city",
"alias_value": "Montreal",
"valid_from": "1969-01-01",
"valid_until": "2004-12-31"
},
{
"id": "alias_nba_24",
"team_canonical_id": "team_nba_brk",
"alias_type": "name",
"alias_value": "New Jersey Nets",
"valid_from": "1977-01-01",
"valid_until": "2012-04-30"
},
{
"id": "alias_nba_25",
"team_canonical_id": "team_nba_brk",
"alias_type": "abbreviation",
"alias_value": "NJN",
"valid_from": "1977-01-01",
"valid_until": "2012-04-30"
},
{
"id": "alias_nba_26",
"team_canonical_id": "team_nba_brk",
"alias_type": "city",
"alias_value": "New Jersey",
"valid_from": "1977-01-01",
"valid_until": "2012-04-30"
},
{
"id": "alias_nba_27",
"team_canonical_id": "team_nba_brk",
"alias_type": "name",
"alias_value": "New York Nets",
"valid_from": "1968-01-01",
"valid_until": "1977-12-31"
},
{
"id": "alias_nba_37",
"team_canonical_id": "team_nba_cho",
"alias_type": "name",
"alias_value": "Charlotte Bobcats",
"valid_from": "2004-01-01",
"valid_until": "2014-04-30"
},
{
"id": "alias_nba_38",
"team_canonical_id": "team_nba_cho",
"alias_type": "abbreviation",
"alias_value": "CHA",
"valid_from": "2004-01-01",
"valid_until": "2014-04-30"
},
{
"id": "alias_nba_42",
"team_canonical_id": "team_nba_lac",
"alias_type": "name",
"alias_value": "San Diego Clippers",
"valid_from": "1978-01-01",
"valid_until": "1984-05-31"
},
{
"id": "alias_nba_43",
"team_canonical_id": "team_nba_lac",
"alias_type": "abbreviation",
"alias_value": "SDC",
"valid_from": "1978-01-01",
"valid_until": "1984-05-31"
},
{
"id": "alias_nba_44",
"team_canonical_id": "team_nba_lac",
"alias_type": "city",
"alias_value": "San Diego",
"valid_from": "1978-01-01",
"valid_until": "1984-05-31"
},
{
"id": "alias_nba_45",
"team_canonical_id": "team_nba_lac",
"alias_type": "name",
"alias_value": "Buffalo Braves",
"valid_from": "1970-01-01",
"valid_until": "1978-05-31"
},
{
"id": "alias_nba_46",
"team_canonical_id": "team_nba_lac",
"alias_type": "abbreviation",
"alias_value": "BUF",
"valid_from": "1970-01-01",
"valid_until": "1978-05-31"
},
{
"id": "alias_nba_47",
"team_canonical_id": "team_nba_lac",
"alias_type": "city",
"alias_value": "Buffalo",
"valid_from": "1970-01-01",
"valid_until": "1978-05-31"
},
{
"id": "alias_nba_31",
"team_canonical_id": "team_nba_mem",
"alias_type": "name",
"alias_value": "Vancouver Grizzlies",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_32",
"team_canonical_id": "team_nba_mem",
"alias_type": "abbreviation",
"alias_value": "VAN",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_33",
"team_canonical_id": "team_nba_mem",
"alias_type": "city",
"alias_value": "Vancouver",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_34",
"team_canonical_id": "team_nba_nop",
"alias_type": "name",
"alias_value": "New Orleans Hornets",
"valid_from": "2002-01-01",
"valid_until": "2013-04-30"
},
{
"id": "alias_nba_35",
"team_canonical_id": "team_nba_nop",
"alias_type": "abbreviation",
"alias_value": "NOH",
"valid_from": "2002-01-01",
"valid_until": "2013-04-30"
},
{
"id": "alias_nba_36",
"team_canonical_id": "team_nba_nop",
"alias_type": "name",
"alias_value": "New Orleans/Oklahoma City Hornets",
"valid_from": "2005-01-01",
"valid_until": "2007-12-31"
},
{
"id": "alias_nba_28",
"team_canonical_id": "team_nba_okc",
"alias_type": "name",
"alias_value": "Seattle SuperSonics",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_29",
"team_canonical_id": "team_nba_okc",
"alias_type": "abbreviation",
"alias_value": "SEA",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_30",
"team_canonical_id": "team_nba_okc",
"alias_type": "city",
"alias_value": "Seattle",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_48",
"team_canonical_id": "team_nba_sac",
"alias_type": "name",
"alias_value": "Kansas City Kings",
"valid_from": "1975-01-01",
"valid_until": "1985-05-31"
},
{
"id": "alias_nba_49",
"team_canonical_id": "team_nba_sac",
"alias_type": "abbreviation",
"alias_value": "KCK",
"valid_from": "1975-01-01",
"valid_until": "1985-05-31"
},
{
"id": "alias_nba_50",
"team_canonical_id": "team_nba_sac",
"alias_type": "city",
"alias_value": "Kansas City",
"valid_from": "1975-01-01",
"valid_until": "1985-05-31"
},
{
"id": "alias_nba_51",
"team_canonical_id": "team_nba_uta",
"alias_type": "name",
"alias_value": "New Orleans Jazz",
"valid_from": "1974-01-01",
"valid_until": "1979-05-31"
},
{
"id": "alias_nba_52",
"team_canonical_id": "team_nba_uta",
"alias_type": "city",
"alias_value": "New Orleans",
"valid_from": "1974-01-01",
"valid_until": "1979-05-31"
},
{
"id": "alias_nba_39",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Washington Bullets",
"valid_from": "1974-01-01",
"valid_until": "1997-05-31"
},
{
"id": "alias_nba_40",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Capital Bullets",
"valid_from": "1973-01-01",
"valid_until": "1973-12-31"
},
{
"id": "alias_nba_41",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Baltimore Bullets",
"valid_from": "1963-01-01",
"valid_until": "1972-12-31"
},
{
"id": "alias_nfl_77",
"team_canonical_id": "team_nfl_was",
"alias_type": "name",
"alias_value": "Washington Redskins",
"valid_from": "1937-01-01",
"valid_until": "2020-07-13"
},
{
"id": "alias_nfl_78",
"team_canonical_id": "team_nfl_was",
"alias_type": "name",
"alias_value": "Washington Football Team",
"valid_from": "2020-07-13",
"valid_until": "2022-02-02"
},
{
"id": "alias_nfl_79",
"team_canonical_id": "team_nfl_was",
"alias_type": "abbreviation",
"alias_value": "WFT",
"valid_from": "2020-07-13",
"valid_until": "2022-02-02"
},
{
"id": "alias_nhl_53",
"team_canonical_id": "team_nhl_ari",
"alias_type": "name",
"alias_value": "Arizona Coyotes",
"valid_from": "2014-01-01",
"valid_until": "2024-04-30"
},
{
"id": "alias_nhl_54",
"team_canonical_id": "team_nhl_ari",
"alias_type": "name",
"alias_value": "Phoenix Coyotes",
"valid_from": "1996-01-01",
"valid_until": "2013-12-31"
},
{
"id": "alias_nhl_55",
"team_canonical_id": "team_nhl_ari",
"alias_type": "abbreviation",
"alias_value": "PHX",
"valid_from": "1996-01-01",
"valid_until": "2013-12-31"
},
{
"id": "alias_nhl_56",
"team_canonical_id": "team_nhl_ari",
"alias_type": "city",
"alias_value": "Phoenix",
"valid_from": "1996-01-01",
"valid_until": "2013-12-31"
},
{
"id": "alias_nhl_57",
"team_canonical_id": "team_nhl_ari",
"alias_type": "name",
"alias_value": "Winnipeg Jets",
"valid_from": "1979-01-01",
"valid_until": "1996-05-31"
},
{
"id": "alias_nhl_58",
"team_canonical_id": "team_nhl_car",
"alias_type": "name",
"alias_value": "Hartford Whalers",
"valid_from": "1979-01-01",
"valid_until": "1997-05-31"
},
{
"id": "alias_nhl_59",
"team_canonical_id": "team_nhl_car",
"alias_type": "abbreviation",
"alias_value": "HFD",
"valid_from": "1979-01-01",
"valid_until": "1997-05-31"
},
{
"id": "alias_nhl_60",
"team_canonical_id": "team_nhl_car",
"alias_type": "city",
"alias_value": "Hartford",
"valid_from": "1979-01-01",
"valid_until": "1997-05-31"
},
{
"id": "alias_nhl_61",
"team_canonical_id": "team_nhl_col",
"alias_type": "name",
"alias_value": "Quebec Nordiques",
"valid_from": "1979-01-01",
"valid_until": "1995-05-31"
},
{
"id": "alias_nhl_62",
"team_canonical_id": "team_nhl_col",
"alias_type": "abbreviation",
"alias_value": "QUE",
"valid_from": "1979-01-01",
"valid_until": "1995-05-31"
},
{
"id": "alias_nhl_63",
"team_canonical_id": "team_nhl_col",
"alias_type": "city",
"alias_value": "Quebec",
"valid_from": "1979-01-01",
"valid_until": "1995-05-31"
},
{
"id": "alias_nhl_64",
"team_canonical_id": "team_nhl_dal",
"alias_type": "name",
"alias_value": "Minnesota North Stars",
"valid_from": "1967-01-01",
"valid_until": "1993-05-31"
},
{
"id": "alias_nhl_65",
"team_canonical_id": "team_nhl_dal",
"alias_type": "abbreviation",
"alias_value": "MNS",
"valid_from": "1967-01-01",
"valid_until": "1993-05-31"
},
{
"id": "alias_nhl_66",
"team_canonical_id": "team_nhl_dal",
"alias_type": "city",
"alias_value": "Minnesota",
"valid_from": "1967-01-01",
"valid_until": "1993-05-31"
},
{
"id": "alias_nhl_76",
"team_canonical_id": "team_nhl_fla",
"alias_type": "city",
"alias_value": "Miami",
"valid_from": "1993-01-01",
"valid_until": "1998-12-31"
},
{
"id": "alias_nhl_67",
"team_canonical_id": "team_nhl_njd",
"alias_type": "name",
"alias_value": "Colorado Rockies",
"valid_from": "1976-01-01",
"valid_until": "1982-05-31"
},
{
"id": "alias_nhl_68",
"team_canonical_id": "team_nhl_njd",
"alias_type": "abbreviation",
"alias_value": "CLR",
"valid_from": "1976-01-01",
"valid_until": "1982-05-31"
},
{
"id": "alias_nhl_69",
"team_canonical_id": "team_nhl_njd",
"alias_type": "city",
"alias_value": "Colorado",
"valid_from": "1976-01-01",
"valid_until": "1982-05-31"
},
{
"id": "alias_nhl_70",
"team_canonical_id": "team_nhl_njd",
"alias_type": "name",
"alias_value": "Kansas City Scouts",
"valid_from": "1974-01-01",
"valid_until": "1976-05-31"
},
{
"id": "alias_nhl_71",
"team_canonical_id": "team_nhl_njd",
"alias_type": "abbreviation",
"alias_value": "KCS",
"valid_from": "1974-01-01",
"valid_until": "1976-05-31"
},
{
"id": "alias_nhl_72",
"team_canonical_id": "team_nhl_njd",
"alias_type": "city",
"alias_value": "Kansas City",
"valid_from": "1974-01-01",
"valid_until": "1976-05-31"
},
{
"id": "alias_nhl_73",
"team_canonical_id": "team_nhl_wpg",
"alias_type": "name",
"alias_value": "Atlanta Thrashers",
"valid_from": "1999-01-01",
"valid_until": "2011-05-31"
},
{
"id": "alias_nhl_74",
"team_canonical_id": "team_nhl_wpg",
"alias_type": "abbreviation",
"alias_value": "ATL",
"valid_from": "1999-01-01",
"valid_until": "2011-05-31"
},
{
"id": "alias_nhl_75",
"team_canonical_id": "team_nhl_wpg",
"alias_type": "city",
"alias_value": "Atlanta",
"valid_from": "1999-01-01",
"valid_until": "2011-05-31"
}
]

File diff suppressed because it is too large Load Diff