├── .gitignore ├── Makefile ├── setup.py ├── README.md └── larry ├── hasher.py ├── __init__.py └── larry.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.jpg 4 | *.png 5 | *.mp4 6 | *.mkv 7 | *.gif 8 | build 9 | dist 10 | *.egg-info 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: develop install clean 2 | 3 | install develop: %: 4 | pip --quiet install -r $< 5 | python setup.py $(SETUPFLAGS) $* $(PYTHONFLAGS) 6 | 7 | clean: 8 | rm -rf *.egg-info build dist 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This file is part of larry 5 | from setuptools import setup 6 | 7 | setup( 8 | name='larry', 9 | 10 | version='0.1', 11 | 12 | description='larry', 13 | 14 | url='http://github.com/fitnr/larry', 15 | 16 | author='Neil Freeman', 17 | 18 | author_email='contact@fakeisthenewreal.org', 19 | 20 | license='All rights reserved', 21 | 22 | packages=[ 23 | 'larry', 24 | ], 25 | 26 | entry_points={ 27 | 'console_scripts': [ 28 | 'larry=larry:main', 29 | ], 30 | }, 31 | 32 | zip_safe=True, 33 | 34 | install_requires=[ 35 | 'Pillow>=3,<3.1', 36 | 'moviepy>=0.2.2.11,<0.3', 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # larry 2 | 3 | Create short, boring film clips. 4 | 5 | Install with `python setup.py install`. Requires [Pillow](https://pypi.python.org/pypi/Pillow/2.1.0), which uses external libraries, so you may get an error without those. Tested on OS X and CentOS. 6 | 7 | ```` 8 | usage: larry [-h] [--max-length MAX_LENGTH] [--start START] 9 | [--granularity FRACTION] [-f {gif,mp4}] [-o OUTPUT] 10 | video 11 | 12 | Create a short, boring film clip 13 | 14 | positional arguments: 15 | video video file to use 16 | 17 | optional arguments: 18 | -h, --help show this help message and exit 19 | --max-length MAX_LENGTH 20 | maximum length of clip 21 | --start START time index to start search [default: random] 22 | --granularity FRACTION 23 | higher values walk the film more carefully 24 | -f {gif,mp4}, --format {gif,mp4} 25 | output format [default is mp4] 26 | -o OUTPUT, --output OUTPUT 27 | save output file here [default: stdout] 28 | ```` 29 | 30 | ## License 31 | 32 | Copyright (c) 2015 Neil Freeman. All rights reserved. -------------------------------------------------------------------------------- /larry/hasher.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PIL import Image 3 | from moviepy.editor import VideoFileClip 4 | 5 | """ 6 | Generate hashes for a video file, 7 | one for each frame at 25 FPS (which makes nice fractions) 8 | """ 9 | 10 | def hamming_distance(s1, s2): 11 | """ 12 | Return the Hamming distance between equal-length sequences 13 | source: Wikipedia 14 | """ 15 | if len(s1) != len(s2): 16 | raise ValueError("Undefined for sequences of unequal length") 17 | 18 | return sum(ch1 != ch2 for ch1, ch2 in zip(s1, s2)) 19 | 20 | def hash_frame(frame, resize=None): 21 | """ 22 | Use PIL to hash a single image (frame). 23 | source: http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html 24 | :frame array representation of the image 25 | :resize int number of pixels on each size of hash image 26 | """ 27 | resize = resize or 8 28 | 29 | i = Image.fromarray(frame).resize((resize, resize), Image.ANTIALIAS).convert("L") 30 | 31 | pixels = list(i.getdata()) 32 | avg = sum(pixels) / len(pixels) 33 | 34 | bits = "".join('1' if pixel < avg else '0' for pixel in pixels) 35 | hexadecimal = int(bits, 2).__format__('016x').upper() 36 | 37 | return hexadecimal 38 | 39 | 40 | def hash_video(videofile, *args): 41 | video = VideoFileClip(videofile, audio=False).set_fps(25) 42 | return (hash_frame(frame, *args) for frame in video.iter_frames()) 43 | 44 | 45 | if __name__ == '__main__': 46 | hashes = hash_video(sys.argv[1]) 47 | sys.stdout.writelines(h + '\n' for h in hashes) 48 | -------------------------------------------------------------------------------- /larry/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This file is part of larry 5 | # Copyright 2015 Neil Freeman 6 | # all rights reserved 7 | import argparse 8 | import os.path 9 | from . import larry 10 | from . import hasher 11 | 12 | __version__ = '0.1.0' 13 | 14 | 15 | def main(): 16 | format_choices = ('gif', 'mp4') 17 | 18 | parser = argparse.ArgumentParser( 19 | description='Create a short, boring film clip') 20 | 21 | parser.add_argument('video', type=str, help='video file to use') 22 | 23 | parser.add_argument('--max-length', default=4, type=int, help='maximum length of clip') 24 | 25 | parser.add_argument('--start', default=None, type=int, 26 | help='time index to start search [default: random]') 27 | 28 | parser.add_argument('--granularity', default=4, dest='fraction', type=int, 29 | help='higher values walk the film more carefully') 30 | 31 | parser.add_argument('-f', '--format', choices=format_choices, default='mp4', 32 | type=str, help='output format [default: mp4]') 33 | 34 | parser.add_argument('-o', '--output', default=None, type=str, 35 | help='save output file here [default: stdout]') 36 | 37 | args = parser.parse_args() 38 | 39 | # find clip, searches video starting from --time-index 40 | clip = larry.find_clip( 41 | args.video, 42 | start=args.start, 43 | max_length=args.max_length, 44 | fraction=args.fraction 45 | ) 46 | 47 | if not clip: 48 | return 49 | 50 | # Save the file to a specific location or just a tmp file 51 | if args.output: 52 | # enforce the proper file suffix 53 | dst = os.path.splitext(args.output)[0] + os.path.extsep + args.format 54 | else: 55 | dst = '/dev/stdout' 56 | 57 | # Write to file 58 | if dst[-3:] == 'mp4': 59 | clip.write_videofile(dst, verbose=False) 60 | else: 61 | clip.write_gif(dst, loop=True, verbose=False) 62 | 63 | if __name__ == '__main__': 64 | main() 65 | -------------------------------------------------------------------------------- /larry/larry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # This file is part of larry 4 | # Copyright 2015 Neil Freeman 5 | # all rights reserved 6 | 7 | from __future__ import division 8 | from random import randint 9 | from moviepy.editor import VideoFileClip 10 | from moviepy.editor import vfx 11 | from .hasher import hamming_distance, hash_frame 12 | 13 | 14 | def subclip(film, start=None, end=None, search_duration=None, **kwargs): 15 | ''' 16 | return a subclip of a film between start and end, lasting at least search_duration 17 | ''' 18 | search_duration = search_duration or 5 * 60 19 | 20 | # Pick a random place in a film, skipping the very start and end 21 | start = start or randint(30, int(film.duration) - 30) 22 | start = min(start, film.duration - 30) 23 | 24 | end = min(start + search_duration, film.duration) 25 | 26 | return film.subclip(start, end) 27 | 28 | 29 | def find_clip(filename, min_length=None, max_length=None, **kwargs): 30 | """ 31 | Returns VideoFileClip subclip object or None. 32 | Loops through film, one-half second at a time, 33 | looking at phashes trying to put together a 3+ second clip. 34 | :filename File to search 35 | :start start index in seconds 36 | :search_duration length of search in seconds 37 | :fraction search by of a second 38 | :min_length minimum length of output clip, in seconds 39 | :max_length maximum length of output clip, in seconds 40 | """ 41 | # fractions of a second to search by 42 | fraction = kwargs.pop('fraction', 2) 43 | 44 | # argument in seconds, convert to fractions of a second 45 | min_length = (min_length or 2) * fraction 46 | max_length = (max_length or 4) * fraction 47 | 48 | # cut up complete film 49 | complete = VideoFileClip(filename, audio=False) 50 | film = subclip(complete, **kwargs) 51 | 52 | buff = [] 53 | 54 | # search film by fraction-of-a-second units 55 | for t in range(int(film.duration) * fraction): 56 | tc = t / fraction 57 | phash = hash_frame(film.get_frame(tc)) 58 | 59 | try: 60 | dist = hamming_distance(buff[-1], phash) 61 | 62 | except IndexError: 63 | # If IndexError, we are at the start of the loop. 64 | # Add the hash and continue. 65 | buff.append(phash) 66 | continue 67 | 68 | # If max distance is exceeded, 69 | # either return a buffer that reaches min length or start search over. 70 | if dist > 6: 71 | if len(buff) > min_length: 72 | break 73 | else: 74 | buff = [phash] 75 | continue 76 | 77 | # If hash is close to previous hash, extend our buffer 78 | else: 79 | buff.append(phash) 80 | 81 | if len(buff) >= max_length: 82 | break 83 | 84 | if len(buff) > min_length: 85 | clipstart = tc - len(buff) / fraction + 1 / fraction 86 | clipend = tc - 1 / fraction 87 | 88 | # slow down slightly to make things more ponderous 89 | return film.subclip(clipstart, clipend).fx(vfx.speedx, 0.80) 90 | 91 | else: 92 | return None 93 | --------------------------------------------------------------------------------