openvibers/openvibe.bench Commits
The clip battle: two clips of one stream, blind (item 151, C20) (#106)
Changes
@@@ -310,6 +310,10 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.bt-panes { display: grid; gap: 12px; grid-template-columns: 1fr; margin-top: 16px; }.bt-pane { margin: 0; display: grid; gap: 6px; min-width: 0; }.bt-pane figcaption { font-size: .76rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++/* A live.clip battle: the two clips, 16:9, and what shows when a picker abstained or a link ran out. */++.bt-clip { width: 100%; max-width: 100%; aspect-ratio: 16 / 9; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: #000; display: block; }++.bt-clip-none { aspect-ratio: 16 / 9; max-width: 100%; display: grid; place-content: center; gap: 6px; text-align: center; padding: 16px; border: 1px dashed var(--border, #1f2d47); border-radius: 12px; color: var(--text-muted, #7386a3); }++.bt-clip-none b { color: var(--text-primary, #e6edf7); }.bt-pane-frame { width: 100%; max-width: 100%; aspect-ratio: 4 / 3; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-primary, #0a0f1c); display: block; }.bt-vote { margin-top: 18px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); display: grid; gap: 12px; }.bt-vote fieldset { margin: 0; padding: 0; border: 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }@@@ -132,11 +132,14 @@ async function createApp(opts = {}) {contentSecurityPolicy: {directives: {defaultSrc: ["'self'"],-- scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],++ // cdnjs: hls.js for a live.clip battle's HLS clips (server/http/battle.js), loaded only when a clip needs it.++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', 'https://cdnjs.cloudflare.com'],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],-- connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://openvibe.media'],++ // A live.clip battle plays its two clips from OpenVibe.Media (signed links; HLS through blob: sources).++ mediaSrc: ["'self'", 'https://openvibe.media', 'blob:'],frameSrc: ["'self'", 'https://openvibe.network', require('./bench/render').RENDER_ORIGIN],frameAncestors: ["'none'"],objectSrc: ["'none'"],@@@ -16,7 +16,8 @@*/const crypto = require('crypto');--const { SHOWCASE_KIND_IDS } = require('../kinds');++const { SHOWCASE_KIND_IDS, isPrivateKind } = require('../kinds');++const clipLib = require('./clip');const num = (v, d = 0) => (v == null || Number.isNaN(Number(v)) ? d : Number(v));@@@ -120,11 +121,13 @@ function createBattles(ctx) {`INSERT INTO bench_battle_seen (id, subject, pair_key, round_id, kind_id, a_entry, b_entry, net_hash, at)VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz)`,[id, subject, key, row.id, row.kind_id, a.id, b.id, netHash, s.iso()]);++ // A private kind's pair (live.clip) carries the clips themselves: a voter watches them, while their links last.++ const clip = (e) => (isPrivateKind(row.kind_id) ? { payload: clipLib.forViewer(typeof e.payload === 'string' ? JSON.parse(e.payload) : e.payload, { now: s.now(), playback: true }) } : {});return {id, round_id: row.id, kind_id: row.kind_id, prompt: row.prompt,question: questionFor(row.kind_id),-- a: { id: a.id, provider: a.provider, model: a.model, cost_usd: num(a.cost_usd), ms: num(a.ms) },-- b: { id: b.id, provider: b.provider, model: b.model, cost_usd: num(b.cost_usd), ms: num(b.ms) },++ a: { id: a.id, provider: a.provider, model: a.model, cost_usd: num(a.cost_usd), ms: num(a.ms), ...clip(a) },++ b: { id: b.id, provider: b.provider, model: b.model, cost_usd: num(b.cost_usd), ms: num(b.ms), ...clip(b) },};}@@@ -12,7 +12,7 @@ const express = require('express');const { asyncRouter } = require('./router');const { sameOrigin } = require('./same-origin');const renderLib = require('../bench/render');--const { kindById } = require('../kinds');++const { kindById, isPrivateKind } = require('../kinds');const { esc, raw } = require('../render/html');const { send } = require('../render/layout');const { battleUseThis } = require('../views/usethis');@@@ -32,6 +32,21 @@ function paneOf(slot, letter) {+ '</figure>';}++/**++ * A live.clip pane: the clip itself, blind. No title (the vote is on the moment), no source, no policy. An abstention++ * says so; a clip whose link ran out says that instead of showing a broken player.++ */++function clipPaneOf(slot, letter) {++ const p = slot.payload || {};++ const body = p.abstained++ ? '<div class="bt-clip-none"><b>No clip here</b><span>This picker found nothing worth clipping in this stretch.</span></div>'++ : p.playback && p.playback.url++ ? '<video class="bt-clip" controls preload="metadata" playsinline src="' + esc(p.playback.url) + '"></video>'++ : '<div class="bt-clip-none"><b>This clip\'s link ran out</b><span>Vote on the other one, or come back for the next pair.</span></div>';++ return '<figure class="bt-pane bt-pane-clip" data-slot="' + esc(letter) + '" data-entry-id="' + esc(slot.id) + '">'++ + '<figcaption><span class="bt-pane-label">Clip ' + esc(letter) + '</span></figcaption>' + body + '</figure>';++}++function voteForm(battle) {const q = battle.question;return '<form class="bt-vote" method="post" action="/battle" data-battle-form="1">'@@@ -56,16 +71,38 @@ function battleView(battle) {return '<section class="bt-battle" data-battle="' + esc(battle.id) + '" data-kind="' + esc(battle.kind_id) + '">'+ '<h1 class="bt-prompt" data-prompt="1">' + esc(battle.prompt) + '</h1>'+ '<p class="bt-meta"><span class="br-chip">' + esc(kind ? kind.name : battle.kind_id) + '</span>'-- + '<span class="bm-fine">Blind: you see two outputs, not their names.</span></p>'-- + '<div class="bt-panes">' + paneOf(battle.a, 'A') + paneOf(battle.b, 'B') + '</div>'++ + '<span class="bm-fine">' + (isPrivateKind(battle.kind_id) ? 'Blind: two clips of the same stream. Which one would you rather share?' : 'Blind: you see two outputs, not their names.') + '</span></p>'++ + '<div class="bt-panes">' + (isPrivateKind(battle.kind_id) ? clipPaneOf(battle.a, 'A') + clipPaneOf(battle.b, 'B') : paneOf(battle.a, 'A') + paneOf(battle.b, 'B')) + '</div>'+ voteForm(battle)++ + (isPrivateKind(battle.kind_id) ? CLIP_PLAYER : '')+ '</section>';}++/**++ * A clip whose link is an HLS playlist (Media's virtual clips) does not play in a desktop browser's own player: on++ * that error, hls.js (pinned, from cdnjs) takes over that element. A cut clip (a file) plays as it is.++ */++const HLS_JS = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js';++const CLIP_PLAYER = '<script>(function(){var vs=[].slice.call(document.querySelectorAll("video.bt-clip"));var waiting=[];var loading=false;'++ + 'function attach(v){if(window.Hls&&window.Hls.isSupported()){var h=new window.Hls();h.loadSource(v.getAttribute("src"));h.attachMedia(v);}}'++ + 'function hls(v){if(window.Hls){attach(v);return}waiting.push(v);if(loading)return;loading=true;var s=document.createElement("script");s.src=' + JSON.stringify(HLS_JS) + ';'++ + 's.onload=function(){waiting.splice(0).forEach(attach)};document.head.appendChild(s)}'++ + 'vs.forEach(function(v){v.addEventListener("error",function(){if(v.dataset.hls)return;v.dataset.hls="1";hls(v)})})})();</script>';++function revealView(battle, { baseUrl = '' } = {}) {const kind = kindById(battle.kind_id);const vote = battle.vote || {};const winner = vote.outcome === 'left' ? 'A' : vote.outcome === 'right' ? 'B' : vote.outcome === 'tie' ? 'Tie' : 'Both bad';++ if (isPrivateKind(battle.kind_id)) {++ // A clip pair opens on which picker made each clip (selector@version), never the recording or its playback.++ const pol = (slot) => { const r = slot.route || {}; const p = (typeof r === 'string' ? JSON.parse(r) : r).policy || {}; return `${p.selector || '?'}@${p.version || '?'}`; };++ return '<section class="bt-battle bt-revealed" data-battle="' + esc(battle.id) + '" data-kind="' + esc(battle.kind_id) + '">'++ + '<h1 class="bt-prompt">Thank you: your vote counts</h1>'++ + '<p class="bt-meta"><span class="br-chip">' + esc(kind ? kind.name : battle.kind_id) + '</span><span class="br-chip">' + esc('Your vote: ' + winner) + '</span></p>'++ + '<dl class="bt-facts"><div><dt>Clip A</dt><dd>' + esc(pol(battle.a)) + '</dd></div><div><dt>Clip B</dt><dd>' + esc(pol(battle.b)) + '</dd></div></dl>'++ + '<p class="bm-actions"><a class="bm-btn" href="/battle?kind=live.clip">Next pair</a></p>'++ + '</section>';++ }const fact = (slot, letter) => '<dl class="bt-facts" data-slot="' + esc(letter) + '">'+ '<div><dt>Model</dt><dd>' + esc(slot.model) + '</dd></div>'+ '<div><dt>Provider</dt><dd>' + esc(slot.provider) + '</dd></div>'@@@ -176,7 +213,7 @@ function createBattlePages(ctx) {config, viewer: viewerOf(req), path: req.originalUrl,title: 'Battle: two outputs, one prompt',description: 'Pick the better output, blind. The names open after the vote.',-- headExtra: '<meta property="og:title" content="Vote: which is better?">'++ headExtra: isPrivateKind(battle.kind_id) ? '' : '<meta property="og:title" content="Vote: which is better?">'+ '<meta property="og:description" content="Two outputs, one prompt, blind. Vote on OpenVibe.Bench.">'+ '<meta property="og:image" content="' + esc(config.baseUrl + '/battle/card/' + battle.id + '.svg') + '">'+ '<meta property="og:image:type" content="image/svg+xml">'@@@ -187,7 +224,8 @@ function createBattlePages(ctx) {r.get('/card/:id.svg', async (req, res) => {const battle = await battles.battleOf(String(req.params.id || ''));-- if (!battle) return res.status(404).type('text/plain').send('No such card.');++ // A private kind's battle has no public card.++ if (!battle || isPrivateKind(battle.kind_id)) return res.status(404).type('text/plain').send('No such card.');res.set('Cache-Control', 'public, max-age=60');res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data: 'self'");return res.type('image/svg+xml').send(cardSvg(battle, config.baseUrl));@@@ -119,6 +119,31 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(claimed, null, 'the job runner never claims it');});++ await check('the battle page: two clips, blind, playable; no share card; the reveal names the pickers only', async () => {++ const fresh = await newRound();++ for (const v of ['v3', 'v4']) {++ const e = await t.get(`/api/v1/rounds/${fresh.id}/entries`, { bearer: live, json: { payload: clip(v), cost_usd: 0.001 } });++ assert.strictEqual(e.status, 201, e.text);++ }++ const viewer = t.network.addUser('battler');++ // The battle picks among waiting rounds at random; the newest (this one) comes first in its list.++ const was = t.ctx.random;++ t.ctx.random = () => 0;++ const page = await t.get('/battle?kind=live.clip', { as: viewer });++ t.ctx.random = was;++ assert.strictEqual(page.status, 200, page.text);++ const videos = page.text.match(/<video class="bt-clip"[^>]*src="https:\/\/media\.openvibe\.media\/p\/v[34]\.mp4/g) || [];++ assert.strictEqual(videos.length, 2, 'both clips play');++ assert.ok(!page.text.includes('The cat walks onto the keyboard'), 'no title: the vote is on the moment');++ assert.ok(!page.text.includes('moment-picker'), 'blind: no policy before the vote');++ assert.ok(!page.text.includes('/battle/card/'), 'no share card for a private battle');++ assert.ok(page.text.includes('hls.min.js'), 'HLS clips can play');++ const battleId = /data-battle="([^"]+)"/.exec(page.text)[1];++ assert.strictEqual((await t.get(`/battle/card/${battleId}.svg`)).status, 404);++ const csp = page.headers.get('content-security-policy') || '';++ assert.match(csp, /media-src[^;]*https:\/\/openvibe\.media/);++ });++await t.close();done();})().catch((e) => { console.error(e); process.exit(1); });
@@ -310,6 +310,10 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -310,6 +310,10 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.bt-panes { display: grid; gap: 12px; grid-template-columns: 1fr; margin-top: 16px; }
.bt-panes { display: grid; gap: 12px; grid-template-columns: 1fr; margin-top: 16px; }
.bt-pane { margin: 0; display: grid; gap: 6px; min-width: 0; }
.bt-pane { margin: 0; display: grid; gap: 6px; min-width: 0; }
.bt-pane figcaption { font-size: .76rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.bt-pane figcaption { font-size: .76rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+/* A live.clip battle: the two clips, 16:9, and what shows when a picker abstained or a link ran out. */
+.bt-clip { width: 100%; max-width: 100%; aspect-ratio: 16 / 9; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: #000; display: block; }
+.bt-clip-none { aspect-ratio: 16 / 9; max-width: 100%; display: grid; place-content: center; gap: 6px; text-align: center; padding: 16px; border: 1px dashed var(--border, #1f2d47); border-radius: 12px; color: var(--text-muted, #7386a3); }
+.bt-clip-none b { color: var(--text-primary, #e6edf7); }
.bt-pane-frame { width: 100%; max-width: 100%; aspect-ratio: 4 / 3; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-primary, #0a0f1c); display: block; }
.bt-pane-frame { width: 100%; max-width: 100%; aspect-ratio: 4 / 3; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-primary, #0a0f1c); display: block; }
.bt-vote { margin-top: 18px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); display: grid; gap: 12px; }
.bt-vote { margin-top: 18px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); display: grid; gap: 12px; }
.bt-vote fieldset { margin: 0; padding: 0; border: 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.bt-vote fieldset { margin: 0; padding: 0; border: 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
@@ -132,11 +132,14 @@ async function createApp(opts = {}) {
@@ -132,11 +132,14 @@ async function createApp(opts = {}) {
contentSecurityPolicy: {
contentSecurityPolicy: {
directives: {
directives: {
defaultSrc: ["'self'"],
defaultSrc: ["'self'"],
- scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
+ // cdnjs: hls.js for a live.clip battle's HLS clips (server/http/battle.js), loaded only when a clip needs it.
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
- connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://openvibe.media'],
+ // A live.clip battle plays its two clips from OpenVibe.Media (signed links; HLS through blob: sources).
+ mediaSrc: ["'self'", 'https://openvibe.media', 'blob:'],
frameSrc: ["'self'", 'https://openvibe.network', require('./bench/render').RENDER_ORIGIN],
frameSrc: ["'self'", 'https://openvibe.network', require('./bench/render').RENDER_ORIGIN],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
@@ -16,7 +16,8 @@
@@ -16,7 +16,8 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const { SHOWCASE_KIND_IDS } = require('../kinds');
+const { SHOWCASE_KIND_IDS, isPrivateKind } = require('../kinds');
+const clipLib = require('./clip');
const num = (v, d = 0) => (v == null || Number.isNaN(Number(v)) ? d : Number(v));
const num = (v, d = 0) => (v == null || Number.isNaN(Number(v)) ? d : Number(v));
@@ -120,11 +121,13 @@ function createBattles(ctx) {
@@ -120,11 +121,13 @@ function createBattles(ctx) {
`INSERT INTO bench_battle_seen (id, subject, pair_key, round_id, kind_id, a_entry, b_entry, net_hash, at)
`INSERT INTO bench_battle_seen (id, subject, pair_key, round_id, kind_id, a_entry, b_entry, net_hash, at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz)`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz)`,
[id, subject, key, row.id, row.kind_id, a.id, b.id, netHash, s.iso()]);
[id, subject, key, row.id, row.kind_id, a.id, b.id, netHash, s.iso()]);
+ // A private kind's pair (live.clip) carries the clips themselves: a voter watches them, while their links last.
+ const clip = (e) => (isPrivateKind(row.kind_id) ? { payload: clipLib.forViewer(typeof e.payload === 'string' ? JSON.parse(e.payload) : e.payload, { now: s.now(), playback: true }) } : {});
return {
return {
id, round_id: row.id, kind_id: row.kind_id, prompt: row.prompt,
id, round_id: row.id, kind_id: row.kind_id, prompt: row.prompt,
question: questionFor(row.kind_id),
question: questionFor(row.kind_id),
- a: { id: a.id, provider: a.provider, model: a.model, cost_usd: num(a.cost_usd), ms: num(a.ms) },
- b: { id: b.id, provider: b.provider, model: b.model, cost_usd: num(b.cost_usd), ms: num(b.ms) },
+ a: { id: a.id, provider: a.provider, model: a.model, cost_usd: num(a.cost_usd), ms: num(a.ms), ...clip(a) },
+ b: { id: b.id, provider: b.provider, model: b.model, cost_usd: num(b.cost_usd), ms: num(b.ms), ...clip(b) },
};
};
}
}
@@ -12,7 +12,7 @@ const express = require('express');
@@ -12,7 +12,7 @@ const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./same-origin');
const { sameOrigin } = require('./same-origin');
const renderLib = require('../bench/render');
const renderLib = require('../bench/render');
-const { kindById } = require('../kinds');
+const { kindById, isPrivateKind } = require('../kinds');
const { esc, raw } = require('../render/html');
const { esc, raw } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { battleUseThis } = require('../views/usethis');
const { battleUseThis } = require('../views/usethis');
@@ -32,6 +32,21 @@ function paneOf(slot, letter) {
@@ -32,6 +32,21 @@ function paneOf(slot, letter) {
+ '</figure>';
+ '</figure>';
}
}
+/**
+ * A live.clip pane: the clip itself, blind. No title (the vote is on the moment), no source, no policy. An abstention
+ * says so; a clip whose link ran out says that instead of showing a broken player.
+ */
+function clipPaneOf(slot, letter) {
+ const p = slot.payload || {};
+ const body = p.abstained
+ ? '<div class="bt-clip-none"><b>No clip here</b><span>This picker found nothing worth clipping in this stretch.</span></div>'
+ : p.playback && p.playback.url
+ ? '<video class="bt-clip" controls preload="metadata" playsinline src="' + esc(p.playback.url) + '"></video>'
+ : '<div class="bt-clip-none"><b>This clip\'s link ran out</b><span>Vote on the other one, or come back for the next pair.</span></div>';
+ return '<figure class="bt-pane bt-pane-clip" data-slot="' + esc(letter) + '" data-entry-id="' + esc(slot.id) + '">'
+ + '<figcaption><span class="bt-pane-label">Clip ' + esc(letter) + '</span></figcaption>' + body + '</figure>';
+}
+
function voteForm(battle) {
function voteForm(battle) {
const q = battle.question;
const q = battle.question;
return '<form class="bt-vote" method="post" action="/battle" data-battle-form="1">'
return '<form class="bt-vote" method="post" action="/battle" data-battle-form="1">'
@@ -56,16 +71,38 @@ function battleView(battle) {
@@ -56,16 +71,38 @@ function battleView(battle) {
return '<section class="bt-battle" data-battle="' + esc(battle.id) + '" data-kind="' + esc(battle.kind_id) + '">'
return '<section class="bt-battle" data-battle="' + esc(battle.id) + '" data-kind="' + esc(battle.kind_id) + '">'
+ '<h1 class="bt-prompt" data-prompt="1">' + esc(battle.prompt) + '</h1>'
+ '<h1 class="bt-prompt" data-prompt="1">' + esc(battle.prompt) + '</h1>'
+ '<p class="bt-meta"><span class="br-chip">' + esc(kind ? kind.name : battle.kind_id) + '</span>'
+ '<p class="bt-meta"><span class="br-chip">' + esc(kind ? kind.name : battle.kind_id) + '</span>'
- + '<span class="bm-fine">Blind: you see two outputs, not their names.</span></p>'
- + '<div class="bt-panes">' + paneOf(battle.a, 'A') + paneOf(battle.b, 'B') + '</div>'
+ + '<span class="bm-fine">' + (isPrivateKind(battle.kind_id) ? 'Blind: two clips of the same stream. Which one would you rather share?' : 'Blind: you see two outputs, not their names.') + '</span></p>'
+ + '<div class="bt-panes">' + (isPrivateKind(battle.kind_id) ? clipPaneOf(battle.a, 'A') + clipPaneOf(battle.b, 'B') : paneOf(battle.a, 'A') + paneOf(battle.b, 'B')) + '</div>'
+ voteForm(battle)
+ voteForm(battle)
+ + (isPrivateKind(battle.kind_id) ? CLIP_PLAYER : '')
+ '</section>';
+ '</section>';
}
}
+/**
+ * A clip whose link is an HLS playlist (Media's virtual clips) does not play in a desktop browser's own player: on
+ * that error, hls.js (pinned, from cdnjs) takes over that element. A cut clip (a file) plays as it is.
+ */
+const HLS_JS = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js';
+const CLIP_PLAYER = '<script>(function(){var vs=[].slice.call(document.querySelectorAll("video.bt-clip"));var waiting=[];var loading=false;'
+ + 'function attach(v){if(window.Hls&&window.Hls.isSupported()){var h=new window.Hls();h.loadSource(v.getAttribute("src"));h.attachMedia(v);}}'
+ + 'function hls(v){if(window.Hls){attach(v);return}waiting.push(v);if(loading)return;loading=true;var s=document.createElement("script");s.src=' + JSON.stringify(HLS_JS) + ';'
+ + 's.onload=function(){waiting.splice(0).forEach(attach)};document.head.appendChild(s)}'
+ + 'vs.forEach(function(v){v.addEventListener("error",function(){if(v.dataset.hls)return;v.dataset.hls="1";hls(v)})})})();</script>';
+
function revealView(battle, { baseUrl = '' } = {}) {
function revealView(battle, { baseUrl = '' } = {}) {
const kind = kindById(battle.kind_id);
const kind = kindById(battle.kind_id);
const vote = battle.vote || {};
const vote = battle.vote || {};
const winner = vote.outcome === 'left' ? 'A' : vote.outcome === 'right' ? 'B' : vote.outcome === 'tie' ? 'Tie' : 'Both bad';
const winner = vote.outcome === 'left' ? 'A' : vote.outcome === 'right' ? 'B' : vote.outcome === 'tie' ? 'Tie' : 'Both bad';
+ if (isPrivateKind(battle.kind_id)) {
+ // A clip pair opens on which picker made each clip (selector@version), never the recording or its playback.
+ const pol = (slot) => { const r = slot.route || {}; const p = (typeof r === 'string' ? JSON.parse(r) : r).policy || {}; return `${p.selector || '?'}@${p.version || '?'}`; };
+ return '<section class="bt-battle bt-revealed" data-battle="' + esc(battle.id) + '" data-kind="' + esc(battle.kind_id) + '">'
+ + '<h1 class="bt-prompt">Thank you: your vote counts</h1>'
+ + '<p class="bt-meta"><span class="br-chip">' + esc(kind ? kind.name : battle.kind_id) + '</span><span class="br-chip">' + esc('Your vote: ' + winner) + '</span></p>'
+ + '<dl class="bt-facts"><div><dt>Clip A</dt><dd>' + esc(pol(battle.a)) + '</dd></div><div><dt>Clip B</dt><dd>' + esc(pol(battle.b)) + '</dd></div></dl>'
+ + '<p class="bm-actions"><a class="bm-btn" href="/battle?kind=live.clip">Next pair</a></p>'
+ + '</section>';
+ }
const fact = (slot, letter) => '<dl class="bt-facts" data-slot="' + esc(letter) + '">'
const fact = (slot, letter) => '<dl class="bt-facts" data-slot="' + esc(letter) + '">'
+ '<div><dt>Model</dt><dd>' + esc(slot.model) + '</dd></div>'
+ '<div><dt>Model</dt><dd>' + esc(slot.model) + '</dd></div>'
+ '<div><dt>Provider</dt><dd>' + esc(slot.provider) + '</dd></div>'
+ '<div><dt>Provider</dt><dd>' + esc(slot.provider) + '</dd></div>'
@@ -176,7 +213,7 @@ function createBattlePages(ctx) {
@@ -176,7 +213,7 @@ function createBattlePages(ctx) {
config, viewer: viewerOf(req), path: req.originalUrl,
config, viewer: viewerOf(req), path: req.originalUrl,
title: 'Battle: two outputs, one prompt',
title: 'Battle: two outputs, one prompt',
description: 'Pick the better output, blind. The names open after the vote.',
description: 'Pick the better output, blind. The names open after the vote.',
- headExtra: '<meta property="og:title" content="Vote: which is better?">'
+ headExtra: isPrivateKind(battle.kind_id) ? '' : '<meta property="og:title" content="Vote: which is better?">'
+ '<meta property="og:description" content="Two outputs, one prompt, blind. Vote on OpenVibe.Bench.">'
+ '<meta property="og:description" content="Two outputs, one prompt, blind. Vote on OpenVibe.Bench.">'
+ '<meta property="og:image" content="' + esc(config.baseUrl + '/battle/card/' + battle.id + '.svg') + '">'
+ '<meta property="og:image" content="' + esc(config.baseUrl + '/battle/card/' + battle.id + '.svg') + '">'
+ '<meta property="og:image:type" content="image/svg+xml">'
+ '<meta property="og:image:type" content="image/svg+xml">'
@@ -187,7 +224,8 @@ function createBattlePages(ctx) {
@@ -187,7 +224,8 @@ function createBattlePages(ctx) {
r.get('/card/:id.svg', async (req, res) => {
r.get('/card/:id.svg', async (req, res) => {
const battle = await battles.battleOf(String(req.params.id || ''));
const battle = await battles.battleOf(String(req.params.id || ''));
- if (!battle) return res.status(404).type('text/plain').send('No such card.');
+ // A private kind's battle has no public card.
+ if (!battle || isPrivateKind(battle.kind_id)) return res.status(404).type('text/plain').send('No such card.');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data: 'self'");
res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data: 'self'");
return res.type('image/svg+xml').send(cardSvg(battle, config.baseUrl));
return res.type('image/svg+xml').send(cardSvg(battle, config.baseUrl));
@@ -119,6 +119,31 @@ const { boot, check, done } = require('./helpers/boot');
@@ -119,6 +119,31 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(claimed, null, 'the job runner never claims it');
assert.strictEqual(claimed, null, 'the job runner never claims it');
});
});
+ await check('the battle page: two clips, blind, playable; no share card; the reveal names the pickers only', async () => {
+ const fresh = await newRound();
+ for (const v of ['v3', 'v4']) {
+ const e = await t.get(`/api/v1/rounds/${fresh.id}/entries`, { bearer: live, json: { payload: clip(v), cost_usd: 0.001 } });
+ assert.strictEqual(e.status, 201, e.text);
+ }
+ const viewer = t.network.addUser('battler');
+ // The battle picks among waiting rounds at random; the newest (this one) comes first in its list.
+ const was = t.ctx.random;
+ t.ctx.random = () => 0;
+ const page = await t.get('/battle?kind=live.clip', { as: viewer });
+ t.ctx.random = was;
+ assert.strictEqual(page.status, 200, page.text);
+ const videos = page.text.match(/<video class="bt-clip"[^>]*src="https:\/\/media\.openvibe\.media\/p\/v[34]\.mp4/g) || [];
+ assert.strictEqual(videos.length, 2, 'both clips play');
+ assert.ok(!page.text.includes('The cat walks onto the keyboard'), 'no title: the vote is on the moment');
+ assert.ok(!page.text.includes('moment-picker'), 'blind: no policy before the vote');
+ assert.ok(!page.text.includes('/battle/card/'), 'no share card for a private battle');
+ assert.ok(page.text.includes('hls.min.js'), 'HLS clips can play');
+ const battleId = /data-battle="([^"]+)"/.exec(page.text)[1];
+ assert.strictEqual((await t.get(`/battle/card/${battleId}.svg`)).status, 404);
+ const csp = page.headers.get('content-security-policy') || '';
+ assert.match(csp, /media-src[^;]*https:\/\/openvibe\.media/);
+ });
+
await t.close();
await t.close();
done();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });