openvibers/openvibe.bench Commits

The clip battle: two clips of one stream, blind (item 151, C20) (#106)

OP OpenVibers committed 10 days ago · 2b25889d31 · 5 files, +83 −10

* The clip battle: two clips of one stream, blind, at /battle?kind=live.clip (item 151, C20)

A live.clip pair plays its two clips side by side (hls.js from cdnjs only when a clip is an HLS playlist), with no
title or policy before the vote; the reveal names the pickers only; a private battle has no share card. CSP adds
openvibe.media to media-src and connect-src.

* live-clip test: the battle check asks for the newest round (the pick is random among waiting rounds)

Changes

ddiff --git a/public/css/bench.css b/public/css/bench.css
iindex 0f8aa53..be402b5 100644
---- a/public/css/bench.css
++++ b/public/css/bench.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex cdb3332..c07f4ae 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'"],
ddiff --git a/server/bench/battles.js b/server/bench/battles.js
iindex 5c13996..6681d83 100644
---- a/server/bench/battles.js
++++ b/server/bench/battles.js
@@@ -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) },
};
}
ddiff --git a/server/http/battle.js b/server/http/battle.js
iindex cbab71a..6a03bd2 100644
---- a/server/http/battle.js
++++ b/server/http/battle.js
@@@ -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));
ddiff --git a/test/live-clip.test.js b/test/live-clip.test.js
iindex 4e2f232..991235b 100644
---- a/test/live-clip.test.js
++++ b/test/live-clip.test.js
@@@ -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); });