diff --git a/api/lib/VideoFormat.php b/api/lib/VideoFormat.php index 74c29442..13a00822 100644 --- a/api/lib/VideoFormat.php +++ b/api/lib/VideoFormat.php @@ -30,17 +30,8 @@ final class VideoFormat public static function plan(array $probe): array { $streams = is_array($probe['streams'] ?? null) ? $probe['streams'] : []; - $video = null; - $audio = false; - foreach ($streams as $stream) { - $type = $stream['codec_type'] ?? ''; - // 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 ($type === 'video' && $video === null && !$still) { - $video = $stream; - } - $audio = $audio || $type === 'audio'; - } + $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'); } @@ -129,20 +120,45 @@ final class VideoFormat /** * A still from a little way in (not the very first frame, which is often black), as wide as the - * picker draws it. + * 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 */ - public static function posterArgs(string $video, string $target, float $duration): array + 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=320:-2', '-q:v', '4', '-f', 'image2', $target, + '-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 $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 */ public static function probeArgs(string $path): array { @@ -177,6 +193,22 @@ final class VideoFormat return max(0.0, min(1.0, ((int) $m[1]) / 1e6 / $duration)); } + /** + * @param array $probe + * @return array|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 $video */ private static function rotation(array $video): int { diff --git a/api/lib/VideoWorker.php b/api/lib/VideoWorker.php index ec11ed89..788607dd 100644 --- a/api/lib/VideoWorker.php +++ b/api/lib/VideoWorker.php @@ -49,8 +49,9 @@ final class VideoWorker if (!$finished) { return; } - $details = VideoFormat::details(self::probe($tmpVideo)); - $this->run(VideoFormat::posterArgs($tmpVideo, $tmpPoster, $details['duration']), 0, null); + $converted = self::probe($tmpVideo); + $details = VideoFormat::details($converted); + $this->run(VideoFormat::posterArgs($tmpVideo, $tmpPoster, $details['duration'], VideoFormat::colour($converted)), 0, null); if ($queue->job($folder, $hash) === null) { return; } diff --git a/tests/php/videos.test.php b/tests/php/videos.test.php index 2e7a688a..35b78942 100644 --- a/tests/php/videos.test.php +++ b/tests/php/videos.test.php @@ -80,6 +80,13 @@ check('nor is a song with a cover picture', $noPicture(['streams' => [stream('au check('a progress line says how far', VideoFormat::progress("out_time_us=2500000\n", 10.0) === 0.25); check('other lines say nothing', VideoFormat::progress('frame=12', 10.0) === null && VideoFormat::progress('out_time_us=1', 0.0) === null); check('progress never passes the end', VideoFormat::progress('out_time_us=99000000', 10.0) === 1.0); +$colourOf = static fn(array $video): array => VideoFormat::colour(['streams' => [stream('video', $video)]]); +check('a poster reads a video as the video says', $colourOf(['color_space' => 'bt709', 'height' => 360, 'color_range' => 'tv']) === ['matrix' => 'bt709', 'range' => 'tv']); +check('BT.601 by either of its names', $colourOf(['color_space' => 'smpte170m', 'height' => 1080])['matrix'] === 'bt601' && $colourOf(['color_space' => 'bt470bg'])['matrix'] === 'bt601'); +check('without a word, as Chrome guesses: BT.709 from 720 lines', $colourOf(['height' => 720])['matrix'] === 'bt709' && $colourOf(['color_space' => 'unknown', 'height' => 1080])['matrix'] === 'bt709'); +check('and BT.601 below', $colourOf(['height' => 719])['matrix'] === 'bt601', $colourOf(['height' => 719])); +check('the full range only when it says so', $colourOf(['color_range' => 'pc'])['range'] === 'pc' && $colourOf([])['range'] === 'tv'); +check('the poster turns it into what a JPEG is read as', str_contains(VideoFormat::posterArgs('in', 'out', 2.0, ['matrix' => 'bt709', 'range' => 'tv'])[13], 'in_color_matrix=bt709:in_range=tv:out_color_matrix=bt601:out_range=pc')); // The store and its queue. $data = sys_get_temp_dir() . '/feuerwerk-php-videos-' . bin2hex(random_bytes(4)); @@ -229,6 +236,40 @@ check('asking again queues it once', $store->convert($later['file'])['status'] = check('the worker converts it from the kept upload', $worker->runOnce() && $store->record($later['file'])['status'] === 'ready' && $store->record($later['file'])['original'] === $later['original']); check('a video not there cannot be converted', $store->convert("{$folder}/" . str_repeat('0', 64) . '.mp4') === null && $store->convert('../x.mp4') === null); +// The poster is as red as the video (#411): tagged BT.709, and untagged on both sides of 720 lines. +/** A red picture in H.264, its colours turned with `$matrix`, said in the file only when `$tagged`. */ +function makeRed(string $path, string $size, string $matrix, bool $tagged): void +{ + $tags = $tagged ? ['-colorspace', $matrix, '-color_primaries', $matrix, '-color_trc', $matrix] : []; + $args = [ + 'ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', "color=0xff0000:size={$size}:rate=25:duration=0.6", + '-vf', "scale=out_color_matrix={$matrix}:out_range=tv", '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-qp', '0', + ...$tags, $path, + ]; + exec(implode(' ', array_map('escapeshellarg', $args)), $out, $code); + if ($code !== 0) { + throw new RuntimeException("cannot make {$path}"); + } +} + +/** @return list red, green, blue in the middle of a JPEG */ +function middleOf(string $jpeg): array +{ + $rgb = (string) shell_exec('ffmpeg -v error -i ' . escapeshellarg($jpeg) . ' -vf format=rgb24,crop=1:1:iw/2:ih/2 -f rawvideo -pix_fmt rgb24 -'); + return array_values(unpack('C3', $rgb) ?: []); +} + +foreach ([['1280x720', 'bt709', true], ['1280x720', 'bt709', false], ['640x360', 'smpte170m', false]] as [$size, $matrix, $tagged]) { + makeRed("{$tmp}/red.mp4", $size, $matrix, $tagged); + $red = $store->store((string) file_get_contents("{$tmp}/red.mp4"), 'red.mp4', $folder); + while ($worker->runOnce()) { + } + $rgb = middleOf("{$data}/videos/" . $store->record($red['file'])['poster']); + $said = $tagged ? 'saying so' : 'saying nothing'; + check("a red {$size} video in {$matrix}, {$said}, has a red poster", count($rgb) === 3 && $rgb[0] >= 245 && $rgb[1] <= 10 && $rgb[2] <= 10, $rgb); + $store->delete($red['file']); +} + check('deleting a converted video takes poster, details and the upload with it', $store->delete($big['file']) && glob("{$data}/videos/{$folder}/" . basename($big['file'], '.mp4') . '*') === []); check('deleting a file that is not there says so', $store->delete($big['file']) === false);