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>
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-clutch.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-midnight.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-orbit.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-retro.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-sunset.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 231 KiB |
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
85
SportsTime/Features/Trip/Views/TravelPlacement.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
233
SportsTimeTests/Features/Trip/TravelPlacementTests.swift
Normal 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 Houston→Cincinnati 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")
|
||||
|
||||
// Houston→Cincinnati: 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)")
|
||||
|
||||
// Cincinnati→Houston: 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)")
|
||||
|
||||
// Houston→Chicago: 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)")
|
||||
|
||||
// Chicago→Houston: 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() {
|
||||
// A→B→A→B 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)")
|
||||
}
|
||||
}
|
||||
136
docs/codexGrowthReviewTemplate.md
Normal 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
|
||||
138
docs/codexGrowthReviewWeek1Example.md
Normal 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
@@ -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/>
|
||||
133
docs/codexMarketingChecklist.md
Normal 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
@@ -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 you’ve never done an away-game road trip, you’re missing out.` | Set B | `If you’ve 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 he’s a diehard fan. I asked for his stadium count.` | Set D | `My friend: 4 stadiums. Me: 27.` | `If you don’t 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 you’re 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.` | `Couple’s 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 I’ll 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: what’s 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 | "I’m Down" Friend Test | `Send this to the flaky "I’m down" friend.` | Set C | `“I’m down” friend when it’s time to pick dates…` | `If they’re 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 can’t call it a stadium bucket list if you don’t track it.` | `No scoreboard, no flex. What’s 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 `I’ll 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.
|
||||
- Don’t 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 I’ll do one.`
|
||||
|
||||
411
docs/reels.md
Normal 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 "I’m 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 you’ve 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 you’re 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 team’s 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 you’re 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 you’re in [city], here’s 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. That’s 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 you’re 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 you’re 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 it’s 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 - "I’m Down" Friend Test (Funny)
|
||||
1. Hook: "‘I’m down’ friend when it’s time to pick dates…"
|
||||
2. Concept: flaky friend callout + poll accountability.
|
||||
3. Storyboard:
|
||||
- Scene 1: "I’m down" text.
|
||||
- Scene 2: excuse texts.
|
||||
- Scene 3: poll creation in app.
|
||||
- Scene 4: vote deadline.
|
||||
- Scene 5: winner route.
|
||||
4. On-screen text:
|
||||
- "I’m down starter pack"
|
||||
- "No decisions"
|
||||
- "Poll > excuses"
|
||||
5. VO: "If you’re 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 you’re 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 can’t call it a stadium bucket list if you don’t 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 they’re doing the bucket list. What’s 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.
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
39
marketing-videos/public/ASSET_README.md
Normal 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.
|
||||
0
marketing-videos/public/broll/.gitkeep
Normal file
0
marketing-videos/public/overlays/.gitkeep
Normal file
0
marketing-videos/public/screenrecs/.gitkeep
Normal file
78
marketing-videos/scripts/render-week1.sh
Executable 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)"
|
||||
@@ -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}
|
||||
|
||||
43
marketing-videos/src/components/shared/FilmGrain.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
306
marketing-videos/src/components/shared/TikTokCaption.tsx
Normal 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 };
|
||||
@@ -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";
|
||||
|
||||
430
marketing-videos/src/configs/week1.json
Normal 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
|
||||
}
|
||||
]
|
||||
180
marketing-videos/src/engine/VideoFromConfig.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
marketing-videos/src/engine/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { VideoFromConfig } from "./VideoFromConfig";
|
||||
export type {
|
||||
VideoConfig,
|
||||
SceneConfig,
|
||||
SceneType,
|
||||
CaptionLine,
|
||||
Week1Configs,
|
||||
} from "./types";
|
||||
export { ASSET_KEYS } from "./types";
|
||||
273
marketing-videos/src/engine/scenes/CTAEndCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
423
marketing-videos/src/engine/scenes/ChatScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
346
marketing-videos/src/engine/scenes/FlexScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
180
marketing-videos/src/engine/scenes/HookCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
marketing-videos/src/engine/scenes/KineticCaption.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
402
marketing-videos/src/engine/scenes/MapScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
408
marketing-videos/src/engine/scenes/PollScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
318
marketing-videos/src/engine/scenes/ScreenRecScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
463
marketing-videos/src/engine/scenes/TextPunchScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
marketing-videos/src/engine/scenes/index.ts
Normal 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";
|
||||
89
marketing-videos/src/engine/types.ts
Normal 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;
|
||||
1325
marketing-videos/src/videos/AwayGameTake/index.tsx
Normal file
1365
marketing-videos/src/videos/GroupChatChaos/index.tsx
Normal file
1111
marketing-videos/src/videos/LocalCityRoute/index.tsx
Normal file
1373
marketing-videos/src/videos/SpreadsheetEra/index.tsx
Normal file
979
marketing-videos/src/videos/StadiumCountFlex/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
274
marketing-videos/src/videos/TheFanTest/FollowTeamScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
175
marketing-videos/src/videos/TheFanTest/HotTakeScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
269
marketing-videos/src/videos/TheFanTest/ReactionScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
321
marketing-videos/src/videos/TheFanTest/RoadGamesScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
301
marketing-videos/src/videos/TheFanTest/RouteItineraryScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
marketing-videos/src/videos/TheFanTest/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
189
marketing-videos/src/videos/TheGroupChat/AllTalkScene.tsx
Normal 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>
|
||||
);
|
||||
215
marketing-videos/src/videos/TheGroupChat/CTAScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
254
marketing-videos/src/videos/TheGroupChat/ChatScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
332
marketing-videos/src/videos/TheGroupChat/PollScene.tsx
Normal 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 12–18 · 4 games · 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>
|
||||
);
|
||||
};
|
||||
290
marketing-videos/src/videos/TheGroupChat/RouteScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
281
marketing-videos/src/videos/TheGroupChat/SolutionScene.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
107
marketing-videos/src/videos/TheGroupChat/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119252
sportstime_export/games_canonical.json
Normal file
380
sportstime_export/league_structure.json
Normal 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
|
||||
}
|
||||
]
|
||||
72
sportstime_export/sports_canonical.json
Normal 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
|
||||
}
|
||||
]
|
||||
2120
sportstime_export/stadium_aliases.json
Normal file
3572
sportstime_export/stadiums_canonical.json
Normal file
634
sportstime_export/team_aliases.json
Normal 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"
|
||||
}
|
||||
]
|
||||