├── .gitignore ├── MANIFEST.in ├── README.rst ├── README.txt ├── apiary.apib ├── bin └── qtfaststart ├── qtfaststart ├── __init__.py ├── __main__.py ├── command.py ├── exceptions.py └── processor.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build 3 | dist 4 | *.pyc 5 | *~ 6 | *.tmp 7 | *.swp 8 | MANIFEST 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Quicktime/MP4 Fast Start 2 | ------------------------ 3 | Enable streaming and pseudo-streaming of Quicktime and MP4 files by 4 | moving metadata and offset information to the front of the file. 5 | 6 | This program is based on qt-faststart.c from the ffmpeg project, which is 7 | released into the public domain, as well as ISO 14496-12:2005 (the official 8 | spec for MP4), which can be obtained from the ISO or found online. 9 | 10 | The goals of this project are to run anywhere without compilation (in 11 | particular, many Windows and Mac OS X users have trouble getting 12 | qt-faststart.c compiled), to run about as fast as the C version, to be more 13 | user friendly, and to use less actual lines of code doing so. 14 | 15 | Features 16 | -------- 17 | 18 | * Works everywhere Python (2.6+) can be installed 19 | * Handles both 32-bit (stco) and 64-bit (co64) atoms 20 | * Handles any file where the mdat atom is before the moov atom 21 | * Preserves the order of other atoms 22 | * Can replace the original file (if given no output file) 23 | 24 | Installing from PyPi 25 | -------------------- 26 | 27 | To install from PyPi, you may use ``easy_install`` or ``pip``:: 28 | 29 | easy_install qtfaststart 30 | 31 | Installing from source 32 | ---------------------- 33 | 34 | Download a copy of the source, ``cd`` into the top-level 35 | ``qtfaststart`` directory, and run:: 36 | 37 | python setup.py install 38 | 39 | If you are installing to your system Python (instead of a virtualenv), you 40 | may need root access (via ``sudo`` or ``su``). 41 | 42 | Usage 43 | ----- 44 | See ``qtfaststart --help`` for more info! If outfile is not present then 45 | the infile is overwritten:: 46 | 47 | $ qtfaststart infile [outfile] 48 | 49 | To run without installing you can use:: 50 | 51 | $ bin/qtfaststart infile [outfile] 52 | 53 | To see a list of top-level atoms and their order in the file:: 54 | 55 | $ bin/qtfaststart --list infile 56 | 57 | If on Windows, the qtfaststart script will not execute, so use:: 58 | 59 | > python -m qtfaststart ... 60 | 61 | History 62 | ------- 63 | * 2013-08-07: Copy input file permissions to output file. 64 | * 2013-08-06: Fix a bug producing 8kb mdat output. 65 | * 2013-07-05: Introduced Python 3 support. 66 | * 2013-07-05: Added launcher via 'python -m qtfaststart'. 67 | * 2013-07-05: Internal refactoring for clarity and robustness. Functions 68 | now work with named tuples. Backward compatability is maintained. Expect 69 | a future, backward-incompatible release to replace other functions. 70 | * 2013-07-05: Created an ``Atom`` namedtuple to represent a fourcc atom 71 | (name, stream position, and size). 72 | * 2013-01-28: Support strange zero-name, zero-length atoms, re-license 73 | under the MIT license, version bump to 1.7 74 | * 2011-11-01: Fix long-standing os.SEEK_CUR bug, version bump to 1.6 75 | * 2011-10-11: Packaged and published to PyPi by Greg Taylor 76 | , version bump to 1.5. 77 | * 2010-02-21: Add support for final mdat atom with zero size, patch by 78 | Dmitry Simakov , version bump to 1.4. 79 | * 2009-11-05: Added --sample option. Version bump to 1.3 80 | * 2009-03-13: Update to be more library-friendly by using logging module, 81 | rename fast_start => process, version bump to 1.2 82 | * 2008-10-04: Bug fixes, support multiple atoms of the same type, 83 | version bump to 1.1 84 | * 2008-09-02: Initial release 85 | 86 | License 87 | ------- 88 | Copyright (C) 2008 - 2013 Daniel G. Taylor 89 | 90 | Permission is hereby granted, free of charge, to any person obtaining a copy 91 | of this software and associated documentation files (the "Software"), to deal 92 | in the Software without restriction, including without limitation the rights 93 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 94 | copies of the Software, and to permit persons to whom the Software is 95 | furnished to do so, subject to the following conditions: 96 | 97 | The above copyright notice and this permission notice shall be included in all 98 | copies or substantial portions of the Software. 99 | 100 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 101 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 102 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 103 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 104 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 105 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 106 | THE SOFTWARE. 107 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | README.rst -------------------------------------------------------------------------------- /apiary.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: http://polls.apiblueprint.org/ 3 | 4 | # Polls API 5 | 6 | Polls is a simple API allowing consumers to view polls and vote in them. 7 | 8 | ## Questions Collection [/questions] 9 | 10 | ### List All Questions [GET] 11 | 12 | + Response 200 (application/json) 13 | 14 | [ 15 | { 16 | "question": "Favourite programming language?", 17 | "published_at": "2015-08-05T08:40:51.620Z", 18 | "choices": [ 19 | { 20 | "choice": "Swift", 21 | "votes": 2048 22 | }, { 23 | "choice": "Python", 24 | "votes": 1024 25 | }, { 26 | "choice": "Objective-C", 27 | "votes": 512 28 | }, { 29 | "choice": "Ruby", 30 | "votes": 256 31 | } 32 | ] 33 | } 34 | ] 35 | 36 | ### Create a New Question [POST] 37 | 38 | You may create your own question using this action. It takes a JSON 39 | object containing a question and a collection of answers in the 40 | form of choices. 41 | 42 | + Request (application/json) 43 | 44 | { 45 | "question": "Favourite programming language?", 46 | "choices": [ 47 | "Swift", 48 | "Python", 49 | "Objective-C", 50 | "Ruby" 51 | ] 52 | } 53 | 54 | + Response 201 (application/json) 55 | 56 | + Headers 57 | 58 | Location: /questions/2 59 | 60 | + Body 61 | 62 | { 63 | "question": "Favourite programming language?", 64 | "published_at": "2015-08-05T08:40:51.620Z", 65 | "choices": [ 66 | { 67 | "choice": "Swift", 68 | "votes": 0 69 | }, { 70 | "choice": "Python", 71 | "votes": 0 72 | }, { 73 | "choice": "Objective-C", 74 | "votes": 0 75 | }, { 76 | "choice": "Ruby", 77 | "votes": 0 78 | } 79 | ] 80 | } -------------------------------------------------------------------------------- /bin/qtfaststart: -------------------------------------------------------------------------------- 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 | qtfaststart 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 qtfaststart import command 17 | command.run() 18 | -------------------------------------------------------------------------------- /qtfaststart/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.8" 2 | -------------------------------------------------------------------------------- /qtfaststart/__main__.py: -------------------------------------------------------------------------------- 1 | from qtfaststart import command 2 | command.run() 3 | -------------------------------------------------------------------------------- /qtfaststart/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 qtfaststart import VERSION 12 | from qtfaststart import processor 13 | from qtfaststart.exceptions import FastStartException 14 | 15 | log = logging.getLogger("qtfaststart") 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 | -------------------------------------------------------------------------------- /qtfaststart/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 | -------------------------------------------------------------------------------- /qtfaststart/processor.py: -------------------------------------------------------------------------------- 1 | """ 2 | The guts that actually do the work. This is available here for the 3 | 'qtfaststart' 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 qtfaststart.exceptions import FastStartSetupError 15 | from qtfaststart.exceptions import MalformedFileError 16 | from qtfaststart.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 qtfaststart.exceptions import FastStartException 21 | 22 | CHUNK_SIZE = 8192 23 | 24 | log = logging.getLogger("qtfaststart") 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 = 999999 197 | free_size = 0 198 | 199 | # Make sure moov occurs AFTER mdat, otherwise no need to run! 200 | for atom in index: 201 | # The atoms are guaranteed to exist from get_index above! 202 | if atom.name == "moov": 203 | moov_atom = atom 204 | moov_pos = atom.position 205 | elif atom.name == "mdat": 206 | mdat_pos = atom.position 207 | elif atom.name == "free" and atom.position < mdat_pos and cleanup: 208 | # This free atom is before the mdat! 209 | free_size += atom.size 210 | log.info("Removing free atom at %d (%d bytes)" % 211 | (atom.position, atom.size)) 212 | elif (atom.name == "\x00\x00\x00\x00" and atom.position < mdat_pos): 213 | # This is some strange zero atom with incorrect size 214 | free_size += 8 215 | log.info("Removing strange zero atom at %s (8 bytes)" % 216 | atom.position) 217 | 218 | # Offset to shift positions 219 | offset = - free_size 220 | if moov_pos < mdat_pos: 221 | if to_end: 222 | # moov is in the wrong place, shift by moov size 223 | offset -= moov_atom.size 224 | else: 225 | if not to_end: 226 | # moov is in the wrong place, shift by moov size 227 | offset += moov_atom.size 228 | 229 | if offset == 0: 230 | # No free atoms to process and moov is correct, we are done! 231 | msg = "This file appears to already be setup!" 232 | log.error(msg) 233 | raise FastStartSetupError(msg) 234 | 235 | # Check for compressed moov atom 236 | is_compressed = _moov_is_compressed(datastream, moov_atom) 237 | if is_compressed: 238 | msg = "Movies with compressed headers are not supported" 239 | log.error(msg) 240 | raise UnsupportedFormatError(msg) 241 | 242 | # read and fix moov 243 | moov = _patch_moov(datastream, moov_atom, offset) 244 | 245 | log.info("Writing output...") 246 | outfile = open(outfilename, "wb") 247 | 248 | # Write ftype 249 | for atom in index: 250 | if atom.name == "ftyp": 251 | log.debug("Writing ftyp... (%d bytes)" % atom.size) 252 | datastream.seek(atom.position) 253 | outfile.write(datastream.read(atom.size)) 254 | 255 | if not to_end: 256 | _write_moov(moov, outfile) 257 | 258 | # Write the rest 259 | skip_atom_types = ["ftyp", "moov"] 260 | if cleanup: 261 | skip_atom_types += ["free"] 262 | 263 | atoms = [item for item in index if item.name not in skip_atom_types] 264 | for atom in atoms: 265 | log.debug("Writing %s... (%d bytes)" % (atom.name, atom.size)) 266 | datastream.seek(atom.position) 267 | 268 | # for compatability, allow '0' to mean no limit 269 | cur_limit = limit or float('inf') 270 | cur_limit = min(cur_limit, atom.size) 271 | 272 | for chunk in get_chunks(datastream, CHUNK_SIZE, cur_limit): 273 | outfile.write(chunk) 274 | 275 | if to_end: 276 | _write_moov(moov, outfile) 277 | 278 | # Close and set permissions 279 | outfile.close() 280 | try: 281 | shutil.copymode(infilename, outfilename) 282 | except: 283 | log.warn("Could not copy file permissions!") 284 | 285 | def _write_moov(moov, outfile): 286 | # Write moov 287 | bytes = moov.getvalue() 288 | log.debug("Writing moov... (%d bytes)" % len(bytes)) 289 | outfile.write(bytes) 290 | 291 | def _patch_moov(datastream, atom, offset): 292 | datastream.seek(atom.position) 293 | moov = io.BytesIO(datastream.read(atom.size)) 294 | 295 | # reload the atom from the fixed stream 296 | atom = _read_atom_ex(moov) 297 | 298 | for atom in _find_atoms_ex(atom, moov): 299 | # Read either 32-bit or 64-bit offsets 300 | ctype, csize = dict( 301 | stco=('L', 4), 302 | co64=('Q', 8), 303 | )[atom.name] 304 | 305 | # Get number of entries 306 | version, entry_count = struct.unpack(">2L", moov.read(8)) 307 | 308 | log.info("Patching %s with %d entries" % (atom.name, entry_count)) 309 | 310 | entries_pos = moov.tell() 311 | 312 | struct_fmt = ">%(entry_count)s%(ctype)s" % vars() 313 | 314 | # Read entries 315 | entries = struct.unpack(struct_fmt, moov.read(csize * entry_count)) 316 | 317 | # Patch and write entries 318 | offset_entries = [entry + offset for entry in entries] 319 | moov.seek(entries_pos) 320 | moov.write(struct.pack(struct_fmt, *offset_entries)) 321 | return moov 322 | 323 | def get_chunks(stream, chunk_size, limit): 324 | remaining = limit 325 | while remaining: 326 | chunk = stream.read(min(remaining, chunk_size)) 327 | if not chunk: 328 | return 329 | remaining -= len(chunk) 330 | yield chunk 331 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | try: 2 | from setuptools import setup 3 | except ImportError: 4 | from distutils.core import setup 5 | 6 | import qtfaststart 7 | 8 | with open('README.txt') as readme: 9 | long_description = readme.read() 10 | 11 | setup_params = dict( 12 | name='qtfaststart', 13 | version=qtfaststart.VERSION, 14 | description='Quicktime atom positioning in Python for fast streaming.', 15 | long_description=long_description, 16 | author='Daniel G. Taylor', 17 | author_email='dan@programmer-art.org', 18 | url='https://github.com/gtaylor/qtfaststart', 19 | license='MIT License', 20 | platforms=["any"], 21 | provides=['qtfaststart'], 22 | packages=[ 23 | 'qtfaststart', 24 | ], 25 | scripts=['bin/qtfaststart'], 26 | classifiers=[ 27 | 'Development Status :: 5 - Production/Stable', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Natural Language :: English', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python', 33 | 'Topic :: Multimedia :: Video :: Conversion', 34 | 'Topic :: Software Development :: Libraries :: Python Modules', 35 | ], 36 | ) 37 | 38 | if __name__ == '__main__': 39 | setup(**setup_params) 40 | --------------------------------------------------------------------------------