The worker made the poster with a plain scale=320:-2. ffmpeg then read every picture as BT.601 in the limited range, so a red of 255 in a BT.709 video came out as 229 in the JPEG. The picker showed paler posters than the video in the editor. - VideoFormat::colour reads matrix and range of the converted MP4. When the file says nothing, it follows Chrome: BT.709 from 720 lines up, BT.601 below. That guess was measured in Chromium with a red H.264 video without colour tags. - posterArgs turns the picture from that matrix into BT.601 in the full range, the way a JPEG is read. accurate_rnd and full_chroma_int keep dark greys from landing two steps too low. - The search for the first moving video stream is now one helper, used by plan and colour. - Posters made before this change stay as they are. Tests: - videos.test.php in the worker image: ALL PASSED, 90 checks. Three red videos go through the real worker: BT.709 tagged, 720p without tags and 360p in BT.601. Red comes out as 254. - Counter-check with the old filter: 3 checks red, red is 229 again. - php -l clean on all three files. The gate was not run, the change has no TypeScript. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
9.8 KiB
PHP
249 lines
9.8 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* The one format every uploaded video is turned into (#402), as plain rules: what ffprobe said in,
|
||
* the ffmpeg arguments out. Nothing here runs a process, so all of it is testable without ffmpeg.
|
||
*
|
||
* The target is MP4 with H.264 and AAC, `yuv420p`, at a constant frame rate and with a keyframe
|
||
* every 15 frames: the editor seeks to clip starts and loops back to them, and a seek can only land
|
||
* on a keyframe quickly. The picture fits into 3840 × 2160 (2160 × 3840 standing up), so a 4K
|
||
* recording of the editor stays 4K, and is never made larger than it came.
|
||
*/
|
||
final class VideoFormat
|
||
{
|
||
public const MAX_LONG_SIDE = 3840;
|
||
public const MAX_SHORT_SIDE = 2160;
|
||
public const MAX_FPS = 60;
|
||
public const KEYFRAME_EVERY = 15;
|
||
/** Used when a file does not say its frame rate at all. */
|
||
private const FALLBACK_FPS = '30';
|
||
|
||
/**
|
||
* What the conversion makes of a probed file: the size it is shown at after rotation, scaled to
|
||
* fit, the frame rate as ffmpeg takes it, and whether there is sound to keep.
|
||
*
|
||
* @param array<string,mixed> $probe ffprobe's `-show_streams -show_format` as JSON
|
||
* @return array{width:int,height:int,fps:string,audio:bool,duration:float}
|
||
* @throws InvalidArgumentException when there is no picture in it
|
||
*/
|
||
public static function plan(array $probe): array
|
||
{
|
||
$streams = is_array($probe['streams'] ?? null) ? $probe['streams'] : [];
|
||
$video = self::picture($probe);
|
||
$audio = in_array('audio', array_column($streams, 'codec_type'), true);
|
||
if ($video === null || (int) ($video['width'] ?? 0) <= 0 || (int) ($video['height'] ?? 0) <= 0) {
|
||
throw new InvalidArgumentException('There is no picture in this file');
|
||
}
|
||
|
||
[$width, $height] = [(int) $video['width'], (int) $video['height']];
|
||
if (abs(self::rotation($video)) % 180 === 90) {
|
||
[$width, $height] = [$height, $width];
|
||
}
|
||
[$width, $height] = self::fit($width, $height);
|
||
|
||
return [
|
||
'width' => $width,
|
||
'height' => $height,
|
||
'fps' => self::fps($video),
|
||
'audio' => $audio,
|
||
'duration' => self::duration($probe, $video),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The size inside the box, keeping the shape, both sides even (H.264 in `yuv420p` needs that).
|
||
*
|
||
* @return array{0:int,1:int}
|
||
*/
|
||
public static function fit(int $width, int $height): array
|
||
{
|
||
[$boxW, $boxH] = $width >= $height
|
||
? [self::MAX_LONG_SIDE, self::MAX_SHORT_SIDE]
|
||
: [self::MAX_SHORT_SIDE, self::MAX_LONG_SIDE];
|
||
$scale = min(1.0, $boxW / $width, $boxH / $height);
|
||
$even = static fn(float $side): int => max(2, 2 * (int) floor(round($side, 3) / 2));
|
||
return [$even($width * $scale), $even($height * $scale)];
|
||
}
|
||
|
||
/**
|
||
* The frame rate the file is written at: its own average, unless that is above 60 or unknown.
|
||
* Kept as ffmpeg's fraction, so 29.97 stays 30000/1001 and frames do not drift against the time.
|
||
*
|
||
* @param array<string,mixed> $video
|
||
*/
|
||
public static function fps(array $video): string
|
||
{
|
||
foreach (['avg_frame_rate', 'r_frame_rate'] as $key) {
|
||
$rate = self::rate((string) ($video[$key] ?? ''));
|
||
if ($rate !== null) {
|
||
return $rate[1] > self::MAX_FPS ? (string) self::MAX_FPS : $rate[0];
|
||
}
|
||
}
|
||
return self::FALLBACK_FPS;
|
||
}
|
||
|
||
/** A fraction such as `30000/1001` as a number of frames per second. */
|
||
public static function fpsValue(string $fps): float
|
||
{
|
||
$rate = self::rate($fps);
|
||
return $rate === null ? 0.0 : $rate[1];
|
||
}
|
||
|
||
/**
|
||
* @param array{width:int,height:int,fps:string,audio:bool} $plan
|
||
* @return list<string>
|
||
*/
|
||
public static function convertArgs(string $source, string $target, array $plan): array
|
||
{
|
||
$args = [
|
||
'ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
|
||
'-i', $source,
|
||
'-map', '0:v:0',
|
||
];
|
||
if ($plan['audio']) {
|
||
$args = [...$args, '-map', '0:a:0'];
|
||
}
|
||
$args = [
|
||
...$args,
|
||
'-vf', "scale={$plan['width']}:{$plan['height']}:flags=lanczos,setsar=1",
|
||
'-r', $plan['fps'], '-fps_mode', 'cfr',
|
||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '21',
|
||
'-pix_fmt', 'yuv420p',
|
||
'-g', (string) self::KEYFRAME_EVERY, '-keyint_min', (string) self::KEYFRAME_EVERY, '-sc_threshold', '0',
|
||
];
|
||
$args = $plan['audio']
|
||
? [...$args, '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000']
|
||
: [...$args, '-an'];
|
||
return [...$args, '-sn', '-dn', '-map_metadata', '-1', '-movflags', '+faststart', '-progress', 'pipe:1', '-nostats', '-f', 'mp4', $target];
|
||
}
|
||
|
||
/**
|
||
* A still from a little way in (not the very first frame, which is often black), as wide as the
|
||
* picker draws it. A JPEG is always read as BT.601 in the full range, so the picture is turned
|
||
* into that from what the video is; left alone, a red of 255 in BT.709 came out as 229 (#411).
|
||
*
|
||
* @param array{matrix:string,range:string} $colour
|
||
* @return list<string>
|
||
*/
|
||
public static function posterArgs(string $video, string $target, float $duration, array $colour): array
|
||
{
|
||
$at = sprintf('%.3f', max(0.0, min(1.0, $duration / 2)));
|
||
$scale = "scale=320:-2:in_color_matrix={$colour['matrix']}:in_range={$colour['range']}"
|
||
. ':out_color_matrix=bt601:out_range=pc:flags=accurate_rnd+full_chroma_int';
|
||
return [
|
||
'ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
|
||
'-ss', $at, '-i', $video, '-frames:v', '1',
|
||
'-vf', $scale, '-q:v', '4', '-f', 'image2', $target,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* How the picture of a probed file is to be read, as ffmpeg's scale filter names it. Without a
|
||
* word in the file this is Chrome's guess, measured: BT.709 from 720 lines up, BT.601 below.
|
||
*
|
||
* @param array<string,mixed> $probe
|
||
* @return array{matrix:string,range:string}
|
||
*/
|
||
public static function colour(array $probe): array
|
||
{
|
||
$video = self::picture($probe) ?? [];
|
||
$matrix = match ($video['color_space'] ?? '') {
|
||
'bt709' => 'bt709',
|
||
'smpte170m', 'bt470bg' => 'bt601',
|
||
'bt2020nc', 'bt2020c' => 'bt2020',
|
||
'smpte240m' => 'smpte240m',
|
||
'fcc' => 'fcc',
|
||
default => (int) ($video['height'] ?? 0) >= 720 ? 'bt709' : 'bt601',
|
||
};
|
||
return ['matrix' => $matrix, 'range' => ($video['color_range'] ?? '') === 'pc' ? 'pc' : 'tv'];
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public static function probeArgs(string $path): array
|
||
{
|
||
return ['ffprobe', '-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', $path];
|
||
}
|
||
|
||
/**
|
||
* What the editor is told about a converted file, read back from the file itself rather than
|
||
* the plan: a crop or a rounding inside ffmpeg is what the editor has to draw.
|
||
*
|
||
* @param array<string,mixed> $probe
|
||
* @return array{width:int,height:int,duration:float,fps:float,audio:bool}
|
||
*/
|
||
public static function details(array $probe): array
|
||
{
|
||
$plan = self::plan($probe);
|
||
return [
|
||
'width' => $plan['width'],
|
||
'height' => $plan['height'],
|
||
'duration' => round($plan['duration'], 3),
|
||
'fps' => round(self::fpsValue($plan['fps']), 3),
|
||
'audio' => $plan['audio'],
|
||
];
|
||
}
|
||
|
||
/** The share of the work done, from one `out_time_us=` line of ffmpeg's progress. */
|
||
public static function progress(string $line, float $duration): ?float
|
||
{
|
||
if ($duration <= 0 || !preg_match('/^out_time_us=(\d+)$/', trim($line), $m)) {
|
||
return null;
|
||
}
|
||
return max(0.0, min(1.0, ((int) $m[1]) / 1e6 / $duration));
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $probe
|
||
* @return array<string,mixed>|null the first video stream that moves
|
||
*/
|
||
private static function picture(array $probe): ?array
|
||
{
|
||
foreach (is_array($probe['streams'] ?? null) ? $probe['streams'] : [] as $stream) {
|
||
// A cover picture in a music file is a video stream as well, one that is a single frame.
|
||
$still = (int) (($stream['disposition'] ?? [])['attached_pic'] ?? 0) === 1;
|
||
if (($stream['codec_type'] ?? '') === 'video' && !$still) {
|
||
return $stream;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** @param array<string,mixed> $video */
|
||
private static function rotation(array $video): int
|
||
{
|
||
foreach (is_array($video['side_data_list'] ?? null) ? $video['side_data_list'] : [] as $side) {
|
||
if (isset($side['rotation']) && is_numeric($side['rotation'])) {
|
||
return (int) $side['rotation'];
|
||
}
|
||
}
|
||
$tag = ($video['tags'] ?? [])['rotate'] ?? null;
|
||
return is_numeric($tag) ? (int) $tag : 0;
|
||
}
|
||
|
||
/** @param array<string,mixed> $probe @param array<string,mixed> $video */
|
||
private static function duration(array $probe, array $video): float
|
||
{
|
||
foreach ([$video['duration'] ?? null, ($probe['format'] ?? [])['duration'] ?? null] as $value) {
|
||
if (is_numeric($value) && (float) $value > 0) {
|
||
return (float) $value;
|
||
}
|
||
}
|
||
return 0.0;
|
||
}
|
||
|
||
/** @return array{0:string,1:float}|null the fraction as written and its value, null for none */
|
||
private static function rate(string $text): ?array
|
||
{
|
||
if (!preg_match('#^(\d+)(?:/(\d+))?$#', $text, $m)) {
|
||
return null;
|
||
}
|
||
$num = (int) $m[1];
|
||
$den = isset($m[2]) ? (int) $m[2] : 1;
|
||
if ($num <= 0 || $den <= 0) {
|
||
return null;
|
||
}
|
||
return [$den === 1 ? (string) $num : "{$num}/{$den}", $num / $den];
|
||
}
|
||
}
|