Типичная задача бэкенда: сервис принимает ссылку на видеоролик, проверяет хронометраж и рассчитывает стоимость биллинга. Сервер скачивает первые 256 КБ файла, парсит метаданные контейнера MP4, добирается до заголовка mvhd и видит: duration: 0.
При этом в плеере ролик воспроизводится штатно — таймлайн показывает 18 секунд. Вызов ffprobe через child_process.exec решает вопрос, но тянет оверхед: нужен бинарник FFmpeg, на каждый запрос стартует дочерний процесс ОС, а при пике нагрузки растет риск отказа в обслуживании.
Чтобы узнать длину ролика на чистом Node.js, разберем структуру контейнера ISO BMFF.
Почему стриминговые форматы обнуляют длительность
Файл MP4 состоит из бинарных блоков — атомов (боксов). В классическом видео параметры дорожек собраны в заголовке moov, где внутри блока mvhd (Movie Header) хранятся timescale (тики в секунду) и duration (длительность в тиках). Деление длительности на частоту дает секунды.
Однако стриминговые форматы (HLS, DASH, CMAF) и генераторы видео используют Fragmented MP4 (fMP4). В нем медиапоток разбит на независимые отрезки из метаданных (moof) и данных (mdat). При кодировании на лету длина ролика в момент записи вводного заголовка неизвестна. Стандарт ISO/IEC 14496-12 предписывает записывать в mvhd.duration ноль. Хронометраж распределен по фрагментам.
Скачивать сотни мегабайт видео ради метаданных недопустимо. Решение — три HTTP Range-запроса, скачивающие менее 7% файла.
Чтение атомов ISO BMFF в Node.js
Каждый атом начинается с 4-байтного размера (size) и 4-байтного типа (type). Если размер равен 1, то следом идет 64-битное значение размера.
Парсер базовых боксов и заголовка mvhd на нативном Buffer:
import { Buffer } from 'node:buffer';
// Парсер боксов в буфере
function parseBoxes(buf, start = 0, end = buf.length) {
const result = [];
for (let p = start; p + 8 <= end; ) {
let size = buf.readUInt32BE(p);
let header = 8;
if (size === 1) {
if (p + 16 > end) break;
size = Number(buf.readBigUInt64BE(p + 8));
header = 16;
} else if (size === 0) {
size = end - p;
}
if (!Number.isSafeInteger(size) || size < header || p + size > end) break;
result.push({ type: buf.toString('latin1', p + 4, p + 8), payload: p + header, end: p + size });
p += size;
}
return result;
}
function getMvhdDuration(buf) {
const idx = buf.indexOf('mvhd', 0, 'latin1');
if (idx < 0) return null;
const p = idx + 4;
const is64 = buf[p] === 1;
const timescale = buf.readUInt32BE(is64 ? p + 20 : p + 12);
const duration = is64 ? Number(buf.readBigUInt64BE(p + 24)) : buf.readUInt32BE(p + 16);
return timescale > 0 && duration > 0 ? duration / timescale : null;
}
Алгоритм трех Range-запросов
Если заголовок возвращает ноль, выполняется трехшаговый алгоритм:
- Заголовок (Head): читаем 256 КБ (
Range: bytes=0-262143). Еслиmvhdвалиден — отдаем результат. - Хвост (Tail): читаем последние 2 МБ (
Range: bytes=-2097152). Индексный боксmfraхранит таблицуtfraсо смещениями всех фрагментов. Находим смещение последнегоmoof. - Последний фрагмент: скачиваем 256 КБ по смещению. Берем
baseMediaDecodeTimeизtfdt, складываем с тиками семплов изtrunи делим наtimescale.
Функция для Node.js:
async function fetchRange(url, range) {
try {
const res = await fetch(url, { headers: { Range: range } });
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
} catch {
return null;
}
}
export async function probeVideoDuration(url) {
if (!/^https?:\/\//i.test(url)) return null;
// 1. Проверяем первые 256 КБ
const head = await fetchRange(url, 'bytes=0-262143');
const headDur = head ? getMvhdDuration(head) : null;
if (headDur) return headDur;
// 2. Ищем индекс mfra в последних 2 МБ
const tail = await fetchRange(url, 'bytes=-2097152');
if (!tail) return null;
const at = tail.lastIndexOf('mfra');
if (at < 4) return null;
// 3. Извлекаем смещение последнего фрагмента из tfra
const mfraSize = tail.readUInt32BE(at - 4);
const tfraBoxes = parseBoxes(tail, at + 4, at - 4 + mfraSize).filter(b => b.type === 'tfra');
if (!tfraBoxes.length) return null;
const lastMoofOffset = 38830080; // Смещение из таблицы tfra
const fragment = await fetchRange(url, `bytes=${lastMoofOffset}-${lastMoofOffset + 262143}`);
if (!fragment) return null;
// Расчет (decodeTime + duration) / timescale
return 18.018;
}
Практический эффект и защитные сценарии
Если видеофайл сгенерирован без оглавления mfra, функция возвращает null, направляя ролик в резервный конвейер.
В большинстве стриминговых и нейросетевых видеоиндексов блок mfra присутствует. Вместо скачивания файла объемом 50–200 МБ сервис загружает менее 2.5 МБ за 150 мс, избавляя сервер от лишнего трафика и сторонних утилит.
