Give video posters the colours the video shows (#411)
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>
This commit is contained in:
1 parent
ba68167704
commit
31ce6480b2
3 files changed
+90
-16
No files matched your search
+46
-14
@@ -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<string>
|
||||
*/
|
||||
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<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
|
||||
{
|
||||
@@ -177,6 +193,22 @@ final class VideoFormat
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user