#!/usr/bin/env php false, 'letterbox' => false, 'no-audio' => false, 'exposure' => '0', // stops 'peak' => '1000', // '10000' is max 'preset' => 'medium', 'fps' => '60000/1001', 'size' => $maxBytes, 'quality' => 1.0, 'hdr' => false, 'dither' => false, 'width' => false, 'height' => false, 'keyframe-int' => 0, 'vibrance' => 0, 'crop-width' => false, 'crop-height' => false, 'crop-left' => false, 'crop-top' => false, ]; 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" . " --crop crop to 16:9\n" . " --letterbox pad to 16:9\n" . " --no-audio strip audio\n" . " --exposure=n adjust exposure\n" . " --peak=n set HDR peak nits\n" . " --preset=key set h.264 encoding preset\n" . " --fps=n frame rate limit\n" . " --size=n target file size in bytes (default 3.5M)\n" . " --quality=n fraction of base bitrate to break on (deafult 0.75)\n" . " --hdr force HDR input processing on\n" . " --dither enable dithering in 8-bit downconversion\n" . " --width=n override frame width in pixels\n" . " --height=n override frame height in pixels\n" . " --keyframe-int=n set keyframe interval (default 0)\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 evenize( $n ) { $n = round( $n ); if ( $n & 1 ) { $n++; } return $n; } 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 ) { $maxBits = 8 * sizify( $options['size'] ); $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; $cropLeft = 0; $cropTop = 0; if ( $options['crop-width'] ) { $cropWidth = intval( $options['crop-width'] ); $cropLeft = intval( ( $width - $cropWidth ) / 2 ); $width = $cropWidth; } if ( $options['crop-height'] ) { $cropHeight = intval( $options['crop-height'] ); $cropTop = intval( ( $height - $cropHeight ) / 2 ); $height = $cropHeight; } if ( $options['crop-top'] ) { $cropTop = intval( $options['crop-top'] ); } if ( $options['crop-left'] ) { $cropLeft = intval( $options['crop-left'] ); } // @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']; if ( $options['keyframe-int'] ) { $keyframeInt = intval( $options['keyframe-int'] ); } else { $keyframeInt = intval( ceil( $duration * 60 ) ); } $bitrate = floor( $maxBits / $duration ); if ( $options[ 'no-audio' ] || count( $audioTracks ) == 0 ) { $audio = [ '-an' ]; } else { $audioBitrate = 96 * 1000; $audio = [ '-ac', 2, '-b:a', $audioBitrate, ]; $bitrate -= $audioBitrate; } $bitrate = max( $bitrate, 16000 ); $mbits = 1000 * 1000; $base = intval( $mbits * floatval( $options['quality'] ) ); if ( $bitrate < 0.125 * $base || $height < 144 ) { $frameWidth = 256; $frameHeight = 144; $bitrate = min( $bitrate, $base * 0.25 ); } elseif ( $bitrate < 0.25 * $base || $height < 180 ) { $frameWidth = 320; $frameHeight = 180; $bitrate = min( $bitrate, $base * 0.5 ); } elseif ( $bitrate < 0.5 * $base || $height < 288 ) { $frameWidth = 512; $frameHeight = 288; $bitrate = min( $bitrate, $base * 0.5 ); } elseif ( $bitrate < 1 * $base || $height < 480 ) { $frameWidth = 640; $frameHeight = 360; $bitrate = min( $bitrate, $base ); } elseif ( $bitrate < 2 * $base || $height < 540) { $frameWidth = 854; $frameHeight = 480; $bitrate = min( $bitrate, $base * 2 ); } elseif ( $bitrate < 2.5 * $base || $height < 720) { $frameWidth = 960; $frameHeight = 540; $bitrate = min( $bitrate, $base * 2.5 ); } elseif ( $bitrate < 4 * $base || $height < 1080) { $frameWidth = 1280; $frameHeight = 720; $bitrate = min( $bitrate, $base * 4 ); } elseif ( $bitrate < 8 * $base || $height < 1440) { $frameWidth = 1920; $frameHeight = 1080; $bitrate = min( $bitrate, $base * 8 ); } elseif ( $bitrate < 16 * $base || $height < 2160) { $frameWidth = 2560; $frameHeight = 1440; $bitrate = min( $bitrate, $base * 16 ); } else { $frameWidth = 3840; $frameHeight = 2160; $bitrate = min( $bitrate, $base * 32 ); } $aspect = $width / $height; $pixels = $width * $height; // canonical min rate is 0.125 megabit at 144p $bitrate = max( $bitrate, 0.125 * $base ); /* $minWidth = 640; $minHeight = 360; $baseWidth = 854; $baseHeight = 480; $pixelsPerBit = ( $baseWidth * $baseHeight ) / $base; $maxWidth = 1920; $maxHeight = 1080; $maxrate = $base * ( $maxWidth * $maxHeight ) / ( $baseWidth * $baseHeight ); $pixels = $bitrate * $pixelsPerBit; $frameHeight = evenize( sqrt( $pixels / $aspect ) ); $frameWidth = evenize( $frameHeight * $aspect ); if ( $aspect > 16 / 9 ) { if ( $frameWidth < $minWidth ) { $frameWidth = $minWidth; $frameHeight = evenize( $frameWidth / $aspect ); } elseif ( $frameWidth > $maxWidth ) { $frameWidth = $maxWidth; $frameHeight = evenize( $frameWidth / $aspect ); $bitrate = min( $bitrate, $maxrate ); } } else { if ( $frameHeight < $minHeight ) { $frameHeight = $minHeight; $frameWidth = evenize( $frameHeight * $aspect ); } elseif ( $frameWidth > $maxWidth ) { $frameHeight = $maxHeight; $frameWidth = evenize( $frameHeight * $aspect ); $bitrate = min( $bitrate, $maxrate ); } } */ if ( $options['width'] ) { $frameWidth = intval( $options['width'] ); } if ( $options['height'] ) { $frameHeight = intval( $options['height'] ); } $wide = $aspect > ( $frameWidth / $frameHeight ); $crop = boolval( $options['crop'] ); $letterbox = boolval( $options['letterbox'] ); if ( $crop ) { if ( $wide ) { $scaleHeight = $frameHeight; $scaleWidth = evenize( $frameHeight * $aspect ); } else { $scaleWidth = $frameWidth; $scaleHeight = evenize( $frameWidth / $aspect ); } } else { if ( $wide ) { $scaleWidth = $frameWidth; $scaleHeight = evenize( $frameWidth / $aspect ); } else { $scaleHeight = $frameHeight; $scaleWidth = evenize( $frameHeight * $aspect ); } } $exposure = floatval( $options['exposure'] ); $peakNits = floatval( $options['peak'] ); $sdrNits = 80; $peak = $peakNits / $sdrNits; $vibrance = floatval( $options['vibrance'] ); $filters = []; if ( $options['crop-width'] || $options['crop-height'] ) { $filters[] = "crop=w=$width:x=$cropLeft:h=$height:y=$cropTop"; } $filters[] = "scale=w=$scaleWidth:h=$scaleHeight"; if ( $hdr ) { $filters[] = "zscale=t=linear"; if ( $exposure ) { $filters[] = "exposure=$exposure"; } $filters[] = "tonemap=hable:peak=$peak:desat=0.0"; if ( $options['dither'] ) { $dither = ":dither=ordered"; } else { $dither = ""; } $filters[] = "zscale=tin=linear:t=709:p=709:m=709:r=full$dither"; if ( $vibrance ) { $filters[] = "vibrance=$vibrance"; } } $filters[] = "format=yuv420p"; if ( $crop ) { $filters[] = "crop=w=$frameWidth:h=$frameHeight"; } elseif ( $letterbox ) { $offsetX = round( ( $frameWidth - $scaleWidth) / 2 ); $offsetY = round( ( $frameHeight - $scaleHeight) / 2 ); $filters[] = "pad=w=$frameWidth:h=$frameHeight:x=$offsetX:y=$offsetY"; } $vf = implode( ',', $filters ); $fps = $options['fps']; $preset = $options['preset']; $tempPrefix = 'pack-vid-passlog' . rand(0,1 << 31); $passlog = tempnam( '.', $tempPrefix ); run( 'ffmpeg', array_merge( [ '-i', $src, '-f', 'mp4', '-fpsmax', $fps, '-vf', $vf, '-c:v', 'libx264', '-b:v', $bitrate, '-preset', $preset, '-pass', '1', '-passlogfile', $passlog, '-g', $keyframeInt, ], $audio, [ '-y', '/dev/null' ] ) ); run( 'ffmpeg', array_merge( [ '-i', $src, '-vf', $vf, '-fpsmax', $fps, '-c:v', 'libx264', '-b:v', $bitrate, '-preset', $preset, '-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(); } }