hls-test/transcode-segment.php
2023-02-13 14:05:37 -08:00

411 lines
12 KiB
PHP

<?php
function run( $prog, $params ) {
$cmd = escapeshellcmd( $prog ) . " " . implode( ' ', array_map( 'escapeshellarg', $params ) );
echo "\n$cmd\n\n";
$output = [];
$code = 0;
if ( exec($cmd, $output, $code) === false ) {
throw new Exception( 'failed to exec ffmpeg' );
}
if ( $code ) {
throw new Exception( "ffmpeg returned coded $code" );
}
return $output;
}
function ffprobe( $file ) {
$output = run( 'ffprobe', [ '-hide_banner', '-show_format', '-show_streams', '-print_format', 'json', '--', $file ] );
$json = implode( "\n", $output );
return json_decode( $json );
}
class Audio {
public const FORMATS = [
'aac' => [
'container' => 'mp4',
'options' => [
'-acodec', 'aac',
'-ar', 44100,
'-ac', 2,
'-b:a', '112k',
],
],
'opus' => [
'container' => 'mp4',
'options' => [
'-acodec', 'libopus',
'-ar', 48000,
'-ac', 2,
'-b:a', '96k',
],
],
'vorbis.webm' => [
'container' => 'webm',
'options' => [
'-acodec', 'libvorbis',
'-b:a', '112k',
],
],
// with the added id3 timestamps this work great with iOS HLS
// but mac safari doesn't seem happy with anything i do with them
'mp3' => [
'container' => 'mp3',
'options' => [
'-acodec', 'libmp3lame',
'-ar', 44100,
'-ac', 2,
'-b:a', '128k',
],
],
// works on iOS HLS but seems stuttery? i dunno why
// mac safari doesn't seem to like it either
'mp3.ts' => [
'container' => 'ts',
'options' => [
'-acodec', 'libmp3lame',
'-ar', 44100,
'-ac', 2,
'-b:a', '128k',
],
],
// no dice on ios or macos safari
'mp3.mp4' => [
'container' => 'mp4',
'options' => [
'-acodec', 'libmp3lame',
'-ar', 44100,
'-ac', 2,
'-b:a', '128k',
],
]
];
}
class Video {
// Normalize input frame rates to the next up of these.
// Lets us ensure that keyframes are places where they belong.
public const RATES = [
15, 24, 25, 30, 48, 50, 60
];
public const FORMATS = [
'mjpeg' => [
// it doesn't seem to like this after all. worth trying!
'container' => 'mov',
'options' => [
'common' => [
'-vcodec', 'mjpeg',
],
'fallback' => [
// no specific options :D
],
],
'resolutions' => [
'144p' => [
'width' => 176,
'height' => 144,
'bitrate' => '1024k',
]
]
],
'vp9' => [
'container' => 'mp4',
'options' => [
'common' => [
'-vcodec', 'libvpx-vp9',
'-row-mt', '1',
'-tile-columns', '4',
],
'fast' => [
'-quality', 'realtime',
'-cpu-used', '5',
],
'pass1' => [
'-quality', 'good',
'-cpu-used', '2',
'-pass', '1',
],
'pass2' => [
'-quality', 'good',
'-cpu-used', '1',
'-pass', '2',
]
],
'resolutions' => [
'240p' => [
'width' => 426,
'height' => 240,
'bitrate' => '150k',
],
'360p' => [
'width' => 640,
'height' => 360,
'bitrate' => '250k',
],
'480p' => [
'width' => 854,
'height' => 480,
'bitrate' => '750k',
],
'720p' => [
'width' => 1280,
'height' => 720,
'bitrate' => '2500k',
],
'1080p' => [
'width' => 1920,
'height' => 1080,
'bitrate' => '5000k',
],
'1440p' => [
'width' => 2560,
'height' => 1440,
'bitrate' => '9000k',
],
'2160p' => [
'width' => 3840,
'height' => 2160,
'bitrate' => '12500k',
],
],
],
];
}
class Fraction {
public $numerator = 0;
public $denominator = 0;
public function __construct( $num, $denom ) {
$this->numerator = $num;
$this->denominator = $denom;
}
public function toFloat() {
return $this->numerator / $this->denominator;
}
public function toString() {
return "$this->numerator/$this->denominator";
}
public static function fromString( $frac ) {
list ( $num, $denom ) = array_map( 'intval', explode( '/', $frac, 2 ) );
return new Fraction( $num, $denom );
}
}
class SourceFile {
public $filename = '';
public $duration = 0.0;
public $video = false;
public $width = 0;
public $height = 0;
public $fps = null;
public $audio = false;
public $sampleRate = 0;
public $channels = 0;
public function __construct( $filename ) {
$this->filename = $filename;
$data = ffprobe( $filename );
$this->duration = $data->format->duration;
foreach ( $data->streams as $stream ) {
if ( $stream->codec_type == 'video' && !$this->video ) {
$this->video = true;
$this->width = $stream->width;
$this->height = $stream->height;
$this->fps = Fraction::fromString( $stream->r_frame_rate );
}
if ( $stream->codec_type === 'audio' && !$this->audio ) {
$this->audio = true;
$this->sampleRate = $stream->sample_rate;
$this->channels = $stream->channels;
}
}
}
}
class Transcoder {
private $source = null;
private $fps = 0;
private $gop = 0;
public const SEGMENT_DURATION = 10;
public function __construct( SourceFile $source ) {
$this->source = $source;
// Normalize input fps to an even standard
$infps = $this->source->fps->toFloat();
$this->fps = Video::RATES[0];
foreach ( Video::RATES as $rate ) {
if ( $rate >= $infps ) {
$this->fps = $rate;
break;
}
}
// Each self-contained group of pictures starts with a keyframe.
$this->gop = $this->fps * self::SEGMENT_DURATION;
}
private function ffmpeg( $options, $outfile, $container ) {
$playlist = "$outfile.m3u8";
$init = "$outfile.init.$container";
if ( $container == 'mp4' ) {
// HLS muxer seems to give the right options for fMP4
$segment = "$outfile.$container";
$segmentOptions = [
'-f', 'hls',
'-hls_segment_type', 'fmp4',
'-hls_flags', 'single_file',
'-hls_time', '10',
'-hls_playlist_type', 'vod',
'-hls_fmp4_init_filename', $init,
'-hls_segment_filename', $segment,
'-y', $playlist,
];
} elseif ( $container == 'ts' ) {
$segment = "$outfile.$container";
$segmentOptions = [
'-f', 'hls',
'-hls_segment_type', 'mpegts',
'-hls_flags', 'single_file',
'-hls_time', '10',
'-hls_playlist_type', 'vod',
'-hls_segment_filename', $segment,
'-y', $playlist,
];
} elseif ( $container == 'webm' ) {
$segment = "$outfile.%04d.$container";
$segmentOptions = [
'-f', 'segment',
'-segment_time', '10',
'-segment_list', $playlist,
'-y', $segment
];
} elseif ( $container == 'mov' ) {
// For MJPEG, MP4 doesn't work in Apple HLS for some reason
// but QuickTime is sortof ok for one segment?
// Note segment won't make single fMP4-style files though.
$segment = "$outfile.%04d.$container";
$segmentOptions = [
'-f', 'segment',
//'-segment_format_options', 'movflags=frag_keyframe+empty_moov',
//'-segment_format_options', 'movflags=+frag_keyframe+empty_moov+default_base_moof+faststart',
'-segment_time', '10',
'-segment_list', $playlist,
'-y', $segment
];
} elseif ( $container == 'mp3' ) {
// For MP3, segment it raw.
// We'll need to postprocess to add an ID3 tag with timestamp
// and to reassemble into a file with byte ranges.
$segment = "$outfile.%04d.$container";
$segmentOptions = [
'-f', 'segment',
'-segment_format_options', 'id3v2_version=0:write_xing=0:write_id3v1=0',
'-segment_time', '10',
'-segment_list', $playlist,
'-y', $segment
];
} else {
die( 'missing container in config' );
}
$ffmpegOptions = array_merge( [
'-hide_banner',
'-i', $this->source->filename,
], $options, $segmentOptions);
$output = run( 'ffmpeg', $ffmpegOptions );
}
public function video( $codec, $resolution, $mode ) {
if ( !$this->source->video ) {
throw new Error('no video');
}
$format = Video::FORMATS[$codec];
$res = $format['resolutions'][$resolution];
$options = array_merge(
[
'-pix_fmt', 'yuv420p',
'-r', $this->fps,
],
$format['options']['common'],
$format['options'][$mode],
[
'-vf', "scale=" . implode( ':', [ $res['width'], $res['height'] ] ),
'-b:v', $res['bitrate'],
'-g', $this->gop,
'-keyint_min', $this->gop, // may not be generic enough
'-an',
]
);
$this->ffmpeg(
$options,
"{$this->source->filename}.{$resolution}.{$codec}.{$mode}",
$format['container']
);
}
public function audio( $codec ) {
if ( !$this->source->audio ) {
throw new Error('no audio');
}
$format = Audio::FORMATS[$codec];
$options = array_merge(
$format['options'],
[
'-vn',
]
);
$this->ffmpeg(
$options,
"{$this->source->filename}.audio.{$codec}",
$format['container']
);
}
}
$infiles = [
'caminandes-llamigos.webm',
];
foreach ( $infiles as $filename ) {
$source = new SourceFile( $filename );
$codec = new Transcoder( $source );
$codec->audio('aac');
$codec->audio('opus');
//$codec->audio('vorbis.webm');
$codec->audio('mp3');
$codec->audio('mp3.ts');
$codec->audio('mp3.mp4');
foreach ( Video::FORMATS['mjpeg']['resolutions'] as $res => $format ) {
$codec->video('mjpeg', $res, 'fallback');
}
foreach ( Video::FORMATS['vp9']['resolutions'] as $res => $format ) {
if ( $format['width'] <= $source->width && $format['height'] <= $source->height ) {
$codec->video('vp9', $res, 'fast');
}
}
foreach ( Video::FORMATS['vp9']['resolutions'] as $res => $format ) {
if ( $format['width'] <= $source->width && $format['height'] <= $source->height ) {
$codec->video('vp9', $res, 'pass1');
$codec->video('vp9', $res, 'pass2');
}
}
}