419 lines
12 KiB
PHP
Executable file
419 lines
12 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,
|
|
'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,
|
|
];
|
|
|
|
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" .
|
|
" --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 ) {
|
|
$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'] );
|
|
$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";
|
|
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'];
|
|
|
|
$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();
|
|
}
|
|
}
|