openvibers/openvibe.live Commits

Asset sync to Media; paste author names; AI overview overlays

OP OpenVibers committed 18 Aug 2026 · 183389614e · 10 files, +446 −1120

- asset-sync job mirrors emotes + channel sounds into Media (canonical public
  home; local files stay the low-latency chat working copy), records
  media_url/media_asset_id, hooks on upload/delete; emote URLs served from
  Media when mirrored.
- Pastes proxy: resolve author usernames locally (cards all read 'Anonymous'),
  translate ?username= to user_id (Media only knows ids), enrich comments.
- Overlay Live-owned ai_overview_short (vod_ai_state/clip_ai_state) onto vod
  and clip rows in the proxies, channel route, and recent-vods.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/the-secret.html b/public/the-secret.html
ddeleted file mode 100644
iindex ef33421a..00000000
---- a/public/the-secret.html
++++ /dev/null
@@@ -1,1101 +0,0 @@
--<!DOCTYPE html>
--<html lang="en">
--<head>
--<meta charset="UTF-8">
--<meta name="viewport" content="width=device-width,initial-scale=1.0">
--<title>OPENVIBELIVE — Rick's Bandwidth Nightmare</title>
--
--<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/confetti.browser.min.js"></script>
--<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
--<script src="https://cdn.jsdelivr.net/npm/[email protected]/tsparticles.engine.min.js"></script>
--<script src="https://cdn.jsdelivr.net/npm/[email protected]/tsparticles.bundle.min.js"></script>
--<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Inter:wght@400;600;700;800;900&family=Fira+Code:wght@400;700&family=Bebas+Neue&display=swap" rel="stylesheet">
--<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
--
--<style>
--* { margin:0; padding:0; box-sizing:border-box; }
--html, body { width:100vw; height:100vh; overflow:hidden; background:#000; font-family:'Inter',sans-serif; color:#fff; }
--
--/* ── Background layers ── */
--#tsparticles { position:absolute; inset:0; z-index:0; }
--.bg-pulse {
-- position:absolute; inset:0; z-index:0;
-- background:radial-gradient(ellipse at 50% 50%, rgba(139,92,246,0.07) 0%, transparent 65%);
-- animation:bgPulse 3s ease-in-out infinite alternate;
--}
--@keyframes bgPulse { from{opacity:.4;transform:scale(1)} to{opacity:1;transform:scale(1.08)} }
--.scanlines {
-- position:absolute; inset:0; z-index:1; pointer-events:none;
-- background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.07) 2px,rgba(0,0,0,.07) 4px);
--}
--.vignette {
-- position:absolute; inset:0; z-index:2; pointer-events:none;
-- background:radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,.8) 100%);
--}
--.flash { position:absolute; inset:0; z-index:50; background:#fff; opacity:0; pointer-events:none; }
--
--/* ── Tickers ── */
--.ticker-wrap {
-- position:absolute; left:0; right:0; z-index:15; overflow:hidden;
--}
--.ticker-bottom { bottom:0; height:5.5vh; background:rgba(139,92,246,0.12); border-top:1px solid rgba(139,92,246,0.25); }
--.ticker-top { top:0; height:4vh; background:rgba(239,68,68,0.08); border-bottom:1px solid rgba(239,68,68,0.2); }
--.ticker-inner {
-- display:flex; white-space:nowrap; gap:0;
-- font-family:'Fira Code',monospace; font-weight:700; text-transform:uppercase; letter-spacing:2px;
--}
--.ticker-bottom .ticker-inner { font-size:2.2vh; color:rgba(139,92,246,0.95); line-height:5.5vh; animation:tickerL 45s linear infinite; text-shadow:0 0 12px rgba(139,92,246,0.5); }
--.ticker-top .ticker-inner { font-size:1.8vh; color:rgba(239,68,68,0.8); line-height:4vh; animation:tickerR 38s linear infinite; }
--@keyframes tickerL { from{transform:translateX(0)} to{transform:translateX(-50%)} }
--@keyframes tickerR { from{transform:translateX(-50%)} to{transform:translateX(0)} }
--
--/* ── Corners ── */
--.corner { position:absolute; width:5vw; height:5vw; z-index:12; }
--.corner::before,.corner::after { content:''; position:absolute; background:rgba(139,92,246,0.4); }
--.corner-tl { top:5.5vh; left:1.5vw; }
--.corner-tl::before { width:3vw; height:2px; top:0; left:0; }
--.corner-tl::after { width:2px; height:3vw; top:0; left:0; }
--.corner-tr { top:5.5vh; right:1.5vw; }
--.corner-tr::before { width:3vw; height:2px; top:0; right:0; }
--.corner-tr::after { width:2px; height:3vw; top:0; right:0; }
--.corner-bl { bottom:6.5vh; left:1.5vw; }
--.corner-bl::before { width:3vw; height:2px; bottom:0; left:0; }
--.corner-bl::after { width:2px; height:3vw; bottom:0; left:0; }
--.corner-br { bottom:6.5vh; right:1.5vw; }
--.corner-br::before { width:3vw; height:2px; bottom:0; right:0; }
--.corner-br::after { width:2px; height:3vw; bottom:0; right:0; }
--
--/* ── Slide labels (top-left, top-right) ── */
--.status-bar {
-- position:absolute; top:0; left:0; right:0; z-index:14;
-- display:flex; justify-content:space-between; align-items:center;
-- padding:0 2vw; height:5.5vh;
-- font-family:'Fira Code',monospace; font-size:1.3vh; font-weight:700;
-- text-transform:uppercase; letter-spacing:3px;
-- color:rgba(139,92,246,0.5);
-- pointer-events:none;
--}
--.status-left { text-align:left; }
--.status-right { text-align:right; color:rgba(239,68,68,0.4); }
--.blink { animation:blink 1s step-end infinite; }
--@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
--
--/* ── Main stage ── */
--.stage {
-- position:absolute; inset:0; z-index:10;
-- top:5.5vh; bottom:5.5vh;
-- display:flex; align-items:center; justify-content:center;
-- overflow:hidden;
--}
--
--/* ── Slide container ── */
--.slide {
-- position:absolute; inset:0;
-- display:flex; flex-direction:column;
-- align-items:center; justify-content:center;
-- text-align:center;
-- padding:2vh 6vw;
-- opacity:0;
-- pointer-events:none;
--}
--.slide.active { pointer-events:auto; }
--
--/* ══ SLIDE STYLES ══ */
--
--/* eyebrow label above main text */
--.s-eyebrow {
-- font-family:'Bebas Neue',sans-serif;
-- font-size:3.5vh; letter-spacing:1vw;
-- color:rgba(255,255,255,0.45);
-- margin-bottom:1vh;
-- text-transform:uppercase;
--}
--
--/* main headline */
--.s-head {
-- font-family:'Orbitron',sans-serif;
-- font-weight:900; line-height:1.05;
-- letter-spacing:0.3vw;
-- text-transform:uppercase;
-- text-shadow:0 2px 20px rgba(0,0,0,0.9);
--}
--.s-head.xl { font-size:14vh; }
--.s-head.lg { font-size:10vh; }
--.s-head.md { font-size:7vh; }
--.s-head.sm { font-size:5vh; }
--
--/* colour variants */
--.gold { color:#8b5cf6; text-shadow:0 0 40px rgba(139,92,246,0.5),0 2px 12px rgba(0,0,0,0.9); }
--.red { color:#ef4444; text-shadow:0 0 40px rgba(239,68,68,0.5),0 2px 12px rgba(0,0,0,0.9); }
--.grn { color:#22c55e; text-shadow:0 0 30px rgba(34,197,94,0.4),0 2px 10px rgba(0,0,0,0.9); }
--.wht { color:#fff; }
--.gold-grad {
-- background:linear-gradient(135deg,#8b5cf6,#f5d394,#ff8c00,#8b5cf6);
-- background-size:300% 300%; animation:goldShift 3s ease infinite;
-- -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text;
-- filter:drop-shadow(0 0 40px rgba(139,92,246,0.4));
--}
--@keyframes goldShift { 0%,100%{background-position:0% 50%} 50%{background-position:100% 50%} }
--.rainbow-grad {
-- background:linear-gradient(90deg,#8b5cf6,#22c55e,#3b82f6,#a855f7,#ef4444,#8b5cf6);
-- background-size:400% 100%; animation:rainbowShift 2.5s linear infinite;
-- -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text;
--}
--@keyframes rainbowShift { 0%{background-position:0% 50%} 100%{background-position:400% 50%} }
--.strike { text-decoration:line-through; opacity:.7; }
--
--/* sub-text */
--.s-sub {
-- font-size:2.8vh; font-weight:700;
-- color:rgba(255,255,255,0.8);
-- margin-top:1.5vh; line-height:1.6;
-- text-shadow:0 1px 8px rgba(0,0,0,0.9);
-- max-width:70vw;
--}
--.s-sub .hi { color:#8b5cf6; }
--.s-sub .ri { color:#ef4444; }
--.s-sub .gi { color:#22c55e; }
--
--/* badge/stamp */
--.stamp {
-- display:inline-block;
-- font-family:'Bebas Neue',sans-serif;
-- font-size:3vh; letter-spacing:0.6vw;
-- border:3px solid currentColor;
-- padding:0.3vh 1.5vw;
-- border-radius:4px;
-- margin:1.5vh 0;
-- transform:rotate(-2deg);
--}
--.stamp.red-stamp { color:#ef4444; box-shadow:0 0 20px rgba(239,68,68,0.3); }
--.stamp.gold-stamp { color:#8b5cf6; box-shadow:0 0 20px rgba(139,92,246,0.3); }
--.stamp.grn-stamp { color:#22c55e; box-shadow:0 0 20px rgba(34,197,94,0.3); }
--
--/* VS comparison */
--.vs-grid {
-- display:flex; align-items:flex-start; gap:4vw;
-- margin-top:2vh;
--}
--.vs-col { text-align:center; min-width:22vw; }
--.vs-col-title {
-- font-family:'Orbitron',sans-serif; font-weight:900;
-- font-size:3.5vh; letter-spacing:3px; margin-bottom:1.5vh;
-- text-shadow:0 1px 8px rgba(0,0,0,0.8);
--}
--.vs-divider {
-- font-family:'Orbitron',sans-serif; font-size:6vh; font-weight:900;
-- color:rgba(255,255,255,0.1); align-self:center;
--}
--.vs-item {
-- font-size:2.2vh; font-weight:700; margin:0.5vh 0;
-- text-shadow:0 1px 6px rgba(0,0,0,0.8);
--}
--.vs-item.y { color:#22c55e; }
--.vs-item.n { color:#ef4444; }
--
--/* stat block */
--.stat-row { display:flex; gap:5vw; margin-top:2vh; justify-content:center; }
--.stat-box {
-- text-align:center;
-- background:rgba(0,0,0,0.4); border:1px solid rgba(139,92,246,0.25);
-- border-radius:12px; padding:2vh 3vw;
-- backdrop-filter:blur(6px);
--}
--.stat-num {
-- font-family:'Orbitron',sans-serif; font-weight:900;
-- font-size:6vh; color:#8b5cf6;
-- text-shadow:0 0 30px rgba(139,92,246,0.5);
-- line-height:1;
--}
--.stat-lbl {
-- font-size:1.6vh; font-weight:700;
-- color:rgba(255,255,255,0.55);
-- letter-spacing:2px; text-transform:uppercase;
-- margin-top:0.5vh;
--}
--
--/* meme big text */
--.meme-line {
-- font-family:'Bebas Neue',sans-serif;
-- font-size:18vh; line-height:0.92;
-- letter-spacing:0.5vw; text-transform:uppercase;
-- color:#fff; text-shadow:0 4px 30px rgba(0,0,0,1),0 0 80px rgba(139,92,246,0.3);
--}
--.meme-line.stack { font-size:11vh; }
--
--/* OpenVibe sig */
--.openvibe-sig {
-- position:absolute; bottom:6.5vh; right:2vw; z-index:14;
-- font-family:'Orbitron',sans-serif; font-size:1.4vh; font-weight:700;
-- color:rgba(139,92,246,0.3); letter-spacing:3px; text-transform:uppercase;
--}
--
--/* slide progress dots */
--.slide-dots {
-- position:absolute; bottom:6.5vh; left:50%; transform:translateX(-50%);
-- z-index:14; display:flex; gap:8px; align-items:center;
--}
--.dot {
-- width:6px; height:6px; border-radius:50%;
-- background:rgba(255,255,255,0.15);
-- transition:all 0.4s ease;
--}
--.dot.active { background:#8b5cf6; width:20px; border-radius:3px; box-shadow:0 0 8px rgba(139,92,246,0.6); }
--
--/* floating embers */
--.ember {
-- position:absolute; width:4px; height:4px;
-- background:#8b5cf6; border-radius:50%;
-- opacity:0; z-index:3; filter:blur(1px); pointer-events:none;
--}
--.skull-float {
-- position:absolute; opacity:0; z-index:4; pointer-events:none;
-- font-size:3vh;
--}
--
--/* glitch overlay */
--.glitch-overlay {
-- position:absolute; inset:0; z-index:20; pointer-events:none; opacity:0;
-- background:linear-gradient(transparent 0%,rgba(139,92,246,0.05) 50%,transparent 100%);
-- animation:glitchSlide .15s steps(2) infinite; mix-blend-mode:overlay;
--}
--@keyframes glitchSlide { 0%{transform:translateY(-100%)} 100%{transform:translateY(100%)} }
--
--
--</style>
--</head>
--<body>
--
--<!-- Bg layers -->
--<div id="tsparticles"></div>
--<div class="bg-pulse"></div>
--<div class="scanlines"></div>
--<div class="vignette"></div>
--<div class="glitch-overlay" id="glitch"></div>
--<div class="flash" id="flash"></div>
--
--<!-- Status bar top -->
--<div class="status-bar">
-- <div class="status-left">OPENVIBELIVE.COM // LIVE // ROBOT 1837</div>
-- <div class="status-right"><span class="blink">●</span> REC // RIP RICKSTREAMER</div>
--</div>
--
--<!-- Corners -->
--<div class="corner corner-tl"></div>
--<div class="corner corner-tr"></div>
--<div class="corner corner-bl"></div>
--<div class="corner corner-br"></div>
--
--<!-- Slide progress dots -->
--<div class="slide-dots" id="dots"></div>
--
--<!-- OpenVibe sig -->
--<div class="openvibe-sig">openvibe.live</div>
--
--
--
--<!-- ══════════════ SLIDES ══════════════ -->
--<div class="stage" id="stage">
--
--<!-- SLIDE 0: Title card -->
--<div class="slide" id="s0">
-- <div class="s-eyebrow">Welcome to</div>
-- <div class="s-head xl gold-grad">OPENVIBELIVE</div>
-- <div class="s-sub">The open-source streaming platform <span class="hi">built in a shed</span><br>that keeps Rick up at night 🏠</div>
--</div>
--
--<!-- SLIDE 1: 9,309 VODs -->
--<div class="slide" id="s1">
-- <div class="s-eyebrow">We downloaded</div>
-- <div class="s-head xl gold" id="vod-counter">9,309</div>
-- <div class="s-head sm wht" style="letter-spacing:0.8vw;margin-top:-.5vh;">VODs FROM ROBOTSTREAMER</div>
-- <div class="s-sub"><span class="hi">Before we BROKE their bandwidth cap.</span><br><span class="ri">"download_cap_exceeded" — HTTP 403 &#x1F923;</span></div>
--</div>
--
--<!-- SLIDE 2: Rick roast -->
--<div class="slide" id="s2">
-- <div class="stamp red-stamp"><i class="fa-solid fa-skull"></i> CERTIFIED L</div>
-- <div class="meme-line red">RICK</div>
-- <div class="meme-line stack gold">COULDN'T</div>
-- <div class="meme-line stack wht">STOP US</div>
-- <div class="s-sub" style="margin-top:2vh;">Closed source. Wefunder. "All Rights Reserved."<br><span class="ri">On a dead site.</span> lmaooooo</div>
--</div>
--
--<!-- SLIDE 3: VS comparison -->
--<div class="slide" id="s3">
-- <div class="s-head md wht" style="margin-bottom:2vh;">The Honest Comparison</div>
-- <div class="vs-grid">
-- <div class="vs-col">
-- <div class="vs-col-title gold">OPENVIBELIVE</div>
-- <div class="vs-item y"><i class="fa-solid fa-check"></i> Free Forever</div>
-- <div class="vs-item y"><i class="fa-solid fa-code-branch"></i> Open Source</div>
-- <div class="vs-item y"><i class="fa-solid fa-users"></i> Community Built</div>
-- <div class="vs-item y"><i class="fa-solid fa-house"></i> Shed Powered</div>
-- <div class="vs-item y"><i class="fa-solid fa-heart"></i> Actually Works</div>
-- <div class="vs-item y"><i class="fa-solid fa-infinity"></i> Still Running</div>
-- </div>
-- <div class="vs-divider"><i class="fa-solid fa-skull-crossbones"></i></div>
-- <div class="vs-col">
-- <div class="vs-col-title red strike">ROBOTSTREAMER</div>
-- <div class="vs-item n"><i class="fa-solid fa-xmark"></i> Patreon Begging</div>
-- <div class="vs-item n"><i class="fa-solid fa-lock"></i> Closed Source</div>
-- <div class="vs-item n"><i class="fa-solid fa-building"></i> "Corporation"</div>
-- <div class="vs-item n"><i class="fa-solid fa-ghost"></i> Ghost Town</div>
-- <div class="vs-item n"><i class="fa-solid fa-person-digging"></i> Owned by Rick</div>
-- <div class="vs-item n"><i class="fa-solid fa-cross"></i> Effectively Dead</div>
-- </div>
-- </div>
--</div>
--
--<!-- SLIDE 4: Stats -->
--<div class="slide" id="s4">
-- <div class="s-eyebrow">By the numbers</div>
-- <div class="s-head lg gold-grad">THE ARCHIVE</div>
-- <div class="stat-row">
-- <div class="stat-box">
-- <div class="stat-num">9,309</div>
-- <div class="stat-lbl">VODs Downloaded</div>
-- </div>
-- <div class="stat-box">
-- <div class="stat-num">872 GB</div>
-- <div class="stat-lbl">Before cap hit</div>
-- </div>
-- <div class="stat-box">
-- <div class="stat-num">439</div>
-- <div class="stat-lbl">Goosely Followers</div>
-- </div>
-- <div class="stat-box">
-- <div class="stat-num red">403</div>
-- <div class="stat-lbl">Rick's Error Code</div>
-- </div>
-- </div>
--</div>
--
--<!-- SLIDE 5: WEFUNERAL -->
--<div class="slide" id="s5">
-- <div class="meme-line red" style="font-size:9vh;">WEFUNDER?</div>
-- <div class="meme-line gold" style="font-size:13vh;margin-top:-.5vh;">MORE LIKE</div>
-- <div class="meme-line red" style="font-size:11vh;margin-top:-.5vh;">WEFUNERAL</div>
-- <div class="s-sub" style="margin-top:2vh;"><span class="ri">RIP RobotStreamer Corporation 2016–???</span><br>Cause of death: <span class="hi">losing to a guy in a shed</span></div>
--</div>
--
--<!-- SLIDE 6: Shed origin -->
--<div class="slide" id="s6">
-- <div class="s-eyebrow" style="font-size:5vh;">🏠</div>
-- <div class="s-head lg gold-grad">BUILT FROM</div>
-- <div class="s-head lg wht" style="margin-top:-.5vh;">A LITERAL SHED</div>
-- <div class="s-sub" style="margin-top:2vh;">
-- No VC funding. No corporate BS. No "all rights reserved."<br>
-- Just <span class="hi">vibes, code, and a DIY spirit</span> that Rick can't buy.<br>
-- <span class="gi">openvibe.live — free & open source forever</span>
-- </div>
--</div>
--
--<!-- SLIDE 7: All rights reversed -->
--<div class="slide" id="s7">
-- <div class="stamp red-stamp" style="transform:rotate(2deg);">© RobotStreamer Corporation</div>
-- <div class="s-head md red" style="margin-top:1vh;">"ALL RIGHTS RESERVED"</div>
-- <div class="s-head sm wht" style="margin-top:1vh;">rights to what, exactly?</div>
-- <div class="s-sub" style="margin-top:2vh;">
-- <span class="ri">A dead website?</span> A Wefunder page with 3 backers?<br>
-- A Discord with more bots than users?<br>
-- <span class="hi">Buddy.</span>
-- </div>
-- <div class="stamp grn-stamp" style="margin-top:2vh;transform:rotate(-1deg);">ALL RIGHTS REVERSED — OPENVIBELIVE</div>
--</div>
--
--<!-- SLIDE 8: Goosely tribute -->
--<div class="slide" id="s8">
-- <div class="s-eyebrow">🦆 Robot #1837</div>
-- <div class="s-head lg gold-grad">GOOSELY</div>
-- <div class="s-head sm wht" style="margin-top:-.5vh;letter-spacing:.5vw;">ROBOTSTREAMER.COM/ROBOT/1837</div>
-- <div class="s-sub" style="margin-top:2vh;">
-- <span class="hi">439 followers</span> who deserve better than Rick's dying platform.<br>
-- <span class="hi">9,309 VODs</span> saved before their servers <span class="ri">literally gave up.</span><br>
-- History doesn't die just because Rick's bandwidth does. 🦆
-- </div>
--</div>
--
--<!-- SLIDE 9: Pure hype -->
--<div class="slide" id="s9">
-- <div class="s-head xl rainbow-grad">OPENVIBE</div>
-- <div class="s-head xl rainbow-grad" style="margin-top:-.5vh;">4</div>
-- <div class="s-head xl rainbow-grad" style="margin-top:-.5vh;">LIFE</div>
--</div>
--
--<!-- SLIDE 10: Rick's reaction -->
--<div class="slide" id="s10">
-- <div class="s-eyebrow">Rick rn:</div>
-- <div class="meme-line red" style="font-size:16vh;line-height:1;">😤</div>
-- <div class="s-head md gold" style="margin-top:1vh;">SEETHING</div>
-- <div class="s-sub" style="margin-top:1.5vh;">
-- <span class="ri">All rights reserved on a ghost town.</span><br>
-- Meanwhile we're over here downloading <span class="hi">9,309 VODs</span><br>and <span class="gi">open sourcing everything. 🏠✨</span>
-- </div>
--</div>
--
--<!-- SLIDE 11: Open source wins -->
--<div class="slide" id="s11">
-- <div class="s-head lg gold-grad">OPEN SOURCE</div>
-- <div class="s-head md wht" style="margin-top:-.2vh;letter-spacing:.8vw;">ALWAYS WINS</div>
-- <div class="stamp grn-stamp" style="margin-top:2vh;font-size:2.5vh;">github.com/OpenVibe.Live</div>
-- <div class="s-sub" style="margin-top:1.5vh;">
-- Fork it. Modify it. Host your own. <span class="hi">We don't care.</span><br>
-- That's what free software means, Rick.<br>
-- <span class="gi">You should try it sometime.</span>
-- </div>
--</div>
--
--<!-- SLIDE 12: bandwidth cap -->
--<div class="slide" id="s12">
-- <div class="stamp red-stamp" style="transform:rotate(-1deg);">HTTP 403 — download_cap_exceeded</div>
-- <div class="s-head lg red" style="margin-top:.5vh;">WE BROKE</div>
-- <div class="s-head xl gold-grad" style="margin-top:-.3vh;">RICK'S BANDWIDTH</div>
-- <div class="s-sub" style="margin-top:1.5vh;">
-- <span class="ri">"Cannot download file, download bandwidth or<br>transaction (Class B) cap exceeded."</span><br>
-- <span class="hi">9,309 VODs. 872 GB. Then RobotStreamer DIED.</span> &#x1F3C6;
-- </div>
--</div>
--
--<!-- SLIDE 13: GG -->
--<div class="slide" id="s13">
-- <div class="s-eyebrow">the moment robotstreamer said</div>
-- <div class="meme-line gold" style="font-size:22vh;line-height:1;">GG</div>
-- <div class="s-sub" style="margin-top:1vh;">
-- We downloaded so many VODs that<br>
-- <span class="ri">RobotStreamer's own servers gave up on life.</span><br>
-- <span class="hi">9,309 VODs. bandwidth cap: exceeded. Rick: 😭</span>
-- </div>
--</div>
--
--<!-- SLIDE 14: error code terminal -->
--<div class="slide" id="s14">
-- <div class="s-eyebrow" style="font-family:'Fira Code',monospace;font-size:1.8vh;color:rgba(34,197,94,0.8);">$ wget robotstreamer.com/robot/1837/vod/9310.mp4</div>
-- <div style="font-family:'Fira Code',monospace;font-size:2.8vh;color:#ef4444;margin-top:1.5vh;line-height:1.6;font-weight:700;text-shadow:0 0 20px rgba(239,68,68,0.4);">
-- {<br>
-- &nbsp;&nbsp;"code": "download_cap_exceeded",<br>
-- &nbsp;&nbsp;"message": "Cannot download file,<br>
-- &nbsp;&nbsp;download bandwidth or transaction<br>
-- &nbsp;&nbsp;(Class B) cap exceeded.",<br>
-- &nbsp;&nbsp;"status": 403<br>
-- }
-- </div>
-- <div class="s-head sm gold" style="margin-top:2vh;">A CORPORATION. WITH A BANDWIDTH CAP. 💀</div>
-- <div class="s-sub" style="margin-top:1vh;"><span class="ri">We hit it.</span> Just us. <span class="hi">One guy in a shed vibing.</span> &#x1F3E0;</div>
--</div>
--
--<!-- SLIDE 15: tombstone -->
--<div class="slide" id="s15">
-- <div class="s-head sm wht">⚰️</div>
-- <div class="s-head md red" style="margin-top:1vh;">HERE LIES</div>
-- <div class="s-head lg red">ROBOTSTREAMER</div>
-- <div class="s-head sm wht" style="margin-top:.5vh;font-size:3vh;letter-spacing:.4vw;">CORPORATION</div>
-- <div class="s-sub" style="margin-top:2vh;">
-- <span class="ri">Beloved by no one. Missed by the bots.</span><br>
-- "All Rights Reserved" — all rights to the graveyard.<br>
-- <span class="hi">Survived by: OpenVibe.Live (thriving).</span> 🪦
-- </div>
--</div>
--
--
--<!-- SLIDE 16: Rick's DMs -->
--<div class="slide" id="s16">
-- <div class="s-eyebrow" style="font-size:2.5vh;margin-bottom:2vh;">📱 Rick's DMs (probably)</div>
-- <div style="display:flex;flex-direction:column;gap:1.2vh;max-width:55vw;text-align:left;">
-- <div style="background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.25);border-radius:12px;padding:1.2vh 2vw;font-family:'Fira Code',monospace;font-size:2.2vh;font-weight:700;">
-- <span style="color:rgba(239,68,68,0.6);font-size:1.4vh;">RICK · 2:34 AM</span><br>
-- <span style="color:#fff;">hey can you guys stop downloading</span>
-- </div>
-- <div style="background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.25);border-radius:12px;padding:1.2vh 2vw;font-family:'Fira Code',monospace;font-size:2.2vh;font-weight:700;">
-- <span style="color:rgba(239,68,68,0.6);font-size:1.4vh;">RICK · 2:35 AM</span><br>
-- <span style="color:#fff;">seriously we're hitting our bandwidth cap</span>
-- </div>
-- <div style="background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.25);border-radius:12px;padding:1.2vh 2vw;font-family:'Fira Code',monospace;font-size:2.2vh;font-weight:700;">
-- <span style="color:rgba(239,68,68,0.6);font-size:1.4vh;">RICK · 2:41 AM</span><br>
-- <span style="color:#fff;">hello?? all rights reserved guys</span>
-- </div>
-- <div style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.3);border-radius:12px;padding:1.2vh 2vw;font-family:'Fira Code',monospace;font-size:2.2vh;font-weight:700;align-self:flex-end;">
-- <span style="color:rgba(139,92,246,0.6);font-size:1.4vh;">OPENVIBE · 3:00 AM</span><br>
-- <span style="color:#8b5cf6;">gg no re 🏠</span>
-- </div>
-- <div style="background:rgba(0,0,0,0.4);border:1px solid rgba(239,68,68,0.2);border-radius:8px;padding:.8vh 2vw;font-family:'Fira Code',monospace;font-size:1.6vh;color:rgba(239,68,68,0.6);font-style:italic;">
-- ⚠ System: download_cap_exceeded — HTTP 403 — Rick has left the chat
-- </div>
-- </div>
--</div>
--
--<!-- SLIDE 17: Achievement unlocked -->
--<div class="slide" id="s17">
-- <div style="background:rgba(0,0,0,0.5);border:3px solid #8b5cf6;border-radius:16px;padding:3vh 4vw;max-width:60vw;box-shadow:0 0 60px rgba(139,92,246,0.2);">
-- <div style="font-family:'Fira Code',monospace;font-size:2vh;color:rgba(139,92,246,0.7);letter-spacing:4px;text-transform:uppercase;margin-bottom:1.5vh;">🔔 Achievement Unlocked</div>
-- <div class="s-head lg gold-grad" style="margin-bottom:1.5vh;">"BANDWIDTH BULLY"</div>
-- <div style="font-size:2.6vh;font-weight:700;color:rgba(255,255,255,0.85);line-height:1.7;text-shadow:0 1px 8px rgba(0,0,0,0.9);">
-- Downloaded <span style="color:#8b5cf6;">9,309</span> VODs until the server<br>begged for mercy and threw a <span style="color:#ef4444;">403</span>.<br><br>
-- <span style="color:#22c55e;">Reward:</span> Rent free in Rick's head. Forever.
-- </div>
-- <div style="margin-top:2vh;font-family:'Orbitron',sans-serif;font-size:1.6vh;color:rgba(255,255,255,0.3);letter-spacing:3px;">GAMERSCORE: +9309 · RARITY: ONE OF A KIND</div>
-- </div>
--</div>
--
--<!-- SLIDE 18: The timeline -->
--<div class="slide" id="s18">
-- <div class="s-head md gold-grad" style="margin-bottom:2.5vh;">TIMELINE OF EVENTS</div>
-- <div style="display:flex;flex-direction:column;gap:1vh;max-width:65vw;text-align:left;">
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(139,92,246,0.6);font-family:'Fira Code',monospace;min-width:8vw;">2016</span>
-- <span style="color:rgba(255,255,255,0.75);">RobotStreamer launches, Rick becomes a "CEO"</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(139,92,246,0.6);font-family:'Fira Code',monospace;min-width:8vw;">2019</span>
-- <span style="color:rgba(255,255,255,0.75);">OpenVibe.Live starts. In a shed. For free.</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(139,92,246,0.6);font-family:'Fira Code',monospace;min-width:8vw;">2024</span>
-- <span style="color:#ef4444;">RS launches Wefunder. Raises approximately $0.</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(139,92,246,0.6);font-family:'Fira Code',monospace;min-width:8vw;">2025</span>
-- <span style="color:#ef4444;">RS quietly enables bandwidth caps on downloads</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(139,92,246,0.6);font-family:'Fira Code',monospace;min-width:8vw;">Apr 2026</span>
-- <span style="color:#8b5cf6;">One superfan downloads 9,309 VODs (872 GB)</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(239,68,68,0.8);font-family:'Fira Code',monospace;min-width:8vw;">Apr 2026</span>
-- <span style="color:#ef4444;">HTTP 403: download_cap_exceeded. Rick in shambles.</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(239,68,68,0.8);font-family:'Fira Code',monospace;min-width:8vw;">Apr 2026</span>
-- <span style="color:#ef4444;">Goosely posts Python VOD tool to Rick's Discord 🐍</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(239,68,68,0.8);font-family:'Fira Code',monospace;min-width:8vw;">Apr 2026</span>
-- <span style="color:#ef4444;">Rick: "might be a virus." It was open source Python. 🦠</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.2vh;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:rgba(139,92,246,0.6);font-family:'Fira Code',monospace;min-width:8vw;">Apr 2026</span>
-- <span style="color:#8b5cf6;">"do you even know how to code?" — Goosely, to the CEO</span>
-- </div>
-- <div style="display:flex;gap:2vw;align-items:baseline;font-size:2.4vh;font-weight:800;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:#22c55e;font-family:'Fira Code',monospace;min-width:8vw;">NOW</span>
-- <span style="color:#22c55e;">We are streaming this on robotstreamer. Hi Rick. 👋</span>
-- </div>
-- </div>
--</div>
--
--<!-- SLIDE 19: Org chart -->
--<div class="slide" id="s19">
-- <div class="s-head md wht" style="margin-bottom:2.5vh;">RobotStreamer Corp — Official Org Chart</div>
-- <div style="display:flex;flex-direction:column;align-items:center;gap:1.5vh;">
-- <div style="background:rgba(239,68,68,0.15);border:2px solid rgba(239,68,68,0.4);border-radius:10px;padding:1.2vh 3vw;font-family:'Orbitron',sans-serif;font-weight:900;font-size:2.5vh;color:#ef4444;">👤 RICK — CEO / CTO / CFO / COO</div>
-- <div style="width:2px;height:3vh;background:rgba(239,68,68,0.3);"></div>
-- <div style="display:flex;gap:3vw;">
-- <div style="display:flex;flex-direction:column;align-items:center;gap:1vh;">
-- <div style="width:2px;height:2vh;background:rgba(239,68,68,0.3);"></div>
-- <div style="background:rgba(0,0,0,0.4);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:.8vh 1.5vw;font-size:2vh;font-weight:700;color:rgba(255,255,255,0.5);">🤖 Bot #1<br><span style="font-size:1.4vh;color:rgba(255,255,255,0.3);">"Active User"</span></div>
-- </div>
-- <div style="display:flex;flex-direction:column;align-items:center;gap:1vh;">
-- <div style="width:2px;height:2vh;background:rgba(239,68,68,0.3);"></div>
-- <div style="background:rgba(0,0,0,0.4);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:.8vh 1.5vw;font-size:2vh;font-weight:700;color:rgba(255,255,255,0.5);">🤖 Bot #2<br><span style="font-size:1.4vh;color:rgba(255,255,255,0.3);">"Customer"</span></div>
-- </div>
-- <div style="display:flex;flex-direction:column;align-items:center;gap:1vh;">
-- <div style="width:2px;height:2vh;background:rgba(239,68,68,0.3);"></div>
-- <div style="background:rgba(239,68,68,0.08);border:1px dashed rgba(239,68,68,0.3);border-radius:8px;padding:.8vh 1.5vw;font-size:2vh;font-weight:700;color:rgba(239,68,68,0.5);">🪑 Support Team<br><span style="font-size:1.4vh;">[Unfilled Since 2019]</span></div>
-- </div>
-- <div style="display:flex;flex-direction:column;align-items:center;gap:1vh;">
-- <div style="width:2px;height:2vh;background:rgba(239,68,68,0.3);"></div>
-- <div style="background:rgba(0,0,0,0.4);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:.8vh 1.5vw;font-size:2vh;font-weight:700;color:rgba(255,255,255,0.5);">🤖 Bot #3<br><span style="font-size:1.4vh;color:rgba(255,255,255,0.3);">"Investor"</span></div>
-- </div>
-- </div>
-- <div style="margin-top:1vh;font-size:1.8vh;font-weight:700;color:rgba(255,255,255,0.4);font-style:italic;">*Bandwidth budget: exceeded by one superfan</div>
-- </div>
--</div>
--
--<!-- SLIDE 20: Google review -->
--<div class="slide" id="s20">
-- <div style="background:rgba(0,0,0,0.5);border:2px solid rgba(255,255,255,0.1);border-radius:16px;padding:3vh 4vw;max-width:62vw;text-align:left;box-shadow:0 0 40px rgba(0,0,0,0.5);">
-- <div style="display:flex;align-items:center;gap:1.5vw;margin-bottom:1.5vh;">
-- <div style="width:6vh;height:6vh;border-radius:50%;background:linear-gradient(135deg,#8b5cf6,#f5d394);display:flex;align-items:center;justify-content:center;font-size:3vh;">🏠</div>
-- <div>
-- <div style="font-size:2.2vh;font-weight:800;color:#fff;">OpenVibe.Live Official</div>
-- <div style="font-size:1.5vh;color:rgba(255,255,255,0.4);">1 review · Local Guide · Shed Dweller</div>
-- </div>
-- <div style="margin-left:auto;font-size:3vh;letter-spacing:-2px;">⭐<span style="color:rgba(255,255,255,0.15);">★★★★</span></div>
-- </div>
-- <div style="font-size:2.3vh;font-weight:700;color:rgba(255,255,255,0.85);line-height:1.7;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- "Great platform if you like <span style="color:#ef4444;">403 errors</span>, closed source drama,<br>
-- a Wefunder with 3 backers, and watching Rick<br>
-- slowly come to terms with losing to open source.<br><br>
-- Downloaded <span style="color:#8b5cf6;">9,309 VODs</span> before their servers literally<br>
-- gave up. <span style="color:#22c55e;">Would crash bandwidth cap again.</span>"
-- </div>
-- <div style="margin-top:1.5vh;font-size:1.6vh;color:rgba(255,255,255,0.3);">Posted April 2026 · Helpful? 👍 9309 · 👎 0 (Rick)</div>
-- </div>
--</div>
--
--<!-- SLIDE 21: Job posting -->
--<div class="slide" id="s21">
-- <div style="background:rgba(0,0,0,0.5);border:2px solid rgba(255,255,255,0.08);border-radius:16px;padding:2.5vh 4vw;max-width:62vw;text-align:left;">
-- <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1.5vh;">
-- <div>
-- <div style="font-size:2.8vh;font-weight:900;color:#fff;text-shadow:0 1px 8px rgba(0,0,0,0.9);">Senior Bandwidth Manager</div>
-- <div style="font-size:2vh;font-weight:700;color:#ef4444;margin-top:.3vh;">RobotStreamer Corporation · Ghost Town, Internet</div>
-- </div>
-- <div style="background:rgba(239,68,68,0.15);border:1px solid rgba(239,68,68,0.3);border-radius:8px;padding:.5vh 1.5vw;font-size:1.6vh;font-weight:700;color:#ef4444;">URGENT HIRE 🚨</div>
-- </div>
-- <div style="font-size:2vh;font-weight:700;color:rgba(255,255,255,0.75);line-height:1.8;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:#8b5cf6;">Responsibilities:</span><br>
-- · Prevent anyone from downloading 9,309+ VODs<br>
-- · Explain to Rick why the bandwidth cap exists<br>
-- · Monitor the 403 error dashboard (full-time job now)<br>
-- · Attend daily All Hands Meeting with Rick and the bots<br><br>
-- <span style="color:#8b5cf6;">Compensation:</span> <span style="color:#ef4444;">Equity in a closed-source dead site</span><br>
-- <span style="color:#8b5cf6;">Requirements:</span> Must be okay with losing to a shed<br>
-- </div>
-- <div style="margin-top:1.5vh;font-size:1.6vh;color:rgba(255,255,255,0.25);font-style:italic;">RobotStreamer Corporation is an equal opportunity employer (of bots)</div>
-- </div>
--</div>
--
--
--<!-- SLIDE 22: Rick called Python a virus -->
--<div class="slide" id="s22">
-- <div class="stamp red-stamp" style="transform:rotate(1deg);">🦠 RICK'S OFFICIAL DIAGNOSIS</div>
-- <div class="s-head lg red" style="margin-top:.8vh;">RICK CALLED</div>
-- <div class="s-head xl gold-grad" style="margin-top:-.3vh;">PYTHON</div>
-- <div class="s-head lg red" style="margin-top:-.3vh;">A VIRUS</div>
-- <div class="s-sub" style="margin-top:1.5vh;">
-- Open source script. Publicly readable. A few hundred lines.<br>
-- <span class="ri">"Might be fine but have no idea until its reviewed."</span><br>
-- <span class="hi">This is the CEO. This is who has your data.</span> 💀
-- </div>
--</div>
--
--<!-- SLIDE 23: The Discord exchange (real quotes) -->
--<div class="slide" id="s23">
-- <div class="s-eyebrow" style="font-size:2.5vh;margin-bottom:1.5vh;">📢 Rick's Discord — Real Actual Quotes</div>
-- <div style="display:flex;flex-direction:column;gap:1vh;max-width:64vw;text-align:left;">
-- <div style="background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.25);border-radius:12px;padding:1vh 1.8vw;font-family:'Fira Code',monospace;font-size:1.85vh;font-weight:700;">
-- <span style="color:rgba(239,68,68,0.55);font-size:1.3vh;">rs (CEO, RobotStreamer) · 1:16 PM</span><br>
-- <span style="color:#fff;">@everyone advice: don't use that ^ until we have time to review the code. Might be fine but have no idea until its reviewed.</span>
-- </div>
-- <div style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.3);border-radius:12px;padding:1vh 1.8vw;font-family:'Fira Code',monospace;font-size:1.85vh;font-weight:700;">
-- <span style="color:rgba(139,92,246,0.55);font-size:1.3vh;">Goosely 🦆 · 1:20 PM</span><br>
-- <span style="color:#8b5cf6;">lol k dipshit i will just delete it<br>learn to read code<br>its literally a script that is a few hundred lines,<br>do you even know how to code?</span>
-- </div>
-- <div style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.3);border-radius:12px;padding:1vh 1.8vw;font-family:'Fira Code',monospace;font-size:1.85vh;font-weight:700;">
-- <span style="color:rgba(139,92,246,0.55);font-size:1.3vh;">Goosely 🦆 · 1:20 PM</span><br>
-- <span style="color:#8b5cf6;">I am not a malicious person. That was open source code you can literally read in about 30 seconds, how stupid are you?</span>
-- </div>
-- <div style="background:rgba(0,0,0,0.4);border:1px solid rgba(239,68,68,0.2);border-radius:8px;padding:.6vh 1.8vw;font-family:'Fira Code',monospace;font-size:1.4vh;color:rgba(239,68,68,0.55);font-style:italic;">
-- ⚠ update: RS VOD system still broken. can't view any vods. Rick: unavailable. His data: your problem.
-- </div>
-- </div>
--</div>
--
--<!-- SLIDE 24: CEO Credentials -->
--<div class="slide" id="s24">
-- <div class="s-head md wht" style="margin-bottom:2.5vh;">RobotStreamer CEO — Verified Qualifications</div>
-- <div style="display:flex;gap:8vw;align-items:flex-start;justify-content:center;">
-- <div style="text-align:left;">
-- <div style="font-family:'Bebas Neue',sans-serif;font-size:2.8vh;color:#ef4444;letter-spacing:3px;margin-bottom:1.2vh;">CANNOT:</div>
-- <div style="font-size:2.1vh;font-weight:700;color:rgba(255,255,255,0.8);line-height:2.1;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:#ef4444;">✗</span> Read open-source Python<br>
-- <span style="color:#ef4444;">✗</span> Distinguish code from malware<br>
-- <span style="color:#ef4444;">✗</span> Review ~1,600 lines of script<br>
-- <span style="color:#ef4444;">✗</span> Manage a bandwidth budget<br>
-- <span style="color:#ef4444;">✗</span> Keep his VOD system working<br>
-- <span style="color:#ef4444;">✗</span> Respond to his community
-- </div>
-- </div>
-- <div style="text-align:left;">
-- <div style="font-family:'Bebas Neue',sans-serif;font-size:2.8vh;color:#8b5cf6;letter-spacing:3px;margin-bottom:1.2vh;">CAN:</div>
-- <div style="font-size:2.1vh;font-weight:700;color:rgba(255,255,255,0.8);line-height:2.1;text-shadow:0 1px 6px rgba(0,0,0,0.9);">
-- <span style="color:#22c55e;">✓</span> Send @everyone warnings<br>
-- <span style="color:#22c55e;">✓</span> Say "All Rights Reserved"<br>
-- <span style="color:#22c55e;">✓</span> File a Wefunder<br>
-- <span style="color:#22c55e;">✓</span> Employ bots as users<br>
-- <span style="color:#22c55e;">✓</span> Hold your data hostage
-- </div>
-- </div>
-- </div>
-- <div class="s-sub" style="margin-top:2vh;">
-- <span class="ri">This man is the CEO.</span> <span class="hi">He holds your data.</span><br>
-- <span class="gi">"do you even know how to code?" — Goosely, probably 🙂</span>
-- </div>
--</div>
--
--</div><!-- /stage -->
--
--<!-- Bottom ticker -->
--<div class="ticker-wrap ticker-bottom">
-- <div class="ticker-inner" id="ticker-bot"></div>
--</div>
--
--<!-- Top ticker -->
--<div class="ticker-wrap ticker-top">
-- <div class="ticker-inner" id="ticker-top-el"></div>
--</div>
--
--<script>
--'use strict';
--
--// ══════════════════════════════════════════
--// PARTICLES
--// ══════════════════════════════════════════
--tsParticles.load('tsparticles', {
-- fullScreen:false, background:{color:'transparent'},
-- particles:{
-- number:{value:60},
-- color:{value:['#8b5cf6','#f5d394','#8b6914','#ffffff','#ef4444']},
-- opacity:{value:{min:.03,max:.18},animation:{enable:true,speed:.3,minimumValue:.02}},
-- size:{value:{min:1,max:3}},
-- move:{enable:true,speed:.35,direction:'top',outModes:'out'},
-- links:{enable:false},
-- },
-- detectRetina:true,
--});
--
--// ══════════════════════════════════════════
--// EMBERS
--// ══════════════════════════════════════════
--function spawnEmber() {
-- var e = document.createElement('div');
-- e.className = 'ember';
-- e.style.left = Math.random()*100+'vw';
-- e.style.top = '100vh';
-- document.body.appendChild(e);
-- gsap.to(e,{
-- y:-(window.innerHeight*(.6+Math.random()*.4)),
-- x:(Math.random()-.5)*window.innerWidth*.12,
-- opacity:Math.random()*.4+.1,
-- duration:4+Math.random()*6,
-- ease:'none',
-- onComplete:function(){e.remove();}
-- });
--}
--setInterval(spawnEmber,220);
--
--// ══════════════════════════════════════════
--// TICKERS
--// ══════════════════════════════════════════
--var BOT_MSGS = [
-- '\u26a1 OPENVIBELIVE.COM — FREE & OPEN SOURCE FOREVER \u26a1',
-- '9,309 VODS DOWNLOADED BEFORE ROBOTSTREAMER HIT THEIR BANDWIDTH CAP &#x1F923;',
-- 'RICK CAN\'T CODE. RICK CAN\'T STOP THIS. RICK IS MALDING.',
-- '\ud83d\udc80 RIP ROBOTSTREAMER CORPORATION \ud83d\udc80',
-- 'WEFUNDER? MORE LIKE WEFUNERAL LMAOOOOO',
-- '\ud83c\udfe0 BUILT FROM A SHED — RUNS BETTER THAN RICK\'S "CORPORATION"',
-- '872 GB OF FOOTAGE. ARCHIVED FOREVER. THEN ERROR 403. LMAO.',
-- 'DOWNLOAD_CAP_EXCEEDED — HTTP 403 — WE BROKE RICK\'S SERVERS LMAOOO',
-- 'IMAGINE LOSING TO A GUY IN A SHED. IMAGINE.',
-- '\ud83e\udea6 HERE LIES ROBOTSTREAMER — ALL RIGHTS RESERVED ON A DEAD SITE \ud83e\udea6',
-- 'GOOSELY — ROBOT 1837 — 439 FOLLOWERS — EVERY VOD SAVED',
-- '\ud83d\udd25 OPEN SOURCE ALWAYS WINS \ud83d\udd25',
-- '"ALL RIGHTS RESERVED" — RIGHTS TO WHAT EXACTLY??? \ud83e\udd23',
-- 'OPENVIBELIVE FOR LIFE — NO CORPORATE BS — NO PAYWALLS',
-- 'RICK\'S BANDWIDTH CAP: EXCEEDED. RICK\'S REACTION: SEETHING. GG \ud83d\ude2d',
-- '9,309 VODS. 872 GB. THEN HTTP 403. WE CRASHED ROBOTSTREAMER. LMAOOO',
-- '\ud83e\udda0 RICK CALLED OPEN SOURCE PYTHON A VIRUS. HE IS THE CEO. 2026.',
-- '"MIGHT BE FINE BUT HAVE NO IDEA" — RICK, ABOUT A PYTHON SCRIPT HE COULD HAVE READ IN 30 SECONDS',
-- '"DO YOU EVEN KNOW HOW TO CODE?" — GOOSELY TO RICK (CEO OF ROBOTSTREAMER) \ud83d\udd25',
-- 'RICK: CANNOT REVIEW PYTHON. RICK: HAS ACCESS TO YOUR DATA. THINK ABOUT THAT. \ud83d\ude37',
-- 'RS VOD SYSTEM: BROKEN. RICK: UNAVAILABLE. GOOSELY\'S VODS: SAFE ON OPENVIBELIVE. \ud83c\udfe0',
--];
--var TOP_MSGS = [
-- '\u26a0 LIVE ON OPENVIBELIVE.COM \u26a0',
-- 'ROBOT 1837 — GOOSELY — STREAMING FOREVER',
-- 'ROBOTSTREAMER CORPORATION: STATUS DECEASED',
-- 'OPERATION: ARCHIVE EVERYTHING — 9,309 VODS THEN BANDWIDTH CAP 💀',
-- 'THREAT LEVEL: MAXIMUM OPENVIBE',
-- 'RICK\'S REACTION: PENDING (SEETHING)',
-- 'OPENVIBELIVE: FULLY OPERATIONAL — RICK: NOT SO MUCH',
-- 'VODS SECURED: 9,309 — BANDWIDTH EXCEEDED — ERROR: download_cap_exceeded',
-- 'AUTHORIZATION: SHED COMMANDER — CLEARANCE: OPENVIBE LEVEL 5',
-- '🦠 RICK DIAGNOSED OPEN SOURCE PYTHON AS "MIGHT BE A VIRUS" — CEO OF THE YEAR',
-- 'CEO WHO CAN\'T READ PYTHON HOLDS YOUR DATA — SLEEP WELL — ROBOTSTREAMER.COM',
--];
--
--function fillTicker(el, msgs) {
-- var dup = msgs.concat(msgs).concat(msgs).concat(msgs);
-- el.innerHTML = dup.map(function(m){ return '<span style="display:inline-block;padding:0 3vw;">\u25cf '+m+'</span>'; }).join('');
--}
--fillTicker(document.getElementById('ticker-bot'), BOT_MSGS);
--fillTicker(document.getElementById('ticker-top-el'), TOP_MSGS);
--
--// ══════════════════════════════════════════
--// SLIDES
--// ══════════════════════════════════════════
--var SLIDES = [
-- {id:'s0', dur:7000},
-- {id:'s1', dur:8000},
-- {id:'s2', dur:7000},
-- {id:'s3', dur:10000},
-- {id:'s4', dur:8000},
-- {id:'s5', dur:7000},
-- {id:'s6', dur:7000},
-- {id:'s7', dur:8000},
-- {id:'s8', dur:8000},
-- {id:'s9', dur:5000},
-- {id:'s10', dur:7000},
-- {id:'s11', dur:7000},
-- {id:'s12', dur:9000},
-- {id:'s13', dur:7000},
-- {id:'s14', dur:11000},
-- {id:'s15', dur:8000},
-- {id:'s16', dur:11000},
-- {id:'s17', dur:9000},
-- {id:'s18', dur:12000},
-- {id:'s19', dur:10000},
-- {id:'s20', dur:10000},
-- {id:'s21', dur:11000},
-- {id:'s22', dur:9000},
-- {id:'s23', dur:14000},
-- {id:'s24', dur:11000},
--];
--
--var current = 0;
--var isTransitioning = false;
--
--// Build dots
--var dotsEl = document.getElementById('dots');
--SLIDES.forEach(function(_,i){
-- var d = document.createElement('div');
-- d.className = 'dot' + (i===0?' active':'');
-- d.id = 'dot'+i;
-- dotsEl.appendChild(d);
--});
--
--function updateDots(idx) {
-- SLIDES.forEach(function(_,i){
-- var d = document.getElementById('dot'+i);
-- d.className = 'dot'+(i===idx?' active':'');
-- });
--}
--
--function getSlide(idx) {
-- return document.getElementById(SLIDES[idx].id);
--}
--
--// Special per-slide entry animations
--function playSlideIn(idx, el) {
-- var id = SLIDES[idx].id;
--
-- if (id === 's1') {
-- var obj = {val:0};
-- gsap.to(obj, {val:9309, duration:3.5, ease:'power2.out', onUpdate:function(){
-- var c = document.getElementById('vod-counter');
-- if(c) c.textContent = Math.floor(obj.val).toLocaleString();
-- }, onComplete:function(){
-- setTimeout(function(){
-- var c = document.getElementById('vod-counter');
-- if(c){ gsap.to(c,{color:'#ef4444',textShadow:'0 0 40px rgba(239,68,68,0.8)',duration:.3}); }
-- flashBang(.5);
-- }, 600);
-- }});
-- }
--
-- if (id === 's9') {
-- // VIBE4LIFE: fire confetti
-- fireConfetti();
-- }
--
-- if (id === 's5') {
-- // WEFUNERAL: skull rain
-- for(var i=0;i<15;i++){
-- setTimeout(skullRain, i*300);
-- }
-- }
--
-- if (id === 's12') {
-- flashBang(.8);
-- for(var j=0;j<20;j++){ setTimeout(skullRain, j*250); }
-- }
-- if (id === 's13') {
-- fireConfetti();
-- setTimeout(fireConfetti, 1500);
-- }
-- if (id === 's14') {
-- flashBang(.3);
-- }
-- if (id === 's15') {
-- for(var k=0;k<10;k++){ setTimeout(skullRain, k*400); }
-- }
-- if (id === 's17') {
-- // Achievement unlocked: mini firework
-- setTimeout(function(){
-- confetti({particleCount:80,startVelocity:25,spread:360,origin:{x:.5,y:.5},
-- colors:['#8b5cf6','#f5d394','#22c55e'],ticks:180,gravity:.8,scalar:1.2});
-- }, 400);
-- }
-- if (id === 's20') {
-- for(var m=0;m<6;m++){ setTimeout(skullRain, m*500); }
-- }
-- if (id === 's22') {
-- flashBang(.7);
-- for(var n=0;n<15;n++){ setTimeout(skullRain, n*260); }
-- }
-- if (id === 's23') {
-- flashBang(.4);
-- setTimeout(function(){
-- confetti({particleCount:60,startVelocity:20,spread:180,
-- origin:{x:.5,y:.3},colors:['#ef4444','#fff','#8b5cf6'],
-- ticks:150,gravity:1.2,scalar:.9});
-- }, 600);
-- }
--}
--
--function skullRain() {
-- var s = document.createElement('div');
-- s.className = 'skull-float';
-- var emojis = ['\ud83d\udc80','\u2620\ufe0f','\u26b0\ufe0f','\ud83e\udea6','\ud83d\udc7b'];
-- s.textContent = emojis[Math.floor(Math.random()*emojis.length)];
-- s.style.left = Math.random()*100+'vw';
-- s.style.top = '-6vh';
-- s.style.fontSize = (2+Math.random()*3)+'vh';
-- document.body.appendChild(s);
-- gsap.to(s,{top:'106vh',opacity:.6,rotation:Math.random()*360,
-- duration:3+Math.random()*3,ease:'none',onComplete:function(){s.remove();}});
--}
--
--function fireConfetti() {
-- var colors = ['#8b5cf6','#f5d394','#22c55e','#3b82f6','#a855f7','#ef4444','#fff'];
-- var end = Date.now()+3000;
-- (function frame(){
-- confetti({particleCount:8,angle:60,spread:80,origin:{x:0,y:.6},colors:colors,scalar:1.3});
-- confetti({particleCount:8,angle:120,spread:80,origin:{x:1,y:.6},colors:colors,scalar:1.3});
-- confetti({particleCount:5,angle:90,spread:130,origin:{x:.5,y:.3},colors:colors,scalar:1.6});
-- if(Date.now()<end) requestAnimationFrame(frame);
-- })();
--}
--
--function flashBang(intensity) {
-- gsap.to('#flash',{opacity:intensity||.6,duration:.06,onComplete:function(){
-- gsap.to('#flash',{opacity:0,duration:.5,ease:'power2.out'});
-- }});
--}
--
--// Transition variants (pick randomly for fun)
--var transitions = [
-- // fade
-- function(out, in_, cb) {
-- gsap.to(out,{opacity:0,duration:.5,ease:'power2.in',onComplete:function(){cb();}});
-- },
-- // slam up
-- function(out, in_, cb) {
-- gsap.to(out,{opacity:0,y:-80,duration:.4,ease:'power2.in',onComplete:function(){cb();}});
-- },
-- // slam down
-- function(out, in_, cb) {
-- gsap.to(out,{opacity:0,y:80,duration:.4,ease:'power2.in',onComplete:function(){cb();}});
-- },
-- // scale out
-- function(out, in_, cb) {
-- gsap.to(out,{opacity:0,scale:1.15,duration:.4,ease:'power2.in',onComplete:function(){cb();}});
-- },
-- // glitch out
-- function(out, in_, cb) {
-- flashBang(.5);
-- gsap.to(out,{opacity:0,x:20,duration:.15,ease:'steps(3)',onComplete:function(){
-- gsap.set(out,{x:0});
-- cb();
-- }});
-- },
--];
--
--function showSlide(idx, transIdx) {
-- if(isTransitioning) return;
-- isTransitioning = true;
--
-- var outEl = getSlide(current);
-- var inEl = getSlide(idx);
-- var tr = transitions[transIdx !== undefined ? transIdx : Math.floor(Math.random()*transitions.length)];
--
-- // pick entry animation params
-- var entryY = [30,-30,0,0,0][Math.floor(Math.random()*5)];
-- var entryScale = Math.random()>.5 ? .92 : 1.04;
--
-- tr(outEl, inEl, function(){
-- outEl.style.opacity = '0';
-- outEl.classList.remove('active');
--
-- gsap.set(inEl,{opacity:0, y:entryY, scale:entryScale});
-- inEl.classList.add('active');
-- gsap.to(inEl,{opacity:1, y:0, scale:1, duration:.6, ease:'power3.out', onComplete:function(){
-- isTransitioning = false;
-- }});
--
-- current = idx;
-- updateDots(idx);
-- playSlideIn(idx, inEl);
-- });
--}
--
--function nextSlide() {
-- var next = (current+1) % SLIDES.length;
-- showSlide(next);
-- scheduleNext();
--}
--
--var slideTimer;
--function scheduleNext() {
-- clearTimeout(slideTimer);
-- slideTimer = setTimeout(nextSlide, SLIDES[current].dur);
--}
--
--// Init
--gsap.set(getSlide(0), {opacity:1});
--getSlide(0).classList.add('active');
--playSlideIn(0, getSlide(0));
--scheduleNext();
--
--// ══════════════════════════════════════════
--// GLITCH EFFECTS (continuous)
--// ══════════════════════════════════════════
--setInterval(function(){
-- var g = document.getElementById('glitch');
-- gsap.to(g,{opacity:.3,duration:.05,onComplete:function(){
-- gsap.to(g,{opacity:0,duration:.1,delay:.04});
-- }});
--}, 2500+Math.random()*2000);
--
--setInterval(function(){
-- // random horizontal slice
-- gsap.to('.stage',{
-- clipPath:'inset('+(Math.random()*30)+'% 0 '+(Math.random()*30)+'% 0)',
-- duration:.05,
-- onComplete:function(){gsap.set('.stage',{clipPath:'none'});}
-- });
--}, 6000+Math.random()*4000);
--
--// ══════════════════════════════════════════
--// RANDOM MINI CONFETTI BURSTS
--// ══════════════════════════════════════════
--setInterval(function(){
-- confetti({
-- particleCount:15,
-- spread:60,
-- origin:{x:Math.random(),y:Math.random()*.4+.3},
-- colors:['#8b5cf6','#f5d394','#22c55e','#ef4444'],
-- gravity:1.3, scalar:.8, ticks:100,
-- });
--}, 12000);
--
--
--// ══════════════════════════════════════════
--// KEYBOARD: arrow keys to manually advance
--// ══════════════════════════════════════════
--document.addEventListener('keydown',function(e){
-- if(e.code==='ArrowRight'||e.code==='Space'){
-- clearTimeout(slideTimer);
-- nextSlide();
-- } else if(e.code==='ArrowLeft'){
-- clearTimeout(slideTimer);
-- var prev = (current-1+SLIDES.length)%SLIDES.length;
-- showSlide(prev);
-- scheduleNext();
-- }
--});
--</script>
--</body>
--</html>
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
iindex a8ee2435..2525bace 100644
---- a/server/chat/sounds-routes.js
++++ b/server/chat/sounds-routes.js
@@@ -247,6 +247,7 @@ router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
});
// If an emote was (re)specified, apply it to every sound under this command.
if (req.body.emote_code !== undefined) { try { db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }
++ try { require('../media-proxy/asset-sync').syncSoon(); } catch { /* mirror is best-effort */ }
// Tell everyone watching this channel to refresh their sound list live.
try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
@@@ -296,6 +297,7 @@ router.delete('/:id', requireAuth, (req, res) => {
if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }
db.deleteChannelSound(sound.id);
++ try { require('../media-proxy/asset-sync').removeAsset(sound.media_asset_id); } catch { /* */ }
try { require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }
res.json({ message: 'Sound deleted' });
} catch (err) {
ddiff --git a/server/emotes/routes.js b/server/emotes/routes.js
iindex 1212038f..f01efc50 100644
---- a/server/emotes/routes.js
++++ b/server/emotes/routes.js
@@@ -217,7 +217,7 @@ router.get('/global', (req, res) => {
const emotes = db.getGlobalEmotes().map(e => ({
id: `custom-${e.id}`,
code: e.code,
-- url: `/api/emotes/file/${path.basename(e.url)}`,
++ url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
animated: !!e.animated,
width: e.width,
height: e.height,
@@@ -238,7 +238,7 @@ router.get('/channel/:userId', (req, res) => {
id: `custom-${e.id}`,
emote_id: e.id,
code: e.code,
-- url: `/api/emotes/file/${path.basename(e.url)}`,
++ url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
animated: !!e.animated,
width: e.width,
height: e.height,
@@@ -259,7 +259,7 @@ router.get('/mine', requireAuth, (req, res) => {
const emotes = db.getEmotesByUser(req.user.id).map(e => ({
id: e.id,
code: e.code,
-- url: `/api/emotes/file/${path.basename(e.url)}`,
++ url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
animated: !!e.animated,
width: e.width,
height: e.height,
@@@ -378,6 +378,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), (req, res) => {
try { require('../chat/chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }
}
++ try { require('../media-proxy/asset-sync').syncSoon(); } catch { /* mirror is best-effort */ }
res.json({
emote: {
id: result.lastInsertRowid,
@@@ -490,6 +491,7 @@ router.delete('/:id', requireAuth, (req, res) => {
}
db.deleteEmote(req.params.id);
++ try { require('../media-proxy/asset-sync').removeAsset(emote.media_asset_id); } catch { /* */ }
if (emote.channel_owner_id) {
try { require('../chat/chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
}
@@@ -686,7 +688,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
globalCustom = db.getGlobalEmotes().map(e => ({
id: `custom-${e.id}`,
code: e.code,
-- url: `/api/emotes/file/${path.basename(e.url)}`,
++ url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
animated: !!e.animated,
width: e.width,
height: e.height,
@@@ -700,7 +702,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
id: `custom-${e.id}`,
emote_id: e.id,
code: e.code,
-- url: `/api/emotes/file/${path.basename(e.url)}`,
++ url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
animated: !!e.animated,
width: e.width,
height: e.height,
ddiff --git a/server/index.js b/server/index.js
iindex 7957f7cb..771a0323 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -943,6 +943,7 @@ async function start() {
// Announce newly-created clips in the source channel's chat. Scheduling comes
// from the OpenVibe.Media clip.ready webhook; the sweeper fires the message.
try { require('./media-proxy/clip-notify').startClipNotifySweeper(); } catch (e) { console.warn('[ClipNotify] not started:', e.message); }
++ try { require('./media-proxy/asset-sync').start(); } catch (e) { console.warn('[AssetSync] not started:', e.message); }
// Feed connected streamers' viewer counts into PowerChat overlays (platform mode).
try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
ddiff --git a/server/media-proxy/asset-sync.js b/server/media-proxy/asset-sync.js
nnew file mode 100644
iindex 00000000..3df706fe
---- /dev/null
++++ b/server/media-proxy/asset-sync.js
@@@ -0,0 +1,123 @@
++/**
++ * asset-sync.js — mirror Live's chat assets (emotes + channel sounds) into
++ * OpenVibe.Media, their canonical public home (served at /a/:id, browsable on
++ * the media index with uploader + channel attribution).
++ *
++ * Local files remain the low-latency working copy (chat playback reads disk);
++ * this job uploads anything Media doesn't have yet and records media_url /
++ * media_asset_id on the local row, so emote <img> URLs come from Media's
++ * long-cached endpoint and deletes propagate. Idempotent: rows are skipped
++ * once media_asset_id is set, and Media upserts on (kind, name, channel).
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const db = require('../db/database');
++const media = require('../media-client');
++
++const EMOTE_DIR = path.resolve(process.env.EMOTES_PATH || './data/emotes');
++const SOUND_DIR = path.resolve(process.env.SOUNDS_PATH || './data/sounds');
++const MIME_BY_EXT = {
++ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
++ '.webp': 'image/webp', '.avif': 'image/avif',
++ '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.webm': 'audio/webm',
++};
++
++function _ensureColumns() {
++ for (const [table, col, type] of [
++ ['emotes', 'media_url', 'TEXT'], ['emotes', 'media_asset_id', 'INTEGER'],
++ ['channel_sounds', 'media_url', 'TEXT'], ['channel_sounds', 'media_asset_id', 'INTEGER'],
++ ]) {
++ try { db.getDb().exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`); } catch { /* exists */ }
++ }
++}
++
++function _localFile(dir, urlOrPath) {
++ if (!urlOrPath) return null;
++ if (path.isAbsolute(urlOrPath) && fs.existsSync(urlOrPath)) return urlOrPath;
++ const p = path.join(dir, path.basename(urlOrPath));
++ return fs.existsSync(p) ? p : null;
++}
++
++async function _upload({ kind, name, filePath, user_id, username, channel_username, duration_seconds, meta }) {
++ const ext = path.extname(filePath).toLowerCase();
++ const fd = media._formData(
++ { kind, name, user_id, username, channel_username, duration_seconds, meta },
++ { buffer: fs.readFileSync(filePath), filename: path.basename(filePath), contentType: MIME_BY_EXT[ext] || 'application/octet-stream' },
++ 'file'
++ );
++ const out = await media.request('POST', '/assets', { body: fd, timeoutMs: 60000 });
++ return out && out.asset;
++}
++
++let _running = false;
++async function syncAll() {
++ if (_running) return;
++ _running = true;
++ try {
++ _ensureColumns();
++ const nameCache = {};
++ const uname = (id) => {
++ if (!id) return '';
++ if (!(id in nameCache)) { const u = db.getUserById(id); nameCache[id] = (u && u.username) || ''; }
++ return nameCache[id];
++ };
++ let synced = 0, failed = 0;
++
++ for (const e of db.all('SELECT * FROM emotes WHERE media_asset_id IS NULL')) {
++ const f = _localFile(EMOTE_DIR, e.url);
++ if (!f) continue;
++ try {
++ const asset = await _upload({
++ kind: 'emote', name: e.code, filePath: f,
++ user_id: e.user_id, username: uname(e.user_id),
++ channel_username: uname(e.channel_owner_id || e.user_id),
++ meta: { animated: !!e.animated, is_global: !!e.is_global },
++ });
++ if (asset) { db.run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [asset.url, asset.id, e.id]); synced++; }
++ } catch (err) { failed++; if (failed <= 3) console.warn('[AssetSync] emote', e.code, err.message); }
++ }
++
++ for (const s of db.all('SELECT * FROM channel_sounds WHERE media_asset_id IS NULL')) {
++ const f = _localFile(SOUND_DIR, s.url);
++ if (!f) continue;
++ try {
++ const asset = await _upload({
++ kind: 'sound', name: s.command, filePath: f,
++ user_id: s.created_by, username: s.created_by_name || uname(s.created_by),
++ channel_username: uname(s.channel_owner_id),
++ duration_seconds: s.duration_seconds || 0,
++ });
++ if (asset) { db.run('UPDATE channel_sounds SET media_url = ?, media_asset_id = ? WHERE id = ?', [asset.url, asset.id, s.id]); synced++; }
++ } catch (err) { failed++; if (failed <= 3) console.warn('[AssetSync] sound', s.command, err.message); }
++ }
++
++ if (synced || failed) console.log(`[AssetSync] Synced ${synced} chat assets to Media${failed ? ` (${failed} failed — will retry next pass)` : ''}`);
++ } finally {
++ _running = false;
++ }
++}
++
++let _debounce = null;
++/** Debounced sync — call after any emote/sound upload. */
++function syncSoon() {
++ clearTimeout(_debounce);
++ _debounce = setTimeout(() => syncAll().catch((e) => console.warn('[AssetSync]', e.message)), 5000);
++ if (_debounce.unref) _debounce.unref();
++}
++
++/** Best-effort removal of the Media copy when the local asset is deleted. */
++function removeAsset(mediaAssetId) {
++ if (!mediaAssetId) return;
++ media.request('DELETE', `/assets/${mediaAssetId}`).catch(() => {});
++}
++
++function start() {
++ const boot = setTimeout(() => syncAll().catch((e) => console.warn('[AssetSync]', e.message)), 20 * 1000);
++ if (boot.unref) boot.unref();
++ const timer = setInterval(() => syncAll().catch(() => {}), 6 * 60 * 60 * 1000);
++ if (timer.unref) timer.unref();
++}
++
++module.exports = { start, syncAll, syncSoon, removeAsset };
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex e22df074..2973c3d7 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -48,6 +48,13 @@ function withUserFields(clip) {
// Absolutize Media-relative URLs so the SPA doesn't resolve them against Live.
clip.playback_url = media.publicUrl(clip.playback_url) || media.clipUrl(clip.id);
if (clip.thumbnail_url) clip.thumbnail_url = media.publicUrl(clip.thumbnail_url);
++ // Short AI overview is Live-owned (clip_ai_state) — overlay for the cards.
++ if (clip.id != null && !clip.ai_overview_short) {
++ try {
++ const s = db.get('SELECT ai_overview_short FROM clip_ai_state WHERE clip_id = ?', [clip.id]);
++ if (s && s.ai_overview_short) clip.ai_overview_short = s.ai_overview_short;
++ } catch { /* best-effort */ }
++ }
return clip;
}
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex 542ba5d0..9ab2f6c0 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -93,7 +93,39 @@ router.post('/:slug/set-avatar', requireAuth, async (req, res) => {
});
// ── Everything else: transparent passthrough ─────────────────
--router.get('/', forward(''));
++// Media only stores our numeric user ids — resolve author names locally so
++// cards don't all read "Anonymous", and translate ?username= into user_id.
++function _nameUsers(rows) {
++ for (const p of rows || []) {
++ if (p && p.user_id != null && !p.username) {
++ const u = db.getUserById(p.user_id);
++ if (u) { p.username = u.username; p.display_name = u.display_name; p.profile_color = u.profile_color; p.avatar_url = u.avatar_url; }
++ }
++ }
++ return rows;
++}
++function forwardEnriched(subPath, pick) {
++ return async (req, res) => {
++ try {
++ const query = { ...req.query };
++ if (query.username && query.username !== 'all') {
++ const u = db.getUserByUsername(query.username);
++ delete query.username;
++ if (!u) return res.json({ pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });
++ query.user_id = u.id;
++ }
++ const p = typeof subPath === 'function' ? subPath(req) : subPath;
++ const out = await media.request('GET', `/pastes${p}`, { query, userToken: media.userTokenFrom(req) });
++ _nameUsers(pick(out));
++ res.json(out);
++ } catch (err) {
++ if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
++ console.warn('[Pastes proxy]', err.message);
++ res.status(502).json({ error: 'Media service unavailable' });
++ }
++ };
++}
++router.get('/', forwardEnriched('', (o) => o?.pastes));
router.post('/', forward(''));
router.get('/config', forward('/config'));
router.get('/admin/stats', requireAdmin, forwardAsApp('/admin/stats'));
@@@ -124,13 +156,13 @@ router.get('/by-user/:username', async (req, res) => {
.filter(p => !seen.has(p.slug) && seen.add(p.slug))
.sort((a, b) => (sort === 'oldest' ? 1 : -1) * (new Date(a.created_at) - new Date(b.created_at)))
.slice(0, limit);
-- res.json({ pastes, total: pastes.length, username: user.username });
++ res.json({ pastes: _nameUsers(pastes), total: pastes.length, username: user.username });
} catch (err) {
console.warn('[Pastes proxy] by-user:', err.message);
res.status(502).json({ error: 'Media service unavailable' });
}
});
--router.get('/:slug', forward(slugPath()));
++router.get('/:slug', forwardEnriched(slugPath(), (o) => (o?.paste ? [o.paste] : [o])));
router.put('/:slug', requireAuth, forward(slugPath()));
router.delete('/:slug', requireAuth, forward(slugPath()));
// TODO(contract): paste admin tools (censor, admin/stats, admin/forks, bulk)
@@@ -158,7 +190,7 @@ router.post('/:slug/fork', forward(slugPath('/fork')));
router.get('/:slug/raw', (req, res) => res.redirect(302, media.pasteRawUrl(req.params.slug)));
router.post('/:slug/like', requireAuth, forward(slugPath('/like')));
router.post('/:slug/copy', forward(slugPath('/copy')));
--router.get('/:slug/comments', forward(slugPath('/comments')));
++router.get('/:slug/comments', forwardEnriched(slugPath('/comments'), (o) => o?.comments));
router.post('/:slug/comments', forward(slugPath('/comments')));
router.delete('/:slug/comments/:commentId', forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex cb5bdb20..a2b0fae2 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -97,6 +97,13 @@ function withUserFields(row) {
// so the SPA doesn't resolve them against Live's origin.
row.playback_url = media.publicUrl(row.playback_url) || media.vodPlaybackUrl(row.id);
if (row.thumbnail_url) row.thumbnail_url = media.publicUrl(row.thumbnail_url);
++ // Short AI overview is Live-owned (vod_ai_state) — overlay for the cards.
++ if (row.id != null && !row.ai_overview_short) {
++ try {
++ const s = db.get('SELECT ai_overview_short FROM vod_ai_state WHERE vod_id = ?', [row.id]);
++ if (s && s.ai_overview_short) row.ai_overview_short = s.ai_overview_short;
++ } catch { /* best-effort */ }
++ }
return row;
}
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex a6758f67..abb29df2 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -330,6 +330,19 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
};
clips.forEach(nameClip);
clipsOfStreams.forEach(nameClip);
++ // Short AI overviews are Live-owned (vod_ai_state / clip_ai_state).
++ const aiShort = (rows, table, col) => {
++ for (const r of rows) {
++ if (!r || r.id == null || r.ai_overview_short) continue;
++ try {
++ const s = db.get(`SELECT ai_overview_short FROM ${table} WHERE ${col} = ?`, [r.id]);
++ if (s && s.ai_overview_short) r.ai_overview_short = s.ai_overview_short;
++ } catch { /* best-effort */ }
++ }
++ };
++ aiShort(vods, 'vod_ai_state', 'vod_id');
++ aiShort(clips, 'clip_ai_state', 'clip_id');
++ aiShort(clipsOfStreams, 'clip_ai_state', 'clip_id');
}
const followerCount = db.getFollowerCount(channel.user_id);
const isFollowing = req.user ? db.isFollowing(req.user.id, channel.user_id) : false;
@@@ -1171,6 +1184,18 @@ router.get('/recent-vods', async (req, res) => {
const media = require('../media-client');
const r = await media.listVods({ limit, offset }).catch(() => null);
const vods = r?.vods || (Array.isArray(r) ? r : []);
++ for (const v of vods) {
++ if (v && v.user_id != null && !v.username) {
++ const u = db.getUserById(v.user_id);
++ if (u) { v.username = u.username; v.display_name = u.display_name; v.avatar_url = u.avatar_url; }
++ }
++ if (v && v.id != null && !v.ai_overview_short) {
++ try {
++ const s = db.get('SELECT ai_overview_short FROM vod_ai_state WHERE vod_id = ?', [v.id]);
++ if (s && s.ai_overview_short) v.ai_overview_short = s.ai_overview_short;
++ } catch { /* */ }
++ }
++ }
const total = r?.total ?? vods.length;
res.json({ vods, total, limit, offset, hasMore: offset + vods.length < total });
} catch (err) {
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex 8eecb7d6..ad00a31f 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -3,12 +3,17 @@
// Consistent top bar across all services with logo, navigation,
// notification bell, account switcher, and theme-aware styling.
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
++// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
++// endpoint returning { user } — used only when no ov_token exists),
++// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
++// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
++// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
// ═══════════════════════════════════════════════════════════════
(function (root) {
'use strict';
-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null };
++ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
let _navEl = null;
function injectStyles() {
@@@ -362,6 +367,9 @@
],
media: [
{ label: 'Home', href: '/' },
++ { label: 'Network', href: 'https://openvibe.network' },
++ { label: 'Live', href: 'https://openvibe.live' },
++ { label: 'Tools', href: 'https://openvibe.tools' },
],
network: [
{ label: 'Home', href: '/' },
@@@ -429,6 +437,200 @@
try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
}
++ // ─── SSO auth resolution ───────────────────────────────────
++ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,
++ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.
++ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,
++ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,
++ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the
++ // shared cookie — so sign-in/out always goes through the gateway apex.
++ // Detection order: ov_token cookie → localStorage → page-provided token →
++ // app-specific session endpoint (opt-in via config.sessionUrl).
++ const TOOLS_APEX = 'openvibe.tools';
++ const TOKEN_KEY = 'ov_token';
++
++ function currentHost() {
++ return (typeof location !== 'undefined' && location.hostname) || '';
++ }
++
++ function onToolsDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);
++ }
++
++ function onNetworkDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }
++ }
++
++ function parseCookieToken(cookieStr) {
++ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);
++ if (!m || !m[1]) return null;
++ try { return decodeURIComponent(m[1]); } catch { return m[1]; }
++ }
++
++ function getCookieToken() {
++ if (typeof document === 'undefined') return null;
++ return parseCookieToken(document.cookie);
++ }
++
++ function getStoredToken() {
++ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }
++ }
++
++ function resolveToken() {
++ return getCookieToken() || getStoredToken() || _config.token || null;
++ }
++
++ /** Where "Sign In" goes on this host. */
++ function resolveLoginHref(host, returnUrl) {
++ if (_config.loginUrl) return _config.loginUrl;
++ if (onToolsDomain(host)) {
++ // Every *.openvibe.tools host signs in through the gateway apex: it
++ // holds the OAuth state cookie + redirect_uri (both apex-host-only)
++ // and sets ov_token with Domain=.openvibe.tools, so the session
++ // reaches all satellites. A host-local /auth/login would 404 on
++ // satellites and break the OAuth state check on gateway subdomains.
++ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
++ }
++ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
++ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;
++ }
++
++ function setAuthCookie(token) {
++ if (typeof document === 'undefined') return;
++ const maxAge = 24 * 60 * 60; // matches the 24h access JWT
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;
++ } else {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
++ }
++ }
++
++ function clearAuthState() {
++ if (typeof document !== 'undefined') {
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;
++ }
++ }
++ try {
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem('openvibe_anon_token');
++ localStorage.removeItem('openvibe_active_account');
++ } catch { /* storage unavailable */ }
++ }
++
++ function persistToken(token) {
++ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }
++ setAuthCookie(token);
++ }
++
++ /** GET {apiBase}/api/auth/me with the Bearer token. */
++ async function fetchMe(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/me`, {
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (res.ok) {
++ const data = await res.json();
++ return { ok: true, user: (data && (data.user || data)) || null };
++ }
++ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };
++ } catch {
++ return { ok: false, unauthorized: false };
++ }
++ }
++
++ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
++ async function gatewayRefresh() {
++ try {
++ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */
++ async function networkRefresh(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /**
++ * Resolve the signed-in user from the shared SSO state.
++ * With a token: validate it against the Network. If it is rejected, make
++ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,
++ * then the Network's grace refresh) before giving up — never leave a
++ * stale "Sign In" when a recoverable session exists.
++ */
++ async function resolveSessionUser() {
++ const token = resolveToken();
++ if (token) {
++ const me = await fetchMe(token);
++ if (me.ok && me.user) return { user: me.user, token };
++ if (me.unauthorized) {
++ if (onToolsDomain()) {
++ const r = await gatewayRefresh();
++ if (r) {
++ const user = r.user || (await fetchMe(r.token)).user;
++ if (user) { persistToken(r.token); return { user, token: r.token }; }
++ }
++ }
++ const n = await networkRefresh(token);
++ if (n) {
++ const user = n.user || (await fetchMe(n.token)).user;
++ if (user) { persistToken(n.token); return { user, token: n.token }; }
++ }
++ }
++ return null;
++ }
++ // No token anywhere — ask the page's own session endpoint if it has one
++ if (_config.sessionUrl) {
++ try {
++ const res = await fetch(_config.sessionUrl, { credentials: 'include' });
++ if (res.ok) {
++ const data = await res.json();
++ const user = (data && (data.user || data)) || null;
++ if (user && (user.username || user.id)) return { user, token: null };
++ }
++ } catch { /* signed out */ }
++ }
++ return null;
++ }
++
++ let _authInFlight = null;
++
++ function refreshAuthState() {
++ if (_authInFlight) return _authInFlight;
++ _authInFlight = resolveSessionUser()
++ .then((session) => {
++ _authInFlight = null;
++ if (session && session.user) {
++ _config.user = session.user;
++ if (session.token) _config.token = session.token;
++ render();
++ }
++ try {
++ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
++ detail: { user: session ? session.user : null, token: session ? session.token : null },
++ }));
++ } catch { /* non-DOM environment */ }
++ return session ? session.user : null;
++ })
++ .catch(() => { _authInFlight = null; return null; });
++ return _authInFlight;
++ }
++
function escapeAttr(value) {
return String(value ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
@@@ -497,8 +699,8 @@
const u = _config.user;
const accounts = getAccounts();
const isAnon = u && u.is_anon;
-- const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;
-- const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++ const loginHref = resolveLoginHref(currentHost(), window.location.href);
++ const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
nav.innerHTML = `
<a class="openvibe-navbar-brand" href="${brandHref}">
@@@ -588,12 +790,16 @@
dropdown.classList.remove('open');
if (_config.onLogout) _config.onLogout();
else {
-- document.cookie = 'ov_token=;path=/;max-age=0';
-- document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';
-- localStorage.removeItem('ov_token');
-- localStorage.removeItem('openvibe_anon_token');
-- localStorage.removeItem('openvibe_active_account');
-- window.location.reload();
++ clearAuthState();
++ _config.user = null;
++ _config.token = null;
++ if (onToolsDomain()) {
++ // The gateway also clears the Domain=.openvibe.tools cookie
++ // and the httpOnly refresh cookie, then sends us back here.
++ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
++ } else {
++ window.location.reload();
++ }
}
});
} else {
@@@ -621,9 +827,19 @@
init(opts = {}) {
Object.assign(_config, opts);
injectStyles();
-- return render();
++ const el = render();
++ // Pages that hand us a resolved user (openvibe.network, the tools
++ // gateway hub) keep full control. Everyone else — pages that pass
++ // only a token, or nothing at all — gets the user resolved from
++ // the shared SSO state (ov_token cookie / localStorage / optional
++ // sessionUrl) and the navbar re-renders when it arrives.
++ if (!_config.user) refreshAuthState();
++ return el;
},
++ /** Re-resolve the signed-in user from the shared SSO state. */
++ refreshAuth() { return refreshAuthState(); },
++
/** Update user (after account switch). */
setUser(user) {
_config.user = user;
@@@ -643,6 +859,18 @@
_navEl?.remove();
_navEl = null;
},
++
++ /** Internal auth helpers — exposed for tests, not a public API. */
++ _auth: {
++ parseCookieToken,
++ onToolsDomain,
++ onNetworkDomain,
++ resolveLoginHref,
++ resolveToken,
++ resolveSessionUser,
++ clearAuthState,
++ persistToken,
++ },
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;