Add media creator

This commit is contained in:
MartinBraquet
2026-07-13 13:40:01 +02:00
parent e2f49c6090
commit c4074a9d4e
15 changed files with 3463 additions and 0 deletions

11
media-creator/.gitignore vendored Normal file
View 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
View 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
View File

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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);

View 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}/`);

View 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}
/>
</>
);
};

View 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'},
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,5 @@
// Remotion entry point. Registered compositions live in ./Root.
import {registerRoot} from 'remotion';
import {RemotionRoot} from './Root';
registerRoot(RemotionRoot);

View 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>
);
};

View 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;

View 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"]
}

View 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>
)
}