diff --git a/docs/amazon-insert-video.md b/docs/amazon-insert-video.md index cdf973f..84032da 100644 --- a/docs/amazon-insert-video.md +++ b/docs/amazon-insert-video.md @@ -53,7 +53,7 @@ More generations → **more variety** in inserts, **not** denser inserts by defa ### Plain English 1. While someone browses a **product grid**, we drop **fashion reel tiles** into the stream — same size as a product card. -2. Pool = **category room reels** (all gens) **+ persona jet-set reels** (`REELS_JETSET`). +2. Pool = **category room reels** (all gens) **+ persona series** (`REELS_JETSET`, `REELS_SKI`, …). 3. **Never repeat the same reel id on one page.** 4. Gaps between inserts are **random 2–6 products** (immersive / unpredictable). 5. If they’re already in a room (e.g. Handbags), we skip handbags reels — persona jet-set still allowed. @@ -74,7 +74,7 @@ More generations → **more variety** in inserts, **not** denser inserts by defa | Piece | Detail | |-------|--------| -| Pool | `CATEGORY_REELS` = gen1 ∪ gen2 ∪ `REELS_JETSET` | +| Pool | `CATEGORY_REELS` = gen1 ∪ gen2 ∪ `REELS_JETSET` ∪ `REELS_SKI` | | Interleave | `interleaveReelInserts` → `src/lib/reelInserts.ts` (`minGap`/`maxGap`) | | UI grid | `ProductGrid` + `ReelInsertCard` | | Cross-promo | `excludeCategory` for room reels only | @@ -99,8 +99,15 @@ Shuffle is **computed on the client** when the grid renders. Same seed → same | Gen 2 poster | `public/brand/videos/reels/posters/{category}-g2.jpg` | | Jet-set video | `public/brand/videos/reels/jetset-{vibeId}.mp4` | | Jet-set poster | `public/brand/videos/reels/posters/jetset-{vibeId}.jpg` | +| Ski holiday video | `public/brand/videos/reels/ski-{vibeId}.mp4` | +| Ski holiday poster | `public/brand/videos/reels/posters/ski-{vibeId}.jpg` | | R2 (optional) | `video/…` via mint — see [`imagine-r2-videos.md`](./imagine-r2-videos.md) | +### Rate limits (Imagine video) + +`grok-imagine-video` is **2 requests/second** per team (tier may rise with API spend). +**Always generate persona batches sequentially** (one start at a time, ≥2–3s between starts). Parallel fan-out of 6 will 429. + ### Production pipeline 1. Fashion stills: Imagine `image_gen` @ **3:4** → poster path for that gen. diff --git a/public/brand/videos/reels/posters/ski-atelier.jpg b/public/brand/videos/reels/posters/ski-atelier.jpg new file mode 100644 index 0000000..e6baa63 Binary files /dev/null and b/public/brand/videos/reels/posters/ski-atelier.jpg differ diff --git a/public/brand/videos/reels/posters/ski-dew.jpg b/public/brand/videos/reels/posters/ski-dew.jpg new file mode 100644 index 0000000..aec2158 Binary files /dev/null and b/public/brand/videos/reels/posters/ski-dew.jpg differ diff --git a/public/brand/videos/reels/posters/ski-gilded.jpg b/public/brand/videos/reels/posters/ski-gilded.jpg new file mode 100644 index 0000000..c96636c Binary files /dev/null and b/public/brand/videos/reels/posters/ski-gilded.jpg differ diff --git a/public/brand/videos/reels/posters/ski-luxe.jpg b/public/brand/videos/reels/posters/ski-luxe.jpg new file mode 100644 index 0000000..d700c22 Binary files /dev/null and b/public/brand/videos/reels/posters/ski-luxe.jpg differ diff --git a/public/brand/videos/reels/posters/ski-muse.jpg b/public/brand/videos/reels/posters/ski-muse.jpg new file mode 100644 index 0000000..f1dd578 Binary files /dev/null and b/public/brand/videos/reels/posters/ski-muse.jpg differ diff --git a/public/brand/videos/reels/posters/ski-sillage.jpg b/public/brand/videos/reels/posters/ski-sillage.jpg new file mode 100644 index 0000000..d8ed284 Binary files /dev/null and b/public/brand/videos/reels/posters/ski-sillage.jpg differ diff --git a/public/brand/videos/reels/ski-atelier.mp4 b/public/brand/videos/reels/ski-atelier.mp4 new file mode 100644 index 0000000..af267b3 Binary files /dev/null and b/public/brand/videos/reels/ski-atelier.mp4 differ diff --git a/public/brand/videos/reels/ski-dew.mp4 b/public/brand/videos/reels/ski-dew.mp4 new file mode 100644 index 0000000..6871735 Binary files /dev/null and b/public/brand/videos/reels/ski-dew.mp4 differ diff --git a/public/brand/videos/reels/ski-gilded.mp4 b/public/brand/videos/reels/ski-gilded.mp4 new file mode 100644 index 0000000..16f39ff Binary files /dev/null and b/public/brand/videos/reels/ski-gilded.mp4 differ diff --git a/public/brand/videos/reels/ski-luxe.mp4 b/public/brand/videos/reels/ski-luxe.mp4 new file mode 100644 index 0000000..7f1ab50 Binary files /dev/null and b/public/brand/videos/reels/ski-luxe.mp4 differ diff --git a/public/brand/videos/reels/ski-muse.mp4 b/public/brand/videos/reels/ski-muse.mp4 new file mode 100644 index 0000000..7cc7d7b Binary files /dev/null and b/public/brand/videos/reels/ski-muse.mp4 differ diff --git a/public/brand/videos/reels/ski-sillage.mp4 b/public/brand/videos/reels/ski-sillage.mp4 new file mode 100644 index 0000000..bfc0224 Binary files /dev/null and b/public/brand/videos/reels/ski-sillage.mp4 differ diff --git a/src/components/ReelInsertCard.tsx b/src/components/ReelInsertCard.tsx index 08c26e6..17abcd9 100644 --- a/src/components/ReelInsertCard.tsx +++ b/src/components/ReelInsertCard.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react' import { reelCtaLabel, reelHref, + reelSeries, type CategoryReel, } from '../data/reels' import { trackEvent } from '../lib/analytics' @@ -11,7 +12,7 @@ import { trackEvent } from '../lib/analytics' /** * Fashion film tile in product grids (same 4:5 well as ProductCard). * Muted autoplay when in view. - * Category reels → shop room; persona jet-set → vibe check (/quiz). + * Category reels → shop room; persona series → vibe check (/quiz). */ export function ReelInsertCard({ reel, @@ -25,8 +26,17 @@ export function ReelInsertCard({ const videoRef = useRef(null) const to = reelHref(reel) const cta = reelCtaLabel(reel) - const isPersona = Boolean(reel.vibeId) - const kicker = reel.kicker ?? (isPersona ? 'House persona' : 'From the house') + const series = reelSeries(reel) + const isPersona = series === 'jetset' || series === 'ski' + const badge = + series === 'ski' ? 'Ski' : series === 'jetset' ? 'Persona' : 'Discover' + const kicker = + reel.kicker ?? + (series === 'ski' + ? 'Ski holiday' + : series === 'jetset' + ? 'House persona' + : 'From the house') useEffect(() => { const el = videoRef.current @@ -55,10 +65,10 @@ export function ReelInsertCard({ onClick={() => trackEvent('reel_insert_click', { reel_id: reel.id, - reel_category: reel.category ?? 'persona', + reel_category: reel.category ?? series, vibe_id: reel.vibeId, list_name: listName, - engagement_type: isPersona ? 'persona_jetset' : 'cross_promo', + engagement_type: isPersona ? `persona_${series}` : 'cross_promo', }) } > @@ -78,7 +88,7 @@ export function ReelInsertCard({ - {isPersona ? 'Persona' : 'Discover'} + {badge} @@ -124,11 +134,11 @@ export function ReelInsertCard({ onClick={() => trackEvent('reel_insert_click', { reel_id: reel.id, - reel_category: reel.category ?? 'persona', + reel_category: reel.category ?? series, vibe_id: reel.vibeId, list_name: listName, engagement_type: isPersona - ? 'persona_jetset_cta' + ? `persona_${series}_cta` : 'cross_promo_cta', }) } diff --git a/src/data/reels.ts b/src/data/reels.ts index 01f4617..c15fdd0 100644 --- a/src/data/reels.ts +++ b/src/data/reels.ts @@ -1,28 +1,35 @@ /** * Fashion reels for product-grid inserts and /reels. * - * Pool includes category room clips + persona jet-set films. + * Pool includes category room clips + persona series (jet-set, ski holiday). * Product grids interleave unique reels with random 2–6 product gaps * (see lib/reelInserts.ts). + * + * Video gen rate limit: grok-imagine-video is **2 RPS** — always sequential. */ import type { Category } from './types' import { CATEGORY_LABELS } from './catalog' import { HEROES } from './categoryHeroes' import { VIBE_LIST } from './vibes' -/** Creative wave — gen 1 launch, gen 2 second wave, gen 3 persona jet-set */ -export type ReelGeneration = 1 | 2 | 3 +/** Creative wave — 1–2 rooms, 3 jet-set, 4 ski holiday */ +export type ReelGeneration = 1 | 2 | 3 | 4 + +/** Persona / room series for filters and badges */ +export type ReelSeries = 'room' | 'jetset' | 'ski' export type CategoryReel = { /** Stable id for keys / analytics */ id: string /** - * Shop room for category reels. Omitted for persona jet-set inserts + * Shop room for category reels. Omitted for persona series inserts * (those link to the vibe check, not a shop category). */ category?: Category - /** Persona id when this is a house-model jet-set film */ + /** Persona id when this is a house-model film */ vibeId?: string + /** Series for UI filters (default room if category set) */ + series?: ReelSeries /** Generation wave (1, 2, 3, …) */ generation: ReelGeneration title: string @@ -60,7 +67,7 @@ export const REEL_CATEGORIES: Category[] = [ ] /** Latest generation number in the catalog (bump when adding a wave). */ -export const REEL_LATEST_GENERATION: ReelGeneration = 3 +export const REEL_LATEST_GENERATION: ReelGeneration = 4 const MOTION_G1: Record = { handbags: 'Soft light glide across structured leather', @@ -107,6 +114,7 @@ function buildReel( return { id: `${category}-g${generation}`, category, + series: 'room', generation, title: hero?.title ?? CATEGORY_LABELS[category], blurb: hero?.blurb ?? CATEGORY_LABELS[category], @@ -190,6 +198,7 @@ export const REELS_JETSET: CategoryReel[] = JETSET.map((j) => { return { id: `jetset-${j.vibeId}`, vibeId: j.vibeId, + series: 'jetset' as ReelSeries, generation: 3 as ReelGeneration, title: `${name} · ${j.destination}`, blurb: j.blurb, @@ -203,15 +212,97 @@ export const REELS_JETSET: CategoryReel[] = JETSET.map((j) => { }) /** - * Full insert + /reels catalog (all generations + persona jet-set). + * Persona ski-holiday inserts — house models on extravagant alpine holidays. + * Link to the vibe check (/quiz). + */ +const SKI: { + vibeId: string + destination: string + motionLabel: string + blurb: string +}[] = [ + { + vibeId: 'luxe', + destination: 'Courchevel', + motionLabel: 'Chalet champagne glow', + blurb: + 'Vivienne on the terrace — cream cashmere, gold low, Alps lit like a private gallery.', + }, + { + vibeId: 'muse', + destination: 'St. Moritz', + motionLabel: 'Pink jacket, village lights', + blurb: + 'Camille at blue hour — soft glam against snow and chalet windows. The mountain dresses up.', + }, + { + vibeId: 'sillage', + destination: 'Aspen lodge', + motionLabel: 'Fireplace trail', + blurb: + 'Noor by the fire — perfume mist, violet wrap, snow beyond the glass. Sillage holds in the cold.', + }, + { + vibeId: 'atelier', + destination: 'Gstaad gondola', + motionLabel: 'Ivory suit, gold bag', + blurb: + 'Margot finishes the mountain — structured bag, gold zippers, the lift as runway.', + }, + { + vibeId: 'dew', + destination: 'Zermatt powder', + motionLabel: 'Glass skin in snow light', + blurb: + 'Isla in white on the powder morning — barrier glow that photographs expensive in thin air.', + }, + { + vibeId: 'gilded', + destination: 'Verbier night', + motionLabel: 'Emerald chalet arrival', + blurb: + 'Aurelia at the chalet door — bronze silk, fur, emerald armor. Collector winter.', + }, +] + +export const REELS_SKI: CategoryReel[] = SKI.map((j) => { + const vibe = VIBE_LIST.find((v) => v.id === j.vibeId) + const name = vibe?.avatar.name ?? j.vibeId + const personaTitle = vibe?.title ?? 'House persona' + return { + id: `ski-${j.vibeId}`, + vibeId: j.vibeId, + series: 'ski' as ReelSeries, + generation: 4 as ReelGeneration, + title: `${name} · ${j.destination}`, + blurb: j.blurb, + video: `/brand/videos/reels/ski-${j.vibeId}.mp4`, + poster: `/brand/videos/reels/posters/ski-${j.vibeId}.jpg`, + motionLabel: j.motionLabel, + href: '/quiz', + hrefLabel: 'Find your persona', + kicker: `${personaTitle} · Ski holiday`, + } +}) + +/** + * Full insert + /reels catalog (rooms + persona series). * Product-grid randomizer uses this entire pool — unique per page. */ export const CATEGORY_REELS: CategoryReel[] = [ ...REELS_GEN1, ...REELS_GEN2, ...REELS_JETSET, + ...REELS_SKI, ] +/** Resolve series for filters / badges */ +export function reelSeries(reel: CategoryReel): ReelSeries { + if (reel.series) return reel.series + if (reel.vibeId) return 'jetset' + return 'room' +} + export function reelsForGeneration(gen: ReelGeneration): CategoryReel[] { return CATEGORY_REELS.filter((r) => r.generation === gen) } diff --git a/src/data/videoWatch.ts b/src/data/videoWatch.ts index c05dd3a..6c190c7 100644 --- a/src/data/videoWatch.ts +++ b/src/data/videoWatch.ts @@ -7,8 +7,10 @@ import { REELS_GEN1, REELS_GEN2, REELS_JETSET, + REELS_SKI, reelCtaLabel, reelHref, + reelSeries, type CategoryReel, } from './reels' import { VIBE_LIST } from './vibes' @@ -23,14 +25,16 @@ export type WatchClip = { poster?: string aspect: WatchAspect /** Filter chip */ - group: 'category' | 'carpet' | 'cafe' | 'jetset' | 'all' + group: 'category' | 'carpet' | 'cafe' | 'jetset' | 'ski' | 'all' /** Optional deep link */ href?: string hrefLabel?: string } function fromReel(reel: CategoryReel): WatchClip { - const isPersona = Boolean(reel.vibeId) + const series = reelSeries(reel) + const group = + series === 'ski' ? 'ski' : series === 'jetset' ? 'jetset' : 'category' return { id: reel.id, title: reel.title, @@ -38,7 +42,7 @@ function fromReel(reel: CategoryReel): WatchClip { video: reel.video, poster: reel.poster, aspect: 'portrait', - group: isPersona ? 'jetset' : 'category', + group, href: reelHref(reel), hrefLabel: reelCtaLabel(reel), } @@ -65,7 +69,7 @@ const CARPET_CLIPS: WatchClip[] = VIBE_LIST.map((v) => { } }) -/** Persona café lifestyle films (16:9) — Amazon scroll energy */ +/** Persona café lifestyle films (16:9) */ const CAFE_CLIPS: WatchClip[] = VIBE_LIST.flatMap((v) => { const cafe = v.campaigns.find( (c) => @@ -89,7 +93,7 @@ const CAFE_CLIPS: WatchClip[] = VIBE_LIST.flatMap((v) => { }) /** - * Full watch feed: category + jet-set + carpet + café films. + * Full watch feed: category + jet-set + ski + carpet + café films. */ export const WATCH_CLIPS: WatchClip[] = [ ...CATEGORY_REELS.map(fromReel), @@ -99,6 +103,7 @@ export const WATCH_CLIPS: WatchClip[] = [ export const WATCH_CATEGORY_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'category') export const WATCH_JETSET_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'jetset') +export const WATCH_SKI_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'ski') export const WATCH_CARPET_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'carpet') export const WATCH_CAFE_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'cafe') @@ -107,10 +112,12 @@ export function watchClipCount() { total: WATCH_CLIPS.length, category: WATCH_CATEGORY_CLIPS.length, jetset: WATCH_JETSET_CLIPS.length, + ski: WATCH_SKI_CLIPS.length, carpet: WATCH_CARPET_CLIPS.length, cafe: WATCH_CAFE_CLIPS.length, gen1: REELS_GEN1.length, gen2: REELS_GEN2.length, jetsetPool: REELS_JETSET.length, + skiPool: REELS_SKI.length, } } diff --git a/src/lib/reelInserts.ts b/src/lib/reelInserts.ts index 6e256da..788393f 100644 --- a/src/lib/reelInserts.ts +++ b/src/lib/reelInserts.ts @@ -2,7 +2,7 @@ * Interleave fashion reels into product lists. * * Rules: - * - Pool = full CATEGORY_REELS (category waves + persona jet-set) + * - Pool = full CATEGORY_REELS (category waves + persona series: jet-set, ski, …) * - Never repeat the same reel id on one page * - Gap between inserts is random 2–6 products (immersive / unpredictable) * - Daily seed keeps order stable for a given list within a day diff --git a/src/pages/Reels.tsx b/src/pages/Reels.tsx index 38fd317..0f28b99 100644 --- a/src/pages/Reels.tsx +++ b/src/pages/Reels.tsx @@ -5,15 +5,17 @@ import { REELS_GEN1, REELS_GEN2, REELS_JETSET, + REELS_SKI, reelCtaLabel, reelHref, + reelSeries, type CategoryReel, } from '../data/reels' import { reelsSeo } from '../lib/seoData' import { useEffect, useRef } from 'react' /** - * Short fashion films — portrait clips for rooms + persona jet-set. + * Short fashion films — rooms + persona series (ski, jet-set). */ export function ReelsPage() { return ( @@ -28,9 +30,9 @@ export function ReelsPage() { Moving pictures

