Media cache: still-picture lane re-encodes album-art videos as one looped frame in H.264
This commit is contained in:
@@ -31,8 +31,9 @@ export const LOW = 1; // auto-cache after a play
|
||||
|
||||
// Compression-lane revision. `optimized` stores the revision a copy was last
|
||||
// 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).
|
||||
export const OPT_REV = 2;
|
||||
// x264, which never beat YouTube's own encode; rev 2 is HEVC; rev 3 adds the
|
||||
// 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}$/;
|
||||
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 SAMPLE_SECONDS = 10; // per sample window (two windows)
|
||||
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
|
||||
// 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:'
|
||||
@@ -420,6 +428,10 @@ export function createMediaCache({
|
||||
};
|
||||
// Very long videos would tie the lane up for hours on 2 niced threads.
|
||||
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 prefix = `${id}-${now()}-${hevc ? 'x265' : 'x264'}`;
|
||||
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));
|
||||
const probe = await validateMedia(out, row.duration, { ffmpeg, ffprobe, codecs: [hevc ? 'hevc' : 'h264'] });
|
||||
|
||||
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}: ${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})`);
|
||||
await commitOptimized(id, row, src, out, probe, probe.vcodec);
|
||||
} catch (err) {
|
||||
await markDone().catch(() => {}); // don't retry a source the encoder can't improve
|
||||
throw err;
|
||||
@@ -487,6 +479,69 @@ export function createMediaCache({
|
||||
// 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.
|
||||
// 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) {
|
||||
let srcBytes = 0;
|
||||
let encBytes = 0;
|
||||
|
||||
@@ -36,6 +36,11 @@ beforeAll(async () => {
|
||||
'-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',
|
||||
'-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).
|
||||
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',
|
||||
@@ -54,7 +59,7 @@ beforeAll(async () => {
|
||||
ff('-i', fx('good.mp4'), '-c', 'copy', '-movflags', '+faststart', 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)));
|
||||
});
|
||||
}, 30000);
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('validateMedia', () => {
|
||||
@@ -396,6 +401,44 @@ describe('media cache jobs', () => {
|
||||
expect(lines.some((l) => /sample predicts/.test(l))).toBe(false);
|
||||
}, 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 () => {
|
||||
await clearDb();
|
||||
const seed = makeCache();
|
||||
|
||||
Reference in New Issue
Block a user