Học từ Facebook #60: Đoán trước segment live và đọc hộp sidx
oz-player xin segment live kế tiếp bằng URL dự đoán thay vì chờ manifest cập nhật, và đọc byte-range từng segment từ hộp sidx của MP4. Kèm một lỗi DataView.
Video trên web hiện đại ít khi là một file .mp4 tải từ đầu đến cuối. Nó được cắt thành nhiều segment vài giây, và một file manifest (MPD của chuẩn DASH, hoặc playlist .m3u8 của HLS) cho trình phát biết segment nào nằm ở đâu. Với video phát trực tiếp (live), manifest phải được tải lại liên tục để biết segment mới nhất.
Hai bài trước (#58, #59) chọn bậc chất lượng. Bài này nói về câu hỏi "tải segment nào, ở byte nào", qua hai mẹo trong oz-player của Facebook.
Tip
Tóm tắt
- Với live, manifest của Facebook có thêm thuộc tính
FBPredictedMedia(template URL cho segment chưa tồn tại) vàFBPredictedMediaEndNumber(số của segment mới nhất). Trình phát xin luôn segmentn + 1mà không chờ manifest kế tiếp; server giữ request tới khi segment xong. - Manifest chỉ giữ vài segment gần nhất; thời lượng trung bình
FBAverageDurationcho phép ước lượng số thứ tự của segment cũ hơn cửa sổ đó. - Với video theo yêu cầu (VOD), trình phát đọc hộp
sidxtrong file MP4 để biết byte-range của từng segment, rồi tải bằng headerRange. - Bộ đọc sidx gốc tạo
new DataView(u8.buffer), bỏ quabyteOffsetcủa view. Truyền vào mộtsubarraylà đọc sai chỗ. Bản viết lại dùngnew DataView(buf, byteOffset, byteLength).
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Facebook làm thế nào?
#Manifest live có thêm "segment dự đoán"
Một SegmentTimeline DASH chuẩn liệt kê các segment đã có bằng thẻ <S t="…" d="…"/> (thời điểm bắt đầu, thời lượng). Module OzPredictedSegmentTimelineParser (bundle 26.VideoPlayer/all.js, bản trùng ở 21.js) đọc thêm bốn thuộc tính riêng của Facebook trên thẻ SegmentTimeline:
// đổi tên biến
const attrs = template.SegmentTimeline[0].$;
let predictedUrl;
if (attrs.FBPredictedMedia) {
predictedUrl = new URI(joinUrlPaths(rep.baseUrl, attrs.FBPredictedMedia)).addQueryData('_nc_sc', 1).toString();
}
const startNumber = attrs.FBPredictedMediaStartNumber && Number.parseInt(attrs.FBPredictedMediaStartNumber, 0);
const endNumber = attrs.FBPredictedMediaEndNumber && Number.parseInt(attrs.FBPredictedMediaEndNumber, 0);
const avgDuration = attrs.FBAverageDuration && Number.parseInt(attrs.FBAverageDuration, 0);
const segments = OzDefaultSegmentTimelineParser.parseSegments(config, template);
return ((rep.isTemplatedMpd && startNumber != null && avgDuration != null) || !rep.isTemplatedMpd)
&& predictedUrl != null && endNumber != null
? new PredictedSegmentTemplateSegmentsContainer(config, mediaUrl, timescale, segments, predictedUrl, startNumber, endNumber, avgDuration, rep.isStaticMpd)
: new SegmentTemplateSegmentsContainer(config, mediaUrl, timescale, segments, predictedUrl, rep.isStaticMpd);
js
Thiếu thuộc tính nào thì rơi về container thường. Manifest cũ, hoặc CDN đã cắt thuộc tính lạ, vẫn phát được.
parseInt(x, 0) là chi tiết nhỏ: radix 0 được đặc tả coi như "không truyền", tức hệ 10, trừ khi chuỗi bắt đầu bằng 0x thì thành hệ 16. Chạy đúng với số thường, nhưng dòng timescale ngay bên dưới lại dùng radix 10, nên nhiều khả năng đây là lỗi gõ.
#Đánh số từ cuối
PredictedSegmentTemplateSegmentsContainer không đánh số từ đầu timeline mà từ cuối: entry cuối mang số endNumber, entry trước nó endNumber − 1, và cứ thế lùi. Manifest live chỉ giữ cửa sổ vài segment gần nhất, nên "segment cuối là số mấy" là thông tin chắc chắn nhất.
getSegmentByTime(sec) { // nhánh thường
const x = sec * this.timescale;
let found = null, fromEnd = 0;
for (let i = 0; i < this.segments.length; i++) {
const s = this.segments[this.segments.length - 1 - i];
invariant(s.r === 0); // không hỗ trợ @r
if (lessThanOrEqual(s.t + s.d * (s.r + 1), x, epsilon)) break;
found = s; fromEnd = i;
}
return found && this.makeSegment(found.t / ts, (found.t + found.d) / ts, this.endNumber - fromEnd);
}
getPredictedSegmentAfter(segment) {
return segment?.getSequenceNumber() != null && this.canPredict() // num_predictive_segments > 0
? this.makeSegment(0, 0, segment.getSequenceNumber() + 1)
: null;
}
js
makeSegment thay $Number$ trong template dự đoán. Segment dự đoán có khoảng thời gian 0–0 vì chưa ai biết nó dài bao nhiêu; trình phát chỉ cần URL để xin nó, thời gian thật sẽ biết khi nhận được dữ liệu hoặc khi manifest sau liệt kê nó.
Khi bật cờ pdash_seq_based_approx, container còn trả lời được thời điểm trước cửa sổ timeline. Số segment trước cửa sổ là endNumber − số entry + 1 − startNumber, mỗi cái dài khoảng FBAverageDuration, nên khoảng thời gian manifest phủ bắt đầu từ:
start = (segments[0].t - avgDuration - (endNumber - segments.length - startNumber) * avgDuration) / timescale
js
Người xem kéo ngược về 5 phút trước trong một buổi live, trình phát vẫn đoán được số segment cần xin mà manifest không phải liệt kê hàng trăm entry.
#Vì sao đoán URL lại nhanh hơn
Không có URL dự đoán, trình phát phải: chờ tới lượt tải lại manifest, tải manifest, thấy segment mới, rồi mới xin segment. Độ trễ trung bình cỡ nửa chu kỳ tải lại manifest cộng hai vòng mạng. Có URL dự đoán thì trình phát xin n + 1 ngay khi nhận xong n; server (hoặc CDN) giữ request tới khi segment được đóng gói xong rồi trả về. Trong demo, độ trễ trung bình giảm từ khoảng 2 giây xuống bằng một vòng mạng.
Tham số _nc_sc=1 gắn vào URL dự đoán, và cờ touch_cb_key thêm hậu tố _oz vào tham số cb. Có lẽ để CDN và log phân biệt request dự đoán với request thường; bundle không cho biết thêm.
sequenceDiagram participant P as Trình phát participant C as CDN / server P->>C: GET manifest C-->>P: timeline tới #41, FBPredictedMedia, EndNumber=41 P->>C: GET v-41.m4s C-->>P: segment 41 P->>C: GET v-42.m4s?_nc_sc=1 (chưa có) Note over C: giữ request tới khi #42 đóng gói xong C-->>P: segment 42 P->>C: GET v-43.m4s?_nc_sc=1 Note over P: chỉ tải lại manifest khi cần, ví dụ đổi bậc chất lượng
#Hộp sidx: mục lục nằm ngay trong file
Với VOD, Facebook có thể phục vụ mỗi bậc chất lượng là một file MP4 duy nhất. Đầu file có hộp sidx (Segment Index Box, ISO/IEC 14496-12): một bảng liệt kê từng đoạn với kích thước byte và thời lượng. Trình phát tải phần đầu file, đọc sidx, rồi xin từng đoạn bằng Range: bytes=…. Không cần manifest liệt kê từng segment, và CDN chỉ phải lưu một file.
oz-player/parsers/OzMp4SidxParser:
const SIDX = 1936286840; // 0x73696478 = 'sidx'
function parse(url, sidxOffset, u8) {
const r = new OzDataViewReader(new DataView(u8.buffer)); // ← bỏ qua u8.byteOffset
let size = r.readUint32();
if (r.readUint32() !== SIDX) throw new Error('Invalid box type, expected "sidx".');
if (size == 1) size = r.readUint64(); // largesize
const version = r.readUint8(); r.skip(3); r.skip(4); // flags, reference_ID
const timescale = r.readUint32();
let t, firstOffset;
version == 0 ? (t = r.readUint32(), firstOffset = r.readUint32())
: (t = r.readUint64(), firstOffset = r.readUint64());
r.skip(2);
const count = r.readUint16();
let byte = sidxOffset + size + firstOffset, first = 0;
for (let i = 0; i < count; i++) {
const a = r.readUint32();
const isIndex = (a & 0x80000000) >>> 31, refSize = a & 0x7fffffff;
const duration = r.readUint32();
r.readUint32(); // SAP
if (isIndex == 1) throw new Error('Heirarchical SIDXs are not supported.');
if (i === 0) first = t / timescale;
segments.push({ time: [t / timescale - first, (t + duration) / timescale - first], bytes: [byte, byte + refSize - 1] });
t += duration; byte += refSize;
}
}
js
Điểm cần biết khi đọc:
- Bit cao nhất của mỗi entry là
reference_type: 0 là đoạn media, 1 là trỏ tới một sidx khác (sidx phân cấp, dùng cho file rất dài). Bản gốc không hỗ trợ loại 1 và ném lỗi (chữ "Heirarchical" viết sai chính tả). - Byte bắt đầu = vị trí hộp sidx + kích thước hộp +
first_offset. Theo chuẩn,first_offsettính từ byte ngay sau hộp sidx. - Thời gian bị trừ đi mốc của đoạn đầu, nên đoạn đầu luôn bắt đầu ở 0 giây.
Lỗi thật nằm ở dòng đầu: new DataView(u8.buffer) tạo view trên toàn bộ ArrayBuffer bên dưới. Nếu u8 là subarray (ví dụ trình phát tải một lần cả moov lẫn sidx rồi cắt lấy phần sidx), DataView sẽ đọc từ byte 0 của buffer lớn, gặp ftyp thay vì sidx. Phần "Hộp sidx" trong demo tái hiện đúng điều đó: đọc kiểu gốc báo "Không phải hộp sidx". Có lẽ ở Facebook phần sidx luôn được tải bằng một request riêng nên buffer bắt đầu đúng chỗ và lỗi không lộ ra.
#Bên trong trình duyệt
fetchvớiRange:fetch(url, { headers: { Range: 'bytes=1304-62170' } })trả206 Partial Content. Server và CDN phải hỗ trợ range; CORS khác origin cầnAccess-Control-Expose-Headers: Content-Rangenếu muốn đọc header đó.ArrayBuffer, typed array vàDataView: mộtUint8Arraylà một "cửa sổ" trên ArrayBuffer, cóbyteOffsetvàbyteLengthriêng.subarray()tạo cửa sổ mới không sao chép;slice()thì sao chép. Code nhậnUint8Arraymà dùng.bufferthì phải dùng kèm offset và length của nó.- Big-endian: số trong hộp MP4 lưu theo thứ tự byte lớn trước.
DataView.getUint32(offset)mặc định là big-endian, đúng với MP4; typed array nhưUint32Arraythì dùng thứ tự của máy (thường little-endian), không dùng được để đọc MP4. - Số 64-bit:
getBigUint64trảBigInt. Ghép haigetUint32thànhNumberthì chỉ an toàn tới 2^53; thời gian và byte của video thực tế còn xa giới hạn đó, nhưng nên kiểm tra. - MSE: segment tải về được đưa vào
SourceBuffer.appendBuffer(). Segment dự đoán cũng vậy; MSE không quan tâm segment được xin bằng URL nào.
#Viết lại
Template URL hỗ trợ $Number$, $Number%05d$ và $Time$. Timeline được trải @r ra và đánh số từ cuối như bản gốc:
src/lib/dashSegments.ts
export const fillTemplate = (template: string, number: number, time?: number) =>
template
.replace(/\$Number(?:%0(\d+)d)?\$/g, (_, w) => String(number).padStart(Number(w ?? 0), '0'))
.replace(/\$Time\$/g, String(time ?? ''));
/** Trải @r ra thành từng segment, đánh số sao cho entry cuối mang số endNumber. */
function expand(info: PredictedTimelineInfo) {
const flat: { t: number; d: number }[] = [];
for (const e of info.entries) for (let i = 0; i <= (e.r ?? 0); i++) flat.push({ t: e.t + i * e.d, d: e.d });
const last = info.endNumber ?? flat.length;
return flat.map((s, i) => ({ ...s, number: last - (flat.length - 1 - i) }));
}
ts
Khoảng thời gian manifest phủ, tìm segment theo thời gian (kể cả trước cửa sổ), và đoán segment kế tiếp:
src/lib/dashSegments.ts
/** Khoảng thời gian manifest này phủ, tính cả phần trước cửa sổ timeline nếu biết startNumber. */
timeRange(): { start: number; end: number } | null {
if (this.flat.length === 0) return null;
const ts = this.info.timescale;
const first = this.flat[0];
const last = this.flat[this.flat.length - 1];
const { startNumber, averageDuration } = this.info;
const before = startNumber != null && averageDuration ? Math.max(0, first.number - startNumber) * averageDuration : 0;
return { start: (first.t - before) / ts, end: (last.t + last.d) / ts };
}
/** Segment chứa thời điểm `sec`. Trước cửa sổ timeline thì ước lượng số theo thời lượng trung bình. */
segmentAt(sec: number): SegmentRef | null {
const ts = this.info.timescale;
const x = sec * ts;
const hit = this.flat.find((s) => s.t <= x && x < s.t + s.d);
if (hit) return this.ref(hit);
const range = this.timeRange();
const { startNumber, averageDuration } = this.info;
if (!range || sec < range.start || sec >= range.end || startNumber == null || !averageDuration) return null;
const first = this.flat[0];
const back = Math.ceil((first.t - x) / averageDuration);
const number = Math.max(startNumber, first.number - back);
const start = (first.t - (first.number - number) * averageDuration) / ts;
return { number, url: fillTemplate(this.info.media, number), start, end: start + averageDuration / ts, predicted: false };
}
/** `count` segment SAU segment số `after`, lấy URL từ template dự đoán. */
predictAfter(after: number, count = 1): SegmentRef[] {
if (!this.canPredict) return [];
const out: SegmentRef[] = [];
for (let n = after + 1; n <= after + count; n++)
out.push({ number: n, url: fillTemplate(this.info.predictedMedia!, n), start: NaN, end: NaN, predicted: true });
return out;
}
ts
Đọc sidx với DataView đúng offset, giữ lại tham chiếu loại "index" thay vì ném lỗi:
src/lib/dashSegments.ts
const SIDX = 0x73696478; // 'sidx'
/**
* Đọc hộp sidx. `bytes` có thể là view nằm giữa một ArrayBuffer lớn hơn:
* DataView phải dùng đúng byteOffset/byteLength của view (bản gốc dùng `new DataView(u8.buffer)`).
* `boxOffset` là vị trí của hộp sidx trong file, để tính byte tuyệt đối.
*/
export function parseSidx(bytes: Uint8Array, boxOffset: number): { timescale: number; references: SidxReference[] } {
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let p = 0;
const u32 = () => ((p += 4), v.getUint32(p - 4));
const u64 = () => {
const hi = u32();
const lo = u32();
const n = hi * 2 ** 32 + lo;
if (!Number.isSafeInteger(n)) throw new Error('Giá trị 64-bit vượt Number.MAX_SAFE_INTEGER');
return n;
};
let size = u32();
if (u32() !== SIDX) throw new Error('Không phải hộp sidx');
if (size === 1) size = u64(); // largesize
const version = v.getUint8(p);
p += 4; // version + flags
p += 4; // reference_ID
const timescale = u32();
if (!timescale) throw new Error('timescale = 0');
let time = version === 0 ? u32() : u64(); // earliest_presentation_time
const firstOffset = version === 0 ? u32() : u64();
p += 2; // reserved
const count = v.getUint16(p);
p += 2;
let byte = boxOffset + size + firstOffset; // mốc: byte ngay sau hộp sidx
const references: SidxReference[] = [];
for (let i = 0; i < count; i++) {
const a = u32();
const duration = u32();
const sap = u32();
const refSize = a & 0x7fffffff;
references.push({
kind: a >>> 31 ? 'index' : 'media',
startByte: byte,
endByte: byte + refSize - 1,
start: time / timescale,
end: (time + duration) / timescale,
startsWithSap: sap >>> 31 === 1,
});
time += duration;
byte += refSize;
}
return { timescale, references };
}
ts
Xem toàn bộ src/lib/dashSegments.ts
src/lib/dashSegments.ts
/**
* Hai cách tìm segment DASH mà không cần manifest liệt kê hết:
* 1. SegmentTimeline kèm "predicted media" (kiểu oz-player của Facebook): đoán URL segment kế tiếp
* của luồng live trước khi manifest kịp cập nhật.
* 2. Hộp sidx của MP4 (ISO-BMFF): đọc bảng byte-range/thời gian của từng segment từ chính file.
* Viết lại từ đầu.
*/
export interface TimelineEntry {
/** Thời điểm bắt đầu (đơn vị timescale). */
t: number;
/** Thời lượng (đơn vị timescale). */
d: number;
/** Số lần lặp thêm (DASH @r). Bản gốc yêu cầu r = 0. */
r?: number;
}
export interface PredictedTimelineInfo {
timescale: number;
/** Template URL của segment đã có, ví dụ "seg-$Number$.m4s". */
media: string;
entries: TimelineEntry[];
/** Template URL cho segment CHƯA có trong manifest (FBPredictedMedia). */
predictedMedia?: string;
/** Số thứ tự segment đầu tiên còn trên server (FBPredictedMediaStartNumber). */
startNumber?: number;
/** Số thứ tự của entry cuối trong timeline (FBPredictedMediaEndNumber). */
endNumber?: number;
/** Thời lượng trung bình một segment, đơn vị timescale (FBAverageDuration). */
averageDuration?: number;
}
export interface SegmentRef {
number: number | null;
url: string;
/** Giây. Segment dự đoán chưa biết thời lượng thì start = end = NaN. */
start: number;
end: number;
predicted: boolean;
}
const int = (v: string | null | undefined) => (v == null || v === '' ? undefined : Number.parseInt(v, 10));
/** Đọc <SegmentTemplate> chứa <SegmentTimeline> từ chuỗi XML (cần DOMParser: trình duyệt hoặc jsdom). */
export function parseSegmentTemplate(xml: string): PredictedTimelineInfo {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const tpl = doc.getElementsByTagName('SegmentTemplate')[0];
const tl = tpl?.getElementsByTagName('SegmentTimeline')[0];
if (!tpl || !tl) throw new Error('Thiếu SegmentTemplate/SegmentTimeline');
const entries: TimelineEntry[] = [];
let next = 0;
for (const s of Array.from(tl.getElementsByTagName('S'))) {
const t = int(s.getAttribute('t')) ?? next; // @t vắng mặt: nối tiếp entry trước
const d = int(s.getAttribute('d'))!;
const r = int(s.getAttribute('r')) ?? 0;
entries.push({ t, d, r });
next = t + d * (r + 1);
}
return {
timescale: int(tpl.getAttribute('timescale')) ?? 1,
media: tpl.getAttribute('media') ?? '',
entries,
predictedMedia: tl.getAttribute('FBPredictedMedia') ?? undefined,
startNumber: int(tl.getAttribute('FBPredictedMediaStartNumber')),
endNumber: int(tl.getAttribute('FBPredictedMediaEndNumber')),
averageDuration: int(tl.getAttribute('FBAverageDuration')),
};
}
export const fillTemplate = (template: string, number: number, time?: number) =>
template
.replace(/\$Number(?:%0(\d+)d)?\$/g, (_, w) => String(number).padStart(Number(w ?? 0), '0'))
.replace(/\$Time\$/g, String(time ?? ''));
/** Trải @r ra thành từng segment, đánh số sao cho entry cuối mang số endNumber. */
function expand(info: PredictedTimelineInfo) {
const flat: { t: number; d: number }[] = [];
for (const e of info.entries) for (let i = 0; i <= (e.r ?? 0); i++) flat.push({ t: e.t + i * e.d, d: e.d });
const last = info.endNumber ?? flat.length;
return flat.map((s, i) => ({ ...s, number: last - (flat.length - 1 - i) }));
}
export class PredictedTimeline {
private readonly flat: { t: number; d: number; number: number }[];
constructor(private readonly info: PredictedTimelineInfo) {
this.flat = expand(info);
}
get canPredict(): boolean {
return !!this.info.predictedMedia && this.info.endNumber != null;
}
private ref(s: { t: number; d: number; number: number }): SegmentRef {
const ts = this.info.timescale;
return { number: s.number, url: fillTemplate(this.info.media, s.number, s.t), start: s.t / ts, end: (s.t + s.d) / ts, predicted: false };
}
/** Khoảng thời gian manifest này phủ, tính cả phần trước cửa sổ timeline nếu biết startNumber. */
timeRange(): { start: number; end: number } | null {
if (this.flat.length === 0) return null;
const ts = this.info.timescale;
const first = this.flat[0];
const last = this.flat[this.flat.length - 1];
const { startNumber, averageDuration } = this.info;
const before = startNumber != null && averageDuration ? Math.max(0, first.number - startNumber) * averageDuration : 0;
return { start: (first.t - before) / ts, end: (last.t + last.d) / ts };
}
/** Segment chứa thời điểm `sec`. Trước cửa sổ timeline thì ước lượng số theo thời lượng trung bình. */
segmentAt(sec: number): SegmentRef | null {
const ts = this.info.timescale;
const x = sec * ts;
const hit = this.flat.find((s) => s.t <= x && x < s.t + s.d);
if (hit) return this.ref(hit);
const range = this.timeRange();
const { startNumber, averageDuration } = this.info;
if (!range || sec < range.start || sec >= range.end || startNumber == null || !averageDuration) return null;
const first = this.flat[0];
const back = Math.ceil((first.t - x) / averageDuration);
const number = Math.max(startNumber, first.number - back);
const start = (first.t - (first.number - number) * averageDuration) / ts;
return { number, url: fillTemplate(this.info.media, number), start, end: start + averageDuration / ts, predicted: false };
}
/** `count` segment SAU segment số `after`, lấy URL từ template dự đoán. */
predictAfter(after: number, count = 1): SegmentRef[] {
if (!this.canPredict) return [];
const out: SegmentRef[] = [];
for (let n = after + 1; n <= after + count; n++)
out.push({ number: n, url: fillTemplate(this.info.predictedMedia!, n), start: NaN, end: NaN, predicted: true });
return out;
}
last(): SegmentRef | null {
return this.flat.length ? this.ref(this.flat[this.flat.length - 1]) : null;
}
}
// ---------------------------------------------------------------- sidx
export interface SidxReference {
/** 'media' = segment thật; 'index' = trỏ tới một sidx khác (sidx phân cấp). */
kind: 'media' | 'index';
startByte: number;
endByte: number;
start: number;
end: number;
startsWithSap: boolean;
}
const SIDX = 0x73696478; // 'sidx'
/**
* Đọc hộp sidx. `bytes` có thể là view nằm giữa một ArrayBuffer lớn hơn:
* DataView phải dùng đúng byteOffset/byteLength của view (bản gốc dùng `new DataView(u8.buffer)`).
* `boxOffset` là vị trí của hộp sidx trong file, để tính byte tuyệt đối.
*/
export function parseSidx(bytes: Uint8Array, boxOffset: number): { timescale: number; references: SidxReference[] } {
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let p = 0;
const u32 = () => ((p += 4), v.getUint32(p - 4));
const u64 = () => {
const hi = u32();
const lo = u32();
const n = hi * 2 ** 32 + lo;
if (!Number.isSafeInteger(n)) throw new Error('Giá trị 64-bit vượt Number.MAX_SAFE_INTEGER');
return n;
};
let size = u32();
if (u32() !== SIDX) throw new Error('Không phải hộp sidx');
if (size === 1) size = u64(); // largesize
const version = v.getUint8(p);
p += 4; // version + flags
p += 4; // reference_ID
const timescale = u32();
if (!timescale) throw new Error('timescale = 0');
let time = version === 0 ? u32() : u64(); // earliest_presentation_time
const firstOffset = version === 0 ? u32() : u64();
p += 2; // reserved
const count = v.getUint16(p);
p += 2;
let byte = boxOffset + size + firstOffset; // mốc: byte ngay sau hộp sidx
const references: SidxReference[] = [];
for (let i = 0; i < count; i++) {
const a = u32();
const duration = u32();
const sap = u32();
const refSize = a & 0x7fffffff;
references.push({
kind: a >>> 31 ? 'index' : 'media',
startByte: byte,
endByte: byte + refSize - 1,
start: time / timescale,
end: (time + duration) / timescale,
startsWithSap: sap >>> 31 === 1,
});
time += duration;
byte += refSize;
}
return { timescale, references };
}
/** Bản đọc giống bản gốc (bỏ qua byteOffset của view), chỉ để demo cho thấy lỗi. */
export function parseSidxIgnoringOffset(bytes: Uint8Array, boxOffset: number) {
return parseSidx(new Uint8Array(bytes.buffer), boxOffset);
}
/** Tạo hộp sidx version 0 từ danh sách segment, dùng cho demo và test. */
export function buildSidx(timescale: number, segments: { size: number; duration: number }[], earliest = 0, firstOffset = 0): Uint8Array {
const size = 32 + segments.length * 12;
const v = new DataView(new ArrayBuffer(size));
v.setUint32(0, size);
v.setUint32(4, SIDX);
v.setUint32(12, 1); // reference_ID
v.setUint32(16, timescale);
v.setUint32(20, earliest);
v.setUint32(24, firstOffset);
v.setUint16(30, segments.length);
segments.forEach((s, i) => {
const o = 32 + i * 12;
v.setUint32(o, s.size & 0x7fffffff);
v.setUint32(o + 4, s.duration);
v.setUint32(o + 8, 0x90000000); // starts_with_SAP = 1, SAP_type = 1
});
return new Uint8Array(v.buffer);
}
ts
Khác bản gốc:
parseSidxtạoDataViewtheobyteOffset/byteLengthcủa view, nên nhận đượcsubarray.- Sidx phân cấp không ném lỗi: entry trỏ tới sidx con được trả về với
kind: 'index', lớp gọi tự quyết định tải tiếp. - Thời gian sidx giữ nguyên mốc
earliest_presentation_timethay vì trừ về 0; muốn bắt đầu từ 0 thì trừreferences[0].start. - Số 64-bit vượt
Number.MAX_SAFE_INTEGERthì báo lỗi thay vì lặng lẽ mất độ chính xác. - Timeline hỗ trợ
@r(lặp) và@tvắng mặt, hai thứ chuẩn DASH cho phép mà bản gốc khẳng định không xảy ra. parseIntluôn dùng radix 10.
#Đi qua code
parseSegmentTemplate()đọc XML bằngDOMParser.@tvắng mặt thì segment nối tiếp segment trước, đúng chuẩn.expand()trải mỗi entry{t, d, r}thànhr + 1segment, rồi gán số sao cho segment cuối mangendNumber. Không cóendNumberthì đánh số 1..n.timeRange()cộng thêm phần trước cửa sổ:(số đầu cửa sổ − startNumber) × thời lượng trung bình.segmentAt()tìm trong cửa sổ trước; ngoài cửa sổ thì lùiceil((t đầu − x) / trung bình)segment, không lùi quástartNumber. Thời gian của segment ước lượng tính từ số của nó, không lấy chính thời điểm được hỏi làm điểm bắt đầu như bản gốc.predictAfter(n, count)trảcountsegment dự đoán liên tiếp. Bản gốc chỉ đoán một; cấu hìnhnum_predictive_segmentscó lẽ giới hạn số request dự đoán đang treo.parseSidx()đọc tuần tự bằng con trỏp.u64()ghép hai nửa 32-bit.buildSidx()tạo hộp sidx version 0 để demo và test không cần file MP4 thật.
#Tại sao không làm cách khác?
Tải lại manifest thường xuyên hơn (mỗi giây)? Với hàng triệu người xem một buổi live, mỗi người tải manifest mỗi giây là hàng triệu request mỗi giây chỉ để hỏi "có gì mới không". Xin thẳng segment kế tiếp gộp câu hỏi và câu trả lời thành một request.
Low-Latency DASH chuẩn (chunked CMAF)? LL-DASH cho phép tải segment khi nó đang được ghi, bằng chunked transfer, và dùng availabilityTimeOffset để báo trình phát xin sớm. Ý tưởng gần giống. Thuộc tính FB* có lẽ có trước khi các chuẩn này phổ biến, hoặc phục vụ hạ tầng CDN riêng của Facebook.
Liệt kê mọi segment VOD trong manifest thay vì dùng sidx? Manifest của một video 2 giờ với segment 2 giây có 3600 entry cho mỗi bậc chất lượng. sidx để mục lục nằm trong file, tải khi cần.
#Edge case và cạm bẫy
- Đoán sai số segment. Server có thể đổi cấu hình giữa chừng (khởi động lại encoder, đổi thời lượng segment). Request dự đoán trả 404 thì quay về tải manifest; đừng đoán mù nhiều lượt liên tiếp.
- Request treo quá lâu. Server giữ request dự đoán, nhưng proxy hoặc trình duyệt có thể cắt kết nối sau một khoảng thời gian. Đặt timeout rõ ràng và thử lại.
- Buổi live kết thúc. Segment
n + 1sẽ không bao giờ có. Bản gốc dùngisStaticMpdvàisEndingSegmentđể biết segment cuối; manifest chuyển sangtype="static"là tín hiệu dừng đoán. - Sidx phân cấp gặp ở file rất dài hoặc file được ghép. Bộ đọc nên ít nhất báo lỗi rõ ràng, hoặc tải sidx con.
- Byte-range sai một byte.
endBytetrong headerRangelà bao gồm, nên làstart + size − 1.
#Dùng trong dự án
import { parseSegmentTemplate, parseSidx, PredictedTimeline } from './dashSegments';
// Live: theo segment dự đoán, quay lại manifest khi đoán hỏng
async function followLive(manifestUrl: string, append: (b: ArrayBuffer) => Promise<void>, signal: AbortSignal) {
let tl = new PredictedTimeline(parseSegmentTemplate(await (await fetch(manifestUrl, { signal })).text()));
let n = tl.last()?.number ?? 0;
while (!signal.aborted) {
const [next] = tl.predictAfter(n);
const res = next && (await fetch(next.url, { signal }).catch(() => null));
if (!res?.ok) {
tl = new PredictedTimeline(parseSegmentTemplate(await (await fetch(manifestUrl, { signal })).text()));
continue;
}
await append(await res.arrayBuffer());
n = next.number!;
}
}
// VOD: đọc sidx rồi tải đoạn thứ i
async function fetchSegment(url: string, indexRange: [number, number], i: number) {
const head = await fetch(url, { headers: { Range: `bytes=${indexRange[0]}-${indexRange[1]}` } });
const { references } = parseSidx(new Uint8Array(await head.arrayBuffer()), indexRange[0]);
const ref = references[i];
return fetch(url, { headers: { Range: `bytes=${ref.startByte}-${ref.endByte}` } });
}
ts
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { buildSidx, fillTemplate, parseSegmentTemplate, parseSidx, parseSidxIgnoringOffset, PredictedTimeline } from './dashSegments';
const xml = `<SegmentTemplate timescale="1000" media="v-$Number$.m4s">
<SegmentTimeline FBPredictedMedia="p-$Number$.m4s" FBPredictedMediaStartNumber="1"
FBPredictedMediaEndNumber="40" FBAverageDuration="2000">
<S t="74000" d="2000" r="2"/>
</SegmentTimeline>
</SegmentTemplate>`;
describe('PredictedTimeline', () => {
const tl = new PredictedTimeline(parseSegmentTemplate(xml));
it('đánh số từ cuối và đoán segment kế tiếp', () => {
expect(tl.last()).toMatchObject({ number: 40, start: 78, end: 80 });
expect(tl.predictAfter(40, 2).map((s) => s.url)).toEqual(['p-41.m4s', 'p-42.m4s']);
});
it('ước lượng segment trước cửa sổ bằng thời lượng trung bình', () => {
expect(tl.timeRange()).toEqual({ start: 0, end: 80 }); // 37 segment × 2 s trước cửa sổ
expect(tl.segmentAt(75)).toMatchObject({ number: 38 });
expect(tl.segmentAt(11)).toMatchObject({ number: 6, start: 10, end: 12 });
expect(tl.segmentAt(1)).toMatchObject({ number: 1, start: 0 });
expect(tl.segmentAt(80)).toBeNull();
});
it('template có độ rộng', () => {
expect(fillTemplate('s-$Number%05d$.m4s', 42)).toBe('s-00042.m4s');
});
});
describe('parseSidx', () => {
const sidx = buildSidx(1000, [{ size: 100, duration: 2000 }, { size: 50, duration: 2000 }]);
it('tính byte tuyệt đối từ vị trí hộp', () => {
const { references } = parseSidx(sidx, 500);
expect(references.map((r) => [r.startByte, r.endByte, r.start, r.end])).toEqual([
[500 + sidx.length, 500 + sidx.length + 99, 0, 2],
[500 + sidx.length + 100, 500 + sidx.length + 149, 2, 4],
]);
});
it('đọc được subarray, còn cách gốc thì không', () => {
const file = new Uint8Array(64 + sidx.length);
file.set(sidx, 64);
const view = file.subarray(64);
expect(parseSidx(view, 64).references).toHaveLength(2);
expect(() => parseSidxIgnoringOffset(view, 64)).toThrow('Không phải hộp sidx');
});
});
ts
#So với thư viện và chuẩn web
- shaka-player và dash.js đều có bộ đọc sidx riêng (shaka:
shaka.media.Mp4SegmentIndexParser, dash.js: trongSegmentBaseLoader) và hỗ trợSegmentBase@indexRangecủa DASH, là cách chuẩn để manifest chỉ chỗ hộp sidx trong file. - Low-Latency DASH (DVB-DASH, DASH-IF IOP) dùng
availabilityTimeOffsetvà chunked CMAF để xin segment trước khi nó hoàn tất; LL-HLS dùng#EXT-X-PRELOAD-HINTđể báo URL phần kế tiếp và "blocking playlist reload". Đây là bản chuẩn hoá của đúng ý tưởngFBPredictedMedia. - mp4box.js đọc được toàn bộ cấu trúc hộp MP4, kể cả sidx; nặng hơn nhiều so với một hàm đọc riêng sidx.
DataViewvàgetBigUint64có trên mọi trình duyệt hiện đại (tính tới 10/2026).
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx (bài này)
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

