[ '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'); } } }