Re-encode cached videos to HEVC when it saves at least 15 percent and serve them only to devices that can decode HEVC
This commit is contained in:
@@ -9,7 +9,7 @@ import { join } from 'node:path';
|
||||
const root = mkdtempSync(join(tmpdir(), 'ytp-media-test-'));
|
||||
process.env.DB_PATH = join(root, 'test.db');
|
||||
const dbmod = await import('./db.js');
|
||||
const { createMediaCache, validateMedia, MediaSkip, HIGH, LOW } = await import('./media-cache.js');
|
||||
const { createMediaCache, validateMedia, MediaSkip, HIGH, LOW, OPT_REV } = await import('./media-cache.js');
|
||||
|
||||
const fx = (name) => join(root, name);
|
||||
const ff = (...args) => {
|
||||
@@ -33,6 +33,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'));
|
||||
// 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',
|
||||
'-c:a', 'aac', '-shortest', fx('hevc.mp4'));
|
||||
ff('-i', fx('hevc.mp4'), '-c', 'copy', '-tag:v', 'hev1', fx('hevc-hev1.mp4'));
|
||||
const buf = readFileSync(fx('good.mp4'));
|
||||
writeFileSync(fx('truncated.mp4'), buf.subarray(0, Math.floor(buf.length * 0.6)));
|
||||
// The nasty case: moov up front (index says 10 s) but the media data is cut.
|
||||
@@ -61,6 +66,14 @@ describe('validateMedia', () => {
|
||||
test('rejects a non-h264 file', async () => {
|
||||
await expect(validateMedia(fx('mpeg4.mp4'), 10)).rejects.toThrow(/not h264/);
|
||||
});
|
||||
test('accepts hvc1-tagged HEVC for the compression lane, but not as a download', async () => {
|
||||
const p = await validateMedia(fx('hevc.mp4'), 10);
|
||||
expect(p.vcodec).toBe('hevc');
|
||||
await expect(validateMedia(fx('hevc.mp4'), 10, { codecs: ['h264'] })).rejects.toThrow(/not h264/);
|
||||
});
|
||||
test('rejects hev1-tagged HEVC (Safari refuses it)', async () => {
|
||||
await expect(validateMedia(fx('hevc-hev1.mp4'), 10)).rejects.toThrow(/hvc1/);
|
||||
});
|
||||
test('rejects a file shorter than the source video', async () => {
|
||||
await expect(validateMedia(fx('good.mp4'), 60)).rejects.toThrow(/truncated/);
|
||||
});
|
||||
@@ -210,19 +223,39 @@ describe('media cache jobs', () => {
|
||||
expect(calls).toEqual(['bootAAAAAA1']);
|
||||
});
|
||||
|
||||
test('x264 compression lane swaps in a smaller copy under a new gen', async () => {
|
||||
test('HEVC compression lane swaps in a smaller copy under a new gen', async () => {
|
||||
await clearDb();
|
||||
const { cache } = makeCache({ transcode: { enabled: true, crf: 35, preset: 'ultrafast', threads: 2 } });
|
||||
const { cache } = makeCache({ transcode: { enabled: true, codec: 'hevc', crf: 34, preset: 'ultrafast', threads: 2 } });
|
||||
await cache.init();
|
||||
const first = await cache.ensureCached('optAAAAAAA1', { priority: HIGH });
|
||||
expect(await until(async () => (await dbmod.getMedia('optAAAAAAA1')).optimized === 1)).toBe(true);
|
||||
expect(first.vcodec).toBe('h264');
|
||||
expect(await until(async () => (await dbmod.getMedia('optAAAAAAA1')).optimized === OPT_REV)).toBe(true);
|
||||
const after = await dbmod.getMedia('optAAAAAAA1');
|
||||
if (after.gen !== first.gen) {
|
||||
expect(after.size).toBeLessThan(first.size);
|
||||
// old generation is kept for in-flight playback (grace period)
|
||||
expect(await cache.filePath('optAAAAAAA1', first.gen)).not.toBeNull();
|
||||
}
|
||||
expect(after.gen).toBeGreaterThan(first.gen);
|
||||
expect(after.vcodec).toBe('hevc');
|
||||
expect(after.size).toBeLessThan(first.size);
|
||||
// old generation is kept for in-flight playback (grace period)
|
||||
expect(await cache.filePath('optAAAAAAA1', first.gen)).not.toBeNull();
|
||||
expect(await cache.filePath('optAAAAAAA1', after.gen, 'm4a')).not.toBeNull();
|
||||
// and the HEVC copy still passes the gate verify() uses
|
||||
expect(await cache.verify('optAAAAAAA1')).toBe(true);
|
||||
});
|
||||
|
||||
test('copies evaluated by an older lane revision are re-queued at boot', async () => {
|
||||
await clearDb();
|
||||
const seed = makeCache();
|
||||
await seed.cache.init();
|
||||
await seed.cache.ensureCached('revAAAAAAA1', { priority: HIGH });
|
||||
await dbmod.upsertMedia('revAAAAAAA1', { optimized: 1 }); // the old x264 revision
|
||||
// A fresh instance over the same dir + DB, as after a deploy.
|
||||
const same = 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: 10 }), download: async () => { throw new Error('no download expected'); },
|
||||
transcode: { enabled: true, codec: 'hevc', crf: 34, preset: 'ultrafast', threads: 2 }, freeBytes: () => 100 * 1024 ** 3,
|
||||
});
|
||||
await same.init();
|
||||
expect(await until(async () => (await dbmod.getMedia('revAAAAAAA1')).optimized === OPT_REV)).toBe(true);
|
||||
});
|
||||
|
||||
test('verify() drops and refetches a copy that went bad on disk, keeps a good one', async () => {
|
||||
|
||||
Reference in New Issue
Block a user