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
|
// 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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user