2023-03-01 20:47:55 +00:00
|
|
|
<?php
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
define( 'MOV_TFHD_BASE_DATA_OFFSET', 0x01 );
|
|
|
|
define( 'MOV_TFHD_STSD_ID', 0x02 );
|
|
|
|
define( 'MOV_TFHD_DEFAULT_DURATION', 0x08 );
|
|
|
|
define( 'MOV_TFHD_DEFAULT_SIZE', 0x10 );
|
|
|
|
define( 'MOV_TFHD_DEFAULT_FLAGS', 0x20 );
|
|
|
|
define( 'MOV_TFHD_DURATION_IS_EMPTY', 0x010000 );
|
|
|
|
define( 'MOV_TFHD_DEFAULT_BASE_IS_MOOF', 0x020000 );
|
|
|
|
|
|
|
|
define( 'MOV_TRUN_DATA_OFFSET', 0x01 );
|
|
|
|
define( 'MOV_TRUN_FIRST_SAMPLE_FLAGS', 0x04 );
|
|
|
|
define( 'MOV_TRUN_SAMPLE_DURATION', 0x100 );
|
|
|
|
define( 'MOV_TRUN_SAMPLE_SIZE', 0x200 );
|
|
|
|
define( 'MOV_TRUN_SAMPLE_FLAGS', 0x400 );
|
|
|
|
define( 'MOV_TRUN_SAMPLE_CTS', 0x800 );
|
|
|
|
|
2023-03-01 20:47:55 +00:00
|
|
|
class MP4Reader {
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @var resource $file
|
|
|
|
*/
|
2023-03-02 21:11:27 +00:00
|
|
|
protected $file;
|
2023-03-01 20:47:55 +00:00
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
public function __construct( $file ) {
|
|
|
|
$this->file = $file;
|
2023-03-01 20:47:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return int
|
|
|
|
*/
|
|
|
|
public function pos() {
|
|
|
|
$pos = ftell( $this->file );
|
|
|
|
if ( $pos === false ) {
|
|
|
|
throw new Exception( 'Failed to read position in MP4 file' );
|
|
|
|
}
|
|
|
|
return $pos;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param int $length number of bytes to read
|
|
|
|
* @return string|false raw bytes or false on eof
|
|
|
|
*/
|
|
|
|
public function readBytes( $length ) {
|
|
|
|
$bytes = fread( $this->file, $length );
|
|
|
|
if ( feof( $this->file ) ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if ( $bytes === false || strlen( $bytes ) < $length ) {
|
|
|
|
|
|
|
|
throw new Exception( "Failed to read $length bytes from MP4 file" );
|
|
|
|
}
|
|
|
|
return $bytes;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param int $length number of bytes to skip over
|
|
|
|
*/
|
|
|
|
private function skipBytes( $length ) {
|
|
|
|
$retval = fseek( $this->file, $length, SEEK_CUR );
|
|
|
|
if ( $retval < 0 ) {
|
|
|
|
throw new Exception( "Failed to skip ahead $length bytes in MP4 file" );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
/**
|
|
|
|
* @return int|false 64-bit integer value or false on eof
|
|
|
|
*/
|
|
|
|
public function read64() {
|
|
|
|
$bytes = $this->readBytes( 8 );
|
|
|
|
if ( $bytes === false ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
$data = unpack( 'Jval', $bytes );
|
|
|
|
return $data['val'];
|
|
|
|
}
|
|
|
|
|
2023-03-01 20:47:55 +00:00
|
|
|
/**
|
|
|
|
* @return int|false 32-bit integer value or false on eof
|
|
|
|
*/
|
|
|
|
public function read32() {
|
|
|
|
$bytes = $this->readBytes( 4 );
|
|
|
|
if ( $bytes === false ) {
|
2023-03-02 21:11:27 +00:00
|
|
|
return false;
|
2023-03-01 20:47:55 +00:00
|
|
|
}
|
|
|
|
$data = unpack( 'Nval', $bytes );
|
|
|
|
return $data['val'];
|
|
|
|
}
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
/**
|
|
|
|
* @return int|false 24-bit integer value or false on eof
|
|
|
|
*/
|
|
|
|
public function read24() {
|
|
|
|
$bytes = $this->readBytes( 3 );
|
|
|
|
if ( $bytes === false ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
$data = unpack( 'Nval', "\x00$bytes" );
|
|
|
|
return $data['val'];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return int|false 16-bit integer value or false on eof
|
|
|
|
*/
|
|
|
|
public function read16() {
|
|
|
|
$bytes = $this->readBytes( 2 );
|
|
|
|
if ( $bytes === false ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
$data = unpack( 'nval', $bytes );
|
|
|
|
return $data['val'];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return int|false 8-bit integer value or false on eof
|
|
|
|
*/
|
|
|
|
public function read8() {
|
|
|
|
$bytes = $this->readBytes( 1 );
|
|
|
|
if ( $bytes === false ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return ord( $bytes );
|
|
|
|
}
|
|
|
|
|
2023-03-01 20:47:55 +00:00
|
|
|
/**
|
|
|
|
* @return string|false 4-byte type code or false on eof
|
|
|
|
*/
|
|
|
|
public function readType() {
|
|
|
|
return $this->readBytes( 4 );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param callable $callback in the form function(MP4Box)
|
2023-03-02 21:11:27 +00:00
|
|
|
* @return bool true on success, or false on eof
|
2023-03-01 20:47:55 +00:00
|
|
|
*/
|
|
|
|
public function readBox( $callback ) {
|
|
|
|
$start = $this->pos();
|
|
|
|
$size = $this->read32();
|
|
|
|
if ( $size === false ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
$end = $start + $size;
|
|
|
|
|
|
|
|
$type = $this->readType();
|
|
|
|
if ( $type === false ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
$box = new MP4Box( $this->file, $start, $size, $type );
|
2023-03-01 20:47:55 +00:00
|
|
|
$retval = call_user_func( $callback, $box );
|
|
|
|
|
|
|
|
$remaining = $end - $this->pos();
|
|
|
|
if ( $remaining > 0 ) {
|
|
|
|
$this->skipBytes( $remaining );
|
|
|
|
}
|
2023-03-02 21:11:27 +00:00
|
|
|
return true;
|
2023-03-01 20:47:55 +00:00
|
|
|
}
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
/**
|
|
|
|
* Scan a series of boxes and pass control based on type.
|
|
|
|
* Unrecognized boxes will be skipped over.
|
|
|
|
* @param callable[] $map array of callables keyed by fourCC type code
|
|
|
|
*/
|
|
|
|
public function boxes( $map ) {
|
|
|
|
$ok = true;
|
|
|
|
while ( $ok ) {
|
|
|
|
$ok = $this->readBox( function ( $box ) use ( $map ) {
|
|
|
|
$handler = $map[$box->type] ?? false;
|
|
|
|
if ( is_callable( $handler ) ) {
|
|
|
|
call_user_func( $handler, $box );
|
|
|
|
} else if ( is_array( $handler ) ) {
|
|
|
|
$box->boxes( $handler );
|
|
|
|
} else if ( $handler === false ) {
|
|
|
|
// no-op
|
|
|
|
} else {
|
|
|
|
throw new Exception( "Unexpected callback or map type for type $box->type" );
|
|
|
|
}
|
|
|
|
} );
|
|
|
|
}
|
|
|
|
}
|
2023-03-01 20:47:55 +00:00
|
|
|
}
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
class MP4FileReader extends MP4Reader {
|
|
|
|
/**
|
|
|
|
* @param string $filename
|
|
|
|
*/
|
|
|
|
public function __construct( $filename ) {
|
|
|
|
$file = fopen( $filename, 'rb' );
|
|
|
|
if ( !$file ) {
|
|
|
|
throw new Exception( 'Failed to open MP4 input file' );
|
|
|
|
}
|
|
|
|
parent::__construct( $file );
|
|
|
|
}
|
|
|
|
|
|
|
|
function __destruct() {
|
|
|
|
if ( $this->file ) {
|
|
|
|
fclose( $this->file );
|
|
|
|
$this->file = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class MP4Box extends MP4Reader {
|
2023-03-01 20:47:55 +00:00
|
|
|
public $start;
|
|
|
|
public $size;
|
|
|
|
public $type;
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
public function __construct( $file, $start, $size, $type ) {
|
|
|
|
parent::__construct( $file );
|
2023-03-01 20:47:55 +00:00
|
|
|
$this->start = $start;
|
|
|
|
$this->size = $size;
|
|
|
|
$this->type = $type;
|
|
|
|
}
|
|
|
|
|
2023-03-01 21:55:30 +00:00
|
|
|
public function end() {
|
2023-03-01 20:47:55 +00:00
|
|
|
return $this->start + $this->size;
|
|
|
|
}
|
|
|
|
|
2023-03-01 21:55:30 +00:00
|
|
|
public function remaining() {
|
2023-03-01 20:47:55 +00:00
|
|
|
return $this->end() - $this->pos();
|
|
|
|
}
|
|
|
|
|
|
|
|
public function readBytes( $length ) {
|
2023-03-02 21:11:27 +00:00
|
|
|
if ( $length > $this->remaining() ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return parent::readBytes( $length );
|
2023-03-01 20:47:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function hexdump( $str ) {
|
|
|
|
$out = '';
|
|
|
|
$len = strlen( $str );
|
|
|
|
for ( $i = 0; $i < $len; $i++ ) {
|
|
|
|
$char = $str[$i];
|
|
|
|
$byte = ord( $char );
|
|
|
|
// really?
|
|
|
|
$digits = str_pad( dechex( $byte ), 2, '0', STR_PAD_LEFT );
|
|
|
|
$out .= $digits;
|
|
|
|
}
|
|
|
|
return $out;
|
|
|
|
}
|
|
|
|
|
|
|
|
function safestr( $str ) {
|
|
|
|
$out = '';
|
|
|
|
$len = strlen( $str );
|
|
|
|
for ( $i = 0; $i < $len; $i++ ) {
|
|
|
|
$char = $str[$i];
|
|
|
|
$byte = ord( $char );
|
|
|
|
if ( $byte >= 32 && $byte <= 126 ) {
|
|
|
|
$out .= $char;
|
|
|
|
} else {
|
|
|
|
$out .= '.';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return $out;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param string $filename - input MP4 file to read
|
|
|
|
* @return array[] - list of segment info
|
|
|
|
*/
|
|
|
|
function extractFragmentedMP4( $filename ) {
|
|
|
|
$segments = [];
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
$mp4 = new MP4FileReader( $filename );
|
2023-03-01 20:47:55 +00:00
|
|
|
$eof = false;
|
2023-03-01 21:55:30 +00:00
|
|
|
$moof = false;
|
2023-03-02 21:11:27 +00:00
|
|
|
$timestamp = 0.0;
|
|
|
|
$duration = 0.0;
|
|
|
|
$timescale = 0;
|
|
|
|
$dts = 0;
|
|
|
|
$first_pts = 0;
|
|
|
|
$max_pts = 0;
|
2023-03-01 20:47:55 +00:00
|
|
|
$init = false;
|
|
|
|
|
2023-03-02 21:11:27 +00:00
|
|
|
/*
|
|
|
|
Need to:
|
|
|
|
- find the end of the moov; everything up to that is the initialization segment
|
|
|
|
- https://www.w3.org/TR/mse-byte-stream-format-isobmff/#iso-init-segments
|
|
|
|
- find the start of each styp+moof fragment
|
|
|
|
- https://www.w3.org/TR/mse-byte-stream-format-isobmff/#iso-media-segments
|
|
|
|
- find the start timestamp of each moof fragment
|
|
|
|
- find the duration of each moof fragment
|
|
|
|
|
|
|
|
moov.trak.mdia.mdhd.timescale - looks useful 12288 for 24fps?
|
|
|
|
moov.trak.mdia.mdhd.duration - 0 on the moov
|
|
|
|
moov.mvex.trex.default_sample_duration - 0 on the moov
|
|
|
|
|
|
|
|
moof.traf.tfhd.default_sample_duration is 512 on these
|
|
|
|
moof.traf.tfdt.baseMediaDecodeTime is 0 on the first, 122880 on the second frag (10 seconds in?)
|
|
|
|
moof.mdat.sample_duration is empty here (uses default I guess?)
|
|
|
|
moof.mdat.sample_composition_time_offset also empty
|
|
|
|
|
|
|
|
|
|
|
|
opus has timescale 48000 in moov.trak.mdia.mdhd
|
|
|
|
*/
|
|
|
|
$mp4->boxes( [
|
|
|
|
'moov' => [
|
|
|
|
'trak' => [
|
|
|
|
'mdia' => [
|
|
|
|
'mdhd' => function ( $box ) use ( &$timescale ) {
|
|
|
|
$version = $box->read8();
|
|
|
|
$flags = $box->read24();
|
|
|
|
if ( $version == 1 ) {
|
|
|
|
$box->read64();
|
|
|
|
$box->read64();
|
|
|
|
} else {
|
|
|
|
$box->read32();
|
|
|
|
$box->read32();
|
|
|
|
}
|
|
|
|
$timescale = $box->read32();
|
2023-03-01 21:55:30 +00:00
|
|
|
}
|
2023-03-02 21:11:27 +00:00
|
|
|
],
|
|
|
|
],
|
|
|
|
],
|
|
|
|
'moof' => function ( $box ) use ( &$segments, &$moof, &$init, &$timestamp, &$duration, &$dts, &$first_pts, &$max_pts, &$timescale ) {
|
|
|
|
if ( !$init ) {
|
|
|
|
$init = [
|
|
|
|
'start' => 0,
|
|
|
|
'size' => $box->start,
|
|
|
|
'timestamp' => 0.0,
|
|
|
|
'duration' => 0.0,
|
|
|
|
];
|
|
|
|
$segments['init'] = $init;
|
2023-03-01 20:47:55 +00:00
|
|
|
}
|
2023-03-02 21:11:27 +00:00
|
|
|
$moof = $box->start;
|
|
|
|
$default_sample_duration = 0;
|
|
|
|
$first_pts = 0;
|
|
|
|
$max_pts = 0;
|
|
|
|
$box->boxes( [
|
|
|
|
'traf' => [
|
|
|
|
'tfhd' => function( $box ) use ( &$default_sample_duration ) {
|
|
|
|
$version = $box->read8();
|
|
|
|
$flags = $box->read24();
|
|
|
|
|
|
|
|
$track_id = $box->read32();
|
|
|
|
if ( $flags & MOV_TFHD_BASE_DATA_OFFSET ) {
|
|
|
|
$box->read64();
|
|
|
|
}
|
|
|
|
if ( $flags & MOV_TFHD_STSD_ID ) {
|
|
|
|
$box->read32();
|
|
|
|
}
|
|
|
|
if ( $flags & MOV_TFHD_DEFAULT_DURATION ) {
|
|
|
|
$default_sample_duration = $box->read32();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
'trun' => function( $box ) use ( &$default_sample_duration, &$dts, &$first_pts, &$max_pts ) {
|
|
|
|
$version = $box->read8();
|
|
|
|
$flags = $box->read24();
|
|
|
|
$entries = $box->read32();
|
|
|
|
if ( $flags & MOV_TRUN_DATA_OFFSET ) {
|
|
|
|
$box->read32();
|
|
|
|
}
|
|
|
|
if ( $flags & MOV_TRUN_FIRST_SAMPLE_FLAGS ) {
|
|
|
|
$box->read32();
|
|
|
|
}
|
|
|
|
for ( $i = 0; $i < $entries; $i++ ) {
|
|
|
|
$pts = $dts;
|
|
|
|
$sample_duration = $default_sample_duration;
|
|
|
|
if ( $flags & MOV_TRUN_SAMPLE_DURATION ) {
|
|
|
|
$sample_duration = $box->read32();
|
|
|
|
}
|
|
|
|
if ( $flags & MOV_TRUN_SAMPLE_SIZE ) {
|
|
|
|
$box->read32();
|
|
|
|
}
|
|
|
|
if ( $flags & MOV_TRUN_SAMPLE_FLAGS ) {
|
|
|
|
$box->read32();
|
|
|
|
}
|
|
|
|
if ( $flags & MOV_TRUN_SAMPLE_CTS ) {
|
|
|
|
$pts += $box->read32();
|
|
|
|
}
|
|
|
|
if ( $i == 0 ) {
|
|
|
|
$first_pts = $pts;
|
|
|
|
}
|
|
|
|
$max_pts = max( $max_pts, $pts + $sample_duration );
|
|
|
|
$dts += $sample_duration;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
],
|
|
|
|
] );
|
|
|
|
},
|
|
|
|
'mdat' => function ( $box ) use ( &$segments, &$moof, &$first_pts, &$max_pts, &$timescale ) {
|
|
|
|
array_push( $segments, [
|
|
|
|
'start' => $moof,
|
|
|
|
'size' => $box->end() - $moof,
|
|
|
|
'timestamp' => $first_pts / $timescale,
|
|
|
|
'duration' => ( $max_pts - $first_pts ) / $timescale,
|
|
|
|
] );
|
|
|
|
}
|
|
|
|
] );
|
2023-03-01 20:47:55 +00:00
|
|
|
|
|
|
|
return $segments;
|
|
|
|
}
|
|
|
|
|
2023-03-03 19:36:12 +00:00
|
|
|
// http://www.mp3-tech.org/programmer/frame_header.html
|
|
|
|
|
|
|
|
class MP3FrameHeader {
|
|
|
|
private $header;
|
|
|
|
|
|
|
|
public $valid = false;
|
|
|
|
public $size = 0;
|
2023-03-07 22:34:57 +00:00
|
|
|
public $samples = 0;
|
|
|
|
public $duration = 0.0;
|
2023-03-03 19:36:12 +00:00
|
|
|
|
|
|
|
public $sync;
|
|
|
|
public $mpeg;
|
|
|
|
public $layer;
|
|
|
|
public $protection;
|
|
|
|
public $bitrate;
|
|
|
|
public $sampleRate;
|
|
|
|
public $padding;
|
|
|
|
|
|
|
|
private static $bits = [
|
|
|
|
'sync' => [ 21, 11 ],
|
|
|
|
'mpeg' => [ 19, 2 ],
|
|
|
|
'layer' => [ 17, 2 ],
|
|
|
|
'protection' => [ 16, 1 ],
|
|
|
|
'bitrate' => [ 12, 4 ],
|
|
|
|
'sampleRate' => [ 10, 2 ],
|
|
|
|
'padding' => [ 9, 1 ],
|
|
|
|
'private' => [ 8, 1 ], // not needed below this
|
|
|
|
'channelMode' => [ 6, 2 ],
|
|
|
|
'modeExt' => [ 4, 2 ],
|
|
|
|
'copyright' => [ 3, 1 ],
|
|
|
|
'original' => [ 2, 1 ],
|
|
|
|
'emphasis' => [ 0, 2 ],
|
|
|
|
];
|
|
|
|
|
|
|
|
private const SYNC_MASK = 0x7ff;
|
|
|
|
|
2023-03-07 22:34:57 +00:00
|
|
|
private static $versions = [
|
|
|
|
'MPEG-2.5',
|
|
|
|
'reserved',
|
|
|
|
'MPEG-2',
|
|
|
|
'MPEG-1',
|
|
|
|
];
|
|
|
|
|
|
|
|
private static $layers = [
|
|
|
|
'reserved',
|
|
|
|
'III',
|
|
|
|
'II',
|
|
|
|
'I',
|
|
|
|
];
|
|
|
|
|
|
|
|
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 ],
|
|
|
|
];
|
|
|
|
|
2023-03-03 19:36:12 +00:00
|
|
|
// 1s used for reserved slots to avoid exploding
|
|
|
|
// in case of invalid input
|
|
|
|
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 ],
|
|
|
|
];
|
|
|
|
|
|
|
|
private static $bitrates = [
|
2023-03-07 22:34:57 +00:00
|
|
|
// MPEG-2
|
2023-03-03 19:36:12 +00:00
|
|
|
[
|
2023-03-07 22:34:57 +00:00
|
|
|
// invalid layer
|
2023-03-03 19:36:12 +00:00
|
|
|
[ 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 ],
|
|
|
|
],
|
2023-03-07 22:34:57 +00:00
|
|
|
// MPEG-1
|
2023-03-03 19:36:12 +00:00
|
|
|
[
|
2023-03-07 22:34:57 +00:00
|
|
|
// invalid layer
|
2023-03-03 19:36:12 +00:00
|
|
|
[ 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 ],
|
|
|
|
]
|
2023-03-03 00:04:38 +00:00
|
|
|
];
|
2023-03-03 19:36:12 +00:00
|
|
|
|
|
|
|
private function field( $name ) {
|
|
|
|
[ $shift, $bits ] = self::$bits[$name];
|
|
|
|
$mask = ( 1 << $bits ) - 1;
|
|
|
|
return ( $this->header >> $shift ) & $mask;
|
|
|
|
}
|
|
|
|
|
|
|
|
public function __construct( $header ) {
|
|
|
|
$this->header = $header;
|
|
|
|
|
2023-03-07 22:34:57 +00:00
|
|
|
$sync = $this->field( 'sync' );
|
|
|
|
$this->sync = $sync == self::SYNC_MASK;
|
2023-03-03 19:36:12 +00:00
|
|
|
if ( $this->sync ) {
|
2023-03-07 22:34:57 +00:00
|
|
|
$mpeg = $this->field( 'mpeg' );
|
|
|
|
$this->mpeg = self::$versions[$mpeg];
|
|
|
|
|
|
|
|
$layer = $this->field( 'layer' );
|
|
|
|
$this->layer = self::$layers[$layer];
|
|
|
|
|
|
|
|
$protection = $this->field( 'protection' );
|
|
|
|
$this->protection = !$protection;
|
|
|
|
|
2023-03-03 19:36:12 +00:00
|
|
|
$br = $this->field( 'bitrate' );
|
2023-03-07 22:34:57 +00:00
|
|
|
$this->bitrate = 1000 * self::$bitrates[$mpeg & 1][$layer][$br];
|
|
|
|
|
2023-03-03 19:36:12 +00:00
|
|
|
$sr = $this->field( 'sampleRate' );
|
2023-03-07 22:34:57 +00:00
|
|
|
$this->sampleRate = self::$sampleRates[$mpeg][$sr];
|
|
|
|
|
2023-03-03 19:36:12 +00:00
|
|
|
$this->padding = $this->field( 'padding' );
|
|
|
|
|
2023-03-07 22:34:57 +00:00
|
|
|
if ( $this->sync ) {
|
|
|
|
if ( $this->bitrate == 0 ) {
|
|
|
|
$this->valid = false;
|
|
|
|
throw new Exception( "Invalid bitrate" );
|
|
|
|
}
|
|
|
|
if ( $this->sampleRate == 1 ) {
|
|
|
|
$this->valid = false;
|
|
|
|
throw new Exception( "Invalid sample rate" );
|
|
|
|
}
|
|
|
|
$this->valid = true;
|
2023-03-03 19:36:12 +00:00
|
|
|
}
|
2023-03-07 22:34:57 +00:00
|
|
|
|
|
|
|
$this->samples = self::$samplesPerFrame[$mpeg][$layer];
|
|
|
|
$this->duration = $this->samples / $this->sampleRate;
|
|
|
|
$nbits = $this->duration * $this->bitrate;
|
|
|
|
$nbytes = $nbits / 8;
|
|
|
|
$this->size = intval( $nbytes );
|
2023-03-03 19:36:12 +00:00
|
|
|
if ( $this->protection ) {
|
|
|
|
$this->size += 2;
|
|
|
|
}
|
|
|
|
if ( $this->padding ) {
|
|
|
|
$this->size++;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class MP3Reader {
|
|
|
|
|
|
|
|
private $file;
|
2023-03-07 22:34:57 +00:00
|
|
|
private $timestamp = 0.0;
|
2023-03-03 19:36:12 +00:00
|
|
|
|
|
|
|
public function __construct( $file ) {
|
|
|
|
$this->file = $file;
|
|
|
|
}
|
|
|
|
|
|
|
|
public function pos() {
|
|
|
|
return ftell( $this->file );
|
|
|
|
}
|
|
|
|
|
2023-03-07 22:41:16 +00:00
|
|
|
public function readSegment() {
|
2023-03-03 19:36:12 +00:00
|
|
|
while ( true ) {
|
|
|
|
$start = $this->pos();
|
2023-03-07 22:34:57 +00:00
|
|
|
$lookahead = 10;
|
|
|
|
$bytes = fread( $this->file, $lookahead );
|
|
|
|
if ( $bytes === false || strlen( $bytes ) < $lookahead ) {
|
2023-03-03 19:36:12 +00:00
|
|
|
// end of file
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-03-07 22:34:57 +00:00
|
|
|
// Check for MP3 frame header sync pattern
|
2023-03-03 19:36:12 +00:00
|
|
|
$data = unpack( "Nval", $bytes );
|
|
|
|
$header = new MP3FrameHeader( $data['val'] );
|
2023-03-07 22:34:57 +00:00
|
|
|
if ( $header->sync ) {
|
2023-03-07 22:41:16 +00:00
|
|
|
// Note we don't need the data at this time.
|
|
|
|
fseek( $this->file, $start + $header->size, SEEK_SET );
|
|
|
|
$this->timestamp += $header->duration;
|
|
|
|
return [
|
2023-03-07 22:34:57 +00:00
|
|
|
'start' => $start,
|
|
|
|
'size' => $header->size,
|
|
|
|
'timestamp' => $this->timestamp,
|
|
|
|
'duration' => $header->duration,
|
|
|
|
];
|
2023-03-03 19:36:12 +00:00
|
|
|
}
|
|
|
|
|
2023-03-07 22:34:57 +00:00
|
|
|
// 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
|
|
|
|
$id3 = unpack( "a3tag/nversion/Cflags/C4size", $bytes );
|
|
|
|
if ( $id3['tag'] === 'ID3' ) {
|
|
|
|
$size = $lookahead +
|
|
|
|
( $id3['size4'] |
|
|
|
|
( $id3['size3'] << 7) |
|
|
|
|
( $id3['size2'] << 14) |
|
|
|
|
( $id3['size1'] << 21) );
|
|
|
|
// For byte range purposes; count as zero duration
|
2023-03-07 22:41:16 +00:00
|
|
|
fseek( $this->file, $start + $size, SEEK_SET );
|
|
|
|
return [
|
2023-03-07 22:34:57 +00:00
|
|
|
'start' => $start,
|
|
|
|
'size' => $size,
|
|
|
|
'timestamp' => $this->timestamp,
|
|
|
|
'duration' => 0.0,
|
|
|
|
];
|
2023-03-03 19:36:12 +00:00
|
|
|
}
|
2023-03-07 22:34:57 +00:00
|
|
|
|
|
|
|
$hex = hexdump( $bytes );
|
|
|
|
$safe = safestr ( $bytes );
|
|
|
|
throw new Exception("Invalid packet at $start? $hex $safe");
|
2023-03-03 19:36:12 +00:00
|
|
|
|
2023-03-07 22:34:57 +00:00
|
|
|
// back up and try again
|
2023-03-03 19:36:12 +00:00
|
|
|
fseek( $this->file, $start + 1, SEEK_SET );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function extractMP3( $filename ) {
|
|
|
|
$file = fopen( $filename, 'rb' );
|
|
|
|
if ( !$file ) {
|
|
|
|
throw new Exception( 'Error opening MP3 file' );
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
$reader = new MP3Reader( $file );
|
|
|
|
$segments = [];
|
|
|
|
$timestamp = 0.0;
|
|
|
|
while ( true ) {
|
|
|
|
$start = $reader->pos();
|
2023-03-07 22:41:16 +00:00
|
|
|
$segment = $reader->readSegment();
|
2023-03-07 22:34:57 +00:00
|
|
|
if ( !$segment ) {
|
2023-03-07 22:41:16 +00:00
|
|
|
break;
|
2023-03-03 19:36:12 +00:00
|
|
|
}
|
2023-03-07 22:34:57 +00:00
|
|
|
$segments[] = $segment;
|
2023-03-03 19:36:12 +00:00
|
|
|
}
|
2023-03-07 22:34:57 +00:00
|
|
|
return $segments;
|
2023-03-03 19:36:12 +00:00
|
|
|
} finally {
|
|
|
|
fclose( $file );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function consolidate( $target, $segments ) {
|
|
|
|
$out = [];
|
|
|
|
if ( isset( $segments['init'] ) ) {
|
|
|
|
$out['init'] = $segments['init'];
|
|
|
|
}
|
2023-03-07 22:34:57 +00:00
|
|
|
if ( count( $segments ) < 2 ) {
|
|
|
|
return $segments;
|
|
|
|
}
|
2023-03-03 19:36:12 +00:00
|
|
|
|
2023-03-07 23:17:30 +00:00
|
|
|
$n = count( $segments );
|
|
|
|
if ( isset( $segments['init'] ) ) {
|
|
|
|
$n--;
|
|
|
|
}
|
2023-03-03 00:04:38 +00:00
|
|
|
$start = $segments[0]['start'];
|
|
|
|
$size = $segments[0]['size'];
|
|
|
|
$timestamp = $segments[0]['timestamp'];
|
|
|
|
$duration = $segments[0]['duration'];
|
2023-03-07 23:17:30 +00:00
|
|
|
$nextTarget = $timestamp + $target;
|
|
|
|
$nextDuration = $target;
|
2023-03-03 00:04:38 +00:00
|
|
|
$i = 1;
|
2023-03-07 23:17:30 +00:00
|
|
|
while ( $i < $n - 1 ) {
|
2023-03-03 00:04:38 +00:00
|
|
|
// Append segments until we get close
|
2023-03-07 23:17:30 +00:00
|
|
|
while ( $i < $n - 1 && $duration < $nextDuration ) {
|
2023-03-03 00:04:38 +00:00
|
|
|
$total = $duration + $segments[$i]['duration'];
|
2023-03-07 23:17:30 +00:00
|
|
|
if ( $total >= $nextDuration ) {
|
|
|
|
$after = $total - $nextDuration;
|
|
|
|
$before = $nextDuration - $duration;
|
|
|
|
echo "$before $after\n";
|
|
|
|
echo "($total $nextDuration)\n";
|
2023-03-03 00:04:38 +00:00
|
|
|
if ( $before < $after ) {
|
|
|
|
// Break segment early
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
$duration += $segments[$i]['duration'];
|
|
|
|
$size += $segments[$i]['size'];
|
|
|
|
$i++;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Save out a segment
|
|
|
|
$out[] = [
|
|
|
|
'start' => $start,
|
|
|
|
'size' => $size,
|
|
|
|
'timestamp' => $timestamp,
|
|
|
|
'duration' => $duration,
|
|
|
|
];
|
2023-03-07 23:17:30 +00:00
|
|
|
$nextTarget += $target;
|
|
|
|
$nextDuration = $nextTarget - $timestamp - $duration;
|
|
|
|
echo "[$nextTarget, $nextDuration]\n";
|
2023-03-03 00:04:38 +00:00
|
|
|
|
|
|
|
if ( $i < $n ) {
|
|
|
|
$segment = $segments[$i];
|
|
|
|
$start = $segment['start'];
|
|
|
|
$size = $segment['size'];
|
|
|
|
$timestamp = $segment['timestamp'];
|
|
|
|
$duration = $segment['duration'];
|
|
|
|
$i++;
|
|
|
|
}
|
|
|
|
}
|
2023-03-07 23:17:30 +00:00
|
|
|
$out[] = [
|
|
|
|
'start' => $start,
|
|
|
|
'size' => $size,
|
|
|
|
'timestamp' => $timestamp,
|
|
|
|
'duration' => $duration,
|
|
|
|
];
|
2023-03-03 00:04:38 +00:00
|
|
|
return $out;
|
|
|
|
}
|
|
|
|
|
2023-03-02 22:23:42 +00:00
|
|
|
function playlist( $filename, $segments ) {
|
|
|
|
/*
|
|
|
|
#EXTM3U
|
|
|
|
#EXT-X-VERSION:7
|
|
|
|
#EXT-X-TARGETDURATION:10
|
|
|
|
#EXT-X-MEDIA-SEQUENCE:0
|
|
|
|
#EXT-X-PLAYLIST-TYPE:VOD
|
|
|
|
#EXT-X-MAP:URI="new-vp9.mp4",BYTERANGE="811@0"
|
|
|
|
#EXTINF:10.000000,
|
|
|
|
#EXT-X-BYTERANGE:1058384@811
|
|
|
|
new-vp9.mp4
|
|
|
|
#EXTINF:10.000000,
|
|
|
|
#EXT-X-BYTERANGE:1085979@1059195
|
|
|
|
new-vp9.mp4
|
|
|
|
#EXTINF:10.000000,
|
|
|
|
#EXT-X-BYTERANGE:1268619@2145174
|
|
|
|
new-vp9.mp4
|
|
|
|
#EXTINF:10.000000,
|
|
|
|
#EXT-X-BYTERANGE:1418664@3413793
|
|
|
|
new-vp9.mp4
|
|
|
|
#EXTINF:10.000000,
|
|
|
|
#EXT-X-BYTERANGE:1129265@4832457
|
|
|
|
new-vp9.mp4
|
|
|
|
...
|
|
|
|
#EXT-X-ENDLIST
|
|
|
|
*/
|
|
|
|
$lines = [];
|
|
|
|
$lines[] = "#EXTM3U";
|
|
|
|
$lines[] = "#EXT-X-TARGETDURATION:10";
|
|
|
|
$lines[] = "#EXT-MEDIA-SEQUENCE:0";
|
|
|
|
$lines[] = "#EXT-PLAYLIST-TYPE:VOD";
|
|
|
|
|
|
|
|
$init = $segments['init'] ?? false;
|
|
|
|
if ( $init ) {
|
|
|
|
$lines[] = "#EXT-X-MAP:URI=\"$filename\",BYTERANGE=\"{$init['size']}@{$init['start']}\"";
|
|
|
|
}
|
|
|
|
|
|
|
|
$n = count( $segments ) - 1;
|
|
|
|
for ( $i = 0; $i < $n; $i++ ) {
|
|
|
|
$segment = $segments[$i];
|
|
|
|
$lines[] = "#EXTINF:{$segment['duration']}";
|
|
|
|
$lines[] = "#EXT-X-BYTERANGE:{$segment['size']}@{$segment['start']}";
|
|
|
|
$lines[] = $filename;
|
|
|
|
}
|
|
|
|
|
|
|
|
return implode( "\n", $lines );
|
|
|
|
}
|
|
|
|
|
2023-03-01 20:47:55 +00:00
|
|
|
$argv = $_SERVER['argv'];
|
|
|
|
$self = array_shift( $argv );
|
|
|
|
$filename = array_shift( $argv );
|
2023-03-03 19:36:12 +00:00
|
|
|
$target = 10;
|
|
|
|
|
|
|
|
$ext = substr( $filename, strrpos( $filename, '.' ) );
|
|
|
|
if ( $ext === '.mp3' ) {
|
|
|
|
$segments = extractMP3( $filename );
|
|
|
|
} elseif ( $ext === '.mp4' ) {
|
|
|
|
$segments = extractFragmentedMP4( $filename );
|
|
|
|
} else {
|
|
|
|
die( "Unexpected file extension $ext\n" );
|
|
|
|
}
|
|
|
|
$segments = consolidate( $target, $segments );
|
2023-03-01 21:55:30 +00:00
|
|
|
|
2023-03-02 22:23:42 +00:00
|
|
|
/*
|
2023-03-01 21:55:30 +00:00
|
|
|
foreach ( $segments as $key => $segment ) {
|
|
|
|
if ( $key === 'init' ) {
|
|
|
|
print "$key {$segment['start']},{$segment['size']}\n";
|
|
|
|
} else {
|
|
|
|
print "$key {$segment['timestamp']},{$segment['duration']} @ {$segment['start']},{$segment['size']}\n";
|
|
|
|
}
|
|
|
|
}
|
2023-03-02 22:23:42 +00:00
|
|
|
*/
|
|
|
|
$m3u8 = playlist( urlencode( $filename ), $segments );
|
|
|
|
print $m3u8 . "\n";
|