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
123 changes: 123 additions & 0 deletions .claude/skills/onboarding-videos/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
name: onboarding-videos
description: Add, replace, and optimize the looping demo videos in the onboarding "welcome" bento grid (packages/ui/src/features/onboarding). Covers the ffmpeg compression workflow (the scripts/optimize-onboarding-videos.mjs wrapper), the faststart + right-size best practices that keep playback smooth, the first-frame poster convention, and how to wire a new clip into WelcomeScreen. Use when onboarding demo clips feel laggy, when a clip is being added/swapped/re-recorded, or when posters and videos drift out of sync.
allowed-tools: Bash(node scripts/optimize-onboarding-videos.mjs:*), Bash(pnpm optimize:onboarding-videos:*), Bash(ffmpeg:*), Bash(ffprobe:*), Bash(npx biome check:*), Bash(pnpm --filter @posthog/ui typecheck:*)
---

# Onboarding bento demo videos

The first onboarding step (`WelcomeScreen`) shows a bento grid of feature cards.
A card with a `media` entry plays a short, muted, looping screen recording; cards
without one show a static placeholder. The featured (large, top-left) card is the
only one that plays at a time — hover just moves the highlight.

| Thing | Where |
| --- | --- |
| Clips + posters | `packages/ui/src/features/onboarding/assets/<feature>-<light\|dark>.{mp4,jpg}` |
| Wiring (`MEDIA` map, `startTime`) | `packages/ui/src/features/onboarding/components/WelcomeScreen.tsx` |
| `<video>` element + play/seek logic | `packages/ui/src/features/onboarding/components/FeatureBentoCard.tsx` |
| Optimizer script | `scripts/optimize-onboarding-videos.mjs` (`pnpm optimize:onboarding-videos`) |

## Why clips need optimizing (the "it feels laggy" fix)

Raw screen recordings hitch in this UI for two reasons, both fixed by re-encoding:

1. **`moov` atom at the end of the file.** Without faststart the player must read
to EOF before it can start, so first-play and every seek stutter. We seek on
mount and on every loop (`FeatureBentoCard` parks the clip on `videoStartTime`),
so this bites constantly. Fix: `-movflags +faststart`.
2. **Resolution far larger than it renders.** The featured slot is never wider than
~500 CSS px (the grid is `max-w-[760px]`). Even at 2× retina that's ~1000px, but
recordings come in at ~1876px. Decoding huge frames and downscaling them — on
every play and during the framer-motion slot reflow — is wasted work. Fix: cap
width at **1000px** (which also matches the poster width exactly).

Bitrate/size are usually already modest; **decode cost and startup are the lag**,
not bytes. Don't chase file size at the expense of width/faststart.

## Canonical encode target

H.264 · `yuv420p` · **≤1000px wide** (keep aspect, even height) · CRF 23 ·
`+faststart` · no audio. These live as constants at the top of
`scripts/optimize-onboarding-videos.mjs` — change them there, not ad-hoc.

## Optimize existing clips

```bash
pnpm optimize:onboarding-videos # encode any clip not already optimized
pnpm optimize:onboarding-videos --dry-run # show what would change
pnpm optimize:onboarding-videos --force # re-encode all (after changing the target constants)
```

Requires ffmpeg (`brew install ffmpeg`). The script tags each output with a
`comment` marker, so re-runs skip already-optimized files — it's safe to run any
time, including right after dropping in a new clip. It rewrites files in place;
review the `git diff --stat` and the printed before/after sizes.

## Add or replace a clip

