├── .gitignore ├── LICENSE ├── README.md ├── dwencode ├── __init__.py ├── __main__.py ├── concatenate.py ├── encode.py ├── example.bat ├── ffpath.py ├── probe │ ├── __init__.py │ ├── ffprobe.py │ └── quicktime.py └── thumbnail.py └── dwencode_example.png /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | .vscode 4 | test.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 DreamWall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dwencode 2 | FFmpeg python wrapper to: 3 | * encode image sequence to movie with overlay text. 4 | * concatenate videos, including side-by-side/stacked concatenation for comparing multiple videos. 5 | 6 | ## Example: 7 | 8 | ![image] 9 | 10 | [image]: dwencode_example.png "side-by-side input vs overlay" 11 | 12 | Python script: 13 | ```python 14 | rectangle1 = dict(x=96, y=60, width=768, height=480, color='#FFEE55', opacity=.2, thickness=2) 15 | rectangle2 = dict(x=144, y=90, width=672, height=420, color='#909090', opacity=.3, thickness=1) 16 | image = dict(path='c:/path/to/dw_transp.png', x=10, y=10) 17 | metadata = (('author', 'John Doe'), ('title', 'seq10_sh...')) 18 | 19 | encode( 20 | images_path='c:/path/to/image.%04d.jpg', 21 | output_path='c:/path/to/encode_test.mov', 22 | start=15, 23 | end=60, 24 | target_width=960, 25 | target_height=600, 26 | top_left='{datetime}', 27 | top_middle='proj_ep010_sq120_sh0170_spline_v002_tk001', 28 | top_middle_color='#FFEE55', 29 | top_right='DreamWall', 30 | bottom_left='f:%.1fmm' % 35, 31 | bottom_middle='{framerange}', 32 | bottom_right='John Doe', 33 | font_path='c:/path/to/luxisr_0.ttf', 34 | rectangles=[rectangle1, rectangle2], 35 | overlay_image=image, 36 | video_codec='-c:v libx264 -profile:v baseline -level 3.0', 37 | metadata=metadata, 38 | overwrite=True) 39 | ``` 40 | 41 | Windows commandline example:\ 42 | (for Linux just replace `^` by `\` ) 43 | ``` 44 | python c:\path\to\dwencode\dwencode ^ 45 | "c:/path/to/image.####.jpg" ^ 46 | "c:/path/to/encode_test.mov" ^ 47 | --start 15 ^ 48 | --end 60 ^ 49 | --target-width 960 ^ 50 | --target-height 600 ^ 51 | --top-left {datetime} ^ 52 | --top-middle proj_ep010_sq120_sh0170_spline_v002_tk001 ^ 53 | --top-middle-color #FFEE55 ^ 54 | --top-right DreamWall ^ 55 | --bottom-left 35.0mm ^ 56 | --bottom-middle {framerange} ^ 57 | --bottom-right username ^ 58 | --font-path d:/_tmp/luxisr_0.ttf ^ 59 | --rectangle 96-60-768-480-FFEE55-.2-2 ^ 60 | --rectangle 144-90-672-420-909090-.3-1 ^ 61 | --overlay-image d:/_tmp/dw_transp.png-10-10 ^ 62 | --video-codec="-c:v libx264 -profile:v baseline -level 3.0" ^ 63 | --overwrite ^ 64 | --metadata "author:John Doe" ^ 65 | --metadata "title:proj_ep010_sq120_sh0170_spline_v002_tk001" 66 | ``` 67 | 68 | ## Documentation: 69 | The default codec is `libx264` and can be used with `.mov` container. 70 | 71 | Image ratio is preserved. Input a different target ratio to add `black bars`.\ 72 | `Font size` is automatically adapted to target size. 73 | 74 | As text, you can use the following expressions: 75 | - `{frame}`: current frame 76 | - `{framerange}`: current frame + first and last frame. e.g. `130 [40-153]` 77 | - `{datetime}`: date in `YYYY/MM/DD HH:MM` format. 78 | 79 | ### As a python module: 80 | ```python 81 | import dwencode 82 | dwencode.encode( 83 | images_path, # mandatory 84 | output_path, # mandatory 85 | start=None, # default is 0 86 | end=None, # mandatory 87 | frame_rate=None, # default is 24 88 | sound_path=None, 89 | source_width=None, # optional if you have Pillow (PIL) 90 | source_height=None, # optional if you have Pillow (PIL) 91 | target_width=None, 92 | target_height=None, 93 | 94 | top_left=None, 95 | top_middle=None, 96 | top_right=None, 97 | bottom_left=None, 98 | bottom_middle=None, 99 | bottom_right=None, 100 | top_left_color=None, 101 | top_middle_color=None, 102 | top_right_color=None, 103 | bottom_left_color=None, 104 | bottom_middle_color=None, 105 | bottom_right_color=None, 106 | font_path=None, 107 | 108 | overlay_image=None, 109 | rectangles=None, 110 | 111 | video_codec=None, 112 | audio_codec=None, 113 | ffmpeg_path=None, 114 | metadata=None, 115 | overwrite=False) 116 | ``` 117 | 118 | 119 | ### Commandline arguments: 120 | ``` 121 | python dwencode input.####.jpg output.mov 122 | 123 | -s, --start int 124 | -e, --end int 125 | -fps, --framerate int 126 | 127 | -a, --sound-path path 128 | 129 | -sw, --source-width 130 | -sh, --source-height 131 | -tw, --target-width ints 132 | -th, --target-height 133 | 134 | -tl, --top-left 135 | -tm, --top-middle 136 | -tr, --top-right texts 137 | -bm, --bottom-middle 138 | -br, --bottom-right 139 | 140 | -tlc, --top-left-color 141 | -tmc, --top-middle-color=COLOR 142 | -trc, --top-right-color=COLOR colors in RRGGBB@A format 143 | -blc, --bottom-left-color=COLOR 144 | -bmc, --bottom-middle-color=COLOR 145 | -brc, --bottom-right-color=COLOR 146 | 147 | -font, --font-path font path 148 | 149 | -i, --overlay-image image path 150 | 151 | -box, --rectangle x-y-width-height-color-opacity-thickness (repeatable) 152 | 153 | -c:v, --video-codec ffmpeg arg 154 | -c:a, --audio-codec ffmpeg arg 155 | 156 | -p, --ffmpeg_path path 157 | 158 | -m, --metadata key:value (repeatable) 159 | 160 | -ow, --overwrite flag 161 | 162 | -ffp, --ffmpeg-path ffmpeg path (if ffmpeg not in PATH) 163 | -------------------------------------------------------------------------------- /dwencode/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Olivier Evers' 2 | __copyright__ = 'DreamWall' 3 | __license__ = 'MIT' 4 | 5 | from dwencode.encode import encode, extract_image_from_video 6 | from dwencode.concatenate import concatenate_videos 7 | from dwencode.thumbnail import create_thumbnail 8 | -------------------------------------------------------------------------------- /dwencode/__main__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Olivier Evers' 2 | __copyright__ = 'DreamWall' 3 | __license__ = 'MIT' 4 | 5 | 6 | import argparse 7 | import encode 8 | 9 | 10 | parser = argparse.ArgumentParser() 11 | 12 | parser.add_argument('images_path', help='frame number: ####') 13 | parser.add_argument('output_path') 14 | 15 | parser.add_argument('-s', '--start', type=int) 16 | parser.add_argument('-e', '--end', type=int) 17 | parser.add_argument('-fps', '--framerate', type=int) 18 | 19 | parser.add_argument('-a', '--sound-path', type=int) 20 | parser.add_argument('-ao', '--sound-offset', type=float) 21 | 22 | parser.add_argument('-sw', '--source-width', type=int) 23 | parser.add_argument('-sh', '--source-height', type=int) 24 | parser.add_argument('-tw', '--target-width', type=int) 25 | parser.add_argument('-th', '--target-height', type=int) 26 | 27 | parser.add_argument('-tl', '--top-left') 28 | parser.add_argument('-tm', '--top-middle') 29 | parser.add_argument('-tr', '--top-right') 30 | parser.add_argument('-bl', '--bottom-left') 31 | parser.add_argument('-bm', '--bottom-middle') 32 | parser.add_argument('-br', '--bottom-right') 33 | 34 | parser.add_argument('-tlc', '--top-left-color') 35 | parser.add_argument('-tmc', '--top-middle-color') 36 | parser.add_argument('-trc', '--top-right-color') 37 | parser.add_argument('-blc', '--bottom-left-color') 38 | parser.add_argument('-bmc', '--bottom-middle-color') 39 | parser.add_argument('-brc', '--bottom-right-color') 40 | 41 | parser.add_argument('-font', '--font-path') 42 | parser.add_argument('-fs', '--font-scale', type=float, default=1.0) 43 | 44 | parser.add_argument('-i', '--overlay-image', help='path-x-y') 45 | 46 | parser.add_argument( 47 | '-box', '--rectangle', action='append', 48 | help='x-y-width-height-color-opacity-thickness') 49 | 50 | parser.add_argument('-c:v', '--video-codec') 51 | parser.add_argument('-c:a', '--audio-codec') 52 | parser.add_argument('-as', '--add-silent-audio') 53 | parser.add_argument('-ss', '--silence_settings') 54 | 55 | parser.add_argument('-m', '--metadata', action='append', help='key:value') 56 | 57 | parser.add_argument( 58 | '-ow', '--overwrite', default=False, action='store_true') 59 | 60 | parser.add_argument('-ffp', '--ffmpeg-path') 61 | 62 | args = parser.parse_args() 63 | 64 | # Reformat some args: 65 | images_path = args.images_path 66 | for i in range(8, 0, -1): 67 | images_path = images_path.replace('#' * i, '%0{}d'.format(i)) 68 | 69 | rectangles = [] 70 | for rectangle in args.rectangle or []: 71 | try: 72 | rectangle = rectangle.split('-') 73 | rectangles.append(dict( 74 | x=round(float(rectangle[0])), 75 | y=round(float(rectangle[1])), 76 | width=round(float(rectangle[2])), 77 | height=round(float(rectangle[3])), 78 | color=rectangle[4], 79 | opacity=float(rectangle[5]), 80 | thickness=round(float(rectangle[6])), 81 | )) 82 | except BaseException as e: 83 | raise Exception('Wrong rectangle argument\n%s' % e) 84 | 85 | if args.overlay_image: 86 | try: 87 | path, x, y = args.overlay_image.split('-') 88 | overlay_image = dict(path=path, x=int(x), y=int(y)) 89 | except BaseException as e: 90 | raise Exception('Wrong image argument\n%s' % e) 91 | 92 | metadata = [] 93 | for metadatum in args.metadata: 94 | if ':' not in metadatum: 95 | raise Exception( 96 | 'Wrong metadata argument.' 97 | ' Use ":" separator between key and value') 98 | try: 99 | key, value = metadatum.split(':') 100 | 1 / len(key) 101 | 1 / len(value) 102 | metadata.append((key, value)) 103 | except (ZeroDivisionError, ValueError) as e: 104 | raise Exception('Wrong metadata argument\n%s' % e) 105 | 106 | encode.encode( 107 | images_path=images_path, 108 | output_path=args.output_path, 109 | 110 | start=args.start, 111 | end=args.end, 112 | frame_rate=args.framerate, 113 | 114 | sound_path=args.sound_path, 115 | sound_offset=args.sound_offset, 116 | 117 | source_width=args.source_width, 118 | source_height=args.source_height, 119 | target_width=args.target_width, 120 | target_height=args.target_height, 121 | 122 | top_left=args.top_left, 123 | top_middle=args.top_middle, 124 | top_right=args.top_right, 125 | bottom_left=args.bottom_left, 126 | bottom_middle=args.bottom_middle, 127 | bottom_right=args.bottom_right, 128 | top_left_color=args.top_left_color, 129 | top_middle_color=args.top_middle_color, 130 | top_right_color=args.top_right_color, 131 | bottom_left_color=args.bottom_left_color, 132 | bottom_middle_color=args.bottom_middle_color, 133 | bottom_right_color=args.bottom_right_color, 134 | 135 | font_path=args.font_path, 136 | font_scale=args.font_scale, 137 | 138 | overlay_image=overlay_image, 139 | rectangles=rectangles, 140 | 141 | video_codec=args.video_codec, 142 | audio_codec=args.audio_codec, 143 | add_silent_audio=args.add_silent_audio, 144 | silence_settings=args.silence_settings, 145 | 146 | ffmpeg_path=args.ffmpeg_path, 147 | 148 | metadata=metadata, 149 | 150 | overwrite=args.overwrite) 151 | -------------------------------------------------------------------------------- /dwencode/concatenate.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Olivier Evers' 2 | __copyright__ = 'DreamWall' 3 | __license__ = 'MIT' 4 | 5 | 6 | import os 7 | import shlex 8 | import locale 9 | import subprocess as sp 10 | from dwencode.ffpath import get_ffmpeg_path 11 | 12 | 13 | DEFAULT_CONCAT_ENCODING = '-vcodec copy -c:a copy' 14 | DEFAULT_CONCAT_STACK_ENCODING = ( 15 | '-c:v libx264 -crf 26 -preset fast -tune animation -c:a aac -b:a 128k') 16 | STACKED_ARGS = ( 17 | "color=d=0.1[c];[c][0]scale2ref[c][v1];" 18 | "[c][1]scale2ref='{scale2ref}'[c][v2];" 19 | "[c][v1]overlay=0:0[ol-vid1];" 20 | "[ol-vid1][v2]overlay={overlay},setsar=1") 21 | 22 | 23 | def _get_common_root(paths): 24 | paths = [os.path.normpath(p).replace('\\', '/') for p in paths] 25 | root = os.path.commonprefix(paths) 26 | if not root or os.path.normpath(root) != os.path.abspath(root): 27 | # os.path.exists('/') returns True on Windows... 28 | raise Exception('Videos need to be on the same disk.') 29 | if not os.path.isdir(root): 30 | root = os.path.dirname(root) 31 | if root.endswith('/'): 32 | root = root[:-1] 33 | return root 34 | 35 | 36 | def _get_videos_durations(paths): 37 | from dwencode.probe import get_video_duration 38 | durations = [] 39 | for path in paths: 40 | try: 41 | durations.append(get_video_duration(path)) 42 | except ValueError: 43 | print('ERROR: Could not get duration of %s' % path) 44 | raise 45 | return durations 46 | 47 | 48 | def _create_list_file(paths, root, index=0, timings=None): 49 | concat_list = [] 50 | for i, path in enumerate(paths): 51 | concat_list.append('file %s' % path.replace(root, '.')) 52 | if timings: 53 | timing = timings[i] 54 | concat_list.extend( 55 | ['duration %s' % timing, 'outpoint %s' % timing]) 56 | concat_list = '\n'.join(concat_list) 57 | print(concat_list) 58 | list_path = os.path.join( 59 | root, 'temp_video_concatenation_list_%i.txt' % index).replace( 60 | '\\', '/') 61 | 62 | if os.path.exists(list_path): 63 | os.remove(list_path) 64 | with open(list_path, 'w') as f: 65 | f.write(concat_list) 66 | 67 | return list_path 68 | 69 | 70 | def _get_input_args( 71 | paths, stack_orientation='horizontal', master_list_index=0): 72 | input_pattern = '-f concat -safe 0 -i %s ' 73 | if not isinstance(paths[0], list): 74 | common_root = _get_common_root(paths) 75 | list_path = _create_list_file(paths, common_root) 76 | args = input_pattern % list_path 77 | return [list_path], args, common_root 78 | common_root = _get_common_root( 79 | [path for sublist in paths for path in sublist]) 80 | args = ' ' 81 | lists_paths = [] 82 | timings = _get_videos_durations(paths[master_list_index]) 83 | for i, stack in enumerate(paths): 84 | list_path = _create_list_file(stack, common_root, i, timings) 85 | lists_paths.append(list_path) 86 | args += input_pattern % list_path 87 | if stack_orientation in ('horizontal', 0): 88 | scale2ref = "w=main_w+iw:h=max(main_h,ih)" 89 | overlay = "W-w:0" 90 | else: 91 | scale2ref = "w=max(main_w,iw):h=main_h+ih" 92 | overlay = "0:H-h" 93 | stackarg = STACKED_ARGS.format(scale2ref=scale2ref, overlay=overlay) 94 | args += '-filter_complex "%s" ' % stackarg 95 | return lists_paths, args, common_root 96 | 97 | 98 | def concatenate_videos( 99 | paths, output_path, verbose=False, ffmpeg_path=None, delete_list=True, 100 | ffmpeg_codec=DEFAULT_CONCAT_ENCODING, overwrite=False, 101 | stack_orientation='horizontal', stack_master_list=0): 102 | """ 103 | Movies are expected to have: 104 | - a common parent directory 105 | - same format 106 | 107 | @paths argument can be a list or a list of lists. If there is multiple 108 | lists, it will encode them side by side (or on top of each other, 109 | depending on the @stack_orientation argument). 110 | 111 | @stack_master_list is the index of the list which will drive the timing 112 | of the concatenation. 113 | """ 114 | ffmpeg = get_ffmpeg_path(ffmpeg_path) 115 | list_paths, input_args, common_root = _get_input_args( 116 | paths, stack_orientation, stack_master_list) 117 | overwrite = '-y' if overwrite else '' 118 | 119 | if isinstance(paths[0], list) and ffmpeg_codec == DEFAULT_CONCAT_ENCODING: 120 | # => obviously cannot stream copy 121 | ffmpeg_codec = DEFAULT_CONCAT_STACK_ENCODING 122 | 123 | cmd = '%s %s %s %s %s' % ( 124 | ffmpeg, input_args, ffmpeg_codec, overwrite, output_path) 125 | 126 | print(cmd) 127 | cmd = shlex.split(cmd) 128 | 129 | try: 130 | if verbose: 131 | proc = sp.Popen( 132 | cmd, cwd=common_root, stdout=sp.PIPE, stderr=sp.PIPE) 133 | out, err = proc.communicate() 134 | encoding = locale.getpreferredencoding() 135 | out = out.decode(encoding) 136 | print(out) 137 | err = err.decode(encoding) 138 | print(err) 139 | if proc.returncode != 0: 140 | raise ValueError(err) 141 | else: 142 | sp.call(cmd, cwd=common_root) 143 | finally: 144 | if delete_list: 145 | for list_path in list_paths: 146 | os.remove(list_path) 147 | -------------------------------------------------------------------------------- /dwencode/encode.py: -------------------------------------------------------------------------------- 1 | """ 2 | FFmpeg python wrapper to encode image sequence to movie with overlay text. 3 | """ 4 | 5 | __author__ = 'Olivier Evers' 6 | __copyright__ = 'DreamWall' 7 | __license__ = 'MIT' 8 | 9 | 10 | import os 11 | import datetime 12 | import shlex 13 | import subprocess 14 | 15 | from dwencode.ffpath import get_ffmpeg_path 16 | 17 | 18 | def extract_image_from_video(video_path, time, output_path, ffmpegpath=None): 19 | ffmpeg = get_ffmpeg_path(path=ffmpegpath) 20 | subprocess.check_call(shlex.split( 21 | f'{ffmpeg} -ss {time} -i {video_path} -frames:v 1 -y {output_path}')) 22 | 23 | 24 | def conform_path(font_path): 25 | r"""Unix path with escaped semicolon (e.g. 'C\:/WINDOWS/fonts/font.ttf')""" 26 | if font_path is None: 27 | return 28 | return font_path.replace('\\', '/').replace(':', r'\:') 29 | 30 | 31 | def get_image_format(image_path): 32 | try: 33 | from PIL import Image 34 | except ImportError: 35 | raise Exception( 36 | 'Install Pillow (PIL) or specify source_width and source_height.') 37 | with Image.open(image_path) as image: 38 | return image.size 39 | 40 | 41 | def get_padding_values(width, height, target_width, target_height): 42 | scale_x = float(target_width) / width 43 | scale_y = float(target_height) / height 44 | scale = min(scale_x, scale_y) 45 | if scale_x <= scale_y: 46 | image_width = target_width 47 | image_height = round(height * scale) 48 | x_offset = 0 49 | y_offset = round((target_height - image_height) / 2) 50 | else: 51 | # black bars on the side instead of top/bottom: 52 | image_width = round(width * scale) 53 | x_offset = round((target_width - image_width) / 2) 54 | y_offset = 0 55 | return image_width, x_offset, y_offset 56 | 57 | 58 | def drawtext( 59 | text, x, y, color=None, font_path=None, size=36, start=None, end=None): 60 | if text == '{framerange}': 61 | return draw_framerange(x, y, color, font_path, size, start, end) 62 | args = [] 63 | if not color: 64 | # TODO: handle border colors options 65 | args.append('bordercolor=black@0.4:borderw=%s' % int(size / 18)) 66 | color = color or 'white' 67 | text = text.replace(':', r'\:') 68 | text = text.replace('{frame}', '%{frame_num}') 69 | timetag = datetime.datetime.now().strftime(r'%Y/%m/%d %H\:%M') 70 | text = text.replace('{datetime}', timetag) 71 | args.extend([ 72 | None if font_path is None else "fontfile='%s'" % font_path, 73 | "text='%s'" % text, 74 | "x=%s" % x, 75 | "y=%s" % y, 76 | "start_number=%i" % start, 77 | "fontcolor=%s" % color, 78 | "fontsize=%i" % size, 79 | ]) 80 | args = ':'.join([a for a in args if a]) 81 | return "drawtext=%s" % args 82 | 83 | 84 | def draw_framerange( 85 | x, y, color, font_path=None, size=36, start=None, end=None): 86 | # framerange is made of two separate texts: 87 | left_text, right_text = '{frame}', '[%i-%i]' % (start, end) 88 | x = str(x) 89 | if '/2' in x: 90 | # middle 91 | left_x = '%s-(tw/2)-3' % x 92 | right_x = '%s+(tw/2)+3' % x 93 | elif 'w-' in x.replace('tw-', ''): 94 | # right 95 | x = x.replace('tw', 'tw/2') 96 | # ffmpeg doesnt allow to align text on another text. Offset 97 | # position by a fixed amount: 98 | left_x = '%s-%i-(tw/2)-3' % (x, size * 6) 99 | right_x = '%s-%i+(tw/2)+3' % (x, size * 6) 100 | else: 101 | # left 102 | left_x = '%s+%i-(tw)-3' % (x, size * 3) 103 | right_x = '%s+%i+3' % (x, size * 3) 104 | return ','.join(( 105 | drawtext(left_text, left_x, y, color, font_path, size, start), 106 | drawtext(right_text, right_x, y, color, font_path, size, start) 107 | )) 108 | 109 | 110 | def drawbox(x, y, width, height, color, opacity, thickness): 111 | return 'drawbox=x=%s:y=%s:w=%s:h=%s:color=%s@%s:t=%s' % ( 112 | x, y, width, height, color, opacity, thickness) 113 | 114 | 115 | def imagepos(x, y): 116 | return '[0:v][1:v]overlay=%i:%i' % (x, y) 117 | 118 | 119 | def encode( 120 | images_path, 121 | output_path, 122 | start=None, 123 | end=None, 124 | frame_rate=None, 125 | sound_path=None, 126 | sound_offset=None, 127 | source_width=None, 128 | source_height=None, 129 | target_width=None, 130 | target_height=None, 131 | top_left=None, 132 | top_middle=None, 133 | top_right=None, 134 | bottom_left=None, 135 | bottom_middle=None, 136 | bottom_right=None, 137 | top_left_color=None, 138 | top_middle_color=None, 139 | top_right_color=None, 140 | bottom_left_color=None, 141 | bottom_middle_color=None, 142 | bottom_right_color=None, 143 | font_path=None, 144 | font_scale=1.0, 145 | overlay_image=None, 146 | rectangles=None, 147 | input_args=None, 148 | video_codec=None, 149 | audio_codec=None, 150 | add_silent_audio=False, 151 | silence_settings=None, 152 | ffmpeg_path=None, 153 | metadata=None, 154 | overwrite=False, 155 | verbose=False): 156 | """ 157 | Encode images to movie with text overlays (using FFmpeg). 158 | 159 | - images_path (str) Use patterns such as "/path/to/image.%04d.jpg" 160 | - output_path (str) With any FFmpeg supported extensions 161 | - start (int) First frame. Default is 0 162 | - end (int) Last frame 163 | - frame_rate (float) Default is 24 164 | - sound_path (str) Optional 165 | - sound_offset (float) Default is 0 166 | - source_width (int) Optional if you have Pillow (PIL) 167 | - source_height (int) Optional if you have Pillow (PIL) 168 | - target_width (str) Different ratio than source will add black bars. 169 | - target_height (str) Different ratio than source will add black bars. 170 | - top_left (str) Text to display 171 | - top_middle (str) Text to display 172 | - top_right (str) Text to display 173 | - bottom_left (str) Text to display 174 | - bottom_middle (str) Text to display 175 | - bottom_right (str) Text to display 176 | - top_left_color (str) Text color. Format: #RRGGBB@A 177 | - top_middle_color (str) Text color. Format: #RRGGBB@A 178 | - top_right_color (str) Text color. Format: #RRGGBB@A 179 | - bottom_left_color (str) Text color. Format: #RRGGBB@A 180 | - bottom_middle_color (str) Text color. Format: #RRGGBB@A 181 | - bottom_right_color (str) Text color. Format: #RRGGBB@A 182 | - font_path (str) FFmpeg supported font for all texts 183 | - overlay_image (dict) needs {path, x, y} 184 | - rectangles (dicts) need {x,y,width,height,color,opacity,thickness} 185 | - video_codec (str) FFmpeg video codec arguments 186 | - audio_codec (str) FFmpeg audio codec arguments 187 | - add_silent_audio (str) add silent audio if no audio is provided 188 | - silence_settings (str) FFmpeg sound codec settings 189 | - ffmpeg_path (str) Default: searches for 'ffmpeg' in PATH env 190 | - metadata (str) Movie metadata 191 | - overwrite (str) Default is False 192 | 193 | You can use the following text expressions: 194 | - {frame}: current frame 195 | - {framerange}: current frame + first and last frame. 196 | e.g. `130 [40-153]` 197 | - {datetime}: date in YYYY/MM/DD HH:MM format. 198 | 199 | The default codec is `libx264` and can be used with `.mov` 200 | container. 201 | 202 | Image ratio is preserved. Input a different target ratio to add black bars. 203 | 204 | Font size is automatically adapted to target size. 205 | """ 206 | # Check ffmpeg is found: 207 | ffmpeg_path = get_ffmpeg_path(ffmpeg_path) 208 | 209 | frame_rate = frame_rate or 24 210 | start = start or 0 211 | 212 | font_path = conform_path(font_path) 213 | if source_width and source_height: 214 | width, height = source_width, source_height 215 | else: 216 | width, height = get_image_format(images_path % start) 217 | target_width = target_width or width 218 | target_height = target_height or height 219 | 220 | # Command start 221 | cmd = ffmpeg_path or 'ffmpeg' 222 | if not verbose: 223 | cmd += ' -hide_banner -loglevel error -nostats' 224 | 225 | # Input 226 | cmd += ' -framerate %i -f image2 -start_number %i' % (frame_rate, start) 227 | if input_args: 228 | cmd += ' %s ' % input_args 229 | cmd += ' -i "%s"' % images_path 230 | 231 | # Overlay inputs 232 | if overlay_image: 233 | cmd += ' -i "%s"' % overlay_image['path'] 234 | 235 | # Audio codec 236 | if audio_codec and (sound_path or add_silent_audio): 237 | audio_codec = ' ' + audio_codec 238 | elif sound_path: 239 | audio_codec = ' -c:a copy' 240 | else: 241 | audio_codec = ' ' 242 | 243 | # Sound 244 | if sound_path: 245 | if sound_offset: 246 | cmd += ' -itsoffset %f' % sound_offset 247 | cmd += ' -i "%s"' % sound_path 248 | if end: 249 | duration = (end - start + 1) / frame_rate 250 | cmd += ' -t %s' % duration 251 | if '-c:a copy' not in audio_codec: 252 | cmd += ' -af apad -shortest' # make sure audio is as long as vid 253 | elif add_silent_audio: 254 | # Add empty sound in case of concatenate with "-c:a copy" 255 | silence_settings = silence_settings or 'anullsrc=cl=mono:r=48000' 256 | cmd += ' -f lavfi -i %s -shortest' % silence_settings 257 | 258 | # Start filter complex 259 | filter_complex = [] 260 | 261 | # Add overlay images 262 | if overlay_image: 263 | filter_complex.append(imagepos(overlay_image['x'], overlay_image['y'])) 264 | 265 | # Scaling and padding 266 | image_width, x_offset, y_offset = get_padding_values( 267 | width, height, target_width, target_height) 268 | filter_complex.append('scale=%i:-1' % image_width) 269 | filter_complex.append('pad=%i:%i:%i:%i' % ( 270 | target_width, target_height, x_offset, y_offset)) 271 | 272 | # Overlay text 273 | font_size = round(target_width / 53.0 * font_scale) 274 | margin_size = left_pos = top_pos = round(target_width / 240.0) 275 | right_pos = 'w-%i-(tw)' % margin_size 276 | bottom_pos = target_height - font_size - margin_size 277 | middle_pos = '(w-tw)/2' 278 | 279 | kwargs = dict(font_path=font_path, size=font_size, start=start, end=end) 280 | filters_args = ( 281 | (top_left, left_pos, top_pos, top_left_color), 282 | (top_middle, middle_pos, top_pos, top_middle_color), 283 | (top_right, right_pos, top_pos, top_right_color), 284 | (bottom_left, top_pos, bottom_pos, bottom_left_color), 285 | (bottom_middle, middle_pos, bottom_pos, bottom_middle_color), 286 | (bottom_right, right_pos, bottom_pos, bottom_right_color)) 287 | 288 | for text, left, top, color in filters_args: 289 | if not text: 290 | continue 291 | filter_complex.append(drawtext(text, left, top, color, **kwargs)) 292 | 293 | # Add boxes (rectangles/safe-frames) 294 | for rectangle in rectangles or []: 295 | filter_complex.append(drawbox(**rectangle)) 296 | 297 | # Format filter complex 298 | cmd += ' -filter_complex "%s"' % ','.join(filter_complex) 299 | 300 | # Metadata 301 | for key, value in metadata or []: 302 | cmd += ' -metadata %s="%s"' % (key, value) 303 | 304 | # Video codec 305 | if not video_codec: 306 | cmd += ' -vcodec libx264' 307 | else: 308 | if not video_codec.startswith(' '): 309 | video_codec = ' ' + video_codec 310 | cmd += video_codec 311 | 312 | # Sound 313 | cmd += audio_codec + ' -fflags +genpts' 314 | 315 | # Output 316 | if overwrite: 317 | cmd += ' -y' 318 | cmd += ' "%s"' % output_path 319 | 320 | # Launch ffmpeg 321 | print(cmd) 322 | if os.name == 'nt': 323 | cmd = shlex.split(cmd) 324 | proc = subprocess.Popen( 325 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 326 | out, err = proc.communicate() 327 | if proc.returncode != 0: 328 | print(out) 329 | raise Exception(err) 330 | 331 | 332 | if __name__ == '__main__': 333 | directory = '~' 334 | if os.name == 'nt': 335 | directory = 'd:/_tmp' 336 | output_path = directory + '/encode_test.mov' 337 | images_path = ( 338 | directory + '/playblast_example/dwencode_playblast_scene.%04d.jpg') 339 | focal = 'f:%.1fmm' % 35 340 | metadata = ( 341 | ('author', 'John Doe'), 342 | ('title', 'seq10_sh...')) 343 | username = 'John Doe' 344 | site = 'DreamWall' 345 | version = 'proj_ep010_sq120_sh0170_spline_v002_tk001' 346 | 347 | start = 15 348 | end = 60 349 | target_width = 1920 / 1.5 350 | target_height = 1200 / 1.5 351 | rectangle1 = dict( 352 | x=int(target_width * .1), 353 | y=int(target_height * .1), 354 | width=int(target_width * .8), 355 | height=int(target_height * .8), 356 | color='#FFEE55', 357 | opacity=.2, 358 | thickness=2) 359 | rectangle2 = dict( 360 | x=int(target_width * .15), 361 | y=int(target_height * .15), 362 | width=int(target_width * .7), 363 | height=int(target_height * .7), 364 | color='#909090', 365 | opacity=.3, 366 | thickness=1) 367 | image = dict(path=directory + '/dw_transp.png', x=10, y=10) 368 | 369 | encode( 370 | images_path=images_path, 371 | start=15, 372 | end=60, 373 | output_path=output_path, 374 | target_width=target_width, 375 | target_height=target_height, 376 | top_left='{datetime}', 377 | top_middle=version, 378 | top_middle_color='#FFEE55', 379 | top_right=site, 380 | bottom_left=focal, 381 | bottom_middle='{framerange}', 382 | bottom_right='username', 383 | font_path=directory + '/luxisr_0.ttf', 384 | rectangles=[rectangle1, rectangle2], 385 | overlay_image=image, 386 | video_codec='-c:v libx264 -profile:v baseline -level 3.0', 387 | metadata=metadata, 388 | overwrite=True) 389 | 390 | os.startfile(output_path) 391 | -------------------------------------------------------------------------------- /dwencode/example.bat: -------------------------------------------------------------------------------- 1 | py D:\_dev\dwdev\dwencode_public\dwencode ^ 2 | "d:/_tmp/playblast_example/dwencode_playblast_scene.####.jpg" ^ 3 | "d:/_tmp/encode_test.mov" ^ 4 | --start 15 ^ 5 | --end 60 ^ 6 | --target-width 960 ^ 7 | --target-height 600 ^ 8 | --top-left {datetime} ^ 9 | --top-middle proj_ep010_sq120_sh0170_spline_v002_tk001 ^ 10 | --top-middle-color #FFEE55 ^ 11 | --top-right DreamWall ^ 12 | --bottom-left 35.0mm ^ 13 | --bottom-middle {framerange} ^ 14 | --bottom-right username ^ 15 | --font-path d:/_tmp/luxisr_0.ttf ^ 16 | --rectangle 96-60-768-480-FFEE55-.2-2 ^ 17 | --rectangle 144-90-672-420-909090-.3-1 ^ 18 | --overlay-image d:/_tmp/dw_transp.png-10-10 ^ 19 | --video-codec="-c:v libx264 -profile:v baseline -level 3.0" ^ 20 | --overwrite ^ 21 | --metadata "author:John Doe" ^ 22 | --metadata "title:proj_ep010_sq120_sh0170_spline_v002_tk001" 23 | 24 | 25 | start d:/_tmp/encode_test.mov 26 | -------------------------------------------------------------------------------- /dwencode/ffpath.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import lru_cache 3 | 4 | 5 | CREATE_NO_WINDOW = 0x08000000 6 | 7 | 8 | @lru_cache() 9 | def get_ffmpeg_executable_path(name='ffmpeg', path=None): 10 | executable_name = f'{name}.exe' if os.name == 'nt' else name 11 | if path: 12 | if os.path.isdir(path): 13 | path = f'{path}/{executable_name}'.replace('\\', '/') 14 | if not os.path.exists(path): 15 | raise Exception(f'"{name}" not found.') 16 | return path 17 | else: 18 | for path in os.environ['PATH'].split(os.pathsep): 19 | path = f'{path}/{executable_name}'.replace('\\', '/') 20 | if os.path.exists(path): 21 | return path 22 | raise Exception(f'"{name}" not found.') 23 | 24 | 25 | def get_ffmpeg_path(path=None): 26 | return get_ffmpeg_executable_path('ffmpeg', path) 27 | 28 | 29 | def get_ffprobe_path(path=None): 30 | return get_ffmpeg_executable_path('ffprobe', path) 31 | -------------------------------------------------------------------------------- /dwencode/probe/__init__.py: -------------------------------------------------------------------------------- 1 | from dwencode.probe import ffprobe 2 | from dwencode.probe import quicktime 3 | 4 | 5 | try: 6 | import cv2 # opencv-python 7 | except ImportError: 8 | print('Warning: cannot use opencv => slower movie duration queries.') 9 | cv2 = None 10 | 11 | 12 | def cv2_get_video_duration(video_path, frames=False): 13 | video = cv2.VideoCapture(video_path) 14 | duration = video.get(cv2.CAP_PROP_FRAME_COUNT) 15 | if not frames and duration: 16 | duration /= video.get(cv2.CAP_PROP_FPS) 17 | return duration 18 | 19 | 20 | def get_video_duration(video_path, frames=False, ffprobe_path=None): 21 | if cv2 is not None: 22 | return cv2_get_video_duration(video_path, frames) 23 | elif video_path.endswith('.mov'): 24 | return quicktime.get_mov_duration(video_path, frames, framerate=25.0) 25 | else: 26 | return ffprobe.get_video_duration(video_path, frames, ffprobe_path) 27 | -------------------------------------------------------------------------------- /dwencode/probe/ffprobe.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import json 4 | import locale 5 | import subprocess as sp 6 | from dwencode.ffpath import get_ffprobe_path 7 | 8 | 9 | CREATE_NO_WINDOW = 0x08000000 10 | 11 | 12 | def probe(vid_file_path, ffprobe_path=None): 13 | ffprobe_path = get_ffprobe_path(ffprobe_path) 14 | command = [ 15 | ffprobe_path, '-loglevel', 'quiet', '-print_format', 'json', 16 | '-show_format', '-show_streams', vid_file_path] 17 | proc = sp.Popen( 18 | command, stdout=sp.PIPE, stderr=sp.STDOUT, 19 | cwd=os.path.expanduser('~'), # fix for Windows msg about UNC paths 20 | creationflags=CREATE_NO_WINDOW) 21 | out = proc.communicate()[0].decode(locale.getpreferredencoding()) 22 | try: 23 | return json.loads(out) 24 | except ValueError: 25 | print('Could not load output as json: \n%s' % out) 26 | raise 27 | 28 | 29 | def get_video_duration(video_path, frames=False, ffprobe_path=None): 30 | data = probe(video_path, ffprobe_path) 31 | vid_stream = [s for s in data['streams'] if 'nb_frames' in s][0] 32 | if frames: 33 | return int(vid_stream['nb_frames']) 34 | return float(vid_stream['duration']) 35 | 36 | 37 | def get_audio_duration(video_path, ffprobe_path=None): 38 | data = probe(video_path, ffprobe_path) 39 | stream = [s for s in data['streams'] if s['codec_type'] == 'audio'][0] 40 | return float(stream['duration']) 41 | 42 | 43 | def get_format(vid_file_path, ffprobe_path=None): 44 | ffprobe_path = get_ffprobe_path(ffprobe_path) 45 | data = probe(vid_file_path, ffprobe_path) 46 | vid_stream = [s for s in data['streams'] if 'coded_width' in s][0] 47 | return vid_stream['coded_width'], vid_stream['coded_height'] 48 | 49 | 50 | def _get_formats(vid_file_paths, ffprobe_path): 51 | processes = dict() 52 | for path in vid_file_paths: 53 | command = [ 54 | ffprobe_path, '-loglevel', 'quiet', '-print_format', 'json', 55 | '-show_format', '-show_streams', path] 56 | p = sp.Popen(command, stdout=sp.PIPE, stderr=sp.STDOUT, shell=True) 57 | processes[path] = p 58 | 59 | formats = dict() 60 | for path, p in processes.items(): 61 | try: 62 | data = json.loads(p.communicate()[0]) 63 | vid_stream = [s for s in data['streams'] if 'coded_width' in s][0] 64 | format_ = vid_stream['coded_width'], vid_stream['coded_height'] 65 | formats[path] = format_ 66 | except BaseException: 67 | formats[path] = (0, 0) 68 | 69 | return formats 70 | 71 | 72 | def _chunks(list_, chunk_size): 73 | """Yield successive n-sized chunks from l.""" 74 | for i in range(0, len(list_), chunk_size): 75 | yield list_[i:i + chunk_size] 76 | 77 | 78 | def get_formats(vid_file_paths, chunk_size=64, ffprobe_path=None): 79 | # Cannot open infinite number of files at the same time, so cut the list 80 | # into pieces: 81 | ffprobe_path = get_ffprobe_path(ffprobe_path) 82 | start_time = time.time() 83 | formats = dict() 84 | count = len(vid_file_paths) 85 | for i, paths_chunk in enumerate(_chunks(vid_file_paths, chunk_size)): 86 | print('Getting movies formats: %i/%i' % ((i + 1) * chunk_size, count)) 87 | formats.update(_get_formats(paths_chunk, ffprobe_path=ffprobe_path)) 88 | print(time.time() - start_time) 89 | return formats 90 | 91 | 92 | def get_streams_durations(file_path, ffprobe_path=None): 93 | data = probe(file_path, ffprobe_path) 94 | if len(data['streams']) != 2: 95 | raise ValueError('This only handles one video and one audio stream.') 96 | durations = { 97 | s['codec_type']: float(s['duration']) for s in data['streams']} 98 | if len(durations) != 2: 99 | raise ValueError('This only handles one video and one audio stream.') 100 | return durations 101 | 102 | 103 | def are_streams_of_same_duration(file_path, ffprobe_path=None): 104 | durations = get_streams_durations(file_path, ffprobe_path) 105 | return durations['video'] == durations['audio'] 106 | 107 | 108 | def is_audio_shorter_than_video(file_path, ffprobe_path=None): 109 | durations = get_streams_durations(file_path, ffprobe_path) 110 | return durations['video'] > durations['audio'] 111 | -------------------------------------------------------------------------------- /dwencode/probe/quicktime.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- mode: python -*- 3 | 4 | """ 5 | Downloaded from https://github.com/Deconimus/quicktime-parser 6 | """ 7 | 8 | 9 | # This program is free software. It comes without any warranty, to the extent 10 | # permitted by applicable law. You can redistribute it and/or modify it under 11 | # the terms of the Do What The Fuck You Want To Public License, Version 2, as 12 | # published by Sam Hocevar. See http://sam.zoy.org/wtfpl/COPYING for more 13 | # details. 14 | 15 | # Some useful resources: 16 | # - http://atomicparsley.sourceforge.net/mpeg-4files.html 17 | # - http://developer.apple.com/library/mac/#documentation/QuickTime/ 18 | # QTFF/QTFFChap2/qtff2.html 19 | # - http://www.sno.phy.queensu.ca/~phil/exiftool/TagNames/QuickTime.html 20 | 21 | import datetime 22 | import os.path 23 | import struct 24 | import time 25 | 26 | NAMES = { # not used for anything, but just documents a short blurb 27 | # about what these things mean 28 | "vmhd": "video information media header", 29 | "mvhd": 'movie header', 30 | "tkhd": 'track header', 31 | # The media header atom specifies the characteristics of a media, 32 | # including time scale and duration 33 | "mdhd": 'media header', 34 | "smhd": 'sound media information header', 35 | # The handler reference atom specifies the media handler component that is 36 | # to be used to interpret the media's data 37 | "hdlr": 'handler reference', 38 | 39 | # The sample description atom contains a table of sample descriptions 40 | "stsd": "sample description", 41 | "stts": "time-to-sample", # Time-to-sample atoms store duration 42 | # information for a media's samples, providing a mapping from a time in a 43 | # media to the corresponding data sample 44 | # The sample-to-chunk atom contains a table that maps samples to chunks 45 | "stsc": "sample-to-chunk", 46 | "stco": 'chunk offset', # Chunk offset atoms identify the location of 47 | # each chunk of data 48 | "stsz": 'sample size', # You use sample size atoms to specify the size of 49 | # each sample 50 | # The composition offset atom contains a sample-by-sample mapping of the 51 | # decode-to-presentation time 52 | "ctts": 'composition offset', 53 | "stss": "sync sample", # The sync sample atom identifies the key frames 54 | } 55 | 56 | CONTAINER_ATOMS = ["moov", "trak", "mdia", "minf", "dinf", "stbl"] 57 | # undocumented atoms or redundant for metadata 58 | _IGNORE_ATOMS = ["iods", "mdat"] 59 | _ATOMS = { 60 | "pnot": (12, "I2x4s2x", 61 | ("Modification time", "Atom type"), 62 | (0,)), 63 | "vmhd": (12, "4xH6x", 64 | ("graphics mode",), 65 | ()), 66 | "mvhd": (100, "4x5IH10x36x7I", 67 | ("Creation time", "Modification time", 68 | "Time Scale", 69 | 'Duration', 70 | 'Preferred rate', 71 | 'Preferred volume', 72 | 'preview time', 73 | 'preview duration', 74 | 'poster time', 75 | 'selection time', 76 | 'selection duration', 77 | 'current time', 78 | 'next track id' 79 | ), 80 | (4, 8)), 81 | "tkhd": (84, "4x2I72x", 82 | ("Creation time", "Modification time"), 83 | (4, 8)), 84 | "mdhd": (24, "B3x4I2H", # 3x is "flags" 85 | ("Version", "Creation time", "Modification time", 86 | "Time Scale", "Duration", "Language", "Quality"), 87 | (4, 8)), # positions where dates are so we can modify them 88 | "smhd": (8, "4xH2x", 89 | ("balance",), 90 | ()) 91 | } 92 | 93 | _VARIABLE_LEN_ATOMS = { 94 | "hdlr": (4 + 5 * 4, "4x5I", 95 | ("Component type", 96 | 'component subtype', 97 | 'component manufacturer', 98 | 'component flags', 99 | 'component flags mask'), 100 | (), 101 | "component name" 102 | ), 103 | "stsd": (8, "4xI", 104 | ("number of entries",), 105 | (), 106 | "sample description table"), 107 | "stts": (8, "4xI", 108 | ("number of entries",), 109 | (), 110 | "time-to-sample table"), 111 | "stsc": (8, "4xI", 112 | ("number of entries",), 113 | (), 114 | "sample-to-chunk table"), 115 | "stco": (8, "4xI", 116 | ("number of entries",), 117 | (), 118 | "chunk offset table"), 119 | "stsz": (12, "4xII", 120 | ("sample size", "number of entries",), 121 | (), 122 | "sample size table"), 123 | "ctts": (12, "4xII", 124 | ("entry count",), 125 | (), 126 | "composition offset table"), 127 | "stss": (12, "4xII", 128 | ("number of entries",), 129 | (), 130 | "sync sample table") 131 | } 132 | 133 | _VARIABLE_CHAINED_ATOMS = { 134 | "dref": (8, "4xI", 135 | ("number of entries",), 136 | (), 137 | "data references" 138 | ) 139 | } 140 | 141 | _DATES = ("Creation time", "Modification time") 142 | 143 | 144 | class Mov(object): 145 | def __init__(self, fn): 146 | self._fn = fn 147 | self._offsets = [] 148 | self.metadata = dict() 149 | 150 | def parse(self): 151 | fsize = os.path.getsize(self._fn) 152 | # print("File: {} ({} bytes, {} MB)".format( 153 | # self._fn, fsize, fsize / (1024.**2))) 154 | with open(self._fn, "rb") as self._f: 155 | self._parse(fsize) 156 | 157 | def _f_read(self, l): 158 | # print('reading '+str(l)) 159 | return self._f.read(l) 160 | 161 | def _f_skip(self, l): 162 | self._f.seek(l, 1) 163 | 164 | def _parse(self, length, depth=0): 165 | n = 0 166 | while n < length: 167 | data = self._f_read(8) 168 | al, an = struct.unpack(">I4s", data) 169 | an = an.decode() 170 | 171 | if an in _ATOMS: 172 | self._parse_atom(an, al - 8, depth) 173 | elif an == "udta": 174 | self._parse_udta(al - 8, depth) 175 | elif an == "ftyp": 176 | self._read_ftyp(al - 8, depth) 177 | elif an in CONTAINER_ATOMS: 178 | self._parse(al - 8, depth + 1) 179 | elif an in _VARIABLE_LEN_ATOMS: 180 | self._parse_atom(an, al - 8, depth, variable=True) 181 | elif an in _VARIABLE_CHAINED_ATOMS: 182 | self._parse_atom(an, al - 8, depth, chained=True) 183 | elif an in _IGNORE_ATOMS: 184 | self._f_skip(al - 8) 185 | elif an == "meta": 186 | self._parse_meta(al - 8, depth) 187 | else: 188 | # print('unhandled thingie',al,an) 189 | if al == 1: 190 | # 64 bit! 191 | # print("64 bit header!") 192 | al = struct.unpack(">Q", self._f_read(8))[0] 193 | self._f_skip(al - 16) 194 | else: 195 | self._f_skip(al - 8) 196 | n += al 197 | 198 | def _parse_atom(self, atom, length, depth, variable=False, chained=False): 199 | if variable: 200 | spec = _VARIABLE_LEN_ATOMS[atom] 201 | elif chained: 202 | spec = _VARIABLE_CHAINED_ATOMS[atom] 203 | else: 204 | spec = _ATOMS[atom] 205 | assert length == spec[0] 206 | 207 | pos = self._f.tell() 208 | data = self._f_read(length) 209 | if variable: 210 | v = struct.unpack(">" + spec[1], data[:spec[0]]) 211 | elif chained: 212 | v = struct.unpack(">" + spec[1], data[:spec[0]]) 213 | else: 214 | v = struct.unpack(">" + spec[1], data) 215 | k = spec[2] 216 | for i in range(0, len(k)): 217 | vv = v[i] 218 | if isinstance(vv, bytes): 219 | vv = vv.decode() 220 | elif k[i] in _DATES: 221 | vv = self._macdate2date(vv) 222 | # print("{}{}: {}".format(prefix, k[i], vv)) 223 | metakey = k[i].lower().strip() 224 | if metakey not in self.metadata.keys(): 225 | self.metadata[metakey] = vv 226 | 227 | if variable or chained: 228 | lim = 10 229 | realdata = data[spec[0]:] 230 | if len(realdata) > lim: 231 | # print("{}{}: {}{}{}{}".format( 232 | # prefix, spec[4], realdata[:lim], '...', 233 | # len(realdata)-lim,' more bytes')) 234 | pass 235 | else: 236 | # print("{}{}: {}".format(prefix, spec[4], realdata)) 237 | pass 238 | 239 | for offset in spec[3]: 240 | self._offsets.append(pos + offset) 241 | 242 | def _parse_meta(self, length, depth): 243 | 244 | pos = self._f.tell() 245 | 246 | self._f_skip(16) 247 | 248 | header_version = self._f_read(4).decode("latin1") 249 | if header_version != "mdta": 250 | return 251 | 252 | self._f_skip(33) 253 | 254 | keys = [] 255 | values = [] 256 | 257 | h = self._f_read(4).decode("latin1") 258 | while h == "mdta": 259 | data = "" 260 | b = self._f_read(1) 261 | while b != b'\x00': 262 | data += b.decode("latin1") 263 | b = self._f_read(1) 264 | keys.append(data.lower().strip()) 265 | while b == b'\x00': 266 | b = self._f_read(1) 267 | h = self._f_read(4).decode("latin1") 268 | 269 | i = 0 270 | while i < len(keys): 271 | h = "" 272 | while h != "data": 273 | b = self._f_read(1) 274 | while b == b'\x00': 275 | b = self._f_read(1) 276 | b = self._f_read(1) 277 | if b != b'\x00': 278 | h = b.decode("latin1") 279 | h += self._f_read(3).decode("latin1") 280 | self._f_skip(8) 281 | data = "" 282 | b = self._f_read(1) 283 | while b != b'\x00' and self._f.tell() - pos < length: 284 | data += b.decode("latin1") 285 | b = self._f_read(1) 286 | values.append(data.strip()) 287 | i += 1 288 | 289 | for i in range(0, min(len(keys), len(values))): 290 | 291 | key = keys[i] 292 | if key.startswith("com.apple.quicktime."): 293 | key = key[20:] 294 | 295 | self.metadata[key] = values[i] 296 | 297 | self._f.seek(pos + length, 0) 298 | 299 | def _read_ftyp(self, length, depth): 300 | data = self._f_read(8) 301 | brand, version = struct.unpack(">4sI", data) 302 | brand = brand.decode("latin1") 303 | # print("{}Brand: {}, version: {}".format(prefix, brand, version)) 304 | self._f_skip(length - 8) 305 | 306 | def _parse_udta(self, length, depth): 307 | n = 0 308 | while n < length: 309 | atom_size, data_type = struct.unpack(">I4s", self._f_read(8)) 310 | data_type = data_type.decode("latin1") 311 | n += atom_size 312 | 313 | def _macdate2date(self, md): 314 | d = datetime.datetime(1904, 1, 1) + datetime.timedelta(seconds=md) 315 | return "{} ({})".format(d, md) 316 | 317 | def _date2macdate(self, d): 318 | td = datetime.datetime(1970, 1, 1) - datetime.datetime(1904, 1, 1) 319 | dd = d + td 320 | sec = time.mktime(dd.timetuple()) - time.timezone 321 | return int(sec) 322 | 323 | def set_date(self, d): 324 | md = self._date2macdate(d) 325 | print("New date: {} ({})".format(d, md)) 326 | with open(self._fn, "r+b") as f: 327 | print("Writing new date at {} positions...".format( 328 | len(self._offsets))) 329 | for offset in self._offsets: 330 | f.seek(offset) 331 | data = struct.pack(">I", md) 332 | f.write(data) 333 | f.flush() 334 | print("Touching file...") 335 | ts = time.mktime(d.timetuple()) 336 | os.utime(self._fn, (ts, ts)) 337 | print("Done!") 338 | 339 | 340 | def get_mov_duration(mov_path, frames=False, framerate=25.0): 341 | # FIXME: get framerate from movie 342 | m = Mov(mov_path) 343 | m.parse() 344 | duration = ( 345 | m.metadata['duration'] / float(m.metadata['time scale'])) 346 | if frames: 347 | duration = int(round(duration * framerate)) 348 | return duration 349 | -------------------------------------------------------------------------------- /dwencode/thumbnail.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from dwencode.ffpath import get_ffmpeg_path 3 | 4 | 5 | def create_thumbnail( 6 | source, output_path, width=256, height=144, 7 | time=0, overwrite=False, creationflags=0): 8 | """ 9 | source can be a video or an image. 10 | """ 11 | cmd = [ 12 | get_ffmpeg_path(), 13 | '-i', source, '-ss', str(time), '-frames:v', '1', '-vf', 14 | f'scale={width}:{height}'] 15 | if overwrite: 16 | cmd.append('-y') 17 | cmd.append(output_path) 18 | subprocess.check_call(cmd, creationflags=creationflags) 19 | -------------------------------------------------------------------------------- /dwencode_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DreamWall-Animation/dwencode/722a2789ab00cfbdd58a815dc1e016f0ca3b21f6/dwencode_example.png --------------------------------------------------------------------------------