openvibers/openvibe.media Commits

Live rewind across a phone turn: the DVR playlist starts a period with its own init segment at each change of shape; a clip past the recorded edge keeps its length (#182)

OP OpenVibers committed 18 days ago · 67be21d858 · 6 files, +551 −18

Stream 2795 turned between 720x1280 and 1280x720. The recording is a stream copy whose moov names the first size, and
Chrome showed rewound landscape frames squeezed into portrait (1280x2276). fmp4-index reads each H.264 fragment's
keyframe SPS (avc-shape.js); another aspect ratio starts a period, named after #EXT-X-DISCONTINUITY with
dvr/init-<offset>.mp4 (the moov rewritten for that SPS). Clip 505 asked for 30 s past a recording whose newest keyframe
was 70 s old and was squeezed to 1 s; the window now moves back to end at the edge, measured from the index.

Changes

ddiff --git a/docs/media-fabric.md b/docs/media-fabric.md
iindex 9d952e3..0c35996 100644
---- a/docs/media-fabric.md
++++ b/docs/media-fabric.md
@@@ -243,6 +243,13 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
(`dvr/init.mp4`, `dvr/<offset>-<length>.m4s`; server/vod/fmp4-index.js reads its moof/mdat pairs as they are
appended), so Live's DVR seeks with no second copy; `VOD_DVR_SNAPSHOT_MP4=0` then stops the per-pass copy of MP4
recordings. Public and unlisted recordings only (an HLS player sends no credentials).
++ **Periods at a turn (2026-10-26).** The recording is a stream copy, so a phone turned mid-stream changes the frames'
++ shape at a keyframe while the moov still names the first shape, and Chrome showed every rewound landscape frame
++ squeezed into portrait (stream 2795). The index reads each H.264 fragment's first sample up to its first slice (the
++ keyframe's SPS and PPS, vod/avc-shape.js); a size of another aspect ratio starts a period, and the playlist puts
++ `#EXT-X-DISCONTINUITY` and `dvr/init-<offset>.mp4` (the moov rewritten for that SPS: avcC, sample entry size, track
++ size) before it. A lowered resolution of the same shape stays in its period: the decoder follows it in-band. A clip
++ of a recording asked for past the recorded edge (the end of the last whole fragment) keeps its length and ends there.
**Still open in F3:** the growing live/DVR playlist written as OpenRestream segments (F3.2), write-behind durability for
live segments and its upload-lag metric, captions on the timeline, and Live's player moving to HLS. Segment-bucket
demand (F2.4) now records reads per timeline segment; deciding placement per segment stays F5's. Renditions are F4.
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 2bda1af..8f84af8 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -251,10 +251,12 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
// ── DVR of a recording in progress (vod/fmp4-index.js; docs/media-fabric.md F3.2) ──
// While an MP4 recording is written, /v/:id/dvr.m3u8 is an EVENT playlist over the file itself: dvr/init.mp4 (its ftyp
// and moov) and dvr/<offset>-<length>.m4s (whole moof+mdat fragments, about 6 s a segment), so a DVR player seeks
--// anywhere in the stream without a second copy of the recording. Public and unlisted recordings only (an HLS player
++// anywhere in the stream without a second copy of the recording. After the picture changes shape (a phone turned),
++// dvr/init-<offset>.mp4 is the moov rewritten for the new SPS, named after a discontinuity. Public and unlisted recordings only (an HLS player
// sends no credentials), CORS-open like the other public media. The playlist is never cached; a segment's bytes never
// change while the file grows. Not recording (finalize rewrites the file) or not an MP4: 404, and players keep /v/:id.
const DVR_SEGMENT = /^(\d{1,15})-(\d{1,10})\.m4s$/;
++const DVR_PERIOD_INIT = /^init-(\d{1,15})\.mp4$/;
const DVR_SEGMENT_MAX_BYTES = 64 * 1024 * 1024;
async function dvrSource(req, res) {
if (drill.refuseBytes(res)) return null;
@@@ -287,6 +289,18 @@ router.get('/v/:id/dvr/init.mp4', dvrRoute(async (req, res, { filePath, index })
sendFileStream(res, filePath, { start: 0, end: index.initEnd - 1 });
}));
router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index }) => {
++ // A later period's init segment (the moov rewritten for the shape the stream turned to: vod/fmp4-index.js periods).
++ const init = DVR_PERIOD_INIT.exec(String(req.params.segment || ''));
++ if (init) {
++ const head = Buffer.alloc(index.initEnd);
++ const fh = await fs.promises.open(filePath, 'r');
++ try { await fh.read(head, 0, index.initEnd, 0); } finally { await fh.close(); }
++ let bytes = null;
++ try { bytes = require('../vod/fmp4-index').periodInit(head, index, Number(init[1])); } catch (err) { console.warn('[Public] DVR period init:', err.message); }
++ if (!bytes) return notFound(res);
++ res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': bytes.length, 'Cache-Control': 'public, max-age=3600' });
++ return res.end(bytes);
++ }
const m = DVR_SEGMENT.exec(String(req.params.segment || ''));
const last = index.fragments[index.fragments.length - 1];
const offset = m ? Number(m[1]) : NaN;
ddiff --git a/server/vod/avc-shape.js b/server/vod/avc-shape.js
nnew file mode 100644
iindex 0000000..9b2c045
---- /dev/null
++++ b/server/vod/avc-shape.js
@@@ -0,0 +1,258 @@
++'use strict';
++/**
++ * OpenVibe.Media — the H.264 picture size a recording in progress sends, and an init segment for it (the DVR's periods,
++ * vod/fmp4-index.js).
++ *
++ * A recording is a stream copy: when the broadcaster's phone turns, the frames change shape at a keyframe, whose in-band
++ * SPS names the new size, while the file's moov still describes the first one. A player given that one init segment
++ * keeps the first shape's aspect ratio for every frame (Chrome showed a landscape stretch of stream 2795 squeezed into
++ * 720x1280 as 1280x2276). So the DVR starts a new period at each change of shape, and gives it an init segment made from
++ * the file's own moov with the new SPS and PPS and the new size.
++ *
++ * nals(buf, lengthSize) the NAL units of a length-prefixed sample (what the recorder's mp4 holds): [{ type, data }]
++ * parameterSets(buf, size) the SPS and PPS before the first slice of a sample: { sps, pps } (Buffers, or null)
++ * parseSps(sps) { profile, compat, level, chromaFormat, bitDepthLuma, bitDepthChroma, width, height }
++ * parseAvcC(body) { lengthSize, sps: [Buffer], pps: [Buffer] } from an avcC box body
++ * videoEntry(moov) the video track's sample entry in a moov body: { type, width, height, avcC } or null
++ * initWith(init, { sps, pps }) the init segment (ftyp + moov bytes) rewritten for that SPS/PPS: avcC, the sample entry's
++ * width/height and the track header's size
++ * sameShape(a, b) whether two sizes have one aspect ratio (a lowered resolution is not a new shape)
++ */
++
++const u32 = (b, o) => b.readUInt32BE(o);
++const u64 = (b, o) => Number(b.readBigUInt64BE(o));
++const type4 = (b, o) => b.toString('latin1', o, o + 4);
++
++const SAME_SHAPE = 0.04; // |ln(aspect a / aspect b)|, as in jobs/shapes.js: 1280x720 and 426x240 are one shape
++const HIGH_PROFILES = new Set([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135]);
++const VISUAL_ENTRY_FIELDS = 78; // a VisualSampleEntry's fields before its child boxes (ISO 14496-12 12.1.3)
++
++/** Boxes inside [start, end) of buf: [{ type, start, size, header, body }]. */
++function boxes(buf, start = 0, end = buf.length) {
++ const out = [];
++ let o = start;
++ while (o + 8 <= end) {
++ let size = u32(buf, o);
++ const type = type4(buf, o + 4);
++ let header = 8;
++ if (size === 1) { if (o + 16 > end) break; size = u64(buf, o + 8); header = 16; }
++ else if (size === 0) size = end - o;
++ if (size < header || o + size > end) break;
++ out.push({ type, start: o, size, header, body: o + header });
++ o += size;
++ }
++ return out;
++}
++const find = (buf, box, type, skip = 0) => boxes(buf, box.body + skip, box.start + box.size).find((c) => c.type === type) || null;
++
++function nals(buf, lengthSize = 4) {
++ const out = [];
++ let o = 0;
++ while (o + lengthSize <= buf.length) {
++ let n = 0;
++ for (let i = 0; i < lengthSize; i++) n = n * 256 + buf[o + i];
++ o += lengthSize;
++ if (!n || o + n > buf.length) break; // a read that stops mid-NAL keeps the ones before it
++ out.push({ type: buf[o] & 0x1f, data: buf.subarray(o, o + n) });
++ o += n;
++ }
++ return out;
++}
++
++function parameterSets(buf, lengthSize = 4) {
++ let sps = null, pps = null;
++ for (const n of nals(buf, lengthSize)) {
++ if (n.type >= 1 && n.type <= 5) break; // the first slice: parameter sets come before it
++ if (n.type === 7 && !sps) sps = Buffer.from(n.data);
++ else if (n.type === 8 && !pps) pps = Buffer.from(n.data);
++ }
++ return { sps, pps };
++}
++
++/** An RBSP bit reader (emulation prevention bytes removed). */
++function bits(nal) {
++ const rbsp = [];
++ for (let i = 1; i < nal.length; i++) { // past the NAL header byte
++ if (i >= 3 && nal[i] === 3 && nal[i - 1] === 0 && nal[i - 2] === 0) continue;
++ rbsp.push(nal[i]);
++ }
++ let pos = 0;
++ const bit = () => {
++ if (pos >= rbsp.length * 8) throw new Error('SPS ends early');
++ const b = (rbsp[pos >> 3] >> (7 - (pos & 7))) & 1;
++ pos++;
++ return b;
++ };
++ const u = (n) => { let v = 0; for (let i = 0; i < n; i++) v = v * 2 + bit(); return v; };
++ const ue = () => { let zeros = 0; while (!bit()) { if (++zeros > 31) throw new Error('bad exp-Golomb code'); } return (2 ** zeros) - 1 + u(zeros); };
++ const se = () => { const k = ue(); return k % 2 ? (k + 1) / 2 : -(k / 2); };
++ return { u, ue, se };
++}
++
++function parseSps(sps) {
++ if (!sps || sps.length < 4 || (sps[0] & 0x1f) !== 7) throw new Error('not an SPS');
++ const r = bits(sps);
++ const profile = r.u(8);
++ const compat = r.u(8);
++ const level = r.u(8);
++ r.ue(); // seq_parameter_set_id
++ let chromaFormat = 1, bitDepthLuma = 8, bitDepthChroma = 8, separateColourPlane = 0;
++ if (HIGH_PROFILES.has(profile)) {
++ chromaFormat = r.ue();
++ if (chromaFormat === 3) separateColourPlane = r.u(1);
++ bitDepthLuma = r.ue() + 8;
++ bitDepthChroma = r.ue() + 8;
++ r.u(1); // qpprime_y_zero_transform_bypass_flag
++ if (r.u(1)) { // seq_scaling_matrix_present_flag
++ for (let i = 0; i < (chromaFormat !== 3 ? 8 : 12); i++) {
++ if (!r.u(1)) continue;
++ let last = 8, next = 8;
++ for (let j = 0; j < (i < 6 ? 16 : 64); j++) {
++ if (next !== 0) next = (last + r.se() + 256) % 256;
++ last = next === 0 ? last : next;
++ }
++ }
++ }
++ }
++ r.ue(); // log2_max_frame_num_minus4
++ const pocType = r.ue();
++ if (pocType === 0) r.ue();
++ else if (pocType === 1) {
++ r.u(1); r.se(); r.se();
++ const cycle = r.ue();
++ for (let i = 0; i < cycle; i++) r.se();
++ }
++ r.ue(); // max_num_ref_frames
++ r.u(1); // gaps_in_frame_num_value_allowed_flag
++ const widthMbs = r.ue() + 1;
++ const heightMapUnits = r.ue() + 1;
++ const frameMbsOnly = r.u(1);
++ if (!frameMbsOnly) r.u(1); // mb_adaptive_frame_field_flag
++ r.u(1); // direct_8x8_inference_flag
++ let cropLeft = 0, cropRight = 0, cropTop = 0, cropBottom = 0;
++ if (r.u(1)) { cropLeft = r.ue(); cropRight = r.ue(); cropTop = r.ue(); cropBottom = r.ue(); }
++ const arrayType = separateColourPlane ? 0 : chromaFormat;
++ const cropX = arrayType === 0 ? 1 : (chromaFormat === 3 ? 1 : 2);
++ const cropY = (arrayType === 0 ? 1 : (chromaFormat === 1 ? 2 : 1)) * (2 - frameMbsOnly);
++ const width = widthMbs * 16 - cropX * (cropLeft + cropRight);
++ const height = (2 - frameMbsOnly) * heightMapUnits * 16 - cropY * (cropTop + cropBottom);
++ if (width <= 0 || height <= 0) throw new Error('SPS names no picture');
++ return { profile, compat, level, chromaFormat, bitDepthLuma, bitDepthChroma, width, height };
++}
++
++function parseAvcC(body) {
++ if (!body || body.length < 7 || body[0] !== 1) return null;
++ const lengthSize = (body[4] & 3) + 1;
++ let o = 6;
++ const read = (count) => {
++ const list = [];
++ for (let i = 0; i < count && o + 2 <= body.length; i++) {
++ const n = body.readUInt16BE(o);
++ o += 2;
++ if (o + n > body.length) break;
++ list.push(Buffer.from(body.subarray(o, o + n)));
++ o += n;
++ }
++ return list;
++ };
++ const sps = read(body[5] & 0x1f);
++ const pps = o < body.length ? read(body[o++]) : [];
++ return { lengthSize, sps, pps };
++}
++
++/** The video track's trak, its sample entry and the boxes on the way, in a moov body (offsets in `moov`). */
++function videoPath(moov) {
++ for (const trak of boxes(moov).filter((b) => b.type === 'trak')) {
++ const mdia = find(moov, trak, 'mdia');
++ const hdlr = mdia && find(moov, mdia, 'hdlr');
++ if (!hdlr || type4(moov, hdlr.body + 8) !== 'vide') continue;
++ const minf = find(moov, mdia, 'minf');
++ const stbl = minf && find(moov, minf, 'stbl');
++ const stsd = stbl && find(moov, stbl, 'stsd');
++ if (!stsd) return null;
++ const entry = boxes(moov, stsd.body + 8, stsd.start + stsd.size)[0];
++ if (!entry || entry.size < entry.header + VISUAL_ENTRY_FIELDS) return null;
++ return { trak, tkhd: find(moov, trak, 'tkhd'), mdia, minf, stbl, stsd, entry };
++ }
++ return null;
++}
++
++function videoEntry(moov) {
++ const p = videoPath(moov);
++ if (!p) return null;
++ const { entry } = p;
++ const avcC = (entry.type === 'avc1' || entry.type === 'avc3') ? find(moov, entry, 'avcC', VISUAL_ENTRY_FIELDS) : null;
++ return {
++ type: entry.type,
++ width: moov.readUInt16BE(entry.body + 24),
++ height: moov.readUInt16BE(entry.body + 26),
++ avcC: avcC ? parseAvcC(moov.subarray(avcC.body, avcC.start + avcC.size)) : null,
++ };
++}
++
++const box = (type, ...parts) => {
++ const body = Buffer.concat(parts);
++ const head = Buffer.alloc(8);
++ head.writeUInt32BE(body.length + 8, 0);
++ head.write(type, 4, 'latin1');
++ return Buffer.concat([head, body]);
++};
++
++function avcCBox(sps, ppsList, lengthSize, info) {
++ const parts = [Buffer.from([1, info.profile, info.compat, info.level, 0xfc | (lengthSize - 1), 0xe0 | 1])];
++ const withLength = (b) => { const l = Buffer.alloc(2); l.writeUInt16BE(b.length, 0); return Buffer.concat([l, b]); };
++ parts.push(withLength(sps), Buffer.from([ppsList.length]), ...ppsList.map(withLength));
++ if (HIGH_PROFILES.has(info.profile)) { // ISO 14496-15 5.3.3.1.2: the chroma and bit-depth fields, no SPS extensions
++ parts.push(Buffer.from([0xfc | (info.chromaFormat & 3), 0xf8 | ((info.bitDepthLuma - 8) & 7), 0xf8 | ((info.bitDepthChroma - 8) & 7), 0]));
++ }
++ return box('avcC', ...parts);
++}
++
++/**
++ * A container box of `buf` rebuilt with `keep` bytes of its own fields first and each child replaced by what `map`
++ * returns for it (a Buffer), or copied when it returns nothing.
++ */
++function rebuild(buf, b, keep, map) {
++ const kids = boxes(buf, b.body + keep, b.start + b.size).map((c) => map(c) || buf.subarray(c.start, c.start + c.size));
++ return box(b.type, buf.subarray(b.body, b.body + keep), ...kids);
++}
++
++function initWith(init, { sps, pps }) {
++ const top = boxes(init);
++ const moovBox = top.find((b) => b.type === 'moov');
++ if (!moovBox) throw new Error('no moov');
++ const moov = init.subarray(moovBox.body, moovBox.start + moovBox.size);
++ const p = videoPath(moov);
++ if (!p || !(p.entry.type === 'avc1' || p.entry.type === 'avc3')) throw new Error('not an H.264 video track');
++ const info = parseSps(sps);
++ const old = find(moov, p.entry, 'avcC', VISUAL_ENTRY_FIELDS);
++ const prior = old ? parseAvcC(moov.subarray(old.body, old.start + old.size)) : null;
++ const lengthSize = prior ? prior.lengthSize : 4;
++ const ppsList = pps ? [pps] : (prior ? prior.pps : []);
++
++ const fields = Buffer.from(moov.subarray(p.entry.body, p.entry.body + VISUAL_ENTRY_FIELDS));
++ fields.writeUInt16BE(info.width, 24);
++ fields.writeUInt16BE(info.height, 26);
++ const entry = box(p.entry.type, fields, ...boxes(moov, p.entry.body + VISUAL_ENTRY_FIELDS, p.entry.start + p.entry.size)
++ .map((c) => (c.type === 'avcC' ? avcCBox(sps, ppsList, lengthSize, info) : moov.subarray(c.start, c.start + c.size))));
++ const tkhd = Buffer.from(moov.subarray(p.tkhd.start, p.tkhd.start + p.tkhd.size));
++ const sizeAt = p.tkhd.header + (tkhd[p.tkhd.header] === 1 ? 88 : 76); // width, height: 16.16 fixed point
++ tkhd.writeUInt32BE(info.width * 65536, sizeAt);
++ tkhd.writeUInt32BE(info.height * 65536, sizeAt + 4);
++
++ const at = (b) => (c) => c.start === b.start;
++ const stsd = rebuild(moov, p.stsd, 8, (c) => (at(p.entry)(c) ? entry : null));
++ const stbl = rebuild(moov, p.stbl, 0, (c) => (at(p.stsd)(c) ? stsd : null));
++ const minf = rebuild(moov, p.minf, 0, (c) => (at(p.stbl)(c) ? stbl : null));
++ const mdia = rebuild(moov, p.mdia, 0, (c) => (at(p.minf)(c) ? minf : null));
++ const trak = rebuild(moov, p.trak, 0, (c) => (at(p.mdia)(c) ? mdia : at(p.tkhd)(c) ? tkhd : null));
++ const newMoov = box('moov', ...boxes(moov).map((c) => (at(p.trak)(c) ? trak : moov.subarray(c.start, c.start + c.size))));
++ return Buffer.concat(top.map((b) => (b === moovBox ? newMoov : init.subarray(b.start, b.start + b.size))));
++}
++
++function sameShape(a, b) {
++ if (!a || !b || !a.width || !a.height || !b.width || !b.height) return true;
++ return Math.abs(Math.log((a.width / a.height) / (b.width / b.height))) <= SAME_SHAPE;
++}
++
++module.exports = { nals, parameterSets, parseSps, parseAvcC, videoEntry, initWith, sameShape, SAME_SHAPE, _boxes: boxes };
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 74970f6..1409cc7 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -148,6 +148,22 @@ const flag = (v) => (['1', 'true'].includes(String(v)) ? 1 : ['0', 'false'].incl
// With `materialize: true`, or a source without a timeline, it is cut into its own file (202, background clip.cut event).
// Multipart `video`: direct upload of an already-cut blob (201, ready) —
// inherited browser-MediaRecorder clip path.
++/**
++ * How much of a recording in progress is in its file: the end of its last whole fragment (vod/fmp4-index.js, exact and
++ * a few small reads) for the recorder's fragmented MP4, else ffprobe's duration.
++ */
++async function recordedSeconds(filePath) {
++ if (/\.mp4$/i.test(filePath)) {
++ try {
++ const index = await require('./fmp4-index').refresh(filePath);
++ const first = !index.broken && index.fragments[0];
++ const last = first && index.fragments[index.fragments.length - 1];
++ if (last) return last.start + last.duration - first.start; // like ffprobe's duration: from the first frame
++ } catch { /* probe it */ }
++ }
++ try { return (await tools.probeVodInfo(filePath)).duration || 0; } catch { return 0; }
++}
++
router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.single('video'), async (req, res) => {
try {
const body = req.body || {};
@@@ -190,14 +206,17 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
if (!win && !source) return res.status(404).json({ error: 'VOD media unavailable' });
// Live recording: clamp to what's ACTUALLY on disk, not wall-clock — a
-- // seek past end-of-file emits a header-only "broken clip".
++ // seek past end-of-file emits a header-only "broken clip". The window keeps its length: it moves back to end at
++ // the recorded edge. Clip 505 (2026-10-26) asked for 30 s past a recording whose newest keyframe was 70 s old,
++ // and squeezing the start up to the edge left a 1 s window whose cut failed three times.
if (vod.is_recording && source && source.kind === 'file') {
-- let recordedDur = 0;
-- try { const pi = await tools.probeVodInfo(source.value); recordedDur = pi.duration || 0; } catch { /* */ }
++ const recordedDur = await recordedSeconds(source.value);
if (recordedDur > 0) {
const safeEdge = Math.max(0, recordedDur - 0.5);
-- endTime = Math.min(endTime, safeEdge);
-- startTime = Math.max(0, Math.min(startTime, endTime - 1));
++ if (endTime > safeEdge) {
++ endTime = safeEdge;
++ startTime = Math.max(0, Math.min(startTime, endTime - duration));
++ }
duration = endTime - startTime;
if (duration < 1) {
return res.status(422).json({ error: 'Not enough recorded footage yet — try again in a moment.' });
ddiff --git a/server/vod/fmp4-index.js b/server/vod/fmp4-index.js
iindex d9ecb8d..cc3bdd9 100644
---- a/server/vod/fmp4-index.js
++++ b/server/vod/fmp4-index.js
@@@ -13,15 +13,26 @@
* for the next read. Each read starts where the last stopped, so a refresh costs a stat and the new boxes' headers (a
* moof is read whole, a few KB; an mdat only by its header). Times come from the timescale in the moov's mdhd and each
* fragment's tfdt and trun (or tfhd/trex defaults), on the video track when there is one.
++ *
++ * Periods: the recording is a stream copy, so when the broadcaster's phone turns the frames change shape at a keyframe
++ * while the moov still describes the first shape, and a player shows every later frame at the first shape's aspect
++ * ratio (stream 2795, 2026-10-26: landscape squeezed into portrait on rewind). For H.264 each fragment's first sample is
++ * read up to its first slice (a keyframe's SPS and PPS, a few hundred bytes); a size of another aspect ratio starts a
++ * new period, and the playlist puts #EXT-X-DISCONTINUITY and that period's own init segment (dvr/init-<offset>.mp4, the
++ * moov rewritten for its SPS: vod/avc-shape.js) before it. A lowered resolution of the same shape is not a period: the
++ * decoder follows it in-band.
*/
const fsp = require('fs').promises;
++const avc = require('./avc-shape');
const MAX_FRAGMENT_BYTES = 256 * 1024 * 1024; // a moof+mdat bigger than this is not a fragment the recorder writes
const MAX_MOOV_BYTES = 4 * 1024 * 1024;
const _states = new Map(); // file path → index state (one per recording; dropped by forget())
++const SAMPLE_HEAD_BYTES = 4096; // enough of a keyframe sample for its SPS and PPS (they come before the first slice)
++
function emptyState() {
-- return { offset: 0, initEnd: 0, tracks: new Map(), videoId: null, fragments: [], pendingMoof: null, broken: null };
++ return { offset: 0, initEnd: 0, tracks: new Map(), videoId: null, avc: null, periods: [], fragments: [], pendingMoof: null, broken: null };
}
const u32 = (b, o) => b.readUInt32BE(o);
@@@ -85,28 +96,66 @@ function parseMoof(buf, tracks) {
const flags = u32(buf, tfhd.body) & 0xffffff;
const id = u32(buf, tfhd.body + 4);
let p = tfhd.body + 8;
++ const baseOffset = (flags & 0x01) ? u64(buf, tfhd.body + 8) : null; // else the moof's first byte (default-base-is-moof)
if (flags & 0x01) p += 8; // base_data_offset
if (flags & 0x02) p += 4; // sample_description_index
let defaultDuration = (tracks.get(id) || {}).defaultDuration || 0;
-- if (flags & 0x08) defaultDuration = u32(buf, p);
++ if (flags & 0x08) { defaultDuration = u32(buf, p); p += 4; }
++ const defaultSize = (flags & 0x10) ? u32(buf, p) : 0;
const tfdt = child(buf, traf, 'tfdt');
const start = tfdt ? (buf[tfdt.body] === 1 ? u64(buf, tfdt.body + 4) : u32(buf, tfdt.body + 4)) : null;
let duration = 0;
++ let first = null; // the first sample's { offset (from the base), size }
for (const trun of children(buf, traf.body, traf.start + traf.size).filter((c) => c.type === 'trun')) {
const tf = u32(buf, trun.body) & 0xffffff;
const count = u32(buf, trun.body + 4);
let q = trun.body + 8;
++ const dataOffset = (tf & 0x001) ? buf.readInt32BE(q) : null;
if (tf & 0x001) q += 4; // data_offset
if (tf & 0x004) q += 4; // first_sample_flags
const per = ((tf & 0x100) ? 4 : 0) + ((tf & 0x200) ? 4 : 0) + ((tf & 0x400) ? 4 : 0) + ((tf & 0x800) ? 4 : 0);
++ if (!first && count && dataOffset != null) first = { offset: dataOffset, size: (tf & 0x200) ? u32(buf, q + ((tf & 0x100) ? 4 : 0)) : defaultSize };
if (tf & 0x100) { for (let i = 0; i < count; i++) duration += u32(buf, q + i * per); }
else duration += count * defaultDuration;
}
-- out.set(id, { start, duration });
++ out.set(id, { start, duration, first, baseOffset });
}
return out;
}
++/** The H.264 video track of a moov body: { lengthSize, sps, pps, shape: { width, height } }, or null (not H.264). */
++function h264Of(moovBody) {
++ try {
++ const e = avc.videoEntry(moovBody);
++ if (!e || !e.avcC) return null;
++ const sps = e.avcC.sps[0] || null;
++ let shape = { width: e.width, height: e.height };
++ if (sps) { try { const i = avc.parseSps(sps); shape = { width: i.width, height: i.height }; } catch { /* the entry's size */ } }
++ return { lengthSize: e.avcC.lengthSize, sps, pps: e.avcC.pps[0] || null, shape };
++ } catch { return null; }
++}
++
++/**
++ * Read the start of a video fragment's first sample: when its SPS names a size of another shape than the current
++ * period's, a new period starts at this fragment. Never fails the index: an unreadable sample keeps the period.
++ */
++async function notePeriod(fh, st, moof, t, start) {
++ if (!t.first || !(t.first.size > 0)) return;
++ try {
++ const at = (t.baseOffset != null ? t.baseOffset : moof.offset) + t.first.offset;
++ const length = Math.min(t.first.size, SAMPLE_HEAD_BYTES);
++ const head = Buffer.alloc(length);
++ const { bytesRead } = await fh.read(head, 0, length, at);
++ const { sps, pps } = avc.parameterSets(head.subarray(0, bytesRead), st.avc.lengthSize);
++ if (!sps) return;
++ const info = avc.parseSps(sps);
++ const cur = st.periods[st.periods.length - 1];
++ const shape = { width: info.width, height: info.height };
++ if (avc.sameShape(cur, shape)) return;
++ st.periods.push({ offset: moof.offset, start, ...shape, sps, pps: pps || cur.pps || st.avc.pps });
++ } catch { /* keep the period */ }
++}
++
async function readAt(fh, offset, length) {
const buf = Buffer.alloc(length);
const { bytesRead } = await fh.read(buf, 0, length, offset);
@@@ -140,6 +189,8 @@ async function refresh(filePath) {
st.tracks = parseMoov(body);
for (const [id, t] of st.tracks) if (t.handler === 'vide' && st.videoId == null) st.videoId = id;
st.initEnd = st.offset + boxSize;
++ st.avc = h264Of(body);
++ st.periods = [st.avc ? { offset: null, start: 0, ...st.avc.shape } : { offset: null, start: 0 }];
} else if (type === 'moof') {
if (!st.initEnd) { st.broken = 'moof before moov'; break; }
const body = await readAt(fh, st.offset + header, boxSize - header);
@@@ -152,7 +203,8 @@ async function refresh(filePath) {
const track = st.tracks.get(id);
if (t && track && track.timescale && length <= MAX_FRAGMENT_BYTES) {
const start = t.start != null ? t.start / track.timescale : (st.fragments.length ? st.fragments[st.fragments.length - 1].start + st.fragments[st.fragments.length - 1].duration : 0);
-- st.fragments.push({ offset: m.offset, length, start, duration: t.duration / track.timescale });
++ if (st.avc && id === st.videoId) await notePeriod(fh, st, m, t, start);
++ st.fragments.push({ offset: m.offset, length, start, duration: t.duration / track.timescale, period: st.periods.length - 1 });
}
st.pendingMoof = null;
} else if (type === 'ftyp' || type === 'free' || type === 'skip' || type === 'styp' || type === 'sidx' || type === 'mfra' || type === 'prft') {
@@@ -162,38 +214,65 @@ async function refresh(filePath) {
}
st.offset += boxSize;
}
-- return { initEnd: st.initEnd, fragments: st.fragments, size, broken: st.broken || undefined };
++ return { initEnd: st.initEnd, fragments: st.fragments, periods: st.periods, size, broken: st.broken || undefined };
} finally {
await fh.close();
}
}
--/** Fragments grouped into segments of at least `target` seconds (each starts on a fragment, so on a keyframe). */
++/**
++ * Fragments grouped into segments of at least `target` seconds (each starts on a fragment, so on a keyframe). A segment
++ * never spans two periods.
++ */
function segments(fragments, target = 6) {
const out = [];
let cur = null;
for (const f of fragments) {
-- if (cur && cur.offset + cur.length === f.offset && cur.duration < target) { cur.length += f.length; cur.duration += f.duration; }
-- else { cur = { offset: f.offset, length: f.length, start: f.start, duration: f.duration }; out.push(cur); }
++ const period = f.period || 0;
++ if (cur && cur.offset + cur.length === f.offset && cur.duration < target && cur.period === period) { cur.length += f.length; cur.duration += f.duration; }
++ else { cur = { offset: f.offset, length: f.length, start: f.start, duration: f.duration, period }; out.push(cur); }
}
return out;
}
++/** A period's init segment URI relative to the playlist: the file's own for the first, a rewritten moov after a change. */
++function initUri(index, period) {
++ const p = (index.periods || [])[period];
++ return p && p.offset != null ? `dvr/init-${p.offset}.mp4` : 'dvr/init.mp4';
++}
++
/**
* The EVENT playlist of a recording in progress: the init segment and every whole segment so far, as URIs relative to
-- * the playlist (dvr/init.mp4, dvr/<offset>-<length>.m4s). `ended` closes it (#EXT-X-ENDLIST).
++ * the playlist (dvr/init.mp4, dvr/<offset>-<length>.m4s). Each change of period is a discontinuity with that period's
++ * own init segment. `ended` closes it (#EXT-X-ENDLIST).
*/
function playlist(index, { target = 6, ended = false } = {}) {
const segs = segments(index.fragments, target);
const longest = segs.reduce((m, s) => Math.max(m, s.duration), 0);
const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${Math.max(target + 4, Math.ceil(longest))}`,
-- '#EXT-X-MEDIA-SEQUENCE:0', '#EXT-X-PLAYLIST-TYPE:EVENT', '#EXT-X-INDEPENDENT-SEGMENTS', `#EXT-X-MAP:URI="dvr/init.mp4"`];
-- for (const s of segs) lines.push(`#EXTINF:${s.duration.toFixed(3)},`, `dvr/${s.offset}-${s.length}.m4s`);
++ '#EXT-X-MEDIA-SEQUENCE:0', '#EXT-X-PLAYLIST-TYPE:EVENT', '#EXT-X-INDEPENDENT-SEGMENTS'];
++ let period = null;
++ for (const s of segs) {
++ if (s.period !== period) {
++ if (period !== null) lines.push('#EXT-X-DISCONTINUITY');
++ lines.push(`#EXT-X-MAP:URI="${initUri(index, s.period)}"`);
++ period = s.period;
++ }
++ lines.push(`#EXTINF:${s.duration.toFixed(3)},`, `dvr/${s.offset}-${s.length}.m4s`);
++ }
++ if (period === null) lines.push(`#EXT-X-MAP:URI="${initUri(index, 0)}"`);
if (ended) lines.push('#EXT-X-ENDLIST');
return `${lines.join('\n')}\n`;
}
++/** The init segment of the period that starts at fragment `offset` (the moov rewritten for its SPS), or null. */
++function periodInit(initBytes, index, offset) {
++ const p = (index.periods || []).find((x) => x.offset != null && x.offset === offset);
++ if (!p || !p.sps) return null;
++ return avc.initWith(initBytes, { sps: p.sps, pps: p.pps });
++}
++
/** Forget a recording's index (finalized, or its file replaced). */
function forget(filePath) { _states.delete(filePath); }
--module.exports = { refresh, segments, playlist, forget, _parseMoov: parseMoov, _parseMoof: parseMoof, MAX_FRAGMENT_BYTES };
++module.exports = { refresh, segments, playlist, periodInit, forget, _parseMoov: parseMoov, _parseMoof: parseMoof, MAX_FRAGMENT_BYTES };
ddiff --git a/test/dvr-shape-periods.test.js b/test/dvr-shape-periods.test.js
nnew file mode 100644
iindex 0000000..b4e57c7
---- /dev/null
++++ b/test/dvr-shape-periods.test.js
@@@ -0,0 +1,156 @@
++'use strict';
++// The live rewind across a phone turn (vod/avc-shape.js, vod/fmp4-index.js periods, /v/:id/dvr/init-<offset>.mp4), and
++// a live clip past the recorded edge (vod/clips-routes.js).
++//
++// Stream 2795 (2026-10-26) turned between 720x1280 and 1280x720. The recording is a stream copy whose moov names the
++// first size, and Chrome played every rewound landscape frame squeezed into portrait (1280x2276). Now:
++// - SPS parsing gives the picture size: baseline, high profile with cropping, emulation prevention bytes.
++// - A fragment whose keyframe SPS names another shape starts a period; a lowered resolution of the same shape does not;
++// indexing as the file grows finds the same periods.
++// - The playlist puts #EXT-X-DISCONTINUITY and the period's own init segment before it; that init (the moov rewritten
++// for the new SPS) plus the period's segment decodes at the new size. Unknown offsets are 404s.
++// - A clip of a recording asked for past the recorded edge keeps its length and ends at the edge (clip 505 was squeezed
++// to 1 s and failed).
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { spawnSync } = require('child_process');
++
++const avc = require('../server/vod/avc-shape');
++
++// ── SPS parsing (x264 output) ──
++const hex = (h) => Buffer.from(h, 'hex');
++const SPS_720x1280 = hex('6742c01fd900b40a1b0110000003001000000303c0f1832480');
++const SPS_1280x720 = hex('6742c01fd9005005bb0110000003001000000303c0f1832480');
++const SPS_1080_HIGH = hex('67640028acd940780227e5c044000003000400000300f03c60c658');
++const PPS_BASE = hex('68cb83cb20');
++assert.deepStrictEqual([avc.parseSps(SPS_720x1280).width, avc.parseSps(SPS_720x1280).height], [720, 1280]);
++assert.deepStrictEqual([avc.parseSps(SPS_1280x720).width, avc.parseSps(SPS_1280x720).height], [1280, 720]);
++const high = avc.parseSps(SPS_1080_HIGH);
++assert.deepStrictEqual([high.profile, high.width, high.height], [100, 1920, 1080], 'high profile, 1088 cropped to 1080');
++assert.throws(() => avc.parseSps(PPS_BASE), /not an SPS/);
++assert.ok(avc.sameShape({ width: 1280, height: 720 }, { width: 426, height: 240 }), 'a lowered resolution is the same shape');
++assert.ok(!avc.sameShape({ width: 720, height: 1280 }, { width: 1280, height: 720 }), 'a turn is not');
++
++// A length-prefixed sample: AUD, SPS, PPS, then a slice; parameter sets after the slice are not the keyframe's.
++const lp = (b) => { const l = Buffer.alloc(4); l.writeUInt32BE(b.length, 0); return Buffer.concat([l, b]); };
++const sample = Buffer.concat([lp(hex('0910')), lp(SPS_1280x720), lp(PPS_BASE), lp(hex('65888400')), lp(SPS_720x1280)]);
++const ps = avc.parameterSets(sample, 4);
++assert.ok(ps.sps.equals(SPS_1280x720) && ps.pps.equals(PPS_BASE), 'the SPS and PPS before the first slice');
++assert.deepStrictEqual(avc.parameterSets(sample.subarray(0, 30), 4), { sps: null, pps: null }, 'a cut-off read keeps the whole NALs only');
++
++(async () => {
++ const ff = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], { encoding: 'utf8' });
++ if (ff.status !== 0 || !/libx264/.test(ff.stdout) || spawnSync('ffprobe', ['-version']).status !== 0) {
++ console.log('dvr shape periods: SPS checks passed; recording checks skipped (ffmpeg with libx264 not found)');
++ process.exit(0);
++ }
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-dvr-shape-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ process.env.VOD_PATH = dir('vods');
++ process.env.CLIPS_PATH = dir('clips');
++ process.env.FILES_PATH = dir('files');
++ process.env.OBJECTS_PATH = dir('objects');
++ process.env.THUMBNAILS_PATH = dir('thumbnails');
++ process.env.PASTES_PATH = dir('pastes');
++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++ process.env.MEDIA_JOBS_POLL_MS = '60000';
++ const ffmpeg = (...args) => { const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args], { encoding: 'utf8' }); assert.strictEqual(r.status, 0, r.stderr); };
++ const probe = (file) => spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file], { encoding: 'utf8' }).stdout.trim();
++
++ // A phone stream as the recorder writes it: H.264 baseline copied into a fragmented MP4, a keyframe a second.
++ // Portrait 4 s, landscape 4 s, landscape at a lowered resolution 4 s (same shape), portrait again 4 s.
++ const shapes = ['144x256', '256x144', '128x72', '144x256'];
++ const parts = shapes.map((s, i) => {
++ const f = path.join(tmp, `p${i}.h264`);
++ ffmpeg('-f', 'lavfi', '-i', `testsrc=duration=4:size=${s}:rate=10`, '-c:v', 'libx264', '-profile:v', 'baseline', '-bf', '0', '-g', '10',
++ '-pix_fmt', 'yuv420p', '-bsf:v', 'h264_mp4toannexb', '-f', 'h264', f);
++ return fs.readFileSync(f);
++ });
++ const annexb = path.join(tmp, 'all.h264');
++ fs.writeFileSync(annexb, Buffer.concat(parts));
++ const full = path.join(tmp, 'turn.mp4');
++ ffmpeg('-r', '10', '-f', 'h264', '-i', annexb, '-c:v', 'copy', '-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', full);
++ const bytes = fs.readFileSync(full);
++
++ const fmp4 = require('../server/vod/fmp4-index');
++ const whole = await fmp4.refresh(full);
++ assert.deepStrictEqual(whole.periods.map((p) => [p.width, p.height]), [[144, 256], [256, 144], [144, 256]],
++ 'portrait, landscape (its lowered resolution included), portrait again');
++ assert.strictEqual(whole.periods[0].offset, null, 'the first period is the file\'s own init');
++ assert.ok(Math.abs(whole.periods[1].start - 4) < 0.2 && Math.abs(whole.periods[2].start - 12) < 0.2, 'periods start at the turns');
++ for (const f of whole.fragments) {
++ const expect = f.start < 3.9 ? 0 : f.start < 11.9 ? 1 : 2;
++ assert.strictEqual(f.period, expect, `fragment at ${f.start}s is in period ${expect}`);
++ }
++
++ // Growing: the same periods when the file is read a piece at a time.
++ const growing = path.join(process.env.VOD_PATH, 'vod-live-9.mp4');
++ const cut = whole.fragments[7].offset + 100;
++ fs.writeFileSync(growing, bytes.subarray(0, cut));
++ const half = await fmp4.refresh(growing);
++ assert.strictEqual(half.periods.length, 2, 'two periods so far');
++ fs.appendFileSync(growing, bytes.subarray(cut));
++ const grown = await fmp4.refresh(growing);
++ assert.deepStrictEqual(grown.periods.map((p) => [p.offset, p.width, p.height]), whole.periods.map((p) => [p.offset, p.width, p.height]));
++
++ // The playlist: a discontinuity and the period's init before each change; no segment spans one.
++ const m3u8 = fmp4.playlist(whole, { ended: true });
++ const lines = m3u8.trim().split('\n');
++ assert.strictEqual(lines.filter((l) => l === '#EXT-X-DISCONTINUITY').length, 2);
++ assert.deepStrictEqual(lines.filter((l) => l.startsWith('#EXT-X-MAP')), ['#EXT-X-MAP:URI="dvr/init.mp4"',
++ `#EXT-X-MAP:URI="dvr/init-${whole.periods[1].offset}.mp4"`, `#EXT-X-MAP:URI="dvr/init-${whole.periods[2].offset}.mp4"`]);
++ const segs = fmp4.segments(whole.fragments, 6);
++ for (const s of segs) {
++ const inside = whole.fragments.filter((f) => f.offset >= s.offset && f.offset < s.offset + s.length);
++ assert.ok(inside.every((f) => f.period === s.period), 'a segment stays in its period');
++ }
++ // Before any segment: the first period's init.
++ assert.ok(fmp4.playlist({ fragments: [], periods: [{ offset: null }] }).includes('#EXT-X-MAP:URI="dvr/init.mp4"'));
++
++ // The period's init + its first segment decode at the period's size; the file's own init names the first size.
++ const init = fmp4.periodInit(bytes.subarray(0, whole.initEnd), whole, whole.periods[1].offset);
++ const seg = segs.find((s) => s.offset === whole.periods[1].offset);
++ const landscape = path.join(tmp, 'landscape.mp4');
++ fs.writeFileSync(landscape, Buffer.concat([init, bytes.subarray(seg.offset, seg.offset + seg.length)]));
++ assert.strictEqual(probe(landscape), '256,144', 'the rewritten init describes the landscape period');
++ assert.strictEqual(spawnSync('ffmpeg', ['-v', 'error', '-i', landscape, '-f', 'null', '-']).status, 0, 'and it decodes');
++ assert.strictEqual(fmp4.periodInit(bytes.subarray(0, whole.initEnd), whole, 12345), null, 'no period at that offset');
++
++ // The routes.
++ const db = require('../server/db/database');
++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++ await db.run(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE
++ VALUES (9, 'live', 5, 'Turning', ?, 1, 'public', 1)`, [growing]);
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/', require('../server/public/routes'));
++ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = async (p) => { const r = await fetch(base + p); return { status: r.status, body: Buffer.from(await r.arrayBuffer()), type: r.headers.get('content-type') }; };
++ const pl = await get('/v/9/dvr.m3u8');
++ assert.ok(pl.body.toString().includes(`dvr/init-${whole.periods[1].offset}.mp4`), 'the served playlist names the period inits');
++ const served = await get(`/v/9/dvr/init-${whole.periods[1].offset}.mp4`);
++ assert.strictEqual(served.status, 200);
++ assert.strictEqual(served.type, 'video/mp4');
++ assert.ok(served.body.equals(init), 'the route serves the rewritten init');
++ assert.strictEqual((await get('/v/9/dvr/init-12345.mp4')).status, 404, 'an offset that starts no period');
++
++ // A clip of the last 3 s asked for past the recorded edge (the file ends at 16 s): 3 s ending at the edge.
++ const clip = await fetch(`${base}/api/v1/live/clips`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' },
++ body: JSON.stringify({ vod_id: 9, start_s: 40, end_s: 43, user_id: 5 }) });
++ assert.ok(clip.status === 201 || clip.status === 202, `clip created (${clip.status})`);
++ const id = (await clip.json()).id;
++ const row = await db.get('SELECT start_time, end_time, duration_seconds FROM clips WHERE id = ?', [id]);
++ assert.ok(Math.abs(row.end_time - 15.5) < 0.2, `ends at the recorded edge (${row.end_time})`);
++ assert.ok(Math.abs(row.end_time - row.start_time - 3) < 0.01, `keeps its 3 s (${row.start_time}–${row.end_time})`);
++
++ server.close();
++ console.log('dvr shape periods: ok');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });