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)
Changes
@@@ -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 areappended), so Live's DVR seeks with no second copy; `VOD_DVR_SNAPSHOT_MP4=0` then stops the per-pass copy of MP4recordings. 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 forlive segments and its upload-lag metric, captions on the timeline, and Live's player moving to HLS. Segment-bucketdemand (F2.4) now records reads per timeline segment; deciding placement per segment stays F5's. Renditions are F4.@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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.singif (!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.' });@@@ -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 writesconst 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_offsetif (flags & 0x02) p += 4; // sample_description_indexlet 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_offsetif (tf & 0x004) q += 4; // first_sample_flagsconst 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 };nnew file mode 100644@@@ -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); });
@@ -243,6 +243,13 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
@@ -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
(`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
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).
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
**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
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.
demand (F2.4) now records reads per timeline segment; deciding placement per segment stays F5's. Renditions are F4.
@@ -251,10 +251,12 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
@@ -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) ──
// ── 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
// 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
// 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
// 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.
// 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_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;
const DVR_SEGMENT_MAX_BYTES = 64 * 1024 * 1024;
async function dvrSource(req, res) {
async function dvrSource(req, res) {
if (drill.refuseBytes(res)) return null;
if (drill.refuseBytes(res)) return null;
@@ -287,6 +289,18 @@ router.get('/v/:id/dvr/init.mp4', dvrRoute(async (req, res, { filePath, index })
@@ -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 });
sendFileStream(res, filePath, { start: 0, end: index.initEnd - 1 });
}));
}));
router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index }) => {
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 m = DVR_SEGMENT.exec(String(req.params.segment || ''));
const last = index.fragments[index.fragments.length - 1];
const last = index.fragments[index.fragments.length - 1];
const offset = m ? Number(m[1]) : NaN;
const offset = m ? Number(m[1]) : NaN;
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -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 };
@@ -148,6 +148,22 @@ const flag = (v) => (['1', 'true'].includes(String(v)) ? 1 : ['0', 'false'].incl
@@ -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).
// 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) —
// Multipart `video`: direct upload of an already-cut blob (201, ready) —
// inherited browser-MediaRecorder clip path.
// 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) => {
router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.single('video'), async (req, res) => {
try {
try {
const body = req.body || {};
const body = req.body || {};
@@ -190,14 +206,17 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
@@ -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' });
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
// 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') {
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) {
if (recordedDur > 0) {
const safeEdge = Math.max(0, recordedDur - 0.5);
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;
duration = endTime - startTime;
if (duration < 1) {
if (duration < 1) {
return res.status(422).json({ error: 'Not enough recorded footage yet — try again in a moment.' });
return res.status(422).json({ error: 'Not enough recorded footage yet — try again in a moment.' });
@@ -13,15 +13,26 @@
@@ -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
* 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
* 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.
* 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 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_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 MAX_MOOV_BYTES = 4 * 1024 * 1024;
const _states = new Map(); // file path → index state (one per recording; dropped by forget())
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() {
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);
const u32 = (b, o) => b.readUInt32BE(o);
@@ -85,28 +96,66 @@ function parseMoof(buf, tracks) {
@@ -85,28 +96,66 @@ function parseMoof(buf, tracks) {
const flags = u32(buf, tfhd.body) & 0xffffff;
const flags = u32(buf, tfhd.body) & 0xffffff;
const id = u32(buf, tfhd.body + 4);
const id = u32(buf, tfhd.body + 4);
let p = tfhd.body + 8;
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 & 0x01) p += 8; // base_data_offset
if (flags & 0x02) p += 4; // sample_description_index
if (flags & 0x02) p += 4; // sample_description_index
let defaultDuration = (tracks.get(id) || {}).defaultDuration || 0;
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 tfdt = child(buf, traf, 'tfdt');
const start = tfdt ? (buf[tfdt.body] === 1 ? u64(buf, tfdt.body + 4) : u32(buf, tfdt.body + 4)) : null;
const start = tfdt ? (buf[tfdt.body] === 1 ? u64(buf, tfdt.body + 4) : u32(buf, tfdt.body + 4)) : null;
let duration = 0;
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')) {
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 tf = u32(buf, trun.body) & 0xffffff;
const count = u32(buf, trun.body + 4);
const count = u32(buf, trun.body + 4);
let q = trun.body + 8;
let q = trun.body + 8;
+ const dataOffset = (tf & 0x001) ? buf.readInt32BE(q) : null;
if (tf & 0x001) q += 4; // data_offset
if (tf & 0x001) q += 4; // data_offset
if (tf & 0x004) q += 4; // first_sample_flags
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);
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); }
if (tf & 0x100) { for (let i = 0; i < count; i++) duration += u32(buf, q + i * per); }
else duration += count * defaultDuration;
else duration += count * defaultDuration;
}
}
- out.set(id, { start, duration });
+ out.set(id, { start, duration, first, baseOffset });
}
}
return out;
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) {
async function readAt(fh, offset, length) {
const buf = Buffer.alloc(length);
const buf = Buffer.alloc(length);
const { bytesRead } = await fh.read(buf, 0, length, offset);
const { bytesRead } = await fh.read(buf, 0, length, offset);
@@ -140,6 +189,8 @@ async function refresh(filePath) {
@@ -140,6 +189,8 @@ async function refresh(filePath) {
st.tracks = parseMoov(body);
st.tracks = parseMoov(body);
for (const [id, t] of st.tracks) if (t.handler === 'vide' && st.videoId == null) st.videoId = id;
for (const [id, t] of st.tracks) if (t.handler === 'vide' && st.videoId == null) st.videoId = id;
st.initEnd = st.offset + boxSize;
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') {
} else if (type === 'moof') {
if (!st.initEnd) { st.broken = 'moof before moov'; break; }
if (!st.initEnd) { st.broken = 'moof before moov'; break; }
const body = await readAt(fh, st.offset + header, boxSize - header);
const body = await readAt(fh, st.offset + header, boxSize - header);
@@ -152,7 +203,8 @@ async function refresh(filePath) {
@@ -152,7 +203,8 @@ async function refresh(filePath) {
const track = st.tracks.get(id);
const track = st.tracks.get(id);
if (t && track && track.timescale && length <= MAX_FRAGMENT_BYTES) {
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);
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;
st.pendingMoof = null;
} else if (type === 'ftyp' || type === 'free' || type === 'skip' || type === 'styp' || type === 'sidx' || type === 'mfra' || type === 'prft') {
} else if (type === 'ftyp' || type === 'free' || type === 'skip' || type === 'styp' || type === 'sidx' || type === 'mfra' || type === 'prft') {
@@ -162,38 +214,65 @@ async function refresh(filePath) {
@@ -162,38 +214,65 @@ async function refresh(filePath) {
}
}
st.offset += boxSize;
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 {
} finally {
await fh.close();
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) {
function segments(fragments, target = 6) {
const out = [];
const out = [];
let cur = null;
let cur = null;
for (const f of fragments) {
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;
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 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 } = {}) {
function playlist(index, { target = 6, ended = false } = {}) {
const segs = segments(index.fragments, target);
const segs = segments(index.fragments, target);
const longest = segs.reduce((m, s) => Math.max(m, s.duration), 0);
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))}`,
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');
if (ended) lines.push('#EXT-X-ENDLIST');
return `${lines.join('\n')}\n`;
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). */
/** Forget a recording's index (finalized, or its file replaced). */
function forget(filePath) { _states.delete(filePath); }
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -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); });