Files
portal/app/app/Support/Mp4Probe.php
T
Дмитрий 7922bffb6d
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(телеграм-реклама): заголовок и картинка в авто-режиме + честная проверка медиа по требованиям МТС
Пачка 1 замечаний владельца о едином виде рекламных экранов. Песочница выключена
02.08.2026 — каждая дыра ниже стоила живых денег.

1. Заголовок объявления в АВТО-правиле. Кабинет МТС требует его для рекламы сайта;
   утром 02.08 поле довезли до разовой формы, а в авто его не было вовсе — накопитель
   создавал кампанию без ad_headline, и она сгорела бы в кабинете уже после списания.
   Колонка client_tg_auto_rule.ad_headline, приём в контроллере (обязателен только при
   включённом авто и не-телеграмной ссылке), перенос в кампанию, поле на экране.

2. Картинка/видео в авто-правиле. Колонка media_path, отдельная загрузка
   POST /api/telegram/auto-rule/media, перенос в кампанию, поле на экране.

3. Проверка медиа переписана по настоящим требованиям кабинета, снятым глазами 02.08.
   Было mimes:png,jpg,jpeg,gif,mp4|max:51200 — врало по пяти пунктам: принимало GIF,
   пропускало вдвое больший вес, не смотрело пиксели и длительность, зря отказывало
   в mov/webm. Стало правило App\Rules\ClientTg\MtsMedia: картинка JPEG/PNG до 25 МБ
   и 640x360...5120x2880, видео до 20 МБ, 3-55 секунд, от 640x360. Формат картинки
   определяется по содержимому, длительность и кадр видео читает App\Support\Mp4Probe
   из контейнера — без внешних программ. Отказ человеческий: «Картинка слишком
   маленькая: 300x200. Нужна не меньше 640x360».

4. Цена показа с медиа — 600 руб. с картинкой, 680 с видео — теперь видна ДО загрузки
   файла, на обоих экранах.

Сверх плана, найдено по дороге:

5. Предохранитель накопителя: правило, сохранённое до 02.08 со ссылкой на сайт и пустым
   заголовком, всё равно ушло бы в кабинет. Теперь такая пачка держится черновиком,
   в журнал пишется причина no_headline.

6. Отказ сервера доходит до клиента его словами. Оба экрана глушили ответ общей фразой
   «Не удалось рассчитать кампанию», и человек не понимал, что не так с файлом.

Границы честности: длительность и размер кадра читаются только у mp4/mov/m4v; у
webm/mkv/mpeg/wmv проверяются формат и вес — так и записано в Mp4Probe.

Проверено: 281 тест телеграм-модуля, 1753 фронтенд-теста, статанализ 0, Pint чист.
Глазами НЕ принимали — приёмка живьём в пачке 5. На боевой не выкачено.

Журнал схемы: v9.64 — номер взят как максимум по всем веткам плюс один, чтобы не
повторить столкновения 29.07 и 01.08.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:41:26 +03:00

