javascript

Как узнать длительность видео по ссылке, скачав 7% файла, и почему в заголовке MP4 бывает ноль

  • четверг, 8 октября 2026 г. в 00:00:04
https://habr.com/ru/articles/1091264/

Пользователь загрузил ролик на 37 мегабайт и получил ответ: «Не удалось определить длительность видео». Ролик был обычный, открывался в любом плеере, ffprobe показывал 18 секунд. В логах ошибок при этом не было вообще. Под катом разбор того, как устроена длительность в MP4, почему в заголовке файла она бывает нулевой и как достать её тремя HTTP‑запросами без ffmpeg.

Зачем мерить длительность на сервере

Я делаю Aigena, сервис, где нейросети генерируют и правят видео. Обработка там стоит денег за каждую секунду ролика. Долгое время длительность присылал браузер: прочитал метаданные файла, положил число в запрос. Сервер этому числу верил.

Проблема очевидна задним числом. Обработчику ролик уходит целиком, а счёт выставляется по числу из запроса. Отправил duration: 1 для пятиминутного видео, получил пять минут обработки по цене одной секунды. Платил бы за это я.

Поэтому длительность надо мерить самому, по файлу, который уже лежит в хранилище.

Первое, что приходит в голову: ffprobe. Он умеет читать по HTTP и сам делает range‑запросы, это рабочий вариант. Я его не взял по двум причинам. Не хотелось запускать дочерний процесс внутри обработчика запроса, от которого зависит списание денег. И не хотелось тащить на сервер бинарник ради одного числа, которое, как мне казалось, лежит в заголовке файла на известном месте.

Про «на известном месте» я ошибался, но об этом ниже.

MP4 за две минуты

Файл MP4 состоит из боксов. У каждого бокса в начале четыре байта размера и четыре байта имени: ftyp, moov, mdat и так далее. Боксы вложены друг в друга, как папки.

Всё, что нужно знать о ролике, лежит в moov. Внутри него есть mvhd, заголовок фильма, а в нём два поля: timescale, сколько тиков в секунде, и duration, сколько тиков длится ролик. Делим одно на другое и получаем секунды.

Тонкость одна: moov может стоять в начале файла или в конце. Если ролик готовили для веба, то в начале. Если просто экспортировали из редактора, то после всех медиаданных, в хвосте.

Отсюда первая версия. Просим у хранилища первые 256 килобайт. Не нашли mvhd, просим последние два мегабайта.

async function fetchRange(url, range) {
  try {
    const res = await fetch(url, {
      headers: { Range: range },
      signal: AbortSignal.timeout(15_000),
    });
    if (!res.ok) return null;
    return Buffer.from(await res.arrayBuffer());
  } catch {
    return null;
  }
}

// mvhd: version(1) flags(3) created modified timescale(4) duration
// В версии 0 даты и длительность по 4 байта, в версии 1 по 8.
function parseMvhd(buf) {
  const idx = buf.indexOf("mvhd", 0, "latin1");
  if (idx < 0) return null;
  const p = idx + 4;
  if (buf[p] === 1) {
    if (p + 32 > buf.length) return null;
    const timescale = buf.readUInt32BE(p + 20);
    const duration = Number(buf.readBigUInt64BE(p + 24));
    return timescale > 0 ? duration / timescale : null;
  }
  if (p + 20 > buf.length) return null;
  const timescale = buf.readUInt32BE(p + 12);
  const duration = buf.readUInt32BE(p + 16);
  return timescale > 0 ? duration / timescale : null;
}

Тридцать строк, работает на всём, что я загружал сам. Выкатил и забыл.

Что сломалось

Третьего октября пришёл тот самый ролик на 37 мегабайт. Сервер ответил отказом ещё до того, как создал запись о задаче, поэтому в списке ошибок его не было. Человек видел сообщение на экране, я не видел ничего.

Я скачал файл и посмотрел, что в нём. mvhd на месте, в первых килобайтах. timescale нормальный. duration равен нулю.

Не мусор, не обрезанный файл. Честный ноль, записанный тем, кто этот файл создал.

Это фрагментированный MP4. В обычном файле сначала пишутся все кадры, а в конце, когда длительность уже известна, заголовок с таблицами. Фрагментированный пишется по‑другому: заголовок сразу, а потом куски по несколько секунд, каждый со своим маленьким заголовком moof. Так удобно стримить и записывать на лету: если запись оборвётся, уже записанные куски останутся читаемыми.

