#!/usr/bin/env php false, 'letterbox' => false, 'no-audio' => false, 'audio-bitrate' => 96000, 'audio-rate' => false, 'audio-channels' => false, 'exposure' => '0', // stops 'peak' => '1000', // '10000' is max 'preset' => false, 'fps' => '60', 'size' => $maxBytes, 'quality' => 1.0, 'bitrate' => 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, 'tonemap' => 'hable', 'color-temperature' => false, 'vcodec' => 'libx264', 'stretch' => 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" . " --bitrate=n target bitrate (exclusive with --size)\n" . " --size=n target file size in bytes (exclusive with --bitrate)\n" . " --quality=n fraction of base bitrate to break on (deafult 1.0)\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" . " --tonemap=mode set HDR tone mapping mode (hable, clip, etc)\n" . " --color-temperature=n adjust color temp va 6500K\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 ) { $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 ) ); } if ( $options['bitrate'] ) { $bitrate = sizify( $options['bitrate'] ); } else if ( $options[ 'size' ] ) { $maxBits = 8 * sizify( $options['size'] ); $bitrate = floor( $maxBits / $duration ); } else { // Calculate a target bitrate from the size later $bitrate = null; } if ( $options[ 'no-audio' ] || count( $audioTracks ) == 0 ) { $audio = [ '-an' ]; } else { $audioBitrate = $options[ 'audio-bitrate' ]; $audio = [ '-b:a', $audioBitrate, ]; if ( $options['audio-channels'] ) { $audio[] = '-ac'; $audio[] = $options[ 'audio-channels' ]; } if ( $options[ 'audio-rate' ] ) { $audio[] = '-ar'; $audio[] = $options[ 'audio-rate' ]; } $bitrate -= $audioBitrate; } if ( $options[ 'width' ] && $options[ 'height' ] ) { // Use exact given dimensions. $frameWidth = intval( $options[ 'width' ] ); $frameHeight = intval( $options[ 'height' ] ); } else { // Select target resolution from the target bitrate... if ( !$bitrate ) { // If we didn't get given one, default to 5Mbits 1080HD $bitrate = 5000000; } $mbits = 1000 * 1000; $base = intval( $mbits * floatval( $options['quality'] ) ); if ( $bitrate < 0.125 * $base || $height < 144 ) { $frameWidth = 256; $frameHeight = 144; } elseif ( $bitrate < 0.25 * $base || $height < 180 ) { $frameWidth = 320; $frameHeight = 180; } elseif ( $bitrate < 0.5 * $base || $height < 288 ) { $frameWidth = 512; $frameHeight = 288; } elseif ( $bitrate < 1 * $base || $height < 480 ) { $frameWidth = 640; $frameHeight = 360; } elseif ( $bitrate < 2 * $base || $height < 540) { $frameWidth = 854; $frameHeight = 480; } elseif ( $bitrate < 2.5 * $base || $height < 720) { $frameWidth = 960; $frameHeight = 540; } elseif ( $bitrate < 4 * $base || $height < 1080) { $frameWidth = 1280; $frameHeight = 720; } elseif ( $bitrate < 8 * $base || $height < 1440) { $frameWidth = 1920; $frameHeight = 1080; } elseif ( $bitrate < 16 * $base || $height < 2160) { $frameWidth = 2560; $frameHeight = 1440; } else { $frameWidth = 3840; $frameHeight = 2160; } } $aspect = $width / $height; $pixels = $width * $height; if ( $options[ 'stretch' ] ) { // Use this option when making non-square output $scaleWidth = $frameWidth; $scaleHeight = $frameHeight; } else { // Assumes square pixels $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'] ); $tonemap = $options['tonemap']; $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:p=709:m=709"; if ( $exposure ) { $filters[] = "exposure=$exposure"; } $colortemperature = $options['color-temperature']; if ( $colortemperature ) { $filters[] = "colortemperature=$colortemperature"; } $filters[] = "tonemap=$tonemap: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']; if ( $options['preset'] ) { $preset = [ '-preset', $options[ 'preset' ] ]; } else { $preset = []; } $extension = pathinfo( $dest )[ 'extension' ]; if ( $extension === 'mp4' || $extension === 'mov' ) { $format = [ '-movflags', '+faststart' ]; } else if ( $extension === 'webm' || $extension === 'mkv' ) { $format = [ '-cues_to_front', 1 ]; } else { $format = []; } $vcodec = $options['vcodec']; $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', $vcodec, '-b:v', $bitrate, ], $preset, [ '-pass', '1', '-passlogfile', $passlog, '-g', $keyframeInt, '-an', '-y', '/dev/null' ] ) ); run( 'ffmpeg', array_merge( [ '-i', $src, '-vf', $vf, '-fpsmax', $fps, '-c:v', $vcodec, '-b:v', $bitrate, ], $preset, [ '-pass', '2', '-passlogfile', $passlog, '-g', $keyframeInt, ], $audio, $format, [ '-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(); } }