#!/usr/bin/env php [ 'width' => 640, 'height' => 360, 'codec' => 'mjpeg', 'bitrate' => '4000k', 'still' => true, ], '360p.hdr.thumb.avif' => [ 'width' => 640, 'height' => 360, 'codec' => 'libsvtav1', 'bitrate' => '2000k', 'hdr' => true, 'still' => true, ], '2160p.hdr.av1.webm' => [ 'width' => 3840, 'height' => 2160, 'codec' => 'libsvtav1', 'bitrate' => '25000k', 'hdr' => true, ], '2160p.sdr.av1.webm' => [ 'width' => 3840, 'height' => 2160, 'codec' => 'libsvtav1', 'bitrate' => '20000k', ], '2160p.sdr.h264.mp4' => [ 'width' => 3840, 'height' => 2160, 'codec' => 'libx264', 'bitrate' => '50000k', ], '1080p.hdr.av1.webm' => [ 'width' => 1920, 'height' => 1080, 'codec' => 'libsvtav1', 'bitrate' => '6000k', 'hdr' => true, ], '1080p.sdr.av1.webm' => [ 'width' => 1920, 'height' => 1080, 'codec' => 'libsvtav1', 'bitrate' => '4800k', ], '720p.hdr.hevc.mp4' => [ 'width' => 1280, 'height' => 720, 'codec' => 'libx265', 'bitrate' => '4800k', 'hdr' => true, ], '720p.sdr.h264.mp4' => [ 'width' => 1280, 'height' => 720, 'codec' => 'libx264', 'bitrate' => '4800k', ], ]; $options = [ 'no-audio' => false, 'exposure' => '0', // stops 'peak' => '1000', // '10000' is max 'preset' => 'medium', 'vibrance' => 0, ]; while ( count( $args ) > 0 && substr( $args[0], 0, 2 ) == '--' ) { $option = substr( array_shift( $args ), 2 ); $parts = explode( '=', $option, 2 ); if ( count( $parts ) == 2 ) { [ $key, $val ] = $parts; $options[$key] = $val; } else { $options[$option] = true; } } if ( count ( $args ) < 2 ) { die( "Usage: $self [options...] \n" . "Options:\n" . " --no-audio strip audio\n" . " --exposure=n adjust exposure\n" . " --peak=n set HDR peak nits\n" ); } [ $src, $dest ] = $args; convert( $src, $dest, $options ); exit(0); // function run( $cmd, $args ) { $commandLine = implode( ' ', array_merge( [ escapeshellcmd( $cmd ) ], array_map( 'escapeshellarg', $args ) ) ); echo "$commandLine\n"; $output = shell_exec( $commandLine ); if ( $output === false ) { throw new Error( "Failed to run $cmd" ); } return $output; } function ffprobe( $path ) { $output = run( 'ffprobe', [ '-hide_banner', '-show_format', '-show_streams', '-print_format', 'json', '--', $path ] ); $data = json_decode( $output ); if ( $data === null ) { throw new Error( "Failed to read JSON from ffprobe: $output" ); } return $data; } function sizify( $str ) { $matches = []; if ( preg_match( '/^(\d+(?:\.\d+)?)([kmgt]?)$/i', $str, $matches ) ) { [ , $digits, $suffix ] = $matches; $n = floatval( $digits ); switch ( strtolower( $suffix ) ) { case 't': $n *= 1000; // fall through case 'g': $n *= 1000; // fall through case 'm': $n *= 1000; // fall through case 'k': $n *= 1000; // fall through default: return $n; } return $n; } die( "Unexpected size format '$str'\n" ); } function extractTracks( $streams, $type ) { return array_values( array_filter( $streams, function ( $stream ) use ( $type ) { return $stream->codec_type === $type; } ) ); } function convert( $src, $dest, $options ) { global $profiles; $probe = ffprobe( $src ); $videoTracks = extractTracks( $probe->streams, 'video' ); $audioTracks = extractTracks( $probe->streams, 'audio' ); if ( count( $videoTracks ) == 0 ) { var_dump( $probe ); die("oh no\n"); } $track = $videoTracks[0]; $duration = floatval( $probe->format->duration ); $width = $track->width; $height = $track->height; // @fixme some files are missing this? trims from qt? //$hdr = $track->color_primaries === 'bt2020' || $options['hdr']; // pix_fmt: "yuv420p10le" $hdr = substr( $track->pix_fmt, -5 ) === 'p10le' || $options['hdr']; $keyframeInt = intval( ceil( $duration * 60 ) ); if (!preg_match( '/(\d+p)\.(.*?)$/', $dest, $matches ) ) { die('nooo'); } $profile = $matches[1] . '.' . $matches[2]; $codec = $profiles[$profile]['codec']; $bitrate = $profiles[$profile]['bitrate']; $scaleWidth = $profiles[$profile]['width']; $scaleHeight = $profiles[$profile]['height']; $tonemap = $hdr && !( $profiles[$profile]['hdr'] ?? false ); $still = $profiles[$profile]['still'] ?? false; if ( $still || $options[ 'no-audio' ] || count( $audioTracks ) == 0 ) { $audio = [ '-an' ]; } else { $audio = []; } if (!$codec ) { die('no'); } $exposure = floatval( $options['exposure'] ); $peakNits = floatval( $options['peak'] ); $sdrNits = 80; $peak = $peakNits / $sdrNits; $vibrance = floatval( $options['vibrance'] ); $filters = []; $filters[] = "scale=w=$scaleWidth:h=$scaleHeight"; if ( $tonemap) { $filters[] = "zscale=t=linear"; if ( $exposure ) { $filters[] = "exposure=$exposure"; } $filters[] = "tonemap=hable:peak=$peak:desat=0.0"; $filters[] = "zscale=tin=linear:t=709:p=709:m=709:r=full:dither=ordered"; if ( $vibrance ) { $filters[] = "vibrance=$vibrance"; } $filters[] = "format=yuv420p"; } $vf = implode( ',', $filters ); if ( $still ) { run( 'ffmpeg', [ '-i', $src, '-vf', $vf, '-c:v', $codec, '-b:v', $bitrate, '-update', 1, '-frames:v', 1, '-an', '-y', $dest ] ); } else { $tempPrefix = 'pack-vid-passlog' . rand(0,1 << 31); $passlog = tempnam( '.', $tempPrefix ); run( 'ffmpeg', array_merge( [ '-i', $src, '-f', 'null', '-vf', $vf, '-c:v', $codec, '-b:v', $bitrate, '-pass', '1', '-passlogfile', $passlog, '-g', $keyframeInt, ], $audio, [ '-y', '/dev/null' ] ) ); run( 'ffmpeg', array_merge( [ '-i', $src, '-vf', $vf, '-c:v', $codec, '-b:v', $bitrate, '-pass', '2', '-passlogfile', $passlog, '-g', $keyframeInt, ], $audio, [ '-movflags', '+faststart', '-y', $dest ] ) ); $len = strlen( $tempPrefix ); if ( $len > 0 ) { $dir = dir( './' ); for ( $entry = $dir->read(); $entry !== false; $entry = $dir->read() ) { if ( substr( $entry, 0, $len ) === $tempPrefix ) { print "...deleting temp file: $entry\n"; unlink( $entry ); } } $dir->close(); } } }