Цена этого удобства в том, что на момент записи главного заголовка длительность ещё неизвестна. И туда пишут ноль.

Воспроизвести можно одной командой:

ffmpeg -i input.mp4 -c copy -movflags frag_keyframe+empty_moov frag.mp4

Я проверил на тестовом ролике в 18 секунд. В обычном файле боксы идут так: ftyp, free, mdat, moov, длительность в mvhd равна 18000. Во фрагментированном: ftyp, moov, потом пары moof и mdat, в самом конце mfra. Длительность в mvhd равна нулю. ffprobe на обоих показывает 18 секунд, потому что он читает фрагменты, а не верит заголовку.

Где длительность лежит на самом деле

Раз заголовок молчит, длительность надо собирать из фрагментов. Читать все фрагменты подряд значит скачать весь файл, а этого я и хотел избежать. Нужен только последний: время его начала плюс длительность его кадров и есть конец ролика.

Найти последний фрагмент помогает бокс mfra в хвосте файла. Это оглавление: в нём для каждой дорожки записано, с какого байта начинается каждый фрагмент.

Дальше по цепочке:

  1. Из moov берём timescale каждой дорожки (бокс mdhd) и длительность кадра по умолчанию (бокс trex).

  2. Из mfra берём смещение последнего фрагмента.

  3. Третьим запросом читаем 256 килобайт с этого смещения. Сам moof маленький, кадры из mdat нам не нужны.

  4. В moof для каждой дорожки есть tfdt, время начала фрагмента, и trun, список кадров. Складываем время начала с длительностями кадров.

  5. Делим на timescale. Берём максимум по дорожкам.

На том ролике получилось так: время начала последнего фрагмента 536536, в нём четыре кадра по 1001 тику, в секунде 30 000 тиков. (536536 + 4 × 1001) / 30 000 = 18,018 секунды.

Три запроса: 256 КБ, 2 МБ и ещё 256 КБ. Около двух с половиной мегабайт из тридцати семи.

Код

Сначала общий разбор боксов. Размер 1 означает, что настоящий размер лежит в следующих восьми байтах, размер 0 означает «до конца файла».

function boxes(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),
      start: p,
      payload: p + header,
      end: p + size,
    });
    p += size;
  }
  return result;
}

Дорожки: номер из tkhd, timescale из mdhd, длительность кадра по умолчанию из trex.

function fragmentTracks(head) {
  const tracks = new Map();
  const moov = boxes(head).find((b) => b.type === "moov");
  if (!moov) return tracks;
  const children = boxes(head, moov.payload, moov.end);

  for (const trak of children.filter((b) => b.type === "trak")) {
    const inner = boxes(head, trak.payload, trak.end);
    const tkhd = inner.find((b) => b.type === "tkhd");
    const mdia = inner.find((b) => b.type === "mdia");
    const mdhd = mdia && boxes(head, mdia.payload, mdia.end).find((b) => b.type === "mdhd");
    if (!tkhd || !mdhd) continue;
    // В версии 1 даты занимают по 8 байт, поля сдвигаются.
    const idAt = tkhd.payload + (head[tkhd.payload] === 1 ? 20 : 12);
    const scaleAt = mdhd.payload + (head[mdhd.payload] === 1 ? 20 : 12);
    if (idAt + 4 > tkhd.end || scaleAt + 4 > mdhd.end) continue;
    const timescale = head.readUInt32BE(scaleAt);
    if (timescale) tracks.set(head.readUInt32BE(idAt), { timescale, defaultDuration: 0 });
  }

  const mvex = children.find((b) => b.type === "mvex");
  if (mvex) {
    for (const trex of boxes(head, mvex.payload, mvex.end).filter((b) => b.type === "trex")) {
      if (trex.payload + 16 > trex.end) continue;
      const track = tracks.get(head.readUInt32BE(trex.payload + 4));
      if (track) track.defaultDuration = head.readUInt32BE(trex.payload + 12);
    }
  }
  return tracks;
}

Смещение последнего фрагмента из mfra. Записи в tfra переменной длины: три двухбитных поля в заголовке говорят, сколько байт занимают номера, плюс время и смещение по 4 или 8 байт.