206 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
/**
* Читает длительность и размер кадра из контейнера ISO-BMFF (mp4 / mov / m4v).
*
* Зачем: кабинет МТС берёт видео только 3…55 секунд и не мельче 640×360, а узнать
* это надо ДО запуска — песочница выключена 02.08.2026, деньги списываются раньше,
* чем робот доходит до кабинета. Внешних программ (ffprobe) на боевом нет и ставить
* их ради одной проверки не хочется, поэтому читаем заголовки сами.
*
* Формат простой: файл — цепочка «боксов» (4 байта длины, 4 байта типа, содержимое).
* Нужны два: `moov/mvhd` (масштаб времени + длительность) и `moov/trak/tkhd`
* (ширина/высота дорожки в формате 16.16). Пиксели не трогаем.
*
* 🪤 Границы честности: webm/mkv/mpeg/wmv устроены иначе (EBML и прочее) — их этот
* пробник не понимает и возвращает null. Значит для них длительность и размер кадра
* НЕ проверяются: вес и формат проверим, остальное отсеет кабинет. Это осознанный
* предел, а не забытая ветка.
*/
final class Mp4Probe
{
/** Больше боксов на одном уровне у нормального файла не бывает — страховка от зацикливания. */
private const MAX_BOXES = 512;
/**
* @return array{duration_sec: ?float, width: ?int, height: ?int}|null null — файл не ISO-BMFF
*/
public static function probe(string $path): ?array
{
$size = @filesize($path);
if ($size === false || $size < 16) {
return null;
}
$fh = @fopen($path, 'rb');
if ($fh === false) {
return null;
}
try {
$moov = self::boxes($fh, 0, $size, 'moov')[0] ?? null;
if ($moov === null) {
return null; // не ISO-BMFF (или битый файл)
}
[$moovStart, $moovEnd] = $moov;
$duration = null;
$mvhd = self::boxes($fh, $moovStart, $moovEnd, 'mvhd')[0] ?? null;
if ($mvhd !== null) {
$duration = self::readDuration($fh, $mvhd[0], $mvhd[1]);
}
// Дорожек несколько (видео + звук); у звуковой размер 0×0 — берём крупнейшую.
$width = null;
$height = null;
foreach (self::boxes($fh, $moovStart, $moovEnd, 'trak') as [$trakStart, $trakEnd]) {
$tkhd = self::boxes($fh, $trakStart, $trakEnd, 'tkhd')[0] ?? null;
if ($tkhd === null) {
continue;
}
$frame = self::readFrame($fh, $tkhd[0], $tkhd[1]);
if ($frame === null) {
continue;
}
[$w, $h] = $frame;
if ($w > 0 && $h > 0 && ($width === null || $w * $h > $width * $height)) {
$width = $w;
$height = $h;
}
}
return ['duration_sec' => $duration, 'width' => $width, 'height' => $height];
} finally {
fclose($fh);
}
}
/**
* Все боксы нужного типа среди соседей в отрезке [$start, $end).
*
* @param resource $fh
* @return list<array{0: int, 1: int}> пары «начало содержимого, конец бокса»
*/
private static function boxes($fh, int $start, int $end, string $type): array
{
$found = [];
$pos = $start;
for ($guard = 0; $guard < self::MAX_BOXES && $pos + 8 <= $end; $guard++) {
fseek($fh, $pos);
$header = fread($fh, 8);
if ($header === false || strlen($header) < 8) {
break;
}
$size = self::uint32(substr($header, 0, 4));
$boxType = substr($header, 4, 4);
$payload = $pos + 8;
if ($size === 1) {
// Длина 64-битная — лежит сразу за заголовком.
$ext = fread($fh, 8);
if ($ext === false || strlen($ext) < 8) {
break;
}
$size = self::uint32(substr($ext, 0, 4)) * 4294967296 + self::uint32(substr($ext, 4, 4));
$payload += 8;
} elseif ($size === 0) {
$size = $end - $pos; // «до конца» — по стандарту так помечают последний бокс
}
if ($size < 8 || $pos + $size > $end) {
break; // мусор: дальше идти небезопасно
}
if ($boxType === $type) {
$found[] = [$payload, $pos + $size];
}
$pos += $size;
}
return $found;
}
/**
* Длительность в секундах из mvhd: масштаб времени и число «тиков».
*
* @param resource $fh
*/
private static function readDuration($fh, int $start, int $end): ?float
{
fseek($fh, $start);
$head = fread($fh, 4);
if ($head === false || strlen($head) < 4) {
return null;
}
$version = ord($head[0]);
// v0: создан(4) изменён(4) масштаб(4) длительность(4); v1: то же, но времена и
// длительность по 8 байт.
$need = $version === 0 ? 16 : 28;
if ($start + 4 + $need > $end) {
return null;
}
$body = fread($fh, $need);
if ($body === false || strlen($body) < $need) {
return null;
}
if ($version === 0) {
$timescale = self::uint32(substr($body, 8, 4));
$ticks = self::uint32(substr($body, 12, 4));
} else {
$timescale = self::uint32(substr($body, 16, 4));
$ticks = self::uint32(substr($body, 20, 4)) * 4294967296 + self::uint32(substr($body, 24, 4));
}
if ($timescale <= 0 || $ticks <= 0) {
return null;
}
return $ticks / $timescale;
}
/**
* Ширина и высота дорожки из tkhd (числа 16.16 — целая часть в старших двух байтах).
*
* @param resource $fh
* @return array{0: int, 1: int}|null
*/
private static function readFrame($fh, int $start, int $end): ?array
{
fseek($fh, $start);
$version = fread($fh, 1);
if ($version === false || $version === '') {
return null;
}
// До ширины: заголовок(4) + времена/id/длительность + reserved + layer/group/
// volume/reserved + матрица(36). У v1 времена и длительность длиннее на 12 байт.
$offset = ord($version) === 0 ? 76 : 88;
if ($start + $offset + 8 > $end) {
return null;
}
fseek($fh, $start + $offset);
$raw = fread($fh, 8);
if ($raw === false || strlen($raw) < 8) {
return null;
}
return [self::uint32(substr($raw, 0, 4)) >> 16, self::uint32(substr($raw, 4, 4)) >> 16];
}
private static function uint32(string $bytes): int
{
/** @var array{1: int} $parsed */
$parsed = unpack('N', $bytes);
return $parsed[1];
}
}