diff --git a/.changeset/peering-launch-film.md b/.changeset/peering-launch-film.md new file mode 100644 index 000000000..3846980fa --- /dev/null +++ b/.changeset/peering-launch-film.md @@ -0,0 +1,6 @@ +--- +"nostream": patch +"nostream-website": patch +--- + +feat(website): add the relay peering launch film and home-page section diff --git a/website/hero-animation/README.md b/website/hero-animation/README.md index 520279a40..073b67cd6 100644 --- a/website/hero-animation/README.md +++ b/website/hero-animation/README.md @@ -68,6 +68,23 @@ pnpm run render:release:small # → output/release/nostream-3.4.0-low.mp4 (~2.3 pnpm run frames:release # → output/release/frames/f0060.png … (one still per scene + the end card) ``` +## Peering launch film + +A 40 s cut for relay peering ([#942](https://github.com/cameri/nostream/issues/942)), in the same dialect as the 3.3.0 release film: **1200 frames** at 1920×1080, composed in `src/peering-film.ts` and rendered by `src/peering-render.ts`. Scene copy and claim traceability live in [`docs/peering-film-copy.md`](docs/peering-film-copy.md) — draft that before changing on-screen text. + +1. **People** (frames 0–240): run a relay for your people, not the whole network — peers for its scope, fetches on demand. +2. **Worth** (240–520): your archive is worth something now; sell feeds in sats; flat per-period terms, never per event. +3. **Fast** (520–760): cache peers and an operator-configured window (e.g. the last hour); hot feed near readers. +4. **Middle** (760–980): relays that only talk to relays — archive/cache infrastructure, edges facing users. +5. **Close** (980–1200): today every relay tries to be everything → one thing well; the network assembles the rest. + +```bash +pnpm run render:peering # → output/peering/nostream-peering.mp4 +pnpm run render:peering:small # → output/peering/nostream-peering-low.mp4 +pnpm run frames:peering # → output/peering/frames/f0150.png … (one still per scene + the end card) +cd ../site && pnpm run media # → public/media/peering.mp4, peering-poster.webp (home #peering) +``` + ## Admin console film **35 s** at 1920×1080. Opens on “Operator Console”, then each feature is a short explanation on the landing-hero backdrop (dot grid, planet, sparkle halo) followed by that screenshot full frame: metrics, settings, NIP-66, notifications. @@ -92,6 +109,7 @@ After changing the admin film, run `pnpm run media` in `website/site` to re-enco - Timeline & layout: `src/film.ts` (scene windows `HERO`, `WHAT`, `FLOW`, `FEATURES`, `DEPLOY`) - Release film: `src/release-film.ts` (scene windows `PROBLEM`, `WAIT`, `DOCS`, `CLOSE`), rendered by `src/release-render.ts` +- Peering film: `src/peering-film.ts` (scene windows `PEOPLE`, `WORTH`, `FAST`, `MIDDLE`, `CLOSE`), rendered by `src/peering-render.ts` - Admin film: `src/admin-film.ts`, rendered by `src/admin-render.ts` - Palette & fonts: `src/theme.ts` - Logos / planet / grid: `src/build-assets.ts` (source SVGs in `assets/icons-raw/`, Nostr PNG in `assets/logos/`) diff --git a/website/hero-animation/docs/peering-film-copy.md b/website/hero-animation/docs/peering-film-copy.md new file mode 100644 index 000000000..d7913f1f4 --- /dev/null +++ b/website/hero-animation/docs/peering-film-copy.md @@ -0,0 +1,109 @@ +# Relay peering launch film — scene copy + +Source: [nostream#942](https://github.com/cameri/nostream/issues/942). Composition: `src/peering-film.ts`. +40 s · 1920×1080 · 30 fps · 1200 frames. One job per scene; jobs-to-be-done only (no protocol tutorial). + +**Approval:** Draft for maintainer review before the peering release ships on the site. + +## Factual guardrails (#931, #932–#940) + +| Claim on screen | Spec / issue | +|---|---| +| **Flat or traffic-based** fees for a **period** the operator sets | #931 paid peering terms (`amount`, `unit`, `period` in NIP-11) | +| **Never per event** | #931 out of scope: no per-event payments | +| **Lightning**, **sats**, **between relays** | #931 / #937 settlement between peer relays | +| **Cache window** operator-configured; “the last hour” is an **example** | #931 cache peers + rolling-window scope in NIP-11 | +| **Ask an archive** outside the window | #931 cache miss must not imply “event does not exist” | +| **Sell feeds** to other relays | #931 archive/backbone peers; paid terms | +| No operator names, adoption stats, or network metrics | #942 production notes | + +--- + +## Scene 1 · PEOPLE (frames 0–240) + +**Beat:** Run a relay for your people, not the whole network. + +| Element | Copy | +|---|---| +| Kicker | `01 — YOUR PEOPLE` | +| Headline | Run a relay for your people. | +| Accent | Not the whole network. | +| Card 1 | **SCOPE** · Your people · the people you follow | +| Card 2 | **PEERING** · Their relays · the ones your people use | +| Card 3 | **FETCHES** · The rest · on demand | +| Payoff | Feel complete without ingesting everything. | + +**Issue gloss:** Community relay peers for its scope and fetches the rest on demand. + +--- + +## Scene 2 · WORTH (frames 240–520) + +**Beat:** Your archive is worth something now. + +| Element | Copy | +|---|---| +| Kicker | `02 — YOUR ARCHIVE` | +| Headline | Your archive / is worth something now. | +| Sub | Sell feeds to other relays — settled in sats. | +| Panel | PEERING TERMS · **PER PERIOD** chip | +| Terms | fee: flat or traffic-based — you set the terms · period: you set the length · settlement: Lightning, in sats, between relays · per event: **never** | +| Payoff | A product, not a cost center. | + +**Issue gloss:** Deep history can be sold to other relays; completeness becomes a product. + +--- + +## Scene 3 · FAST (frames 520–760) + +**Beat:** Fast, everywhere. + +| Element | Copy | +|---|---| +| Kicker | `03 — HOT FEED` | +| Headline | Fast, everywhere. | +| Sub | The hot feed, near the readers. | +| Track | OLDER THAN THE WINDOW · THE HOT FEED | +| Note A | BEFORE THIS CUT · Ask an archive. · outside the window | +| Note B | CACHE PEERS · A rolling window · the last hour, or whatever you configure | +| Payoff | Small relays feel instant — without owning the firehose. | + +**Issue gloss:** Cache peers holding only the last hour put hot feed near readers. + +--- + +## Scene 4 · MIDDLE (frames 760–980) + +**Beat:** Relays that only talk to relays. + +| Element | Copy | +|---|---| +| Kicker | `04 — THE MIDDLE` | +| Headline | Relays that only talk to relays. | +| Accent | The network can grow a middle. | +| Archive | Deep history · backbone infrastructure | +| Cache | Rolling window · hot feed near readers | +| Edge | **EDGE** · Readers connect · the relay readers connect to | +| Payoff | Infrastructure behind. Users in front. | + +**Issue gloss:** Archives and caches as infrastructure; edge relays facing readers. + +--- + +## Scene 5 · CLOSE (frames 980–1200) + +**Beat:** Inversion — one thing well; the network assembles the rest. + +| Element | Copy | +|---|---| +| Contrast | Today, every relay tries to be everything. | +| Headline | Each relay gets to be one thing well. | +| Accent | The network assembles the rest. | +| Lockup | [nostream mark] nostream relay peering | + +--- + +## Site + +After render: `pnpm run media` in `website/site` → `public/media/peering.mp4`, `peering-poster.webp`. +Home section: `#peering` (`Peering.astro`). diff --git a/website/hero-animation/package.json b/website/hero-animation/package.json index 07148a32f..107158711 100644 --- a/website/hero-animation/package.json +++ b/website/hero-animation/package.json @@ -16,6 +16,9 @@ "render:release": "tsx src/release-render.ts video very_high", "render:release:small": "tsx src/release-render.ts video low", "frames:release": "tsx src/release-render.ts frames 60 300 650 1000 1199", + "render:peering": "tsx src/peering-render.ts video very_high", + "render:peering:small": "tsx src/peering-render.ts video low", + "frames:peering": "tsx src/peering-render.ts frames 150 470 680 940 1100 1199", "preview:admin": "tsx src/admin-render.ts preview --open", "render:admin": "tsx src/admin-render.ts video very_high", "render:admin:small": "tsx src/admin-render.ts video low", diff --git a/website/hero-animation/src/peering-film.ts b/website/hero-animation/src/peering-film.ts new file mode 100644 index 000000000..b109559f0 --- /dev/null +++ b/website/hero-animation/src/peering-film.ts @@ -0,0 +1,585 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Composition, Layer, LayerAnimation, Vignette } from 'framefields' +import { + BG, + BODY, + FG, + FG_2, + FONT_MONO, + FONT_SANS, + FPS, + H, + LINE, + MUTED, + monoWidth, + PANEL, + PANEL_BORDER, + PINK, + TEAL, + W, + WIRE, +} from './theme.js' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const asset = (name: string) => path.join(ROOT, 'assets', 'gen', `${name}.png`) + +/** + * Relay peering launch film. + * Same visual dialect as the 3.3.0 release film (omp.sh palette, dot grid, + * particle planet, terminal cards): 40 s @ 30 fps = + * 1200 frames. One job per scene, framed as what peering makes possible. + * + * Scene copy doc: docs/peering-film-copy.md (#942). On-screen claims follow the peering plan (#931–#940): + * - terms are flat or traffic-based fees for a period the operator sets, never per event + * - settlement is Lightning between relays, in sats + * - a cache window is operator-configured; "the last hour" is one such window + * No named operators, counts, or network statistics. + */ +export const DURATION_FILM = 1200 + +type Node = + | ReturnType + | ReturnType + | ReturnType + | ReturnType +type Key = [number, number] | [number, number, string] +type Window = [number, number] + +const LAST = DURATION_FILM - 1 + +/** Scene boundaries (frames). One idea each, then the inversion over the planet. */ +const PEOPLE = { start: 0, end: 240 } +const WORTH = { start: 240, end: 520 } +const FAST = { start: 520, end: 760 } +const MIDDLE = { start: 760, end: 980 } +const CLOSE = { start: 980, end: DURATION_FILM } + +const INK = '#08080a' + +const rect = (x: number, y: number, w: number, h: number, opts: Record = {}) => + Layer.box({ position: 'absolute', x, y, width: w, height: h, opacity: 0, ...opts }) + +const img = (name: string, x: number, y: number, size: number) => + Layer.image(asset(name), { position: 'absolute', x, y, width: size, height: size, opacity: 0 }) + + +function mergeWindows(windows: Window[], gap: number): Window[] { + const sorted = [...windows].sort((a, b) => a[0] - b[0]) + const out: Window[] = [] + for (const w of sorted) { + const prev = out[out.length - 1] + if (prev && w[0] <= prev[1] + gap) { + prev[1] = Math.max(prev[1], w[1]) + } else { + out.push([...w]) + } + } + return out +} + +function windowKeys(windows: Window[], peak = 1, inF = 14, outF = 14): Key[] { + const keys: Key[] = [] + let last = -1 + const push = (f: number, v: number, ease?: string) => { + const frame = Math.max(0, Math.min(LAST, Math.round(f))) + if (frame <= last) return + keys.push(ease ? [frame, v, ease] : [frame, v]) + last = frame + } + const merged = mergeWindows(windows, inF + outF) + if (merged[0][0] > 0) push(0, 0) + for (const [s, e] of merged) { + push(s, 0) + push(s + inF, peak, 'power2.out') + if (e < DURATION_FILM) { + push(e - outF, peak) + push(e, 0, 'power2.in') + } + } + return keys +} + +interface Show { + start: number + end?: number + dy?: number + inF?: number + outF?: number + peak?: number +} + +function show(y: number, { start, end = DURATION_FILM, dy = 0, inF = 14, outF = 14, peak = 1 }: Show) { + const anim = LayerAnimation.create().keys('opacity', windowKeys([[start, end]], peak, inF, outF)) + if (dy) { + const yk: Key[] = [ + [start, y + dy], + [start + inF + 8, y, 'power3.out'], + ] + if (end < DURATION_FILM) { + yk.push([end - outF, y]) + yk.push([end, y - dy * 0.6, 'power2.in']) + } + anim.keys('y', yk) + } + return anim +} + +const mono = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_MONO, + fontSize: size, + fill: BODY, + opacity: 0, + ...opts, + }) + +const sans = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_SANS, + fontSize: size, + fontWeight: 700, + fill: FG, + letterSpacing: -1.2, + opacity: 0, + ...opts, + }) + +/** Headline that rises in and fades with its scene, matching the 3.3.0 film. */ +function headlineAnim(y: number, start: number, end: number, delay: number) { + const a = start + delay + return LayerAnimation.create() + .keys('opacity', [ + [a, 0], + [a + 22, 1, 'power2.out'], + [end - 14, 1], + [end, 0, 'power2.in'], + ]) + .fromTo('y', y + 22, y, { start: a, end: a + 28, ease: 'power3.out' }) +} + +export async function buildFilm(): Promise { + const pending: { node: Node; anim?: LayerAnimation }[] = [] + const add = (node: Node, anim?: LayerAnimation) => pending.push({ node, anim }) + + const sceneKicker = (label: string, start: number, end: number, y = 132) => { + const hx = 168 + add(mono(label, hx, y, 15, { letterSpacing: 3, fill: TEAL }), show(y, { start: start + 4, end, dy: 10, inF: 12 })) + add( + rect(hx, y + 26, 72, 1, { background: TEAL }), + LayerAnimation.create() + .keys('opacity', windowKeys([[start + 8, end]], 0.9, 10, 10)) + .keys('width', [ + [start + 8, 0], + [start + 28, 72, 'power2.out'], + [Math.max(start + 30, end - 12), 72], + [end, 0], + ]), + ) + } + + // ── Backdrop: same stack as the landing hero (grid, planet, halo, grain) ─ + add( + Layer.image(asset('grid'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0 }), + LayerAnimation.create().keys('opacity', [ + [0, 0], + [28, 1, 'power2.out'], + [LAST, 1], + ]), + ) + + const planetSize = 1700 + const haloSize = planetSize * (2000 / 1600) + const planetX = 1180 + const planetY: Key[] = [ + [0, 1180], + [55, 680, 'power3.out'], + [WORTH.start + 20, 820, 'power2.inOut'], + [FAST.end - 20, 860], + [MIDDLE.start + 20, 920, 'power2.inOut'], + [CLOSE.start + 32, 560, 'power3.out'], + [LAST, 545], + ] + const planetOpacity: Key[] = [ + [0, 0], + [35, 0.82], + [WORTH.start + 20, 0.55], + [FAST.end - 20, 0.48], + [MIDDLE.end - 20, 0.42], + [CLOSE.start + 32, 1], + ] + add( + Layer.image(asset('planet'), { + position: 'absolute', + x: planetX, + y: 1180, + width: planetSize, + height: planetSize, + opacity: 0, + }), + LayerAnimation.create() + .keys('y', planetY) + .keys('opacity', planetOpacity) + .fromTo('rotation', 0, 48, { start: 0, end: LAST, ease: 'linear' }), + ) + const haloOffset = (haloSize - planetSize) / 2 + add( + Layer.image(asset('sparkles'), { + position: 'absolute', + x: planetX - haloOffset, + y: 1180 - haloOffset, + width: haloSize, + height: haloSize, + opacity: 0, + }), + LayerAnimation.create() + .keys('opacity', planetOpacity.map(([f, v, e]) => (e ? [f, (v as number) * 0.85, e] : [f, (v as number) * 0.85]) as Key)) + .keys( + 'y', + planetY.map(([f, v, e]) => (e ? [f, (v as number) - haloOffset, e] : [f, (v as number) - haloOffset]) as Key), + ) + .fromTo('rotation', 12, -38, { start: 0, end: LAST, ease: 'linear' }), + ) + + // ── Brand chrome: official mark + wordmark ─────────────────────────────── + add(img('nostream', 118, 38, 26), show(38, { start: 10, inF: 20 })) + add(mono('nostream', 152, 34, 22, { fill: FG, fontWeight: 700 }), show(34, { start: 10, inF: 20 })) + + const footer = 'OPEN SOURCE · MIT LICENSE · GITHUB.COM/CAMERI/NOSTREAM' + add(mono(footer, (W - monoWidth(footer, 17)) / 2, 1010, 17, { fill: MUTED }), show(1010, { start: CLOSE.start + 24, inF: 16 })) + + const hx = 168 + + // ── Scene 1 · PEOPLE: a relay for your people, not the whole network ─── + { + const end = PEOPLE.end + sceneKicker('01 — YOUR PEOPLE', PEOPLE.start, end) + add( + sans('Run a relay for your people.', hx, 196, 72), + headlineAnim(196, PEOPLE.start, end, 12), + ) + add( + sans('Not the whole network.', hx, 292, 44, { fill: PINK, fontWeight: 600, letterSpacing: -0.6 }), + headlineAnim(292, PEOPLE.start, end, 36), + ) + + const cards = [ + { k: 'SCOPE', t: 'Your people', d: 'the people you follow' }, + { k: 'PEERING', t: 'Their relays', d: 'the ones your people use' }, + { k: 'FETCHES', t: 'The rest', d: 'on demand' }, + ] + const cardY = 420 + const cardW = 520 + const cardH = 228 + cards.forEach((c, i) => { + const x = hx + i * (cardW + 36) + const s = PEOPLE.start + 58 + i * 16 + add( + Layer.box({ + position: 'absolute', + x, + y: cardY, + width: cardW, + height: cardH, + background: PANEL, + borderColor: i === 1 ? PINK : PANEL_BORDER, + borderWidth: i === 1 ? 2 : 1, + borderRadius: 10, + opacity: 0, + }), + show(cardY, { start: s, end, dy: 16 }), + ) + add( + Layer.box({ position: 'absolute', x: x + 36, y: cardY + 22, width: 36, height: 4, background: PINK, opacity: 0 }), + show(cardY + 22, { start: s + 6, end, inF: 8 }), + ) + add(mono(c.k, x + 36, cardY + 36, 18, { fill: TEAL }), show(cardY + 36, { start: s + 8, end, inF: 10 })) + add(sans(c.t, x + 36, cardY + 78, 40), show(cardY + 78, { start: s + 12, end, dy: 10, inF: 12 })) + add(mono(c.d, x + 36, cardY + 148, 22, { fill: FG_2 }), show(cardY + 148, { start: s + 20, end, inF: 12 })) + }) + add( + sans('Feel complete without ingesting everything.', hx, 708, 34, { fill: BODY, fontWeight: 500, letterSpacing: -0.4 }), + headlineAnim(708, PEOPLE.start, end, 118), + ) + } + + // ── Scene 2 · WORTH: flat or traffic-based fees, never per event ───────── + { + const end = WORTH.end + sceneKicker('02 — YOUR ARCHIVE', WORTH.start, end) + add(sans('Your archive', hx, 176, 84), headlineAnim(176, WORTH.start, end, 8)) + add( + sans('is worth something now.', hx, 274, 84, { fill: PINK }), + headlineAnim(274, WORTH.start, end, 26), + ) + add( + mono('Sell feeds to other relays — settled in sats.', hx, 378, 24, { fill: BODY }), + headlineAnim(378, WORTH.start, end, 44), + ) + + const panelX = hx + const panelY = 448 + const panelW = 1584 + const panelH = 340 + add( + Layer.box({ + position: 'absolute', + x: panelX, + y: panelY, + width: panelW, + height: panelH, + background: PANEL, + borderColor: PANEL_BORDER, + borderWidth: 1, + borderRadius: 10, + opacity: 0, + }), + show(panelY, { start: WORTH.start + 48, end, dy: 14 }), + ) + add(rect(panelX + 44, panelY + 18, 120, 3, { background: PINK }), show(panelY + 18, { start: WORTH.start + 58, end, inF: 10 })) + add(mono('PEERING TERMS', panelX + 44, panelY + 32, 18, { fill: TEAL }), show(panelY + 32, { start: WORTH.start + 64, end, inF: 10 })) + + const chipW = 248 + const chipH = 52 + const chipX = panelX + panelW - chipW - 28 + const chipY = panelY + 22 + add( + Layer.box({ position: 'absolute', x: chipX, y: chipY, width: chipW, height: chipH, background: PINK, borderRadius: 26, opacity: 0 }), + show(chipY, { start: WORTH.start + 150, end, inF: 10 }), + ) + add( + mono('PER PERIOD', chipX, chipY + 14, 22, { fill: INK, fontWeight: 700, align: 'center', width: chipW }), + show(chipY + 14, { start: WORTH.start + 156, end, inF: 10 }), + ) + + const rows = [ + { k: 'fee', v: 'flat or traffic-based — you set the terms', color: FG }, + { k: 'period', v: 'you set the length', color: FG_2 }, + { k: 'settlement', v: 'Lightning, in sats, between relays', color: FG, icon: true }, + { k: 'per event', v: 'never', color: PINK }, + ] + rows.forEach((row, i) => { + const yy = panelY + 96 + i * 56 + const s = WORTH.start + 78 + i * 14 + add(mono(row.k, panelX + 44, yy, 26, { fill: MUTED }), show(yy, { start: s, end, inF: 8 })) + const vx = panelX + 360 + if (row.icon) { + add( + Layer.image(asset('lightning'), { position: 'absolute', x: vx, y: yy - 4, width: 32, height: 32, opacity: 0 }), + show(yy - 4, { start: s + 6, end, inF: 8 }), + ) + } + add(mono(row.v, row.icon ? vx + 44 : vx, yy, 26, { fill: row.color }), show(yy, { start: s + 4, end, inF: 10 })) + }) + + add( + sans('A product, not a cost center.', hx, 828, 36, { fill: BODY, fontWeight: 500, letterSpacing: -0.4 }), + headlineAnim(828, WORTH.start, end, 168), + ) + } + + // ── Scene 3 · FAST: an operator-set cache window, illustrated as an hour ─ + { + const end = FAST.end + sceneKicker('03 — HOT FEED', FAST.start, end) + add(sans('Fast, everywhere.', hx, 196, 88), headlineAnim(196, FAST.start, end, 8)) + add( + sans('The hot feed, near the readers.', hx, 308, 42, { fill: BODY, fontWeight: 500, letterSpacing: -0.4 }), + headlineAnim(308, FAST.start, end, 30), + ) + + const trackX = hx + const trackY = 470 + const trackW = 1584 + const split = Math.round(trackW * 0.62) + add( + Layer.box({ position: 'absolute', x: trackX, y: trackY, width: trackW, height: 16, background: LINE, borderRadius: 8, opacity: 0 }), + show(trackY, { start: FAST.start + 52, end, inF: 10 }), + ) + add( + Layer.box({ position: 'absolute', x: trackX + split, y: trackY, width: trackW - split, height: 16, background: TEAL, borderRadius: 8, opacity: 0 }), + LayerAnimation.create() + .keys('opacity', [ + [FAST.start + 70, 0], + [FAST.start + 82, 1], + [end - 14, 1], + [end, 0, 'power2.in'], + ]) + .fromTo('width', 0, trackW - split, { start: FAST.start + 74, end: FAST.start + 130, ease: 'power2.out' }), + ) + add( + Layer.box({ position: 'absolute', x: trackX + split - 2, y: trackY - 14, width: 4, height: 44, background: PINK, borderRadius: 2, opacity: 0 }), + show(trackY - 14, { start: FAST.start + 88, end, inF: 8 }), + ) + + add(mono('OLDER THAN THE WINDOW', trackX, trackY - 48, 18, { fill: MUTED }), show(trackY - 48, { start: FAST.start + 60, end, inF: 10 })) + add(mono('THE HOT FEED', trackX + split + 16, trackY - 48, 18, { fill: TEAL }), show(trackY - 48, { start: FAST.start + 78, end, inF: 10 })) + + const notes = [ + { k: 'BEFORE THIS CUT', t: 'Ask an archive.', d: 'outside the window', edge: PANEL_BORDER }, + { k: 'CACHE PEERS', t: 'A rolling window', d: 'the last hour, or whatever you configure', edge: TEAL }, + ] + const noteY = 548 + const noteW = 760 + notes.forEach((n, i) => { + const x = trackX + i * (noteW + 64) + const s = FAST.start + 100 + i * 16 + add( + Layer.box({ + position: 'absolute', + x, + y: noteY, + width: noteW, + height: 176, + background: PANEL, + borderColor: n.edge, + borderWidth: n.edge === TEAL ? 2 : 1, + borderRadius: 10, + opacity: 0, + }), + show(noteY, { start: s, end, dy: 12 }), + ) + add(mono(n.k, x + 36, noteY + 28, 18, { fill: TEAL }), show(noteY + 28, { start: s + 8, end, inF: 8 })) + add(sans(n.t, x + 36, noteY + 64, 40), show(noteY + 64, { start: s + 12, end, dy: 8, inF: 12 })) + add(mono(n.d, x + 36, noteY + 122, 22, { fill: FG_2 }), show(noteY + 122, { start: s + 18, end, inF: 10 })) + }) + + add( + sans('Small relays feel instant — without owning the firehose.', hx, 800, 34, { fill: BODY, fontWeight: 500, letterSpacing: -0.3 }), + headlineAnim(800, FAST.start, end, 136), + ) + } + + // ── Scene 4 · MIDDLE: archives and caches behind user-facing edges ────── + { + const end = MIDDLE.end + sceneKicker('04 — THE MIDDLE', MIDDLE.start, end) + add(sans('Relays that only talk to relays.', hx, 188, 64), headlineAnim(188, MIDDLE.start, end, 8)) + add( + sans('The network can grow a middle.', hx, 274, 40, { fill: PINK, fontWeight: 600, letterSpacing: -0.5 }), + headlineAnim(274, MIDDLE.start, end, 28), + ) + + const roles = [ + { k: 'ARCHIVE', t: 'Deep history', d: 'backbone infrastructure', accent: false }, + { k: 'CACHE', t: 'Rolling window', d: 'hot feed near readers', accent: false }, + { k: 'EDGE', t: 'Readers connect', d: 'the relay readers connect to', accent: true }, + ] + const cardY = 430 + const cardW = 500 + const cardH = 240 + const gap = 42 + const centers = roles.map((_, i) => hx + i * (cardW + gap) + cardW / 2) + const lineY = cardY - 36 + add( + Layer.box({ + position: 'absolute', + x: centers[0], + y: lineY, + width: centers[2] - centers[0], + height: 2, + background: WIRE, + opacity: 0, + }), + show(lineY, { start: MIDDLE.start + 48, end, inF: 12 }), + ) + add( + Layer.box({ position: 'absolute', x: centers[0], y: lineY - 3, width: 72, height: 8, background: PINK, borderRadius: 4, opacity: 0 }), + LayerAnimation.create() + .keys('opacity', [ + [MIDDLE.start + 70, 0], + [MIDDLE.start + 82, 1], + [end - 14, 1], + [end, 0, 'power2.in'], + ]) + .fromTo('x', centers[0], centers[2] - 72, { start: MIDDLE.start + 78, end: MIDDLE.start + 150, ease: 'power1.inOut' }), + ) + + roles.forEach((c, i) => { + const x = hx + i * (cardW + gap) + const s = MIDDLE.start + 56 + i * 16 + add( + Layer.box({ + position: 'absolute', + x, + y: cardY, + width: cardW, + height: cardH, + background: PANEL, + borderColor: c.accent ? PINK : PANEL_BORDER, + borderWidth: c.accent ? 2 : 1, + borderRadius: 10, + opacity: 0, + }), + show(cardY, { start: s, end, dy: 16 }), + ) + add( + Layer.box({ position: 'absolute', x: centers[i] - 7, y: lineY - 6, width: 14, height: 14, background: c.accent ? PINK : TEAL, borderRadius: 7, opacity: 0 }), + show(lineY - 6, { start: s, end, inF: 8 }), + ) + const textW = cardW - 72 + add(mono(c.k, x + 36, cardY + 40, 18, { fill: TEAL }), show(cardY + 40, { start: s + 8, end, inF: 10 })) + add( + sans(c.t, x + 36, cardY + 84, 40, { width: textW, lineHeight: 1.12 }), + show(cardY + 84, { start: s + 12, end, dy: 10, inF: 12 }), + ) + add( + mono(c.d, x + 36, cardY + 156, 22, { fill: FG_2, width: textW, lineHeight: 1.35 }), + show(cardY + 156, { start: s + 20, end, inF: 12 }), + ) + }) + + add( + sans('Infrastructure behind. Users in front.', hx, 724, 34, { fill: BODY, fontWeight: 500, letterSpacing: -0.3 }), + headlineAnim(724, MIDDLE.start, end, 120), + ) + } + + // ── Scene 5 · CLOSE: the inversion, over the planet ───────────────────── + { + add( + mono('Today, every relay tries to be everything.', 0, 268, 26, { width: W, align: 'center', fill: MUTED }), + show(268, { start: CLOSE.start + 10, dy: 12, inF: 16 }), + ) + add( + sans('Each relay gets to be one thing well.', 0, 340, 72, { width: W, height: 96, align: 'center' }), + LayerAnimation.create() + .keys('opacity', [ + [CLOSE.start + 24, 0], + [CLOSE.start + 48, 1, 'power2.out'], + ]) + .fromTo('y', 362, 340, { start: CLOSE.start + 24, end: CLOSE.start + 52, ease: 'power3.out' }), + ) + add( + sans('The network assembles the rest.', 0, 448, 42, { width: W, height: 56, align: 'center', fill: PINK, fontWeight: 600, letterSpacing: -0.4 }), + show(448, { start: CLOSE.start + 58, dy: 16 }), + ) + const sign = 'nostream relay peering' + const mark = 44 + const signSize = 24 + const signW = monoWidth(sign, signSize) + const lockupW = mark + 14 + signW + const lockupX = (W - lockupW) / 2 + add(img('nostream', lockupX, 548, mark), show(548, { start: CLOSE.start + 88, inF: 18 })) + add(mono(sign, lockupX + mark + 14, 560, signSize, { fill: TEAL }), show(560, { start: CLOSE.start + 94, inF: 18 })) + } + + const film = new Composition({ + width: W, + height: H, + fps: FPS, + durationMs: (DURATION_FILM / FPS) * 1000, + backgroundColor: BG, + fonts: [path.join(ROOT, 'assets/fonts/JetBrainsMono.ttf'), path.join(ROOT, 'assets/fonts/Inter.ttf')], + }) + for (const { node, anim } of pending) film.add((anim ? node.animate(anim) : node) as never) + film.add(Layer.image(asset('grain'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0.85 }) as never) + film.apply(new Vignette({ strength: 0.16, radius: 0.95, softness: 0.7 })) + return film +} diff --git a/website/hero-animation/src/peering-render.ts b/website/hero-animation/src/peering-render.ts new file mode 100644 index 000000000..54a675eb3 --- /dev/null +++ b/website/hero-animation/src/peering-render.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { HeadlessMediaRenderer } from 'framefields' +import { buildFilm } from './peering-film.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const OUTPUT = path.join(__dirname, '..', 'output', 'peering') + +const [mode = 'frames', ...args] = process.argv.slice(2) +const film = await buildFilm() +await fs.mkdir(path.join(OUTPUT, 'frames'), { recursive: true }) + +if (mode === 'frames') { + const renderer = new HeadlessMediaRenderer() + for (const frame of args.map(Number)) { + const file = path.join(OUTPUT, 'frames', `f${String(frame).padStart(4, '0')}.png`) + await fs.writeFile(file, await film.renderFrame({ frame, renderer })) + console.log(file) + } + process.exit(0) +} + +const quality = args[0] ?? 'high' +const started = Date.now() +const result = await film.renderVideo({ + outputPath: path.join( + OUTPUT, + quality === 'very_high' ? 'nostream-peering.mp4' : `nostream-peering-${quality}.mp4`, + ), + quality, +}) +await result.cleanup?.().catch(() => {}) +console.log(`${result.filePath} (${((Date.now() - started) / 1000).toFixed(1)} s)`) +process.exit(0) diff --git a/website/site/README.md b/website/site/README.md index 712187884..41ee7beac 100644 --- a/website/site/README.md +++ b/website/site/README.md @@ -2,7 +2,7 @@ Landing page, docs and changelog for nostream, in the same omp.sh-style dialect as the Framefields films in [`../hero-animation`](../hero-animation). -- `/` framed landing film ([PR #803](https://github.com/cameri/nostream/pull/803) / `film.ts`), sparkles, who it's for, feature pillars, admin & observability, NIPs +- `/` framed landing film ([PR #803](https://github.com/cameri/nostream/pull/803) / `film.ts`), sparkles, who it's for, feature pillars, relay peering film, admin & observability, NIPs - `/get-started` copy-paste build command for the [`run-a-decentralized-social-network-relay`](https://schemaformat.ai/schematics/run-a-decentralized-social-network-relay/SCHEMATIC.md) agent schematic - `/docs` deploy path chooser plus the repository markdown (README, CLI, CONFIGURATION, deploy docs, Redis, ...) rendered as pages - `/changelog` rendered from `CHANGELOG.md` @@ -30,6 +30,7 @@ Media comes from the landing film in `../hero-animation` (needs `ffmpeg` for `pn |-------|--------| | `hero.mp4`, `hero-low.mp4`, `hero-poster.webp`, `hero.meta.json` | Landing film — `src/film.ts` (`pnpm run render`, `pnpm run frames`) | | `admin.mp4`, `admin-poster.webp` | Operator console film — `src/admin-film.ts` (`pnpm run render:admin`, `pnpm run frames:admin`) | +| `peering.mp4`, `peering-low.mp4`, `peering-poster.webp` | Relay peering launch film — `src/peering-film.ts` (`pnpm run render:peering`, `pnpm run frames:peering`); copy in `docs/peering-film-copy.md` | | `planet.webp` | Particle planet — `src/build-assets.ts` (`pnpm run assets`) | ```bash @@ -37,6 +38,7 @@ cd website/hero-animation pnpm run assets pnpm run render && pnpm run frames pnpm run render:admin && pnpm run frames:admin # when the admin film or screenshots change +pnpm run render:peering && pnpm run frames:peering # when the peering launch film changes cd ../site pnpm run media ``` diff --git a/website/site/public/media/peering-low.mp4 b/website/site/public/media/peering-low.mp4 new file mode 100644 index 000000000..0832c3fd9 Binary files /dev/null and b/website/site/public/media/peering-low.mp4 differ diff --git a/website/site/public/media/peering-poster.webp b/website/site/public/media/peering-poster.webp new file mode 100644 index 000000000..c58a7b875 Binary files /dev/null and b/website/site/public/media/peering-poster.webp differ diff --git a/website/site/public/media/peering.mp4 b/website/site/public/media/peering.mp4 new file mode 100644 index 000000000..5a98db037 Binary files /dev/null and b/website/site/public/media/peering.mp4 differ diff --git a/website/site/scripts/build-media.mjs b/website/site/scripts/build-media.mjs index f38c7328d..5916d8ddb 100644 --- a/website/site/scripts/build-media.mjs +++ b/website/site/scripts/build-media.mjs @@ -15,6 +15,8 @@ const HERO_META = path.join(MEDIA, 'hero.meta.json') const LANDING_POSTER = 'f0130.png' const ADMIN_LANDING = path.join(LANDING, 'admin') const ADMIN_POSTER = 'f0050.png' +const PEERING_LANDING = path.join(LANDING, 'peering') +const PEERING_POSTER = 'f1100.png' mkdirSync(MEDIA, { recursive: true }) @@ -133,4 +135,36 @@ if (adminVideo) { console.warn('skip admin.mp4: run `pnpm run render:admin` in website/hero-animation first') } +const peeringPoster = path.join(PEERING_LANDING, 'frames', PEERING_POSTER) +if (existsSync(peeringPoster)) { + await sharp(peeringPoster).resize(1920).webp({ quality: 90 }).toFile(path.join(MEDIA, 'peering-poster.webp')) + console.log('peering poster → public/media') +} else { + console.warn(`skip peering poster: run \`pnpm run frames:peering\` in website/hero-animation (need frames/${PEERING_POSTER})`) +} + +const peeringVideo = [ + process.env.PEERING_SRC, + path.join(PEERING_LANDING, 'nostream-peering.mp4'), + path.join(PEERING_LANDING, 'nostream-peering-very_high.mp4'), + path.join(PEERING_LANDING, 'nostream-peering-low.mp4'), +].find((p) => p && existsSync(p)) + +const peeringLowVideo = [ + process.env.PEERING_LOW_SRC, + path.join(PEERING_LANDING, 'nostream-peering-low.mp4'), + peeringVideo === path.join(PEERING_LANDING, 'nostream-peering-low.mp4') ? null : peeringVideo, +].find((p) => p && existsSync(p) && p !== peeringVideo) + +if (peeringVideo) { + encodeHero(peeringVideo, path.join(MEDIA, 'peering.mp4'), 15) + console.log('peering.mp4 ← peering launch film') + if (peeringLowVideo) { + encodeHero(peeringLowVideo, path.join(MEDIA, 'peering-low.mp4'), 22) + console.log('peering-low.mp4 ← peering launch film (low variant)') + } +} else { + console.warn('skip peering.mp4: run `pnpm run render:peering` in website/hero-animation first') +} + console.log('done → public/media/') diff --git a/website/site/src/components/home/Console.astro b/website/site/src/components/home/Console.astro index e0537b3e2..0dbdc3c1a 100644 --- a/website/site/src/components/home/Console.astro +++ b/website/site/src/components/home/Console.astro @@ -39,7 +39,7 @@ const features = [
-

