pack-vid/pack-vid
Brion Vibber 97a21bd75f audio tweaks etc
* pass --no-audio through from pack-set to pack-vid
* don't reserve bitrate for audio if there's no audio track
2023-05-16 16:54:36 -07:00

291 lines
8.1 KiB
PHP
Executable file

#!/usr/bin/env php
<?php
// Squishes given video input into sub-4000kb .mp4
// Crops or pads to 16:9 (letterbox default; --crop to crop)
// Includes stereo audio (to strip, pass --no-audio)
// HDR to SDR tonemapping
// Picks bitrate to match
// Picks resolution based on bitrate target
// 2-pass encoding with libx264 veryslow
// fps limiter: warning requires newer ffmpeg than debian-buster
$args = $_SERVER['argv'];
$self = array_shift( $args );
$maxBytes = 4000 * 1000; // fit in 4MB
$maxBytes = $maxBytes * 15 / 16; // leave some headroom
$options = [
'crop' => false,
'letterbox' => false,
'no-audio' => false,
'exposure' => '0', // stops
'peak' => '1000', // '10000' is max
'preset' => 'medium',
'fps' => '60000/1001',
'size' => $maxBytes,
'quality' => 1.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...] <srcfile.mp4> <destfile.mp4>\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"
);
}
[ $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 = ceil( $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( $track->duration );
$width = $track->width;
$height = $track->height;
$hdr = $track->color_primaries === 'bt2020';
$keyframeInt = 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 < 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 );
} else {
$frameWidth = 1920;
$frameHeight = 1080;
$bitrate = min( $bitrate, $base * 8 );
}
$aspect = $width / $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;
$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";
$filters[] = "zscale=t=bt709:p=bt709:m=bt709:r=full";
$filters[] = "vibrance=0.20";
}
$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,
//'-r', $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,
//'-r', $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();
}
}