Brion Vibber
70d6c7366a
mjpeg in mov is not working in hls with multiple segments on ios mp3 raw with id3 timestamps works best on ios hls mp3 in ts works on ios, but seems stuttery weirdly! mp3 in mp4 just don't work
394 lines
12 KiB
PHP
394 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',
|
|
],
|
|
],
|
|
// 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 == '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('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');
|
|
}
|
|
}
|
|
}
|