03 Admin & observability

+

04 Admin & observability

Know what your relay is actually doing.

diff --git a/website/site/src/components/home/Nips.astro b/website/site/src/components/home/Nips.astro index 07e6168e8..8d88e8b68 100644 --- a/website/site/src/components/home/Nips.astro +++ b/website/site/src/components/home/Nips.astro @@ -8,7 +8,7 @@ const nips = SUPPORTED_NIPS.map((n) => ({ n, label: String(n).padStart(2, '0'),
-

04 NIPs supported

+

05 NIPs supported

{SUPPORTED_NIPS.length} NIPs, advertised in NIP-11.

diff --git a/website/site/src/components/home/Peering.astro b/website/site/src/components/home/Peering.astro new file mode 100644 index 000000000..09bbb21c8 --- /dev/null +++ b/website/site/src/components/home/Peering.astro @@ -0,0 +1,92 @@ +--- +import FilmFrame from '../FilmFrame.astro' + +const media = (file: string) => `${import.meta.env.BASE_URL}media/${file}`.replace(/\/\//, '/') +--- + +

+
+
+
+

03 Relay peering

+

One job per relay. The network assembles the rest.

+
+
+

+ Peering lets operators specialize — community scope, deep archives, hot caches at the edge — without every + relay pretending to be the whole network. +

+
+
+ +
+ + +
+
+
+ + + + diff --git a/website/site/src/pages/index.astro b/website/site/src/pages/index.astro index 1564488e0..2ca351e8b 100644 --- a/website/site/src/pages/index.astro +++ b/website/site/src/pages/index.astro @@ -1,6 +1,7 @@ --- import Audience from '../components/home/Audience.astro' import Console from '../components/home/Console.astro' +import Peering from '../components/home/Peering.astro' import CtaBand from '../components/home/CtaBand.astro' import Hero from '../components/home/Hero.astro' import Nips from '../components/home/Nips.astro' @@ -12,6 +13,7 @@ import Base from '../layouts/Base.astro' +