From 8221e3c020d67710a1e2791eee9a423e26542233 Mon Sep 17 00:00:00 2001 From: Jaisen Mathai Date: Tue, 20 Oct 2015 01:17:09 -0700 Subject: [PATCH] Add video geotagging support using avmetareadwrite on OS X --- elodie/media/__init__.py | 4 +- elodie/media/media.py | 23 +++- elodie/media/photo.py | 76 ++++++++++- elodie/media/video.py | 244 ++++++++++++++++++++++++++++++++++- tests/scripts/datetime.py | 16 +-- tests/scripts/geolocation.py | 11 +- update.py | 39 +++--- 7 files changed, 355 insertions(+), 58 deletions(-) diff --git a/elodie/media/__init__.py b/elodie/media/__init__.py index a430ebb..388043d 100644 --- a/elodie/media/__init__.py +++ b/elodie/media/__init__.py @@ -1,3 +1 @@ -from media import Media -from photo import Photo -from video import Video +from media import * diff --git a/elodie/media/media.py b/elodie/media/media.py index c3b344b..44eaecd 100644 --- a/elodie/media/media.py +++ b/elodie/media/media.py @@ -19,6 +19,10 @@ import time Media class for general video operations """ class Media(object): + # class / static variable accessible through get_valid_extensions() + video_extensions = ('avi','m4v','mov','mp4','3gp') + photo_extensions = ('jpg', 'jpeg', 'nef', 'dng') + """ @param, source, string, The fully qualified path to the video file """ @@ -201,12 +205,17 @@ class Media(object): return mimetype[0] - def get_class_by_file(Media, _file): + @classmethod + def get_class_by_file(Media, _file, classes): extension = os.path.splitext(_file)[1][1:].lower() - if(extension in Photo.get_valid_extensions()): - return Photo - elif(extension in Video.get_valid_extensions()): - return Video - else: - return None + name = None + if(extension in Media.photo_extensions): + name = 'Photo' + elif(extension in Media.video_extensions): + name = 'Video' + for i in classes: + if(name == i.__name__): + return i(_file) + + return None diff --git a/elodie/media/photo.py b/elodie/media/photo.py index 76f48e2..b640f88 100644 --- a/elodie/media/photo.py +++ b/elodie/media/photo.py @@ -9,17 +9,17 @@ from datetime import datetime import mimetypes import os +import pyexiv2 import re import time -from elodie.media.media import Media +from media import Media +from elodie import geolocation """ -Video class for general photo operations +Photo class for general photo operations """ class Photo(Media): - # class / static variable accessible through get_valid_extensions() - __valid_extensions = ('jpg', 'jpeg', 'nef', 'dng') """ @param, source, string, The fully qualified path to the photo file @@ -48,11 +48,77 @@ class Photo(Media): return re.search('(\d{2}:\d{2}.\d{2})', key).group(1).replace('.', ':') return None + """ + Set album for a photo + + @param, name, string, Name of album + + @returns, boolean + """ + def set_album(self, name): + if(name is None): + return False + + source = self.source + exif_metadata = pyexiv2.ImageMetadata(source) + exif_metadata.read() + + exif_metadata['Xmp.elodie.album'] = name + + exif_metadata.write() + + """ + Set the date/time a photo was taken + + @param, time, datetime, datetime object of when the photo was taken + + @returns, boolean + """ + def set_datetime(self, time): + if(time is None): + return False + + source = self.source + exif_metadata = pyexiv2.ImageMetadata(source) + exif_metadata.read() + + exif_metadata['Exif.Photo.DateTimeOriginal'].value = time + exif_metadata['Exif.Image.DateTime'].value = time + + exif_metadata.write() + return True + + """ + Set lat/lon for a photo + + @param, latitude, float, Latitude of the file + @param, longitude, float, Longitude of the file + + @returns, boolean + """ + def set_location(self, latitude, longitude): + if(latitude is None or longitude is None): + return False + + source = self.source + exif_metadata = pyexiv2.ImageMetadata(source) + exif_metadata.read() + + exif_metadata['Exif.GPSInfo.GPSLatitude'] = geolocation.decimal_to_dms(latitude) + exif_metadata['Exif.GPSInfo.GPSLatitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLatitudeRef', 'N' if latitude >= 0 else 'S') + exif_metadata['Exif.GPSInfo.GPSLongitude'] = geolocation.decimal_to_dms(longitude) + exif_metadata['Exif.GPSInfo.GPSLongitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLongitudeRef', 'E' if longitude >= 0 else 'W') + + exif_metadata.write() + return True + """ Static method to access static __valid_extensions variable. + def set_location(self, latitude, longitude): + return None @returns, tuple """ @classmethod def get_valid_extensions(Photo): - return Photo.__valid_extensions + return Media.photo_extensions diff --git a/elodie/media/video.py b/elodie/media/video.py index 0f9cd9c..bddb5d8 100644 --- a/elodie/media/video.py +++ b/elodie/media/video.py @@ -5,23 +5,25 @@ Video package that handles all video operations # load modules from distutils.spawn import find_executable +import tempfile from sys import argv from datetime import datetime import mimetypes import os import re +import shutil import subprocess import time -from elodie.media.media import Media +from media import Media """ Video class for general video operations """ class Video(Media): # class / static variable accessible through get_valid_extensions() - __valid_extensions = ('avi','m4v','mov','mp4','3gp') + """ @param, source, string, The fully qualified path to the video file @@ -29,8 +31,63 @@ class Video(Media): def __init__(self, source=None): super(Video, self).__init__(source) - # We only want to parse EXIF once so we store it here - self.exif = None + """ + Get latitude or longitude of photo from EXIF + + @returns, time object or None for non-video files or 0 timestamp + """ + def get_coordinate(self, type='latitude'): + exif_data = self.get_exif() + if(exif_data is None): + return None + + coords = re.findall('(GPS %s +: .+)' % type.capitalize(), exif_data) + if(coords is None or len(coords) == 0): + return None + + coord_string = coords[0] + coordinate = re.findall('([0-9.]+)', coord_string) + direction = re.search('[NSEW]$', coord_string) + if(coordinate is None or direction is None): + return None + + direction = direction.group(0) + + decimal_degrees = float(coordinate[0]) + float(coordinate[1])/60 + float(coordinate[2])/3600 + if(direction == 'S' or direction == 'W'): + decimal_degrees = decimal_degrees * -1 + + return decimal_degrees + + """ + Get the date which the video was taken. + The date value returned is defined by the min() of mtime and ctime. + + @returns, time object or None for non-video files or 0 timestamp + """ + def get_date_taken(self): + if(not self.is_valid()): + return None + + source = self.source + seconds_since_epoch = min(os.path.getmtime(source), os.path.getctime(source)) + # We need to parse a string from EXIF into a timestamp. + # we use date.strptime -> .timetuple -> time.mktime to do the conversion in the local timezone + exif_data = self.get_exif() + for key in ['Creation Date', 'Media Create Date']: + date = re.search('%s +: +([0-9: ]+)' % key, exif_data) + if(date is not None): + date_string = date.group(1) + try: + seconds_since_epoch = time.mktime(datetime.strptime(date_string, '%Y:%m:%d %H:%M:%S').timetuple()) + break + except: + pass + + if(seconds_since_epoch == 0): + return None + + return time.gmtime(seconds_since_epoch) """ Get the duration of a video in seconds. @@ -50,6 +107,183 @@ class Video(Media): return re.search('(\d{2}:\d{2}.\d{2})', key).group(1).replace('.', ':') return None + """ + Get exif data from video file. + Not all video files have exif and this currently relies on the CLI exiftool program + + @returns, string or None if exiftool is not found + """ + def get_exif(self): + exiftool = find_executable('exiftool') + if(exiftool is None): + return None + + source = self.source + process_output = subprocess.Popen(['%s "%s"' % (exiftool, source)], stdout=subprocess.PIPE, shell=True) + return process_output.stdout.read() + + """ + Get a dictionary of metadata for a video. + All keys will be present and have a value of None if not obtained. + + @returns, dictionary or None for non-video files + """ + def get_metadata(self): + if(not self.is_valid()): + return None + + source = self.source + metadata = { + "date_taken": self.get_date_taken(), + "latitude": self.get_coordinate('latitude'), + "longitude": self.get_coordinate('longitude'), + "album": None, + #"length": self.get_duration(), + "mime_type": self.get_mimetype(), + "base_name": os.path.splitext(os.path.basename(source))[0], + "extension": self.get_extension() + } + + return metadata + + """ + Set album for a photo + Not yet implemented + + @param, name, string, Name of album + + @returns, boolean + """ + def set_album(self, name): + if(name is None): + return False + + """ + Set the date/time a photo was taken + + @param, time, datetime, datetime object of when the photo was taken + + @returns, boolean + """ + def set_datetime(self, time): + if(time is None): + return False + + source = self.source + exif_metadata = pyexiv2.ImageMetadata(source) + exif_metadata.read() + + exif_metadata['Exif.Photo.DateTimeOriginal'].value = time + exif_metadata['Exif.Image.DateTime'].value = time + + exif_metadata.write() + return True + + """ + Set lat/lon for a video + + @param, latitude, float, Latitude of the file + @param, longitude, float, Longitude of the file + + @returns, boolean + """ + def set_location(self, latitude, longitude): + if(latitude is None or longitude is None): + return False + + print 'SET LOCATION %s %s' % (latitude, longitude) + result = self.__update_using_plist(latitude=latitude, longitude=longitude) + return result + + def __update_using_plist(self, **kwargs): + if('latitude' not in kwargs and 'longitude' not in kwargs): + print 'No lat/lon passed into __create_plist' + return False + + avmetareadwrite = find_executable('avmetareadwrite') + if(avmetareadwrite is None): + print 'Could not find avmetareadwrite' + return False + + source = self.source + + # First we need to write the plist for an existing file to a temporary location + with tempfile.NamedTemporaryFile() as plist_temp: + # We need to write the plist file in a child process but also block for it to be complete. + # http://stackoverflow.com/a/5631819/1318758 + avmetareadwrite_generate_plist_command = '%s -p "%s" "%s"' % (avmetareadwrite, plist_temp.name, source) + write_process = subprocess.Popen([avmetareadwrite_generate_plist_command], stdout=subprocess.PIPE, shell=True) + streamdata = write_process.communicate()[0] + if(write_process.returncode != 0): + print 'Failed to generate plist file' + return False + + with open(plist_temp.name, 'r') as plist_written: + plist_text = plist_written.read() + + # Once the plist file has been written we need to open the file to read and update it. + plist_final = None + with open(plist_temp.name, 'w') as plist_written: + # Depending on the kwargs that were passed in we regex the plist_text before we write it back. + if('latitude' in kwargs and 'longitude' in kwargs): + latitude = kwargs['latitude'] + longitude = kwargs['longitude'] + + # Add a literal '+' to the lat/lon if it is positive. + # Do this first because we convert longitude to a string below. + lat_sign = '+' if latitude > 0 else '' + # We need to zeropad the longitude. + # No clue why - ask Apple. + # We set the sign to + or - and then we take the absolute value and fill it. + lon_sign = '+' if longitude > 0 else '-' + longitude_str = '{:9.5f}'.format(abs(longitude)).replace(' ', '0') + + print longitude_str + print '>%s%s%s%s' % (lat_sign, latitude, lon_sign, longitude_str) + + plist_updated_text = re.sub('\>([+-])([0-9.]+)([+-])([0-9.]+)', '>%s%s%s%s' % (lat_sign, latitude, lon_sign, longitude_str), plist_text); + plist_final = plist_written.name + plist_written.write(plist_updated_text) + + f = open('/Users/jaisenmathai/dev/tools/elodie/script.plist', 'w') + f.write(plist_updated_text) + f.close() + print plist_updated_text + + + # If we've written to the plist file then we proceed + if(plist_final is None): + print 'plist file was not be written to' + return False + + # We create a temporary file to save the modified file to. + # If the modification is successful we will update the existing file. + temp_movie = None + with tempfile.NamedTemporaryFile() as temp_file: + temp_movie = temp_file.name + + # We need to block until the child process completes. + # http://stackoverflow.com/a/5631819/1318758 + avmetareadwrite_command = '%s -w %s "%s" "%s"' % (avmetareadwrite, plist_written.name, source, temp_movie) + update_process = subprocess.Popen([avmetareadwrite_command], stdout=subprocess.PIPE, shell=True) + streamdata = update_process.communicate()[0] + print streamdata + if(update_process.returncode != 0): + print '%s did not complete successfully' % avmetareadwrite_command + return False + + # Before we do anything destructive we confirm that the file is in tact. + metadata = self.get_metadata() + if(metadata['latitude'] is None or metadata['longitude'] is None or metadata['date_taken'] is None): + print 'Something went wrong updating video metadata' + return False + + # Copy file information from original source to temporary file before copying back over + print 'copy from %s to %s' % (temp_movie, source) + shutil.copystat(source, temp_movie) + shutil.move(temp_movie, source) + return True + """ Static method to access static __valid_extensions variable. @@ -57,7 +291,7 @@ class Video(Media): """ @classmethod def get_valid_extensions(Video): - return Video.__valid_extensions + return Media.video_extensions class Transcode(object): # Constructor takes a video object as it's parameter diff --git a/tests/scripts/datetime.py b/tests/scripts/datetime.py index 0989db4..7c882a8 100755 --- a/tests/scripts/datetime.py +++ b/tests/scripts/datetime.py @@ -5,26 +5,24 @@ import shutil import sys from elodie import arguments +from elodie.media.photo import Media from elodie.media.photo import Photo from elodie.media.video import Video def main(argv): - args = arguments.parse(argv, None, ['file=','type='], './import.py --type= --file=') + args = arguments.parse(argv, None, ['file='], './import.py --file=') if('file' not in args): print 'No file specified' sys.exit(1) - if('type' in args and args['type'] == 'video'): - media_type = Video - else: - media_type = Photo + media = Media.get_class_by_file(args['file'], [Photo, Video]) + + if(media is None): + print 'Not a valid file' + sys.exit(1) - media = media_type(args['file']) metadata = media.get_metadata() - - print '%r' % metadata - output = {'date_taken': metadata['date_taken']} print '%r' % output diff --git a/tests/scripts/geolocation.py b/tests/scripts/geolocation.py index 20e3a19..93318bb 100755 --- a/tests/scripts/geolocation.py +++ b/tests/scripts/geolocation.py @@ -6,6 +6,7 @@ import sys from elodie import arguments from elodie import geolocation +from elodie.media.photo import Media from elodie.media.photo import Photo from elodie.media.video import Video @@ -16,12 +17,12 @@ def main(argv): print 'No file specified' sys.exit(1) - if('type' in args and args['type'] == 'photo'): - media_type = Photo - else: - media_type = Video + media = Media.get_class_by_file(args['file'], [Photo, Video]) + + if(media is None): + print 'Not a valid file' + sys.exit(1) - media = media_type(args['file']) metadata = media.get_metadata() place_name = geolocation.place_name(metadata['latitude'], metadata['longitude']) diff --git a/update.py b/update.py index 6b775f1..1360dca 100755 --- a/update.py +++ b/update.py @@ -29,10 +29,6 @@ def parse_arguments(args): return config def main(config, args): - try: - pyexiv2.xmp.register_namespace('https://github.com/jmathai/elodie/', 'elodie') - except KeyError: - pass location_coords = None for arg in args: if(arg[:2] == '--'): @@ -54,19 +50,20 @@ def main(config, args): if(_class is None): continue - write = False - exif_metadata = pyexiv2.ImageMetadata(file_path) - exif_metadata.read() + media = _class(file_path) + + updated = False if(config['location'] is not None): if(location_coords is None): location_coords = geolocation.coordinates_by_name(config['location']) if(location_coords is not None and 'latitude' in location_coords and 'longitude' in location_coords): - exif_metadata['Exif.GPSInfo.GPSLatitude'] = geolocation.decimal_to_dms(location_coords['latitude']) - exif_metadata['Exif.GPSInfo.GPSLatitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLatitudeRef', 'N' if location_coords['latitude'] >= 0 else 'S') - exif_metadata['Exif.GPSInfo.GPSLongitude'] = geolocation.decimal_to_dms(location_coords['longitude']) - exif_metadata['Exif.GPSInfo.GPSLongitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLongitudeRef', 'E' if location_coords['longitude'] >= 0 else 'W') - write = True + location_status = media.set_location(location_coords['latitude'], location_coords['longitude']) + if(location_status != True): + print 'Failed to update location' + sys.exit(1) + updated = True + if(config['time'] is not None): time_string = config['time'] @@ -79,21 +76,15 @@ def main(config, args): sys.exit(1) if(time_format is not None): - exif_metadata['Exif.Photo.DateTimeOriginal'].value = datetime.strptime(time_string, time_format) - exif_metadata['Exif.Image.DateTime'].value = datetime.strptime(time_string, time_format) - write = True + time = datetime.strptime(time_string, time_format) + media.set_datetime(time) + updated = True if(config['album'] is not None): - exif_metadata['Xmp.elodie.album'] = config['album'] - write = True + media.set_album(config['album']) + updated = True - if(write == True): - exif_metadata.write() - - exif_metadata = pyexiv2.ImageMetadata(file_path) - exif_metadata.read() - - media = _class(file_path) + if(updated == True): dest_path = filesystem.process_file(file_path, destination, media, move=True, allowDuplicate=True) print '%s -> %s' % (file_path, dest_path)