1. **Record** light + dark variants at **≥1000px wide**. The optimizer downscales to
1000px but never upscales, so anything narrower ships soft — resolution is the one
thing it can't fix for you. Keep it short (~10–13s); it loops.
2. **Name + drop** the files as `assets/<feature>-light.mp4` and
`assets/<feature>-dark.mp4`. Two things bite every time:
- Recordings almost always arrive with the **light variant unsuffixed**
(`foo.mp4`, only `foo-dark.mp4` is tagged). Rename it to `foo-light.mp4`.
- The source filename (e.g. whatever's in `~/Downloads`) is irrelevant — the
`<feature>` prefix must be the **media id** you'll use in `WelcomeScreen` and
follow the existing convention, not whatever the file was called.

No `assets.d.ts` change needed — `*.mp4`/`*.jpg` are wildcard modules.
3. **Optimize**: `pnpm optimize:onboarding-videos`.
4. **Make the poster** — the still shown before play. **Use the clip's first frame**
and leave `startTime` at 0, so the poster, the first played frame, and the loop
point are all the same with nothing to keep in sync:

```bash
ffmpeg -y -i assets/<feature>-<theme>.mp4 \
-frames:v 1 -vf "scale=1000:-2:flags=lanczos" -q:v 3 \
assets/<feature>-<theme>.jpg
```

5. **Wire it up** in `WelcomeScreen.tsx`. For a *new* media id, four edits, all keyed
by the same slug: import the `.mp4` + `.jpg`, add the id to the `MediaId` union,
add its entry to the `MEDIA` map (`startTime: 0`), and set `media: "<id>"` on the
target `FeatureDef`.

### Replacing an existing clip

Keep the **same asset filename** and the wiring is untouched — none of step 5
applies. Drop the new file over `assets/<feature>-<theme>.mp4`, run the optimizer (a
fresh drop carries no skip-marker, so it re-encodes without `--force`), and
**regenerate that poster** (step 4). If you replace only one theme, re-check that
light and dark still share an aspect ratio — a clip with stray padding frames
differently from its sibling, and the gap shows when the user toggles theme.

## Poster = first frame (and startTime = 0)

`FeatureBentoCard` shows the poster while a card rests, then seeks the `<video>` to
`MEDIA[id].startTime` on mount and loops back there (not necessarily to 0). The
simple, default contract: **the poster is the clip's first frame and `startTime` is
0**, so the still, the first played frame, and the loop point are all identical —
nothing to keep in sync. Keep the poster the same pixel width as the clip (1000px)
so the poster→video swap is seamless.

`startTime` can start/loop mid-clip if you ever need it (the `code-review` clip uses
3s) — but then the poster MUST be that exact frame (`ffmpeg -ss <startTime> -i …`),
or the still jumps the instant playback starts. Prefer the first-frame default
unless you have a specific reason.

## Verify

```bash
# clips: moov should print BEFORE mdat, width ≤ 1000:
ffprobe -v trace <clip>.mp4 2>&1 | grep -o -m2 -E "type:'(moov|mdat)'"
ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 <clip>.mp4

# wiring: a grown MediaId union may need a formatter reflow, so let biome fix it:
npx biome check --write packages/ui/src/features/onboarding/components/WelcomeScreen.tsx
pnpm --filter @posthog/ui typecheck
```

To confirm playback feels smooth in the real app, use the `test-electron-app`
skill to drive the running onboarding flow.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"rebuild:sqlite-electron": "node scripts/rebuild-better-sqlite3-electron.mjs",
"typecheck": "turbo typecheck",
"boundaries": "node scripts/check-host-boundaries.mjs",
"optimize:onboarding-videos": "node scripts/optimize-onboarding-videos.mjs",
"lint": "biome check --write --unsafe",
"format": "biome format --write",
"clean": "pnpm -r clean",
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/assets.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ declare module "*.mp3" {
const src: string;
export default src;
}

declare module "*.mp4" {
const src: string;
export default src;
}

declare module "*.jpg" {
const src: string;
export default src;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,51 @@
overflow: hidden;
}

.feature-bento-card__placeholder--media {
border-style: solid;
border-color: var(--gray-a4);
background: var(--gray-2);
}

.feature-bento-card__video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}

.feature-bento-card--selectable {
cursor: pointer;
}

.feature-bento-card__expand-hint {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-2);
background: rgba(0, 0, 0, 0.45);
color: white;
opacity: 0;
transform: scale(0.9);
transition:
opacity 0.15s ease,
transform 0.15s ease;
pointer-events: none;
}

.feature-bento-card--selectable:hover .feature-bento-card__expand-hint {
opacity: 1;
transform: scale(1);
}