- House personas jet-setting the world — and short films from every - room. They appear in the shop scroll so the house finds you. Scroll - to watch. + Ski holidays, jet-set arrivals, and short films from every room. + They appear in the shop scroll so the house finds you. Scroll to + watch.

@@ -43,11 +45,16 @@ export function ReelsPage() {
+ - {reel.vibeId ? 'Persona' : 'Film'} + {reelSeries(reel) === 'ski' + ? 'Ski' + : reelSeries(reel) === 'jetset' + ? 'Persona' + : 'Film'}
diff --git a/src/pages/Watch.tsx b/src/pages/Watch.tsx index 1b81c67..bcbd29a 100644 --- a/src/pages/Watch.tsx +++ b/src/pages/Watch.tsx @@ -8,12 +8,13 @@ import { WATCH_CATEGORY_CLIPS, WATCH_CLIPS, WATCH_JETSET_CLIPS, + WATCH_SKI_CLIPS, watchClipCount, type WatchClip, } from '../data/videoWatch' import { watchSeo } from '../lib/seoData' -type Filter = 'all' | 'category' | 'jetset' | 'carpet' | 'cafe' +type Filter = 'all' | 'category' | 'jetset' | 'ski' | 'carpet' | 'cafe' /** * Visual short-film gallery — muted autoplay from the house library. @@ -25,6 +26,7 @@ export function WatchPage() { const clips = useMemo(() => { if (filter === 'category') return WATCH_CATEGORY_CLIPS if (filter === 'jetset') return WATCH_JETSET_CLIPS + if (filter === 'ski') return WATCH_SKI_CLIPS if (filter === 'carpet') return WATCH_CARPET_CLIPS if (filter === 'cafe') return WATCH_CAFE_CLIPS return WATCH_CLIPS @@ -42,8 +44,9 @@ export function WatchPage() { The house on film

- {counts.total} short films — jet-set personas, the rooms, the carpet, - the café. Scroll to play. Sound stays off until you invite it. + {counts.total} short films — ski holidays, jet-set, the rooms, the + carpet, the café. Scroll to play. Sound stays off until you invite + it.

@@ -53,6 +56,7 @@ export function WatchPage() { {( [ ['all', `All (${counts.total})`], + ['ski', `Ski holiday (${counts.ski})`], ['jetset', `Jet set (${counts.jetset})`], ['category', `The rooms (${counts.category})`], ['carpet', `The Carpet (${counts.carpet})`], @@ -128,7 +132,9 @@ function WatchTile({ clip }: { clip: WatchClip }) { ? 'The Café' : clip.group === 'jetset' ? 'Jet set' - : 'The rooms' + : clip.group === 'ski' + ? 'Ski holiday' + : 'The rooms' const body = ( <>