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)
Changes
@@@ -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 */ } } },@@@ -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 () => {@@@ -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');
@@ -291,8 +291,7 @@ function cleanupTempFile(filePath) {
@@ -291,8 +291,7 @@ function cleanupTempFile(filePath) {
// ── Live DVR sidecar ─────────────────────────────────────────
// ── Live DVR sidecar ─────────────────────────────────────────
// Per recording: { running, proc, notBefore, retired, warned }. Every pass rewrites the whole growing
// 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
// (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,
// 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.
// dropped the guard while ffmpeg still ran and let the next tick start another full copy.
@@ -310,7 +309,10 @@ function seekableSidecarPath(filePath) {
@@ -310,7 +309,10 @@ function seekableSidecarPath(filePath) {
/**
/**
* Produce a fully-indexed seekable snapshot of the growing recording so DVR
* 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
* 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.
* 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
* → 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).
* (a pass is running or not yet due, no room, or the pass failed).
@@ -333,10 +335,7 @@ async function remuxForLiveSeeking(filePath) {
@@ -333,10 +335,7 @@ async function remuxForLiveSeeking(filePath) {
return false;
return false;
}
}
state.warned = 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),
budgetMs: remuxBudgetMs(bytes),
// Lower priority than the recordings it copies. Only with a pid: setPriority(undefined) renices Media itself.
// 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 */ } } },
onSpawn: (proc) => { state.proc = proc; if (proc.pid) { try { os.setPriority(proc.pid, LIVE_REMUX_NICE); } catch { /* not permitted: run as is */ } } },
@@ -1,10 +1,11 @@
@@ -1,10 +1,11 @@
'use strict';
'use strict';
// Plan §9 gate: seek works during an active DVR. A recording still being written is a fragmented MP4
// 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
// (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,
// 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -70,7 +71,7 @@ const { spawnSync } = require('child_process');
@@ -70,7 +71,7 @@ const { spawnSync } = require('child_process');
try {
try {
const sidecar = tools.seekableSidecarPath(rec);
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');
assert.ok(await snapshot(), 'the first snapshot ran');
const size = fs.statSync(sidecar).size;
const size = fs.statSync(sidecar).size;
const whole = await get('bytes=0-');
const whole = await get('bytes=0-');
@@ -79,7 +80,7 @@ const { spawnSync } = require('child_process');
@@ -79,7 +80,7 @@ const { spawnSync } = require('child_process');
assert.strictEqual(whole.headers.get('content-range'), `bytes 0-${size - 1}/${size}`);
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');
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');
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');
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.
// A seek lands mid-file: the player asks for a range starting there.
const mid = Math.floor(size / 2);
const mid = Math.floor(size / 2);
@@ -99,12 +100,18 @@ const { spawnSync } = require('child_process');
@@ -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)}`);
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);
record(rec, 8);
assert.ok(await snapshot(), 'the next snapshot ran');
assert.ok(await snapshot(), 'the next snapshot ran');
const served = path.join(tmp, 'served-later.mp4');
const served = path.join(tmp, 'served-later.mp4');
fs.writeFileSync(served, (await get()).body);
fs.writeFileSync(served, (await get()).body);
assert.ok(Math.abs(duration(served) - 8) < 0.5, `the later snapshot covers 8 s: ${duration(served)} s`);
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 () => {
await check('once finalized, /v/:id serves the recording itself, cacheable again', async () => {
@@ -46,7 +46,7 @@ fs.promises.statfs = roomy;
@@ -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(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.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.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.
// 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');
assert.strictEqual(tools.remuxBudgetMs(1e6), 180000, 'a small file gets the 3 min floor');