.feature-bento-card__placeholder-grid {
position: absolute;
inset: 0;
Expand Down
153 changes: 132 additions & 21 deletions packages/ui/src/features/onboarding/components/FeatureBentoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,166 @@
import { ArrowsOut } from "@phosphor-icons/react";
import { Flex, Text } from "@radix-ui/themes";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { type KeyboardEvent, type ReactNode, useEffect, useRef } from "react";
import "./FeatureBentoCard.css";

interface FeatureBentoCardProps {
icon: ReactNode;
title: string;
description: string;
/** Highlights the card (the featured card when nothing is hovered). */
active?: boolean;
index?: number;
/** Tailwind classes controlling the cell's grid placement (e.g. "col-span-4 row-span-2"). */
className?: string;
/** Optional looping demo video shown in the placeholder area. */
videoSrc?: string;
/** Still frame shown until the video plays. Should match `videoStartTime`. */
posterSrc?: string;
/** Timestamp (seconds) the still is taken from; playback starts and loops here. */
videoStartTime?: number;
/** When true, this card's video plays. Only one card plays at a time. */
shouldPlay?: boolean;
/** Click handler that promotes this card into the large slot. */
onSelect?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}

function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}

export function FeatureBentoCard({
icon,
title,
description,
active = false,
index = 0,
className = "",
videoSrc,
posterSrc,
videoStartTime = 0,
shouldPlay = false,
onSelect,
onMouseEnter,
onMouseLeave,
}: FeatureBentoCardProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const selectable = Boolean(onSelect);

// Park the video on the still frame so it matches the poster, even after the
// browser has decoded it (poster only shows before the first play).
useEffect(() => {
const video = videoRef.current;
if (!video || !videoSrc) return;

const seekToStart = () => {
try {
video.currentTime = videoStartTime;
} catch {
// Seeking before metadata is ready is a no-op; the listener retries.
}
};

if (video.readyState >= 1) {
seekToStart();
return;
}
video.addEventListener("loadedmetadata", seekToStart, { once: true });
return () => video.removeEventListener("loadedmetadata", seekToStart);
}, [videoSrc, videoStartTime]);

// Play only when told to; otherwise rest on the still frame.
useEffect(() => {
const video = videoRef.current;
if (!video || !videoSrc) return;

if (shouldPlay && !prefersReducedMotion()) {
void video.play().catch(() => {
// Ignore: play() can reject if the element isn't ready yet.
});
} else {
video.pause();
try {
video.currentTime = videoStartTime;
} catch {
// Ignore: resetting before metadata is ready is a no-op.
}
}
}, [shouldPlay, videoSrc, videoStartTime]);

// Loop back to the still frame rather than the very start of the clip.
const handleVideoEnded = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = videoStartTime;
if (shouldPlay && !prefersReducedMotion()) {
void video.play().catch(() => {});
}
};

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!selectable) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect?.();
}
};

return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.08 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={`feature-bento-card ${active ? "feature-bento-card--active" : ""} ${className}`}
onClick={selectable ? onSelect : undefined}
onKeyDown={selectable ? handleKeyDown : undefined}
role={selectable ? "button" : undefined}
tabIndex={selectable ? 0 : undefined}
aria-label={selectable ? `Show ${title}` : undefined}
className={`feature-bento-card h-full w-full ${active ? "feature-bento-card--active" : ""} ${selectable ? "feature-bento-card--selectable" : ""}`}
>
<div className="feature-bento-card__placeholder">
<div
className="feature-bento-card__placeholder-grid"
aria-hidden="true"
/>
<div
className="feature-bento-card__placeholder-glow"
aria-hidden="true"
/>
<Flex
align="center"
justify="center"
className="relative z-10 text-(--gray-9)"
>
<div className="feature-bento-card__icon">{icon}</div>
</Flex>
<div
className={`feature-bento-card__placeholder ${videoSrc ? "feature-bento-card__placeholder--media" : ""}`}
>
{videoSrc ? (
<video
ref={videoRef}
className="feature-bento-card__video"
src={videoSrc}
poster={posterSrc}
muted
playsInline
preload="metadata"
onEnded={handleVideoEnded}
/>
) : (
<>
<div
className="feature-bento-card__placeholder-grid"
aria-hidden="true"
/>
<div
className="feature-bento-card__placeholder-glow"
aria-hidden="true"
/>
<Flex
align="center"
justify="center"
className="relative z-10 text-(--gray-9)"
>
<div className="feature-bento-card__icon">{icon}</div>
</Flex>
</>
)}
{selectable && (
<div className="feature-bento-card__expand-hint" aria-hidden="true">
<ArrowsOut size={13} weight="bold" />
</div>
)}
</div>
<Flex
direction="column"
Expand Down
Loading
Loading