openvibers/openvibe.media Commits
A new thumbnail's name carries its colour (L52: colour before the picture) (#290)
Changes
@@@ -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,nnew file mode 100644@@@ -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 { /* */ } });
@@ -103,6 +103,43 @@ function hasVideoStream(videoPath) {
@@ -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.
* Extract a thumbnail frame from a video file using ffmpeg.
* @param {string} videoPath – Absolute path (or presigned URL) of the video
* @param {string} videoPath – Absolute path (or presigned URL) of the video
@@ -188,7 +225,7 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
@@ -188,7 +225,7 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
ff.on('close', (code) => {
ff.on('close', (code) => {
clearTimeout(killTimer);
clearTimeout(killTimer);
if (code === 0 && fs.existsSync(outPath)) {
if (code === 0 && fs.existsSync(outPath)) {
- resolve(`/t/${outFilename}`);
+ nameWithColour(outPath, outFilename).then((name) => resolve(`/t/${name}`), () => resolve(`/t/${outFilename}`));
} else {
} else {
resolve(null);
resolve(null);
}
}
@@ -449,6 +486,9 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
@@ -449,6 +486,9 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
module.exports = {
module.exports = {
generateFromVideo,
generateFromVideo,
+ colourOf,
+ colourFromName,
+ nameWithColour,
hasVideoStream,
hasVideoStream,
generateVodThumbnail,
generateVodThumbnail,
generateClipThumbnail,
generateClipThumbnail,
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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 { /* */ } });