$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 = 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'; } 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 $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 */ 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. * * @return list */ public static function posterArgs(string $video, string $target, float $duration): array { $at = sprintf('%.3f', max(0.0, min(1.0, $duration / 2))); 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, ]; } /** @return list */ 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 $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 $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 $probe @param array $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]; } }