openvibers/openrestream Commits
Front page: the signal itself, one stream in, every platform out (#220)
Changes
@@@ -33,6 +33,57 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}.ore-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}++/* The hero (11-07): the signal flow, one stream in, every platform out, a switch each. */++.ore-hero{display:grid;gap:28px;align-items:center;padding:28px 0 8px}++@media (min-width:900px){.ore-hero{grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:40px;padding:56px 0 16px}}++.ore-eyebrow{margin:0 0 10px;font-size:.78rem;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:color-mix(in srgb,var(--accent,#3b82f6) 70%,var(--ore-text))}++.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}++.ore-accent{background:linear-gradient(100deg,#9146FF,#FF0033 45%,#53FC18 90%);-webkit-background-clip:text;background-clip:text;color:transparent}++.ore-lede{margin:16px 0 0;max-width:52ch;font-size:1.06rem;line-height:1.6;color:var(--ore-muted)}++.ore-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}++.ore-btn{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 20px;border-radius:14px;border:1px solid var(--ore-line);color:var(--ore-text)!important;font-weight:750;text-decoration:none;transition:transform .15s,border-color .15s}++.ore-btn:hover{border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,var(--ore-line));transform:translateY(-1px)}++.ore-btn-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)!important;box-shadow:0 12px 30px -14px var(--accent,#3b82f6)}++.ore-note{margin:14px 0 0;font-size:.86rem;color:var(--ore-muted)}++.ore-flow{position:relative;display:grid;grid-template-columns:minmax(0,104px) 22px minmax(0,118px) minmax(230px,1fr);align-items:center;gap:0;padding:22px;border:1px solid var(--ore-line);border-radius:24px;background:radial-gradient(120% 120% at 0% 50%,color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent),transparent 60%),var(--ore-panel);counter-reset:ore-on}++.ore-src,.ore-hub{display:grid;justify-items:center;gap:3px;text-align:center;padding:12px 8px;border-radius:18px;background:var(--ore-bg);border:1px solid var(--ore-line);min-width:0}++.ore-src b,.ore-hub b{font-size:.84rem;letter-spacing:-.01em}.ore-src small,.ore-hub small{font-size:.72rem;color:var(--ore-muted);max-width:12ch}++.ore-src-dot{width:12px;height:12px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ore-rec 1.6s ease-out infinite}++@keyframes ore-rec{70%{box-shadow:0 0 0 10px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}++.ore-trunk{height:3px;background:repeating-linear-gradient(90deg,var(--accent,#3b82f6) 0 10px,transparent 10px 22px);background-size:44px 3px;animation:ore-flow 1s linear infinite;border-radius:2px}++.ore-hub{position:relative;border-color:color-mix(in srgb,var(--accent,#3b82f6) 45%,var(--ore-line))}++.ore-hub-ring{width:30px;height:30px;border-radius:50%;border:3px solid var(--accent,#3b82f6);box-shadow:0 0 18px color-mix(in srgb,var(--accent,#3b82f6) 60%,transparent)}++.ore-n::before{content:counter(ore-on)}++.ore-total{margin:0;font-size:.78rem;font-weight:700;color:var(--ore-muted);text-align:center;max-width:14ch}++@media (min-width:641px){.ore-src{grid-column:1;grid-row:1/3}.ore-trunk{grid-column:2;grid-row:1/3}.ore-hub{grid-column:3;grid-row:1;align-self:end}.ore-total{grid-column:3;grid-row:2;align-self:start;justify-self:center;margin-top:8px}.ore-dests{grid-column:4;grid-row:1/3;padding-left:6px}}++.ore-n{color:var(--ore-text);font-weight:900}++.ore-dests{list-style:none;margin:0;padding:0 0 0 0;display:grid;gap:6px}++.ore .ore-dest label{margin:0;color:inherit;font-size:inherit;position:relative;display:grid;grid-template-columns:26px 26px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:42px;padding:0 10px 0 0;border-radius:12px;cursor:pointer}++.ore-dest-in{position:absolute;opacity:0;pointer-events:none}++.ore-dest:has(.ore-dest-in:checked){counter-increment:ore-on}++.ore-wire{height:3px;border-radius:2px;background:color-mix(in srgb,var(--ore-text) 14%,transparent)}++.ore-dest:has(.ore-dest-in:checked) .ore-wire{background:repeating-linear-gradient(90deg,var(--c) 0 8px,transparent 8px 18px);background-size:36px 3px;animation:ore-flow .9s linear infinite;animation-delay:calc(var(--i) * -.13s)}++@keyframes ore-flow{to{background-position:36px 0}}++.ore-dest-mark{display:grid;place-items:center;width:26px;height:26px;color:var(--c)}++.ore-dest-mark svg{width:24px;height:24px}++.ore-dest-ov{font-size:.72rem;font-weight:900;color:var(--accent,#3b82f6)}++.ore-dest-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:700;font-size:.95rem;color:var(--ore-muted);transition:color .2s}++.ore-dest:has(.ore-dest-in:checked) .ore-dest-name{color:var(--ore-text)}++.ore-dest:not(:has(.ore-dest-in:checked)) .ore-dest-mark{filter:grayscale(1);opacity:.5}++.ore-switch{position:relative;width:38px;height:22px;border-radius:999px;background:color-mix(in srgb,var(--ore-text) 18%,transparent);transition:background-color .2s}++.ore-switch::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .2s cubic-bezier(.2,1.4,.4,1)}++.ore-dest:has(.ore-dest-in:checked) .ore-switch{background:var(--c)}++.ore-dest-own:has(.ore-dest-in:checked) .ore-switch{background:var(--accent,#3b82f6)}++.ore-dest:has(.ore-dest-in:checked) .ore-switch::after{transform:translateX(16px)}++.ore .ore-dest:has(.ore-dest-in:focus-visible) label{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}++.ore .ore-dest label:hover{background:color-mix(in srgb,var(--ore-text) 5%,transparent)}++/* A phone: top to bottom (stream, then OpenRestream, then the switches), the wires short and to the left. */++@media (max-width:640px){.ore-flow{grid-template-columns:1fr;justify-items:stretch;padding:16px;gap:0}.ore-src,.ore-hub{justify-self:center;grid-auto-flow:row}.ore-total{order:3;grid-column:1;grid-row:auto;justify-self:center;margin:10px 0 0}.ore-dests{order:4}.ore .ore-flow .ore-dest label{grid-template-columns:18px 26px minmax(0,1fr) auto;min-height:46px}.ore-trunk{justify-self:center;width:3px;height:22px;background:repeating-linear-gradient(180deg,var(--accent,#3b82f6) 0 8px,transparent 8px 16px);background-size:3px 32px;animation:ore-flow-v 1s linear infinite}.ore-dests{margin-top:12px}.ore-dest label{grid-template-columns:18px 26px minmax(0,1fr) auto;min-height:46px}}++@keyframes ore-flow-v{to{background-position:0 32px}}++.ore-logos{display:none;list-style:none;margin:14px 0 0;padding:0;gap:14px;align-items:center}.ore-logos li{display:grid;place-items:center;width:28px;height:28px;color:var(--c)}.ore-logos svg{width:26px;height:26px}.ore-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}++@media (max-width:640px){.ore-logos{display:flex}.ore-logos li:nth-child(3){width:44px}.ore-logos li:nth-child(3) svg{width:44px}}++.ore-stats-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,160px),1fr));margin:18px 0 6px}++@media (prefers-reduced-motion:reduce){.ore-src-dot,.ore-trunk,.ore-dest .ore-wire{animation:none!important}}.ore-tier-grid{margin:14px 0 0;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,250px),1fr));gap:1px;border:1px solid var(--ore-line);border-radius:14px;overflow:hidden;background:var(--ore-line)}.ore-tier-grid div{padding:12px 16px;background:var(--bg-card,#131c2e)}.ore-tier-grid dt{font-size:.74rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--ore-muted)}.ore-tier-grid dd{margin:4px 0 0;font-size:.95rem;line-height:1.45}nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++// Where OpenRestream streams to, with each platform's mark: Font Awesome Free 6 brand glyphs (CC BY 4.0,++// https://fontawesome.com/license/free), extracted to path data; Kick has no Font Awesome glyph, so it is its word in++// its green. The marks only say which service a destination is (nominative use); OpenRestream is not affiliated with++// any of them.++const MARKS = {++ 'twitch': { w: 512, d: "M391 103H353H391H353V213H391ZM285 103H246H285H246V213H285ZM121 0 24 91 121 0 24 91V421H140V512L237 421H314L488 256V0ZM449 238 372 311 449 238 372 311H295L227 375V311H140V37H449Z" },++ 'youtube': { w: 576, d: "M550 124Q538 87 501 75Q477 70 427 67Q377 65 334 64Q291 64 288 64Q285 64 242 64Q199 65 149 67Q99 70 75 75Q38 87 26 124Q21 147 18 177Q16 208 15 232Q15 255 15 256Q15 258 15 281Q16 305 18 335Q21 366 26 389Q38 426 75 437Q99 442 149 445Q199 447 242 448Q285 448 288 448Q288 448 288 448Q291 448 334 448Q377 447 427 445Q477 442 501 437Q538 426 550 389Q555 366 558 335Q560 305 561 281Q561 258 561 256Q561 255 561 232Q560 208 558 177Q555 147 550 124ZM232 338V175V338V175L375 256L232 338Z" },++ 'facebook': { w: 512, d: "M512 256Q511 184 477 127Q443 69 385 35Q328 1 256 0Q184 1 127 35Q69 69 35 127Q1 184 0 256Q2 348 55 415Q109 482 194 505V334H141V256H194V222Q193 93 319 95Q332 95 349 97Q365 99 375 101V172Q365 171 345 171Q314 171 300 184Q287 198 287 228V256H371L356 334H287V510Q384 496 447 426Q510 356 512 256Z" },++ 'x-twitter': { w: 512, d: "M389 48H460H389H460L306 224L487 464H345L234 319L107 464H36L201 276L27 48H172L273 181L389 48ZM364 422H404H364H404L151 88H109L364 422Z" },++ 'tiktok': { w: 448, d: "M448 210Q380 210 325 171V349Q325 401 296 442Q268 483 220 502Q171 519 123 507Q75 495 40 456Q7 417 1 367Q-4 318 20 272Q45 227 89 204Q134 182 185 188V278Q161 271 139 279Q117 286 102 306Q88 326 88 350Q88 373 102 394Q117 413 140 420Q162 428 186 420Q209 412 223 393Q237 374 237 349V0H325Q325 11 327 22Q338 73 381 102Q412 122 448 123Z" },++ 'linkedin': { w: 448, d: "M416 32H32H416H32Q18 32 9 41Q0 51 0 64V448Q0 461 9 471Q18 480 32 480H416Q430 480 439 471Q448 461 448 448V64Q448 51 439 41Q430 32 416 32ZM135 416H69H135H69V202H136V416ZM102 173Q86 173 75 162Q64 151 64 135Q64 118 75 107Q86 96 102 96Q119 96 129 107Q140 118 141 135Q140 151 129 162Q119 173 102 173ZM384 416H318H384H318V312Q319 292 313 274Q308 257 283 255Q258 256 250 272Q243 288 244 310V416H177V202H241V231H242Q248 218 264 208Q280 197 305 197Q354 198 370 226Q386 255 384 299V416Z" },++};++const PLATFORMS = [++ { id: 'twitch', name: 'Twitch', color: '#9146FF', mark: 'twitch' },++ { id: 'youtube', name: 'YouTube', color: '#FF0033', mark: 'youtube' },++ { id: 'kick', name: 'Kick', color: '#53FC18', mark: null },++ { id: 'x', name: 'X', color: '#E7E9EA', mark: 'x-twitter' },++ { id: 'facebook', name: 'Facebook', color: '#1877F2', mark: 'facebook' },++ { id: 'tiktok', name: 'TikTok', color: '#FE2C55', mark: 'tiktok' },++ { id: 'linkedin', name: 'LinkedIn', color: '#0A66C2', mark: 'linkedin' },++];++/** A platform's mark as inline SVG (currentColor), or its word for Kick. */++function markSvg(p) {++ const m = p.mark && MARKS[p.mark];++ if (!m) return `<svg viewBox="0 0 120 40" aria-hidden="true" focusable="false"><text x="60" y="30" text-anchor="middle" font-family="system-ui,sans-serif" font-size="30" font-weight="900" letter-spacing="-1" fill="currentColor">KICK</text></svg>`;++ return `<svg viewBox="0 0 ${m.w} 512" aria-hidden="true" focusable="false"><path fill="currentColor" d="${m.d}"/></svg>`;++}++module.exports = { PLATFORMS, MARKS, markSvg };@@@ -13,6 +13,7 @@* CSRF: every form carries a token derived from the session cookie (an attacker's page cannot* read the cookie, so it cannot produce the token); SameSite=Lax cookies are the second layer.*/++const { PLATFORMS, markSvg } = require('./platforms');const crypto = require('crypto');const ovServe = require('openvibe-shared/serve');const seo = require('openvibe-shared/seo');@@@ -196,18 +197,32 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }[f.destinations, f.destinations === 1 ? 'destination added' : 'destinations added'],[f.sessions, f.sessions === 1 ? 'time gone live' : 'times gone live'],].filter(([v]) => v > 0) : [];-- return showcase.hero({-- eyebrow: 'OpenRestream · open restreaming',-- title: 'Go live once.', accent: 'Stream everywhere.',-- lede: 'Send one stream from OBS or any RTMP encoder, and OpenRestream sends it to Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at the same time. Your OpenVibe Live channel is on by default, with chat, clips and VODs. It is free and open source, with no follower minimum.',-- actions: [-- { label: 'Start restreaming', href: '/streams', primary: true },-- { label: 'No OBS? Go live from your browser', href: live },-- { label: 'How browser streaming works', href: `${live}/docs/go-live-in-your-browser` },-- ],-- note: 'Alpha. RTMP ingest and RTMP, RTMPS and SRT restreaming run here. Going live from a browser happens on OpenVibe.Live for now.',-- html: stats.length ? `<dl class="ore-stats">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : undefined,-- }) + showcase.features({++ // The hero (the owner, 11-07: "way cooler animations and effects … stand out … platforms front-facing and easy to++ // see … switches … different on mobile versus desktop"): the signal itself. One stream goes in, OpenRestream fans++ // it out, and each platform has its own switch; the wires carry pulses only while their switch is on, and the++ // count says how many places you would be live. Checkboxes, :has() and a CSS counter: no script, so it works++ // under any CSP and without JavaScript. Left to right on a wide screen, top to bottom on a phone.++ const dests = PLATFORMS.map((pl, i) => `<li class="ore-dest" style="--c:${pl.color};--i:${i}"><label><input type="checkbox" class="ore-dest-in"${i < 3 ? ' checked' : ''}><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark">${markSvg(pl)}</span><span class="ore-dest-name">${esc(pl.name)}</span><span class="ore-switch" aria-hidden="true"></span></label></li>`).join('');++ const flow = `<div class="ore-flow" role="group" aria-label="Try it: switch platforms on and off">++<div class="ore-src"><span class="ore-src-dot" aria-hidden="true"></span><b>Your stream</b><small>OBS, any encoder, or your browser</small></div>++<span class="ore-trunk" aria-hidden="true"></span>++<div class="ore-hub"><span class="ore-hub-ring" aria-hidden="true"></span><b>OpenRestream</b></div>++<ul class="ore-dests">${dests}<li class="ore-dest ore-dest-own" style="--c:var(--accent,#3b82f6);--i:${PLATFORMS.length}"><label><input type="checkbox" class="ore-dest-in" checked><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark ore-dest-ov" aria-hidden="true">OV</span><span class="ore-dest-name">OpenVibe</span><span class="ore-switch" aria-hidden="true"></span></label></li></ul>++<p class="ore-total" aria-live="polite">Live on <span class="ore-n"></span> places at once</p>++</div>`;++ const hero = `<section class="ore-hero" aria-labelledby="ore-hero-h">++<div class="ore-hero-copy">++<p class="ore-eyebrow">Open restreaming · free · no follower minimum</p>++<h1 id="ore-hero-h">Go live once.<br><span class="ore-accent">Stream everywhere.</span></h1>++<ul class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>++<p class="ore-lede">Send one stream from OBS or your browser. OpenRestream sends it to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, and your OpenVibe Live channel comes with chat, clips and VODs.</p>++<div class="ore-actions"><a class="ore-btn ore-btn-primary" href="/streams">Start restreaming</a><a class="ore-btn" href="${esc(live)}">Go live from your browser</a></div>++<p class="ore-note">Alpha: RTMP in; RTMP, RTMPS and SRT out. Going live from a browser happens on OpenVibe.Live for now (<a href="${esc(live)}/docs/go-live-in-your-browser">how it works</a>).</p>++</div>++${flow}++</section>++${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;++ return hero + showcase.features({title: 'What OpenRestream does',items: [{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },@@@ -93,7 +93,12 @@ t('the front page leads with open restreaming, links to Live for browser go-livetry {const r = await request(api.base, 'GET', '/');assert.strictEqual(r.status, 200);-- assert.match(r.text, /<h1>Go live once\.<span class="sc-accent"> Stream everywhere\.<\/span><\/h1>/);++ assert.match(r.text, /<h1 id="ore-hero-h">Go live once\.<br><span class="ore-accent">Stream everywhere\.<\/span><\/h1>/);++ // The hero's signal flow (11-07): every platform with its own switch, the count by CSS, no script needed.++ const flow = (/<div class="ore-flow"[\s\S]*?<\/ul>/.exec(r.text) || [''])[0];++ for (const name of ['Twitch', 'YouTube', 'Kick', 'X', 'Facebook', 'TikTok', 'LinkedIn', 'OpenVibe']) assert.match(flow, new RegExp('<span class="ore-dest-name">' + name + '</span>'), name);++ assert.match(flow, /<input type="checkbox" class="ore-dest-in" checked>/);++ assert.doesNotMatch(flow, /<script/);// The free tier's real limits, as the service runs them (market outcome 1): the cap from its own config.const tier = (/<section class="sc-sec ore-tier"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];assert.match(tier, /<h2 id="ore-tier-h">The free tier, in numbers<\/h2>/);
@@ -33,6 +33,57 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
@@ -33,6 +33,57 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}
.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}
.ore-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}
.ore-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}
.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}
.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}
+/* The hero (11-07): the signal flow, one stream in, every platform out, a switch each. */
+.ore-hero{display:grid;gap:28px;align-items:center;padding:28px 0 8px}
+@media (min-width:900px){.ore-hero{grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:40px;padding:56px 0 16px}}
+.ore-eyebrow{margin:0 0 10px;font-size:.78rem;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:color-mix(in srgb,var(--accent,#3b82f6) 70%,var(--ore-text))}
+.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}
+.ore-accent{background:linear-gradient(100deg,#9146FF,#FF0033 45%,#53FC18 90%);-webkit-background-clip:text;background-clip:text;color:transparent}
+.ore-lede{margin:16px 0 0;max-width:52ch;font-size:1.06rem;line-height:1.6;color:var(--ore-muted)}
+.ore-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}
+.ore-btn{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 20px;border-radius:14px;border:1px solid var(--ore-line);color:var(--ore-text)!important;font-weight:750;text-decoration:none;transition:transform .15s,border-color .15s}
+.ore-btn:hover{border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,var(--ore-line));transform:translateY(-1px)}
+.ore-btn-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)!important;box-shadow:0 12px 30px -14px var(--accent,#3b82f6)}
+.ore-note{margin:14px 0 0;font-size:.86rem;color:var(--ore-muted)}
+.ore-flow{position:relative;display:grid;grid-template-columns:minmax(0,104px) 22px minmax(0,118px) minmax(230px,1fr);align-items:center;gap:0;padding:22px;border:1px solid var(--ore-line);border-radius:24px;background:radial-gradient(120% 120% at 0% 50%,color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent),transparent 60%),var(--ore-panel);counter-reset:ore-on}
+.ore-src,.ore-hub{display:grid;justify-items:center;gap:3px;text-align:center;padding:12px 8px;border-radius:18px;background:var(--ore-bg);border:1px solid var(--ore-line);min-width:0}
+.ore-src b,.ore-hub b{font-size:.84rem;letter-spacing:-.01em}.ore-src small,.ore-hub small{font-size:.72rem;color:var(--ore-muted);max-width:12ch}
+.ore-src-dot{width:12px;height:12px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ore-rec 1.6s ease-out infinite}
+@keyframes ore-rec{70%{box-shadow:0 0 0 10px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
+.ore-trunk{height:3px;background:repeating-linear-gradient(90deg,var(--accent,#3b82f6) 0 10px,transparent 10px 22px);background-size:44px 3px;animation:ore-flow 1s linear infinite;border-radius:2px}
+.ore-hub{position:relative;border-color:color-mix(in srgb,var(--accent,#3b82f6) 45%,var(--ore-line))}
+.ore-hub-ring{width:30px;height:30px;border-radius:50%;border:3px solid var(--accent,#3b82f6);box-shadow:0 0 18px color-mix(in srgb,var(--accent,#3b82f6) 60%,transparent)}
+.ore-n::before{content:counter(ore-on)}
+.ore-total{margin:0;font-size:.78rem;font-weight:700;color:var(--ore-muted);text-align:center;max-width:14ch}
+@media (min-width:641px){.ore-src{grid-column:1;grid-row:1/3}.ore-trunk{grid-column:2;grid-row:1/3}.ore-hub{grid-column:3;grid-row:1;align-self:end}.ore-total{grid-column:3;grid-row:2;align-self:start;justify-self:center;margin-top:8px}.ore-dests{grid-column:4;grid-row:1/3;padding-left:6px}}
+.ore-n{color:var(--ore-text);font-weight:900}
+.ore-dests{list-style:none;margin:0;padding:0 0 0 0;display:grid;gap:6px}
+.ore .ore-dest label{margin:0;color:inherit;font-size:inherit;position:relative;display:grid;grid-template-columns:26px 26px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:42px;padding:0 10px 0 0;border-radius:12px;cursor:pointer}
+.ore-dest-in{position:absolute;opacity:0;pointer-events:none}
+.ore-dest:has(.ore-dest-in:checked){counter-increment:ore-on}
+.ore-wire{height:3px;border-radius:2px;background:color-mix(in srgb,var(--ore-text) 14%,transparent)}
+.ore-dest:has(.ore-dest-in:checked) .ore-wire{background:repeating-linear-gradient(90deg,var(--c) 0 8px,transparent 8px 18px);background-size:36px 3px;animation:ore-flow .9s linear infinite;animation-delay:calc(var(--i) * -.13s)}
+@keyframes ore-flow{to{background-position:36px 0}}
+.ore-dest-mark{display:grid;place-items:center;width:26px;height:26px;color:var(--c)}
+.ore-dest-mark svg{width:24px;height:24px}
+.ore-dest-ov{font-size:.72rem;font-weight:900;color:var(--accent,#3b82f6)}
+.ore-dest-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:700;font-size:.95rem;color:var(--ore-muted);transition:color .2s}
+.ore-dest:has(.ore-dest-in:checked) .ore-dest-name{color:var(--ore-text)}
+.ore-dest:not(:has(.ore-dest-in:checked)) .ore-dest-mark{filter:grayscale(1);opacity:.5}
+.ore-switch{position:relative;width:38px;height:22px;border-radius:999px;background:color-mix(in srgb,var(--ore-text) 18%,transparent);transition:background-color .2s}
+.ore-switch::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .2s cubic-bezier(.2,1.4,.4,1)}
+.ore-dest:has(.ore-dest-in:checked) .ore-switch{background:var(--c)}
+.ore-dest-own:has(.ore-dest-in:checked) .ore-switch{background:var(--accent,#3b82f6)}
+.ore-dest:has(.ore-dest-in:checked) .ore-switch::after{transform:translateX(16px)}
+.ore .ore-dest:has(.ore-dest-in:focus-visible) label{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
+.ore .ore-dest label:hover{background:color-mix(in srgb,var(--ore-text) 5%,transparent)}
+/* A phone: top to bottom (stream, then OpenRestream, then the switches), the wires short and to the left. */
+@media (max-width:640px){.ore-flow{grid-template-columns:1fr;justify-items:stretch;padding:16px;gap:0}.ore-src,.ore-hub{justify-self:center;grid-auto-flow:row}.ore-total{order:3;grid-column:1;grid-row:auto;justify-self:center;margin:10px 0 0}.ore-dests{order:4}.ore .ore-flow .ore-dest label{grid-template-columns:18px 26px minmax(0,1fr) auto;min-height:46px}.ore-trunk{justify-self:center;width:3px;height:22px;background:repeating-linear-gradient(180deg,var(--accent,#3b82f6) 0 8px,transparent 8px 16px);background-size:3px 32px;animation:ore-flow-v 1s linear infinite}.ore-dests{margin-top:12px}.ore-dest label{grid-template-columns:18px 26px minmax(0,1fr) auto;min-height:46px}}
+@keyframes ore-flow-v{to{background-position:0 32px}}
+.ore-logos{display:none;list-style:none;margin:14px 0 0;padding:0;gap:14px;align-items:center}.ore-logos li{display:grid;place-items:center;width:28px;height:28px;color:var(--c)}.ore-logos svg{width:26px;height:26px}.ore-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
+@media (max-width:640px){.ore-logos{display:flex}.ore-logos li:nth-child(3){width:44px}.ore-logos li:nth-child(3) svg{width:44px}}
+.ore-stats-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,160px),1fr));margin:18px 0 6px}
+@media (prefers-reduced-motion:reduce){.ore-src-dot,.ore-trunk,.ore-dest .ore-wire{animation:none!important}}
.ore-tier-grid{margin:14px 0 0;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,250px),1fr));gap:1px;border:1px solid var(--ore-line);border-radius:14px;overflow:hidden;background:var(--ore-line)}
.ore-tier-grid{margin:14px 0 0;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,250px),1fr));gap:1px;border:1px solid var(--ore-line);border-radius:14px;overflow:hidden;background:var(--ore-line)}
.ore-tier-grid div{padding:12px 16px;background:var(--bg-card,#131c2e)}.ore-tier-grid dt{font-size:.74rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--ore-muted)}
.ore-tier-grid div{padding:12px 16px;background:var(--bg-card,#131c2e)}.ore-tier-grid dt{font-size:.74rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--ore-muted)}
.ore-tier-grid dd{margin:4px 0 0;font-size:.95rem;line-height:1.45}
.ore-tier-grid dd{margin:4px 0 0;font-size:.95rem;line-height:1.45}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+// Where OpenRestream streams to, with each platform's mark: Font Awesome Free 6 brand glyphs (CC BY 4.0,
+// https://fontawesome.com/license/free), extracted to path data; Kick has no Font Awesome glyph, so it is its word in
+// its green. The marks only say which service a destination is (nominative use); OpenRestream is not affiliated with
+// any of them.
+const MARKS = {
+ 'twitch': { w: 512, d: "M391 103H353H391H353V213H391ZM285 103H246H285H246V213H285ZM121 0 24 91 121 0 24 91V421H140V512L237 421H314L488 256V0ZM449 238 372 311 449 238 372 311H295L227 375V311H140V37H449Z" },
+ 'youtube': { w: 576, d: "M550 124Q538 87 501 75Q477 70 427 67Q377 65 334 64Q291 64 288 64Q285 64 242 64Q199 65 149 67Q99 70 75 75Q38 87 26 124Q21 147 18 177Q16 208 15 232Q15 255 15 256Q15 258 15 281Q16 305 18 335Q21 366 26 389Q38 426 75 437Q99 442 149 445Q199 447 242 448Q285 448 288 448Q288 448 288 448Q291 448 334 448Q377 447 427 445Q477 442 501 437Q538 426 550 389Q555 366 558 335Q560 305 561 281Q561 258 561 256Q561 255 561 232Q560 208 558 177Q555 147 550 124ZM232 338V175V338V175L375 256L232 338Z" },
+ 'facebook': { w: 512, d: "M512 256Q511 184 477 127Q443 69 385 35Q328 1 256 0Q184 1 127 35Q69 69 35 127Q1 184 0 256Q2 348 55 415Q109 482 194 505V334H141V256H194V222Q193 93 319 95Q332 95 349 97Q365 99 375 101V172Q365 171 345 171Q314 171 300 184Q287 198 287 228V256H371L356 334H287V510Q384 496 447 426Q510 356 512 256Z" },
+ 'x-twitter': { w: 512, d: "M389 48H460H389H460L306 224L487 464H345L234 319L107 464H36L201 276L27 48H172L273 181L389 48ZM364 422H404H364H404L151 88H109L364 422Z" },
+ 'tiktok': { w: 448, d: "M448 210Q380 210 325 171V349Q325 401 296 442Q268 483 220 502Q171 519 123 507Q75 495 40 456Q7 417 1 367Q-4 318 20 272Q45 227 89 204Q134 182 185 188V278Q161 271 139 279Q117 286 102 306Q88 326 88 350Q88 373 102 394Q117 413 140 420Q162 428 186 420Q209 412 223 393Q237 374 237 349V0H325Q325 11 327 22Q338 73 381 102Q412 122 448 123Z" },
+ 'linkedin': { w: 448, d: "M416 32H32H416H32Q18 32 9 41Q0 51 0 64V448Q0 461 9 471Q18 480 32 480H416Q430 480 439 471Q448 461 448 448V64Q448 51 439 41Q430 32 416 32ZM135 416H69H135H69V202H136V416ZM102 173Q86 173 75 162Q64 151 64 135Q64 118 75 107Q86 96 102 96Q119 96 129 107Q140 118 141 135Q140 151 129 162Q119 173 102 173ZM384 416H318H384H318V312Q319 292 313 274Q308 257 283 255Q258 256 250 272Q243 288 244 310V416H177V202H241V231H242Q248 218 264 208Q280 197 305 197Q354 198 370 226Q386 255 384 299V416Z" },
+};
+const PLATFORMS = [
+ { id: 'twitch', name: 'Twitch', color: '#9146FF', mark: 'twitch' },
+ { id: 'youtube', name: 'YouTube', color: '#FF0033', mark: 'youtube' },
+ { id: 'kick', name: 'Kick', color: '#53FC18', mark: null },
+ { id: 'x', name: 'X', color: '#E7E9EA', mark: 'x-twitter' },
+ { id: 'facebook', name: 'Facebook', color: '#1877F2', mark: 'facebook' },
+ { id: 'tiktok', name: 'TikTok', color: '#FE2C55', mark: 'tiktok' },
+ { id: 'linkedin', name: 'LinkedIn', color: '#0A66C2', mark: 'linkedin' },
+];
+/** A platform's mark as inline SVG (currentColor), or its word for Kick. */
+function markSvg(p) {
+ const m = p.mark && MARKS[p.mark];
+ if (!m) return `<svg viewBox="0 0 120 40" aria-hidden="true" focusable="false"><text x="60" y="30" text-anchor="middle" font-family="system-ui,sans-serif" font-size="30" font-weight="900" letter-spacing="-1" fill="currentColor">KICK</text></svg>`;
+ return `<svg viewBox="0 0 ${m.w} 512" aria-hidden="true" focusable="false"><path fill="currentColor" d="${m.d}"/></svg>`;
+}
+module.exports = { PLATFORMS, MARKS, markSvg };
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
* CSRF: every form carries a token derived from the session cookie (an attacker's page cannot
* CSRF: every form carries a token derived from the session cookie (an attacker's page cannot
* read the cookie, so it cannot produce the token); SameSite=Lax cookies are the second layer.
* read the cookie, so it cannot produce the token); SameSite=Lax cookies are the second layer.
*/
*/
+const { PLATFORMS, markSvg } = require('./platforms');
const crypto = require('crypto');
const crypto = require('crypto');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
@@ -196,18 +197,32 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -196,18 +197,32 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
[f.destinations, f.destinations === 1 ? 'destination added' : 'destinations added'],
[f.destinations, f.destinations === 1 ? 'destination added' : 'destinations added'],
[f.sessions, f.sessions === 1 ? 'time gone live' : 'times gone live'],
[f.sessions, f.sessions === 1 ? 'time gone live' : 'times gone live'],
].filter(([v]) => v > 0) : [];
].filter(([v]) => v > 0) : [];
- return showcase.hero({
- eyebrow: 'OpenRestream · open restreaming',
- title: 'Go live once.', accent: 'Stream everywhere.',
- lede: 'Send one stream from OBS or any RTMP encoder, and OpenRestream sends it to Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at the same time. Your OpenVibe Live channel is on by default, with chat, clips and VODs. It is free and open source, with no follower minimum.',
- actions: [
- { label: 'Start restreaming', href: '/streams', primary: true },
- { label: 'No OBS? Go live from your browser', href: live },
- { label: 'How browser streaming works', href: `${live}/docs/go-live-in-your-browser` },
- ],
- note: 'Alpha. RTMP ingest and RTMP, RTMPS and SRT restreaming run here. Going live from a browser happens on OpenVibe.Live for now.',
- html: stats.length ? `<dl class="ore-stats">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : undefined,
- }) + showcase.features({
+ // The hero (the owner, 11-07: "way cooler animations and effects … stand out … platforms front-facing and easy to
+ // see … switches … different on mobile versus desktop"): the signal itself. One stream goes in, OpenRestream fans
+ // it out, and each platform has its own switch; the wires carry pulses only while their switch is on, and the
+ // count says how many places you would be live. Checkboxes, :has() and a CSS counter: no script, so it works
+ // under any CSP and without JavaScript. Left to right on a wide screen, top to bottom on a phone.
+ const dests = PLATFORMS.map((pl, i) => `<li class="ore-dest" style="--c:${pl.color};--i:${i}"><label><input type="checkbox" class="ore-dest-in"${i < 3 ? ' checked' : ''}><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark">${markSvg(pl)}</span><span class="ore-dest-name">${esc(pl.name)}</span><span class="ore-switch" aria-hidden="true"></span></label></li>`).join('');
+ const flow = `<div class="ore-flow" role="group" aria-label="Try it: switch platforms on and off">
+<div class="ore-src"><span class="ore-src-dot" aria-hidden="true"></span><b>Your stream</b><small>OBS, any encoder, or your browser</small></div>
+<span class="ore-trunk" aria-hidden="true"></span>
+<div class="ore-hub"><span class="ore-hub-ring" aria-hidden="true"></span><b>OpenRestream</b></div>
+<ul class="ore-dests">${dests}<li class="ore-dest ore-dest-own" style="--c:var(--accent,#3b82f6);--i:${PLATFORMS.length}"><label><input type="checkbox" class="ore-dest-in" checked><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark ore-dest-ov" aria-hidden="true">OV</span><span class="ore-dest-name">OpenVibe</span><span class="ore-switch" aria-hidden="true"></span></label></li></ul>
+<p class="ore-total" aria-live="polite">Live on <span class="ore-n"></span> places at once</p>
+</div>`;
+ const hero = `<section class="ore-hero" aria-labelledby="ore-hero-h">
+<div class="ore-hero-copy">
+<p class="ore-eyebrow">Open restreaming · free · no follower minimum</p>
+<h1 id="ore-hero-h">Go live once.<br><span class="ore-accent">Stream everywhere.</span></h1>
+<ul class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>
+<p class="ore-lede">Send one stream from OBS or your browser. OpenRestream sends it to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, and your OpenVibe Live channel comes with chat, clips and VODs.</p>
+<div class="ore-actions"><a class="ore-btn ore-btn-primary" href="/streams">Start restreaming</a><a class="ore-btn" href="${esc(live)}">Go live from your browser</a></div>
+<p class="ore-note">Alpha: RTMP in; RTMP, RTMPS and SRT out. Going live from a browser happens on OpenVibe.Live for now (<a href="${esc(live)}/docs/go-live-in-your-browser">how it works</a>).</p>
+</div>
+${flow}
+</section>
+${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;
+ return hero + showcase.features({
title: 'What OpenRestream does',
title: 'What OpenRestream does',
items: [
items: [
{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },
{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },
@@ -93,7 +93,12 @@ t('the front page leads with open restreaming, links to Live for browser go-live
@@ -93,7 +93,12 @@ t('the front page leads with open restreaming, links to Live for browser go-live
try {
try {
const r = await request(api.base, 'GET', '/');
const r = await request(api.base, 'GET', '/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /<h1>Go live once\.<span class="sc-accent"> Stream everywhere\.<\/span><\/h1>/);
+ assert.match(r.text, /<h1 id="ore-hero-h">Go live once\.<br><span class="ore-accent">Stream everywhere\.<\/span><\/h1>/);
+ // The hero's signal flow (11-07): every platform with its own switch, the count by CSS, no script needed.
+ const flow = (/<div class="ore-flow"[\s\S]*?<\/ul>/.exec(r.text) || [''])[0];
+ for (const name of ['Twitch', 'YouTube', 'Kick', 'X', 'Facebook', 'TikTok', 'LinkedIn', 'OpenVibe']) assert.match(flow, new RegExp('<span class="ore-dest-name">' + name + '</span>'), name);
+ assert.match(flow, /<input type="checkbox" class="ore-dest-in" checked>/);
+ assert.doesNotMatch(flow, /<script/);
// The free tier's real limits, as the service runs them (market outcome 1): the cap from its own config.
// The free tier's real limits, as the service runs them (market outcome 1): the cap from its own config.
const tier = (/<section class="sc-sec ore-tier"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
const tier = (/<section class="sc-sec ore-tier"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
assert.match(tier, /<h2 id="ore-tier-h">The free tier, in numbers<\/h2>/);
assert.match(tier, /<h2 id="ore-tier-h">The free tier, in numbers<\/h2>/);