openvibers/openvibe.media Commits

The live DVR copy of an MP4 recording is written once per pass, its index last: +faststart rewrote the whole copy a second time (#164)

OP OpenVibers committed 20 days ago · bb8d2580ce · 3 files, +20 −14

Changes

ddiff --git a/server/vod/media-tools.js b/server/vod/media-tools.js
iindex c8a9980..f2968c1 100644
---- a/server/vod/media-tools.js
++++ b/server/vod/media-tools.js
@@@ -291,8 +291,7 @@ function cleanupTempFile(filePath) {
// ── Live DVR sidecar ─────────────────────────────────────────
// Per recording: { running, proc, notBefore, retired, warned }. Every pass rewrites the whole growing
--// file (twice for MP4 +faststart), so a 4-hour recording's pass takes minutes and its copy is as big
--// as the recording. One pass runs at a time, it may use at most 1/LIVE_REMUX_DUTY of the wall clock
++// file once, so a 4-hour recording's pass takes minutes and its copy is as big as the recording. One pass runs at a time, it may use at most 1/LIVE_REMUX_DUTY of the wall clock
// (the next waits 9× as long as the last took), its kill budget scales with the file, and it is
// skipped while the disk cannot take the copy. The old flat 30 s kill cut every long pass short,
// dropped the guard while ffmpeg still ran and let the next tick start another full copy.
@@@ -310,7 +309,10 @@ function seekableSidecarPath(filePath) {
/**
* Produce a fully-indexed seekable snapshot of the growing recording so DVR
* viewers can seek anywhere without the whole file. WebM: copy-remux to write
-- * cues. MP4: copy-remux with +faststart so a complete moov sits at the front.
++ * cues. MP4: copy-remux into a plain MP4 whose complete moov sits at the end. No +faststart: moving the
++ * moov to the front rewrote the whole copy a second time on every pass (2026-10-24), and players read a
++ * tail moov with one Range request (/v/<id> serves ranges). With the moov last, a later snapshot keeps
++ * the earlier samples at the same offsets, so a player's next range read still finds them.
* Writes to <file>.seekable.<ext> WITHOUT touching the growing original.
* → true when a new snapshot replaced the old one; false when this call did nothing
* (a pass is running or not yet due, no room, or the pass failed).
@@@ -333,10 +335,7 @@ async function remuxForLiveSeeking(filePath) {
return false;
}
state.warned = false;
-- const outArgs = filePath.endsWith('.mp4')
-- ? ['-c', 'copy', '-movflags', '+faststart', '-fflags', '+genpts']
-- : ['-c', 'copy', '-fflags', '+genpts'];
-- const { code } = await runFfmpeg(['-y', '-i', filePath, ...outArgs, tmpPath], {
++ const { code } = await runFfmpeg(['-y', '-i', filePath, '-c', 'copy', '-fflags', '+genpts', tmpPath], {
budgetMs: remuxBudgetMs(bytes),
// Lower priority than the recordings it copies. Only with a pid: setPriority(undefined) renices Media itself.
onSpawn: (proc) => { state.proc = proc; if (proc.pid) { try { os.setPriority(proc.pid, LIVE_REMUX_NICE); } catch { /* not permitted: run as is */ } } },
ddiff --git a/test/dvr-seek.test.js b/test/dvr-seek.test.js
iindex 022674b..5eb8282 100644
---- a/test/dvr-seek.test.js
++++ b/test/dvr-seek.test.js
@@@ -1,10 +1,11 @@
'use strict';
// Plan §9 gate: seek works during an active DVR. A recording still being written is a fragmented MP4
// (frag_keyframe+empty_moov, as the recorder writes it); Live's DVR player reads it through /v/:id, which
--// serves the live seek snapshot (remuxForLiveSeeking: a copy-remux with the whole index at the front). Here
++// serves the live seek snapshot (remuxForLiveSeeking: one copy-remux, the whole index after the media). Here
// with real ffmpeg: the snapshot of a recording mid-stream answers ranged reads from any offset with no-cache,
--// carries its index before its media, and a player can seek to a late moment in it; after the recording grows,
--// the next snapshot reaches the new live edge; once finalized, /v/:id serves the recording itself again.
++// carries one whole index (a player reads a tail moov with one range request), and a player can seek to a late
++// moment in it; after the recording grows, the next snapshot reaches the new live edge and keeps the earlier
++// samples at the same offsets; once finalized, /v/:id serves the recording itself again.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
@@@ -70,7 +71,7 @@ const { spawnSync } = require('child_process');
try {
const sidecar = tools.seekableSidecarPath(rec);
-- await check('mid-recording, /v/:id serves the seek snapshot: ranged, uncached, its index before its media', async () => {
++ await check('mid-recording, /v/:id serves the seek snapshot: ranged, uncached, one whole index after its media', async () => {
assert.ok(await snapshot(), 'the first snapshot ran');
const size = fs.statSync(sidecar).size;
const whole = await get('bytes=0-');
@@@ -79,7 +80,7 @@ const { spawnSync } = require('child_process');
assert.strictEqual(whole.headers.get('content-range'), `bytes 0-${size - 1}/${size}`);
assert.ok(whole.body.equals(fs.readFileSync(sidecar)), 'the snapshot, not the growing file');
const moov = whole.body.indexOf('moov'), mdat = whole.body.indexOf('mdat');
-- assert.ok(moov > 0 && mdat > moov, `the index sits before the media (moov ${moov}, mdat ${mdat})`);
++ assert.ok(mdat > 0 && moov > mdat, `the index follows the media, written in one pass (moov ${moov}, mdat ${mdat})`);
assert.strictEqual(whole.body.indexOf('moof'), -1, 'one whole index, not fragments a player must walk');
// A seek lands mid-file: the player asks for a range starting there.
const mid = Math.floor(size / 2);
@@@ -99,12 +100,18 @@ const { spawnSync } = require('child_process');
assert.ok(times.length > 0 && times[0] >= 2.9 && times[0] < 3.6, `seeking to 3.5 s decodes from its keyframe (3 s), not the start: ${times.slice(0, 3)}`);
});
-- await check('as the recording grows, the next snapshot reaches the new live edge', async () => {
++ await check('as the recording grows, the next snapshot reaches the new live edge, earlier samples where they were', async () => {
++ const earlier = fs.readFileSync(path.join(tmp, 'served.mp4'));
record(rec, 8);
assert.ok(await snapshot(), 'the next snapshot ran');
const served = path.join(tmp, 'served-later.mp4');
fs.writeFileSync(served, (await get()).body);
assert.ok(Math.abs(duration(served) - 8) < 0.5, `the later snapshot covers 8 s: ${duration(served)} s`);
++ // A player that started on the earlier snapshot keeps reading ranges after the swap: the media before the
++ // earlier snapshot's tail (its last fragment can be cut differently) is byte-identical in the later one.
++ const later = fs.readFileSync(served);
++ const from = earlier.indexOf('mdat') + 4, to = Math.floor(earlier.indexOf('moov') * 0.8);
++ assert.ok(to > from && earlier.subarray(from, to).equals(later.subarray(from, to)), `the earlier media sits at the same offsets (${from}-${to})`);
});
await check('once finalized, /v/:id serves the recording itself, cacheable again', async () => {
ddiff --git a/test/live-remux-pacing.test.js b/test/live-remux-pacing.test.js
iindex 7e71c68..bcb2cc1 100644
---- a/test/live-remux-pacing.test.js
++++ b/test/live-remux-pacing.test.js
@@@ -46,7 +46,7 @@ fs.promises.statfs = roomy;
assert.strictEqual(await tools.remuxForLiveSeeking(rec), false, 'a call while the pass runs does nothing');
assert.strictEqual(spawned.length, 1, 'no second ffmpeg on the same recording');
assert.strictEqual(spawned[0].args[spawned[0].args.length - 1], tmpOut, 'the pass writes the temp file');
-- assert.ok(spawned[0].args.includes('+faststart'), 'an MP4 snapshot is written with faststart');
++ assert.ok(!spawned[0].args.includes('+faststart') && !spawned[0].args.includes('-movflags'), 'an MP4 snapshot is written once: its moov goes last, no faststart second pass');
// 2) The pass is not cut short at 30 s: its budget scales with the file, and it ends when ffmpeg ends.
assert.strictEqual(tools.remuxBudgetMs(1e6), 180000, 'a small file gets the 3 min floor');