Files
adminandClaude Opus 5.5 31ce6480b2
feuerwerk / gate (push) Successful in 4m30s
feuerwerk / suite (push) Successful in 18m12s
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>
2026-10-13 16:45:20 +02:00

152 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
/**
* Converts what waits in the VideoQueue, one video at a time (#402). Runs in the container
* `feuerwerk-video` through `tools/video-worker.php`, and in CI beside the PHP server.
*
* Each job goes: probe the upload, convert it into a temp file beside its target, read the result
* back for its details, cut a poster, then move all three into place and drop the job. Whatever
* fails on the way fails the job with ffmpeg's own words, and the temp files go.
*/
final class VideoWorker
{
/** How often a running conversion writes its progress, in seconds. */
private const PROGRESS_EVERY = 0.5;
public function __construct(private readonly VideoStore $store, private readonly string $videos)
{
}
/** Converts the oldest waiting video; false when there was none. */
public function runOnce(): bool
{
$job = $this->store->queue()->next();
if ($job === null) {
return false;
}
$this->convert((string) $job['folder'], (string) $job['hash']);
return true;
}
private function convert(string $folder, string $hash): void
{
$queue = $this->store->queue();
if (!$queue->update($folder, $hash, ['state' => VideoQueue::CONVERTING, 'progress' => 0, 'error' => ''])) {
return;
}
$dir = "{$this->videos}/{$folder}";
$tag = bin2hex(random_bytes(4));
$tmpVideo = "{$dir}/{$hash}.{$tag}.tmp.mp4";
$tmpPoster = "{$dir}/{$hash}.{$tag}.tmp.jpg";
try {
$plan = VideoFormat::plan(self::probe($queue->source($folder, $hash)));
$finished = $this->run(
VideoFormat::convertArgs($queue->source($folder, $hash), $tmpVideo, $plan),
$plan['duration'],
static fn(float $p): bool => $queue->update($folder, $hash, ['progress' => round($p, 3)]),
);
if (!$finished) {
return;
}
$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;
}
chmod($tmpVideo, AtomicFile::SERVED);
chmod($tmpPoster, AtomicFile::SERVED);
VideoStore::writeDetails($dir, $hash, $details);
rename($tmpPoster, "{$dir}/{$hash}.jpg");
rename($tmpVideo, "{$dir}/{$hash}.mp4");
$queue->remove($folder, $hash);
} catch (Throwable $e) {
$queue->dropSource($folder, $hash);
$queue->update($folder, $hash, ['state' => VideoQueue::FAILED, 'error' => self::shorten($e->getMessage())]);
} finally {
foreach ([$tmpVideo, $tmpPoster] as $path) {
if (is_file($path)) {
unlink($path);
}
}
}
}
/** @return array<string,mixed> */
private static function probe(string $path): array
{
[$code, $out, $err] = self::capture(VideoFormat::probeArgs($path));
$probe = json_decode($out, true);
if ($code !== 0 || !is_array($probe)) {
throw new RuntimeException('ffmpeg cannot read this file' . ($err === '' ? '' : ": {$err}"));
}
return $probe;
}
/**
* Runs ffmpeg, reporting progress from its `-progress pipe:1` lines to `$report`. Returns false
* when `$report` says the job is gone, after stopping ffmpeg.
*
* @param list<string> $args
* @param (callable(float):bool)|null $report
*/
private function run(array $args, float $duration, ?callable $report): bool
{
$err = tempnam(sys_get_temp_dir(), 'video-err-');
$process = proc_open($args, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['file', $err, 'w']], $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Cannot start ffmpeg');
}
$last = 0.0;
$alive = true;
while (($line = fgets($pipes[1])) !== false) {
$share = $report === null ? null : VideoFormat::progress($line, $duration);
if ($share !== null && microtime(true) - $last >= self::PROGRESS_EVERY) {
$last = microtime(true);
if (!$report($share)) {
$alive = false;
proc_terminate($process);
break;
}
}
}
fclose($pipes[1]);
$code = proc_close($process);
$message = trim((string) file_get_contents($err));
unlink($err);
if (!$alive) {
return false;
}
if ($code !== 0) {
throw new RuntimeException($message === '' ? "ffmpeg stopped with code {$code}" : $message);
}
return true;
}
/**
* @param list<string> $args
* @return array{0:int,1:string,2:string}
*/
private static function capture(array $args): array
{
$process = proc_open($args, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Cannot start ffprobe');
}
$out = (string) stream_get_contents($pipes[1]);
$err = trim((string) stream_get_contents($pipes[2]));
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($process), $out, $err];
}
/** The last lines of what ffmpeg said: enough to tell why, not a log. */
private static function shorten(string $message): string
{
$lines = array_values(array_filter(array_map('trim', explode("\n", $message)), static fn(string $l): bool => $l !== ''));
$text = implode(' · ', array_slice($lines, -2));
return mb_strlen($text) > 300 ? mb_substr($text, 0, 300) . '…' : $text;
}
}