Add video geotagging support using avmetareadwrite on OS X

This commit is contained in:
Jaisen Mathai 2015-10-20 01:17:09 -07:00
parent 0f7bfd9f23
commit 8221e3c020
7 changed files with 355 additions and 58 deletions

View File

@ -1,3 +1 @@
from media import Media from media import *
from photo import Photo
from video import Video

View File

@ -19,6 +19,10 @@ import time
Media class for general video operations Media class for general video operations
""" """
class Media(object): 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 @param, source, string, The fully qualified path to the video file
""" """
@ -201,12 +205,17 @@ class Media(object):
return mimetype[0] 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() extension = os.path.splitext(_file)[1][1:].lower()
if(extension in Photo.get_valid_extensions()): name = None
return Photo if(extension in Media.photo_extensions):
elif(extension in Video.get_valid_extensions()): name = 'Photo'
return Video elif(extension in Media.video_extensions):
else: name = 'Video'
return None
for i in classes:
if(name == i.__name__):
return i(_file)
return None

View File

@ -9,17 +9,17 @@ from datetime import datetime
import mimetypes import mimetypes
import os import os
import pyexiv2
import re import re
import time 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 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 @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 re.search('(\d{2}:\d{2}.\d{2})', key).group(1).replace('.', ':')
return None 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. Static method to access static __valid_extensions variable.
def set_location(self, latitude, longitude):
return None
@returns, tuple @returns, tuple
""" """
@classmethod @classmethod
def get_valid_extensions(Photo): def get_valid_extensions(Photo):
return Photo.__valid_extensions return Media.photo_extensions

View File

@ -5,23 +5,25 @@ Video package that handles all video operations
# load modules # load modules
from distutils.spawn import find_executable from distutils.spawn import find_executable
import tempfile
from sys import argv from sys import argv
from datetime import datetime from datetime import datetime
import mimetypes import mimetypes
import os import os
import re import re
import shutil
import subprocess import subprocess
import time import time
from elodie.media.media import Media from media import Media
""" """
Video class for general video operations Video class for general video operations
""" """
class Video(Media): class Video(Media):
# class / static variable accessible through get_valid_extensions() # 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 @param, source, string, The fully qualified path to the video file
@ -29,8 +31,63 @@ class Video(Media):
def __init__(self, source=None): def __init__(self, source=None):
super(Video, self).__init__(source) 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. 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 re.search('(\d{2}:\d{2}.\d{2})', key).group(1).replace('.', ':')
return None 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. Static method to access static __valid_extensions variable.
@ -57,7 +291,7 @@ class Video(Media):
""" """
@classmethod @classmethod
def get_valid_extensions(Video): def get_valid_extensions(Video):
return Video.__valid_extensions return Media.video_extensions
class Transcode(object): class Transcode(object):
# Constructor takes a video object as it's parameter # Constructor takes a video object as it's parameter

View File

@ -5,26 +5,24 @@ import shutil
import sys import sys
from elodie import arguments from elodie import arguments
from elodie.media.photo import Media
from elodie.media.photo import Photo from elodie.media.photo import Photo
from elodie.media.video import Video from elodie.media.video import Video
def main(argv): def main(argv):
args = arguments.parse(argv, None, ['file=','type='], './import.py --type=<photo or video> --file=<path to file>') args = arguments.parse(argv, None, ['file='], './import.py --file=<path to file>')
if('file' not in args): if('file' not in args):
print 'No file specified' print 'No file specified'
sys.exit(1) sys.exit(1)
if('type' in args and args['type'] == 'video'): media = Media.get_class_by_file(args['file'], [Photo, Video])
media_type = Video
else: if(media is None):
media_type = Photo print 'Not a valid file'
sys.exit(1)
media = media_type(args['file'])
metadata = media.get_metadata() metadata = media.get_metadata()
print '%r' % metadata
output = {'date_taken': metadata['date_taken']} output = {'date_taken': metadata['date_taken']}
print '%r' % output print '%r' % output

View File

@ -6,6 +6,7 @@ import sys
from elodie import arguments from elodie import arguments
from elodie import geolocation from elodie import geolocation
from elodie.media.photo import Media
from elodie.media.photo import Photo from elodie.media.photo import Photo
from elodie.media.video import Video from elodie.media.video import Video
@ -16,12 +17,12 @@ def main(argv):
print 'No file specified' print 'No file specified'
sys.exit(1) sys.exit(1)
if('type' in args and args['type'] == 'photo'): media = Media.get_class_by_file(args['file'], [Photo, Video])
media_type = Photo
else: if(media is None):
media_type = Video print 'Not a valid file'
sys.exit(1)
media = media_type(args['file'])
metadata = media.get_metadata() metadata = media.get_metadata()
place_name = geolocation.place_name(metadata['latitude'], metadata['longitude']) place_name = geolocation.place_name(metadata['latitude'], metadata['longitude'])

View File

@ -29,10 +29,6 @@ def parse_arguments(args):
return config return config
def main(config, args): def main(config, args):
try:
pyexiv2.xmp.register_namespace('https://github.com/jmathai/elodie/', 'elodie')
except KeyError:
pass
location_coords = None location_coords = None
for arg in args: for arg in args:
if(arg[:2] == '--'): if(arg[:2] == '--'):
@ -54,19 +50,20 @@ def main(config, args):
if(_class is None): if(_class is None):
continue continue
write = False media = _class(file_path)
exif_metadata = pyexiv2.ImageMetadata(file_path)
exif_metadata.read() updated = False
if(config['location'] is not None): if(config['location'] is not None):
if(location_coords is None): if(location_coords is None):
location_coords = geolocation.coordinates_by_name(config['location']) location_coords = geolocation.coordinates_by_name(config['location'])
if(location_coords is not None and 'latitude' in location_coords and 'longitude' in location_coords): 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']) location_status = media.set_location(location_coords['latitude'], location_coords['longitude'])
exif_metadata['Exif.GPSInfo.GPSLatitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLatitudeRef', 'N' if location_coords['latitude'] >= 0 else 'S') if(location_status != True):
exif_metadata['Exif.GPSInfo.GPSLongitude'] = geolocation.decimal_to_dms(location_coords['longitude']) print 'Failed to update location'
exif_metadata['Exif.GPSInfo.GPSLongitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLongitudeRef', 'E' if location_coords['longitude'] >= 0 else 'W') sys.exit(1)
write = True updated = True
if(config['time'] is not None): if(config['time'] is not None):
time_string = config['time'] time_string = config['time']
@ -79,21 +76,15 @@ def main(config, args):
sys.exit(1) sys.exit(1)
if(time_format is not None): if(time_format is not None):
exif_metadata['Exif.Photo.DateTimeOriginal'].value = datetime.strptime(time_string, time_format) time = datetime.strptime(time_string, time_format)
exif_metadata['Exif.Image.DateTime'].value = datetime.strptime(time_string, time_format) media.set_datetime(time)
write = True updated = True
if(config['album'] is not None): if(config['album'] is not None):
exif_metadata['Xmp.elodie.album'] = config['album'] media.set_album(config['album'])
write = True updated = True
if(write == True): if(updated == True):
exif_metadata.write()
exif_metadata = pyexiv2.ImageMetadata(file_path)
exif_metadata.read()
media = _class(file_path)
dest_path = filesystem.process_file(file_path, destination, media, move=True, allowDuplicate=True) dest_path = filesystem.process_file(file_path, destination, media, move=True, allowDuplicate=True)
print '%s -> %s' % (file_path, dest_path) print '%s -> %s' % (file_path, dest_path)