function tailBox(buf, type) {
  for (let at = buf.lastIndexOf(type); at >= 4; at = buf.lastIndexOf(type, at - 1)) {
    const size = buf.readUInt32BE(at - 4);
    if (size >= 8 && at - 4 + size <= buf.length) {
      return { type, start: at - 4, payload: at + 4, end: at - 4 + size };
    }
  }
}

function lastFragmentOffset(tail) {
  const mfra = tailBox(tail, "mfra");
  if (!mfra) return null;
  let last = -1;
  for (const tfra of boxes(tail, mfra.payload, mfra.end).filter((b) => b.type === "tfra")) {
    const p = tfra.payload;
    if (p + 16 > tfra.end) continue;
    const wide = tail[p] === 1;
    const lengths = tail.readUInt32BE(p + 8);
    const count = tail.readUInt32BE(p + 12);
    const width =
      (wide ? 16 : 8) + ((lengths >> 4) & 3) + ((lengths >> 2) & 3) + (lengths & 3) + 3;
    const end = p + 16 + count * width;
    if (!count || end > tfra.end) continue;
    const offsetAt = end - width + (wide ? 8 : 4);
    const offset = wide ? Number(tail.readBigUInt64BE(offsetAt)) : tail.readUInt32BE(offsetAt);
    if (Number.isSafeInteger(offset)) last = Math.max(last, offset);
  }
  return last >= 0 ? last : null;
}

И сам фрагмент. Флаги в tfhd и trun говорят, какие необязательные поля присутствуют. Если у кадров нет своей длительности (флаг 0×100 не стоит), берём умолчание из tfhd или из trex.

function fragmentDuration(buf, moof, tracks) {
  let duration = 0;
  for (const traf of boxes(buf, moof.payload, moof.end).filter((b) => b.type === "traf")) {
    const children = boxes(buf, traf.payload, traf.end);
    const tfhd = children.find((b) => b.type === "tfhd");
    const tfdt = children.find((b) => b.type === "tfdt");
    if (!tfhd || !tfdt || tfhd.payload + 8 > tfhd.end || tfdt.payload + 8 > tfdt.end) continue;
    const track = tracks.get(buf.readUInt32BE(tfhd.payload + 4));
    if (!track) continue;

    const flags = buf.readUInt32BE(tfhd.payload) & 0xffffff;
    let cursor = tfhd.payload + 8;
    if (flags & 1) cursor += 8; // base-data-offset
    if (flags & 2) cursor += 4; // sample-description-index
    if (flags & 8 && cursor + 4 > tfhd.end) continue;
    const sampleDuration = flags & 8 ? buf.readUInt32BE(cursor) : track.defaultDuration;

    const version = buf[tfdt.payload];
    if (version === 1 && tfdt.payload + 12 > tfdt.end) continue;
    let ticks =
      version === 1
        ? Number(buf.readBigUInt64BE(tfdt.payload + 4))
        : buf.readUInt32BE(tfdt.payload + 4);

    let samples = 0;
    for (const trun of children.filter((b) => b.type === "trun")) {
      if (trun.payload + 8 > trun.end) return null;
      const runFlags = buf.readUInt32BE(trun.payload) & 0xffffff;
      const count = buf.readUInt32BE(trun.payload + 4);
      if (count > 1_000_000 || (!(runFlags & 0x100) && !sampleDuration)) return null;
      let pos = trun.payload + 8;
      if (runFlags & 1) pos += 4; // data-offset
      if (runFlags & 4) pos += 4; // first-sample-flags
      const fields = [0x100, 0x200, 0x400, 0x800].filter((f) => runFlags & f).length;
      if (pos + count * fields * 4 > trun.end) return null;
      if (!(runFlags & 0x100)) ticks += count * sampleDuration;
      for (let i = 0; i < count; i++) {
        if (runFlags & 0x100) ticks += buf.readUInt32BE(pos);
        pos += fields * 4;
      }
      samples += count;
    }
    if (samples && Number.isSafeInteger(ticks)) {
      duration = Math.max(duration, ticks / track.timescale);
    }
  }
  return duration > 0 ? duration : null;
}

Собираем вместе:

const HEAD_BYTES = 256 * 1024;
const TAIL_BYTES = 2 * 1024 * 1024;

