openvibers/openvibe.design Commits
The mark's intro, as files people can download (#52)
Changes
nnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.gif differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.mp4 differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.png differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.webm differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.gif differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.mp4 differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.png differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.webm differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-live-poster.png differnnew file mode 100644BBinary files /dev/null and b/public/brand/motion/ov-mark-poster.png differ@@@ -43,6 +43,23 @@.br-mini{display:grid;place-items:center}.br-play-side .br-states:has(button:not([hidden]))~.br-meanings{margin-top:14px}++/* The motion: the intro on the brand ground, and the files to download. */++/* The motion as files (#motion): a card per variant, its intro playing on the brand ground, and its formats as chips. */++.br-mo{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:16px}++.br-mo-card{margin:0;display:grid;grid-template-rows:auto 1fr;border-radius:24px;border:1px solid var(--border);background:var(--bg-secondary);overflow:hidden;min-width:0}++.br-mo-stage{display:grid;place-items:center;padding:clamp(12px,2vw,22px);background:#0a0f1c} /* the videos are drawn on #0a0f1c: the stage is the same ground, so no box shows */++.br-motion-video{display:block;width:min(100%,320px);height:auto;aspect-ratio:1;border-radius:14px}++.br-mo-card figcaption{display:grid;align-content:start;gap:6px;padding:16px 18px 18px}++.br-mo-card figcaption>b{font-family:var(--br-face);font-weight:900;font-size:18px}++.br-mo-card figcaption>small{color:var(--text-secondary);font-size:13.5px;line-height:1.5}++.br-mo-fmts{list-style:none;margin:8px 0 0;padding:0;display:flex;flex-wrap:wrap;gap:8px}++.br-mo-fmt{display:grid;gap:1px;min-height:44px;align-content:center;padding:6px 12px;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);text-decoration:none}++.br-mo-fmt b{font-size:13.5px}++.br-mo-fmt span{color:var(--text-muted);font-size:11.5px;white-space:nowrap}++.br-mo-fmt:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}++.br-mo-fmt:focus-visible{outline:2px solid var(--accent);outline-offset:2px}++.br-mo-use{margin-top:18px;max-width:640px}++/* Sizes, side by side on a baseline. */.br-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:center;gap:clamp(16px,4vw,44px);padding:26px clamp(16px,3vw,28px);border-radius:22px;background:var(--bg-secondary);border:1px solid var(--border)}.br-sizes figure{margin:0;display:grid;justify-items:center;gap:8px}nnew file mode 100644@@@ -0,0 +1,237 @@++#!/usr/bin/env node++'use strict';++/**++ * scripts/render-mark-motion.js — the OpenVibe mark's intro as the files people download from /brand.++ *++ * The animation is not drawn here: it is the real openvibe-shared/ov-mark.js (the copy this site pins and serves++ * at /shared/ov-mark.js), played in headless Chrome. We open the mark, ask ov-mark.js for its intro++ * (OVMark.intro), and then drive that intro's own CSS animations frame by frame, so every frame is the mark as it++ * really looks. ffmpeg turns the frames into the files below.++ *++ * ov-mark-intro.webm VP9, 512 × 512, on the brand ground (ffmpeg on this host cannot put an alpha channel++ * into a WebM, so the transparent ground is in the GIF and the APNG)++ * ov-mark-intro.mp4 H.264, 1080 × 1080, on the brand dark ground #0a0f1c++ * ov-mark-intro.gif 256 × 256, transparent++ * ov-mark-intro.png APNG, 256 × 256++ * ov-mark-live-intro.* the same four for the Live variant (data-variant="live": its record light in the O)++ * ov-mark-poster.png the rested mark with its glow, 1024 × 1024++ * ov-mark-live-poster.png the same, for the Live variant++ *++ * Run it by hand — the outputs are committed and CI never runs it:++ *++ * node scripts/render-mark-motion.js # everything++ * node scripts/render-mark-motion.js --frames 12 # a quick look (a short clip)++ * node scripts/render-mark-motion.js --only intro # one variant++ *++ * It needs the host's ffmpeg, Google Chrome (/usr/bin/google-chrome) and Playwright. The outputs land in++ * public/brand/motion/, which /brand serves.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const { execFileSync } = require('child_process');++++const ROOT = path.join(__dirname, '..');++const OUT_DIR = path.join(ROOT, 'public', 'brand', 'motion');++const MARK_JS = path.join(ROOT, 'node_modules', 'openvibe-shared', 'ov-mark.js');++const PLAYWRIGHT = process.env.OV_PLAYWRIGHT || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright';++const CHROME = process.env.OV_CHROME || '/usr/bin/google-chrome';++const FFMPEG = process.env.OV_FFMPEG || 'ffmpeg';++++const FPS = 60;++const SECONDS = 2.2;++const GROUND = '#0a0f1c';++const WEBM_SIZE = 512;++const SMALL_SIZE = 256;++const MP4_SIZE = 1080;++const POSTER_SIZE = 1024;++const GIF_FPS = 30;++++const VARIANTS = [++ { key: 'intro', file: 'ov-mark-intro', variant: '' },++ { key: 'live', file: 'ov-mark-live-intro', variant: 'live' },++];++++const args = process.argv.slice(2);++const opt = (name, fallback) => {++ const i = args.indexOf('--' + name);++ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;++};++const FRAMES = Math.max(2, parseInt(opt('frames', String(Math.round(FPS * SECONDS))), 10) || 132);++const ONLY = opt('only', '');++++const log = (...a) => console.log(...a);++const run = (cmd, argv) => execFileSync(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });++++/** The page the mark is played in: nothing but the mark, on a transparent ground. */++const harness = (size, variant) => `<!doctype html>++<html><head><meta charset="utf-8"><style>++html,body{margin:0;padding:0;width:${size}px;height:${size}px;overflow:hidden;background:transparent}++#stage{width:${size}px;height:${size}px;display:grid;place-items:center;background:transparent}++</style></head>++<body><div id="stage"><span class="ov-mark" data-size="${size}" data-glow${variant ? ' data-variant="' + variant + '"' : ''}></span></div>++<script>++/* The mark's own timers are muted: they only restart the intro, and we place the frames ourselves. */++(function () {++ var real = window.setTimeout.bind(window);++ window.setTimeout = function (fn, delay) {++ var args = Array.prototype.slice.call(arguments, 2);++ return (typeof delay === 'number' && delay > 1200) ? 0 : real.apply(null, [fn, delay].concat(args));++ };++})();++</script>++</body></html>`;++++/**++ * The page script: ov-mark.js's own intro, started for real, and then a seek that puts the intro's animations at++ * any point of its timeline. The mark's own timers are muted first (they only restart the intro), so the frames++ * are ours to place.++ */++const harnessScript = () => `(() => {++ const el = document.querySelector('.ov-mark');++ window.__el = el;++ window.OVMark.intro(el);++ // Put the intro's own animations at a point of its timeline. The mark's timers are muted in the page, and the++ // intro is asked for again before every frame, so nothing takes the attributes away mid-frame.++ window.__seek = async (t) => {++ window.OVMark.intro(el);++ el.setAttribute('data-ovm-intro', '');++ el.setAttribute('data-ovm-run', '');++ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));++ el.setAttribute('data-ovm-intro', '');++ el.setAttribute('data-ovm-run', '');++ const anims = el.getAnimations({ subtree: true });++ for (const a of anims) { try { a.pause(); a.currentTime = t; } catch (e) { /* not seekable */ } }++ return anims.length;++ };++})();`;++++async function capture(context, size, variant, frames) {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mark-motion-'));++ // A page of its own for every pass: ov-mark.js only mounts once per window.++ const page = await context.newPage();++ try {++ await page.setViewportSize({ width: size, height: size });++ await page.setContent(harness(size, variant), { waitUntil: 'load' });++ await page.addScriptTag({ content: fs.readFileSync(MARK_JS, 'utf8') });++ await page.waitForFunction(() => !!window.OVMark && !!window.OVMark.intro);++ await page.evaluate(harnessScript());++ // Let the mark settle (its own intro has run) before the frames start.++ await page.waitForTimeout(400);++ for (let i = 0; i < frames; i++) {++ const t = Math.round((i * 1000) / FPS);++ const anims = await page.evaluate((ms) => window.__seek(ms), t);++ if (i === 0 && !anims) throw new Error('the mark has no animations to play');++ const file = path.join(dir, String(i + 1).padStart(5, '0') + '.png');++ await page.screenshot({ path: file, omitBackground: true });++ }++ return { dir };++ } catch (err) {++ fs.rmSync(dir, { recursive: true, force: true });++ throw err;++ } finally {++ await page.close();++ }++}++++/** The rested mark, with its glow, on a transparent ground. */++async function poster(context, size, variant) {++ const page = await context.newPage();++ await page.setViewportSize({ width: size, height: size });++ await page.setContent(harness(size, variant), { waitUntil: 'load' });++ await page.addScriptTag({ content: fs.readFileSync(MARK_JS, 'utf8') });++ await page.waitForFunction(() => !!window.OVMark);++ await page.waitForTimeout(400);++ await page.evaluate(() => {++ const el = document.querySelector('.ov-mark');++ el.removeAttribute('data-ovm-intro');++ el.removeAttribute('data-ovm-run');++ });++ await page.waitForTimeout(120);++ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mark-poster-')), 'poster.png');++ await page.screenshot({ path: file, omitBackground: true });++ await page.close();++ return file;++}++++const ffmpeg = (argv) => {++ try {++ run(FFMPEG, argv);++ } catch (err) {++ const detail = (err.stderr && err.stderr.toString()) || err.message;++ throw new Error('ffmpeg failed: ' + detail.slice(-1200));++ }++};++++/** VP9 on the brand's own dark ground: the WebM every browser plays. */++const webm = (frames, out) => ffmpeg([++ '-y', '-hide_banner', '-loglevel', 'error',++ '-f', 'lavfi', '-i', `color=c=${GROUND.replace('#', '0x')}:s=${WEBM_SIZE}x${WEBM_SIZE}:r=${FPS}:d=${(FRAMES / FPS).toFixed(3)}`,++ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),++ '-filter_complex', `[0:v][1:v]overlay=format=auto:shortest=1,scale=${WEBM_SIZE}:${WEBM_SIZE}:flags=lanczos,format=yuv420p[out]`,++ '-map', '[out]', '-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', '30', '-deadline', 'good', '-cpu-used', '3',++ '-row-mt', '1', '-auto-alt-ref', '0',++ out,++]);++++/** H.264 on the brand's own dark ground, for anywhere that will not play WebM. */++const mp4 = (frames, out) => ffmpeg([++ '-y', '-hide_banner', '-loglevel', 'error',++ '-f', 'lavfi', '-i', `color=c=${GROUND.replace('#', '0x')}:s=${MP4_SIZE}x${MP4_SIZE}:r=${FPS}:d=${(FRAMES / FPS).toFixed(3)}`,++ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),++ '-filter_complex', `[0:v][1:v]overlay=format=auto:shortest=1,scale=${MP4_SIZE}:${MP4_SIZE}:flags=lanczos,format=yuv420p[out]`,++ '-map', '[out]', '-c:v', 'libx264', '-preset', 'slow', '-crf', '20', '-pix_fmt', 'yuv420p', '-movflags', '+faststart',++ out,++]);++++/** A GIF: its own palette, its own transparency, small enough to post anywhere. */++const gif = (frames, out) => ffmpeg([++ '-y', '-hide_banner', '-loglevel', 'error',++ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),++ '-vf', `fps=${GIF_FPS},scale=${SMALL_SIZE}:${SMALL_SIZE}:flags=lanczos,split[a][b];[a]palettegen=max_colors=192:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle`,++ '-loop', '0',++ out,++]);++++/** An APNG: the same clip, for everywhere a GIF is too few colours. */++const apng = (frames, out) => ffmpeg([++ '-y', '-hide_banner', '-loglevel', 'error',++ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),++ '-vf', `fps=${GIF_FPS},scale=${SMALL_SIZE}:${SMALL_SIZE}:flags=lanczos`,++ '-plays', '0', '-f', 'apng',++ out,++]);++++const kb = (bytes) => (bytes / 1024).toFixed(1) + ' KB';++++(async () => {++ if (!fs.existsSync(MARK_JS)) throw new Error('openvibe-shared/ov-mark.js is not installed: ' + MARK_JS);++ fs.mkdirSync(OUT_DIR, { recursive: true });++ const { chromium } = require(PLAYWRIGHT);++ const browser = await chromium.launch({ executablePath: CHROME });++ const context = await browser.newContext({ viewport: { width: WEBM_SIZE, height: WEBM_SIZE }, deviceScaleFactor: 1, reducedMotion: 'no-preference' });++ const written = [];++ try {++ for (const v of VARIANTS) {++ if (ONLY && ONLY !== v.key) continue;++ log('· the ' + v.key + ' mark');++ const small = await capture(context, WEBM_SIZE, v.variant, FRAMES);++ const large = await capture(context, POSTER_SIZE, v.variant, FRAMES);++ const files = [++ [path.join(OUT_DIR, v.file + '.webm'), () => webm(small.dir, path.join(OUT_DIR, v.file + '.webm'))],++ [path.join(OUT_DIR, v.file + '.mp4'), () => mp4(large.dir, path.join(OUT_DIR, v.file + '.mp4'))],++ [path.join(OUT_DIR, v.file + '.gif'), () => gif(small.dir, path.join(OUT_DIR, v.file + '.gif'))],++ [path.join(OUT_DIR, v.file + '.png'), () => apng(small.dir, path.join(OUT_DIR, v.file + '.png'))],++ ];++ for (const [out, make] of files) { make(); written.push(out); }++ const posterFile = path.join(OUT_DIR, v.key === 'live' ? 'ov-mark-live-poster.png' : 'ov-mark-poster.png');++ fs.copyFileSync(await poster(context, POSTER_SIZE, v.variant), posterFile);++ written.push(posterFile);++ fs.rmSync(small.dir, { recursive: true, force: true });++ fs.rmSync(large.dir, { recursive: true, force: true });++ }++ } finally {++ await browser.close();++ }++ for (const f of written) log(' ' + path.relative(ROOT, f).padEnd(38) + kb(fs.statSync(f).size));++})().catch((err) => { console.error(err); process.exit(1); });@@@ -163,6 +163,8 @@ async function createApp(opts = {}) {};app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false, setHeaders: setAssetHeaders }));app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));++ // The mark's intro, as the files /brand offers to download.++ app.use('/brand/motion', express.static(path.join(PUBLIC_DIR, 'brand', 'motion'), { index: false, redirect: false, setHeaders: setAssetHeaders }));// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));@@@ -11,10 +11,13 @@* brandPage({ base }) base: this site's origin, for the snippets* FILES the downloads served under /brand/: { name, form, tone, wide, title }*/++const fs = require('fs');++const path = require('path');const kit = require('openvibe-shared/kit');const mark = require('openvibe-shared/mark');const appIcons = require('openvibe-shared/app-icons');const { layout, esc } = require('../render/layout');++const { asset } = require('../assets');const FILES = Object.freeze([{ name: 'openvibe-mark.svg', label: 'The mark', note: 'blue and violet, square', form: 'pair', tone: 'brand', wide: false },@@@ -24,6 +27,34 @@ const FILES = Object.freeze([{ name: 'openvibe-mark-white.svg', label: 'White', note: 'for a colour or a photo', form: 'pair', tone: 'white', wide: false },]);++/**++ * The mark's intro as files: the animation people download, and the still that stands in for it. The sizes are read++ * from the files themselves, so the page never promises a size the file does not have.++ */++const MOTION_DIR = path.join(__dirname, '..', '..', 'public', 'brand', 'motion');++const MOTION = Object.freeze([++ { name: 'ov-mark-intro.webm', label: 'The intro', note: '512 px · VP9', art: 'ov-mark-poster.png' },++ { name: 'ov-mark-intro.mp4', label: 'The intro', note: '1080 px · H.264', art: 'ov-mark-poster.png' },++ { name: 'ov-mark-intro.gif', label: 'The intro', note: '256 px · GIF, transparent', art: 'ov-mark-poster.png' },++ { name: 'ov-mark-intro.png', label: 'The intro', note: '256 px · APNG, transparent', art: 'ov-mark-poster.png' },++ { name: 'ov-mark-live-intro.webm', label: 'The Live intro', note: '512 px · VP9', art: 'ov-mark-live-poster.png' },++ { name: 'ov-mark-live-intro.mp4', label: 'The Live intro', note: '1080 px · H.264', art: 'ov-mark-live-poster.png' },++ { name: 'ov-mark-live-intro.gif', label: 'The Live intro', note: '256 px · GIF, transparent', art: 'ov-mark-live-poster.png' },++ { name: 'ov-mark-live-intro.png', label: 'The Live intro', note: '256 px · APNG, transparent', art: 'ov-mark-live-poster.png' },++ { name: 'ov-mark-poster.png', label: 'The mark, still', note: '1024 px · PNG, transparent', art: 'ov-mark-poster.png' },++ { name: 'ov-mark-live-poster.png', label: 'The Live mark, still', note: '1024 px · PNG, transparent', art: 'ov-mark-live-poster.png' },++]);++/** The size of a motion file, as it is on disk (0 when it is not there yet). */++function motionSize(name) {++ try { return fs.statSync(path.join(MOTION_DIR, name)).size; } catch { return 0; }++}++/** A size a person can read: 84 KB, 1.2 MB. */++function humanSize(bytes) {++ if (!bytes) return '—';++ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';++ return Math.max(1, Math.round(bytes / 1024)) + ' KB';++}++let n = 0;/** A static mark, its mask ids unique on the page. */const art = (o = {}) => mark.svg({ id: `br${++n}`, title: 'OpenVibe', ...o });@@@ -113,6 +144,8 @@ function brandPage({ base = 'https://openvibe.design' } = {}) {data-tone ink or white: one colourdata-wide the box hugs the markOVMark.intro(el) plays the intro again -->`;++ const useLive = `<script src="${base}/shared/ov-mark.js"></script>++<span class="ov-mark" data-size="64"></span>`;const useNode = `const mark = require('openvibe-shared/mark');mark.svg({ size: 64 }); // 64 px square@@@ -150,7 +183,25 @@ mark.svg({ form: 'heavy' }); // 22 px and under`;</div></section>`;-- const body = `<article class="br">${hero}${moves}${sizes}${grounds}${names}${colours}${use}${rules}</article>`;++ // The motion as files, one card per variant: the intro playing, what it is, and each format as a chip with its size.++ const VARIANTS = [++ { id: '', label: 'The intro', say: 'The ring draws round, the V drops in on a spring, a glint crosses and the glow comes up. About two seconds, once.', stem: 'ov-mark' },++ { id: 'live', label: 'The Live intro', say: 'The same arrival, with Live’s record light in the O.', stem: 'ov-mark-live' },++ ];++ const FORMATS = [['intro.webm', 'WebM', '512 px'], ['intro.mp4', 'MP4', '1080 px'], ['intro.gif', 'GIF', '256 px, clear'], ['intro.png', 'APNG', '256 px, clear'], ['poster.png', 'Still', '1024 px PNG']];++ const chip = (file, fmt, dims) => `<li><a class="br-mo-fmt" href="${esc(asset('brand/motion/' + file))}" download="${esc(file)}"><b>${fmt}</b><span>${esc(dims)} · ${esc(humanSize(motionSize(file)))}</span></a></li>`;++ const card = (v) => `<figure class="br-mo-card">++ <div class="br-mo-stage"><video class="br-motion-video" src="${esc(asset(`brand/motion/${v.stem}-intro.webm`))}" poster="${esc(asset(`brand/motion/${v.stem}-poster.png`))}" width="512" height="512" muted loop playsinline autoplay preload="metadata" aria-label="${esc(v.label)}: the OpenVibe mark arriving"></video></div>++ <figcaption><b>${v.label}</b><small>${v.say}</small>++ <ul class="br-mo-fmts">${FORMATS.map(([end, fmt, dims]) => chip(`${v.stem}-${end}`, fmt, dims)).join('')}</ul></figcaption>++ </figure>`;++ const motion = `<section class="br-sec" id="motion" aria-labelledby="br-motion">++ <div class="br-sec-head"><h2 id="br-motion">The motion, as files</h2><p>For a video, a slide or a stream overlay. Each one is rendered from the same ov-mark.js the navbar runs, so what you download is how the mark really moves. On a web page, drop in the live mark instead: it moves, and it keeps still under reduced motion.</p></div>++ <div class="br-mo">${VARIANTS.map(card).join('')}</div>++ <div class="br-mo-use"><h3>The live mark, on any page</h3>${kit.snippet({ code: useLive, id: 'br-motion-live', label: 'Copy the HTML' })}</div>++</section>`;++++ const body = `<article class="br">${hero}${moves}${motion}${sizes}${grounds}${names}${colours}${use}${rules}</article>`;return layout({title: 'The OpenVibe mark',description: 'The OpenVibe mark: a ring O and a round V. What each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.',@@@ -168,4 +219,4 @@ function file(name) {return f ? mark.svg({ form: f.form, tone: f.tone, wide: f.wide, title: 'OpenVibe', id: 'ov' }) : null;}--module.exports = { brandPage, file, FILES, STATES };++module.exports = { brandPage, file, FILES, STATES, MOTION, motionSize, humanSize };nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++// The mark's intro as files: every one of them is there, /brand links every one of them with its size, and they are++// served with the right content type and a long life.++const assert = require('node:assert/strict');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++const { MOTION } = require('../server/views/brand');++const { assetVersion } = require('../server/assets');++++const DIR = path.join(__dirname, '..', 'public', 'brand', 'motion');++const TYPES = { '.webm': 'video/webm', '.mp4': 'video/mp4', '.gif': 'image/gif', '.png': 'image/png' };++const url = (name) => {++ const rel = 'brand/motion/' + name;++ return '/' + rel + '?v=' + assetVersion(rel);++};++++(async () => {++ const t = await boot();++ try {++ await check('every file is there, and is a real file', async () => {++ assert.equal(MOTION.length, 10, 'the ten motion files');++ for (const f of MOTION) {++ const p = path.join(DIR, f.name);++ assert.ok(fs.existsSync(p), f.name + ' is missing');++ const size = fs.statSync(p).size;++ assert.ok(size > 2048, f.name + ' is too small to be the mark (' + size + ' bytes)');++ if (f.name.endsWith('.gif')) assert.ok(size < 600 * 1024, f.name + ' is over 600 KB (' + size + ' bytes)');++ }++ });++++ await check('/brand shows the intro and links every file, with its size', async () => {++ const res = await t.get('/brand');++ assert.equal(res.status, 200);++ const html = res.text;++ for (const f of MOTION) {++ assert.ok(html.includes(`href="${url(f.name)}" download="${f.name}"`), f.name + ' is not linked');++ assert.ok(new RegExp('href="[^"]*' + f.name.replace('.', '\\.') + '[^"]*"[^>]*>.{0,400}?\\d+ (KB|MB)').test(html), f.name + ' has no size beside it');++ }++ assert.ok(html.includes('<video class="br-motion-video"'), 'the intro plays');++ assert.ok(html.includes('muted') && html.includes('loop') && html.includes('playsinline'), 'muted, looping, inline');++ assert.ok(html.includes(url('ov-mark-poster.png')), 'the poster');++ assert.ok(html.includes('/shared/ov-mark.js'), 'the drop-in snippet');++ assert.ok(html.includes('data-size="64"'), 'the drop-in shows the mark');++ });++++ await check('the files are served with the right type, and for a long time', async () => {++ for (const f of MOTION) {++ const res = await t.get(url(f.name));++ assert.equal(res.status, 200, f.name);++ assert.match(res.headers.get('content-type'), new RegExp('^' + TYPES[path.extname(f.name)]));++ const cc = res.headers.get('cache-control') || '';++ assert.ok(cc.includes('max-age=31536000') && cc.includes('immutable'), f.name + ' is not cached for long: ' + cc);++ assert.ok(res.buffer.length > 2048, f.name + ' came back empty');++ }++ const gone = await t.get('/brand/motion/ov-mark-intro.mov');++ assert.equal(gone.status, 404);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.gif differ
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.gif differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.mp4 differ
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.mp4 differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.png differ
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.webm differ
Binary files /dev/null and b/public/brand/motion/ov-mark-intro.webm differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.gif differ
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.gif differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.mp4 differ
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.mp4 differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.png differ
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.webm differ
Binary files /dev/null and b/public/brand/motion/ov-mark-live-intro.webm differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-live-poster.png differ
Binary files /dev/null and b/public/brand/motion/ov-mark-live-poster.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/brand/motion/ov-mark-poster.png differ
Binary files /dev/null and b/public/brand/motion/ov-mark-poster.png differ
@@ -43,6 +43,23 @@
@@ -43,6 +43,23 @@
.br-mini{display:grid;place-items:center}
.br-mini{display:grid;place-items:center}
.br-play-side .br-states:has(button:not([hidden]))~.br-meanings{margin-top:14px}
.br-play-side .br-states:has(button:not([hidden]))~.br-meanings{margin-top:14px}
+/* The motion: the intro on the brand ground, and the files to download. */
+/* The motion as files (#motion): a card per variant, its intro playing on the brand ground, and its formats as chips. */
+.br-mo{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:16px}
+.br-mo-card{margin:0;display:grid;grid-template-rows:auto 1fr;border-radius:24px;border:1px solid var(--border);background:var(--bg-secondary);overflow:hidden;min-width:0}
+.br-mo-stage{display:grid;place-items:center;padding:clamp(12px,2vw,22px);background:#0a0f1c} /* the videos are drawn on #0a0f1c: the stage is the same ground, so no box shows */
+.br-motion-video{display:block;width:min(100%,320px);height:auto;aspect-ratio:1;border-radius:14px}
+.br-mo-card figcaption{display:grid;align-content:start;gap:6px;padding:16px 18px 18px}
+.br-mo-card figcaption>b{font-family:var(--br-face);font-weight:900;font-size:18px}
+.br-mo-card figcaption>small{color:var(--text-secondary);font-size:13.5px;line-height:1.5}
+.br-mo-fmts{list-style:none;margin:8px 0 0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
+.br-mo-fmt{display:grid;gap:1px;min-height:44px;align-content:center;padding:6px 12px;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);text-decoration:none}
+.br-mo-fmt b{font-size:13.5px}
+.br-mo-fmt span{color:var(--text-muted);font-size:11.5px;white-space:nowrap}
+.br-mo-fmt:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
+.br-mo-fmt:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.br-mo-use{margin-top:18px;max-width:640px}
+
/* Sizes, side by side on a baseline. */
/* Sizes, side by side on a baseline. */
.br-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:center;gap:clamp(16px,4vw,44px);padding:26px clamp(16px,3vw,28px);border-radius:22px;background:var(--bg-secondary);border:1px solid var(--border)}
.br-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:center;gap:clamp(16px,4vw,44px);padding:26px clamp(16px,3vw,28px);border-radius:22px;background:var(--bg-secondary);border:1px solid var(--border)}
.br-sizes figure{margin:0;display:grid;justify-items:center;gap:8px}
.br-sizes figure{margin:0;display:grid;justify-items:center;gap:8px}
new file mode 100644
new file mode 100644
@@ -0,0 +1,237 @@
@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * scripts/render-mark-motion.js — the OpenVibe mark's intro as the files people download from /brand.
+ *
+ * The animation is not drawn here: it is the real openvibe-shared/ov-mark.js (the copy this site pins and serves
+ * at /shared/ov-mark.js), played in headless Chrome. We open the mark, ask ov-mark.js for its intro
+ * (OVMark.intro), and then drive that intro's own CSS animations frame by frame, so every frame is the mark as it
+ * really looks. ffmpeg turns the frames into the files below.
+ *
+ * ov-mark-intro.webm VP9, 512 × 512, on the brand ground (ffmpeg on this host cannot put an alpha channel
+ * into a WebM, so the transparent ground is in the GIF and the APNG)
+ * ov-mark-intro.mp4 H.264, 1080 × 1080, on the brand dark ground #0a0f1c
+ * ov-mark-intro.gif 256 × 256, transparent
+ * ov-mark-intro.png APNG, 256 × 256
+ * ov-mark-live-intro.* the same four for the Live variant (data-variant="live": its record light in the O)
+ * ov-mark-poster.png the rested mark with its glow, 1024 × 1024
+ * ov-mark-live-poster.png the same, for the Live variant
+ *
+ * Run it by hand — the outputs are committed and CI never runs it:
+ *
+ * node scripts/render-mark-motion.js # everything
+ * node scripts/render-mark-motion.js --frames 12 # a quick look (a short clip)
+ * node scripts/render-mark-motion.js --only intro # one variant
+ *
+ * It needs the host's ffmpeg, Google Chrome (/usr/bin/google-chrome) and Playwright. The outputs land in
+ * public/brand/motion/, which /brand serves.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const OUT_DIR = path.join(ROOT, 'public', 'brand', 'motion');
+const MARK_JS = path.join(ROOT, 'node_modules', 'openvibe-shared', 'ov-mark.js');
+const PLAYWRIGHT = process.env.OV_PLAYWRIGHT || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright';
+const CHROME = process.env.OV_CHROME || '/usr/bin/google-chrome';
+const FFMPEG = process.env.OV_FFMPEG || 'ffmpeg';
+
+const FPS = 60;
+const SECONDS = 2.2;
+const GROUND = '#0a0f1c';
+const WEBM_SIZE = 512;
+const SMALL_SIZE = 256;
+const MP4_SIZE = 1080;
+const POSTER_SIZE = 1024;
+const GIF_FPS = 30;
+
+const VARIANTS = [
+ { key: 'intro', file: 'ov-mark-intro', variant: '' },
+ { key: 'live', file: 'ov-mark-live-intro', variant: 'live' },
+];
+
+const args = process.argv.slice(2);
+const opt = (name, fallback) => {
+ const i = args.indexOf('--' + name);
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
+};
+const FRAMES = Math.max(2, parseInt(opt('frames', String(Math.round(FPS * SECONDS))), 10) || 132);
+const ONLY = opt('only', '');
+
+const log = (...a) => console.log(...a);
+const run = (cmd, argv) => execFileSync(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
+
+/** The page the mark is played in: nothing but the mark, on a transparent ground. */
+const harness = (size, variant) => `<!doctype html>
+<html><head><meta charset="utf-8"><style>
+html,body{margin:0;padding:0;width:${size}px;height:${size}px;overflow:hidden;background:transparent}
+#stage{width:${size}px;height:${size}px;display:grid;place-items:center;background:transparent}
+</style></head>
+<body><div id="stage"><span class="ov-mark" data-size="${size}" data-glow${variant ? ' data-variant="' + variant + '"' : ''}></span></div>
+<script>
+/* The mark's own timers are muted: they only restart the intro, and we place the frames ourselves. */
+(function () {
+ var real = window.setTimeout.bind(window);
+ window.setTimeout = function (fn, delay) {
+ var args = Array.prototype.slice.call(arguments, 2);
+ return (typeof delay === 'number' && delay > 1200) ? 0 : real.apply(null, [fn, delay].concat(args));
+ };
+})();
+</script>
+</body></html>`;
+
+/**
+ * The page script: ov-mark.js's own intro, started for real, and then a seek that puts the intro's animations at
+ * any point of its timeline. The mark's own timers are muted first (they only restart the intro), so the frames
+ * are ours to place.
+ */
+const harnessScript = () => `(() => {
+ const el = document.querySelector('.ov-mark');
+ window.__el = el;
+ window.OVMark.intro(el);
+ // Put the intro's own animations at a point of its timeline. The mark's timers are muted in the page, and the
+ // intro is asked for again before every frame, so nothing takes the attributes away mid-frame.
+ window.__seek = async (t) => {
+ window.OVMark.intro(el);
+ el.setAttribute('data-ovm-intro', '');
+ el.setAttribute('data-ovm-run', '');
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+ el.setAttribute('data-ovm-intro', '');
+ el.setAttribute('data-ovm-run', '');
+ const anims = el.getAnimations({ subtree: true });
+ for (const a of anims) { try { a.pause(); a.currentTime = t; } catch (e) { /* not seekable */ } }
+ return anims.length;
+ };
+})();`;
+
+async function capture(context, size, variant, frames) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mark-motion-'));
+ // A page of its own for every pass: ov-mark.js only mounts once per window.
+ const page = await context.newPage();
+ try {
+ await page.setViewportSize({ width: size, height: size });
+ await page.setContent(harness(size, variant), { waitUntil: 'load' });
+ await page.addScriptTag({ content: fs.readFileSync(MARK_JS, 'utf8') });
+ await page.waitForFunction(() => !!window.OVMark && !!window.OVMark.intro);
+ await page.evaluate(harnessScript());
+ // Let the mark settle (its own intro has run) before the frames start.
+ await page.waitForTimeout(400);
+ for (let i = 0; i < frames; i++) {
+ const t = Math.round((i * 1000) / FPS);
+ const anims = await page.evaluate((ms) => window.__seek(ms), t);
+ if (i === 0 && !anims) throw new Error('the mark has no animations to play');
+ const file = path.join(dir, String(i + 1).padStart(5, '0') + '.png');
+ await page.screenshot({ path: file, omitBackground: true });
+ }
+ return { dir };
+ } catch (err) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ throw err;
+ } finally {
+ await page.close();
+ }
+}
+
+/** The rested mark, with its glow, on a transparent ground. */
+async function poster(context, size, variant) {
+ const page = await context.newPage();
+ await page.setViewportSize({ width: size, height: size });
+ await page.setContent(harness(size, variant), { waitUntil: 'load' });
+ await page.addScriptTag({ content: fs.readFileSync(MARK_JS, 'utf8') });
+ await page.waitForFunction(() => !!window.OVMark);
+ await page.waitForTimeout(400);
+ await page.evaluate(() => {
+ const el = document.querySelector('.ov-mark');
+ el.removeAttribute('data-ovm-intro');
+ el.removeAttribute('data-ovm-run');
+ });
+ await page.waitForTimeout(120);
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mark-poster-')), 'poster.png');
+ await page.screenshot({ path: file, omitBackground: true });
+ await page.close();
+ return file;
+}
+
+const ffmpeg = (argv) => {
+ try {
+ run(FFMPEG, argv);
+ } catch (err) {
+ const detail = (err.stderr && err.stderr.toString()) || err.message;
+ throw new Error('ffmpeg failed: ' + detail.slice(-1200));
+ }
+};
+
+/** VP9 on the brand's own dark ground: the WebM every browser plays. */
+const webm = (frames, out) => ffmpeg([
+ '-y', '-hide_banner', '-loglevel', 'error',
+ '-f', 'lavfi', '-i', `color=c=${GROUND.replace('#', '0x')}:s=${WEBM_SIZE}x${WEBM_SIZE}:r=${FPS}:d=${(FRAMES / FPS).toFixed(3)}`,
+ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),
+ '-filter_complex', `[0:v][1:v]overlay=format=auto:shortest=1,scale=${WEBM_SIZE}:${WEBM_SIZE}:flags=lanczos,format=yuv420p[out]`,
+ '-map', '[out]', '-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', '30', '-deadline', 'good', '-cpu-used', '3',
+ '-row-mt', '1', '-auto-alt-ref', '0',
+ out,
+]);
+
+/** H.264 on the brand's own dark ground, for anywhere that will not play WebM. */
+const mp4 = (frames, out) => ffmpeg([
+ '-y', '-hide_banner', '-loglevel', 'error',
+ '-f', 'lavfi', '-i', `color=c=${GROUND.replace('#', '0x')}:s=${MP4_SIZE}x${MP4_SIZE}:r=${FPS}:d=${(FRAMES / FPS).toFixed(3)}`,
+ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),
+ '-filter_complex', `[0:v][1:v]overlay=format=auto:shortest=1,scale=${MP4_SIZE}:${MP4_SIZE}:flags=lanczos,format=yuv420p[out]`,
+ '-map', '[out]', '-c:v', 'libx264', '-preset', 'slow', '-crf', '20', '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
+ out,
+]);
+
+/** A GIF: its own palette, its own transparency, small enough to post anywhere. */
+const gif = (frames, out) => ffmpeg([
+ '-y', '-hide_banner', '-loglevel', 'error',
+ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),
+ '-vf', `fps=${GIF_FPS},scale=${SMALL_SIZE}:${SMALL_SIZE}:flags=lanczos,split[a][b];[a]palettegen=max_colors=192:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle`,
+ '-loop', '0',
+ out,
+]);
+
+/** An APNG: the same clip, for everywhere a GIF is too few colours. */
+const apng = (frames, out) => ffmpeg([
+ '-y', '-hide_banner', '-loglevel', 'error',
+ '-framerate', String(FPS), '-i', path.join(frames, '%05d.png'),
+ '-vf', `fps=${GIF_FPS},scale=${SMALL_SIZE}:${SMALL_SIZE}:flags=lanczos`,
+ '-plays', '0', '-f', 'apng',
+ out,
+]);
+
+const kb = (bytes) => (bytes / 1024).toFixed(1) + ' KB';
+
+(async () => {
+ if (!fs.existsSync(MARK_JS)) throw new Error('openvibe-shared/ov-mark.js is not installed: ' + MARK_JS);
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ const { chromium } = require(PLAYWRIGHT);
+ const browser = await chromium.launch({ executablePath: CHROME });
+ const context = await browser.newContext({ viewport: { width: WEBM_SIZE, height: WEBM_SIZE }, deviceScaleFactor: 1, reducedMotion: 'no-preference' });
+ const written = [];
+ try {
+ for (const v of VARIANTS) {
+ if (ONLY && ONLY !== v.key) continue;
+ log('· the ' + v.key + ' mark');
+ const small = await capture(context, WEBM_SIZE, v.variant, FRAMES);
+ const large = await capture(context, POSTER_SIZE, v.variant, FRAMES);
+ const files = [
+ [path.join(OUT_DIR, v.file + '.webm'), () => webm(small.dir, path.join(OUT_DIR, v.file + '.webm'))],
+ [path.join(OUT_DIR, v.file + '.mp4'), () => mp4(large.dir, path.join(OUT_DIR, v.file + '.mp4'))],
+ [path.join(OUT_DIR, v.file + '.gif'), () => gif(small.dir, path.join(OUT_DIR, v.file + '.gif'))],
+ [path.join(OUT_DIR, v.file + '.png'), () => apng(small.dir, path.join(OUT_DIR, v.file + '.png'))],
+ ];
+ for (const [out, make] of files) { make(); written.push(out); }
+ const posterFile = path.join(OUT_DIR, v.key === 'live' ? 'ov-mark-live-poster.png' : 'ov-mark-poster.png');
+ fs.copyFileSync(await poster(context, POSTER_SIZE, v.variant), posterFile);
+ written.push(posterFile);
+ fs.rmSync(small.dir, { recursive: true, force: true });
+ fs.rmSync(large.dir, { recursive: true, force: true });
+ }
+ } finally {
+ await browser.close();
+ }
+ for (const f of written) log(' ' + path.relative(ROOT, f).padEnd(38) + kb(fs.statSync(f).size));
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -163,6 +163,8 @@ async function createApp(opts = {}) {
@@ -163,6 +163,8 @@ async function createApp(opts = {}) {
};
};
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
+ // The mark's intro, as the files /brand offers to download.
+ app.use('/brand/motion', express.static(path.join(PUBLIC_DIR, 'brand', 'motion'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
@@ -11,10 +11,13 @@
@@ -11,10 +11,13 @@
* brandPage({ base }) base: this site's origin, for the snippets
* brandPage({ base }) base: this site's origin, for the snippets
* FILES the downloads served under /brand/: { name, form, tone, wide, title }
* FILES the downloads served under /brand/: { name, form, tone, wide, title }
*/
*/
+const fs = require('fs');
+const path = require('path');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const mark = require('openvibe-shared/mark');
const mark = require('openvibe-shared/mark');
const appIcons = require('openvibe-shared/app-icons');
const appIcons = require('openvibe-shared/app-icons');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
+const { asset } = require('../assets');
const FILES = Object.freeze([
const FILES = Object.freeze([
{ name: 'openvibe-mark.svg', label: 'The mark', note: 'blue and violet, square', form: 'pair', tone: 'brand', wide: false },
{ name: 'openvibe-mark.svg', label: 'The mark', note: 'blue and violet, square', form: 'pair', tone: 'brand', wide: false },
@@ -24,6 +27,34 @@ const FILES = Object.freeze([
@@ -24,6 +27,34 @@ const FILES = Object.freeze([
{ name: 'openvibe-mark-white.svg', label: 'White', note: 'for a colour or a photo', form: 'pair', tone: 'white', wide: false },
{ name: 'openvibe-mark-white.svg', label: 'White', note: 'for a colour or a photo', form: 'pair', tone: 'white', wide: false },
]);
]);
+/**
+ * The mark's intro as files: the animation people download, and the still that stands in for it. The sizes are read
+ * from the files themselves, so the page never promises a size the file does not have.
+ */
+const MOTION_DIR = path.join(__dirname, '..', '..', 'public', 'brand', 'motion');
+const MOTION = Object.freeze([
+ { name: 'ov-mark-intro.webm', label: 'The intro', note: '512 px · VP9', art: 'ov-mark-poster.png' },
+ { name: 'ov-mark-intro.mp4', label: 'The intro', note: '1080 px · H.264', art: 'ov-mark-poster.png' },
+ { name: 'ov-mark-intro.gif', label: 'The intro', note: '256 px · GIF, transparent', art: 'ov-mark-poster.png' },
+ { name: 'ov-mark-intro.png', label: 'The intro', note: '256 px · APNG, transparent', art: 'ov-mark-poster.png' },
+ { name: 'ov-mark-live-intro.webm', label: 'The Live intro', note: '512 px · VP9', art: 'ov-mark-live-poster.png' },
+ { name: 'ov-mark-live-intro.mp4', label: 'The Live intro', note: '1080 px · H.264', art: 'ov-mark-live-poster.png' },
+ { name: 'ov-mark-live-intro.gif', label: 'The Live intro', note: '256 px · GIF, transparent', art: 'ov-mark-live-poster.png' },
+ { name: 'ov-mark-live-intro.png', label: 'The Live intro', note: '256 px · APNG, transparent', art: 'ov-mark-live-poster.png' },
+ { name: 'ov-mark-poster.png', label: 'The mark, still', note: '1024 px · PNG, transparent', art: 'ov-mark-poster.png' },
+ { name: 'ov-mark-live-poster.png', label: 'The Live mark, still', note: '1024 px · PNG, transparent', art: 'ov-mark-live-poster.png' },
+]);
+/** The size of a motion file, as it is on disk (0 when it is not there yet). */
+function motionSize(name) {
+ try { return fs.statSync(path.join(MOTION_DIR, name)).size; } catch { return 0; }
+}
+/** A size a person can read: 84 KB, 1.2 MB. */
+function humanSize(bytes) {
+ if (!bytes) return '—';
+ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ return Math.max(1, Math.round(bytes / 1024)) + ' KB';
+}
+
let n = 0;
let n = 0;
/** A static mark, its mask ids unique on the page. */
/** A static mark, its mask ids unique on the page. */
const art = (o = {}) => mark.svg({ id: `br${++n}`, title: 'OpenVibe', ...o });
const art = (o = {}) => mark.svg({ id: `br${++n}`, title: 'OpenVibe', ...o });
@@ -113,6 +144,8 @@ function brandPage({ base = 'https://openvibe.design' } = {}) {
@@ -113,6 +144,8 @@ function brandPage({ base = 'https://openvibe.design' } = {}) {
data-tone ink or white: one colour
data-tone ink or white: one colour
data-wide the box hugs the mark
data-wide the box hugs the mark
OVMark.intro(el) plays the intro again -->`;
OVMark.intro(el) plays the intro again -->`;
+ const useLive = `<script src="${base}/shared/ov-mark.js"></script>
+<span class="ov-mark" data-size="64"></span>`;
const useNode = `const mark = require('openvibe-shared/mark');
const useNode = `const mark = require('openvibe-shared/mark');
mark.svg({ size: 64 }); // 64 px square
mark.svg({ size: 64 }); // 64 px square
@@ -150,7 +183,25 @@ mark.svg({ form: 'heavy' }); // 22 px and under`;
@@ -150,7 +183,25 @@ mark.svg({ form: 'heavy' }); // 22 px and under`;
</div>
</div>
</section>`;
</section>`;
- const body = `<article class="br">${hero}${moves}${sizes}${grounds}${names}${colours}${use}${rules}</article>`;
+ // The motion as files, one card per variant: the intro playing, what it is, and each format as a chip with its size.
+ const VARIANTS = [
+ { id: '', label: 'The intro', say: 'The ring draws round, the V drops in on a spring, a glint crosses and the glow comes up. About two seconds, once.', stem: 'ov-mark' },
+ { id: 'live', label: 'The Live intro', say: 'The same arrival, with Live’s record light in the O.', stem: 'ov-mark-live' },
+ ];
+ const FORMATS = [['intro.webm', 'WebM', '512 px'], ['intro.mp4', 'MP4', '1080 px'], ['intro.gif', 'GIF', '256 px, clear'], ['intro.png', 'APNG', '256 px, clear'], ['poster.png', 'Still', '1024 px PNG']];
+ const chip = (file, fmt, dims) => `<li><a class="br-mo-fmt" href="${esc(asset('brand/motion/' + file))}" download="${esc(file)}"><b>${fmt}</b><span>${esc(dims)} · ${esc(humanSize(motionSize(file)))}</span></a></li>`;
+ const card = (v) => `<figure class="br-mo-card">
+ <div class="br-mo-stage"><video class="br-motion-video" src="${esc(asset(`brand/motion/${v.stem}-intro.webm`))}" poster="${esc(asset(`brand/motion/${v.stem}-poster.png`))}" width="512" height="512" muted loop playsinline autoplay preload="metadata" aria-label="${esc(v.label)}: the OpenVibe mark arriving"></video></div>
+ <figcaption><b>${v.label}</b><small>${v.say}</small>
+ <ul class="br-mo-fmts">${FORMATS.map(([end, fmt, dims]) => chip(`${v.stem}-${end}`, fmt, dims)).join('')}</ul></figcaption>
+ </figure>`;
+ const motion = `<section class="br-sec" id="motion" aria-labelledby="br-motion">
+ <div class="br-sec-head"><h2 id="br-motion">The motion, as files</h2><p>For a video, a slide or a stream overlay. Each one is rendered from the same ov-mark.js the navbar runs, so what you download is how the mark really moves. On a web page, drop in the live mark instead: it moves, and it keeps still under reduced motion.</p></div>
+ <div class="br-mo">${VARIANTS.map(card).join('')}</div>
+ <div class="br-mo-use"><h3>The live mark, on any page</h3>${kit.snippet({ code: useLive, id: 'br-motion-live', label: 'Copy the HTML' })}</div>
+</section>`;
+
+ const body = `<article class="br">${hero}${moves}${motion}${sizes}${grounds}${names}${colours}${use}${rules}</article>`;
return layout({
return layout({
title: 'The OpenVibe mark',
title: 'The OpenVibe mark',
description: 'The OpenVibe mark: a ring O and a round V. What each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.',
description: 'The OpenVibe mark: a ring O and a round V. What each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.',
@@ -168,4 +219,4 @@ function file(name) {
@@ -168,4 +219,4 @@ function file(name) {
return f ? mark.svg({ form: f.form, tone: f.tone, wide: f.wide, title: 'OpenVibe', id: 'ov' }) : null;
return f ? mark.svg({ form: f.form, tone: f.tone, wide: f.wide, title: 'OpenVibe', id: 'ov' }) : null;
}
}
-module.exports = { brandPage, file, FILES, STATES };
+module.exports = { brandPage, file, FILES, STATES, MOTION, motionSize, humanSize };
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+// The mark's intro as files: every one of them is there, /brand links every one of them with its size, and they are
+// served with the right content type and a long life.
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+const { MOTION } = require('../server/views/brand');
+const { assetVersion } = require('../server/assets');
+
+const DIR = path.join(__dirname, '..', 'public', 'brand', 'motion');
+const TYPES = { '.webm': 'video/webm', '.mp4': 'video/mp4', '.gif': 'image/gif', '.png': 'image/png' };
+const url = (name) => {
+ const rel = 'brand/motion/' + name;
+ return '/' + rel + '?v=' + assetVersion(rel);
+};
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('every file is there, and is a real file', async () => {
+ assert.equal(MOTION.length, 10, 'the ten motion files');
+ for (const f of MOTION) {
+ const p = path.join(DIR, f.name);
+ assert.ok(fs.existsSync(p), f.name + ' is missing');
+ const size = fs.statSync(p).size;
+ assert.ok(size > 2048, f.name + ' is too small to be the mark (' + size + ' bytes)');
+ if (f.name.endsWith('.gif')) assert.ok(size < 600 * 1024, f.name + ' is over 600 KB (' + size + ' bytes)');
+ }
+ });
+
+ await check('/brand shows the intro and links every file, with its size', async () => {
+ const res = await t.get('/brand');
+ assert.equal(res.status, 200);
+ const html = res.text;
+ for (const f of MOTION) {
+ assert.ok(html.includes(`href="${url(f.name)}" download="${f.name}"`), f.name + ' is not linked');
+ assert.ok(new RegExp('href="[^"]*' + f.name.replace('.', '\\.') + '[^"]*"[^>]*>.{0,400}?\\d+ (KB|MB)').test(html), f.name + ' has no size beside it');
+ }
+ assert.ok(html.includes('<video class="br-motion-video"'), 'the intro plays');
+ assert.ok(html.includes('muted') && html.includes('loop') && html.includes('playsinline'), 'muted, looping, inline');
+ assert.ok(html.includes(url('ov-mark-poster.png')), 'the poster');
+ assert.ok(html.includes('/shared/ov-mark.js'), 'the drop-in snippet');
+ assert.ok(html.includes('data-size="64"'), 'the drop-in shows the mark');
+ });
+
+ await check('the files are served with the right type, and for a long time', async () => {
+ for (const f of MOTION) {
+ const res = await t.get(url(f.name));
+ assert.equal(res.status, 200, f.name);
+ assert.match(res.headers.get('content-type'), new RegExp('^' + TYPES[path.extname(f.name)]));
+ const cc = res.headers.get('cache-control') || '';
+ assert.ok(cc.includes('max-age=31536000') && cc.includes('immutable'), f.name + ' is not cached for long: ' + cc);
+ assert.ok(res.buffer.length > 2048, f.name + ' came back empty');
+ }
+ const gone = await t.get('/brand/motion/ov-mark-intro.mov');
+ assert.equal(gone.status, 404);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });