Пользователь загрузил ролик на 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 в хвосте файла. Это оглавление: в нём для каждой дорожки записано, с какого байта начинается каждый фрагмент.
Дальше по цепочке:
-
Из
moovберёмtimescaleкаждой дорожки (боксmdhd) и длительность кадра по умолчанию (боксtrex). -
Из
mfraберём смещение последнего фрагмента. -
Третьим запросом читаем 256 килобайт с этого смещения. Сам
moofмаленький, кадры изmdatнам не нужны. -
В
moofдля каждой дорожки естьtfdt, время начала фрагмента, иtrun, список кадров. Складываем время начала с длительностями кадров. -
Делим на
timescale. Берём максимум по дорожкам.
На том ролике получилось так: время начала последнего фрагмента 536536, в нём четыре кадра по 1001 тику, в секунде 30000 тиков. (536536 + 4 × 1001) / 30000 = 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 говорят, какие необязательные поля присутствуют. Если у кадров нет своей длительности (флаг 0x100 не стоит), берём умолчание из 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-запросов и двух с половиной мегабайт трафика независимо от размера ролика. Код занимает около двухсот строк без зависимостей.
Если бы начинал заново, я бы сразу взял файл, записанный телефоном или экранной записью, а не только ролики, которые сам экспортировал из редактора. Все мои тестовые файлы были «правильные», и ошибка ждала первого чужого.
PS Спасибо, что дочитали! Расскажите в комментариях, какой файл сломал ваш «идеально работающий» парсер, мне будет не так обидно. Берегите себя и тестируйте на чужих файлах!
ссылка на оригинал статьи https://habr.com/ru/articles/1091264/