mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
Add media creator
This commit is contained in:
11
media-creator/.gitignore
vendored
Normal file
11
media-creator/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Standalone npm project — its own dependency tree, kept out of the repo.
|
||||
node_modules/
|
||||
|
||||
# Rendered video output.
|
||||
out/
|
||||
|
||||
# Remotion caches / bundles.
|
||||
.remotion/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
79
media-creator/README.md
Normal file
79
media-creator/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Compass — Social Media Videos
|
||||
|
||||
A **standalone** [Remotion](https://www.remotion.dev) studio for producing Compass promo/social videos
|
||||
(Instagram Reels, TikTok, YouTube Shorts). Videos are defined in React and rendered to MP4.
|
||||
|
||||
## ⚠️ Deliberately isolated from the monorepo
|
||||
|
||||
This folder is **not** a yarn workspace and is **not** installed by the repo-root `yarn install`. It has its
|
||||
own dependency tree so the web app and API never pull Remotion (and its bundled headless browser) by accident.
|
||||
|
||||
- Root `package.json` `workspaces` is an explicit list — this folder is intentionally left out.
|
||||
- Install and run it **only from inside `media-creator/`**, using **npm** (not yarn), so it never touches the
|
||||
root `node_modules` or `yarn.lock`.
|
||||
- `node_modules/`, `out/`, and `.remotion/` are gitignored.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd media-creator
|
||||
npm install # first time only — also downloads a headless browser for rendering
|
||||
```
|
||||
|
||||
## Preview (interactive studio)
|
||||
|
||||
```bash
|
||||
npm run studio # opens the Remotion Studio at http://localhost:3000
|
||||
```
|
||||
|
||||
## Render to MP4
|
||||
|
||||
The same intro renders into two Instagram-ready formats:
|
||||
|
||||
```bash
|
||||
npm run render:post # -> out/compass-intro-post.mp4 4:5 (1080×1350) — feed post
|
||||
npm run render:story # -> out/compass-intro-story.mp4 9:16 (1080×1920) — story / reel
|
||||
npm run render # alias for render:post (the primary format)
|
||||
```
|
||||
|
||||
Both are 30fps H.264, ready to upload.
|
||||
|
||||
| Format | Composition id | Ratio | Pixels | Use |
|
||||
| ------ | -------------- | ----- | --------- | ----------------------------------------------------------------- |
|
||||
| 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) |
|
||||
|
||||
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`.
|
||||
|
||||
> Don't post the 9:16 story as a feed post — Instagram crops it to ~4:5. Use `IntroPost` for the feed.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
media-creator/
|
||||
├── package.json standalone deps (npm)
|
||||
├── remotion.config.ts render settings (codec, quality)
|
||||
├── public/logo.svg Compass logo (copied from web/public/favicon.svg)
|
||||
└── src/
|
||||
├── index.ts registerRoot
|
||||
├── Root.tsx composition registry
|
||||
├── theme.ts brand colors / fonts / output FORMATS
|
||||
├── components/ Background, Logo, reusable animations
|
||||
└── scenes/Intro.tsx the intro video (shared by both formats)
|
||||
```
|
||||
|
||||
## Adding another video
|
||||
|
||||
1. Build a component under `src/scenes/`.
|
||||
2. Register a `<Composition>` for it in `src/Root.tsx` (one per format if it needs both aspect ratios —
|
||||
read the canvas with `useVideoConfig()` to adapt the layout, as the Features scene does).
|
||||
3. `npm run studio` to preview, then `npx remotion render <id> out/<name>.mp4`.
|
||||
|
||||
## Keeping the brand in sync
|
||||
|
||||
`src/theme.ts` and `public/logo.svg` mirror the web app (`web/styles/globals.css`,
|
||||
`web/public/favicon.svg`). If the web palette or logo changes, update them here too.
|
||||
|
||||
Content in the intro is sourced from the app's About page and FAQ (`web/pages/about.tsx`,
|
||||
`web/public/md/faq.md`).
|
||||
2639
media-creator/package-lock.json
generated
Normal file
2639
media-creator/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
media-creator/package.json
Normal file
27
media-creator/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "compass-media-creator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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": {
|
||||
"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",
|
||||
"studio": "remotion studio",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"upgrade": "remotion upgrade"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.290",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"remotion": "4.0.290"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
9
media-creator/remotion.config.ts
Normal file
9
media-creator/remotion.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Remotion CLI configuration. Applies to `remotion studio` and `remotion render`.
|
||||
// Docs: https://www.remotion.dev/docs/config
|
||||
import {Config} from '@remotion/cli/config';
|
||||
|
||||
Config.setVideoImageFormat('jpeg');
|
||||
Config.setOverwriteOutput(true);
|
||||
// High quality for social platforms (Reels / Shorts / TikTok re-encode aggressively).
|
||||
Config.setCodec('h264');
|
||||
Config.setCrf(18);
|
||||
45
media-creator/scripts/render-carousel.mjs
Normal file
45
media-creator/scripts/render-carousel.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
// Exports one settled still per Intro scene as a 4:5 Instagram carousel slide.
|
||||
//
|
||||
// Each frame is picked mid-scene — after the entrance animations have settled and
|
||||
// before the scene cross-fades out — from the schedule `S` in src/scenes/Intro.tsx.
|
||||
// If you change scene timings there, update the `frame` values here to match.
|
||||
//
|
||||
// Run with: npm run render:carousel -> out/carousel/01-logo.jpg … 06-cta.jpg
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {mkdirSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const OUT_DIR = join('out', 'carousel');
|
||||
mkdirSync(OUT_DIR, {recursive: true});
|
||||
|
||||
// name (drives upload order via the numeric prefix) -> absolute frame in IntroPost
|
||||
const SLIDES = [
|
||||
{name: '01-logo', frame: 78},
|
||||
{name: '02-hook', frame: 190},
|
||||
{name: '03-what', frame: 315},
|
||||
{name: '04-features', frame: 495},
|
||||
{name: '05-vision', frame: 602},
|
||||
{name: '06-cta', frame: 720},
|
||||
];
|
||||
|
||||
const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
|
||||
for (const {name, frame} of SLIDES) {
|
||||
const output = join(OUT_DIR, `${name}.jpg`);
|
||||
console.log(`→ ${output} (frame ${frame})`);
|
||||
execFileSync(
|
||||
bin,
|
||||
[
|
||||
'remotion',
|
||||
'still',
|
||||
'IntroPost', // 4:5 canvas — see FORMATS in src/theme.ts
|
||||
output,
|
||||
`--frame=${frame}`,
|
||||
'--image-format=jpeg',
|
||||
'--jpeg-quality=95',
|
||||
],
|
||||
{stdio: 'inherit'},
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\nDone — ${SLIDES.length} carousel slides in ${OUT_DIR}/`);
|
||||
31
media-creator/src/Root.tsx
Normal file
31
media-creator/src/Root.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import {Composition} from 'remotion';
|
||||
import {FORMATS} from './theme';
|
||||
import {Intro, INTRO_DURATION} from './scenes/Intro';
|
||||
|
||||
// Register every video composition here. The same Intro scenes render into two
|
||||
// Instagram-ready canvases; render with:
|
||||
// npm run render:post -> out/compass-intro-post.mp4 (4:5 feed post)
|
||||
// npm run render:story -> out/compass-intro-story.mp4 (9:16 story / reel)
|
||||
// npx remotion render <id> out/<name>.mp4
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="IntroPost"
|
||||
component={Intro}
|
||||
durationInFrames={INTRO_DURATION}
|
||||
fps={FORMATS.post.fps}
|
||||
width={FORMATS.post.width}
|
||||
height={FORMATS.post.height}
|
||||
/>
|
||||
<Composition
|
||||
id="IntroStory"
|
||||
component={Intro}
|
||||
durationInFrames={INTRO_DURATION}
|
||||
fps={FORMATS.story.fps}
|
||||
width={FORMATS.story.width}
|
||||
height={FORMATS.story.height}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
43
media-creator/src/components/Animations.tsx
Normal file
43
media-creator/src/components/Animations.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import {interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
|
||||
// Fade + rise, driven by a spring so motion feels physical rather than linear.
|
||||
// `delay` is in frames, relative to the start of the enclosing <Sequence>.
|
||||
export const FadeUp: React.FC<{
|
||||
delay?: number;
|
||||
distance?: number;
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}> = ({delay = 0, distance = 60, children, style}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
const progress = spring({
|
||||
frame: frame - delay,
|
||||
fps,
|
||||
config: {damping: 200, mass: 0.6},
|
||||
});
|
||||
|
||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
||||
const translateY = interpolate(progress, [0, 1], [distance, 0]);
|
||||
|
||||
return (
|
||||
<div style={{...style, opacity, transform: `translateY(${translateY}px)`}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Fade a scene out over its final `fadeFrames` frames so cuts never pop.
|
||||
export const useSceneFade = (
|
||||
durationInFrames: number,
|
||||
fadeFrames = 12,
|
||||
): number => {
|
||||
const frame = useCurrentFrame();
|
||||
return interpolate(
|
||||
frame,
|
||||
[0, fadeFrames, durationInFrames - fadeFrames, durationInFrames],
|
||||
[0, 1, 1, 0],
|
||||
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'},
|
||||
);
|
||||
};
|
||||
44
media-creator/src/components/Background.tsx
Normal file
44
media-creator/src/components/Background.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {AbsoluteFill, useCurrentFrame} from 'remotion';
|
||||
import {colors} from '../theme';
|
||||
|
||||
// Warm espresso gradient with a slow-drifting amber glow. Used behind every scene
|
||||
// so cuts feel like one continuous piece rather than isolated slides.
|
||||
export const Background: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Gentle vertical drift of the glow, looping calmly over ~8s.
|
||||
const drift = Math.sin(frame / 70) * 120;
|
||||
const drift2 = Math.cos(frame / 90) * 100;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(160deg, ${colors.espresso} 0%, ${colors.ink} 55%, ${colors.espressoDeep} 100%)`,
|
||||
}}
|
||||
>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(60% 40% at 30% ${28 + drift / 10}%, ${
|
||||
colors.amberDeep
|
||||
}55 0%, transparent 70%)`,
|
||||
transform: `translateY(${drift}px)`,
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(55% 35% at 75% ${80 + drift2 / 12}%, ${
|
||||
colors.logoBlue
|
||||
}50 0%, transparent 70%)`,
|
||||
transform: `translateY(${drift2}px)`,
|
||||
}}
|
||||
/>
|
||||
{/* Subtle grain-free vignette to focus the center */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(80% 80% at 50% 45%, transparent 55%, rgba(0,0,0,0.45) 100%)',
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
29
media-creator/src/components/Logo.tsx
Normal file
29
media-creator/src/components/Logo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Img, staticFile} from 'remotion';
|
||||
import {colors} from '../theme';
|
||||
|
||||
// The Compass rose (public/logo.svg is a copy of web/public/favicon.svg),
|
||||
// framed in a soft cream disc so it reads on the dark background.
|
||||
export const Logo: React.FC<{size: number}> = ({size}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: colors.creamGlow,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: `0 30px 90px ${colors.amberDeep}55, 0 0 0 ${
|
||||
size * 0.03
|
||||
}px ${colors.amber}33`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile('logo.svg')}
|
||||
style={{width: size, height: size}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
media-creator/src/index.ts
Normal file
5
media-creator/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Remotion entry point. Registered compositions live in ./Root.
|
||||
import {registerRoot} from 'remotion';
|
||||
import {RemotionRoot} from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
374
media-creator/src/scenes/Intro.tsx
Normal file
374
media-creator/src/scenes/Intro.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import React from 'react';
|
||||
import {AbsoluteFill, Sequence, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
import {Background} from '../components/Background';
|
||||
import {Logo} from '../components/Logo';
|
||||
import {FadeUp, useSceneFade} from '../components/Animations';
|
||||
import {colors, DESIGN_HEIGHT, fonts} from '../theme';
|
||||
|
||||
// ─── Scene schedule (frames @ 30fps) ────────────────────────────────────────
|
||||
const S = {
|
||||
logo: {from: 0, dur: 100},
|
||||
hook: {from: 95, dur: 120},
|
||||
what: {from: 210, dur: 135},
|
||||
features: {from: 340, dur: 185},
|
||||
vision: {from: 520, dur: 105},
|
||||
cta: {from: 620, dur: 130},
|
||||
};
|
||||
export const INTRO_DURATION = S.cta.from + S.cta.dur; // 750 frames ≈ 25s
|
||||
|
||||
// Wraps a scene so its whole content cross-fades at the edges — no hard pops.
|
||||
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}) => (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: colors.amberBright,
|
||||
fontSize: 30,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 6,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Scene 1 — logo + wordmark ──────────────────────────────────────────────
|
||||
const LogoScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
const pop = spring({frame, fps, config: {damping: 12, mass: 0.8}});
|
||||
const scale = interpolate(pop, [0, 1], [0.6, 1]);
|
||||
const spin = interpolate(pop, [0, 1], [-25, 0]);
|
||||
|
||||
return (
|
||||
<Scene dur={S.logo.dur}>
|
||||
<div style={{transform: `scale(${scale}) rotate(${spin}deg)`}}>
|
||||
<Logo size={340} />
|
||||
</div>
|
||||
<FadeUp delay={16} style={{marginTop: 60}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: colors.cream,
|
||||
fontSize: 118,
|
||||
fontWeight: 800,
|
||||
letterSpacing: 10,
|
||||
}}
|
||||
>
|
||||
COMPASS
|
||||
</div>
|
||||
</FadeUp>
|
||||
<FadeUp delay={28}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.serif,
|
||||
color: colors.amberBright,
|
||||
fontSize: 46,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
Find your people.
|
||||
</div>
|
||||
</FadeUp>
|
||||
</Scene>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Scene 2 — the hook ─────────────────────────────────────────────────────
|
||||
const HookScene: React.FC = () => (
|
||||
<Scene dur={S.hook.dur}>
|
||||
<FadeUp>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: colors.cream,
|
||||
fontSize: 82,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.12,
|
||||
}}
|
||||
>
|
||||
Tired of endless{' '}
|
||||
<span style={{color: colors.amberLight}}>swiping</span>,{' '}
|
||||
<span style={{color: colors.amberLight}}>ads</span>, and{' '}
|
||||
<span style={{color: colors.amberLight}}>algorithms</span>?
|
||||
</div>
|
||||
</FadeUp>
|
||||
<FadeUp delay={26}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.serif,
|
||||
color: colors.amberBright,
|
||||
fontSize: 44,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 44,
|
||||
}}
|
||||
>
|
||||
There's a better way to connect.
|
||||
</div>
|
||||
</FadeUp>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
// ─── Scene 3 — what Compass is ──────────────────────────────────────────────
|
||||
const WhatScene: React.FC = () => (
|
||||
<Scene dur={S.what.dur}>
|
||||
<FadeUp>
|
||||
<Eyebrow>Meet Compass</Eyebrow>
|
||||
</FadeUp>
|
||||
<FadeUp delay={14} style={{marginTop: 34}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: colors.cream,
|
||||
fontSize: 74,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.18,
|
||||
}}
|
||||
>
|
||||
A free, open platform for{' '}
|
||||
<span style={{color: colors.amberLight}}>deep, authentic</span>{' '}
|
||||
1-on-1 connections.
|
||||
</div>
|
||||
</FadeUp>
|
||||
<FadeUp delay={30} style={{marginTop: 40}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: '#C9C0B4',
|
||||
fontSize: 40,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Built around your values, interests, and ideas —
|
||||
<br />
|
||||
not how you look.
|
||||
</div>
|
||||
</FadeUp>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
// ─── Scene 4 — features ─────────────────────────────────────────────────────
|
||||
const FEATURES: {title: string; text: string}[] = [
|
||||
{title: 'Keyword search', text: 'Find people by what they love — Stoicism to indie film.'},
|
||||
{title: 'Smart notifications', text: 'We ping you when someone who fits joins.'},
|
||||
{title: 'Depth over swipes', text: 'Values and ideas first. Photos stay secondary.'},
|
||||
{title: 'Always free', text: 'No ads. No subscriptions. Your data is never sold.'},
|
||||
{title: 'Open & democratic', text: 'Community-owned, open source, run by a public constitution.'},
|
||||
];
|
||||
|
||||
// The five feature cards are the tallest scene, so they compact on the shorter
|
||||
// 4:5 post canvas to keep comfortable breathing room top and bottom.
|
||||
const useFeatureSizing = () => {
|
||||
const {height} = useVideoConfig();
|
||||
const compact = height < DESIGN_HEIGHT; // true for the 4:5 post format
|
||||
return {
|
||||
compact,
|
||||
cardPad: compact ? '22px 32px' : '30px 36px',
|
||||
cardRadius: compact ? 22 : 26,
|
||||
titleSize: compact ? 40 : 46,
|
||||
textSize: compact ? 30 : 34,
|
||||
rowGap: compact ? 16 : 22,
|
||||
headerGap: compact ? 28 : 40,
|
||||
};
|
||||
};
|
||||
|
||||
const FeatureRow: React.FC<{title: string; text: string; delay: number}> = ({
|
||||
title,
|
||||
text,
|
||||
delay,
|
||||
}) => {
|
||||
const s = useFeatureSizing();
|
||||
return (
|
||||
<FadeUp delay={delay} distance={40}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
gap: 28,
|
||||
background: 'rgba(247,244,239,0.05)',
|
||||
border: '1.5px solid rgba(220,171,113,0.22)',
|
||||
borderRadius: s.cardRadius,
|
||||
padding: s.cardPad,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
borderRadius: 8,
|
||||
background: `linear-gradient(${colors.amberBright}, ${colors.amberDeep})`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: colors.cream,
|
||||
fontSize: s.titleSize,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: '#C1B8AB',
|
||||
fontSize: s.textSize,
|
||||
fontWeight: 500,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeUp>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesScene: React.FC = () => {
|
||||
const s = useFeatureSizing();
|
||||
return (
|
||||
<Scene dur={S.features.dur}>
|
||||
<div style={{width: '100%'}}>
|
||||
<FadeUp style={{marginBottom: s.headerGap}}>
|
||||
<Eyebrow>What makes us different</Eyebrow>
|
||||
</FadeUp>
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: s.rowGap}}>
|
||||
{FEATURES.map((f, i) => (
|
||||
<FeatureRow key={f.title} title={f.title} text={f.text} delay={10 + i * 12} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Scene>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Scene 5 — vision ───────────────────────────────────────────────────────
|
||||
const VisionScene: React.FC = () => (
|
||||
<Scene dur={S.vision.dur}>
|
||||
<FadeUp>
|
||||
<Eyebrow>The vision</Eyebrow>
|
||||
</FadeUp>
|
||||
<FadeUp delay={16} style={{marginTop: 34}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.serif,
|
||||
color: colors.cream,
|
||||
fontSize: 66,
|
||||
lineHeight: 1.28,
|
||||
}}
|
||||
>
|
||||
What{' '}
|
||||
<span style={{color: colors.amberLight, fontWeight: 700}}>Linux</span> is
|
||||
to software and{' '}
|
||||
<span style={{color: colors.amberLight, fontWeight: 700}}>Wikipedia</span>{' '}
|
||||
is to knowledge —
|
||||
<br />
|
||||
Compass is to{' '}
|
||||
<span style={{color: colors.amberBright, fontStyle: 'italic'}}>
|
||||
human connection.
|
||||
</span>
|
||||
</div>
|
||||
</FadeUp>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
// ─── Scene 6 — call to action ───────────────────────────────────────────────
|
||||
const CtaScene: React.FC = () => (
|
||||
<Scene dur={S.cta.dur}>
|
||||
<FadeUp>
|
||||
<Logo size={200} />
|
||||
</FadeUp>
|
||||
<FadeUp delay={12} style={{marginTop: 46}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: colors.cream,
|
||||
fontSize: 84,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Join free today
|
||||
</div>
|
||||
</FadeUp>
|
||||
<FadeUp delay={22} style={{marginTop: 26}}>
|
||||
<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: 40}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.display,
|
||||
color: colors.amberBright,
|
||||
fontSize: 32,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 3,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Open-source · Ad-free · Community-owned
|
||||
</div>
|
||||
</FadeUp>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
// ─── Composition ────────────────────────────────────────────────────────────
|
||||
export const Intro: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: colors.ink}}>
|
||||
<Background />
|
||||
<Sequence from={S.logo.from} durationInFrames={S.logo.dur}>
|
||||
<LogoScene />
|
||||
</Sequence>
|
||||
<Sequence from={S.hook.from} durationInFrames={S.hook.dur}>
|
||||
<HookScene />
|
||||
</Sequence>
|
||||
<Sequence from={S.what.from} durationInFrames={S.what.dur}>
|
||||
<WhatScene />
|
||||
</Sequence>
|
||||
<Sequence from={S.features.from} durationInFrames={S.features.dur}>
|
||||
<FeaturesScene />
|
||||
</Sequence>
|
||||
<Sequence from={S.vision.from} durationInFrames={S.vision.dur}>
|
||||
<VisionScene />
|
||||
</Sequence>
|
||||
<Sequence from={S.cta.from} durationInFrames={S.cta.dur}>
|
||||
<CtaScene />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
39
media-creator/src/theme.ts
Normal file
39
media-creator/src/theme.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Compass brand tokens, mirrored from web/styles/globals.css and the logo artwork.
|
||||
// Keep these in sync if the web palette changes.
|
||||
export const colors = {
|
||||
// Warm neutrals
|
||||
cream: '#F7F4EF', // canvas-50 — cards / light surfaces
|
||||
creamGlow: '#FAF3E9', // primary-50 — subtle glow tint
|
||||
ink: '#1E1A14', // ink-900 — deep warm black
|
||||
espresso: '#2C2416', // canvas-950 — dark espresso
|
||||
espressoDeep: '#0F0D0A', // deepest depth
|
||||
|
||||
// Primary amber ramp (primary-500 base)
|
||||
amber: '#C17F3E',
|
||||
amberLight: '#D09352', // primary-400
|
||||
amberBright: '#DCAB71', // primary-300
|
||||
amberDeep: '#A6682E', // primary-600
|
||||
|
||||
// Accents pulled straight from the compass logo
|
||||
logoBlue: '#1D384B',
|
||||
logoRed: '#E94734',
|
||||
} as const;
|
||||
|
||||
// System-font stack only — no network fetch at render time, so renders are reproducible offline.
|
||||
export const fonts = {
|
||||
display:
|
||||
'"Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif',
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
} as const;
|
||||
|
||||
// Output formats. Same scenes, different canvas — layouts adapt via useVideoConfig().
|
||||
// - post: 4:5 portrait, the tallest ratio Instagram allows in-feed (primary).
|
||||
// - story: 9:16 vertical, full-screen Stories / Reels (secondary).
|
||||
export const FORMATS = {
|
||||
post: {width: 1080, height: 1350, fps: 30},
|
||||
story: {width: 1080, height: 1920, fps: 30},
|
||||
} as const;
|
||||
|
||||
// Design reference height (the story format). Scenes are tuned against this;
|
||||
// the shorter post format compacts where needed.
|
||||
export const DESIGN_HEIGHT = FORMATS.story.height;
|
||||
17
media-creator/tsconfig.json
Normal file
17
media-creator/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src", "remotion.config.ts"]
|
||||
}
|
||||
71
web/components/media-modal.tsx
Normal file
71
web/components/media-modal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {Dialog, Transition} from '@headlessui/react'
|
||||
import {XMarkIcon} from '@heroicons/react/24/outline'
|
||||
import Image from 'next/image'
|
||||
import {Fragment} from 'react'
|
||||
import {isVideo} from 'web/lib/firebase/storage'
|
||||
|
||||
// Enlarges an image or video to the largest size that fits within 90% of the
|
||||
// viewport in both dimensions, without cropping (aspect ratio preserved).
|
||||
export function MediaModal(props: {url: string; open: boolean; setOpen: (open: boolean) => void}) {
|
||||
const {url, open, setOpen} = props
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog className="relative z-50" onClose={() => setOpen(false)}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-linear duration-150"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-linear duration-75"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
{/* background cover */}
|
||||
<div className="bg-canvas-100/75 fixed inset-0" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* close button, fixed so it stays visible whatever the media size */}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-ink-700 hover:text-primary-400 focus:text-primary-400 fixed right-4 top-4 z-10 cursor-pointer outline-none"
|
||||
>
|
||||
<XMarkIcon className="h-8 w-8" />
|
||||
<div className="sr-only">Close</div>
|
||||
</button>
|
||||
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-150"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex items-center justify-center">
|
||||
{isVideo(url) ? (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="h-auto max-h-[90vh] w-auto max-w-[90vw] rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={url}
|
||||
width={2000}
|
||||
height={2000}
|
||||
alt=""
|
||||
className="h-auto max-h-[90vh] w-auto max-w-[90vw] rounded object-contain"
|
||||
/>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user