Add profile tour video generation, including live screenshot capturing and seamless integration with Remotion

- Introduced `capture-profile.mjs` for section-wise profile screenshot capturing.
- Added `ProfileTour` scene showcasing profiles section-by-section.
- Registered `ProfileTourStory` (9:16) and `ProfileTourPost` (4:5) compositions in `Root.tsx`.
- Updated commands for rendering and capturing in `package.json` and README.
This commit is contained in:
MartinBraquet
2026-07-21 14:13:45 +02:00
parent 10e4c2008d
commit 03bbe939c6
6 changed files with 696 additions and 0 deletions

View File

@@ -9,3 +9,7 @@ out/
# Logs
npm-debug.log*
# Persistent browser profile used by scripts/capture-profile.mjs --login.
# Holds a real signed-in session — never commit it.
.auth-profile/

View File

@@ -43,6 +43,57 @@ Both are 30fps H.264, ready to upload.
| Post | `IntroPost` | 4:5 | 1080×1350 | Instagram **feed post** — tallest ratio allowed in-feed (primary) |
| Story | `IntroStory` | 9:16 | 1080×1920 | Instagram **Story / Reel**, TikTok, YouTube Shorts (secondary) |
## Videos
### Intro (`IntroPost` / `IntroStory`)
The brand intro — what Compass is and why. Content sourced from the About page and FAQ.
### Profile tour (`ProfileTourStory` / `ProfileTourPost`)
A ~30s section-by-section walk through a real profile page: header → details → interests →
personality → bio → photos → compatibility prompts → links → CTA. Story is the primary format here
(it's a Reel).
```bash
npm run render:tour # -> out/compass-profile-tour-story.mp4 9:16 (primary)
npm run render:tour:post # -> out/compass-profile-tour-post.mp4 4:5
```
Every shot is a **screenshot of the live app**, not a mock-up, so the video can never drift from
what the product actually looks like. Regenerate the artwork whenever the profile UI changes:
```bash
yarn dev # from the repo root — needs http://localhost:3000
npm run capture:profile # -> public/profile/*.png
npm run capture:profile -- http://localhost:3000/SomeoneElse
```
`scripts/capture-profile.mjs` shoots the page at 430×932 CSS px @ DPR 2 (so the PNGs are 2× and stay
crisp on the 1080-wide canvas), hides fixed chrome and the Next.js dev badge, and clips one image per
card. It borrows Playwright from the **monorepo root** by absolute path, deliberately — this package
stays Remotion-only.
**Compatibility Prompts and Endorsements only render for a signed-in viewer.** Firebase keeps its auth
in IndexedDB, which Playwright's `storageState` does not carry, so the script uses a persistent browser
profile you sign into by hand, once:
```bash
npm run capture:profile -- --login # opens a real window; sign in yourself
npm run capture:profile # later runs reuse the session, headless
```
The session lives in `.auth-profile/` (gitignored — it is a real login). Without it those two cards are
simply skipped, with a warning, and the rest still captures.
> Sign in as **someone other than the profile's owner**. Viewing your own profile renders the prompts
> card in its editing form; a different account gives the visitor view ("Important to …", "Answer
> yourself"), which is what someone watching the reel would actually see.
> The scene scales each shot by width and pans the ones taller than the frame. If a card's height
> changes substantially, update its entry in `SHOTS` at the top of `src/scenes/ProfileTour.tsx` — the
> pan maths reads those dimensions.
Both formats share the same scenes; the taller Features scene compacts automatically on the shorter 4:5
canvas. Formats are defined in `src/theme.ts` (`FORMATS`) and registered in `src/Root.tsx`.

View File

@@ -5,10 +5,13 @@
"description": "Standalone Remotion studio for Compass media creation. Intentionally NOT part of the root yarn workspaces — install and run it on its own with npm.",
"license": "MIT",
"scripts": {
"capture:profile": "node scripts/capture-profile.mjs",
"render": "npm run render:post",
"render:carousel": "node scripts/render-carousel.mjs",
"render:post": "remotion render IntroPost out/compass-intro-post.mp4",
"render:story": "remotion render IntroStory out/compass-intro-story.mp4",
"render:tour": "remotion render ProfileTourStory out/compass-profile-tour-story.mp4",
"render:tour:post": "remotion render ProfileTourPost out/compass-profile-tour-post.mp4",
"studio": "remotion studio",
"typecheck": "tsc --noEmit",
"upgrade": "remotion upgrade"

View File

@@ -0,0 +1,170 @@
// Captures the section screenshots used by the profile-tour video.
//
// Shoots the public profile page at a phone-sized viewport (430×932 CSS px @ DPR 2,
// so every PNG is 2× and stays crisp on the 1080-wide canvas) and clips one image
// per profile section into public/profile/.
//
// Playwright lives in the monorepo root, NOT in this standalone package — this
// script reaches for it by absolute path on purpose, so `npm install` here stays
// Remotion-only. Run it from the repo root with a dev server up:
//
// yarn dev # http://localhost:3000
// node media-creator/scripts/capture-profile.mjs [profileUrl]
//
// Compatibility Prompts and Endorsements only render for a signed-in viewer, so
// capturing them needs a session. Firebase keeps its auth in IndexedDB, which
// Playwright's storageState does NOT carry — hence a persistent browser profile
// you sign into by hand, once:
//
// node media-creator/scripts/capture-profile.mjs --login
//
// That opens a real window; sign in yourself, and the session is kept in
// .auth-profile/ (gitignored) for every later headless run.
import {existsSync, mkdirSync} from 'node:fs';
import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import pw from '../../node_modules/@playwright/test/index.js';
const {chromium} = pw;
const HERE = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const LOGIN = args.includes('--login');
const URL = args.find((a) => !a.startsWith('--')) ?? 'http://localhost:3000/Martin';
const OUT_DIR = join(HERE, '..', 'public', 'profile');
const PROFILE_DIR = join(HERE, '..', '.auth-profile');
// Card title -> output file. Titles come from the ProfileCard headings in
// web/components/profile/profile-info.tsx. The last two need a signed-in
// session; without one they simply aren't in the DOM and are skipped.
const CARDS = [
['Details', '02-details.png'],
['Interests', '03-interests.png'],
['Personality', '04-personality.png'],
['Links', '05-links.png'],
['About Me', '06-bio.png'],
['Compatibility Prompts', '08-prompts.png'],
['Endorsements', '09-endorsements.png'],
];
const PAD = 12; // CSS px of breathing room around each clip
mkdirSync(OUT_DIR, {recursive: true});
// One persistent context for both modes, so --login and the captures share a
// session. Headed only while logging in.
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
headless: !LOGIN,
viewport: {width: 430, height: 932},
deviceScaleFactor: 2,
colorScheme: 'light',
});
const page = context.pages()[0] ?? (await context.newPage());
if (LOGIN) {
console.log('\nA browser window is open. Sign in there yourself — this script never');
console.log('handles your credentials. It waits until you land back on a signed-in page.\n');
await page.goto('http://localhost:3000/signin', {timeout: 60000});
await page.waitForURL((u) => !u.pathname.includes('signin'), {timeout: 300000});
await page.waitForTimeout(3000);
await context.close();
console.log(`Session saved to ${PROFILE_DIR}. Re-run without --login to capture.`);
process.exit(0);
}
if (!existsSync(join(PROFILE_DIR, 'Default'))) {
console.log('No saved session — prompts and endorsements will be skipped.');
console.log('Run with --login once to capture them.\n');
}
await page.goto(URL, {waitUntil: 'networkidle', timeout: 60000});
await page.waitForSelector('[data-testid="profile-content"]', {timeout: 30000});
// Photos are lazy next/image — walk the page top to bottom so every one of them
// starts loading, then come back up and let them decode.
await page.evaluate(async () => {
const step = innerHeight * 0.8;
for (let y = 0; y < document.body.scrollHeight; y += step) {
scrollTo(0, y);
await new Promise((r) => setTimeout(r, 150));
}
scrollTo(0, 0);
});
await page.waitForTimeout(2500);
// Anything pinned to the viewport (bottom nav, sticky top bar, sign-up CTA) would
// smear across a full-page capture — hide it, plus the CSS animations.
// The Next.js dev-tools badge lives in a shadow-DOM portal, so the sweep below
// can't see it — kill it by tag name instead.
await page.addStyleTag({
content: `
*, *::before, *::after { animation: none !important; transition: none !important; }
nextjs-portal, #__next-build-watcher { display: none !important; }
`,
});
await page.evaluate(() => {
for (const el of document.querySelectorAll('body *')) {
const pos = getComputedStyle(el).position;
if (pos === 'fixed' || pos === 'sticky') el.style.display = 'none';
}
});
// Absolute page rect for a node, padded, clamped to the document.
const rectOf = (selectorFn, arg) =>
page.evaluate(
([fn, a, pad]) => {
const el = new Function('a', `return (${fn})(a)`)(a);
if (!el) return null;
const r = el.getBoundingClientRect();
const x = Math.max(0, r.left + scrollX - pad);
const y = Math.max(0, r.top + scrollY - pad);
return {
x,
y,
width: Math.min(r.width + pad * 2, document.documentElement.scrollWidth - x),
height: r.height + pad * 2,
};
},
[selectorFn.toString(), arg, PAD],
);
const shoot = async (file, clip) => {
if (!clip) {
console.warn(`${file} — element not found, skipped`);
return;
}
const path = join(OUT_DIR, file);
await page.screenshot({path, fullPage: true, clip});
console.log(`${file} ${Math.round(clip.width)}×${Math.round(clip.height)} CSS px`);
};
// 1 — header: avatar, name, location, chips, pull-quote.
await shoot(
'01-header.png',
await rectOf(() => document.querySelector('.animate-profile-appear')),
);
// 2..6 — one card per section, found by its heading text.
for (const [title, file] of CARDS) {
await shoot(
file,
await rectOf((t) => {
const cards = [...document.querySelectorAll('.bg-canvas-50.border-canvas-300.border')];
return cards.find((c) => c.innerText.trim().startsWith(t));
}, title),
);
}
// 7 — photo carousel. Grab the scrolling row, not one slide: the clip is clamped
// to the page width, so it frames photo 1 with photo 2 peeking in — as in the app.
await shoot(
'07-photos.png',
await rectOf(() => document.querySelector('.snap-start')?.closest('[class*="overflow"]') ?? null),
);
// 8 — the whole page, for the establishing slow-scroll shot.
await page.screenshot({path: join(OUT_DIR, 'full.png'), fullPage: true});
console.log(' ✓ full.png (full page, for the scroll shot)');
await context.close();
console.log(`\nWrote ${OUT_DIR}`);

View File

@@ -1,6 +1,7 @@
import {Composition} from 'remotion';
import {FORMATS} from './theme';
import {Intro, INTRO_DURATION} from './scenes/Intro';
import {ProfileTour, PROFILE_TOUR_DURATION} from './scenes/ProfileTour';
// Register every video composition here. The same Intro scenes render into two
// Instagram-ready canvases; render with:
@@ -26,6 +27,22 @@ export const RemotionRoot: React.FC = () => {
width={FORMATS.story.width}
height={FORMATS.story.height}
/>
<Composition
id="ProfileTourStory"
component={ProfileTour}
durationInFrames={PROFILE_TOUR_DURATION}
fps={FORMATS.story.fps}
width={FORMATS.story.width}
height={FORMATS.story.height}
/>
<Composition
id="ProfileTourPost"
component={ProfileTour}
durationInFrames={PROFILE_TOUR_DURATION}
fps={FORMATS.post.fps}
width={FORMATS.post.width}
height={FORMATS.post.height}
/>
</>
);
};

