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} /> + + ); }; diff --git a/media-creator/src/scenes/ProfileTour.tsx b/media-creator/src/scenes/ProfileTour.tsx new file mode 100644 index 00000000..5edc6480 --- /dev/null +++ b/media-creator/src/scenes/ProfileTour.tsx @@ -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 ( + + {children} + + ); +}; + +const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => { + const {eyebrowSize} = useFrameBox(); + return ( +
+ {children} +
+ ); +}; + +// ─── 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 ( +
+ +
+ ); +}; + +// 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 ( + + + {eyebrow} + + +
+ {caption} +
+
+ {/* Shots are centred in whatever vertical space the caption leaves. */} +
+ +
+
+ ); +}; + +// ─── Scene 1 — title, over the whole page scrolling by ────────────────────── +const TitleScene: React.FC = () => { + const frame = useCurrentFrame(); + const {height} = useVideoConfig(); + + return ( + + {/* The entire profile drifting past, dimmed right down — sets up "there's a + lot here" before we start pulling out individual sections. */} + + + + +
+ +
+ +
+
+ +
+ What a Compass +
+ profile looks like +
+
+ +
+ No filters. No algorithm. Just you. +
+
+
+
+ ); +}; + +// ─── Scene 9 — call to action ─────────────────────────────────────────────── +const CtaScene: React.FC = () => ( + + + + + +
+ Build yours in +
+ five minutes +
+
+ +
+ compassmeet.com +
+
+ +
+ Free · Ad-free · Open-source +
+
+
+); + +// ─── Composition ──────────────────────────────────────────────────────────── +export const ProfileTour: React.FC = () => { + return ( + + + + + + + + + + Name, place, and the four things +
+ they care about most. + + } + shot={SHOTS.header} + dur={S.header.dur} + /> +
+ + + + What you'd actually want to know — +
+ openly stated, not guessed. + + } + shot={SHOTS.details} + dur={S.details.dur} + /> +
+ + + Searchable. Find your people by what they love.} + shot={SHOTS.interests} + dur={S.interests.dur} + /> + + + + MBTI and Big Five, if you want to share them.} + shot={SHOTS.personality} + dur={S.personality.dur} + /> + + + + + Room to write properly. +
+ 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} + /> +
+ + + Down here, on purpose. Words come first.} + shot={SHOTS.photos} + dur={S.photos.dur} + /> + + + + + Real positions on real questions. +
+ 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} + /> +
+ + + Your corner of the web, attached.} + shot={SHOTS.links} + dur={S.links.dur} + /> + + + + + +
+ ); +};