├── qtfaststart2 ├── __init__.py ├── __main__.py ├── exceptions.py ├── command.py └── processor.py ├── setup.cfg ├── .gitignore ├── bin └── qtfaststart2 ├── CHANGES.md ├── LICENSE.md ├── setup.py └── README.md /qtfaststart2/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = "2.1.1" 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /qtfaststart2/__main__.py: -------------------------------------------------------------------------------- 1 | from qtfaststart2 import command 2 | command.run() 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | build/ 4 | dist 5 | *.pyc 6 | *~ 7 | *.tmp 8 | *.swp 9 | MANIFEST 10 | qtfaststart2.egg-info 11 | -------------------------------------------------------------------------------- /bin/qtfaststart2: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Command line script for convenience. If this is in your path, you should 5 | be able to run it directly like this:: 6 | 7 | qtfaststart2 8 | """ 9 | 10 | import sys 11 | import os 12 | 13 | # Add parent directory to sys.path so that running from dev environment works 14 | sys.path.append(os.path.dirname(os.path.dirname((os.path.abspath(__file__))))) 15 | 16 | from qtfaststart2 import command 17 | command.run() 18 | -------------------------------------------------------------------------------- /qtfaststart2/exceptions.py: -------------------------------------------------------------------------------- 1 | class FastStartException(Exception): 2 | """ 3 | Raised when something bad happens during processing. 4 | """ 5 | pass 6 | 7 | class FastStartSetupError(FastStartException): 8 | """ 9 | Rasised when asked to process a file that does not need processing 10 | """ 11 | pass 12 | 13 | class MalformedFileError(FastStartException): 14 | """ 15 | Raised when the input file is setup in an unexpected way 16 | """ 17 | pass 18 | 19 | class UnsupportedFormatError(FastStartException): 20 | """ 21 | Raised when a movie file is recognized as a format not supported. 22 | """ 23 | pass 24 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## 2.1.1 6 | - Readme updates, skipped a few versions here because of an accidental mess-up with PyPi publishing 7 | 8 | ## 2.0.3 9 | - Fix import errors by renaming everything from qtfaststart to qtfaststart2 10 | 11 | ## 2.0.2 12 | - Fix: Exit gracefully on already optimised files. See https://github.com/danielgtaylor/qtfaststart/pull/17 13 | 14 | ## 2.0.1 15 | - Support files with multiple mdat atoms. qtfaststart2 can mistakenly handle files with multiple mdat atoms as already 16 | set up when the moov atom is actually at the end of the file. This patch makes certain the moov atom is placed before 17 | all mdat atoms. See https://github.com/danielgtaylor/qtfaststart/pull/19 18 | 19 | ## 2.0.0 20 | - Create qtfaststart2 which is branched off from [danielgtaylor/qtfaststart](https://github.com/danielgtaylor/qtfaststart) 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Released under MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | try: 2 | from setuptools import setup 3 | except ImportError: 4 | from distutils.core import setup 5 | 6 | import qtfaststart2 7 | 8 | setup_params = dict( 9 | name='qtfaststart2', 10 | author='danielyaa5', 11 | author_email='danyagithub@gmail.com', 12 | version=qtfaststart2.VERSION, 13 | description='Quicktime atom positioning in Python for fast streaming.', 14 | url='https://github.com/danielyaa5/qtfaststart2', 15 | download_url = 'https://github.com/danielyaa5/qtfaststart2/archive/{version}.tar.gz'.format(version=qtfaststart2.VERSION), 16 | license='MIT License', 17 | platforms=["any"], 18 | provides=['qtfaststart2'], 19 | packages=[ 20 | 'qtfaststart2', 21 | ], 22 | scripts=['bin/qtfaststart2'], 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Natural Language :: English', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | 'Topic :: Multimedia :: Video :: Conversion', 31 | 'Topic :: Software Development :: Libraries :: Python Modules', 32 | ], 33 | ) 34 | 35 | if __name__ == '__main__': 36 | setup(**setup_params) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quicktime/MP4 Fast Start 2 | Forked from [qtfaststart](https://github.com/danielgtaylor/qtfaststart) which 3 | has had no maintainer for many years. 4 | 5 | 6 | Enable streaming and pseudo-streaming of Quicktime and MP4 files by 7 | moving metadata and offset information to the front of the file. 8 | 9 | This program is based on qt-faststart.c from the ffmpeg project, which is 10 | released into the public domain, as well as ISO 14496-12:2005 (the official 11 | spec for MP4), which can be obtained from the ISO or found online. 12 | 13 | The goals of this project are to run anywhere without compilation (in 14 | particular, many Windows and Mac OS X users have trouble getting 15 | qt-faststart.c compiled), to run about as fast as the C version, to be more 16 | user-friendly, and to use less actual lines of code doing so. 17 | 18 | ## Features 19 | * Works everywhere Python (2.6+) can be installed 20 | * Handles both 32-bit (stco) and 64-bit (co64) atoms 21 | * Handles any file where the mdat atom is before the moov atom 22 | * Preserves the order of other atoms 23 | * Can replace the original file (if given no output file) 24 | 25 | ## Installing from PyPi 26 | 27 | To install from PyPi, you may use ``pip``: 28 | 29 | pip install qtfaststart2 30 | 31 | ## Installing from source 32 | 33 | Download a copy of the source, ``cd`` into the top-level 34 | ``qtfaststart2`` directory, and run: 35 | 36 | python setup.py install 37 | 38 | If you are installing to your system Python (instead of a virtualenv), you 39 | may need root access (via ``sudo`` or ``su``). 40 | 41 | ## Usage 42 | See ``qtfaststart2 --help`` for more info! If outfile is not present then 43 | the infile is overwritten:: 44 | 45 | $ qtfaststart2 infile [outfile] 46 | 47 | To run without installing you can use:: 48 | 49 | $ bin/qtfaststart2 infile [outfile] 50 | 51 | To see a list of top-level atoms and their order in the file:: 52 | 53 | $ bin/qtfaststart2 --list infile 54 | 55 | If on Windows, the qtfaststart2 script will not execute, so use:: 56 | 57 | > python -m qtfaststart2 ... 58 | 59 | -------------------------------------------------------------------------------- /qtfaststart2/command.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import print_function 3 | 4 | import logging 5 | import os 6 | import shutil 7 | import sys 8 | import tempfile 9 | 10 | from optparse import OptionParser 11 | from qtfaststart2 import VERSION 12 | from qtfaststart2 import processor 13 | from qtfaststart2.exceptions import FastStartException 14 | 15 | log = logging.getLogger("qtfaststart2") 16 | 17 | def run(): 18 | logging.basicConfig(level = logging.INFO, stream = sys.stdout, 19 | format = "%(message)s") 20 | 21 | parser = OptionParser(usage="%prog [options] infile [outfile]", 22 | version="%prog " + VERSION) 23 | 24 | parser.add_option("-d", "--debug", dest="debug", default=False, 25 | action="store_true", 26 | help="Enable debug output") 27 | parser.add_option("-l", "--list", dest="list", default=False, 28 | action="store_true", 29 | help="List top level atoms") 30 | parser.add_option("-e", "--to_end", dest="to_end", default=False, 31 | action="store_true", 32 | help="Move moov atom to the end of file") 33 | parser.add_option("-s", "--sample", dest="sample", default=False, 34 | action="store_true", 35 | help="Create a small sample of the input file") 36 | 37 | options, args = parser.parse_args() 38 | 39 | if len(args) < 1: 40 | parser.print_help() 41 | raise SystemExit(1) 42 | 43 | if options.debug: 44 | logging.getLogger().setLevel(logging.DEBUG) 45 | 46 | if options.list: 47 | index = processor.get_index(open(args[0], "rb")) 48 | 49 | for atom, pos, size in index: 50 | if atom == "\x00\x00\x00\x00": 51 | # Strange zero atom... display with dashes rather than 52 | # an empty string 53 | atom = "----" 54 | 55 | print(atom, "(" + str(size) + " bytes)") 56 | 57 | raise SystemExit 58 | 59 | if len(args) == 1: 60 | # Replace the original file! 61 | if options.sample: 62 | print("Please pass an output filename when used with --sample!") 63 | raise SystemExit(1) 64 | 65 | tmp, outfile = tempfile.mkstemp() 66 | os.close(tmp) 67 | else: 68 | outfile = args[1] 69 | 70 | limit = 0 71 | if options.sample: 72 | # Create a small sample (4 MiB) 73 | limit = 4 * (1024 ** 2) 74 | 75 | try: 76 | processor.process(args[0], outfile, limit = limit, to_end = options.to_end) 77 | except FastStartException: 78 | # A log message was printed, so exit with an error code 79 | raise SystemExit(1) 80 | 81 | if len(args) == 1: 82 | # Move temp file to replace original 83 | shutil.move(outfile, args[0]) 84 | -------------------------------------------------------------------------------- /qtfaststart2/processor.py: -------------------------------------------------------------------------------- 1 | """ 2 | The guts that actually do the work. This is available here for the 3 | 'qtfaststart2' script and for your application's direct use. 4 | """ 5 | 6 | import shutil 7 | import logging 8 | import os 9 | import struct 10 | import collections 11 | 12 | import io 13 | 14 | from qtfaststart2.exceptions import FastStartSetupError 15 | from qtfaststart2.exceptions import MalformedFileError 16 | from qtfaststart2.exceptions import UnsupportedFormatError 17 | 18 | # This exception isn't directly used, included it for backward compatability 19 | # in the event someone had used it from our namespace previously 20 | from qtfaststart2.exceptions import FastStartException 21 | 22 | CHUNK_SIZE = 8192 23 | 24 | log = logging.getLogger("qtfaststart2") 25 | 26 | # Older versions of Python require this to be defined 27 | if not hasattr(os, 'SEEK_CUR'): 28 | os.SEEK_CUR = 1 29 | 30 | Atom = collections.namedtuple('Atom', 'name position size') 31 | 32 | def read_atom(datastream): 33 | """ 34 | Read an atom and return a tuple of (size, type) where size is the size 35 | in bytes (including the 8 bytes already read) and type is a "fourcc" 36 | like "ftyp" or "moov". 37 | """ 38 | size, type = struct.unpack(">L4s", datastream.read(8)) 39 | type = type.decode('ascii') 40 | return size, type 41 | 42 | 43 | def _read_atom_ex(datastream): 44 | """ 45 | Read an Atom from datastream 46 | """ 47 | pos = datastream.tell() 48 | atom_size, atom_type = read_atom(datastream) 49 | if atom_size == 1: 50 | atom_size, = struct.unpack(">Q", datastream.read(8)) 51 | return Atom(atom_type, pos, atom_size) 52 | 53 | 54 | def get_index(datastream): 55 | """ 56 | Return an index of top level atoms, their absolute byte-position in the 57 | file and their size in a list: 58 | 59 | index = [ 60 | ("ftyp", 0, 24), 61 | ("moov", 25, 2658), 62 | ("free", 2683, 8), 63 | ... 64 | ] 65 | 66 | The tuple elements will be in the order that they appear in the file. 67 | """ 68 | log.debug("Getting index of top level atoms...") 69 | 70 | index = list(_read_atoms(datastream)) 71 | _ensure_valid_index(index) 72 | 73 | return index 74 | 75 | 76 | def _read_atoms(datastream): 77 | """ 78 | Read atoms until an error occurs 79 | """ 80 | while datastream: 81 | try: 82 | atom = _read_atom_ex(datastream) 83 | log.debug("%s: %s" % (atom.name, atom.size)) 84 | except: 85 | break 86 | 87 | yield atom 88 | 89 | if atom.size == 0: 90 | if atom.name == "mdat": 91 | # Some files may end in mdat with no size set, which generally 92 | # means to seek to the end of the file. We can just stop indexing 93 | # as no more entries will be found! 94 | break 95 | else: 96 | # Weird, but just continue to try to find more atoms 97 | continue 98 | 99 | datastream.seek(atom.position + atom.size) 100 | 101 | 102 | def _ensure_valid_index(index): 103 | """ 104 | Ensure the minimum viable atoms are present in the index. 105 | 106 | Raise MalformedFileError if not. 107 | """ 108 | top_level_atoms = set([item.name for item in index]) 109 | for key in ["moov", "mdat"]: 110 | if key not in top_level_atoms: 111 | msg = "%s atom not found, is this a valid MOV/MP4 file?" % key 112 | log.warn(msg) 113 | raise MalformedFileError(msg) 114 | 115 | 116 | def find_atoms(size, datastream): 117 | """ 118 | Compatibilty interface for _find_atoms_ex 119 | """ 120 | fake_parent = Atom('fake', datastream.tell()-8, size+8) 121 | for atom in _find_atoms_ex(fake_parent, datastream): 122 | yield atom.name 123 | 124 | 125 | def _find_atoms_ex(parent_atom, datastream): 126 | """ 127 | Yield either "stco" or "co64" Atoms from datastream. 128 | datastream will be 8 bytes into the stco or co64 atom when the value 129 | is yielded. 130 | 131 | It is assumed that datastream will be at the end of the atom after 132 | the value has been yielded and processed. 133 | 134 | parent_atom is the parent atom, a 'moov' or other ancestor of CO 135 | atoms in the datastream. 136 | """ 137 | stop = parent_atom.position + parent_atom.size 138 | 139 | while datastream.tell() < stop: 140 | try: 141 | atom = _read_atom_ex(datastream) 142 | except: 143 | msg = "Error reading next atom!" 144 | log.exception(msg) 145 | raise MalformedFileError(msg) 146 | 147 | if atom.name in ["trak", "mdia", "minf", "stbl"]: 148 | # Known ancestor atom of stco or co64, search within it! 149 | for res in _find_atoms_ex(atom, datastream): 150 | yield res 151 | elif atom.name in ["stco", "co64"]: 152 | yield atom 153 | else: 154 | # Ignore this atom, seek to the end of it. 155 | datastream.seek(atom.position + atom.size) 156 | 157 | def _moov_is_compressed(datastream, moov_atom): 158 | """ 159 | scan the atoms under the moov atom and detect whether or not the 160 | atom data is compressed 161 | """ 162 | # seek to the beginning of the moov atom contents 163 | datastream.seek(moov_atom.position+8) 164 | 165 | # step through the moov atom childeren to see if a cmov atom is among them 166 | stop = moov_atom.position + moov_atom.size 167 | while datastream.tell() < stop: 168 | child_atom = _read_atom_ex(datastream) 169 | datastream.seek(datastream.tell()+child_atom.size - 8) 170 | 171 | # cmov means compressed moov header! 172 | if child_atom.name == 'cmov': 173 | return True 174 | 175 | return False 176 | 177 | def process(infilename, outfilename, limit=float('inf'), to_end=False, 178 | cleanup=True): 179 | """ 180 | Convert a Quicktime/MP4 file for streaming by moving the metadata to 181 | the front of the file. This method writes a new file. 182 | 183 | If limit is set to something other than zero it will be used as the 184 | number of bytes to write of the atoms following the moov atom. This 185 | is very useful to create a small sample of a file with full headers, 186 | which can then be used in bug reports and such. 187 | 188 | If cleanup is set to False, free atoms and zero atoms will not be 189 | scrubbed from from the mov 190 | """ 191 | datastream = open(infilename, "rb") 192 | 193 | # Get the top level atom index 194 | index = get_index(datastream) 195 | 196 | mdat_pos_first = -1 197 | mdat_pos_last = -1 198 | free_size = 0 199 | 200 | # Get position of first and last mdat atom(s) 201 | for atom in index: 202 | if atom.name == "mdat": 203 | if (atom.position < mdat_pos_first or mdat_pos_first < 0): 204 | mdat_pos_first = atom.position 205 | if (atom.position > mdat_pos_last or mdat_pos_last < 0): 206 | mdat_pos_last = atom.position 207 | 208 | if mdat_pos_first < 0: 209 | # No mdat atom found 210 | msg = "No mdat atom found." 211 | log.error(msg) 212 | raise FastStartSetupError(msg) 213 | 214 | # Make sure moov occurs AFTER mdat, otherwise no need to run! 215 | for atom in index: 216 | # The atoms are guaranteed to exist from get_index above! 217 | if atom.name == "moov": 218 | moov_atom = atom 219 | moov_pos = atom.position 220 | elif atom.name == "free" and atom.position < mdat_pos_first and cleanup: 221 | # This free atom is before the mdat! 222 | free_size += atom.size 223 | log.info("Removing free atom at %d (%d bytes)" % 224 | (atom.position, atom.size)) 225 | elif (atom.name == "\x00\x00\x00\x00" and atom.position < mdat_pos_first): 226 | # This is some strange zero atom with incorrect size 227 | free_size += 8 228 | log.info("Removing strange zero atom at %s (8 bytes)" % 229 | atom.position) 230 | 231 | # Offset to shift positions 232 | offset = - free_size 233 | is_setup = False 234 | if (moov_pos < mdat_pos_last and to_end): 235 | # moov is in the wrong place, shift by moov size 236 | offset -= moov_atom.size 237 | elif (moov_pos > mdat_pos_first and not to_end): 238 | # moov is in the wrong place, shift by moov size 239 | offset += moov_atom.size 240 | elif free_size == 0: 241 | # No free atoms to process and moov is correct, we are done! 242 | msg = "This file appears to be already optimised for streaming. Nothing to do." 243 | log.info(msg) 244 | return 245 | 246 | # Check for compressed moov atom 247 | is_compressed = _moov_is_compressed(datastream, moov_atom) 248 | if is_compressed: 249 | msg = "Movies with compressed headers are not supported" 250 | log.error(msg) 251 | raise UnsupportedFormatError(msg) 252 | 253 | # read and fix moov 254 | moov = _patch_moov(datastream, moov_atom, offset) 255 | 256 | log.info("Writing output...") 257 | outfile = open(outfilename, "wb") 258 | 259 | # Write ftype 260 | for atom in index: 261 | if atom.name == "ftyp": 262 | log.debug("Writing ftyp... (%d bytes)" % atom.size) 263 | datastream.seek(atom.position) 264 | outfile.write(datastream.read(atom.size)) 265 | 266 | if not to_end: 267 | _write_moov(moov, outfile) 268 | 269 | # Write the rest 270 | skip_atom_types = ["ftyp", "moov"] 271 | if cleanup: 272 | skip_atom_types += ["free"] 273 | 274 | atoms = [item for item in index if item.name not in skip_atom_types] 275 | for atom in atoms: 276 | log.debug("Writing %s... (%d bytes)" % (atom.name, atom.size)) 277 | datastream.seek(atom.position) 278 | 279 | # for compatability, allow '0' to mean no limit 280 | cur_limit = limit or float('inf') 281 | cur_limit = min(cur_limit, atom.size) 282 | 283 | for chunk in get_chunks(datastream, CHUNK_SIZE, cur_limit): 284 | outfile.write(chunk) 285 | 286 | if to_end: 287 | _write_moov(moov, outfile) 288 | 289 | # Close and set permissions 290 | outfile.close() 291 | try: 292 | shutil.copymode(infilename, outfilename) 293 | except: 294 | log.warn("Could not copy file permissions!") 295 | 296 | def _write_moov(moov, outfile): 297 | # Write moov 298 | bytes = moov.getvalue() 299 | log.debug("Writing moov... (%d bytes)" % len(bytes)) 300 | outfile.write(bytes) 301 | 302 | def _patch_moov(datastream, atom, offset): 303 | datastream.seek(atom.position) 304 | moov = io.BytesIO(datastream.read(atom.size)) 305 | 306 | # reload the atom from the fixed stream 307 | atom = _read_atom_ex(moov) 308 | 309 | for atom in _find_atoms_ex(atom, moov): 310 | # Read either 32-bit or 64-bit offsets 311 | ctype, csize = dict( 312 | stco=('L', 4), 313 | co64=('Q', 8), 314 | )[atom.name] 315 | 316 | # Get number of entries 317 | version, entry_count = struct.unpack(">2L", moov.read(8)) 318 | 319 | log.info("Patching %s with %d entries" % (atom.name, entry_count)) 320 | 321 | entries_pos = moov.tell() 322 | 323 | struct_fmt = ">%(entry_count)s%(ctype)s" % vars() 324 | 325 | # Read entries 326 | entries = struct.unpack(struct_fmt, moov.read(csize * entry_count)) 327 | 328 | # Patch and write entries 329 | offset_entries = [entry + offset for entry in entries] 330 | moov.seek(entries_pos) 331 | moov.write(struct.pack(struct_fmt, *offset_entries)) 332 | return moov 333 | 334 | def get_chunks(stream, chunk_size, limit): 335 | remaining = limit 336 | while remaining: 337 | chunk = stream.read(min(remaining, chunk_size)) 338 | if not chunk: 339 | return 340 | remaining -= len(chunk) 341 | yield chunk 342 | --------------------------------------------------------------------------------