diff --git a/media-creator/.gitignore b/media-creator/.gitignore
index ea2f80dc..d4aec5a2 100644
--- a/media-creator/.gitignore
+++ b/media-creator/.gitignore
@@ -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/
diff --git a/media-creator/README.md b/media-creator/README.md
index 95ab1a7f..df21306c 100644
--- a/media-creator/README.md
+++ b/media-creator/README.md
@@ -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`.
diff --git a/media-creator/package.json b/media-creator/package.json
index 02e45426..b5f2a55d 100644
--- a/media-creator/package.json
+++ b/media-creator/package.json
@@ -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"
diff --git a/media-creator/scripts/capture-profile.mjs b/media-creator/scripts/capture-profile.mjs
new file mode 100644
index 00000000..68521881
--- /dev/null
+++ b/media-creator/scripts/capture-profile.mjs
@@ -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}`);
diff --git a/media-creator/src/Root.tsx b/media-creator/src/Root.tsx
index 78559d2e..3593e63b 100644
--- a/media-creator/src/Root.tsx
+++ b/media-creator/src/Root.tsx
@@ -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}
/>
+