AI Video Editor Landing New
A white, editorial landing page for an AI video editor: a working editor in the hero (transcript, dithered player, AI Director, multitrack timeline), live bento demos, dithered art, pricing and FAQ.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/landing-video-editor-landing.json02
Or copy the source below (11 files). Dependencies: lucide-react.
Source
components/niceui/pages/landing/LandingPageRushes.tsx
import { Bento } from './rushes/Bento';
import { Cta, Faq, Pricing, RushesFooter, Stats, Testimonials, Workflow } from './rushes/Closing';
import { RushesHero, RushesNav } from './rushes/Hero';
import { ScriptSection } from './rushes/Script';
const keyframes = `
@keyframes rushes-sweep { from { left: 0 } to { left: 100% } }
`;
export function LandingPageRushes() {
return (
<div className="relative min-h-screen overflow-x-clip bg-white font-sans text-zinc-950 antialiased [color-scheme:light] selection:bg-[#ff4f1f]/20 selection:text-zinc-950">
<style>{keyframes}</style>
<div aria-hidden className="pointer-events-none absolute inset-y-0 left-1/2 hidden w-full max-w-7xl -translate-x-1/2 border-x border-zinc-100 xl:block" />
<div className="relative">
<RushesNav />
<RushesHero />
<ScriptSection />
<Bento />
<Workflow />
<Stats />
<Testimonials />
<Pricing />
<Faq />
<Cta />
<RushesFooter />
</div>
</div>
);
}components/niceui/pages/landing/rushes/Bento.tsx
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
import { AudioLines, Check, Crop, Languages, MessageSquare, MousePointer2, Scissors, Sparkles, WandSparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Reveal } from '@/components/niceui/pages/landing/shared/motion';
import { ACC, DitherCanvas, PAPER, SCREEN, clamp, ditherStill, hash, scenes, type Field, type SceneId } from './dither';
import { SectionHead, Serif } from './ui';
/* ——— shared bits ——— */
function useVisible<T extends Element>(): [RefObject<T>, boolean] {
const ref = useRef<T>(null);
const [v, setV] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setV(e.isIntersecting), { threshold: 0.15 });
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, v];
}
const prefersReduced = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Seconds since mount, ticking at `fps` only while `active`. */
function useTicker(active: boolean, fps = 12) {
const [t, setT] = useState(0);
const acc = useRef(0);
useEffect(() => {
if (!active || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const start = performance.now() - acc.current * 1000;
const id = window.setInterval(() => {
acc.current = (performance.now() - start) / 1000;
setT(acc.current);
}, 1000 / fps);
return () => clearInterval(id);
}, [active, fps]);
return t;
}
/** Wall clock in seconds since mount; frozen at `still` for visitors who prefer reduced motion. */
function useClockRef(still: number) {
const t0 = useRef(performance.now());
return useMemo(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return { now: () => still, live: false };
return { now: () => (performance.now() - t0.current) / 1000, live: true };
}, [still]);
}
function Card({ title, body, icon: Icon, className, children, delay = 0 }: { title: string; body: string; icon: typeof Scissors; className?: string; children: ReactNode; delay?: number }) {
return (
<Reveal delay={delay} className={cn('h-full', className)}>
<div className="group flex h-full flex-col overflow-hidden rounded-[28px] border border-zinc-200/80 bg-white p-2 shadow-[0_1px_2px_rgba(0,0,0,0.03)] transition-shadow duration-500 hover:shadow-[0_30px_60px_-30px_rgba(24,24,27,0.25)]">
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[22px] border border-zinc-100 bg-zinc-50/70">{children}</div>
<div className="flex gap-3 px-4 pb-4 pt-4">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-zinc-950 text-white">
<Icon className="h-3.5 w-3.5" />
</span>
<div>
<h3 className="text-[17px] font-semibold tracking-[-0.02em] text-zinc-950">{title}</h3>
<p className="mt-1 text-[14px] leading-relaxed text-zinc-500">{body}</p>
</div>
</div>
</div>
</Reveal>
);
}
function Pill({ on, children, onClick }: { on: boolean; children: ReactNode; onClick: () => void }) {
return (
<button onClick={onClick} className={cn('rounded-full px-2.5 py-1 text-[11.5px] font-medium transition-all active:scale-95', on ? 'bg-zinc-950 text-white' : 'bg-white text-zinc-600 ring-1 ring-zinc-200 hover:text-zinc-950')}>
{children}
</button>
);
}
const SRC = 16 / 9;
const crop = (scene: SceneId, focus: number): Field => (u, v, t, ar) => {
const w = Math.min(1, ar / SRC);
return scenes[scene](Math.min(1 - w, Math.max(0, focus - w / 2)) + u * w, v, t, SRC);
};
/* ——— 1 · Generative B-roll ——— */
const PROMPTS: { text: string; scene: SceneId }[] = [
{ text: 'Aerial coastline at dawn, slow push in', scene: 'coast' },
{ text: 'Neon city at night, drifting pan left', scene: 'city' },
{ text: 'Misty peaks, sun breaking through', scene: 'peaks' },
];
const CYCLE = 6.5;
function BrollVisual() {
const [ref, visible] = useVisible<HTMLDivElement>();
const { now, live } = useClockRef(3.6);
useTicker(visible, 15);
const t = now();
const k = Math.floor(t / CYCLE) % PROMPTS.length;
const local = t % CYCLE;
const typed = PROMPTS[k].text.slice(0, Math.floor(clamp(local / 1.1) * PROMPTS[k].text.length));
const p = clamp((local - 1.1) / 2.2);
const field = useMemo<Field>(
() => (u, v, time, ar) => {
const i = Math.floor(time / CYCLE) % PROMPTS.length;
const q = clamp(((time % CYCLE) - 1.1) / 2.2);
const s = scenes[PROMPTS[i].scene](u, v, time, ar);
if (q >= 1) return s;
const acc = s >= ACC;
const f = Math.floor(time * 14);
const nz = hash(Math.floor(u * 509) + f * 37, Math.floor(v * 283) + f * 11);
const m = (acc ? s - ACC : s) * q + nz * (1 - q) * 0.8;
return acc && q > 0.7 ? ACC + m : m;
},
[],
);
return (
<div ref={ref} className="grid h-full gap-4 p-4 sm:p-5 md:grid-cols-[0.9fr_1.1fr] md:items-center">
<div className="space-y-3">
<div className="rounded-2xl border border-zinc-200 bg-white p-3 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="flex items-center gap-1.5 text-[11px] font-medium text-zinc-400">
<WandSparkles className="h-3.5 w-3.5 text-[#ff4f1f]" /> Generate B-roll
</p>
<p className="mt-2 min-h-[2.8em] text-[15px] leading-snug text-zinc-900">
{typed}
<span className="ml-px inline-block h-[1.05em] w-[2px] translate-y-[3px] animate-blink bg-[#ff4f1f]" />
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{['4s · 24 fps', 'Matched grade', 'Royalty-free'].map((c) => (
<span key={c} className="rounded-full bg-white px-2.5 py-1 font-mono text-[10.5px] text-zinc-500 ring-1 ring-zinc-200">
{c}
</span>
))}
</div>
</div>
<div className="relative aspect-video overflow-hidden rounded-xl bg-[#0d0d0e] shadow-[0_16px_40px_-16px_rgba(0,0,0,0.6)]">
<DitherCanvas field={field} time={live ? now : undefined} still={3.6} pixel={2} ink={PAPER} bg={SCREEN} fps={24} className="absolute inset-0" />
<span className="absolute left-2.5 top-2.5 rounded-md bg-black/60 px-1.5 py-0.5 font-mono text-[9.5px] text-white/80 backdrop-blur">
{p < 1 ? `DENOISING · STEP ${Math.max(1, Math.round(p * 24))}/24` : 'GEN_0' + (k + 1) + ' · READY'}
</span>
<span className={cn('absolute bottom-2.5 right-2.5 flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[9.5px] transition-all', p >= 1 ? 'bg-[#ff4f1f] text-white' : 'bg-black/60 text-white/60')}>
{p >= 1 ? <Check className="h-2.5 w-2.5" /> : <Sparkles className="h-2.5 w-2.5" />} {p >= 1 ? 'Added to V2' : `${Math.round(p * 100)}%`}
</span>
</div>
</div>
);
}
/* ——— 2 · Studio Sound ——— */
function StudioVisual() {
const [ref, visible] = useVisible<HTMLDivElement>();
const t = useTicker(visible, 14);
const [manual, setManual] = useState<boolean | null>(null);
const on = manual ?? Math.floor(t / 3.4) % 2 === 1;
const N = 44;
return (
<div ref={ref} className="flex h-full flex-col justify-between gap-4 p-5">
<div className="flex items-center justify-between">
<span className="font-mono text-[10.5px] uppercase tracking-wider text-zinc-400">A1 · Dialogue</span>
<button role="switch" aria-checked={on} aria-label="Studio Sound" onClick={() => setManual(!on)} className="flex items-center gap-2 text-[12px] font-medium text-zinc-700">
Studio Sound
<span className={cn('relative h-5 w-9 rounded-full transition-colors duration-300', on ? 'bg-[#ff4f1f]' : 'bg-zinc-300')}>
<span className={cn('absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all duration-300 ease-spring', on ? 'left-[18px]' : 'left-0.5')} />
</span>
</button>
</div>
<div className="relative flex h-24 items-center justify-between">
<span className={cn('absolute inset-x-0 top-1/2 h-7 -translate-y-1/2 rounded-md transition-opacity duration-500', on ? 'opacity-0' : 'opacity-100')} style={{ background: 'repeating-linear-gradient(90deg, rgba(255,79,31,0.12) 0 2px, transparent 2px 4px)' }} />
{Array.from({ length: N }, (_, i) => {
const x = i * 0.19 + t * 2.6;
const speech = Math.max(0, Math.sin(x) * 0.5 + 0.5) * Math.max(0, Math.sin(x * 0.41 + 1.3) * 0.5 + 0.5);
const r = hash(i * 5 + Math.floor(t * 14), 17);
const v = on ? speech * (0.7 + r * 0.3) : Math.min(1, speech * (0.6 + r * 0.4) + 0.16 + r * 0.16);
return <span key={i} className={cn('relative w-[3px] rounded-full transition-[height] duration-150', on ? 'bg-zinc-900' : 'bg-zinc-500')} style={{ height: `${Math.max(4, v * 100)}%` }} />;
})}
</div>
<div className="grid grid-cols-2 gap-2">
{[
['Noise floor', on ? '−74 dB' : '−31 dB'],
['Voice clarity', on ? '98%' : '61%'],
].map(([k, v]) => (
<div key={k} className="rounded-xl bg-white px-3 py-2 ring-1 ring-zinc-200">
<p className="text-[10.5px] text-zinc-400">{k}</p>
<p key={v} className={cn('font-mono text-[15px] tabular-nums animate-fade-in', on ? 'text-zinc-950' : 'text-zinc-500')}>{v}</p>
</div>
))}
</div>
</div>
);
}
/* ——— 3 · Captions ——— */
const CAP_STYLES = ['Karaoke', 'Pop', 'Serif', 'Minimal'] as const;
type CapStyle = (typeof CAP_STYLES)[number];
const CAP_LINES = [
['The', 'light'],
['never', 'waits'],
['for', 'you.'],
];
function CaptionsVisual() {
const [ref, visible] = useVisible<HTMLDivElement>();
const t = useTicker(visible, 10);
const [manual, setManual] = useState<CapStyle | null>(null);
const style = manual ?? CAP_STYLES[Math.floor(t / 4) % CAP_STYLES.length];
const step = Math.floor(t / 0.42);
const line = CAP_LINES[Math.floor(step / 2) % CAP_LINES.length];
const cur = step % 2;
const field = useMemo(() => crop('portrait', 0.57), []);
return (
<div ref={ref} className="flex h-full items-center justify-center gap-4 p-5">
<div className="relative aspect-[9/16] h-[232px] shrink-0 overflow-hidden rounded-[18px] bg-[#0d0d0e] ring-4 ring-zinc-950 shadow-[0_18px_40px_-16px_rgba(0,0,0,0.6)]">
<DitherCanvas field={field} pixel={2} ink={PAPER} bg={SCREEN} fps={20} className="absolute inset-0" />
<span className="absolute left-1/2 top-1.5 h-1 w-8 -translate-x-1/2 rounded-full bg-white/20" />
<div className="absolute inset-x-2 bottom-[18%] flex justify-center">
<p key={`${style}-${line[0]}`} className="flex gap-1 text-center animate-scale-in">
{line.map((w, i) => (
<span
key={w}
className={cn(
'rounded-[4px] px-1 transition-all duration-150',
style === 'Karaoke' && cn('text-[15px] font-semibold text-white [text-shadow:0_1px_2px_#000]', i === cur && 'bg-[#ff4f1f] [text-shadow:none]'),
style === 'Pop' && cn('text-[17px] font-black uppercase tracking-tight [text-shadow:0_2px_0_#000,0_0_8px_rgba(0,0,0,0.8)]', i === cur ? 'scale-110 text-[#ff4f1f]' : 'text-white'),
style === 'Serif' && cn('font-serif text-[21px] italic text-white', i === cur ? 'opacity-100' : 'opacity-50'),
style === 'Minimal' && cn('text-[12px] font-medium lowercase text-white/90', i === cur && 'underline decoration-[#ff4f1f] decoration-2 underline-offset-4'),
)}
>
{w}
</span>
))}
</p>
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="mb-1 font-mono text-[10px] uppercase tracking-wider text-zinc-400">Style</span>
{CAP_STYLES.map((s) => (
<Pill key={s} on={style === s} onClick={() => setManual(s)}>
{s}
</Pill>
))}
</div>
</div>
);
}
/* ——— 4 · Smart reframe ——— */
const REFRAMES = { '9:16': 9 / 16, '1:1': 1, '4:5': 4 / 5 } as const;
type Reframe = keyof typeof REFRAMES;
const subjectShift = (t: number) => Math.sin(t * 0.6) * 0.17;
function ReframeVisual() {
const [ratio, setRatio] = useState<Reframe>('9:16');
const { now, live } = useClockRef(2);
const box = useRef<HTMLDivElement>(null);
const field = useMemo<Field>(() => (u, v, t, ar) => scenes.portrait(u - subjectShift(t), v, t, ar), []);
const w = REFRAMES[ratio] / SRC;
useEffect(() => {
const place = () => {
const c = 0.5 + 0.12 / SRC + subjectShift(now());
if (box.current) box.current.style.left = `${Math.min(1 - w, Math.max(0, c - w / 2)) * 100}%`;
};
place();
if (!live) return;
let raf = 0;
const loop = () => {
place();
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [now, live, w]);
return (
<div className="flex h-full flex-col justify-center gap-4 p-5">
<div className="relative aspect-video overflow-hidden rounded-xl bg-[#0d0d0e] shadow-[0_16px_40px_-16px_rgba(0,0,0,0.6)]">
<DitherCanvas field={field} time={live ? now : undefined} still={2} pixel={2} ink={PAPER} bg={SCREEN} fps={24} className="absolute inset-0" />
<div ref={box} className="absolute inset-y-0 transition-[width] duration-500 ease-smooth" style={{ width: `${w * 100}%`, boxShadow: '0 0 0 999px rgba(9,9,11,0.62)' }}>
<span className="absolute inset-0 border border-white/80" />
{['left-0 top-0 border-l-2 border-t-2', 'right-0 top-0 border-r-2 border-t-2', 'left-0 bottom-0 border-b-2 border-l-2', 'right-0 bottom-0 border-b-2 border-r-2'].map((c) => (
<span key={c} className={cn('absolute h-2.5 w-2.5 border-[#ff4f1f]', c)} />
))}
<span className="absolute left-1/2 top-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-[#ff4f1f] px-1 font-mono text-[8.5px] font-medium text-white">{ratio} · TRACKING</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-1.5">
{(Object.keys(REFRAMES) as Reframe[]).map((r) => (
<Pill key={r} on={ratio === r} onClick={() => setRatio(r)}>
{r}
</Pill>
))}
</div>
<span className="flex items-center gap-1 font-mono text-[10.5px] text-zinc-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" /> 98% in frame
</span>
</div>
</div>
);
}
/* ——— 5 · Dubbing ——— */
const LANGS = [
['EN', 'English', 'The light never waits for you.'],
['ES', 'Español', 'La luz nunca te espera.'],
['JA', '日本語', '光は、決して待ってくれない。'],
['DE', 'Deutsch', 'Das Licht wartet nie auf dich.'],
['HI', 'हिन्दी', 'रोशनी कभी तुम्हारा इंतज़ार नहीं करती।'],
['FR', 'Français', 'La lumière ne t’attend jamais.'],
] as const;
function DubVisual() {
const [ref, visible] = useVisible<HTMLDivElement>();
const t = useTicker(visible, 8);
const [manual, setManual] = useState<number | null>(null);
const k = manual ?? Math.floor(t / 2.6) % LANGS.length;
const [code, name, line] = LANGS[k];
return (
<div ref={ref} className="flex h-full flex-col justify-between gap-4 p-5">
<div className="flex flex-wrap gap-1">
{LANGS.map(([c], i) => (
<Pill key={c} on={i === k} onClick={() => setManual(i)}>
{c}
</Pill>
))}
</div>
<div key={code} className="animate-fade-up">
<p className="font-mono text-[10.5px] uppercase tracking-wider text-zinc-400">{name} · Maya’s voice</p>
<p className="mt-1.5 min-h-[2.6em] text-[19px] font-medium leading-snug tracking-[-0.01em] text-zinc-950">“{line}”</p>
<div className="mt-3 flex h-9 items-center gap-[3px]">
{Array.from({ length: 40 }, (_, i) => {
const r = hash(i * 3 + k * 41, k * 7);
const env = Math.sin((i / 39) * Math.PI);
return <span key={i} className="w-[3px] shrink-0 rounded-full bg-zinc-900 animate-fade-in" style={{ height: `${(0.15 + env * r * 0.85) * 100}%`, animationDelay: `${i * 12}ms` }} />;
})}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{['Voice clone', 'Lip-sync', 'Timing kept'].map((c) => (
<span key={c} className="flex items-center gap-1 rounded-full bg-white px-2 py-1 text-[11px] text-zinc-600 ring-1 ring-zinc-200">
<Check className="h-3 w-3 text-[#ff4f1f]" /> {c}
</span>
))}
</div>
</div>
);
}
/* ——— 6 · Clips ——— */
const MOMENTS: { at: number; title: string; score: number; scene: SceneId; len: string }[] = [
{ at: 0.2, title: 'Why the light never waits', score: 96, scene: 'coast', len: '0:42' },
{ at: 0.52, title: 'The night I almost quit', score: 91, scene: 'city', len: '0:37' },
{ at: 0.8, title: 'Editing in my sleep', score: 88, scene: 'portrait', len: '0:51' },
];
const STRIP: SceneId[] = ['portrait', 'coast', 'portrait', 'city', 'portrait', 'reel', 'peaks', 'portrait', 'coast', 'city', 'portrait', 'peaks'];
function ClipsVisual() {
const [ref, visible] = useVisible<HTMLDivElement>();
const t = useTicker(visible, 20);
const local = prefersReduced() ? 5 : t % 8;
const scan = clamp(local / 4.6);
const fading = local > 7.3;
return (
<div ref={ref} className="flex h-full flex-col gap-5 p-4 sm:p-5">
<div>
<div className="mb-2 flex items-center justify-between font-mono text-[10.5px] text-zinc-400">
<span>PODCAST_EP42.MP4</span>
<span>58:12 · 1 video in</span>
</div>
<div className="relative h-12 overflow-hidden rounded-lg ring-1 ring-zinc-200">
<div className="absolute inset-0 flex">
{STRIP.map((s, i) => (
<div key={i} className="h-full flex-1 [image-rendering:pixelated]" style={{ backgroundImage: `url(${ditherStill(`strip-${s}`, scenes[s], 64, 36, 3)})`, backgroundSize: 'auto 100%' }} />
))}
</div>
<div className="absolute inset-y-0 left-0 bg-white/55 transition-none" style={{ left: `${scan * 100}%`, right: 0 }} />
{MOMENTS.map((m) => (
<span key={m.at} className={cn('absolute inset-y-0 w-[7%] border-x-2 border-[#ff4f1f] bg-[#ff4f1f]/25 transition-opacity duration-300', scan >= m.at && !fading ? 'opacity-100' : 'opacity-0')} style={{ left: `${(m.at - 0.035) * 100}%` }} />
))}
<span className="absolute inset-y-0 w-0.5 bg-[#ff4f1f] shadow-[0_0_12px_2px_rgba(255,79,31,0.6)]" style={{ left: `${scan * 100}%`, opacity: scan < 1 ? 1 : 0 }} />
</div>
</div>
<div className="grid flex-1 grid-cols-3 gap-2 sm:gap-3">
{MOMENTS.map((m) => {
const on = scan >= m.at && !fading;
return (
<div key={m.title} className={cn('flex flex-col gap-2.5 rounded-2xl border p-2 transition-all duration-500 sm:flex-row sm:p-2.5', on ? 'border-zinc-200 bg-white shadow-[0_12px_24px_-16px_rgba(0,0,0,0.3)]' : 'border-dashed border-zinc-300 bg-transparent')}>
<div className="relative aspect-[9/16] w-full shrink-0 overflow-hidden rounded-lg bg-zinc-100 sm:w-[88px] sm:self-center">
<div className={cn('absolute inset-0 transition-all duration-500 ease-smooth [image-rendering:pixelated]', on ? 'scale-100 opacity-100' : 'scale-110 opacity-0')} style={{ backgroundImage: `url(${ditherStill(`v-${m.scene}`, crop(m.scene, m.scene === 'portrait' ? 0.57 : 0.62), 54, 96, 3)})`, backgroundSize: 'cover' }} />
{on ? <span className="absolute bottom-1 right-1 rounded bg-black/70 px-1 font-mono text-[8px] text-white">{m.len}</span> : <span className="absolute inset-0 grid place-items-center font-mono text-[9px] text-zinc-400">9:16</span>}
</div>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2">
{on ? (
<p className="text-[12px] font-medium leading-snug text-zinc-900 animate-fade-in sm:text-[13px]">{m.title}</p>
) : (
<span className="space-y-1.5 pt-1">
<span className="block h-2 w-full rounded-full bg-zinc-200/80" />
<span className="block h-2 w-2/3 rounded-full bg-zinc-200/80" />
</span>
)}
<span className="flex items-center gap-1.5">
<span className="relative grid h-8 w-8 place-items-center">
<svg viewBox="0 0 36 36" className="absolute inset-0 -rotate-90">
<circle cx="18" cy="18" r="15" fill="none" stroke="#e4e4e7" strokeWidth="3.5" />
<circle cx="18" cy="18" r="15" fill="none" stroke="#ff4f1f" strokeWidth="3.5" strokeLinecap="round" strokeDasharray={`${(on ? m.score / 100 : 0) * 94.2} 94.2`} className="transition-[stroke-dasharray] duration-1000 ease-smooth" />
</svg>
<span className="font-mono text-[9.5px] font-semibold text-zinc-900">{on ? m.score : '–'}</span>
</span>
<span className="hidden text-[10.5px] leading-tight text-zinc-400 sm:inline">hook
<br />
score</span>
</span>
</div>
</div>
);
})}
</div>
</div>
);
}
/* ——— 7 · Review ——— */
function ReviewVisual() {
const [ref, visible] = useVisible<HTMLDivElement>();
const t = useTicker(visible, 20);
const local = prefersReduced() ? 4.2 : t % 7;
const move = clamp((local - 0.4) / 1.4);
const ease = 1 - Math.pow(1 - move, 3);
const pinned = local > 1.9;
const replied = local > 3.6;
const out = local > 6.5;
const city = useMemo(() => ditherStill('still-city', scenes.city, 160, 90, 4), []);
return (
<div ref={ref} className="relative flex h-full flex-col justify-center gap-3 p-5">
<div className="relative aspect-video overflow-hidden rounded-xl bg-[#0d0d0e] [image-rendering:pixelated]" style={{ backgroundImage: `url(${city})`, backgroundSize: 'cover' }}>
<span className="absolute left-2 top-2 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[9px] text-white/80">00:00:14:06</span>
</div>
<div className="relative h-8 rounded-lg bg-white ring-1 ring-zinc-200">
<div className="absolute inset-y-2 left-2 right-2 flex gap-0.5">
{[0.22, 0.14, 0.28, 0.18, 0.18].map((f, i) => (
<span key={i} className="h-full rounded-[3px] bg-zinc-800" style={{ flex: f, opacity: 0.85 - i * 0.08 }} />
))}
</div>
<span className={cn('absolute -top-2.5 grid h-5 w-5 place-items-center rounded-full rounded-bl-none bg-[#ff4f1f] text-white shadow transition-all duration-300 ease-spring', pinned && !out ? 'scale-100 opacity-100' : 'scale-0 opacity-0')} style={{ left: '61%' }}>
<MessageSquare className="h-2.5 w-2.5" />
</span>
<span className="absolute -top-2.5 left-[24%] grid h-5 w-5 place-items-center rounded-full rounded-bl-none bg-zinc-950 text-white">
<Check className="h-2.5 w-2.5" />
</span>
</div>
<div className="relative min-h-[76px]">
<div className={cn('rounded-xl bg-white p-2.5 ring-1 ring-zinc-200 transition-all duration-300', pinned && !out ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0')}>
<p className="flex items-center gap-1.5 text-[11.5px]">
<span className="grid h-4 w-4 place-items-center rounded-full bg-[#ff4f1f] text-[8px] font-bold text-white">L</span>
<b className="font-medium text-zinc-900">Leo</b>
<span className="font-mono text-[10px] text-zinc-400">@ 14:06</span>
</p>
<p className="mt-1 text-[12.5px] text-zinc-700">Hold this shot 6 frames longer?</p>
<p className={cn('mt-1.5 flex items-center gap-1 text-[11.5px] text-zinc-500 transition-all duration-300', replied ? 'opacity-100' : 'opacity-0')}>
<Check className="h-3 w-3 text-emerald-600" /> Maya extended it · just now
</p>
</div>
</div>
<span className="pointer-events-none absolute flex items-center gap-1 transition-opacity duration-300" style={{ left: `${88 - ease * 30}%`, top: `${74 - ease * 16}%`, opacity: out ? 0 : 1 }}>
<MousePointer2 className="h-4 w-4 fill-[#ff4f1f] text-[#ff4f1f]" />
<span className="rounded-full bg-[#ff4f1f] px-1.5 py-px text-[10px] font-medium text-white">Leo</span>
</span>
</div>
);
}
/* ——— Section ——— */
export function Bento() {
return (
<section id="rushes-features" className="relative scroll-mt-16 border-t border-zinc-100 bg-[#fafaf9] py-24 md:py-32">
<div className="mx-auto max-w-7xl px-5 sm:px-6">
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
<SectionHead
tag="Everything after the shoot"
title={
<>
A post-production team,
<br />
<Serif>in one timeline.</Serif>
</>
}
/>
<p className="max-w-sm text-[16px] leading-relaxed text-zinc-500 md:pb-2">
Seven of the jobs that used to eat your week. Each card is live, so try them.
</p>
</div>
<div className="mt-14 grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="md:col-span-2" icon={WandSparkles} title="Generative B-roll" body="Describe the shot you didn’t get. Rushes generates it, matches your grade and drops it on V2.">
<BrollVisual />
</Card>
<Card icon={AudioLines} title="Studio Sound" body="Kitchen, car or conference hall, it ends up sounding like a treated booth." delay={80}>
<StudioVisual />
</Card>
<Card icon={Sparkles} title="Captions with taste" body="Word-timed captions in styles that look designed, not default." delay={40}>
<CaptionsVisual />
</Card>
<Card icon={Crop} title="Smart reframe" body="One edit, every aspect ratio. Subject tracking keeps faces in frame." delay={120}>
<ReframeVisual />
</Card>
<Card icon={Languages} title="Dub in 29 languages" body="Your voice, your timing, lip-synced. Publish to every market from one cut." delay={200}>
<DubVisual />
</Card>
<Card className="md:col-span-2" icon={Scissors} title="One video in, ten shorts out" body="Rushes scores every moment for hook, clarity and pacing, then cuts vertical clips with captions." delay={40}>
<ClipsVisual />
</Card>
<Card icon={MessageSquare} title="Frame-accurate review" body="Clients comment on the exact frame. You resolve it on the timeline." delay={120}>
<ReviewVisual />
</Card>
</div>
</div>
</section>
);
}components/niceui/pages/landing/shared/motion.tsx
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
export function useInView<T extends Element>(threshold = 0.2) {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
io.disconnect();
}
},
{ threshold },
);
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return { ref, inView };
}
export function Reveal({ children, delay = 0, className }: { children: ReactNode; delay?: number; className?: string }) {
const { ref, inView } = useInView<HTMLDivElement>(0.15);
return (
<div
ref={ref}
className={cn('transition-all duration-1000 ease-smooth', inView ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-6 opacity-0 blur-[2px]', className)}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
export function useCountUp(target: number, active: boolean, duration = 1800) {
const [val, setVal] = useState(0);
useEffect(() => {
if (!active) return;
let raf = 0;
const start = performance.now();
const loop = (now: number) => {
const t = Math.min(1, (now - start) / duration);
setVal(target * (1 - Math.pow(2, -10 * t)));
if (t < 1) raf = requestAnimationFrame(loop);
else setVal(target);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [target, active, duration]);
return val;
}components/niceui/pages/landing/rushes/dither.tsx
import { useEffect, useRef, type CSSProperties } from 'react';
import { cn } from '@/lib/cn';
/*
* Ordered (Bayer 8×8) dithering on a low-resolution canvas, scaled up with crisp pixels.
* A field maps a point (u, v in 0..1; `ar` = width / height) and time in seconds to a density in 0..1.
* Return ACC + density to dither that cell in the accent colour instead of the ink colour.
*/
export type Field = (u: number, v: number, t: number, ar: number) => number;
export const ACC = 2;
const B8 = [
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
];
const BAYER = Float32Array.from(B8, (n) => (n + 0.5) / 64);
type RGB = [number, number, number];
const rgb = (hex: string): RGB => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
export function ditherInto(img: ImageData, field: Field, t: number, ink: RGB, accent: RGB, bg: RGB | null) {
const { width: w, height: h, data } = img;
const ar = w / h;
let i = 0;
for (let y = 0; y < h; y++) {
const v = (y + 0.5) / h;
const row = (y & 7) << 3;
for (let x = 0; x < w; x++, i += 4) {
let d = field((x + 0.5) / w, v, t, ar);
let c = ink;
if (d >= ACC) {
d -= ACC;
c = accent;
}
if (d > BAYER[row | (x & 7)]) {
data[i] = c[0];
data[i + 1] = c[1];
data[i + 2] = c[2];
data[i + 3] = 255;
} else if (bg) {
data[i] = bg[0];
data[i + 1] = bg[1];
data[i + 2] = bg[2];
data[i + 3] = 255;
} else data[i + 3] = 0;
}
}
}
const stills = new Map<string, string>();
/** Renders a field once to a data URL — used for filmstrips and thumbnails. */
export function ditherStill(key: string, field: Field, w: number, h: number, t = 2, ink = PAPER, accent = ACCENT, bg: string | null = SCREEN) {
const hit = stills.get(key);
if (hit) return hit;
if (typeof document === 'undefined') return '';
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const ctx = c.getContext('2d');
if (!ctx) return '';
const img = ctx.createImageData(w, h);
ditherInto(img, field, t, rgb(ink), rgb(accent), bg ? rgb(bg) : null);
ctx.putImageData(img, 0, 0);
const url = c.toDataURL();
stills.set(key, url);
return url;
}
export const INK = '#0b0b0c';
export const ACCENT = '#ff4f1f';
export const PAPER = '#f3efe6';
export const SCREEN = '#0d0d0e';
type DitherProps = {
field: Field;
/** CSS pixels per dither cell. */
pixel?: number;
ink?: string;
accent?: string;
bg?: string;
fps?: number;
/** External clock in seconds (e.g. a playhead). Without it the canvas runs on wall time. */
time?: () => number;
/** Keep following the external clock under reduced motion, because the visitor drives it. */
follow?: boolean;
/** Time used for the static frame (reduced motion, or fps = 0). */
still?: number;
className?: string;
style?: CSSProperties;
};
export function DitherCanvas({ field, pixel = 3, ink = INK, accent = ACCENT, bg, fps = 30, time, follow = false, still = 2, className, style }: DitherProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fieldRef = useRef(field);
const timeRef = useRef(time);
const redraw = useRef<() => void>(() => {});
fieldRef.current = field;
timeRef.current = time;
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!wrap || !canvas || !ctx) return;
const inkC = rgb(ink);
const accC = rgb(accent);
const bgC = bg ? rgb(bg) : null;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const animate = fps > 0 && (!reduce || (follow && !!timeRef.current));
const start = performance.now();
let img: ImageData | null = null;
let raf = 0;
let last = 0;
let visible = false;
const now = () => (timeRef.current ? timeRef.current() : animate ? (performance.now() - start) / 1000 + still : still);
const draw = () => {
if (!img) return;
ditherInto(img, fieldRef.current, now(), inkC, accC, bgC);
ctx.putImageData(img, 0, 0);
};
const size = () => {
const r = wrap.getBoundingClientRect();
const cols = Math.max(1, Math.ceil(r.width / pixel));
const rows = Math.max(1, Math.ceil(r.height / pixel));
if (canvas.width === cols && canvas.height === rows && img) return;
canvas.width = cols;
canvas.height = rows;
canvas.style.width = `${cols * pixel}px`;
canvas.style.height = `${rows * pixel}px`;
img = ctx.createImageData(cols, rows);
draw();
};
const loop = (ts: number) => {
raf = requestAnimationFrame(loop);
if (ts - last < 1000 / fps - 2) return;
last = ts;
draw();
};
const sync = () => {
cancelAnimationFrame(raf);
if (visible && animate) raf = requestAnimationFrame(loop);
};
redraw.current = draw;
size();
const ro = new ResizeObserver(size);
ro.observe(wrap);
const io = new IntersectionObserver(
([e]) => {
visible = e.isIntersecting;
sync();
},
{ rootMargin: '120px' },
);
io.observe(wrap);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
};
}, [pixel, ink, accent, bg, fps, follow, still]);
useEffect(() => redraw.current(), [field]);
return (
<div ref={wrapRef} aria-hidden className={cn('pointer-events-none overflow-hidden', className)} style={style}>
<canvas ref={canvasRef} className="block [image-rendering:pixelated]" />
</div>
);
}
/* ——— math ——— */
export const clamp = (x: number, a = 0, b = 1) => (x < a ? a : x > b ? b : x);
export const smooth = (a: number, b: number, x: number) => {
const t = clamp((x - a) / (b - a));
return t * t * (3 - 2 * t);
};
const LATTICE = (() => {
const a = new Float32Array(256 * 256);
let s = 1337;
for (let i = 0; i < a.length; i++) {
s = (s * 1664525 + 1013904223) >>> 0;
a[i] = s / 4294967296;
}
return a;
})();
export const hash = (x: number, y = 0) => LATTICE[(x & 255) | ((y & 255) << 8)];
export function noise(x: number, y: number) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
}
export const fbm = (x: number, y: number) => noise(x, y) * 0.62 + noise(x * 2.1 + 17, y * 2.1 + 9) * 0.38;
/* ——— scenes: densities are luminance, drawn as light dots on a dark screen ——— */
export const scenes: Record<SceneId, Field> = {
portrait(u, v, t, ar) {
const x = (u - 0.5) * ar;
const cx = 0.12 + Math.sin(t * 0.55) * 0.006;
const hy = 0.42 + Math.sin(t * 2.3) * 0.004;
const dx = x - cx;
const dy = v - hy;
const head = (dx / 0.122) ** 2 + (dy / 0.162) ** 2;
const hair = (dx / 0.138) ** 2 + ((dy + 0.03) / 0.165) ** 2;
const body = (dx / 0.52) ** 2 + ((v - 1.06) / 0.3) ** 2;
const neck = Math.abs(dx) < 0.045 && dy > 0.1 && v < 0.8;
const lit = 0.9 - clamp((dx + 0.26) / 0.52) * 0.78;
if (hair < 1 && dy < -0.07 + (dx / 0.115) ** 2 * 0.12) return hair > 0.82 && dx > 0.02 ? 0.36 : dx < -0.05 ? 0.1 : 0.03;
if (head < 1) {
const eye = Math.min(((dx + 0.042) / 0.017) ** 2 + ((dy + 0.004) / 0.009) ** 2, ((dx - 0.038) / 0.017) ** 2 + ((dy + 0.004) / 0.009) ** 2);
if (eye < 1) return 0.02;
const open = 0.003 + 0.007 * Math.abs(Math.sin(t * 8.5) * Math.sin(t * 3.1));
if ((dx / 0.03) ** 2 + ((dy - 0.085) / open) ** 2 < 1) return 0.02;
if (dx > 0.004 && dx < 0.02 && dy > 0.01 && dy < 0.055) return lit * 0.4;
return lit + (head > 0.74 && dx > 0.02 ? 0.36 : 0);
}
if (neck) return lit * 0.4;
if (body < 1) {
if (Math.abs(dx) < 0.06 - (v - 0.74) * 0.4 && v < 0.9) return 0.03;
return lit * 0.62 + (body > 0.9 && dx > 0 ? 0.3 : 0);
}
let L = 0.1 * Math.exp(-((x - 0.4) ** 2 + (v - 0.35) ** 2) * 14);
for (const [bx, by, br] of BOKEH) {
const d = Math.hypot(x - bx, v - by);
if (d < br) L += d > br * 0.84 ? 0.3 : 0.16;
}
if (x > 0.54 && x < 0.8 && v > 0.08 && v < 0.6 && Math.sin(v * 64) > 0.1) L += 0.12;
return L;
},
coast(u, v, t, ar) {
const x = u * ar;
const hz = 0.58;
const sx = 0.64 * ar;
const sy = 0.47;
const ds = Math.hypot(x - sx, v - sy);
if (v < hz) {
if (ds < 0.08) return ACC + 0.97;
let L = 0.46 * smooth(0.05, hz, v) ** 2 + 0.34 * Math.exp(-ds * 7);
if (v > 0.2 && v < 0.4 && noise(x * 1.6 + t * 0.05, v * 9) > 0.7) L += 0.12;
return L;
}
const depth = (v - hz) / (1 - hz);
const ripple = Math.sin(6 / (depth + 0.08) + t * 1.4 + Math.sin(x * 6 + t * 0.8) * 0.8);
const half = 0.02 + depth * 0.13;
if (Math.abs(x - sx) < half * (0.65 + 0.35 * Math.sin(v * 80 + t * 3)) && ripple > -0.2) return ACC + 0.8 - depth * 0.35;
return 0.2 * (1 - depth) ** 2 + (ripple > 0.8 ? 0.22 * (1 - depth * 0.6) : 0);
},
city(u, v, t, ar) {
const x = u * ar + t * 0.025;
const md = Math.hypot(u * ar - 0.26 * ar, v - 0.2);
if (md < 0.058) return 0.94 - (noise(u * 50, v * 50) > 0.72 ? 0.3 : 0);
const W = 0.12;
const col = Math.floor(x / W);
const fx = x / W - col;
const top = 1 - (0.3 + hash(col, 3) * 0.44);
if (v > top) {
if (fx < 0.08) return 0.01;
const floor = Math.floor((v - top) * 24);
const wy = (v - top) * 24 - floor;
const wx = fx * 4 - Math.floor(fx * 4);
if (wx > 0.32 && wx < 0.74 && wy > 0.32 && wy < 0.76 && fx > 0.14) {
const id = (col * 31 + floor * 7 + Math.floor(fx * 4)) & 255;
if (hash(id, Math.floor(t * 0.3 + hash(id, 9) * 10)) < 0.62) return 0.03;
return hash(id, 5) > 0.95 ? ACC + 0.95 : 0.78;
}
return 0.03 + hash(col, 4) * 0.05 + (fx > 0.9 ? 0.08 : 0);
}
return 0.14 * v * v + 0.18 * Math.exp(-(top - v) * 16) + 0.34 * Math.exp(-md * 16);
},
peaks(u, v, t, ar) {
const x = u * ar;
for (let k = 2; k >= 0; k--) {
const n = fbm(x * (1.3 + k * 0.8) + t * 0.025 * (k + 1) + k * 7.3, k * 3.1);
const r = 0.5 + k * 0.13 - (1 - Math.abs(2 * n - 1)) * (0.3 - k * 0.05);
if (v > r) return [0.4, 0.17, 0][k] + 0.34 * Math.exp(-(v - r) * 38) + (k === 2 ? 0.04 * noise(x * 24, v * 24) : 0);
}
const sd = Math.hypot(x - 0.3 * ar, v - 0.2);
if (sd < 0.068) return ACC + 0.97;
return 0.5 * smooth(0, 0.62, v) ** 2 + 0.32 * Math.exp(-sd * 6);
},
reel(u, v, t, ar) {
const x = (u - 0.5) * ar;
const y = v - 0.5;
const r = Math.hypot(x, y);
if (r > 0.43) {
if (y > 0.18 && y < 0.26 && x > 0.2) return Math.sin(x * 120 - t * 30) > 0.3 ? 0.55 : 0.18;
return 0.08 * Math.exp(-r * 2);
}
if (r > 0.4) return 0.92;
if (r < 0.045) return 0.05;
if (r < 0.085) return r < 0.06 ? ACC + 0.95 : 0.85;
const rot = t * 0.9;
const px = x * Math.cos(rot) + y * Math.sin(rot);
const py = y * Math.cos(rot) - x * Math.sin(rot);
const a = Math.atan2(py, px);
const seg = (Math.PI * 2) / 5;
const c = Math.round(a / seg) * seg;
const hx = Math.cos(c) * 0.245;
const hy = Math.sin(c) * 0.245;
if (Math.hypot(px - hx, py - hy) < 0.1) return 0.05;
return 0.42 + 0.3 * Math.cos(a * 2) * 0.5 + (Math.sin(r * 200) > 0.6 ? 0.12 : 0);
},
};
export type SceneId = 'portrait' | 'coast' | 'city' | 'peaks' | 'reel';
/* ——— ambient fields: dark dots on the white page ——— */
/** A drifting halftone cloud with an accent core — sits behind the hero editor. */
export const halo: Field = (u, v, t, ar) => {
const x = (u - 0.5) * ar;
const y = v - 0.56;
const r = Math.sqrt(x * x * 0.3 + y * y * 2.6);
if (r > 1.06) return 0;
const n = fbm(x * 1.7 + t * 0.045, y * 2.8 - t * 0.035);
const d = 0.78 - r + (n - 0.5) * 0.55;
if (d > 0.52 && n > 0.6) return ACC + Math.min(0.7, (d - 0.5) * 2.4);
return Math.max(0, d * 0.62);
};
/** Sun rising over a calm sea, for the closing call to action. */
export const sunrise: Field = (u, v, t, ar) => {
const x = (u - 0.5) * ar;
const hz = 0.52;
const sy = 0.5 - 0.05 * (0.5 + 0.5 * Math.sin(t * 0.25));
const ds = Math.hypot(x, v - sy);
if (v < hz) {
if (ds < 0.2) return ACC + 0.98;
return 0.06 + 0.46 * smooth(0, hz, v) ** 2 + 0.4 * Math.exp(-(ds - 0.2) * 4);
}
const depth = (v - hz) / (1 - hz);
const ripple = Math.sin(5 / (depth + 0.06) + t * 1.2 + Math.sin(x * 5 + t * 0.7) * 0.9);
if (Math.abs(x) < (0.12 + depth * 0.3) * (0.7 + 0.3 * Math.sin(v * 70 + t * 2.5)) && ripple > -0.3) return ACC + 0.85 - depth * 0.4;
return 0.2 * (1 - depth) ** 1.5 + (ripple > 0.78 ? 0.25 * (1 - depth * 0.5) : 0);
};
const BOKEH: [number, number, number][] = [
[-0.62, 0.22, 0.05],
[-0.48, 0.34, 0.035],
[-0.74, 0.46, 0.028],
[-0.3, 0.16, 0.022],
];components/niceui/pages/landing/rushes/ui.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { Reveal } from '@/components/niceui/pages/landing/shared/motion';
export function scrollToRushes(id: string) {
document.getElementById(`rushes-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/** 24 fps timecode: HH:MM:SS:FF */
export function tc(t: number) {
const f = Math.floor(t * 24);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(Math.floor(f / 86400))}:${p(Math.floor(f / 1440) % 60)}:${p(Math.floor(f / 24) % 60)}:${p(f % 24)}`;
}
export function Mark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 28 28" className={className} aria-hidden="true">
<rect width="28" height="28" rx="8" fill="currentColor" />
<path d="M10 7.5 21 14H10z" fill="#fff" />
<path d="M8.6 14H19.6L8.6 20.5z" fill="#ff4f1f" />
</svg>
);
}
export function Wordmark({ className }: { className?: string }) {
return (
<span className={cn('flex items-center gap-2 text-zinc-950', className)}>
<Mark className="h-7 w-7" />
<span className="text-[19px] font-semibold tracking-[-0.04em]">rushes</span>
</span>
);
}
type BtnProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'dark' | 'light' | 'ghost' | 'white' };
export function RButton({ variant = 'dark', className, children, ...rest }: BtnProps) {
return (
<button
{...rest}
className={cn(
'inline-flex h-11 items-center justify-center gap-2 rounded-full px-5 text-sm font-medium transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4f1f]/50 focus-visible:ring-offset-2',
variant === 'dark' && 'bg-zinc-950 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25),0_8px_20px_-8px_rgba(0,0,0,0.5)] hover:bg-zinc-800',
variant === 'light' && 'bg-white text-zinc-900 shadow-[0_1px_2px_rgba(0,0,0,0.06)] ring-1 ring-zinc-200 hover:bg-zinc-50 hover:ring-zinc-300',
variant === 'ghost' && 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-950',
variant === 'white' && 'bg-white text-zinc-950 hover:bg-zinc-100',
className,
)}
>
{children}
</button>
);
}
export function Eyebrow({ children, className }: { children: ReactNode; className?: string }) {
return (
<span className={cn('inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.16em] text-zinc-500', className)}>
<span className="h-1.5 w-1.5 rounded-full bg-[#ff4f1f]" />
{children}
</span>
);
}
export function SectionHead({ tag, title, body, center, className }: { tag: string; title: ReactNode; body?: ReactNode; center?: boolean; className?: string }) {
return (
<Reveal className={cn('max-w-3xl', center && 'mx-auto text-center', className)}>
<Eyebrow>{tag}</Eyebrow>
<h2 className="mt-5 text-[40px] font-semibold leading-[1.02] tracking-[-0.045em] text-zinc-950 md:text-[60px]">{title}</h2>
{body && <p className={cn('mt-5 max-w-xl text-[17px] leading-relaxed text-zinc-600', center && 'mx-auto')}>{body}</p>}
</Reveal>
);
}
export function Serif({ children }: { children: ReactNode }) {
return <em className="font-serif font-normal italic tracking-[-0.02em]">{children}</em>;
}
/** Viewfinder corner brackets. */
export function Corners({ className, size = 18 }: { className?: string; size?: number }) {
const s = { width: size, height: size };
return (
<div aria-hidden className={cn('pointer-events-none absolute inset-0', className)}>
<span className="absolute left-0 top-0 border-l border-t border-current" style={s} />
<span className="absolute right-0 top-0 border-r border-t border-current" style={s} />
<span className="absolute bottom-0 left-0 border-b border-l border-current" style={s} />
<span className="absolute bottom-0 right-0 border-b border-r border-current" style={s} />
</div>
);
}components/niceui/pages/landing/rushes/Closing.tsx
import { useEffect, useMemo, useState } from 'react';
import { ArrowRight, Check, CloudUpload, FileVideo2, Github, Linkedin, Minus, Plus, Sparkles, Twitter, Youtube } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Reveal, useCountUp, useInView } from '@/components/niceui/pages/landing/shared/motion';
import { ACC, ACCENT, DitherCanvas, INK, PAPER, SCREEN, clamp, scenes, sunrise, type Field } from './dither';
import { Corners, Eyebrow, Mark, RButton, SectionHead, Serif, scrollToRushes } from './ui';
function useLoop(seconds: number, fps = 12) {
const { ref, inView } = useInView<HTMLDivElement>(0.2);
const [t, setT] = useState(0);
useEffect(() => {
if (!inView || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const start = performance.now();
const id = window.setInterval(() => setT(((performance.now() - start) / 1000) % seconds), 1000 / fps);
return () => clearInterval(id);
}, [inView, seconds, fps]);
return { ref, t, live: inView && !window.matchMedia('(prefers-reduced-motion: reduce)').matches };
}
/* ——— Workflow ——— */
function Ingest() {
const { ref, t, live } = useLoop(6);
const files = [
['A001_C004.mov', '2.1 GB', 0],
['phone_broll_07.mp4', '640 MB', 0.5],
['podcast_ep42.wav', '310 MB', 1],
] as const;
return (
<div ref={ref} className="space-y-2">
{files.map(([n, s, d]) => {
const p = live ? clamp((t - d) / 2.4) : 1;
return (
<div key={n} className="rounded-xl bg-white px-3 py-2.5 ring-1 ring-zinc-200">
<div className="flex items-center gap-2 text-[12px]">
<FileVideo2 className="h-3.5 w-3.5 text-zinc-400" />
<span className="truncate font-medium text-zinc-800">{n}</span>
<span className="ml-auto font-mono text-[10.5px] text-zinc-400">{p < 1 ? `${Math.round(p * 100)}%` : s}</span>
{p >= 1 && <Check className="h-3.5 w-3.5 text-[#ff4f1f]" />}
</div>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-zinc-100">
<div className="h-full rounded-full bg-zinc-900" style={{ width: `${p * 100}%` }} />
</div>
</div>
);
})}
<p className="flex items-center gap-1.5 pt-1 font-mono text-[10.5px] text-zinc-500">
<CloudUpload className="h-3.5 w-3.5" /> Multicam synced · transcribing {live ? Math.min(100, Math.round((t / 4) * 100)) : 100}%
</p>
</div>
);
}
function Direct() {
const { ref, t, live } = useLoop(6);
const done = !live || (t > 2.6 && t < 5.6);
const blocks = [0.18, 0.06, 0.2, 0.05, 0.16, 0.08, 0.22];
return (
<div ref={ref} className="space-y-3">
<p className="ml-auto w-fit max-w-[90%] rounded-2xl rounded-br-md bg-zinc-950 px-3 py-2 text-[12.5px] leading-snug text-white">Cut it to 60 seconds, keep the laugh, open on the sunrise.</p>
<div className="rounded-xl bg-white p-2.5 ring-1 ring-zinc-200">
<div className="flex h-8 gap-0.5">
{blocks.map((b, i) => {
const drop = i % 2 === 1;
return (
<span key={i} className={cn('h-full rounded-[4px] transition-all duration-700 ease-smooth', drop ? 'bg-[#ff4f1f]/70' : i === 4 ? 'bg-[#ff4f1f]' : 'bg-zinc-800')} style={{ flexGrow: done && drop ? 0 : b * 100, flexBasis: 0, opacity: done && drop ? 0 : 1 }} />
);
})}
<span className="h-full rounded-[4px] border border-dashed border-zinc-300 transition-all duration-700 ease-smooth" style={{ flexGrow: done ? 19 : 0, flexBasis: 0, opacity: done ? 1 : 0 }} />
</div>
<div className="mt-2 flex items-center justify-between font-mono text-[10.5px] text-zinc-500">
<span className="flex items-center gap-1">
<Sparkles className="h-3 w-3 text-[#ff4f1f]" /> {done ? 'Edited · 3 cuts, 1 move' : 'Director is cutting…'}
</span>
<span className="tabular-nums text-zinc-900">{done ? '00:59:22' : '01:14:08'}</span>
</div>
</div>
</div>
);
}
function Publish() {
const { ref, t, live } = useLoop(6.5);
const outs = ['Long-form · 4K 16:9', 'Vertical · 1080×1920', 'Square · 1080×1080', 'Captions · SRT + burned-in', 'Audio · −14 LUFS'];
return (
<div ref={ref} className="space-y-1.5">
{outs.map((o, i) => {
const ok = !live || t > 0.6 + i * 0.75;
return (
<div key={o} className="flex items-center gap-2.5 rounded-xl bg-white px-3 py-2 text-[12.5px] ring-1 ring-zinc-200">
<span className={cn('grid h-4 w-4 place-items-center rounded-full transition-all duration-300', ok ? 'bg-[#ff4f1f] text-white' : 'bg-zinc-100 text-transparent')}>
<Check className="h-2.5 w-2.5" />
</span>
<span className={cn('transition-colors', ok ? 'text-zinc-900' : 'text-zinc-400')}>{o}</span>
</div>
);
})}
</div>
);
}
export function Workflow() {
const steps = [
{ n: '01', title: 'Drop your rushes', body: 'Cards, phone clips, screen recordings, a three-hour podcast. Rushes ingests, syncs multicam and transcribes while it uploads.', visual: <Ingest /> },
{ n: '02', title: 'Direct in plain English', body: 'Say what you want the way you’d tell an editor. Every decision lands on the timeline, so nothing is a black box.', visual: <Direct /> },
{ n: '03', title: 'Publish everywhere', body: 'Every aspect ratio, caption file and language from one cut, rendered in the cloud in minutes.', visual: <Publish /> },
];
return (
<section id="rushes-workflow" className="relative scroll-mt-16 py-24 md:py-32">
<div className="mx-auto max-w-7xl px-5 sm:px-6">
<SectionHead
center
tag="How it works"
title={
<>
From card to cut,
<br />
<Serif>in three moves.</Serif>
</>
}
body="No new tools to learn. If you can write a message, you can direct an edit."
/>
<div className="relative mt-16 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-3">
<div aria-hidden className="pointer-events-none absolute left-[16%] right-[16%] top-[30px] hidden h-px lg:block" style={{ backgroundImage: 'repeating-linear-gradient(90deg, #d4d4d8 0 3px, transparent 3px 7px)' }} />
{steps.map((s, i) => (
<Reveal key={s.n} delay={i * 110}>
<div className="relative flex h-full flex-col rounded-[28px] border border-zinc-200/80 bg-white p-2">
<div className="flex items-center gap-3 px-4 pb-1 pt-4">
<span className="relative grid h-9 w-9 place-items-center rounded-full bg-zinc-950 font-mono text-[11px] font-medium text-white ring-8 ring-white">{s.n}</span>
<h3 className="text-[18px] font-semibold tracking-[-0.02em] text-zinc-950">{s.title}</h3>
</div>
<p className="px-4 pb-5 pt-2 text-[14.5px] leading-relaxed text-zinc-500">{s.body}</p>
<div className="mt-auto rounded-[22px] border border-zinc-100 bg-zinc-50/70 p-4">{s.visual}</div>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
/* ——— Stats ——— */
const halftoneNum = {
backgroundImage: 'radial-gradient(circle, #0b0b0c 1.25px, transparent 1.5px)',
backgroundSize: '3.6px 3.6px',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
color: 'transparent',
} as const;
function Stat({ to, decimals = 0, suffix, label, active }: { to: number; decimals?: number; suffix: string; label: string; active: boolean }) {
const v = useCountUp(to, active, 2000);
return (
<div className="px-2 py-8 text-center sm:px-6 md:py-10">
<p className="text-[64px] font-semibold leading-none tracking-[-0.06em] sm:text-[88px]" style={halftoneNum}>
{v.toFixed(decimals)}
<span className="text-[#ff4f1f] [-webkit-text-fill-color:#ff4f1f]">{suffix}</span>
</p>
<p className="mt-3 text-[14px] text-zinc-500">{label}</p>
</div>
);
}
export function Stats() {
const { ref, inView } = useInView<HTMLDivElement>(0.3);
return (
<section className="border-y border-zinc-100 bg-white">
<div ref={ref} className="mx-auto grid max-w-7xl grid-cols-2 divide-zinc-100 px-2 lg:grid-cols-4 lg:divide-x [&>*:nth-child(-n+2)]:border-b [&>*:nth-child(-n+2)]:border-zinc-100 lg:[&>*:nth-child(-n+2)]:border-b-0 [&>*:nth-child(odd)]:border-r [&>*:nth-child(odd)]:border-zinc-100 lg:[&>*:nth-child(odd)]:border-r-0">
<Stat to={6} suffix="×" label="faster to a first cut" active={inView} />
<Stat to={11} suffix="M" label="hours of footage edited" active={inView} />
<Stat to={29} suffix="" label="languages, in your voice" active={inView} />
<Stat to={4.9} decimals={1} suffix="★" label="from 12,000 reviews" active={inView} />
</div>
</section>
);
}
/* ——— Testimonials ——— */
const QUOTES = [
{ q: 'We publish a long-form episode and twelve shorts every week with two people. That used to be a team of six.', n: 'Jonah Reyes', r: 'Head of video, Field & Frame', m: '−31 h / week' },
{ q: 'The captions alone paid for it. The B-roll generation is the part clients ask about.', n: 'Priya Natarajan', r: 'Founder, Pellucid Studio', m: '3× client output' },
{ q: 'It edits like someone who has watched every take, because it has. I just argue with it about pacing.', n: 'Tomás Lindqvist', r: 'Documentary editor', m: '4K, 9 languages' },
];
export function Testimonials() {
const portrait = useMemo<Field>(() => (u, v, t, ar) => scenes.portrait(0.36 + u * 0.56, v, t, 16 / 9), []);
return (
<section className="relative py-24 md:py-32">
<div className="mx-auto max-w-7xl px-5 sm:px-6">
<Reveal>
<div className="grid items-center gap-8 rounded-[32px] border border-zinc-200/80 bg-white p-3 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-12 md:p-4">
<div className="relative aspect-square overflow-hidden rounded-[24px] bg-[#0d0d0e] md:aspect-[4/5]">
<DitherCanvas field={portrait} pixel={3} ink={PAPER} bg={SCREEN} fps={20} className="absolute inset-0" />
<Corners className="m-4 text-white/40" size={16} />
<span className="absolute bottom-4 left-4 flex items-center gap-2 rounded-full bg-black/60 py-1 pl-1 pr-3 font-mono text-[10px] uppercase tracking-wider text-white/80 backdrop-blur">
<span className="h-5 w-5 rounded-full bg-[#ff4f1f]" /> 1.2M subscribers
</span>
</div>
<div className="px-3 pb-6 md:px-0 md:pb-0 md:pr-10">
<Eyebrow>Customer story</Eyebrow>
<blockquote className="mt-6 font-serif text-[30px] italic leading-[1.15] tracking-[-0.02em] text-zinc-950 sm:text-[42px]">
“Rushes gave me my weekends back. I shoot on Saturday, and the first cut is waiting when I wake up on Sunday.”
</blockquote>
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-3">
<div>
<p className="text-[15px] font-semibold text-zinc-950">Maya Ocampo</p>
<p className="text-[13.5px] text-zinc-500">Documentary filmmaker</p>
</div>
<span className="hidden h-8 w-px bg-zinc-200 sm:block" />
<div className="flex gap-6">
{[
['14 h', 'saved per video'],
['9', 'languages live'],
].map(([a, b]) => (
<div key={b}>
<p className="font-mono text-[18px] text-zinc-950">{a}</p>
<p className="text-[12px] text-zinc-500">{b}</p>
</div>
))}
</div>
</div>
</div>
</div>
</Reveal>
<div className="mt-4 grid gap-4 md:grid-cols-3">
{QUOTES.map((x, i) => (
<Reveal key={x.n} delay={i * 90}>
<figure className="flex h-full flex-col justify-between rounded-[28px] border border-zinc-200/80 bg-white p-6">
<blockquote className="text-[16px] leading-relaxed text-zinc-800">“{x.q}”</blockquote>
<figcaption className="mt-8 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-full bg-zinc-950 text-[12px] font-semibold text-white" style={{ backgroundImage: 'radial-gradient(rgba(255,255,255,0.22) 0.9px, transparent 1.1px)', backgroundSize: '3px 3px' }}>
{x.n
.split(' ')
.map((p) => p[0])
.join('')}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-semibold text-zinc-950">{x.n}</span>
<span className="block truncate text-[12.5px] text-zinc-500">{x.r}</span>
</span>
<span className="shrink-0 rounded-full bg-[#ff4f1f]/10 px-2 py-0.5 font-mono text-[10.5px] text-[#d63a0c]">{x.m}</span>
</figcaption>
</figure>
</Reveal>
))}
</div>
</div>
</section>
);
}
/* ——— Pricing ——— */
const TIERS = [
{ name: 'Free', m: 0, y: 0, blurb: 'For your first films.', cta: 'Start free', features: ['60 AI minutes a month', '1080p export, no watermark', 'Text-based editing', 'Auto captions in 3 styles'] },
{ name: 'Creator', m: 20, y: 16, blurb: 'For people who publish every week.', cta: 'Start 14-day trial', featured: true, features: ['10 AI hours a month', '4K export', 'Studio Sound and reframe', '100 B-roll generations', 'Clips from long videos'] },
{ name: 'Studio', m: 45, y: 36, blurb: 'Per seat, for teams and agencies.', cta: 'Start 14-day trial', features: ['Unlimited AI hours', 'Dubbing in 29 languages', 'Brand kits and templates', 'Frame-accurate client review', 'Priority cloud render'] },
];
export function Pricing() {
const [yearly, setYearly] = useState(true);
return (
<section id="rushes-pricing" className="relative scroll-mt-16 border-t border-zinc-100 bg-[#fafaf9] py-24 md:py-32">
<div className="mx-auto max-w-7xl px-5 sm:px-6">
<SectionHead center tag="Pricing" title={
<>
Pay for the edit,
<br />
<Serif>not the seat count.</Serif>
</>
} body="Start free. Upgrade when you publish more than you can watch." />
<div className="mt-10 flex justify-center">
<div className="relative flex rounded-full bg-white p-1 ring-1 ring-zinc-200">
<span className={cn('absolute inset-y-1 w-[calc(50%-4px)] rounded-full bg-zinc-950 transition-all duration-300 ease-smooth', yearly ? 'left-[calc(50%)]' : 'left-1')} />
{[
['Monthly', false],
['Yearly · −20%', true],
].map(([l, v]) => (
<button key={l as string} onClick={() => setYearly(v as boolean)} className={cn('relative z-10 w-36 rounded-full py-2 text-[13px] font-medium transition-colors', yearly === v ? 'text-white' : 'text-zinc-500 hover:text-zinc-900')}>
{l as string}
</button>
))}
</div>
</div>
<div className="mx-auto mt-12 grid max-w-6xl grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-3">
{TIERS.map((t, i) => {
const price = yearly ? t.y : t.m;
return (
<Reveal key={t.name} delay={i * 90}>
<div className={cn('relative flex h-full flex-col overflow-hidden rounded-[28px] p-7', t.featured ? 'bg-zinc-950 text-white shadow-[0_40px_80px_-30px_rgba(0,0,0,0.6)]' : 'border border-zinc-200/80 bg-white')}>
{t.featured && (
<>
<DitherCanvas field={featuredGlow} pixel={3} ink="#3f3f46" fps={16} className="absolute inset-x-0 top-0 h-48 [mask-image:linear-gradient(black,transparent)]" />
<span className="absolute right-6 top-7 rounded-full bg-[#ff4f1f] px-2.5 py-1 text-[11px] font-medium text-white">Most popular</span>
</>
)}
<div className="relative">
<h3 className={cn('text-[15px] font-medium', t.featured ? 'text-white' : 'text-zinc-950')}>{t.name}</h3>
<p className={cn('mt-1 text-[13.5px]', t.featured ? 'text-zinc-400' : 'text-zinc-500')}>{t.blurb}</p>
<p className="mt-7 flex items-end gap-1.5">
<span key={price} className="text-[52px] font-semibold leading-none tracking-[-0.05em] animate-fade-up">${price}</span>
<span className={cn('pb-1.5 text-[13px]', t.featured ? 'text-zinc-400' : 'text-zinc-500')}>{price ? (t.name === 'Studio' ? '/ seat / mo' : '/ month') : 'forever'}</span>
</p>
<RButton variant={t.featured ? 'white' : 'dark'} className="mt-7 w-full" onClick={() => scrollToRushes('cta')}>
{t.cta}
</RButton>
<ul className="mt-8 space-y-3">
{t.features.map((f) => (
<li key={f} className={cn('flex items-start gap-2.5 text-[14px]', t.featured ? 'text-zinc-300' : 'text-zinc-600')}>
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[#ff4f1f]" />
{f}
</li>
))}
</ul>
</div>
</div>
</Reveal>
);
})}
</div>
<Reveal className="mx-auto mt-4 max-w-6xl">
<div className="flex flex-col items-start justify-between gap-4 rounded-[24px] border border-zinc-200/80 bg-white px-7 py-5 sm:flex-row sm:items-center">
<div>
<p className="text-[15px] font-semibold text-zinc-950">Enterprise and broadcast</p>
<p className="mt-0.5 text-[13.5px] text-zinc-500">SSO, on-prem render nodes, MAM integrations and an SLA your legal team will like.</p>
</div>
<RButton variant="light" className="h-10 shrink-0" onClick={() => scrollToRushes('cta')}>
Talk to us <ArrowRight className="h-4 w-4" />
</RButton>
</div>
</Reveal>
</div>
</section>
);
}
const featuredGlow: Field = (u, v, t, ar) => {
const x = (u - 0.8) * ar;
const d = Math.hypot(x, v + 0.1);
return clamp(0.9 - d * 0.9 + Math.sin(u * 14 - t * 0.8 + v * 6) * 0.08);
};
/* ——— FAQ ——— */
const FAQS = [
['Is the edit really mine to change?', 'Yes. Director makes ordinary cuts, moves and effects on a normal multitrack timeline. Undo anything, nudge any cut by a frame, or turn the AI off entirely.'],
['Which cameras and formats do you support?', 'ProRes, H.264, H.265, BRAW proxies, phone footage and most audio formats. Multicam sync works from timecode or audio.'],
['Can I export to Premiere, Resolve or Final Cut?', 'Every plan exports XML and EDL with media relinking, so you can finish a grade or mix wherever you like.'],
['Is my footage used to train models?', 'No. Your media is encrypted at rest and never used for training. Studio and Enterprise plans can choose the storage region.'],
['How does voice cloning stay safe?', 'Cloning requires a recorded consent phrase from the speaker, and every dubbed export carries an inaudible watermark.'],
['What counts as an AI minute?', 'One minute of source footage processed by any AI feature. Editing, playback and exports never use minutes.'],
];
export function Faq() {
const [open, setOpen] = useState(0);
return (
<section id="rushes-faq" className="scroll-mt-16 py-24 md:py-32">
<div className="mx-auto grid max-w-7xl grid-cols-[minmax(0,1fr)] gap-12 px-5 sm:px-6 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<SectionHead
tag="FAQ"
title={
<>
Questions,
<br />
<Serif>answered.</Serif>
</>
}
body={
<>
Something else? Write to <span className="text-zinc-950 underline decoration-zinc-300 underline-offset-4">hello@rushes.video</span>, a human editor replies.
</>
}
/>
<div className="divide-y divide-zinc-200/80 border-y border-zinc-200/80">
{FAQS.map(([q, a], i) => {
const on = open === i;
return (
<div key={q}>
<button onClick={() => setOpen(on ? -1 : i)} aria-expanded={on} className="flex w-full items-center justify-between gap-6 py-5 text-left">
<span className="text-[17px] font-medium tracking-[-0.01em] text-zinc-950">{q}</span>
<span className={cn('grid h-7 w-7 shrink-0 place-items-center rounded-full transition-all duration-300', on ? 'bg-zinc-950 text-white' : 'bg-zinc-100 text-zinc-600')}>
{on ? <Minus className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
</span>
</button>
<div className={cn('grid transition-all duration-500 ease-smooth', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<div className="overflow-hidden">
<p className="max-w-2xl pb-6 text-[15px] leading-relaxed text-zinc-500">{a}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}
/* ——— CTA ——— */
export function Cta() {
return (
<section id="rushes-cta" className="scroll-mt-16 px-3 pb-3 sm:px-4">
<div className="relative mx-auto max-w-[1400px] overflow-hidden rounded-[32px] bg-[#0d0d0e] sm:rounded-[40px]">
<DitherCanvas field={sunrise} pixel={3} ink={PAPER} accent={ACCENT} bg={SCREEN} fps={24} className="absolute inset-x-0 bottom-0 h-[230px] [mask-image:linear-gradient(to_bottom,transparent,black_32%)] sm:h-[400px]" />
<Corners className="m-6 text-white/25 sm:m-8" size={22} />
<div className="relative px-6 pb-[250px] pt-20 text-center sm:pb-[380px] sm:pt-28">
<Eyebrow className="text-white/50">Your next film</Eyebrow>
<h2 className="mx-auto mt-6 max-w-4xl text-[44px] font-semibold leading-[0.98] tracking-[-0.05em] text-white sm:text-[76px]">
Your best cut is
<br />
<Serif>already in the footage.</Serif>
</h2>
<p className="mx-auto mt-6 max-w-md text-[16px] leading-relaxed text-white/60">Upload a card tonight. Wake up to a first cut, captions and ten shorts.</p>
<div className="mt-9 flex flex-col items-center justify-center gap-3 sm:flex-row">
<RButton variant="white" className="h-12 w-full px-6 text-[15px] sm:w-auto">
Start editing free <ArrowRight className="h-4 w-4" />
</RButton>
<RButton className="h-12 w-full bg-white/10 px-6 text-[15px] shadow-none ring-1 ring-white/15 hover:bg-white/15 sm:w-auto">Book a demo</RButton>
</div>
<p className="mt-5 font-mono text-[11px] uppercase tracking-[0.14em] text-white/40">Free forever plan · No card needed</p>
</div>
</div>
</section>
);
}
/* ——— Footer ——— */
function DitheredWordmark() {
const [mask, setMask] = useState<{ data: Uint8ClampedArray; w: number; h: number } | null>(null);
useEffect(() => {
let alive = true;
document.fonts.ready.then(() => {
if (!alive) return;
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (!ctx) return;
const font = '600 400px Geist, Inter, system-ui, sans-serif';
ctx.font = font;
const ls = 'letterSpacing' in ctx;
const word = 'rushes';
const m = ctx.measureText(word);
const dot = ctx.measureText('.');
const track = -0.06 * 400;
const w = Math.ceil(m.width + dot.width + (ls ? track * word.length : 0) + 40);
const h = Math.ceil(m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + 30);
c.width = w;
c.height = h;
ctx.font = font;
if (ls) (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${track}px`;
ctx.fillStyle = '#000';
ctx.fillText(word, 10, m.actualBoundingBoxAscent + 14);
const end = 10 + ctx.measureText(word).width;
ctx.fillStyle = '#f00';
ctx.fillText('.', end, m.actualBoundingBoxAscent + 14);
setMask({ data: ctx.getImageData(0, 0, w, h).data, w, h });
});
return () => {
alive = false;
};
}, []);
const field = useMemo<Field>(
() => (u, v, t) => {
if (!mask) return 0;
const i = (Math.floor(v * mask.h) * mask.w + Math.floor(u * mask.w)) * 4;
const a = mask.data[i + 3] / 255;
if (a < 0.4) return 0;
const wave = 0.5 + 0.5 * Math.sin(u * 7 - t * 0.9 + v * 2.5);
const d = a * (0.18 + 0.82 * v ** 1.3) * (0.7 + 0.3 * wave);
return mask.data[i] > 128 ? ACC + 0.95 : d;
},
[mask],
);
return (
<div className="w-full" style={{ aspectRatio: mask ? `${mask.w} / ${mask.h}` : '1000 / 300' }}>
{mask && <DitherCanvas field={field} pixel={3} ink={INK} fps={20} className="h-full w-full" />}
</div>
);
}
const COLS: [string, string[]][] = [
['Product', ['Editor', 'Director', 'B-roll', 'Studio Sound', 'Dubbing', 'Changelog']],
['Workflows', ['Podcasts', 'YouTube', 'Agencies', 'Newsrooms', 'Education']],
['Company', ['About', 'Careers', 'Press', 'Contact']],
['Legal', ['Privacy', 'Terms', 'Security', 'Voice consent']],
];
export function RushesFooter() {
return (
<footer className="relative bg-white pt-20">
<div className="mx-auto max-w-7xl px-5 sm:px-6">
<div className="grid gap-12 lg:grid-cols-[1.1fr_2fr]">
<div>
<span className="flex items-center gap-2 text-zinc-950">
<Mark className="h-7 w-7" />
<span className="text-[19px] font-semibold tracking-[-0.04em]">rushes</span>
</span>
<p className="mt-4 max-w-xs text-[14.5px] leading-relaxed text-zinc-500">The AI video editor for people who would rather be filming.</p>
<form onSubmit={(e) => e.preventDefault()} className="mt-6 flex max-w-sm items-center gap-1.5 rounded-full border border-zinc-200 bg-white p-1 pl-4 focus-within:border-zinc-400">
<input type="email" placeholder="you@studio.com" aria-label="Email" className="h-9 min-w-0 flex-1 bg-transparent text-[14px] text-zinc-900 outline-none placeholder:text-zinc-400" />
<RButton className="h-9 px-4 text-[13px]">Get the newsletter</RButton>
</form>
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{COLS.map(([h, links]) => (
<div key={h}>
<p className="font-mono text-[11px] uppercase tracking-[0.14em] text-zinc-400">{h}</p>
<ul className="mt-4 space-y-2.5">
{links.map((l) => (
<li key={l}>
<a href="#" onClick={(e) => e.preventDefault()} className="text-[14px] text-zinc-600 transition-colors hover:text-zinc-950">
{l}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-16">
<DitheredWordmark />
</div>
<div className="flex flex-col items-start justify-between gap-4 border-t border-zinc-200/80 py-6 sm:flex-row sm:items-center">
<p className="text-[13px] text-zinc-500">© 2026 Rushes Labs, Inc.</p>
<div className="flex items-center gap-5">
<span className="flex items-center gap-1.5 text-[12.5px] text-zinc-500">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" /> All render nodes online
</span>
{[Twitter, Youtube, Linkedin, Github].map((I, i) => (
<a key={i} href="#" onClick={(e) => e.preventDefault()} aria-label={['X', 'YouTube', 'LinkedIn', 'GitHub'][i]} className="text-zinc-400 transition-colors hover:text-zinc-950">
<I className="h-4 w-4" />
</a>
))}
</div>
</div>
</div>
</footer>
);
}components/niceui/pages/landing/rushes/Hero.tsx
import { useEffect, useState, type CSSProperties } from 'react';
import { ArrowRight, Menu, Play, Star, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { DitherCanvas, halo } from './dither';
import { Editor } from './Editor';
import { Corners, RButton, Serif, Wordmark, scrollToRushes, tc } from './ui';
const nav = [
{ label: 'Editing', id: 'script' },
{ label: 'Features', id: 'features' },
{ label: 'Workflow', id: 'workflow' },
{ label: 'Pricing', id: 'pricing' },
{ label: 'FAQ', id: 'faq' },
];
export function RushesNav() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const main = document.querySelector('main');
const onScroll = () => setScrolled((main?.scrollTop ?? 0) > 12 || window.scrollY > 12);
main?.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
main?.removeEventListener('scroll', onScroll);
window.removeEventListener('scroll', onScroll);
};
}, []);
const go = (id: string) => {
setOpen(false);
scrollToRushes(id);
};
return (
<header className={cn('sticky top-0 z-50 transition-all duration-300', scrolled || open ? 'border-b border-zinc-200/70 bg-white/80 backdrop-blur-xl' : 'border-b border-transparent')}>
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-5 sm:px-6">
<button onClick={() => go('top')} aria-label="Rushes home">
<Wordmark />
</button>
<nav className="hidden items-center gap-0.5 md:flex">
{nav.map((n) => (
<button key={n.id} onClick={() => go(n.id)} className="rounded-full px-3.5 py-2 text-[14px] text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-950">
{n.label}
</button>
))}
</nav>
<div className="flex items-center gap-1.5">
<RButton variant="ghost" className="hidden h-9 px-4 sm:inline-flex" onClick={() => go('cta')}>
Log in
</RButton>
<RButton className="h-9 px-4" onClick={() => go('cta')}>
Start free
</RButton>
<button onClick={() => setOpen((o) => !o)} aria-label="Toggle menu" aria-expanded={open} className="grid h-9 w-9 place-items-center rounded-full text-zinc-900 hover:bg-zinc-100 md:hidden">
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
<div className={cn('grid transition-all duration-300 md:hidden', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden">
<div className="flex flex-col px-3 pb-3">
{nav.map((n) => (
<button key={n.id} onClick={() => go(n.id)} className="rounded-xl px-3 py-3 text-left text-[17px] font-medium tracking-[-0.01em] text-zinc-900 hover:bg-zinc-100">
{n.label}
</button>
))}
</div>
</div>
</div>
</header>
);
}
const halftone: CSSProperties = {
backgroundImage: 'radial-gradient(circle, #0b0b0c 1.05px, transparent 1.3px)',
backgroundSize: '3px 3px',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
color: 'transparent',
};
function useRecClock() {
const [t, setT] = useState(14.9);
useEffect(() => {
const t0 = performance.now();
const id = window.setInterval(() => setT(14.9 + (performance.now() - t0) / 1000), 1000 / 12);
return () => clearInterval(id);
}, []);
return t;
}
function Hud() {
const t = useRecClock();
return (
<div aria-hidden className="pointer-events-none absolute inset-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400">
<span className="absolute left-5 top-4 flex items-center gap-2 sm:left-7 sm:top-5">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#ff4f1f]" />
<span className="text-zinc-600">Rec</span>
<span className="tabular-nums">{tc(t)}</span>
</span>
<span className="absolute right-5 top-4 hidden sm:right-7 sm:top-5 sm:block">ISO 400 · 1/48 · 24p</span>
<span className="absolute bottom-4 left-7 hidden md:block">LUT · Rushes Neutral</span>
<span className="absolute bottom-4 right-7 hidden items-center gap-1.5 md:flex">
<span className="relative h-2.5 w-5 rounded-[2px] border border-zinc-400 after:absolute after:-right-[3px] after:top-[2px] after:h-1 after:w-[2px] after:rounded-r-sm after:bg-zinc-400">
<span className="absolute inset-[1.5px] right-[30%] rounded-[1px] bg-zinc-400" />
</span>
84%
</span>
<span className="absolute left-1/2 top-1/2 hidden h-5 w-5 -translate-x-1/2 -translate-y-1/2 opacity-40 lg:block">
<span className="absolute left-1/2 top-0 h-full w-px bg-zinc-300" />
<span className="absolute left-0 top-1/2 h-px w-full bg-zinc-300" />
</span>
</div>
);
}
export function RushesHero() {
return (
<section id="rushes-top" className="relative">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[680px] [mask-image:linear-gradient(to_bottom,black,transparent)]" style={{ backgroundImage: 'linear-gradient(to right, #f4f4f5 1px, transparent 1px)', backgroundSize: '88px 100%', backgroundPosition: 'center top' }} />
<div className="relative mx-auto max-w-7xl px-4 pt-6 sm:px-6 md:pt-10">
<div className="relative mx-auto max-w-[1080px] px-3 pb-14 pt-16 text-center sm:px-10 sm:pb-16 sm:pt-20">
<Corners className="text-zinc-300" size={22} />
<Hud />
<button onClick={() => scrollToRushes('features')} className="group inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1 pr-3 text-[13px] text-zinc-600 shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-colors hover:border-zinc-300 animate-fade-down">
<span className="rounded-full bg-zinc-950 px-2 py-0.5 text-[11px] font-medium text-white">Rushes 3</span>
Direct your edit in plain English
<ArrowRight className="h-3.5 w-3.5 text-zinc-400 transition-transform group-hover:translate-x-0.5" />
</button>
<h1 className="mt-9 text-[46px] font-semibold leading-[0.94] tracking-[-0.055em] text-zinc-950 sm:text-[76px] lg:text-[104px]">
<span className="block animate-fade-up" style={halftone}>
Raw footage in.
</span>
<span className="block animate-fade-up pb-2" style={{ animationDelay: '140ms' }}>
<Serif>Finished film</Serif> out.
</span>
</h1>
<p className="mx-auto mt-7 max-w-[600px] text-[17px] leading-relaxed text-zinc-600 animate-fade-up sm:text-[19px]" style={{ animationDelay: '260ms' }}>
Rushes is the AI video editor that watches every take for you. It cuts the ums, writes the captions, finds the B-roll and reframes for every screen, on a real timeline you can still grab.
</p>
<div className="mt-9 flex flex-col items-center justify-center gap-3 animate-fade-up sm:flex-row" style={{ animationDelay: '380ms' }}>
<RButton className="h-12 w-full px-6 text-[15px] sm:w-auto" onClick={() => scrollToRushes('cta')}>
Start editing free <ArrowRight className="h-4 w-4" />
</RButton>
<RButton variant="light" className="h-12 w-full pl-2 pr-5 text-[15px] sm:w-auto" onClick={() => scrollToRushes('editor')}>
<span className="grid h-8 w-8 place-items-center rounded-full bg-zinc-950 text-white">
<Play className="ml-px h-3 w-3 fill-current" />
</span>
Watch it edit, live
</RButton>
</div>
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-[13px] text-zinc-500 animate-fade-in" style={{ animationDelay: '520ms' }}>
<span className="flex items-center gap-2">
<span className="flex -space-x-1.5">
{['#18181b', '#ff4f1f', '#71717a', '#d4d4d8'].map((c, i) => (
<span key={c} className="h-6 w-6 rounded-full ring-2 ring-white" style={{ background: c, backgroundImage: i % 2 ? 'radial-gradient(rgba(255,255,255,0.5) 0.8px, transparent 1px)' : 'radial-gradient(rgba(255,255,255,0.35) 0.8px, transparent 1px)', backgroundSize: '3px 3px' }} />
))}
</span>
<span className="flex items-center gap-0.5 text-zinc-950">
{[0, 1, 2, 3, 4].map((i) => (
<Star key={i} className="h-3.5 w-3.5 fill-current" />
))}
</span>
<b className="font-medium text-zinc-900">4.9</b> from 12,000+ creators
</span>
<span className="hidden h-3.5 w-px bg-zinc-300 sm:block" />
<span>Free plan, no watermark</span>
</div>
</div>
<div id="rushes-editor" className="relative mx-auto mt-6 max-w-[1180px] scroll-mt-24 animate-fade-up md:mt-14" style={{ animationDelay: '480ms' }}>
<DitherCanvas field={halo} pixel={3} ink="#a1a1aa" fps={24} className="absolute -inset-x-[14%] -bottom-20 -top-28 opacity-70 [mask-image:radial-gradient(ellipse_at_center,black_45%,transparent_72%)]" />
<div className="absolute -top-9 left-2 z-10 hidden items-center gap-2 rounded-full bg-white px-3 py-1.5 text-[12px] text-zinc-600 shadow-[0_4px_16px_-4px_rgba(0,0,0,0.15)] ring-1 ring-zinc-200 md:flex animate-float">
<span className="h-1.5 w-1.5 rounded-full bg-[#ff4f1f]" /> It’s live. Click the timeline, run a command, or type your own.
</div>
<div className="relative">
<Editor />
</div>
</div>
</div>
<LogoCloud />
</section>
);
}
const logos: { name: string; className: string }[] = [
{ name: 'NORTHFORM', className: 'text-[14px] font-semibold tracking-[0.32em]' },
{ name: 'Pellucid', className: 'font-serif text-[25px] italic' },
{ name: 'kinetik', className: 'text-[23px] font-bold tracking-[-0.07em]' },
{ name: '▲ APEX MEDIA', className: 'text-[14px] font-semibold tracking-[0.18em]' },
{ name: 'Field & Frame', className: 'font-serif text-[22px]' },
{ name: 'LUMEN.TV', className: 'font-mono text-[16px] font-medium' },
{ name: 'oddly', className: 'text-[22px] font-semibold italic tracking-[-0.04em]' },
{ name: '◐ Sundial', className: 'text-[19px] font-medium tracking-[-0.02em]' },
];
function LogoCloud() {
return (
<div className="relative mx-auto mt-20 max-w-7xl px-4 pb-6 sm:px-6 md:mt-24">
<p className="text-center text-[13px] text-zinc-500">Cutting rooms at 40,000+ studios, newsrooms and channels</p>
<div className="relative mt-8 overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee items-center gap-16 pr-16 [animation-duration:44s]">
{[...logos, ...logos].map((l, i) => (
<span key={i} aria-hidden={i >= logos.length} className={cn('whitespace-nowrap text-zinc-400 transition-colors duration-300 hover:text-zinc-950', l.className)}>
{l.name}
</span>
))}
</div>
</div>
</div>
);
}components/niceui/pages/landing/rushes/Editor.tsx
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent, type RefObject } from 'react';
import { Check, ChevronDown, Download, Film, Maximize2, Pause, Play, SkipBack, Sparkles, Volume2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { DitherCanvas, PAPER, SCREEN, scenes, type Field, type SceneId } from './dither';
import { BROLL, GROUPS, PIECES, SHOTS, createClock, layout, pieceAt, useClock, type Clock, type Layout } from './editorModel';
import { CMDS, CmdChip, DirectorPanel, ORDER, ScriptPanel, Timeline, matchCommand, type CmdId, type Msg } from './EditorPanels';
import { tc } from './ui';
type Aspect = '16:9' | '9:16' | '1:1';
const RATIO: Record<Aspect, number> = { '16:9': 16 / 9, '9:16': 9 / 16, '1:1': 1 };
const SRC = 16 / 9;
const FOCUS: Record<SceneId, number> = { portrait: 0.57, coast: 0.62, city: 0.36, peaks: 0.3, reel: 0.5 };
const INTRO: Msg[] = [{ id: 1, role: 'ai', text: 'I watched all 5 takes. It’s a strong story with a few ums and long pauses. Want me to tighten it?' }];
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
function useVisible(ref: RefObject<Element>) {
const [v, setV] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setV(e.isIntersecting), { threshold: 0.2 });
io.observe(el);
return () => io.disconnect();
}, [ref]);
return v;
}
/** What's on screen at time t: the shot, or generated B-roll laid over it. */
function sceneAt(lay: Layout, broll: boolean, t: number) {
const i = pieceAt(lay, t);
if (broll) for (const b of BROLL) if (t >= lay.start[b.from] && t < lay.start[b.to] + lay.dur[b.to]) return { scene: b.scene, label: `GEN · ${b.prompt}`, ai: true };
const s = SHOTS[PIECES[i].shot];
return { scene: s.scene, label: `${s.clip} · ${s.label}`, ai: false };
}
export function Editor() {
const clock = useMemo(createClock, []);
const root = useRef<HTMLDivElement>(null);
const visible = useVisible(root);
const [playing, setPlaying] = useState(false);
const [cut, setCut] = useState(false);
const [captions, setCaptions] = useState(false);
const [aspect, setAspect] = useState<Aspect>('16:9');
const [broll, setBroll] = useState(false);
const [studio, setStudio] = useState(false);
const [msgs, setMsgs] = useState<Msg[]>(INTRO);
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState('');
const [pulse, setPulse] = useState<CmdId | null>(null);
const lay = useMemo(() => layout(cut), [cut]);
const applied: Record<CmdId, boolean> = { cut, captions, reframe: aspect === '9:16', broll, studio };
const live = useRef({ lay, broll, applied, busy, playing });
live.current = { lay, broll, applied, busy, playing };
const timers = useRef<number[]>([]);
const later = (ms: number, f: () => void) => void timers.current.push(window.setTimeout(f, ms));
const touched = useRef(false);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
// Playback: advance the shared clock; loop at the end of the cut.
useEffect(() => {
if (!playing) return;
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const dt = Math.max(0, Math.min(0.1, (now - last) / 1000));
last = now;
const t = clock.get() + dt;
clock.set(t >= live.current.lay.total ? 0 : t);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [playing, clock]);
// Pause off-screen, resume when back.
const resume = useRef(false);
useEffect(() => {
if (!visible) {
resume.current = live.current.playing;
setPlaying(false);
} else if (resume.current) setPlaying(true);
}, [visible]);
const apply = (id: CmdId) => {
if (id === 'cut') {
const old = live.current.lay;
const next = layout(true);
const i = pieceAt(old, clock.get());
clock.set(next.start[i] + Math.min(clock.get() - old.start[i], next.dur[i]));
setCut(true);
}
if (id === 'captions') setCaptions(true);
if (id === 'reframe') setAspect('9:16');
if (id === 'broll') setBroll(true);
if (id === 'studio') setStudio(true);
setPulse(id);
later(1400, () => setPulse(null));
};
const run = (id: CmdId, text?: string) => {
if (live.current.busy) return;
const c = CMDS[id];
const uid = Date.now();
if (live.current.applied[id]) {
setMsgs((m) => [...m, { id: uid, role: 'user', text: text ?? c.prompt }, { id: uid + 1, role: 'ai', text: 'Already done. Try another one, or hit Reset.' }]);
return;
}
live.current.busy = true;
setBusy(true);
setMsgs((m) => [...m, { id: uid, role: 'user', text: text ?? c.prompt }, { id: uid + 1, role: 'ai', steps: c.steps.map((label) => ({ label, done: false })) }]);
c.steps.forEach((_, i) =>
later(560 * (i + 1), () => setMsgs((m) => m.map((x) => (x.id === uid + 1 ? { ...x, steps: x.steps!.map((s, j) => (j <= i ? { ...s, done: true } : s)) } : x)))),
);
later(560 * c.steps.length + 280, () => {
apply(id);
setMsgs((m) => m.map((x) => (x.id === uid + 1 ? { ...x, text: c.done } : x)));
live.current.busy = false;
setBusy(false);
});
};
const submit = (e: FormEvent) => {
e.preventDefault();
const text = draft.trim();
if (!text || busy) return;
setDraft('');
const id = matchCommand(text);
if (id) run(id, text);
else {
const uid = Date.now();
setMsgs((m) => [...m, { id: uid, role: 'user', text }, { id: uid + 1, role: 'ai', text: 'This preview knows five moves: cut fillers, captions, vertical reframe, B-roll and Studio Sound. Try one.' }]);
}
};
const reset = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
touched.current = true;
live.current.busy = false;
setBusy(false);
setCut(false);
setCaptions(false);
setAspect('16:9');
setBroll(false);
setStudio(false);
setMsgs(INTRO);
setDraft('');
clock.set(0);
};
// Attract mode: play, then type two commands — until the visitor takes over.
const started = useRef(false);
useEffect(() => {
if (!visible || started.current) return;
started.current = true;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const type = (text: string, id: CmdId, then?: () => void) => {
[...text].forEach((_, i) => later(34 * i, () => !touched.current && setDraft(text.slice(0, i + 1))));
later(34 * text.length + 420, () => {
if (touched.current) return;
setDraft('');
run(id, text);
if (then) later(560 * CMDS[id].steps.length + 2600, then);
});
};
later(700, () => !touched.current && setPlaying(true));
later(2400, () => !touched.current && type(CMDS.cut.prompt, 'cut', () => !touched.current && type(CMDS.captions.prompt, 'captions')));
}, [visible]);
const takeOver = () => {
if (touched.current) return;
touched.current = true;
if (!live.current.busy) setDraft('');
};
const seek = (t: number) => clock.set(t);
return (
<div ref={root} onPointerDownCapture={takeOver} onKeyDownCapture={takeOver} className="relative overflow-hidden rounded-[22px] border border-zinc-200 bg-white text-left shadow-[0_1px_0_rgba(255,255,255,0.8)_inset,0_50px_100px_-40px_rgba(24,24,27,0.35),0_20px_40px_-24px_rgba(24,24,27,0.25)]">
<TitleBar />
<div className="grid lg:h-[380px] lg:grid-cols-[236px_minmax(0,1fr)_288px]">
<div className="hidden min-h-0 lg:flex">
<div className="flex min-h-0 w-full flex-col">
<ScriptPanel clock={clock} lay={lay} cut={cut} broll={broll} onSeek={seek} />
</div>
</div>
<Viewer clock={clock} lay={lay} broll={broll} captions={captions} aspect={aspect} setAspect={setAspect} playing={playing} setPlaying={setPlaying} />
<div className="hidden min-h-0 lg:flex">
<div className="flex min-h-0 w-full flex-col">
<DirectorPanel msgs={msgs} busy={busy} applied={applied} draft={draft} setDraft={setDraft} onRun={(id) => run(id)} onSubmit={submit} onReset={reset} />
</div>
</div>
</div>
<MobileDirector msgs={msgs} busy={busy} applied={applied} onRun={(id) => run(id)} onReset={reset} />
<Timeline clock={clock} lay={lay} cut={cut} captions={captions} broll={broll} studio={studio} pulse={pulse} onSeek={seek} />
</div>
);
}
function TitleBar() {
const [exp, setExp] = useState<'idle' | 'run' | 'done'>('idle');
const [pct, setPct] = useState(0);
useEffect(() => {
if (exp !== 'run') return;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
const p = clamp01((now - t0) / 2400);
setPct(p);
if (p < 1) raf = requestAnimationFrame(tick);
else setExp('done');
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [exp]);
useEffect(() => {
if (exp !== 'done') return;
const id = window.setTimeout(() => setExp('idle'), 3200);
return () => clearTimeout(id);
}, [exp]);
return (
<div className="relative flex h-11 items-center gap-3 border-b border-zinc-200/80 bg-zinc-50/80 px-3.5">
<div className="flex gap-1.5">
{[0, 1, 2].map((i) => (
<span key={i} className="h-[11px] w-[11px] rounded-full bg-zinc-200 ring-1 ring-inset ring-black/5" />
))}
</div>
<div className="flex min-w-0 items-center gap-1.5 text-[12.5px] text-zinc-500">
<Film className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">Maya Ocampo</span>
<span className="hidden text-zinc-300 sm:inline">/</span>
<span className="truncate font-medium text-zinc-900">Coastline<span className="hidden sm:inline"> · launch film</span></span>
<ChevronDown className="h-3 w-3 shrink-0" />
</div>
<span className="hidden items-center gap-1 rounded-full bg-emerald-50 px-2 py-px text-[10.5px] font-medium text-emerald-700 md:flex">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> Saved
</span>
<div className="ml-auto flex items-center gap-2">
<div className="hidden -space-x-1.5 sm:flex">
{[
['MO', '#18181b'],
['LK', '#ff4f1f'],
['JS', '#a1a1aa'],
].map(([n, c]) => (
<span key={n} className="grid h-6 w-6 place-items-center rounded-full text-[9px] font-semibold text-white ring-2 ring-zinc-50" style={{ background: c }}>
{n}
</span>
))}
</div>
<button className="hidden h-7 items-center rounded-lg px-2.5 text-[12px] font-medium text-zinc-700 ring-1 ring-zinc-200 transition-colors hover:bg-white sm:flex">Share</button>
<button onClick={() => exp === 'idle' && setExp('run')} className="flex h-7 items-center gap-1.5 rounded-lg bg-zinc-950 px-2.5 text-[12px] font-medium text-white transition-all hover:bg-zinc-800 active:scale-95">
<Download className="h-3 w-3" /> Export
</button>
</div>
{exp !== 'idle' && (
<div className="absolute right-3 top-[calc(100%+8px)] z-30 w-60 rounded-xl border border-zinc-200 bg-white p-3 shadow-[0_16px_40px_-12px_rgba(0,0,0,0.25)] animate-scale-in">
<div className="flex items-center justify-between text-[12px] font-medium text-zinc-900">
{exp === 'run' ? 'Exporting 4K · H.265' : 'Ready to publish'}
{exp === 'done' ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <span className="font-mono text-[11px] text-zinc-500">{Math.round(pct * 100)}%</span>}
</div>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-zinc-100">
<div className="h-full rounded-full bg-[#ff4f1f]" style={{ width: `${pct * 100}%` }} />
</div>
<p className="mt-2 font-mono text-[10.5px] text-zinc-500">{exp === 'done' ? 'coastline-launch.mp4 · 38.2 MB' : 'Rendering on 4 GPUs…'}</p>
</div>
)}
</div>
);
}
function Viewer({ clock, lay, broll, captions, aspect, setAspect, playing, setPlaying }: {
clock: Clock;
lay: Layout;
broll: boolean;
captions: boolean;
aspect: Aspect;
setAspect: (a: Aspect) => void;
playing: boolean;
setPlaying: (p: boolean | ((p: boolean) => boolean)) => void;
}) {
const view = useRef({ lay, broll });
view.current = { lay, broll };
const field = useMemo<Field>(() => {
let ct = -1;
let scene = scenes.portrait;
let focus = 0.5;
return (u, v, t, ar) => {
if (t !== ct) {
ct = t;
const id = sceneAt(view.current.lay, view.current.broll, t).scene;
scene = scenes[id];
focus = FOCUS[id];
}
const w = Math.min(1, ar / SRC);
const x0 = Math.min(1 - w, Math.max(0, focus - w / 2));
return scene(x0 + u * w, v, t, SRC);
};
}, []);
return (
<div className="flex min-h-0 min-w-0 flex-col">
<div className="relative flex flex-1 items-center justify-center overflow-hidden bg-zinc-100/80 px-3 py-4 [--h:178px] sm:py-5 sm:[--h:250px] lg:py-0 lg:[--h:282px]" style={{ backgroundImage: 'radial-gradient(#e4e4e7 1px, transparent 1px)', backgroundSize: '14px 14px' }}>
<div className="relative max-w-full overflow-hidden rounded-[10px] bg-[#0d0d0e] shadow-[0_12px_30px_-12px_rgba(0,0,0,0.5)] transition-[width] duration-700 ease-smooth" style={{ height: 'var(--h)', width: `calc(var(--h) * ${RATIO[aspect]})` } as CSSProperties}>
<DitherCanvas field={field} time={clock.get} follow pixel={2} ink={PAPER} bg={SCREEN} className="absolute inset-0" />
<PlayerHud clock={clock} lay={lay} broll={broll} aspect={aspect} />
{captions && <CaptionOverlay clock={clock} lay={lay} vertical={aspect !== '16:9'} />}
{!playing && (
<button onClick={() => setPlaying(true)} aria-label="Play" className="absolute inset-0 grid place-items-center bg-black/10 transition-colors hover:bg-black/0">
<span className="grid h-12 w-12 place-items-center rounded-full bg-white/90 text-zinc-950 shadow-lg backdrop-blur transition-transform hover:scale-105">
<Play className="ml-0.5 h-5 w-5 fill-current" />
</span>
</button>
)}
</div>
</div>
<div className="flex h-11 shrink-0 items-center gap-1.5 border-t border-zinc-200/80 px-2.5">
<button onClick={() => clock.set(0)} aria-label="Back to start" className="grid h-7 w-7 place-items-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900">
<SkipBack className="h-3.5 w-3.5 fill-current" />
</button>
<button onClick={() => setPlaying((p) => !p)} aria-label={playing ? 'Pause' : 'Play'} className="grid h-7 w-7 place-items-center rounded-full bg-zinc-950 text-white transition-transform active:scale-90">
{playing ? <Pause className="h-3 w-3 fill-current" /> : <Play className="ml-px h-3 w-3 fill-current" />}
</button>
<Timecode clock={clock} total={lay.total} />
<div className="ml-auto flex items-center gap-0.5 rounded-lg bg-zinc-100 p-0.5">
{(['16:9', '9:16', '1:1'] as Aspect[]).map((a) => (
<button key={a} onClick={() => setAspect(a)} className={cn('rounded-md px-1.5 py-0.5 font-mono text-[10.5px] transition-all', aspect === a ? 'bg-white text-zinc-950 shadow-sm' : 'text-zinc-500 hover:text-zinc-800')}>
{a}
</button>
))}
</div>
<Volume2 className="ml-1 hidden h-3.5 w-3.5 text-zinc-400 sm:block" />
<Maximize2 className="hidden h-3.5 w-3.5 text-zinc-400 sm:block" />
</div>
</div>
);
}
function Timecode({ clock, total }: { clock: Clock; total: number }) {
const f = useClock(clock, (t) => Math.floor(t * 24));
return (
<span className="ml-1 font-mono text-[11px] tabular-nums text-zinc-900">
{tc(f / 24)}
<span className="hidden text-zinc-400 sm:inline"> / {tc(total)}</span>
</span>
);
}
function PlayerHud({ clock, lay, broll, aspect }: { clock: Clock; lay: Layout; broll: boolean; aspect: Aspect }) {
const label = useClock(clock, (t) => sceneAt(lay, broll, t).label);
const ai = label.startsWith('GEN');
return (
<>
<span className={cn('absolute left-2 top-2 flex max-w-[70%] items-center gap-1 truncate rounded-[5px] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider backdrop-blur', ai ? 'bg-[#ff4f1f] text-white' : 'bg-black/55 text-white/80')}>
{ai && <Sparkles className="h-2.5 w-2.5 shrink-0" />}
<span className="truncate">{label}</span>
</span>
{aspect === '16:9' && <span className="absolute right-2 top-2 rounded-[5px] bg-black/55 px-1.5 py-0.5 font-mono text-[9px] text-white/70 backdrop-blur">4K · 24 FPS</span>}
<span className="pointer-events-none absolute inset-3 border border-dashed border-white/10" />
</>
);
}
function CaptionOverlay({ clock, lay, vertical }: { clock: Clock; lay: Layout; vertical: boolean }) {
const gi = useClock(clock, (t) => GROUPS.findIndex((g) => t >= lay.start[g[0]] - 0.05 && t < lay.start[g[g.length - 1]] + lay.dur[g[g.length - 1]] + 0.3));
const wi = useClock(clock, (t) => pieceAt(lay, t));
if (gi < 0) return null;
return (
<div className={cn('pointer-events-none absolute inset-x-2 flex justify-center', vertical ? 'bottom-[22%]' : 'bottom-[9%]')}>
<p key={gi} className={cn('flex flex-wrap justify-center gap-x-1 gap-y-0.5 text-center font-semibold leading-tight text-white animate-scale-in [text-shadow:0_1px_2px_rgba(0,0,0,0.9)]', vertical ? 'max-w-[150px] text-[13px]' : 'text-[12px] sm:text-[15px]')}>
{GROUPS[gi].map((id) => (
<span key={id} className={cn('rounded-[4px] px-1 transition-all duration-150', id === wi ? 'scale-110 bg-[#ff4f1f] [text-shadow:none]' : '')}>
{PIECES[id].text}
</span>
))}
</p>
</div>
);
}
function MobileDirector({ msgs, busy, applied, onRun, onReset }: { msgs: Msg[]; busy: boolean; applied: Record<CmdId, boolean>; onRun: (id: CmdId) => void; onReset: () => void }) {
const last = msgs[msgs.length - 1];
const step = last.steps?.find((s) => !s.done);
const line = busy && step ? `${step.label}…` : last.text ?? '';
return (
<div className="space-y-2 border-t border-zinc-200/80 px-3 py-2.5 lg:hidden">
<div className="flex items-start gap-2">
<span className="mt-px grid h-5 w-5 shrink-0 place-items-center rounded-md bg-zinc-950">
<Sparkles className="h-3 w-3 text-[#ff4f1f]" />
</span>
<p key={line} className="min-h-[2.5em] flex-1 text-[12px] leading-snug text-zinc-700 animate-fade-in">{line}</p>
<button onClick={onReset} className="shrink-0 text-[11px] text-zinc-400 hover:text-zinc-900">Reset</button>
</div>
<div className="-mx-3 flex gap-1 overflow-x-auto px-3 pb-0.5 [scrollbar-width:none]">
{ORDER.map((id) => (
<CmdChip key={id} id={id} done={applied[id]} disabled={busy} onRun={onRun} />
))}
</div>
</div>
);
}components/niceui/pages/landing/rushes/editorModel.ts
import { useSyncExternalStore } from 'react';
import { hash, type SceneId } from './dither';
export type Kind = 'word' | 'filler' | 'pause';
export type Piece = { id: number; text: string; kind: Kind; shot: number; dur: number };
export type Shot = { scene: SceneId; label: string; clip: string };
export const SHOTS: Shot[] = [
{ scene: 'portrait', label: 'Intro', clip: 'A001_C004' },
{ scene: 'coast', label: 'Coast', clip: 'B002_C011' },
{ scene: 'portrait', label: 'Studio', clip: 'A001_C007' },
{ scene: 'city', label: 'City', clip: 'C003_C002' },
{ scene: 'peaks', label: 'Outro', clip: 'D001_C001' },
];
const SCRIPT = [
"Hi, I'm Maya. {um} Three years ago I quit my job to film the coastline I grew up on.",
'Every morning, {uh} the light does something different.',
'So I built a tiny studio, [] and {like} learned to edit on the fly.',
'Then the city called. [] Long nights, {um} longer edits.',
'Now Rushes cuts my footage while I sleep. [] This is the story, {uh} without the busywork.',
];
export const PIECES: Piece[] = SCRIPT.flatMap((line, shot) =>
line.split(' ').map((raw) => {
if (raw === '[]') return { text: '', kind: 'pause' as Kind, shot, dur: 1 };
if (raw.startsWith('{')) return { text: raw.slice(1, -1), kind: 'filler' as Kind, shot, dur: 0.6 };
return { text: raw, kind: 'word' as Kind, shot, dur: Math.min(0.62, 0.17 + raw.replace(/\W/g, '').length * 0.05) };
}),
).map((p, id) => ({ ...p, id }));
export const FULL = PIECES.reduce((s, p) => s + p.dur, 0);
export const SCALE = Math.ceil(FULL + 1.5);
export const WORDS = PIECES.filter((p) => p.kind === 'word').length;
export const FILLERS = PIECES.filter((p) => p.kind === 'filler').length;
export const PAUSES = PIECES.filter((p) => p.kind === 'pause').length;
export const SAVED = PIECES.filter((p) => p.kind !== 'word').reduce((s, p) => s + p.dur, 0);
export type Layout = { start: number[]; dur: number[]; total: number };
export function layout(cut: boolean): Layout {
const start: number[] = [];
const dur: number[] = [];
let t = 0;
for (const p of PIECES) {
const d = cut && p.kind !== 'word' ? 0 : p.dur;
start.push(t);
dur.push(d);
t += d;
}
return { start, dur, total: t };
}
export const shotRange = (l: Layout, shot: number) => {
const ids = PIECES.filter((p) => p.shot === shot).map((p) => p.id);
const a = l.start[ids[0]];
const b = l.start[ids[ids.length - 1]] + l.dur[ids[ids.length - 1]];
return [a, b] as const;
};
export function pieceAt(l: Layout, t: number) {
for (let i = PIECES.length - 1; i >= 0; i--) if (l.dur[i] > 0 && t >= l.start[i]) return i;
return 0;
}
/** Caption groups: up to four words, broken on punctuation. Fillers and pauses are skipped. */
export const GROUPS: number[][] = (() => {
const out: number[][] = [];
let cur: number[] = [];
for (const p of PIECES) {
if (p.kind !== 'word') continue;
if (cur.length && PIECES[cur[0]].shot !== p.shot) {
out.push(cur);
cur = [];
}
cur.push(p.id);
if (/[.,]$/.test(p.text) || cur.length === 4) {
out.push(cur);
cur = [];
}
}
if (cur.length) out.push(cur);
return out;
})();
const find = (shot: number, word: string) => PIECES.find((p) => p.shot === shot && p.text.replace(/\W/g, '') === word)!.id;
export const BROLL: { from: number; to: number; scene: SceneId; prompt: string }[] = [
{ from: find(0, 'film'), to: find(0, 'on'), scene: 'coast', prompt: 'coastline at dawn' },
{ from: find(2, 'learned'), to: find(2, 'fly'), scene: 'reel', prompt: 'spinning film reel' },
];
/** Deterministic waveform heights for a piece, 0..1. */
export function bars(p: Piece, studio: boolean) {
const n = Math.max(2, Math.round(p.dur / 0.085));
return Array.from({ length: n }, (_, i) => {
const r = hash(p.id * 7 + i, i * 3);
if (p.kind === 'pause') return studio ? 0.04 : 0.1 + r * 0.14;
const env = Math.sin(((i + 0.5) / n) * Math.PI);
const v = (p.kind === 'filler' ? 0.35 : 0.42) + env * 0.48 * (0.55 + r * 0.45);
return studio ? v * 0.92 : Math.min(1, v + 0.08 + r * 0.1);
});
}
/* A tiny clock store so only the playhead, timecode and captions re-render every frame. */
export function createClock() {
let t = 0;
const subs = new Set<() => void>();
return {
get: () => t,
set(v: number) {
t = v;
subs.forEach((f) => f());
},
subscribe(f: () => void) {
subs.add(f);
return () => {
subs.delete(f);
};
},
};
}
export type Clock = ReturnType<typeof createClock>;
export function useClock<T>(clock: Clock, select: (t: number) => T): T {
return useSyncExternalStore(clock.subscribe, () => select(clock.get()));
}components/niceui/pages/landing/rushes/EditorPanels.tsx
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { ArrowUp, AudioLines, Captions, Check, Crop, LoaderCircle, Magnet, MousePointer2, RotateCcw, Scissors, Sparkles, WandSparkles, ZoomIn } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ditherStill, scenes } from './dither';
import { BROLL, FILLERS, FULL, GROUPS, PAUSES, PIECES, SAVED, SCALE, SHOTS, WORDS, bars, pieceAt, shotRange, useClock, type Clock, type Layout } from './editorModel';
import { tc } from './ui';
export type CmdId = 'cut' | 'captions' | 'reframe' | 'broll' | 'studio';
export type Msg = { id: number; role: 'user' | 'ai'; text?: string; steps?: { label: string; done: boolean }[] };
export const CMDS: Record<CmdId, { chip: string; prompt: string; icon: typeof Scissors; steps: string[]; done: string }> = {
cut: {
chip: 'Remove filler words',
prompt: 'Cut the ums and tighten the pauses',
icon: Scissors,
steps: [`Transcribing ${FULL.toFixed(1)}s of dialogue`, `Found ${FILLERS} filler words and ${PAUSES} long pauses`, `Ripple-deleting ${SAVED.toFixed(1)}s, cutting on breaths`],
done: `Done. ${SAVED.toFixed(1)}s tighter, and every cut lands on a breath.`,
},
captions: {
chip: 'Add captions',
prompt: 'Add animated captions',
icon: Captions,
steps: [`Aligning ${WORDS} words to the waveform`, 'Style: Bold, karaoke highlight', 'Placing caption blocks on CC'],
done: 'Captions are on. Fix a word in the script and they update.',
},
reframe: {
chip: 'Reframe 9:16',
prompt: 'Make a vertical version for Shorts',
icon: Crop,
steps: ['Tracking the subject across 5 shots', 'Keeping faces on the rule of thirds', 'Rendering the 9:16 crop'],
done: 'Vertical cut ready. The subject stays in frame in every shot.',
},
broll: {
chip: 'Generate B-roll',
prompt: 'Generate B-roll for the talking parts',
icon: WandSparkles,
steps: ['Reading the script for visual moments', 'Generating 2 shots at 4s, 24 fps', 'Matching the grade to your footage'],
done: 'Added 2 generated shots on V2, over “coastline” and “edit on the fly”.',
},
studio: {
chip: 'Studio Sound',
prompt: 'Clean up the audio',
icon: AudioLines,
steps: ['Isolating voice from room tone', 'Removing 60 Hz hum and harsh esses', 'Mastering to −14 LUFS'],
done: 'Studio Sound applied. It sounds like a treated booth now.',
},
};
export const ORDER: CmdId[] = ['cut', 'captions', 'reframe', 'broll', 'studio'];
export function matchCommand(text: string): CmdId | null {
const s = text.toLowerCase();
if (/\bum|\buh|filler|pause|tight|silence|cut/.test(s)) return 'cut';
if (/caption|subtitle/.test(s)) return 'captions';
if (/vertical|9:16|short|reel|tiktok|reframe|portrait/.test(s)) return 'reframe';
if (/b-?roll|generate|cutaway/.test(s)) return 'broll';
if (/sound|audio|noise|hum|echo|voice/.test(s)) return 'studio';
return null;
}
/* ——— Script / media ——— */
export function ScriptPanel({ clock, lay, cut, broll, onSeek }: { clock: Clock; lay: Layout; cut: boolean; broll: boolean; onSeek: (t: number) => void }) {
const [tab, setTab] = useState<'script' | 'media'>('script');
const cur = useClock(clock, (t) => pieceAt(lay, t));
const box = useRef<HTMLDivElement>(null);
const curEl = useRef<HTMLButtonElement>(null);
useEffect(() => {
const b = box.current;
const el = curEl.current;
if (!b || !el) return;
if (el.offsetTop < b.scrollTop + 24 || el.offsetTop > b.scrollTop + b.clientHeight - 48) b.scrollTo({ top: el.offsetTop - 56, behavior: 'smooth' });
}, [cur]);
return (
<div className="flex min-h-0 flex-col border-r border-zinc-200/80">
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-zinc-200/80 px-2">
{(['script', 'media'] as const).map((k) => (
<button key={k} onClick={() => setTab(k)} className={cn('rounded-md px-2.5 py-1 text-[12px] font-medium capitalize transition-colors', tab === k ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-500 hover:text-zinc-800')}>
{k}
</button>
))}
<span className="ml-auto font-mono text-[10px] text-zinc-400">EN · {WORDS} words</span>
</div>
{tab === 'script' ? (
<div ref={box} className="relative min-h-0 flex-1 overflow-y-auto px-3.5 py-3 [scrollbar-width:none]">
{SHOTS.map((s, si) => (
<div key={si} className="mb-3.5">
<div className="mb-1 flex items-center gap-2 font-mono text-[10px] text-zinc-400">
<span>{tc(shotRange(lay, si)[0]).slice(3)}</span>
<span className="h-px flex-1 bg-zinc-100" />
<span className="uppercase tracking-wider">{s.label}</span>
</div>
<p className="text-[13px] leading-[1.75] text-zinc-800">
{PIECES.filter((p) => p.shot === si).map((p) => {
const active = p.id === cur;
const gone = cut && p.kind !== 'word';
const common = cn('-mx-[2px] rounded-[4px] px-[2px] transition-all duration-300', active && !gone && 'bg-[#ff4f1f] text-white', gone && 'text-zinc-300 line-through decoration-zinc-300');
if (p.kind === 'pause')
return (
<button key={p.id} ref={active ? curEl : undefined} onClick={() => onSeek(lay.start[p.id])} className={cn(common, 'mx-0.5 inline-flex translate-y-[-1px] items-center gap-1 bg-zinc-100 font-mono text-[10px] text-zinc-500', active && !gone && 'bg-zinc-900')}>
‖ {p.dur.toFixed(1)}s
</button>
);
return (
<span key={p.id}>
<button ref={active ? curEl : undefined} onClick={() => onSeek(lay.start[p.id])} className={cn(common, p.kind === 'filler' && !gone && !active && 'text-[#e2400f] underline decoration-[#ff4f1f]/50 decoration-wavy underline-offset-[3px]', !active && !gone && 'hover:bg-zinc-100')}>
{p.text}
</button>{' '}
</span>
);
})}
</p>
</div>
))}
</div>
) : (
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-2 overflow-y-auto p-3 [scrollbar-width:none]">
{[...SHOTS.map((s, i) => ({ key: `s${i}`, scene: s.scene, name: s.clip, d: shotRange(lay, i)[1] - shotRange(lay, i)[0], ai: false })), ...(broll ? BROLL.map((b, i) => ({ key: `b${i}`, scene: b.scene, name: `GEN_${b.scene.toUpperCase()}`, d: 4, ai: true })) : [])].map((m) => (
<div key={m.key} className="group">
<div className="relative aspect-video overflow-hidden rounded-md ring-1 ring-zinc-200 [image-rendering:pixelated]" style={{ backgroundImage: `url(${ditherStill(`thumb-${m.scene}`, scenes[m.scene], 112, 63)})`, backgroundSize: 'cover' }}>
{m.ai && <span className="absolute left-1 top-1 rounded bg-[#ff4f1f] px-1 font-mono text-[8px] font-medium text-white">AI</span>}
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1 font-mono text-[8px] text-white">{m.d.toFixed(1)}s</span>
</div>
<p className="mt-1 truncate font-mono text-[9.5px] text-zinc-500">{m.name}</p>
</div>
))}
</div>
)}
</div>
);
}
/* ——— Director (AI) ——— */
export function DirectorPanel({ msgs, busy, applied, draft, setDraft, onRun, onSubmit, onReset }: {
msgs: Msg[];
busy: boolean;
applied: Record<CmdId, boolean>;
draft: string;
setDraft: (s: string) => void;
onRun: (id: CmdId) => void;
onSubmit: (e: FormEvent) => void;
onReset: () => void;
}) {
const list = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = list.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, [msgs]);
return (
<div className="flex min-h-0 flex-col border-l border-zinc-200/80">
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-zinc-200/80 px-3">
<span className="grid h-5 w-5 place-items-center rounded-md bg-zinc-950">
<Sparkles className="h-3 w-3 text-[#ff4f1f]" />
</span>
<span className="text-[12.5px] font-semibold text-zinc-900">Director</span>
<span className="rounded bg-zinc-100 px-1.5 py-px font-mono text-[9.5px] text-zinc-500">rushes-3</span>
<button onClick={onReset} className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900" title="Reset the demo">
<RotateCcw className="h-3 w-3" /> Reset
</button>
</div>
<div ref={list} className="min-h-0 flex-1 space-y-2.5 overflow-y-auto px-3 py-3 [scrollbar-width:none]">
{msgs.map((m) => (m.role === 'user' ? <UserMsg key={m.id} text={m.text!} /> : <AiMsg key={m.id} m={m} />))}
</div>
<div className="shrink-0 space-y-2 border-t border-zinc-200/80 p-2.5">
<div className="flex flex-wrap gap-1">
{ORDER.map((id) => (
<CmdChip key={id} id={id} done={applied[id]} disabled={busy} onRun={onRun} />
))}
</div>
<form onSubmit={onSubmit} className="flex items-center gap-1.5 rounded-xl border border-zinc-200 bg-white py-1 pl-3 pr-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-colors focus-within:border-zinc-400">
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Tell Director what to do…" aria-label="Command" className="h-7 min-w-0 flex-1 bg-transparent text-[12.5px] text-zinc-900 outline-none placeholder:text-zinc-400" />
<button type="submit" disabled={busy || !draft.trim()} aria-label="Send" className="grid h-7 w-7 place-items-center rounded-lg bg-zinc-950 text-white transition-all disabled:bg-zinc-200 disabled:text-zinc-400 active:scale-95">
<ArrowUp className="h-3.5 w-3.5" />
</button>
</form>
</div>
</div>
);
}
export function CmdChip({ id, done, disabled, onRun, className }: { id: CmdId; done: boolean; disabled: boolean; onRun: (id: CmdId) => void; className?: string }) {
const c = CMDS[id];
const Icon = done ? Check : c.icon;
return (
<button
onClick={() => onRun(id)}
disabled={disabled || done}
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-[3px] text-[11px] font-medium transition-all active:scale-95',
done ? 'border-transparent bg-zinc-100 text-zinc-400' : 'border-zinc-200 bg-white text-zinc-700 hover:border-zinc-300 hover:bg-zinc-50 disabled:opacity-50',
className,
)}
>
<Icon className={cn('h-3 w-3', !done && 'text-[#ff4f1f]')} />
{c.chip}
</button>
);
}
function UserMsg({ text }: { text: string }) {
return (
<div className="flex justify-end animate-fade-up">
<p className="max-w-[86%] rounded-2xl rounded-br-md bg-zinc-950 px-3 py-1.5 text-[12px] leading-snug text-white">{text}</p>
</div>
);
}
function AiMsg({ m }: { m: Msg }) {
return (
<div className="flex gap-2 animate-fade-up">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-zinc-100">
<Sparkles className="h-2.5 w-2.5 text-[#ff4f1f]" />
</span>
<div className="min-w-0 flex-1 space-y-1.5">
{m.steps && (
<div className="space-y-1 rounded-xl border border-zinc-200/80 bg-zinc-50/70 p-2">
{m.steps.map((s) => (
<div key={s.label} className={cn('flex items-center gap-1.5 text-[11px] transition-colors', s.done ? 'text-zinc-600' : 'text-zinc-400')}>
{s.done ? <Check className="h-3 w-3 shrink-0 text-[#ff4f1f]" /> : <LoaderCircle className="h-3 w-3 shrink-0 animate-spin" />}
<span className="truncate">{s.label}</span>
</div>
))}
</div>
)}
{m.text && <p className="text-[12px] leading-snug text-zinc-700 animate-fade-in">{m.text}</p>}
</div>
</div>
);
}
/* ——— Timeline ——— */
const pct = (s: number) => `${(s / SCALE) * 100}%`;
const TRACK_LABELS = ['V2', 'V1', 'A1', 'CC'];
export function Timeline({ clock, lay, cut, captions, broll, studio, pulse, onSeek }: {
clock: Clock;
lay: Layout;
cut: boolean;
captions: boolean;
broll: boolean;
studio: boolean;
pulse: CmdId | null;
onSeek: (t: number) => void;
}) {
const area = useRef<HTMLDivElement>(null);
const drag = useRef(false);
const seekAt = (clientX: number) => {
const r = area.current!.getBoundingClientRect();
onSeek(Math.max(0, Math.min(lay.total - 0.02, ((clientX - r.left) / r.width) * SCALE)));
};
return (
<div className="border-t border-zinc-200/80 bg-zinc-50/60">
<div className="flex h-9 items-center gap-0.5 border-b border-zinc-200/70 px-2">
{[MousePointer2, Scissors, Magnet, ZoomIn].map((I, i) => (
<span key={i} className={cn('grid h-6 w-6 place-items-center rounded-md', i === 0 ? 'bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200' : i === 2 ? 'text-[#ff4f1f]' : 'text-zinc-400')}>
<I className="h-3.5 w-3.5" />
</span>
))}
<span className="mx-2 hidden h-4 w-px bg-zinc-200 sm:block" />
<span className="hidden font-mono text-[10px] uppercase tracking-wider text-zinc-400 sm:block">Ripple · Snap</span>
<div className="ml-auto flex items-center gap-1.5">
{studio && <Badge>Studio Sound</Badge>}
{cut && <Badge accent>−{(FULL - lay.total).toFixed(1)}s</Badge>}
<span className="font-mono text-[10.5px] tabular-nums text-zinc-500">{tc(lay.total)}</span>
</div>
</div>
<div className="grid grid-cols-[34px_minmax(0,1fr)] sm:grid-cols-[42px_minmax(0,1fr)]">
<div className="border-r border-zinc-200/70 pt-[22px]">
{TRACK_LABELS.map((l, i) => (
<div key={l} className={cn('flex items-center justify-center font-mono text-[9.5px] font-medium text-zinc-400', ['h-7', 'h-11', 'h-10', 'h-7'][i])}>
{l}
</div>
))}
</div>
<div
ref={area}
className="relative cursor-text touch-pan-y select-none overflow-hidden pr-1"
onPointerDown={(e) => {
drag.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
seekAt(e.clientX);
}}
onPointerMove={(e) => drag.current && seekAt(e.clientX)}
onPointerUp={() => (drag.current = false)}
onPointerCancel={() => (drag.current = false)}
>
<Ruler />
{/* V2 — B-roll */}
<div className={cn('relative h-7 border-b border-zinc-200/60 transition-colors duration-700', pulse === 'broll' && 'bg-[#ff4f1f]/[0.06]')}>
{!broll && <span className="absolute inset-y-0 left-2 flex items-center text-[10px] text-zinc-300">Drop B-roll here, or ask Director</span>}
{BROLL.map((b, i) => (
<div key={i} className={cn('absolute inset-y-[3px] origin-left overflow-hidden rounded-[5px] ring-1 ring-[#ff4f1f] transition-all duration-700 ease-smooth', broll ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0')} style={{ left: pct(lay.start[b.from]), width: pct(lay.start[b.to] + lay.dur[b.to] - lay.start[b.from]), transitionDelay: broll ? `${i * 160}ms` : '0ms' }}>
<Strip scene={b.scene} />
<span className="absolute left-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded bg-[#ff4f1f] px-1 font-mono text-[8.5px] font-medium text-white">
<Sparkles className="h-2 w-2" /> AI
</span>
</div>
))}
</div>
{/* V1 — main */}
<div className={cn('relative h-11 border-b border-zinc-200/60 transition-colors duration-700', pulse === 'reframe' && 'bg-[#ff4f1f]/[0.06]')}>
{SHOTS.map((s, i) => {
const [a, b] = shotRange(lay, i);
return (
<div key={i} className="absolute inset-y-[3px] overflow-hidden rounded-[5px] ring-1 ring-inset ring-black/10 transition-all duration-700 ease-smooth" style={{ left: `calc(${pct(a)} + 1px)`, width: `calc(${pct(b - a)} - 2px)` }}>
<Strip scene={s.scene} />
<span className="absolute left-1 top-1 hidden rounded-[3px] bg-black/70 px-1 font-mono text-[8.5px] leading-[13px] text-white sm:block">{s.clip}</span>
</div>
);
})}
{PIECES.filter((p) => p.kind !== 'word').map((p) => (
<div
key={p.id}
className={cn('absolute inset-y-[3px] rounded-[3px] transition-all duration-700 ease-smooth', cut ? 'opacity-0' : 'opacity-100')}
style={{ left: pct(lay.start[p.id]), width: pct(lay.dur[p.id]), background: 'repeating-linear-gradient(135deg, rgba(255,79,31,0.9) 0 2px, rgba(255,79,31,0.35) 2px 5px)' }}
/>
))}
</div>
{/* A1 — dialogue */}
<div className={cn('relative h-10 border-b border-zinc-200/60 transition-colors duration-700', (pulse === 'studio' || pulse === 'cut') && 'bg-[#ff4f1f]/[0.06]')}>
{PIECES.map((p) => (
<div key={p.id} className="absolute inset-y-1 flex items-center justify-between gap-px overflow-hidden px-px transition-all duration-700 ease-smooth" style={{ left: pct(lay.start[p.id]), width: pct(lay.dur[p.id]) }}>
{bars(p, studio).map((h, i) => (
<span
key={i}
className={cn('w-[2px] shrink-0 rounded-full transition-[height] duration-700', p.kind === 'filler' ? 'bg-[#ff4f1f]' : p.kind === 'pause' ? 'bg-zinc-400' : 'bg-zinc-800')}
style={{ height: `${h * 100}%` }}
/>
))}
</div>
))}
</div>
{/* CC — captions */}
<div className={cn('relative h-7 transition-colors duration-700', pulse === 'captions' && 'bg-[#ff4f1f]/[0.06]')}>
{!captions && <span className="absolute inset-y-0 left-2 flex items-center text-[10px] text-zinc-300">No captions yet</span>}
{GROUPS.map((g, i) => {
const a = lay.start[g[0]];
const b = lay.start[g[g.length - 1]] + lay.dur[g[g.length - 1]];
return (
<div key={i} className={cn('absolute inset-y-[4px] overflow-hidden rounded-[4px] border border-zinc-300 bg-white px-1 transition-all duration-500 ease-smooth', captions ? 'opacity-100' : 'scale-y-0 opacity-0')} style={{ left: `calc(${pct(a)} + 1px)`, width: `calc(${pct(b - a)} - 2px)`, transitionDelay: captions ? `${i * 25}ms` : '0ms' }}>
<span className="block truncate text-[8.5px] leading-[17px] text-zinc-600">{g.map((id) => PIECES[id].text).join(' ')}</span>
</div>
);
})}
</div>
<Playhead clock={clock} />
</div>
</div>
</div>
);
}
function Ruler() {
const marks = Array.from({ length: Math.floor(SCALE / 5) + 1 }, (_, i) => i * 5);
return (
<div className="relative h-[22px] border-b border-zinc-200/70" style={{ backgroundImage: `repeating-linear-gradient(90deg, #d4d4d8 0 1px, transparent 1px ${100 / SCALE}%)`, backgroundSize: '100% 5px', backgroundRepeat: 'no-repeat', backgroundPosition: 'bottom' }}>
{marks.map((s) => (
<span key={s} className="absolute top-1 font-mono text-[9px] text-zinc-400" style={{ left: `calc(${pct(s)} + 3px)` }}>
{`00:${String(s).padStart(2, '0')}`}
</span>
))}
</div>
);
}
function Playhead({ clock }: { clock: Clock }) {
const t = useClock(clock, (v) => v);
return (
<div className="pointer-events-none absolute inset-y-0 z-10 w-px bg-[#ff4f1f]" style={{ left: pct(t) }}>
<span className="absolute -left-[5px] top-0 h-3 w-[11px] rounded-b-[3px] bg-[#ff4f1f] [clip-path:polygon(0_0,100%_0,100%_60%,50%_100%,0_60%)]" />
</div>
);
}
function Strip({ scene }: { scene: keyof typeof scenes }) {
return <div className="absolute inset-0 [image-rendering:pixelated]" style={{ backgroundImage: `url(${ditherStill(`strip-${scene}`, scenes[scene], 64, 36, 3)})`, backgroundSize: 'auto 100%', backgroundRepeat: 'repeat-x' }} />;
}
function Badge({ children, accent }: { children: ReactNode; accent?: boolean }) {
return <span className={cn('rounded-full px-1.5 py-px font-mono text-[9.5px] font-medium animate-scale-in', accent ? 'bg-[#ff4f1f]/10 text-[#e2400f]' : 'bg-zinc-200/70 text-zinc-600')}>{children}</span>;
}components/niceui/pages/landing/rushes/Script.tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { AudioLines, Captions, FileVideo2, LoaderCircle, RotateCcw, Scissors, Search, Sparkles, Type } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Reveal } from '@/components/niceui/pages/landing/shared/motion';
import { hash } from './dither';
import { SectionHead, Serif } from './ui';
type Word = { text: string; dur: number; filler: boolean; flub: boolean };
const LINE =
'So, {um} the first thing you learn on a shoot is that {like} the light never waits for you. We shot the whole opening on a ~Tuesday~, {uh} right before the storm rolled in, and {you} {know} that’s the shot everyone remembers.';
const WORDS: Word[] = LINE.split(' ').map((raw) => {
const filler = raw.startsWith('{');
const flub = raw.startsWith('~');
const text = raw.replace(/[{}~]/g, '');
return { text, filler, flub, dur: filler ? 0.5 : 0.16 + text.replace(/\W/g, '').length * 0.045 };
});
const FILLER_IDS = WORDS.map((w, i) => (w.filler ? i : -1)).filter((i) => i >= 0);
const FLUB = WORDS.findIndex((w) => w.flub);
const TOTAL = WORDS.reduce((s, w) => s + w.dur, 0);
const points = [
{ icon: Type, title: 'Delete a sentence, lose the shot', body: 'Strike words in the script and the picture, audio and captions ripple with it.' },
{ icon: AudioLines, title: 'Overdub a flub', body: 'Type the word you meant. Rushes says it in your voice, matched to the room.' },
{ icon: Search, title: 'Search what was said', body: 'Every take is transcribed, so “that line about the storm” is one search away.' },
];
export function ScriptSection() {
return (
<section id="rushes-script" className="relative scroll-mt-16 py-24 md:py-32">
<div className="mx-auto grid max-w-7xl grid-cols-[minmax(0,1fr)] items-center gap-14 px-5 sm:px-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
<div>
<SectionHead
tag="Text-based editing"
title={
<>
Edit the words.
<br />
<Serif>The cut follows.</Serif>
</>
}
body="Your footage arrives as a document. Read it, trim it, rewrite it. Rushes keeps every frame, waveform and caption in sync underneath."
/>
<div className="mt-10 space-y-6">
{points.map((p, i) => (
<Reveal key={p.title} delay={i * 90} className="flex gap-4">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-zinc-200 bg-white text-zinc-900 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p.icon className="h-[18px] w-[18px]" />
</span>
<div>
<h3 className="text-[16px] font-semibold tracking-[-0.01em] text-zinc-950">{p.title}</h3>
<p className="mt-1 text-[14.5px] leading-relaxed text-zinc-500">{p.body}</p>
</div>
</Reveal>
))}
</div>
</div>
<Reveal delay={120}>
<ScriptCard />
</Reveal>
</div>
</section>
);
}
function ScriptCard() {
const [gone, setGone] = useState<Set<number>>(new Set());
const [fixed, setFixed] = useState(false);
const [dub, setDub] = useState<'closed' | 'open' | 'busy'>('closed');
const [fix, setFix] = useState('Thursday');
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const toggle = (i: number) =>
setGone((g) => {
const n = new Set(g);
if (n.has(i)) n.delete(i);
else n.add(i);
return n;
});
const removeFillers = () =>
FILLER_IDS.forEach((id, k) => timers.current.push(window.setTimeout(() => setGone((g) => new Set(g).add(id)), k * 110)));
const restore = () => {
timers.current.forEach(clearTimeout);
setGone(new Set());
setFixed(false);
setDub('closed');
setFix('Thursday');
};
const overdub = () => {
setDub('busy');
timers.current.push(
window.setTimeout(() => {
setFixed(true);
setDub('closed');
}, 1100),
);
};
const dur = useMemo(() => WORDS.reduce((s, w, i) => s + (gone.has(i) ? 0 : w.dur), 0), [gone]);
const fillersLeft = FILLER_IDS.filter((i) => !gone.has(i)).length;
return (
<div className="relative rounded-[28px] border border-zinc-200/80 bg-white p-2 shadow-[0_40px_80px_-40px_rgba(24,24,27,0.3)]">
<div className="rounded-[22px] border border-zinc-100 bg-zinc-50/60">
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-200/70 px-4 py-3">
<FileVideo2 className="h-4 w-4 text-zinc-500" />
<span className="text-[13px] font-medium text-zinc-900">interview_take3</span>
<span className="font-mono text-[11px] text-zinc-400">.rushes</span>
<div className="ml-auto flex items-center gap-1.5">
<button onClick={removeFillers} disabled={!fillersLeft} className="inline-flex h-8 items-center gap-1.5 rounded-full bg-zinc-950 px-3 text-[12px] font-medium text-white transition-all hover:bg-zinc-800 disabled:bg-zinc-200 disabled:text-zinc-400 active:scale-95">
<Scissors className="h-3.5 w-3.5" /> Remove {fillersLeft || ''} filler{fillersLeft === 1 ? '' : 's'}
</button>
<button onClick={restore} aria-label="Restore" className="grid h-8 w-8 place-items-center rounded-full text-zinc-500 ring-1 ring-zinc-200 transition-colors hover:bg-white hover:text-zinc-900">
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="relative px-5 pb-6 pt-5 sm:px-7">
<p className="mb-3 font-mono text-[10.5px] uppercase tracking-[0.14em] text-zinc-400">Maya Ocampo · 00:41:12:03</p>
<p className="text-[19px] leading-[1.7] tracking-[-0.01em] text-zinc-900 sm:text-[22px]">
{WORDS.map((w, i) => {
const off = gone.has(i);
if (w.flub)
return (
<span key={i} className="relative">
<button
onClick={() => !fixed && setDub((d) => (d === 'closed' ? 'open' : 'closed'))}
className={cn(
'-mx-1 rounded-md px-1 transition-all duration-500',
fixed ? 'bg-[#ff4f1f]/10 text-[#d63a0c]' : 'underline decoration-[#ff4f1f] decoration-dotted decoration-2 underline-offset-[5px] hover:bg-zinc-100',
)}
>
{fixed ? (
<span className="inline-flex items-center gap-1">
{fix || 'Thursday'}
<Sparkles className="h-3.5 w-3.5" />
</span>
) : (
w.text
)}
</button>
{!fixed && dub === 'closed' && (
<button onClick={() => setDub('open')} className="ml-1 inline-flex -translate-y-[3px] items-center gap-0.5 rounded-full bg-[#ff4f1f] px-1.5 py-px align-middle font-mono text-[9.5px] font-medium uppercase tracking-wider text-white transition-transform hover:scale-105">
<Sparkles className="h-2.5 w-2.5" /> fix
</button>
)}
{dub !== 'closed' && (
<span className="absolute bottom-[calc(100%+10px)] left-1/2 z-10 w-[230px] -translate-x-1/2 rounded-2xl border border-zinc-200 bg-white p-2.5 text-left shadow-[0_20px_40px_-12px_rgba(0,0,0,0.25)] animate-scale-in">
<span className="flex items-center gap-1.5 px-1 text-[11px] font-medium text-zinc-500">
<AudioLines className="h-3.5 w-3.5 text-[#ff4f1f]" /> Overdub in Maya’s voice
</span>
<span className="mt-2 flex items-center gap-1.5">
<input value={fix} onChange={(e) => setFix(e.target.value)} disabled={dub === 'busy'} aria-label="Replacement word" className="h-8 min-w-0 flex-1 rounded-lg border border-zinc-200 px-2 text-[14px] text-zinc-900 outline-none focus:border-zinc-400" />
<button onClick={overdub} disabled={dub === 'busy' || !fix.trim()} className="inline-flex h-8 items-center gap-1 rounded-lg bg-zinc-950 px-2.5 text-[12px] font-medium text-white disabled:opacity-60">
{dub === 'busy' ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : 'Fix'}
</button>
</span>
</span>
)}{' '}
</span>
);
return (
<span key={i}>
<button
onClick={() => toggle(i)}
className={cn(
'-mx-[3px] rounded-md px-[3px] transition-all duration-300',
off ? 'text-zinc-300 line-through decoration-[#ff4f1f] decoration-2' : w.filler ? 'bg-[#ff4f1f]/[0.08] text-[#d63a0c] hover:bg-[#ff4f1f]/15' : 'hover:bg-zinc-100',
)}
>
{w.text}
</button>{' '}
</span>
);
})}
</p>
</div>
<div className="border-t border-zinc-200/70 px-4 pb-4 pt-3 sm:px-5">
<div className="relative flex h-14 w-full min-w-0 items-center overflow-hidden">
{WORDS.map((w, i) => (
<div key={i} className="flex h-full min-w-0 items-center justify-around overflow-hidden transition-all duration-700 ease-smooth" style={{ flexGrow: gone.has(i) ? 0 : w.dur, flexBasis: 0, opacity: gone.has(i) ? 0 : 1 }}>
{Array.from({ length: Math.max(2, Math.round(w.dur * 16)) }, (_, b) => {
const r = hash(i * 13 + b, b * 7 + 3);
const env = Math.sin(((b + 0.5) / Math.max(2, Math.round(w.dur * 16))) * Math.PI);
const regen = w.flub && fixed;
return (
<span
key={b}
className={cn('w-[2px] shrink-0 rounded-full', w.filler ? 'bg-[#ff4f1f]/70' : regen ? 'bg-[#ff4f1f] animate-fade-in' : 'bg-zinc-800')}
style={{ height: `${(0.22 + env * (0.45 + r * 0.33)) * 100}%` }}
/>
);
})}
</div>
))}
<span className="pointer-events-none absolute inset-y-0 left-0 w-px bg-[#ff4f1f] [animation:rushes-sweep_9s_linear_infinite]" />
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-[12px] text-zinc-500">
<span className="font-mono tabular-nums text-zinc-900">00:{dur.toFixed(2).padStart(5, '0')}</span>
{dur < TOTAL - 0.01 && <span className="rounded-full bg-[#ff4f1f]/10 px-2 py-px font-mono text-[11px] text-[#d63a0c] animate-scale-in">−{(TOTAL - dur).toFixed(2)}s</span>}
<span className="ml-auto flex items-center gap-3">
{[
[FileVideo2, 'Video'],
[AudioLines, 'Audio'],
[Captions, 'Captions'],
].map(([I, l]) => {
const Icon = I as typeof FileVideo2;
return (
<span key={l as string} className="flex items-center gap-1">
<Icon className="h-3.5 w-3.5" /> {l as string}
</span>
);
})}
<span className="flex items-center gap-1 text-emerald-600">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> in sync
</span>
</span>
</div>
</div>
</div>
</div>
);
}