View File

@@ -0,0 +1,451 @@
import React from 'react';
import {
AbsoluteFill,
Img,
Sequence,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import {Background} from '../components/Background';
import {Logo} from '../components/Logo';
import {FadeUp, useSceneFade} from '../components/Animations';
import {colors, fonts} from '../theme';
// A guided tour of a real Compass profile, section by section.
//
// Every shot is a screenshot of the live profile page, captured by
// scripts/capture-profile.mjs into public/profile/. Re-run that script after any
// profile-UI change and this scene picks the new artwork up automatically — but
// update SHOTS below if a card's height changes a lot, since the pan maths uses it.
// ─── Source artwork ─────────────────────────────────────────────────────────
// Sizes are the CSS-pixel dimensions reported by the capture script. Files are 2×
// those numbers; we scale by width, so only the ratio matters here.
const SHOTS = {
header: {src: 'profile/01-header.png', w: 422, h: 335},
details: {src: 'profile/02-details.png', w: 422, h: 1217},
interests: {src: 'profile/03-interests.png', w: 422, h: 422},
personality: {src: 'profile/04-personality.png', w: 422, h: 464},
links: {src: 'profile/05-links.png', w: 422, h: 188},
bio: {src: 'profile/06-bio.png', w: 422, h: 1768},
photos: {src: 'profile/07-photos.png', w: 422, h: 324},
prompts: {src: 'profile/08-prompts.png', w: 422, h: 2152},
full: {src: 'profile/full.png', w: 422, h: 9948},
} as const;
// ─── Scene schedule (frames @ 30fps) ────────────────────────────────────────
// Scenes overlap by a few frames so the cross-fades in useSceneFade meet cleanly.
const S = {
title: {from: 0, dur: 75},
header: {from: 70, dur: 95},
details: {from: 160, dur: 115},
interests: {from: 270, dur: 90},
personality: {from: 355, dur: 95},
bio: {from: 445, dur: 115},
photos: {from: 555, dur: 85},
prompts: {from: 635, dur: 120},
links: {from: 750, dur: 70},
cta: {from: 815, dur: 105},
};
export const PROFILE_TOUR_DURATION = S.cta.from + S.cta.dur; // 920 frames ≈ 30.7s
// ─── Layout ─────────────────────────────────────────────────────────────────
// The phone-ish frame the screenshots live in. Its height is what decides whether
// a shot fits or has to pan, so it adapts to the canvas: the 9:16 story gets a tall
// frame, the 4:5 post a short one.
const FRAME_WIDTH = 840;
const useFrameBox = () => {
const {height} = useVideoConfig();
const captionBlock = height > 1600 ? 430 : 330; // room for eyebrow + caption
return {
width: FRAME_WIDTH,
height: height - captionBlock - 200,
captionSize: height > 1600 ? 52 : 46,
eyebrowSize: height > 1600 ? 30 : 26,
};
};
const Scene: React.FC<{dur: number; children: React.ReactNode}> = ({dur, children}) => {
const opacity = useSceneFade(dur);
return (
<AbsoluteFill
style={{
opacity,
alignItems: 'center',
justifyContent: 'center',
padding: '0 90px',
textAlign: 'center',
}}
>
{children}
</AbsoluteFill>
);
};
const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => {
const {eyebrowSize} = useFrameBox();
return (
<div
style={{
fontFamily: fonts.display,
color: colors.amberBright,
fontSize: eyebrowSize,
fontWeight: 700,
letterSpacing: 6,
textTransform: 'uppercase',
}}
>
{children}
</div>
);
};
// ─── The shot ───────────────────────────────────────────────────────────────
// A screenshot inside a rounded frame. Shots taller than the frame pan downward
// over the beat (reads as scrolling the page); shots that fit get a slow Ken Burns
// push so nothing on screen is ever completely static.
//
// `pan` caps how far a tall shot travels, as a fraction of its overscroll — the
// bio is ~4 frames tall, and racing through all of it would just be a blur.
const Shot: React.FC<{
shot: {src: string; w: number; h: number};
dur: number;
pan?: number;
}> = ({shot, dur, pan = 1}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const box = useFrameBox();
const scale = box.width / shot.w;
const scaledHeight = shot.h * scale;
const overscroll = Math.max(0, scaledHeight - box.height);
// Hold still for a beat at each end before travelling, so the top of a card
// (its title) is readable on arrival and the bottom is readable on the cut.
const HOLD = 12;
const progress = interpolate(frame, [HOLD, dur - HOLD], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const eased = progress * progress * (3 - 2 * progress); // smoothstep
const translateY = -overscroll * pan * eased;
const zoom = overscroll > 0 ? 1 : interpolate(eased, [0, 1], [1, 1.05]);
// Frame itself springs in, so the cut lands with a little weight.
const entrance = spring({frame, fps, config: {damping: 200, mass: 0.7}});
const frameScale = interpolate(entrance, [0, 1], [0.94, 1]);
return (
<div
style={{
width: box.width,
height: Math.min(box.height, scaledHeight),
borderRadius: 34,
overflow: 'hidden',
border: `1.5px solid ${colors.amberBright}44`,
boxShadow: `0 40px 120px rgba(0,0,0,0.55), 0 0 0 10px ${colors.espresso}`,
transform: `scale(${frameScale})`,
flexShrink: 0,
}}
>
<Img
src={staticFile(shot.src)}
style={{
width: box.width,
display: 'block',
transform: `translateY(${translateY}px) scale(${zoom})`,
transformOrigin: 'top center',
}}
/>
</div>
);
};
// A titled beat: eyebrow + one-line caption above the framed screenshot.
const SectionScene: React.FC<{
eyebrow: string;
caption: React.ReactNode;
shot: {src: string; w: number; h: number};
dur: number;
pan?: number;
}> = ({eyebrow, caption, shot, dur, pan}) => {
const box = useFrameBox();
const opacity = useSceneFade(dur);
const {height} = useVideoConfig();
// The caption block is pinned to a fixed offset rather than centred with the
// shot: a short card (Links) would otherwise drag the text down the frame while
// a tall one (Details) pushes it up, and the wobble is obvious cut to cut.
return (
<AbsoluteFill style={{opacity, padding: `${height * 0.11}px 90px 0`, textAlign: 'center'}}>
<FadeUp>
<Eyebrow>{eyebrow}</Eyebrow>
</FadeUp>
<FadeUp delay={8} style={{marginTop: 18}}>
<div
style={{
fontFamily: fonts.display,
color: colors.cream,
fontSize: box.captionSize,
fontWeight: 700,
lineHeight: 1.25,
}}
>
{caption}
</div>
</FadeUp>
{/* Shots are centred in whatever vertical space the caption leaves. */}
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 0,
}}
>
<Shot shot={shot} dur={dur} pan={pan} />
</div>
</AbsoluteFill>
);
};
// ─── Scene 1 — title, over the whole page scrolling by ──────────────────────
const TitleScene: React.FC = () => {
const frame = useCurrentFrame();
const {height} = useVideoConfig();
return (
<Scene dur={S.title.dur}>
{/* The entire profile drifting past, dimmed right down — sets up "there's a
lot here" before we start pulling out individual sections. */}
<AbsoluteFill style={{overflow: 'hidden', opacity: 0.28}}>
<Img
src={staticFile(SHOTS.full.src)}
style={{
width: 1080,
display: 'block',
transform: `translateY(${-frame * 14 - height * 0.1}px)`,
transformOrigin: 'top center',
}}
/>
</AbsoluteFill>
<AbsoluteFill
style={{
background: `linear-gradient(${colors.ink}dd, ${colors.ink}bb, ${colors.ink}dd)`,
}}
/>
<div style={{position: 'relative', textAlign: 'center'}}>
<FadeUp>
<div style={{display: 'flex', justifyContent: 'center'}}>
<Logo size={180} />
</div>
</FadeUp>
<FadeUp delay={12} style={{marginTop: 46}}>
<div
style={{
fontFamily: fonts.display,
color: colors.cream,
fontSize: 78,
fontWeight: 800,
lineHeight: 1.15,
}}
>
What a Compass
<br />
profile looks like
</div>
</FadeUp>
<FadeUp delay={24} style={{marginTop: 24}}>
<div
style={{
fontFamily: fonts.serif,
color: colors.amberBright,
fontSize: 44,
fontStyle: 'italic',
}}
>
No filters. No algorithm. Just you.
</div>
</FadeUp>
</div>
</Scene>
);
};
// ─── Scene 9 — call to action ───────────────────────────────────────────────
const CtaScene: React.FC = () => (
<Scene dur={S.cta.dur}>
<FadeUp>
<Logo size={190} />
</FadeUp>
<FadeUp delay={12} style={{marginTop: 44}}>
<div
style={{
fontFamily: fonts.display,
color: colors.cream,
fontSize: 80,
fontWeight: 800,
lineHeight: 1.2,
}}
>
Build yours in
<br />
five minutes
</div>
</FadeUp>
<FadeUp delay={22} style={{marginTop: 30}}>
<div
style={{
fontFamily: fonts.display,
fontSize: 56,
fontWeight: 700,
color: colors.espresso,
background: `linear-gradient(${colors.amberBright}, ${colors.amber})`,
padding: '20px 48px',
borderRadius: 999,
boxShadow: `0 20px 60px ${colors.amberDeep}66`,
}}
>
compassmeet.com
</div>
</FadeUp>
<FadeUp delay={34} style={{marginTop: 38}}>
<div
style={{
fontFamily: fonts.display,
color: colors.amberBright,
fontSize: 32,
fontWeight: 600,
letterSpacing: 3,
textTransform: 'uppercase',
}}
>
Free · Ad-free · Open-source
</div>
</FadeUp>
</Scene>
);
// ─── Composition ────────────────────────────────────────────────────────────
export const ProfileTour: React.FC = () => {
return (
<AbsoluteFill style={{backgroundColor: colors.ink}}>
<Background />
<Sequence from={S.title.from} durationInFrames={S.title.dur}>
<TitleScene />
</Sequence>
<Sequence from={S.header.from} durationInFrames={S.header.dur}>
<SectionScene
eyebrow="Who they are"
caption={
<>
Name, place, and the four things
<br />
they care about most.
</>
}
shot={SHOTS.header}
dur={S.header.dur}
/>
</Sequence>
<Sequence from={S.details.from} durationInFrames={S.details.dur}>
<SectionScene
eyebrow="Details"
caption={
<>
What you'd actually want to know
<br />
openly stated, not guessed.
</>
}
shot={SHOTS.details}
dur={S.details.dur}
/>
</Sequence>
<Sequence from={S.interests.from} durationInFrames={S.interests.dur}>
<SectionScene
eyebrow="Interests & causes"
caption={<>Searchable. Find your people by what they love.</>}
shot={SHOTS.interests}
dur={S.interests.dur}
/>
</Sequence>
<Sequence from={S.personality.from} durationInFrames={S.personality.dur}>
<SectionScene
eyebrow="Personality"
caption={<>MBTI and Big Five, if you want to share them.</>}
shot={SHOTS.personality}
dur={S.personality.dur}
/>
</Sequence>
<Sequence from={S.bio.from} durationInFrames={S.bio.dur}>
<SectionScene
eyebrow="About me"
caption={
<>
Room to write properly.
<br />
Not 150 characters.
</>
}
shot={SHOTS.bio}
// The bio runs several screens deep — travel through the top half only.
pan={0.55}
dur={S.bio.dur}
/>
</Sequence>
<Sequence from={S.photos.from} durationInFrames={S.photos.dur}>
<SectionScene
eyebrow="Photos"
caption={<>Down here, on purpose. Words come first.</>}
shot={SHOTS.photos}
dur={S.photos.dur}
/>
</Sequence>
<Sequence from={S.prompts.from} durationInFrames={S.prompts.dur}>
<SectionScene
eyebrow="Compatibility prompts"
caption={
<>
Real positions on real questions.
<br />
Not a vibe check.
</>
}
shot={SHOTS.prompts}
// Eight prompts deep and paginated — drift over the first few rather
// than racing the whole card.
pan={0.35}
dur={S.prompts.dur}
/>
</Sequence>
<Sequence from={S.links.from} durationInFrames={S.links.dur}>
<SectionScene
eyebrow="Links"
caption={<>Your corner of the web, attached.</>}
shot={SHOTS.links}
dur={S.links.dur}
/>
</Sequence>
<Sequence from={S.cta.from} durationInFrames={S.cta.dur}>
<CtaScene />
</Sequence>
</AbsoluteFill>
);
};