async function probeVideoDuration(url) {
  if (!/^https?:\/\//i.test(url)) return null;

  const head = await fetchRange(url, `bytes=0-${HEAD_BYTES - 1}`);
  if (head) {
    const fromHead = parseMvhd(head);
    if (fromHead > 0) return fromHead;
  }

  const tail = await fetchRange(url, `bytes=-${TAIL_BYTES}`);
  if (!tail) return null;
  const fromTail = parseMvhd(tail);
  if (fromTail > 0) return fromTail;

  if (!head) return null;
  const tracks = fragmentTracks(head);
  if (!tracks.size) return null;
  const offset = lastFragmentOffset(tail);
  if (offset === null) return null;

  const fragment = await fetchRange(url, `bytes=${offset}-${offset + HEAD_BYTES - 1}`);
  const moof = fragment && boxes(fragment).find((b) => b.type === "moof");
  return moof ? fragmentDuration(fragment, moof, tracks) : null;
}

Чего парсер не умеет

Честный список, потому что от этого числа зависят деньги.

Нет mfra, нет ответа. Оглавление в конце файла необязательное. Если его нет, я не знаю, где последний фрагмент, и возвращаю null. Можно было бы взять последний moof, который попал в хвостовые два мегабайта. Но если последний фрагмент большой, в окно попадёт предпоследний, и я посчитаю длительность меньше настоящей. Недобрать с клиента хуже, чем попросить его пересохранить файл.

Обрезанные данные тоже null. Любая проверка границ, которая не сошлась, заканчивается отказом, а не догадкой.

Только MP4 и MOV. WebM и MKV устроены иначе, там свой парсер, которого у меня нет.

Заголовку приходится верить. Файл, собранный специально, чтобы обмануть счётчик, обманет и этот код. Ограничивает ущерб то, что обработчик на другой стороне сам режет ролики длиннее своего лимита. Для защиты от целенаправленной подделки нужен полный разбор файла, и это уже работа для ffprobe.

Когда парсер возвращает null, пользователь видит просьбу загрузить ролик в MP4 или MOV, а в лог пишется строка с причиной и названием модели. Без адреса файла и без текста запроса: в логах им делать нечего.

Грабли

Отказ до записи в базу невидим. Проверка длительности стояла раньше, чем создание записи о задаче. Логично: зачем заводить задачу, которую сейчас отклоним. В результате отказ существовал только на экране у пользователя. Теперь каждая такая ветка пишет строку в лог.

Сервер может проигнорировать Range. Тогда вместо куска придёт весь файл с кодом 200. Код это переживёт, файл уже скачан, но память и время уйдут. Стоит проверить, что ваше хранилище отвечает 206.

Версии боксов. У mvhd, mdhd, tkhd, tfdt и tfra есть версия 0 с 32-битными полями и версия 1 с 64-битными. Смещения всех следующих полей от этого сдвигаются. В первой версии парсера я это учёл для mvhd и радовался, какой я молодец.

Number и 64 бита. readBigUInt64BE возвращает BigInt, а дальше хочется обычное число. Для длительностей и смещений это безопасно, но проверка Number.isSafeInteger стоит три символа и снимает вопрос.

Бонус, не про MP4. Пока разбирался, нашёл вторую причину, по которой большие ролики могли не доходить. В Next.js, если в проекте есть proxy (раньше он назывался middleware), тело каждого запроса буферизуется в памяти, чтобы его могли прочитать и proxy, и обработчик. Буфер по умолчанию ограничен десятью мегабайтами. Всё, что больше, в него не попадает. Загрузка файлов шла через тот же proxy, который нужен только для проверки сессии. Я исключил маршрут загрузки из proxy, проверка входа там есть своя. Альтернатива: поднять лимит настройкой proxyClientMaxBodySize, но держать сто мегабайт в памяти ради каждой загрузки мне не захотелось.

Итог

Длительность MP4 не всегда лежит в заголовке. У обычных файлов она в mvhd, в начале или в конце. У фрагментированных там ноль, и настоящую длительность надо собирать из последнего фрагмента, который находится через оглавление mfra.

На всё хватает трёх range‑запросов и двух с половиной мегабайт трафика независимо от размера ролика. Код занимает около двухсот строк без зависимостей.

Если бы начинал заново, я бы сразу взял файл, записанный телефоном или экранной записью, а не только ролики, которые сам экспортировал из редактора. Все мои тестовые файлы были «правильные», и ошибка ждала первого чужого.

P. S. Спасибо, что дочитали! Расскажите в комментариях, какой файл сломал ваш «идеально работающий» парсер, мне будет не так обидно. Берегите себя и тестируйте на чужих файлах