wip
This commit is contained in:
parent
1603c45b7a
commit
c3751acb49
7 changed files with 712 additions and 3 deletions
405
HLS/MP3Segmenter.php
Normal file
405
HLS/MP3Segmenter.php
Normal file
|
@ -0,0 +1,405 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* .m3u8 playlist generation for HLS (HTTP Live Streaming)
|
||||||
|
*
|
||||||
|
* @file
|
||||||
|
* @ingroup HLS
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace MediaWiki\TimedMediaHandler\HLS;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class MP3Segmenter extends Segmenter {
|
||||||
|
|
||||||
|
// http://www.mp3-tech.org/programmer/frame_header.html
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal layout of MP3 frame header bitfield
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private static $bits = [
|
||||||
|
'sync' => [ 21, 11 ],
|
||||||
|
'mpeg' => [ 19, 2 ],
|
||||||
|
'layer' => [ 17, 2 ],
|
||||||
|
'protection' => [ 16, 1 ],
|
||||||
|
'bitrate' => [ 12, 4 ],
|
||||||
|
'sampleRate' => [ 10, 2 ],
|
||||||
|
'padding' => [ 9, 1 ],
|
||||||
|
// below this not needed at present
|
||||||
|
'private' => [ 8, 1 ],
|
||||||
|
'channelMode' => [ 6, 2 ],
|
||||||
|
'modeExt' => [ 4, 2 ],
|
||||||
|
'copyright' => [ 3, 1 ],
|
||||||
|
'original' => [ 2, 1 ],
|
||||||
|
'emphasis' => [ 0, 2 ],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 11-bit sync mask for MP3 frame header
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private const SYNC_MASK = 0x7ff;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of sample count per frame based on version/mode
|
||||||
|
* This is just in case we need to measure non-default sample rates!
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private static $samplesPerFrame = [
|
||||||
|
// invalid / layer 3 / 2 / 1
|
||||||
|
|
||||||
|
// MPEG-2.5
|
||||||
|
[ 0, 576, 1152, 384 ],
|
||||||
|
// Reserved
|
||||||
|
[ 0, 0, 0, 0 ],
|
||||||
|
// MPEG-2
|
||||||
|
[ 0, 576, 1152, 384 ],
|
||||||
|
// MPEG-1
|
||||||
|
[ 0, 1152, 384, 384 ],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of sample rates based on version/mode
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private static $sampleRates = [
|
||||||
|
// MPEG-2.5
|
||||||
|
[ 11025, 12000, 8000, 1 ],
|
||||||
|
// Reserved
|
||||||
|
[ 1, 1, 1, 1 ],
|
||||||
|
// MPEG-2
|
||||||
|
[ 22050, 24000, 16000, 1 ],
|
||||||
|
// MPEG-1
|
||||||
|
[ 44100, 48000, 32000, 1 ],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of bit rates based on version/mode/code
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private static $bitrates = [
|
||||||
|
// MPEG-2
|
||||||
|
[
|
||||||
|
// invalid layer
|
||||||
|
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ],
|
||||||
|
// layer 3
|
||||||
|
[ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ],
|
||||||
|
// layer 2
|
||||||
|
[ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ],
|
||||||
|
// layer 1
|
||||||
|
[ 0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0 ],
|
||||||
|
],
|
||||||
|
// MPEG-1
|
||||||
|
[
|
||||||
|
// invalid layer
|
||||||
|
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ],
|
||||||
|
// layer 3
|
||||||
|
[ 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 192, 224, 256, 320, 0 ],
|
||||||
|
// layer 2
|
||||||
|
[ 0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0 ],
|
||||||
|
// layer 1
|
||||||
|
[ 0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 316, 448, 0 ],
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp resolution for HLS ID3 timestamp tags
|
||||||
|
*/
|
||||||
|
private const KHZ_90 = 90000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a binary field from the MP3 frame header
|
||||||
|
*/
|
||||||
|
private static function field( string $name, int $header ): int {
|
||||||
|
[ $shift, $bits ] = self::$bits[$name];
|
||||||
|
$mask = ( 1 << $bits ) - 1;
|
||||||
|
return ( $header >> $shift ) & $mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode an MP3 header bitfield
|
||||||
|
*/
|
||||||
|
private static function frameHeader( string $bytes ): ?array {
|
||||||
|
if ( strlen( $bytes ) < 4 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = unpack( "Nval", $bytes );
|
||||||
|
$header = $data['val'];
|
||||||
|
|
||||||
|
// This includes "MPEG 2.5" support, so checks for 11 set bits
|
||||||
|
// not 12 set bits as per original MPEG 1/2
|
||||||
|
$sync = self::field( 'sync', $header );
|
||||||
|
if ( $sync !== self::SYNC_MASK ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mpeg = self::field( 'mpeg', $header );
|
||||||
|
$layer = self::field( 'layer', $header );
|
||||||
|
$protection = self::field( 'protection', $header );
|
||||||
|
|
||||||
|
$br = self::field( 'bitrate', $header );
|
||||||
|
$bitrate = 1000 * self::$bitrates[$mpeg & 1][$layer][$br];
|
||||||
|
if ( $bitrate == 0 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sr = self::field( 'sampleRate', $header );
|
||||||
|
$sampleRate = self::$sampleRates[$mpeg][$sr];
|
||||||
|
if ( $sampleRate == 1 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$padding = self::field( 'padding', $header );
|
||||||
|
|
||||||
|
$samples = self::$samplesPerFrame[$mpeg][$layer];
|
||||||
|
$duration = $samples / $sampleRate;
|
||||||
|
$nbits = $duration * $bitrate;
|
||||||
|
$nbytes = $nbits / 8;
|
||||||
|
$size = intval( $nbytes );
|
||||||
|
|
||||||
|
if ( $protection == 0 ) {
|
||||||
|
$size += 2;
|
||||||
|
}
|
||||||
|
if ( $padding == 1 ) {
|
||||||
|
$size++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'samples' => $samples,
|
||||||
|
'sampleRate' => $sampleRate,
|
||||||
|
'size' => $size,
|
||||||
|
'duration' => $duration,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function id3Header( string $bytes ): ?array {
|
||||||
|
// ID3v2.3
|
||||||
|
// https://web.archive.org/web/20081008034714/http://www.id3.org/id3v2.3.0
|
||||||
|
// ID3v2/file identifier "ID3"
|
||||||
|
// ID3v2 version $03 00
|
||||||
|
// ID3v2 flags %abc00000
|
||||||
|
// ID3v2 size 4 * %0xxxxxxx
|
||||||
|
$headerLen = 10;
|
||||||
|
if ( strlen( $bytes ) < $headerLen ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = unpack( "a3tag/nversion/Cflags/C4size", $bytes );
|
||||||
|
if ( $data['tag'] !== 'ID3' ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = $headerLen +
|
||||||
|
( $data['size4'] |
|
||||||
|
( $data['size3'] << 7 ) |
|
||||||
|
( $data['size2'] << 14 ) |
|
||||||
|
( $data['size1'] << 21 ) );
|
||||||
|
return [
|
||||||
|
'size' => $size,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function parse(): void {
|
||||||
|
$file = fopen( $this->filename, 'rb' );
|
||||||
|
$stream = new OwningStreamReader( $file );
|
||||||
|
|
||||||
|
$timestamp = 0.0;
|
||||||
|
while ( true ) {
|
||||||
|
$start = $stream->pos();
|
||||||
|
$lookahead = 10;
|
||||||
|
$bytes = $stream->read( $lookahead );
|
||||||
|
if ( $bytes === null ) {
|
||||||
|
// end of file
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for MP3 frame header sync pattern
|
||||||
|
$header = self::frameHeader( $bytes );
|
||||||
|
if ( $header ) {
|
||||||
|
// Note we don't need the data at this time.
|
||||||
|
$stream->seek( $start + $header['size'] );
|
||||||
|
$timestamp += $header['duration'];
|
||||||
|
$this->segments[] = [
|
||||||
|
'start' => $start,
|
||||||
|
'size' => $header['size'],
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'duration' => $header['duration'],
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ID3v2 tag
|
||||||
|
$id3 = self::id3Header( $bytes );
|
||||||
|
if ( $id3 ) {
|
||||||
|
// For byte range purposes; count as zero duration
|
||||||
|
$stream->seek( $start + $id3['size'] );
|
||||||
|
$this->segments[] = [
|
||||||
|
'start' => $start,
|
||||||
|
'size' => $id3['size'],
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'duration' => 0.0,
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception( "Not a valid MP3 or ID3 frame at $start" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite the file to include ID3 private tags with timestamp
|
||||||
|
* data for HLS at segment boundaries. This will modify the file
|
||||||
|
* in-place and change the segment offsets and sizes in the object.
|
||||||
|
*/
|
||||||
|
public function rewrite(): void {
|
||||||
|
$offset = 0;
|
||||||
|
$id3s = [];
|
||||||
|
$segments = [];
|
||||||
|
foreach ( $this->segments as $i => $orig ) {
|
||||||
|
$id3 = self::timestampTag( $orig['timestamp'] );
|
||||||
|
$delta = strlen( $id3 );
|
||||||
|
$id3s[$i] = $id3;
|
||||||
|
$segments[$i] = [
|
||||||
|
'start' => $orig['start'] + $offset,
|
||||||
|
'size' => $orig['size'] + $delta,
|
||||||
|
'timestamp' => $orig['timestamp'],
|
||||||
|
'duration' => $orig['duration'],
|
||||||
|
];
|
||||||
|
$offset += $delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = fopen( $this->filename, 'rw+b' );
|
||||||
|
$stream = new OwningStreamReader( $file );
|
||||||
|
|
||||||
|
echo "old: ";
|
||||||
|
var_dump( $this->segments );
|
||||||
|
|
||||||
|
echo "new: ";
|
||||||
|
var_dump( $segments );
|
||||||
|
|
||||||
|
// Move each segment forward, starting at the lastmost to work in-place.
|
||||||
|
$preserveKeys = true;
|
||||||
|
foreach ( array_reverse( $this->segments, $preserveKeys ) as $i => $orig ) {
|
||||||
|
$stream->seek( $orig['start'] );
|
||||||
|
$bytes = $stream->readExactly( $orig['size'] );
|
||||||
|
|
||||||
|
$stream->seek( $segments[$i]['start'] );
|
||||||
|
$stream->write( $id3s[$i] );
|
||||||
|
$stream->write( $bytes );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->segments = $segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an ID3 private tag with a timestamp for use in HLS
|
||||||
|
* streams of raw media data such as MP3 or AAC.
|
||||||
|
*/
|
||||||
|
protected static function timestampTag( float $timestamp ): string {
|
||||||
|
/*
|
||||||
|
PRIV frame type
|
||||||
|
|
||||||
|
should contain:
|
||||||
|
|
||||||
|
The ID3 PRIV owner identifier MUST be
|
||||||
|
"com.apple.streaming.transportStreamTimestamp". The ID3 payload MUST
|
||||||
|
be a 33-bit MPEG-2 Program Elementary Stream timestamp expressed as a
|
||||||
|
big-endian eight-octet number, with the upper 31 bits set to zero.
|
||||||
|
Clients SHOULD NOT play Packed Audio Segments without this ID3 tag.
|
||||||
|
|
||||||
|
https://id3.org/id3v2.4.0-frames
|
||||||
|
https://id3.org/id3v2.4.0-structure
|
||||||
|
|
||||||
|
bit order is MSB first, big-endian
|
||||||
|
|
||||||
|
header 10 bytes
|
||||||
|
extended header (var, optional)
|
||||||
|
frames (variable)
|
||||||
|
pading (variable, optional)
|
||||||
|
footer (10 bytes, optional)
|
||||||
|
|
||||||
|
|
||||||
|
header:
|
||||||
|
"ID3"
|
||||||
|
version: 16 bits $04 00
|
||||||
|
flags: 32 bits
|
||||||
|
idv2 size: 32 bits (in chunks of 4 bytes, not counting header or footer)
|
||||||
|
|
||||||
|
flags:
|
||||||
|
bit 7 - unsyncrhonization (??)
|
||||||
|
bit 6 - extended header
|
||||||
|
bit 5 - experimental indicator
|
||||||
|
bit 4 - footer present
|
||||||
|
|
||||||
|
frame:
|
||||||
|
id - 32 bits (four chars)
|
||||||
|
size - 32 bits (in chunks of 4 bytes, excluding frame header)
|
||||||
|
flags - 16 bits
|
||||||
|
(frame data)
|
||||||
|
|
||||||
|
priv payload:
|
||||||
|
owner text string followed by \x00
|
||||||
|
(binary data)
|
||||||
|
|
||||||
|
The timestamps... I think... have 90 kHz integer resolution
|
||||||
|
so convert from the decimal seconds in the HLS
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
$owner = "com.apple.streaming.transportStreamTimestamp\x00";
|
||||||
|
$pts = round( $timestamp * self::KHZ_90 );
|
||||||
|
$thirtyThreeBits = pow( 2, 33 );
|
||||||
|
$thirtyOneBits = pow( 2, 31 );
|
||||||
|
if ( $pts >= $thirtyThreeBits ) {
|
||||||
|
// make sure they won't get too big for 33 bits
|
||||||
|
// this allows about a 24 hour media length
|
||||||
|
throw new Exception( "Timestamp overflow in MP3 output stream: $pts >= $thirtyThreeBits" );
|
||||||
|
}
|
||||||
|
$pts_high = intval( floor( $pts / $thirtyOneBits ) );
|
||||||
|
$pts_low = intval( $pts - ( $pts_high * $thirtyOneBits ) );
|
||||||
|
|
||||||
|
// Private frame payload
|
||||||
|
$frame_data = pack(
|
||||||
|
'a*NN',
|
||||||
|
$owner,
|
||||||
|
$pts_high,
|
||||||
|
$pts_low,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Private frame header
|
||||||
|
$frame_type = 'PRIV';
|
||||||
|
$frame_flags = 0;
|
||||||
|
$frame_length = strlen( $frame_data );
|
||||||
|
if ( $frame_length > 127 ) {
|
||||||
|
throw new Error( "Should never happen: too large ID3 frame data" );
|
||||||
|
}
|
||||||
|
$frame = pack(
|
||||||
|
'a4Nna*',
|
||||||
|
$frame_type,
|
||||||
|
$frame_length,
|
||||||
|
$frame_flags,
|
||||||
|
$frame_data
|
||||||
|
);
|
||||||
|
|
||||||
|
// ID3 tag
|
||||||
|
$tag_type = 'ID3';
|
||||||
|
$tag_version = 0x0400;
|
||||||
|
$tag_flags = 0;
|
||||||
|
// if >127 bytes may need to adjust
|
||||||
|
$tag_length = strlen( $frame );
|
||||||
|
if ( $tag_length > 127 ) {
|
||||||
|
throw new Error( "Should never happen: too large ID3 tag" );
|
||||||
|
}
|
||||||
|
$tag = pack(
|
||||||
|
'a3nCNa*',
|
||||||
|
$tag_type,
|
||||||
|
$tag_version,
|
||||||
|
$tag_flags,
|
||||||
|
$tag_length,
|
||||||
|
$frame
|
||||||
|
);
|
||||||
|
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
}
|
21
HLS/OwningStreamReader.php
Normal file
21
HLS/OwningStreamReader.php
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Base class for streaming media segment readers
|
||||||
|
*
|
||||||
|
* @file
|
||||||
|
* @ingroup HLS
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace MediaWiki\TimedMediaHandler\HLS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base file class that fcloses on destruct.
|
||||||
|
*/
|
||||||
|
class OwningStreamReader extends StreamReader {
|
||||||
|
public function __destruct() {
|
||||||
|
if ( $this->file ) {
|
||||||
|
fclose( $this->file );
|
||||||
|
$this->file = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
155
HLS/Segmenter.php
Normal file
155
HLS/Segmenter.php
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Base class for streaming segment readers
|
||||||
|
*
|
||||||
|
* @file
|
||||||
|
* @ingroup HLS
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace MediaWiki\TimedMediaHandler\HLS;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for reading a media file and segmenting it.
|
||||||
|
*/
|
||||||
|
abstract class Segmenter {
|
||||||
|
|
||||||
|
protected string $filename;
|
||||||
|
protected array $segments;
|
||||||
|
|
||||||
|
public function __construct( string $filename, ?array $segments = null ) {
|
||||||
|
$this->filename = $filename;
|
||||||
|
if ( $segments ) {
|
||||||
|
$this->segments = $segments;
|
||||||
|
} else {
|
||||||
|
$this->segments = [];
|
||||||
|
$this->parse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the segments from the underlying file
|
||||||
|
*/
|
||||||
|
abstract protected function parse(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidate adjacent segments to approach the target segment length.
|
||||||
|
*/
|
||||||
|
public function consolidate( float $target ): void {
|
||||||
|
$out = [];
|
||||||
|
$n = count( $this->segments );
|
||||||
|
$init = $this->segments['init'] ?? false;
|
||||||
|
if ( $init ) {
|
||||||
|
$n--;
|
||||||
|
$out['init'] = $init;
|
||||||
|
}
|
||||||
|
if ( $n < 2 ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $this->segments[0];
|
||||||
|
$start = $first['start'];
|
||||||
|
$size = $first['size'];
|
||||||
|
$timestamp = $first['timestamp'];
|
||||||
|
$duration = $first['duration'];
|
||||||
|
|
||||||
|
$i = 1;
|
||||||
|
while ( $i < $n ) {
|
||||||
|
// Append segments until we get close
|
||||||
|
while ( $i < $n - 1 && $duration < $target ) {
|
||||||
|
$segment = $this->segments[$i];
|
||||||
|
$total = $duration + $segment['duration'];
|
||||||
|
if ( $total >= $target ) {
|
||||||
|
$after = $total - $target;
|
||||||
|
$before = $target - $duration;
|
||||||
|
if ( $before < $after ) {
|
||||||
|
// Break segment early
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$duration += $segment['duration'];
|
||||||
|
$size += $segment['size'];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save out a segment
|
||||||
|
$out[] = [
|
||||||
|
'start' => $start,
|
||||||
|
'size' => $size,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'duration' => $duration,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( $i < $n ) {
|
||||||
|
$segment = $this->segments[$i];
|
||||||
|
$start = $segment['start'];
|
||||||
|
$size = $segment['size'];
|
||||||
|
$timestamp = $segment['timestamp'];
|
||||||
|
$duration = $segment['duration'];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'start' => $start,
|
||||||
|
'size' => $size,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'duration' => $duration,
|
||||||
|
];
|
||||||
|
$this->segments = $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify the media file and segments in-place to insert any
|
||||||
|
* tweaks needed for the file to stream correctly.
|
||||||
|
*
|
||||||
|
* This is used by MP3Segmenter to insert ID3 timestamps.
|
||||||
|
*/
|
||||||
|
public function rewrite(): void {
|
||||||
|
// no-op in default; fragmented .mp4 can be left as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
public function playlist( float $target, string $filename ): string {
|
||||||
|
$lines = [];
|
||||||
|
$lines[] = "#EXTM3U";
|
||||||
|
$lines[] = "#EXT-X-VERSION:7";
|
||||||
|
$lines[] = "#EXT-X-TARGETDURATION:$target";
|
||||||
|
$lines[] = "#EXT-MEDIA-SEQUENCE:0";
|
||||||
|
$lines[] = "#EXT-PLAYLIST-TYPE:VOD";
|
||||||
|
|
||||||
|
$url = urlencode( $filename );
|
||||||
|
|
||||||
|
$init = $this->segments['init'] ?? false;
|
||||||
|
if ( $init ) {
|
||||||
|
$lines[] = "#EXT-X-MAP:URI=\"{$url}\",BYTERANGE=\"{$init['size']}@{$init['start']}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
$n = count( $this->segments ) - 1;
|
||||||
|
for ( $i = 0; $i < $n; $i++ ) {
|
||||||
|
$segment = $this->segments[$i];
|
||||||
|
$lines[] = "#EXTINF:{$segment['duration']},";
|
||||||
|
$lines[] = "#EXT-X-BYTERANGE:{$segment['size']}@{$segment['start']}";
|
||||||
|
$lines[] = "{$url}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = "#EXT-X-ENDLIST";
|
||||||
|
|
||||||
|
return implode( "\n", $lines );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function segment( string $filename ): Segmenter {
|
||||||
|
$ext = strtolower( substr( $filename, strrpos( $filename, '.' ) ) );
|
||||||
|
switch ( $ext ) {
|
||||||
|
case '.mp3':
|
||||||
|
return new MP3Segmenter( $filename );
|
||||||
|
case '.mp4':
|
||||||
|
case '.m4v':
|
||||||
|
case '.m4a':
|
||||||
|
case '.mov':
|
||||||
|
case '.3gp':
|
||||||
|
return new MP4Segmenter( $filename );
|
||||||
|
default:
|
||||||
|
throw new Exception( "Unexpected streaming file extension $ext" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
104
HLS/StreamReader.php
Normal file
104
HLS/StreamReader.php
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Base class for streaming media segment readers
|
||||||
|
*
|
||||||
|
* @file
|
||||||
|
* @ingroup HLS
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace MediaWiki\TimedMediaHandler\HLS;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for reading/writing a media file with wrappers
|
||||||
|
* for exception handling and possible multi usage.
|
||||||
|
*/
|
||||||
|
class StreamReader {
|
||||||
|
/**
|
||||||
|
* @var resource
|
||||||
|
*/
|
||||||
|
protected $file;
|
||||||
|
|
||||||
|
protected int $pos;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $file
|
||||||
|
*/
|
||||||
|
public function __construct( $file ) {
|
||||||
|
if ( get_resource_type( $file ) !== 'stream' ) {
|
||||||
|
throw new Exception( 'Invalid file stream' );
|
||||||
|
}
|
||||||
|
$this->file = $file;
|
||||||
|
$this->pos = $this->tell();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tell(): int {
|
||||||
|
return ftell( $this->file );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pos(): int {
|
||||||
|
return $this->pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to given absolute file position.
|
||||||
|
* @throws Exception on error
|
||||||
|
*/
|
||||||
|
public function seek( int $pos ): void {
|
||||||
|
$this->pos = intval( $pos );
|
||||||
|
|
||||||
|
if ( $this->pos === $this->tell() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$retval = fseek( $this->file, $this->pos, SEEK_SET );
|
||||||
|
|
||||||
|
if ( $retval < 0 ) {
|
||||||
|
throw new Exception( "Failed to seek to $this->pos bytes" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read $len bytes or return null on EOF/short read.
|
||||||
|
* @throws Exception on error
|
||||||
|
*/
|
||||||
|
public function read( int $len ): ?string {
|
||||||
|
$this->seek( $this->pos );
|
||||||
|
$bytes = fread( $this->file, $len );
|
||||||
|
if ( $bytes === false ) {
|
||||||
|
throw new Exception( "Read error for $len bytes at $this->pos" );
|
||||||
|
}
|
||||||
|
if ( strlen( $bytes ) < $len ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$this->pos += strlen( $bytes );
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read exactly $len bytes
|
||||||
|
* @throws Exception on error or short read
|
||||||
|
*/
|
||||||
|
public function readExactly( int $len ): string {
|
||||||
|
$bytes = $this->read( $len );
|
||||||
|
if ( $bytes === null ) {
|
||||||
|
throw new Exception( "Short read for $len bytes at $this->pos" );
|
||||||
|
}
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the given data and return number of bytes written.
|
||||||
|
* Short writes are possible on network connections, in theory.
|
||||||
|
* @throws Exception on error
|
||||||
|
*/
|
||||||
|
public function write( string $bytes ): int {
|
||||||
|
$this->seek( $this->pos );
|
||||||
|
$len = strlen( $bytes );
|
||||||
|
$nbytes = fwrite( $this->file, $bytes );
|
||||||
|
if ( $nbytes === false ) {
|
||||||
|
throw new Exception( "Write error for $len bytes at $this->pos" );
|
||||||
|
}
|
||||||
|
return $nbytes;
|
||||||
|
}
|
||||||
|
}
|
20
HLS/rewrite-mp3.php
Normal file
20
HLS/rewrite-mp3.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require( __DIR__ . '/StreamReader.php' );
|
||||||
|
require( __DIR__ . '/OwningStreamReader.php' );
|
||||||
|
require( __DIR__ . '/Segmenter.php' );
|
||||||
|
require( __DIR__ . '/MP3Segmenter.php' );
|
||||||
|
|
||||||
|
use MediaWiki\TimedMediaHandler\HLS\MP3Segmenter;
|
||||||
|
|
||||||
|
$argv = $_SERVER['argv'];
|
||||||
|
$self = array_shift( $argv );
|
||||||
|
$filename = array_shift( $argv );
|
||||||
|
$target = 10;
|
||||||
|
|
||||||
|
$segmenter = new MP3Segmenter( $filename );
|
||||||
|
$segmenter->consolidate( $target );
|
||||||
|
$segmenter->rewrite();
|
||||||
|
$m3u8 = $segmenter->playlist( $target, $filename );
|
||||||
|
|
||||||
|
print $m3u8 . "\n";
|
|
@ -23,12 +23,13 @@ INFILE=caminandes-llamigos.webm
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Audio for HLS
|
# Audio for HLS
|
||||||
#ffmpeg -i $INFILE -vn $AUDIO_MP3 -y fmp4.audio.mpeg.mp3
|
# note - must make the MP3 because we have to reprocess it!
|
||||||
|
ffmpeg -i $INFILE -vn $AUDIO_MP3 -y fmp4.audio.mpeg.mp3
|
||||||
|
|
||||||
#ffmpeg -i $INFILE -vn $AUDIO_MP3 $AUDFLAGS -y fmp4.audio.mpeg.mp4
|
#ffmpeg -i $INFILE -vn $AUDIO_MP3 $AUDFLAGS -y fmp4.audio.mpeg.mp4
|
||||||
#ffmpeg -i $INFILE -vn $AUDIO_MP3 $AUDFLAGS -y fmp4.audio.mpeg.mov
|
#ffmpeg -i $INFILE -vn $AUDIO_MP3 $AUDFLAGS -y fmp4.audio.mpeg.mov
|
||||||
#ffmpeg -i $INFILE -vn $AUDIO_AAC $AUDFLAGS -y fmp4.audio.aac.mp4
|
#ffmpeg -i $INFILE -vn $AUDIO_AAC $AUDFLAGS -y fmp4.audio.aac.mp4
|
||||||
#ffmpeg -i $INFILE -vn $AUDIO_OPUS $AUDFLAGS -y fmp4.audio.opus.mp4
|
#ffmpeg -i $INFILE -vn $AUDIO_OPUS $AUDFLAGS -y fmp4.audio.opus.mp4
|
||||||
ffmpeg -i $INFILE -vn $AUDIO_OPUS $AUDFLAGS -y fmp4.audio.alac.mp4
|
|
||||||
|
|
||||||
# Video for HLS
|
# Video for HLS
|
||||||
|
|
||||||
|
@ -50,7 +51,8 @@ ffmpeg -i $INFILE -vn $AUDIO_OPUS $AUDFLAGS -y fmp4.audio.alac.mp4
|
||||||
|
|
||||||
|
|
||||||
# Playlist processing
|
# Playlist processing
|
||||||
php extract-playlist.php fmp4.audio.mpeg.mp3 > fmp4.audio.mpeg.mp3.m3u8
|
php HLS/rewrite-mp3.php fmp4.audio.mpeg.mp3 > fmp4.audio.mpeg.mp3.m3u8
|
||||||
|
#php extract-playlist.php fmp4.audio.mpeg.mp3 > fmp4.audio.mpeg.mp3.m3u8
|
||||||
php extract-playlist.php fmp4.audio.mpeg.mp4 > fmp4.audio.mpeg.mp4.m3u8
|
php extract-playlist.php fmp4.audio.mpeg.mp4 > fmp4.audio.mpeg.mp4.m3u8
|
||||||
php extract-playlist.php fmp4.audio.mpeg.mov > fmp4.audio.mpeg.mov.m3u8
|
php extract-playlist.php fmp4.audio.mpeg.mov > fmp4.audio.mpeg.mov.m3u8
|
||||||
php extract-playlist.php fmp4.audio.aac.mp4 > fmp4.audio.aac.mp4.m3u8
|
php extract-playlist.php fmp4.audio.aac.mp4 > fmp4.audio.aac.mp4.m3u8
|
||||||
|
|
2
test-rewrite.sh
Normal file
2
test-rewrite.sh
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
cp fmp4.audio.mpeg.mp3 rewrite.audio.mpeg.mp3
|
||||||
|
php HLS/rewrite-mp3.php rewrite.audio.mpeg.mp3
|
Loading…
Reference in a new issue