openvibers/openvibe.media Commits

A new thumbnail's name carries its colour (L52: colour before the picture) (#290)

OP OpenVibers committed 11 days ago · 314fe2863f · 2 files, +81 −1

* A new thumbnail's name carries its colour, so a page can paint it before the picture arrives

Plan L52 (the owner, 11-01: "a fake thumbnail… so it is not jarring"). After ffmpeg writes a VOD's or clip's frame, a
one-pixel pass averages it and the file is named <prefix>-<id>-<time>-<rrggbb>.jpg. Every page that shows the
thumbnail already has its URL, so it can read the colour from the name and paint it behind the image: no field to
carry, nothing to migrate, and older names simply have no colour. colourFromName reads it back (a URL with a query
too). Every name check stays prefix-based, so nothing else changes.

* colourFromName reads a colour only after the time, so a stream's stable name never reads its id as one

Changes

ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
iindex 737e989..af11db0 100644
---- a/server/thumbnails/thumbnail-service.js
++++ b/server/thumbnails/thumbnail-service.js
@@@ -103,6 +103,43 @@ function hasVideoStream(videoPath) {
});
}
++/**
++ * A picture's average colour, as six hex digits ("1a2b3c"), or null. ffmpeg scales the JPEG to one pixel (area
++ * averaging) and prints its RGB bytes. Used to name a new thumbnail <prefix>-<id>-<time>-<colour>.jpg, so every page
++ * that shows it can paint that colour behind the image before it arrives (plan L52: "a fake thumbnail… so it is not
++ * jarring"), with no field to carry and nothing to migrate.
++ */
++function colourOf(file) {
++ return new Promise((resolve) => {
++ let out = Buffer.alloc(0);
++ let ff;
++ try { ff = spawn('ffmpeg', ['-v', 'error', '-i', file, '-vf', 'scale=1:1:flags=area', '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { stdio: ['ignore', 'pipe', 'ignore'] }); } catch { return resolve(null); }
++ const timer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* gone */ } }, 5000);
++ ff.stdout.on('data', (d) => { out = Buffer.concat([out, d]); });
++ ff.on('error', () => { clearTimeout(timer); resolve(null); });
++ ff.on('close', (code) => {
++ clearTimeout(timer);
++ if (code !== 0 || out.length < 3) return resolve(null);
++ resolve([out[0], out[1], out[2]].map((v) => v.toString(16).padStart(2, '0')).join(''));
++ });
++ });
++}
++
++/** The colour a thumbnail's name carries ("…-1a2b3c.jpg" → "#1a2b3c"), or null. */
++const COLOUR_NAME_RE = /-\d{10,}-([0-9a-f]{6})\.(?:jpe?g|webp|avif|png)(?:[?#].*)?$/i;
++function colourFromName(name) {
++ const m = COLOUR_NAME_RE.exec(String(name || ''));
++ return m ? `#${m[1].toLowerCase()}` : null;
++}
++
++/** Rename a just-written thumbnail to carry its colour; the name it ends up with (the same one when that fails). */
++async function nameWithColour(outPath, outFilename) {
++ const hex = await colourOf(outPath);
++ if (!hex) return outFilename;
++ const named = outFilename.replace(/\.jpg$/, `-${hex}.jpg`);
++ try { fs.renameSync(outPath, path.join(THUMB_DIR, named)); return named; } catch { return outFilename; }
++}
++
/**
* Extract a thumbnail frame from a video file using ffmpeg.
* @param {string} videoPath – Absolute path (or presigned URL) of the video
@@@ -188,7 +225,7 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
ff.on('close', (code) => {
clearTimeout(killTimer);
if (code === 0 && fs.existsSync(outPath)) {
-- resolve(`/t/${outFilename}`);
++ nameWithColour(outPath, outFilename).then((name) => resolve(`/t/${name}`), () => resolve(`/t/${outFilename}`));
} else {
resolve(null);
}
@@@ -449,6 +486,9 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
module.exports = {
generateFromVideo,
++ colourOf,
++ colourFromName,
++ nameWithColour,
hasVideoStream,
generateVodThumbnail,
generateClipThumbnail,
ddiff --git a/test/thumbnail-colour.test.js b/test/thumbnail-colour.test.js
nnew file mode 100644
iindex 0000000..9c2ef19
---- /dev/null
++++ b/test/thumbnail-colour.test.js
@@@ -0,0 +1,40 @@
++'use strict';
++/**
++ * A new thumbnail's name carries its average colour (<prefix>-<id>-<time>-<rrggbb>.jpg), so every page that shows it can
++ * paint that colour behind the picture before it arrives (plan L52, 2026-11-02). Made from a solid red video, the name
++ * says red; colourFromName reads it back from a name or a URL with a query; a name without one reads null.
++ *
++ * node test/thumbnail-colour.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFileSync } = require('child_process');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ovm-thumbcolour-'));
++process.env.THUMBNAILS_PATH = dir;
++
++(async () => {
++ const thumbs = require('../server/thumbnails/thumbnail-service');
++ assert.strictEqual(thumbs.colourFromName('vod-3-1730000000000-1a2b3c.jpg'), '#1a2b3c');
++ assert.strictEqual(thumbs.colourFromName('https://openvibe.media/t/clip-9-1730000000000-ABCDEF.jpg?w=640'), '#abcdef');
++ assert.strictEqual(thumbs.colourFromName('vod-3-1730000000000.jpg'), null, 'an older name has no colour');
++ assert.strictEqual(thumbs.colourFromName('stream-live-12.jpg'), null);
++ assert.strictEqual(thumbs.colourFromName('stream-live-100234.jpg'), null, 'a stream\'s stable name: its id is not a colour');
++
++ let ffmpeg = true;
++ try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); } catch { ffmpeg = false; }
++ if (!ffmpeg) { console.log('thumbnail colour: names read back (no ffmpeg here, so no picture made)'); return; }
++
++ const video = path.join(dir, 'red.mp4');
++ execFileSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'color=c=red:s=320x180:d=3', '-pix_fmt', 'yuv420p', video]);
++ const url = await thumbs.generateFromVideo(video, 'vod', 424242, { seekSeconds: 1 });
++ assert.match(url, /^\/t\/vod-424242-\d+-[0-9a-f]{6}\.jpg$/, url);
++ const hex = thumbs.colourFromName(url).slice(1);
++ const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
++ assert.ok(r > 200 && g < 60 && b < 60, `red reads red: #${hex}`);
++ assert.ok(fs.existsSync(path.join(thumbs.THUMB_DIR, path.basename(url))), 'the file is at its new name');
++ assert.strictEqual(await thumbs.colourOf(path.join(dir, 'missing.jpg')), null);
++ console.log(`thumbnail colour: a red frame is named …-${hex}.jpg; names read back`);
++})().catch((err) => { console.error(err); process.exit(1); }).finally(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ } });