Media cache: still-picture lane re-encodes album-art videos as one looped frame in H.264

This commit is contained in:
Jonathan Sykes
2026-10-16 18:18:42 +08:00
parent a2ab53ea8a
commit fcbc9344ba
2 changed files with 122 additions and 24 deletions

View File

@@ -31,8 +31,9 @@ export const LOW = 1; // auto-cache after a play
// Compression-lane revision. `optimized` stores the revision a copy was last // Compression-lane revision. `optimized` stores the revision a copy was last
// evaluated with; bumping this re-queues every cached copy once (rev 1 was // evaluated with; bumping this re-queues every cached copy once (rev 1 was
// x264, which never beat YouTube's own encode; rev 2 is HEVC). // x264, which never beat YouTube's own encode; rev 2 is HEVC; rev 3 adds the
export const OPT_REV = 2; // still-picture lane, so rev-2 copies are re-checked for it once).
export const OPT_REV = 3;
const ID_RE = /^[A-Za-z0-9_-]{6,64}$/; const ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
export const isMediaId = (id) => typeof id === 'string' && ID_RE.test(id); export const isMediaId = (id) => typeof id === 'string' && ID_RE.test(id);
@@ -45,6 +46,13 @@ const EST_BYTES_PER_SEC = 250 * 1024; // ~2 Mbps: pre-compression 720p + 128k
const MIN_SAVING = 0.15; // keep a re-encode only if ≥15% smaller const MIN_SAVING = 0.15; // keep a re-encode only if ≥15% smaller
const SAMPLE_SECONDS = 10; // per sample window (two windows) const SAMPLE_SECONDS = 10; // per sample window (two windows)
const SAMPLE_MIN_DURATION = 60; // shorter videos are cheap enough to encode whole const SAMPLE_MIN_DURATION = 60; // shorter videos are cheap enough to encode whole
// Still-picture lane: album-art uploads ("Provided to YouTube", fan uploads)
// show one image for the whole song. Measured: largest keyframe-to-keyframe
// scene score 0.003 on a Topic track vs 0.99 on a lyric video, so 0.01 is a
// safe cut. One frame looped at 1 fps costs ~16 kbps instead of ~335-1750.
const STILL_MAX_SCENE = 0.01;
const STILL_MIN_KEYFRAMES = 3;
const STILL_MIN_DURATION = 30;
// x265 tuning from the codec benchmark: stronger adaptive quantisation and // x265 tuning from the codec benchmark: stronger adaptive quantisation and
// psy settings, no SAO blur, lighter deblock, longer lookahead. // psy settings, no SAO blur, lighter deblock, longer lookahead.
const X265_TUNING = 'aq-mode=3:aq-strength=0.8:psy-rd=1.5:psy-rdoq=1.0:no-sao=1:deblock=-1,-1:' const X265_TUNING = 'aq-mode=3:aq-strength=0.8:psy-rd=1.5:psy-rdoq=1.0:no-sao=1:deblock=-1,-1:'
@@ -420,6 +428,10 @@ export function createMediaCache({
}; };
// Very long videos would tie the lane up for hours on 2 niced threads. // Very long videos would tie the lane up for hours on 2 niced threads.
if (transcode.maxSeconds && row.duration > transcode.maxSeconds) { await markDone(); return; } if (transcode.maxSeconds && row.duration > transcode.maxSeconds) { await markDone(); return; }
const still = row.duration >= STILL_MIN_DURATION && await isStill(src).catch(() => false);
if (still) return optimizeStill(id, row, src, markDone);
// Rev-2 copies already went through the HEVC lane; only the still check was new.
if ((row.optimized || 0) >= 2) { await markDone(); return; }
const hevc = transcode.codec !== 'h264'; const hevc = transcode.codec !== 'h264';
const prefix = `${id}-${now()}-${hevc ? 'x265' : 'x264'}`; const prefix = `${id}-${now()}-${hevc ? 'x265' : 'x264'}`;
const out = join(tmpDir, `${prefix}.mp4`); const out = join(tmpDir, `${prefix}.mp4`);
@@ -455,27 +467,7 @@ export function createMediaCache({
if (r.code !== 0) throw new Error(`${hevc ? 'x265' : 'x264'} encode failed: ` + tail(r.stderr)); if (r.code !== 0) throw new Error(`${hevc ? 'x265' : 'x264'} encode failed: ` + tail(r.stderr));
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: [hevc ? 'hevc' : 'h264'] }); const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: [hevc ? 'hevc' : 'h264'] });
const cur = await db.getMedia(id); await commitOptimized(id, row, src, out, probe, probe.vcodec);
if (!cur || cur.status !== 'ready' || cur.gen !== row.gen) return; // replaced or evicted meanwhile
const oldSize = sizeOf(src);
if (probe.size > oldSize * (1 - MIN_SAVING)) {
await db.upsertMedia(id, { optimized: OPT_REV });
log.info?.(`[media] kept original ${id}: ${probe.vcodec} ${fmtMB(probe.size)} vs ${fmtMB(oldSize)}`);
return;
}
const sha256 = await hashFile(out);
const gen = row.gen + 1;
renameSync(out, fileFor(id, gen));
const oldAudio = fileFor(id, row.gen, 'm4a');
const newAudio = fileFor(id, gen, 'm4a');
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); }
await db.upsertMedia(id, {
gen, size: probe.size + sizeOf(newAudio), height: probe.height, vcodec: probe.vcodec,
duration: probe.duration, optimized: OPT_REV, updated_at: now(), sha256,
});
notifyReady(id, gen, sha256, probe, cur.meta);
removeFiles(id, gen, OLD_GEN_GRACE_MS);
log.info?.(`[media] optimized ${id}: ${fmtMB(oldSize)} → ${fmtMB(probe.size)} (${probe.vcodec})`);
} catch (err) { } catch (err) {
await markDone().catch(() => {}); // don't retry a source the encoder can't improve await markDone().catch(() => {}); // don't retry a source the encoder can't improve
throw err; throw err;
@@ -487,6 +479,69 @@ export function createMediaCache({
// Encode two short windows (at 25% and 60% of the video) and compare them // Encode two short windows (at 25% and 60% of the video) and compare them
// with the source's own video bytes over exactly the same timestamps. // with the source's own video bytes over exactly the same timestamps.
// Returns encoded/source (below 1 = smaller). // Returns encoded/source (below 1 = smaller).
async function commitOptimized(id, row, src, out, probe, label) {
const cur = await db.getMedia(id);
if (!cur || cur.status !== 'ready' || cur.gen !== row.gen) return; // replaced or evicted meanwhile
const oldSize = sizeOf(src);
if (probe.size > oldSize * (1 - MIN_SAVING)) {
await db.upsertMedia(id, { optimized: OPT_REV });
log.info?.(`[media] kept original ${id}: ${label} ${fmtMB(probe.size)} vs ${fmtMB(oldSize)}`);
return;
}
const sha256 = await hashFile(out);
const gen = row.gen + 1;
renameSync(out, fileFor(id, gen));
const oldAudio = fileFor(id, row.gen, 'm4a');
const newAudio = fileFor(id, gen, 'm4a');
try { linkSync(oldAudio, newAudio); } catch { copyFileSync(oldAudio, newAudio); }
await db.upsertMedia(id, {
gen, size: probe.size + sizeOf(newAudio), height: probe.height, vcodec: probe.vcodec,
duration: probe.duration, optimized: OPT_REV, updated_at: now(), sha256,
});
notifyReady(id, gen, sha256, probe, cur.meta);
removeFiles(id, gen, OLD_GEN_GRACE_MS);
log.info?.(`[media] optimized ${id} (${label}): ${fmtMB(oldSize)} → ${fmtMB(probe.size)} (${probe.vcodec})`);
}
// Keyframes only: ~1 s for a 5-minute video. Every keyframe-to-keyframe
// scene score must stay under STILL_MAX_SCENE.
async function isStill(src) {
const r = await run(ffmpeg, ['-hide_banner', '-nostdin', '-skip_frame', 'nokey', '-i', src, '-an',
'-vf', "scale=160:-2,select='gte(scene\\,0)',metadata=print:key=lavfi.scene_score", '-f', 'null', '-']);
if (r.code !== 0) return false;
const scores = [...String(r.stderr).matchAll(/scene_score=([\d.]+)/g)].map((m) => Number(m[1]));
return scores.length + 1 >= STILL_MIN_KEYFRAMES && scores.every((x) => x <= STILL_MAX_SCENE);
}
// One frame from the middle, looped at 1 fps, audio copied. H.264 so every
// client plays it from the cache (HEVC is only served to clients that ask).
async function optimizeStill(id, row, src, markDone) {
const prefix = `${id}-${now()}-still`;
const frame = join(tmpDir, `${prefix}.png`);
const out = join(tmpDir, `${prefix}.mp4`);
const threads = String(transcode.threads);
const niced = (args) => (nice ? run(nice, ['-n', '19', ffmpeg, ...args]) : run(ffmpeg, args));
try {
const at = String(Math.min(30, Math.floor(row.duration / 2)));
const f = await niced(['-v', 'error', '-nostdin', '-y', '-ss', at, '-i', src, '-frames:v', '1',
'-vf', 'scale=-2:min(720\\,ih)', frame]);
if (f.code !== 0) throw new Error('still frame grab failed: ' + tail(f.stderr));
const r = await niced(['-v', 'error', '-nostdin', '-y', '-loop', '1', '-framerate', '1', '-i', frame, '-i', src,
'-map', '0:v', '-map', '1:a:0', '-t', String(row.duration), '-shortest',
'-c:v', 'libx264', '-preset', 'medium', '-crf', String(transcode.crf), '-tune', 'stillimage',
'-profile:v', 'high', '-level:v', '4.0', '-pix_fmt', 'yuv420p', '-g', '600', '-threads', threads,
'-c:a', 'copy', '-movflags', '+faststart', '-f', 'mp4', out]);
if (r.code !== 0) throw new Error('still encode failed: ' + tail(r.stderr));
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: ['h264'] });
await commitOptimized(id, row, src, out, probe, 'still');
} catch (err) {
await markDone().catch(() => {});
throw err;
} finally {
sweepTmp(prefix);
}
}
async function sampleRatio(src, duration, videoArgs, prefix) { async function sampleRatio(src, duration, videoArgs, prefix) {
let srcBytes = 0; let srcBytes = 0;
let encBytes = 0; let encBytes = 0;

View File

@@ -36,6 +36,11 @@ beforeAll(async () => {
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', fx('noaudio.mp4')); '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', fx('noaudio.mp4'));
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10', ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
'-c:v', 'mpeg4', '-c:a', 'aac', '-shortest', fx('mpeg4.mp4')); '-c:v', 'mpeg4', '-c:a', 'aac', '-shortest', fx('mpeg4.mp4'));
// Album-art upload: one picture with light grain for 40 s, at YouTube-like bitrate.
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=1:duration=1', '-frames:v', '1', fx('cover.png'));
ff('-loop', '1', '-framerate', '25', '-i', fx('cover.png'), '-f', 'lavfi', '-i', 'sine=frequency=330:duration=40',
'-vf', 'noise=alls=4:allf=t', '-t', '40', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '600k', '-g', '125',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', fx('still40.mp4'));
// HEVC as the compression lane writes it (hvc1) and as Safari rejects it (hev1). // HEVC as the compression lane writes it (hvc1) and as Safari rejects it (hev1).
ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10', ff('-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=10',
'-c:v', 'libx265', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-tag:v', 'hvc1', '-x265-params', 'log-level=error', '-c:v', 'libx265', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-tag:v', 'hvc1', '-x265-params', 'log-level=error',
@@ -54,7 +59,7 @@ beforeAll(async () => {
ff('-i', fx('good.mp4'), '-c', 'copy', '-movflags', '+faststart', fx('good-fs.mp4')); ff('-i', fx('good.mp4'), '-c', 'copy', '-movflags', '+faststart', fx('good-fs.mp4'));
const fs = readFileSync(fx('good-fs.mp4')); const fs = readFileSync(fx('good-fs.mp4'));
writeFileSync(fx('truncated-fs.mp4'), fs.subarray(0, Math.floor(fs.length * 0.6))); writeFileSync(fx('truncated-fs.mp4'), fs.subarray(0, Math.floor(fs.length * 0.6)));
}); }, 30000);
afterAll(() => rmSync(root, { recursive: true, force: true })); afterAll(() => rmSync(root, { recursive: true, force: true }));
describe('validateMedia', () => { describe('validateMedia', () => {
@@ -396,6 +401,44 @@ describe('media cache jobs', () => {
expect(lines.some((l) => /sample predicts/.test(l))).toBe(false); expect(lines.some((l) => /sample predicts/.test(l))).toBe(false);
}, 90000); }, 90000);
test('still-picture lane: one looped frame in H.264, far smaller, still verifies', async () => {
await clearDb();
const lines = [];
const { cache } = makeCache({ fixture: 'still40.mp4', duration: 40, log: { info: (m) => lines.push(m), warn: (m) => lines.push(m) },
transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 } });
await cache.init();
const first = await cache.ensureCached('stilAAAAAA1', { priority: HIGH });
expect(await until(async () => (await dbmod.getMedia('stilAAAAAA1')).optimized === OPT_REV, 60000)).toBe(true);
const after = await dbmod.getMedia('stilAAAAAA1');
expect(after.gen).toBeGreaterThan(first.gen);
expect(after.vcodec).toBe('h264');
expect(after.size).toBeLessThan(first.size * 0.6);
expect(lines.some((l) => /\(still\)/.test(l))).toBe(true);
expect(await cache.verify('stilAAAAAA1')).toBe(true);
}, 90000);
test('a moving copy already done by the HEVC lane is not re-encoded for rev 3', async () => {
await clearDb();
const seed = makeCache({ fixture: 'fat64.mp4', duration: 64,
transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 } });
await seed.cache.init();
await seed.cache.ensureCached('rev2AAAAAA1', { priority: HIGH });
await until(async () => (await dbmod.getMedia('rev2AAAAAA1')).optimized === OPT_REV, 60000);
// Pretend it was evaluated by rev 2 and kept: rev 3 must only run the still check.
await dbmod.upsertMedia('rev2AAAAAA1', { optimized: 2 });
const before = await dbmod.getMedia('rev2AAAAAA1');
// A fresh instance over the same dir + DB, as after a deploy.
const again = createMediaCache({
dir: seed.dir, db: { getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia, mediaStats: dbmod.mediaStats },
log: silentLog, getInfo: async () => ({ duration: 64 }), download: async () => { throw new Error('no download expected'); },
transcode: { enabled: true, codec: 'hevc', crf: 28, preset: 'ultrafast', threads: 2 }, freeBytes: () => 100 * 1024 ** 3,
});
await again.init();
expect(await until(async () => (await dbmod.getMedia('rev2AAAAAA1')).optimized === OPT_REV, 30000)).toBe(true);
expect((await dbmod.getMedia('rev2AAAAAA1')).gen).toBe(before.gen);
}, 120000);
test('copies evaluated by an older lane revision are re-queued at boot', async () => { test('copies evaluated by an older lane revision are re-queued at boot', async () => {
await clearDb(); await clearDb();
const seed = makeCache(); const seed = makeCache();