openvibers/openrestream Commits
The home states the free tier in numbers (market outcome) (#163)
Changes
@@@ -31,6 +31,9 @@ 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}++.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}.ore-faq details{border-bottom:1px solid var(--ore-line);padding:.75rem 0}.ore-faq details:first-of-type{border-top:1px solid var(--ore-line)}.ore-faq summary{cursor:pointer;font-weight:600;list-style-position:inside}.ore-faq summary:hover{color:#93c5fd}.ore-faq details p{margin:.5rem 0 0;color:var(--ore-muted);max-width:46rem}.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}@@@ -167,6 +167,26 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }return `rtmp://${host}${r.publicPort && Number(r.publicPort) !== 1935 ? `:${r.publicPort}` : ''}/live`;};++ /**++ * The free tier, in numbers (market outcome 1: state the real limits, not "free"): every figure is what the service++ * does today, the destination cap read from its own config, so the page can never promise more than it runs.++ */++ function freeTier() {++ const max = Number(config.outputs && config.outputs.maxPerStream) || 10;++ const rows = [++ ['Price', '$0. There is no paid tier'],++ ['Destinations', `Up to ${max} a stream, each with its own health, logs and retries`],++ ['Quality', 'What you send: a stream from OBS is copied, never re-encoded'],++ ['Watermark', 'None'],++ ['In', 'RTMP from OBS or any encoder; or your browser, on OpenVibe.Live'],++ ['Out', 'RTMP, RTMPS and SRT'],++ ['Recording', 'Per stream: a VOD, clips only, or nothing, saved to your OpenVibe.Video'],++ ['Minimum', 'None: no follower, subscriber or eligibility threshold'],++ ];++ return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h">The free tier, in numbers</h2>++<dl class="ore-tier-grid">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>`;++ }++function frontPage(cfg, f) {const live = cfg.liveUrl;const n = (v) => Number(v).toLocaleString('en-US');@@@ -197,7 +217,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }{ icon: 'ov:code', title: 'Open API and SDK', text: 'Streams, keys, sessions and destinations through the OpenVibe API and SDK, for your own tools and bots.' },{ icon: 'ov:community', title: 'Free, open, no minimum', text: 'Any OpenVibe account can restream from the first minute: no follower, subscriber or eligibility threshold, and the code is on GitHub.' },],-- }) + showcase.features({++ }) + freeTier() + showcase.features({id: 'destinations',title: 'Where it can stream',lede: 'Add each destination once, test it, and it goes live with you every time.',@@@ -73,6 +73,12 @@ t('the front page leads with open restreaming, links to Live for browser go-liveconst 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>/);++ // 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>/);++ assert.match(tier, /<dt>Destinations<\/dt><dd>Up to 10 a stream, each with its own health, logs and retries<\/dd>/);++ assert.match(tier, /<dt>Quality<\/dt><dd>What you send: a stream from OBS is copied, never re-encoded<\/dd>/);++ assert.match(tier, /<dt>Out<\/dt><dd>RTMP, RTMPS and SRT<\/dd>/);assert.match(r.text, /<meta name="description" content="Go live once from OBS and restream to Twitch, YouTube, Kick and any RTMP or SRT server at once/);const desc = /<meta name="description" content="([^"]*)"/.exec(r.text)[1];assert.ok(desc.length <= 160, `the description fits a search result (${desc.length})`);
@@ -31,6 +31,9 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
@@ -31,6 +31,9 @@ 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}
+.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}
.ore-faq details{border-bottom:1px solid var(--ore-line);padding:.75rem 0}.ore-faq details:first-of-type{border-top:1px solid var(--ore-line)}
.ore-faq details{border-bottom:1px solid var(--ore-line);padding:.75rem 0}.ore-faq details:first-of-type{border-top:1px solid var(--ore-line)}
.ore-faq summary{cursor:pointer;font-weight:600;list-style-position:inside}.ore-faq summary:hover{color:#93c5fd}.ore-faq details p{margin:.5rem 0 0;color:var(--ore-muted);max-width:46rem}
.ore-faq summary{cursor:pointer;font-weight:600;list-style-position:inside}.ore-faq summary:hover{color:#93c5fd}.ore-faq details p{margin:.5rem 0 0;color:var(--ore-muted);max-width:46rem}
.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}
.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}
@@ -167,6 +167,26 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -167,6 +167,26 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
return `rtmp://${host}${r.publicPort && Number(r.publicPort) !== 1935 ? `:${r.publicPort}` : ''}/live`;
return `rtmp://${host}${r.publicPort && Number(r.publicPort) !== 1935 ? `:${r.publicPort}` : ''}/live`;
};
};
+ /**
+ * The free tier, in numbers (market outcome 1: state the real limits, not "free"): every figure is what the service
+ * does today, the destination cap read from its own config, so the page can never promise more than it runs.
+ */
+ function freeTier() {
+ const max = Number(config.outputs && config.outputs.maxPerStream) || 10;
+ const rows = [
+ ['Price', '$0. There is no paid tier'],
+ ['Destinations', `Up to ${max} a stream, each with its own health, logs and retries`],
+ ['Quality', 'What you send: a stream from OBS is copied, never re-encoded'],
+ ['Watermark', 'None'],
+ ['In', 'RTMP from OBS or any encoder; or your browser, on OpenVibe.Live'],
+ ['Out', 'RTMP, RTMPS and SRT'],
+ ['Recording', 'Per stream: a VOD, clips only, or nothing, saved to your OpenVibe.Video'],
+ ['Minimum', 'None: no follower, subscriber or eligibility threshold'],
+ ];
+ return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h">The free tier, in numbers</h2>
+<dl class="ore-tier-grid">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>`;
+ }
+
function frontPage(cfg, f) {
function frontPage(cfg, f) {
const live = cfg.liveUrl;
const live = cfg.liveUrl;
const n = (v) => Number(v).toLocaleString('en-US');
const n = (v) => Number(v).toLocaleString('en-US');
@@ -197,7 +217,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -197,7 +217,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
{ icon: 'ov:code', title: 'Open API and SDK', text: 'Streams, keys, sessions and destinations through the OpenVibe API and SDK, for your own tools and bots.' },
{ icon: 'ov:code', title: 'Open API and SDK', text: 'Streams, keys, sessions and destinations through the OpenVibe API and SDK, for your own tools and bots.' },
{ icon: 'ov:community', title: 'Free, open, no minimum', text: 'Any OpenVibe account can restream from the first minute: no follower, subscriber or eligibility threshold, and the code is on GitHub.' },
{ icon: 'ov:community', title: 'Free, open, no minimum', text: 'Any OpenVibe account can restream from the first minute: no follower, subscriber or eligibility threshold, and the code is on GitHub.' },
],
],
- }) + showcase.features({
+ }) + freeTier() + showcase.features({
id: 'destinations',
id: 'destinations',
title: 'Where it can stream',
title: 'Where it can stream',
lede: 'Add each destination once, test it, and it goes live with you every time.',
lede: 'Add each destination once, test it, and it goes live with you every time.',
@@ -73,6 +73,12 @@ t('the front page leads with open restreaming, links to Live for browser go-live
@@ -73,6 +73,12 @@ t('the front page leads with open restreaming, links to Live for browser go-live
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>Go live once\.<span class="sc-accent"> Stream everywhere\.<\/span><\/h1>/);
+ // 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>/);
+ assert.match(tier, /<dt>Destinations<\/dt><dd>Up to 10 a stream, each with its own health, logs and retries<\/dd>/);
+ assert.match(tier, /<dt>Quality<\/dt><dd>What you send: a stream from OBS is copied, never re-encoded<\/dd>/);
+ assert.match(tier, /<dt>Out<\/dt><dd>RTMP, RTMPS and SRT<\/dd>/);
assert.match(r.text, /<meta name="description" content="Go live once from OBS and restream to Twitch, YouTube, Kick and any RTMP or SRT server at once/);
assert.match(r.text, /<meta name="description" content="Go live once from OBS and restream to Twitch, YouTube, Kick and any RTMP or SRT server at once/);
const desc = /<meta name="description" content="([^"]*)"/.exec(r.text)[1];
const desc = /<meta name="description" content="([^"]*)"/.exec(r.text)[1];
assert.ok(desc.length <= 160, `the description fits a search result (${desc.length})`);
assert.ok(desc.length <= 160, `the description fits a search result (${desc.length})`);