Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/amazon-insert-video.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand All @@ -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.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/brand/videos/reels/posters/ski-dew.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/brand/videos/reels/ski-atelier.mp4
Binary file not shown.
Binary file added public/brand/videos/reels/ski-dew.mp4
Binary file not shown.
Binary file added public/brand/videos/reels/ski-gilded.mp4
Binary file not shown.
Binary file added public/brand/videos/reels/ski-luxe.mp4
Binary file not shown.
Binary file added public/brand/videos/reels/ski-muse.mp4
Binary file not shown.
Binary file added public/brand/videos/reels/ski-sillage.mp4
Binary file not shown.
26 changes: 18 additions & 8 deletions src/components/ReelInsertCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { useEffect, useRef } from 'react'
import {
reelCtaLabel,
reelHref,
reelSeries,
type CategoryReel,
} from '../data/reels'
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,
Expand All @@ -25,8 +26,17 @@ export function ReelInsertCard({
const videoRef = useRef<HTMLVideoElement>(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
Expand Down Expand Up @@ -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',
})
}
>
Expand All @@ -78,7 +88,7 @@ export function ReelInsertCard({
<span className="absolute top-3 left-3 inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full bg-white/95 text-ink text-[10px] font-bold uppercase tracking-wider px-2.5 py-1 shadow-sm border border-line/80">
<Sparkles className="size-3 text-bamboo" aria-hidden />
{isPersona ? 'Persona' : 'Discover'}
{badge}
</span>
</span>
<span className="absolute top-3 right-3 inline-flex items-center justify-center size-8 rounded-full bg-black/45 text-white backdrop-blur-sm">
Expand Down Expand Up @@ -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',
})
}
Expand Down
105 changes: 98 additions & 7 deletions src/data/reels.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Category, string> = {
handbags: 'Soft light glide across structured leather',
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down
17 changes: 12 additions & 5 deletions src/data/videoWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -23,22 +25,24 @@ 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,
subtitle: reel.motionLabel,
video: reel.video,
poster: reel.poster,
aspect: 'portrait',
group: isPersona ? 'jetset' : 'category',
group,
href: reelHref(reel),
hrefLabel: reelCtaLabel(reel),
}
Expand All @@ -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) =>
Expand All @@ -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),
Expand All @@ -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')

Expand All @@ -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,
}
}
2 changes: 1 addition & 1 deletion src/lib/reelInserts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading