mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
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:
4
media-creator/.gitignore
vendored
4
media-creator/.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
170
media-creator/scripts/capture-profile.mjs
Normal file
170
media-creator/scripts/capture-profile.mjs
Normal 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}`);
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
451
media-creator/src/scenes/ProfileTour.tsx
Normal file
451
media-creator/src/scenes/ProfileTour.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user