file = $file; } /** * @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" ); } } /** * @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 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 */ public function readType() { return $this->readBytes( 4 ); } /** * @param callable $callback in the form function(MP4Box) * @return bool true on success, or false on eof */ 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; } $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 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 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( $file, $start, $size, $type ) { parent::__construct( $file ); $this->start = $start; $this->size = $size; $this->type = $type; } public function end() { return $this->start + $this->size; } public function remaining() { return $this->end() - $this->pos(); } public function readBytes( $length ) { if ( $length > $this->remaining() ) { return false; } return parent::readBytes( $length ); } } 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 = []; $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; /* 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(); } ], ], ], '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(); $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, ] ); } ] ); return $segments; } // http://www.mp3-tech.org/programmer/frame_header.html class MP3FrameHeader { private $header; public $valid = false; public $size = 0; public $samples = 0; public $duration = 0.0; 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; 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 ], ]; // 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 = [ // 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 ], ] ]; 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; $sync = $this->field( 'sync' ); $this->sync = $sync == self::SYNC_MASK; if ( $this->sync ) { $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; $br = $this->field( 'bitrate' ); $this->bitrate = 1000 * self::$bitrates[$mpeg & 1][$layer][$br]; $sr = $this->field( 'sampleRate' ); $this->sampleRate = self::$sampleRates[$mpeg][$sr]; $this->padding = $this->field( 'padding' ); 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; } $this->samples = self::$samplesPerFrame[$mpeg][$layer]; $this->duration = $this->samples / $this->sampleRate; $nbits = $this->duration * $this->bitrate; $nbytes = $nbits / 8; $this->size = intval( $nbytes ); if ( $this->protection ) { $this->size += 2; } if ( $this->padding ) { $this->size++; } } } } class MP3Reader { private $file; private $timestamp = 0.0; public function __construct( $file ) { $this->file = $file; } public function pos() { return ftell( $this->file ); } public function readSegment() { while ( true ) { $start = $this->pos(); $lookahead = 10; $bytes = fread( $this->file, $lookahead ); if ( $bytes === false || strlen( $bytes ) < $lookahead ) { // end of file return false; } // Check for MP3 frame header sync pattern $data = unpack( "Nval", $bytes ); $header = new MP3FrameHeader( $data['val'] ); if ( $header->sync ) { // Note we don't need the data at this time. fseek( $this->file, $start + $header->size, SEEK_SET ); $this->timestamp += $header->duration; return [ 'start' => $start, 'size' => $header->size, 'timestamp' => $this->timestamp, 'duration' => $header->duration, ]; } // 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 fseek( $this->file, $start + $size, SEEK_SET ); return [ 'start' => $start, 'size' => $size, 'timestamp' => $this->timestamp, 'duration' => 0.0, ]; } $hex = hexdump( $bytes ); $safe = safestr ( $bytes ); throw new Exception("Invalid packet at $start? $hex $safe"); // back up and try again 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(); $segment = $reader->readSegment(); if ( !$segment ) { break; } $segments[] = $segment; } return $segments; } finally { fclose( $file ); } } function consolidate( $target, $segments ) { $out = []; if ( isset( $segments['init'] ) ) { $out['init'] = $segments['init']; } if ( count( $segments ) < 2 ) { return $segments; } $n = count( $segments ); if ( isset( $segments['init'] ) ) { $n--; } $start = $segments[0]['start']; $size = $segments[0]['size']; $timestamp = $segments[0]['timestamp']; $duration = $segments[0]['duration']; //$nextTarget = $timestamp + $target; $nextDuration = $target; $i = 1; while ( $i < $n ) { // Append segments until we get close while ( $i < $n - 1 && $duration < $nextDuration ) { $total = $duration + $segments[$i]['duration']; if ( $total >= $nextDuration ) { $after = $total - $nextDuration; $before = $nextDuration - $duration; 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, ]; //$nextTarget += $target; //$nextDuration = $nextTarget - $timestamp - $duration; if ( $i < $n ) { $segment = $segments[$i]; $start = $segment['start']; $size = $segment['size']; $timestamp = $segment['timestamp']; $duration = $segment['duration']; $i++; } } $out[] = [ 'start' => $start, 'size' => $size, 'timestamp' => $timestamp, 'duration' => $duration, ]; return $out; } 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-VERSION:7"; $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; } $lines[] = "#EXT-X-ENDLIST"; return implode( "\n", $lines ); } $argv = $_SERVER['argv']; $self = array_shift( $argv ); $filename = array_shift( $argv ); $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 ); /* 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"; } } */ $m3u8 = playlist( urlencode( $filename ), $segments ); print $m3u8 . "\n";