openvibers/openvibe.design Commits

The mark's intro, as files people can download (#52)

OP OpenVibers committed 13 days ago · efd282f1b7 · 15 files, +372 −2

* The mark's intro, as files people can download

The animation the owner liked — the O drawing itself round, the V dropping in on a
spring, the glint and the glow — is on /brand now: it plays in a <video>, and every
file is offered with its size. A WebM and an MP4 on the brand ground, a transparent
GIF and APNG, and the rested mark as a still, for the mark and for the Live variant
with its record light in the O. The embed snippet for the live drop-in is beside them.

scripts/render-mark-motion.js makes them by playing the real ov-mark.js in headless
Chrome and taking its intro frame by frame, so what people download is what the
navbar runs. The outputs are committed; CI never runs the script.

/brand/motion/* is served with the right content type and a year's cache, like the
site's other assets, and openvibe-shared moves to v4.65.0 for the mark's depth, its
glint and its glow.

* /brand's motion section, redesigned: a card per variant with its intro playing and its formats as chips

Changes

ddiff --git a/public/brand/motion/ov-mark-intro.gif b/public/brand/motion/ov-mark-intro.gif
nnew file mode 100644
iindex 0000000..5cae207
BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.gif differ
ddiff --git a/public/brand/motion/ov-mark-intro.mp4 b/public/brand/motion/ov-mark-intro.mp4
nnew file mode 100644
iindex 0000000..19c1bee
BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.mp4 differ
ddiff --git a/public/brand/motion/ov-mark-intro.png b/public/brand/motion/ov-mark-intro.png
nnew file mode 100644
iindex 0000000..aecbf00
BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.png differ
ddiff --git a/public/brand/motion/ov-mark-intro.webm b/public/brand/motion/ov-mark-intro.webm
nnew file mode 100644
iindex 0000000..a9edff8
BBinary files /dev/null and b/public/brand/motion/ov-mark-intro.webm differ
ddiff --git a/public/brand/motion/ov-mark-live-intro.gif b/public/brand/motion/ov-mark-live-intro.gif
nnew file mode 100644
iindex 0000000..4b62cc7
BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.gif differ
ddiff --git a/public/brand/motion/ov-mark-live-intro.mp4 b/public/brand/motion/ov-mark-live-intro.mp4
nnew file mode 100644
iindex 0000000..5d394fb
BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.mp4 differ
ddiff --git a/public/brand/motion/ov-mark-live-intro.png b/public/brand/motion/ov-mark-live-intro.png
nnew file mode 100644
iindex 0000000..4e87b5f
BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.png differ
ddiff --git a/public/brand/motion/ov-mark-live-intro.webm b/public/brand/motion/ov-mark-live-intro.webm
nnew file mode 100644
iindex 0000000..744c67b
BBinary files /dev/null and b/public/brand/motion/ov-mark-live-intro.webm differ
ddiff --git a/public/brand/motion/ov-mark-live-poster.png b/public/brand/motion/ov-mark-live-poster.png
nnew file mode 100644
iindex 0000000..20dcd7f
BBinary files /dev/null and b/public/brand/motion/ov-mark-live-poster.png differ
ddiff --git a/public/brand/motion/ov-mark-poster.png b/public/brand/motion/ov-mark-poster.png
nnew file mode 100644
iindex 0000000..b980f34
BBinary files /dev/null and b/public/brand/motion/ov-mark-poster.png differ
ddiff --git a/public/css/brand.css b/public/css/brand.css
iindex 907d2d8..08949ed 100644
---- a/public/css/brand.css
++++ b/public/css/brand.css
@@@ -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}
ddiff --git a/scripts/render-mark-motion.js b/scripts/render-mark-motion.js
nnew file mode 100644
iindex 0000000..b9e5bc6
---- /dev/null
++++ b/scripts/render-mark-motion.js
@@@ -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); });
ddiff --git a/server/app.js b/server/app.js
iindex 6d5baaa..ce58762 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/views/brand.js b/server/views/brand.js
iindex f99cee3..4b74422 100644
---- a/server/views/brand.js
++++ b/server/views/brand.js
@@@ -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 colour
data-wide the box hugs the mark
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');
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&rsquo;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)} &middot; ${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 };
ddiff --git a/test/brand-motion.test.js b/test/brand-motion.test.js
nnew file mode 100644
iindex 0000000..f5ac70b
---- /dev/null
++++ b/test/brand-motion.test.js
@@@ -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=&quot;64&quot;'), '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); });