diff --git a/extract-playlist.php b/extract-playlist.php index 241190a..c57c9b8 100644 --- a/extract-playlist.php +++ b/extract-playlist.php @@ -1,27 +1,29 @@ file = fopen( $filename, 'rb' ); - if ( !$this->file ) { - throw new Exception( 'Failed to open MP4 input file' ); - } - } - - function __destruct() { - if ( $this->file ) { - fclose( $this->file ); - $this->file = null; - } + public function __construct( $file ) { + $this->file = $file; } /** @@ -61,18 +63,65 @@ class MP4Reader { } } + /** + * @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']; + } + /** * @return int|false 32-bit integer value or false on eof */ public function read32() { $bytes = $this->readBytes( 4 ); if ( $bytes === false ) { - return $bytes; + return false; } $data = unpack( 'Nval', $bytes ); return $data['val']; } + /** + * @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 ); + } + /** * @return string|false 4-byte type code or false on eof */ @@ -82,7 +131,7 @@ class MP4Reader { /** * @param callable $callback in the form function(MP4Box) - * @return mixed the return value from the callback, or false on eof + * @return bool true on success, or false on eof */ public function readBox( $callback ) { $start = $this->pos(); @@ -97,35 +146,72 @@ class MP4Reader { return false; } - $box = new MP4Box( $this, $start, $size, $type ); + $box = new MP4Box( $this->file, $start, $size, $type ); $retval = call_user_func( $callback, $box ); $remaining = $end - $this->pos(); if ( $remaining > 0 ) { $this->skipBytes( $remaining ); } - return $retval; + return true; } + /** + * 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" ); + } + } ); + } + } } -class MP4Box { - private $reader; +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 { public $start; public $size; public $type; - public function __construct( MP4Reader $reader, $start, $size, $type ) { - $this->reader = $reader; + public function __construct( $file, $start, $size, $type ) { + parent::__construct( $file ); $this->start = $start; $this->size = $size; $this->type = $type; } - public function pos() { - return $this->reader->pos(); - } - public function end() { return $this->start + $this->size; } @@ -134,32 +220,13 @@ class MP4Box { return $this->end() - $this->pos(); } - public function guard( $length ) { - $remaining = $this->remaining(); - if ( $remaining < $length ) { - throw new Exception( "Reading beyond end of box; had $remaining bytes, wanted $length" ); - } - } - public function readBytes( $length ) { - $this->guard( $length ); - return $this->reader->readBytes( $length ); + if ( $length > $this->remaining() ) { + return false; + } + return parent::readBytes( $length ); } - public function read32() { - $this->guard( 4 ); - return $this->reader->read32(); - } - - public function readType() { - $this->guard( 4 ); - return $this->reader->readType(); - } - - public function readBox( $callback ) { - $this->guard( 8 ); - return $this->reader->readBox( $callback ); - } } function hexdump( $str ) { @@ -197,66 +264,136 @@ function safestr( $str ) { function extractFragmentedMP4( $filename ) { $segments = []; - $mp4 = new MP4Reader( $filename ); + $mp4 = new MP4FileReader( $filename ); $eof = false; $moof = false; + $timestamp = 0.0; + $duration = 0.0; + $timescale = 0; + $dts = 0; + $first_pts = 0; + $max_pts = 0; $init = false; - while ( !$eof ) { - $eof = !$mp4->readBox( function ( $box ) use ( &$segments, &$moof, &$init ) { - /* - 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 + /* + 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 + 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 + 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 - */ - - switch ( $box->type ) { - case 'ftyp': - break; - case 'moof': - if ( !$init ) { - $init = [ - 'start' => 0, - 'size' => $box->end(), - 'timestamp' => 0.0, - 'duration' => 0.0, - ]; - $segments['init'] = $init; + 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(); } - $moof = $box->start; - break; - case 'mdat': - // @todo use timestamp and duration data - array_push( $segments, [ - 'start' => $moof, - 'size' => $box->end() - $moof, - 'timestamp' => 0.0, - 'duration' => 0.0, - ] ); - break; - default: - // ignore + ], + ], + ], + '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; } + $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(); - return true; - } ); - } + $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; + } + echo "$first_pts - $max_pts\n"; + } + ], + ] ); + }, + 'mdat' => function ( $box ) use ( &$segments, &$moof, &$first_pts, &$max_pts, &$timescale ) { + var_dump( $first_pts ); + var_dump( $max_pts ); + var_dump( $timescale ); + array_push( $segments, [ + 'start' => $moof, + 'size' => $box->end() - $moof, + 'timestamp' => $first_pts / $timescale, + 'duration' => ( $max_pts - $first_pts ) / $timescale, + ] ); + } + ] ); return $segments; }