diff options
Diffstat (limited to 'src/VideoMetadata.vala')
-rw-r--r-- | src/VideoMetadata.vala | 655 |
1 files changed, 655 insertions, 0 deletions
diff --git a/src/VideoMetadata.vala b/src/VideoMetadata.vala new file mode 100644 index 0000000..100a040 --- /dev/null +++ b/src/VideoMetadata.vala @@ -0,0 +1,655 @@ +/* Copyright 2010-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class VideoMetadata : MediaMetadata { + + private MetadataDateTime timestamp = null; + private string title = null; + private string comment = null; + + public VideoMetadata() { + } + + ~VideoMetadata() { + } + + public override void read_from_file(File file) throws Error { + QuickTimeMetadataLoader quicktime = new QuickTimeMetadataLoader(file); + if (quicktime.is_supported()) { + timestamp = quicktime.get_creation_date_time(); + title = quicktime.get_title(); + // TODO: is there an quicktime.get_comment ?? + comment = null; + return; + } + AVIMetadataLoader avi = new AVIMetadataLoader(file); + if (avi.is_supported()) { + timestamp = avi.get_creation_date_time(); + title = avi.get_title(); + comment = null; + return; + } + + throw new IOError.NOT_SUPPORTED("File %s is not a supported video format", file.get_path()); + } + + public override MetadataDateTime? get_creation_date_time() { + return timestamp; + } + + public override string? get_title() { + return title; + } + + public override string? get_comment() { + return comment; + } + +} + +private class QuickTimeMetadataLoader { + + // Quicktime calendar date/time format is number of seconds since January 1, 1904. + // This converts to UNIX time (66 years + 17 leap days). + public const time_t QUICKTIME_EPOCH_ADJUSTMENT = 2082844800; + + private File file = null; + + public QuickTimeMetadataLoader(File file) { + this.file = file; + } + + public MetadataDateTime? get_creation_date_time() { + return new MetadataDateTime((time_t) get_creation_date_time_for_quicktime()); + } + + public string? get_title() { + // Not supported. + return null; + } + + // Checks if the given file is a QuickTime file. + public bool is_supported() { + QuickTimeAtom test = new QuickTimeAtom(file); + + bool ret = false; + try { + test.open_file(); + test.read_atom(); + + // Look for the header. + if ("ftyp" == test.get_current_atom_name()) { + ret = true; + } else { + // Some versions of QuickTime don't have + // an ftyp section, so we'll just look + // for the mandatory moov section. + while(true) { + if ("moov" == test.get_current_atom_name()) { + ret = true; + break; + } + test.next_atom(); + test.read_atom(); + if (test.is_last_atom()) { + break; + } + } + } + } catch (GLib.Error e) { + debug("Error while testing for QuickTime file for %s: %s", file.get_path(), e.message); + } + + try { + test.close_file(); + } catch (GLib.Error e) { + debug("Error while closing Quicktime file: %s", e.message); + } + return ret; + } + + private ulong get_creation_date_time_for_quicktime() { + QuickTimeAtom test = new QuickTimeAtom(file); + time_t timestamp = 0; + + try { + test.open_file(); + bool done = false; + while(!done) { + // Look for "moov" section. + test.read_atom(); + if (test.is_last_atom()) break; + if ("moov" == test.get_current_atom_name()) { + QuickTimeAtom child = test.get_first_child_atom(); + while (!done) { + // Look for "mvhd" section, or break if none is found. + child.read_atom(); + if (child.is_last_atom() || 0 == child.section_size_remaining()) { + done = true; + break; + } + + if ("mvhd" == child.get_current_atom_name()) { + // Skip 4 bytes (version + flags) + child.read_uint32(); + // Grab the timestamp. + timestamp = child.read_uint32() - QUICKTIME_EPOCH_ADJUSTMENT; + done = true; + break; + } + child.next_atom(); + } + } + test.next_atom(); + } + } catch (GLib.Error e) { + debug("Error while testing for QuickTime file: %s", e.message); + } + + try { + test.close_file(); + } catch (GLib.Error e) { + debug("Error while closing Quicktime file: %s", e.message); + } + + // Some Android phones package videos recorded with their internal cameras in a 3GP + // container that looks suspiciously like a QuickTime container but really isn't -- for + // the timestamps of these Android 3GP videos are relative to the UNIX epoch + // (January 1, 1970) instead of the QuickTime epoch (January 1, 1904). So, if we detect a + // QuickTime movie with a negative timestamp, we can be pretty sure it isn't a valid + // QuickTime movie that was shot before 1904 but is instead a non-compliant 3GP video + // file. If we detect such a video, we correct its time. See this Redmine ticket + // (http://redmine.yorba.org/issues/3314) for more information. + if (timestamp < 0) + timestamp += QUICKTIME_EPOCH_ADJUSTMENT; + + return (ulong) timestamp; + } +} + +private class QuickTimeAtom { + private GLib.File file = null; + private string section_name = ""; + private uint64 section_size = 0; + private uint64 section_offset = 0; + private GLib.DataInputStream input = null; + private QuickTimeAtom? parent = null; + + public QuickTimeAtom(GLib.File file) { + this.file = file; + } + + private QuickTimeAtom.with_input_stream(GLib.DataInputStream input, QuickTimeAtom parent) { + this.input = input; + this.parent = parent; + } + + public void open_file() throws GLib.Error { + close_file(); + input = new GLib.DataInputStream(file.read()); + input.set_byte_order(DataStreamByteOrder.BIG_ENDIAN); + section_size = 0; + section_offset = 0; + section_name = ""; + } + + public void close_file() throws GLib.Error { + if (null != input) { + input.close(); + input = null; + } + } + + private void advance_section_offset(uint64 amount) { + section_offset += amount; + if (null != parent) { + parent.advance_section_offset(amount); + } + } + + public QuickTimeAtom get_first_child_atom() { + // Child will simply have the input stream + // but not the size/offset. This works because + // child atoms follow immediately after a header, + // so no skipping is required to access the child + // from the current position. + return new QuickTimeAtom.with_input_stream(input, this); + } + + public uchar read_byte() throws GLib.Error { + advance_section_offset(1); + return input.read_byte(); + } + + public uint32 read_uint32() throws GLib.Error { + advance_section_offset(4); + return input.read_uint32(); + } + + public uint64 read_uint64() throws GLib.Error { + advance_section_offset(8); + return input.read_uint64(); + } + + public void read_atom() throws GLib.Error { + // Read atom size. + section_size = read_uint32(); + + // Read atom name. + GLib.StringBuilder sb = new GLib.StringBuilder(); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + section_name = sb.str; + + // Check string. + if (section_name.length != 4) { + throw new IOError.NOT_SUPPORTED("QuickTime atom name length is invalid for %s", + file.get_path()); + } + for (int i = 0; i < section_name.length; i++) { + if (!section_name[i].isprint()) { + throw new IOError.NOT_SUPPORTED("Bad QuickTime atom in file %s", file.get_path()); + } + } + + if (1 == section_size) { + // This indicates the section size is a 64-bit + // value, specified below the atom name. + section_size = read_uint64(); + } + } + + private void skip(uint64 skip_amount) throws GLib.Error { + skip_uint64(input, skip_amount); + } + + public uint64 section_size_remaining() { + assert(section_size >= section_offset); + return section_size - section_offset; + } + + public void next_atom() throws GLib.Error { + skip(section_size_remaining()); + section_size = 0; + section_offset = 0; + } + + public string get_current_atom_name() { + return section_name; + } + + public bool is_last_atom() { + return 0 == section_size; + } + +} + +private class AVIMetadataLoader { + + private File file = null; + + // A numerical date string, i.e 2010:01:28 14:54:25 + private const int NUMERICAL_DATE_LENGTH = 19; + + // Marker for timestamp section in a Nikon nctg blob. + private const uint16 NIKON_NCTG_TIMESTAMP_MARKER = 0x13; + + // Size limit to ensure we don't parse forever on a bad file. + private const int MAX_STRD_LENGTH = 100; + + public AVIMetadataLoader(File file) { + this.file = file; + } + + public MetadataDateTime? get_creation_date_time() { + return new MetadataDateTime((time_t) get_creation_date_time_for_avi()); + } + + public string? get_title() { + // Not supported. + return null; + } + + // Checks if the given file is an AVI file. + public bool is_supported() { + AVIChunk chunk = new AVIChunk(file); + bool ret = false; + try { + chunk.open_file(); + chunk.read_chunk(); + // Look for the header and identifier. + if ("RIFF" == chunk.get_current_chunk_name() && + "AVI " == chunk.read_name()) { + ret = true; + } + } catch (GLib.Error e) { + debug("Error while testing for AVI file: %s", e.message); + } + + try { + chunk.close_file(); + } catch (GLib.Error e) { + debug("Error while closing AVI file: %s", e.message); + } + return ret; + } + + // Parses a Nikon nctg tag. Based losely on avi_read_nikon() in FFmpeg. + private string read_nikon_nctg_tag(AVIChunk chunk) throws GLib.Error { + bool found_date = false; + while (chunk.section_size_remaining() > sizeof(uint16)*2) { + uint16 tag = chunk.read_uint16(); + uint16 size = chunk.read_uint16(); + if (NIKON_NCTG_TIMESTAMP_MARKER == tag) { + found_date = true; + break; + } + chunk.skip(size); + } + + if (found_date) { + // Read numerical date string, example: 2010:01:28 14:54:25 + GLib.StringBuilder sb = new GLib.StringBuilder(); + for (int i = 0; i < NUMERICAL_DATE_LENGTH; i++) { + sb.append_c((char) chunk.read_byte()); + } + return sb.str; + } + return ""; + } + + // Parses a Fujifilm strd tag. Based on information from: + // http://www.eden-foundation.org/products/code/film_date_stamp/index.html + private string read_fuji_strd_tag(AVIChunk chunk) throws GLib.Error { + chunk.skip(98); // Ignore 98-byte binary blob. + chunk.skip(8); // Ignore the string "FUJIFILM" + // Read until we find four colons, then two more chars. + int colons = 0; + int post_colons = 0; + GLib.StringBuilder sb = new GLib.StringBuilder(); + // End of date is two chars past the fourth colon. + while (colons <= 4 && post_colons < 2) { + char c = (char) chunk.read_byte(); + if (4 == colons) { + post_colons++; + } + if (':' == c) { + colons++; + } + if (c.isprint()) { + sb.append_c(c); + } + if (sb.len > MAX_STRD_LENGTH) { + return ""; // Give up searching. + } + } + + if (sb.str.length < NUMERICAL_DATE_LENGTH) { + return ""; + } + // Date is now at the end of the string. + return sb.str.substring(sb.str.length - NUMERICAL_DATE_LENGTH); + } + + // Recursively read file until the section is found. + private string? read_section(AVIChunk chunk) throws GLib.Error { + while (true) { + chunk.read_chunk(); + string name = chunk.get_current_chunk_name(); + if ("IDIT" == name) { + return chunk.section_to_string(); + } else if ("nctg" == name) { + return read_nikon_nctg_tag(chunk); + } else if ("strd" == name) { + return read_fuji_strd_tag(chunk); + } + + if ("LIST" == name) { + chunk.read_name(); // Read past list name. + string result = read_section(chunk.get_first_child_chunk()); + if (null != result) { + return result; + } + } + + if (chunk.is_last_chunk()) { + break; + } + chunk.next_chunk(); + } + return null; + } + + // Parses a date from a string. + // Largely based on GStreamer's avi/gstavidemux.c + // and the information here: + // http://www.eden-foundation.org/products/code/film_date_stamp/index.html + private ulong parse_date(string sdate) { + if (sdate.length == 0) { + return 0; + } + + Date date = Date(); + uint seconds = 0; + int year, month, day, hour, min, sec; + char weekday[4]; + char monthstr[4]; + + if (sdate[0].isdigit()) { + // Format is: 2005:08:17 11:42:43 + // Format is: 2010/11/30/ 19:42 + // Format is: 2010/11/30 19:42 + string tmp = sdate.dup(); + tmp.canon("0123456789 ", ' '); // strip everything but numbers and spaces + sec = 0; + int result = tmp.scanf("%d %d %d %d %d %d", out year, out month, out day, out hour, out min, out sec); + if(result < 5) { + return 0; + } + date.set_dmy((DateDay) day, (DateMonth) month, (DateYear) year); + seconds = sec + min * 60 + hour * 3600; + } else { + // Format is: Mon Mar 3 09:44:56 2008 + if(7 != sdate.scanf("%3s %3s %d %d:%d:%d %d", weekday, monthstr, out day, out hour, + out min, out sec, out year)) { + return 0; // Error + } + date.set_dmy((DateDay) day, month_from_string((string) monthstr), (DateYear) year); + seconds = sec + min * 60 + hour * 3600; + } + + Time time = Time(); + date.to_time(out time); + + // watch for overflow (happens on quasi-bogus dates, like Year 200) + time_t tm = time.mktime(); + ulong result = tm + seconds; + if (result < tm) { + debug("Overflow for timestamp in video file %s", file.get_path()); + + return 0; + } + + return result; + } + + private DateMonth month_from_string(string s) { + switch (s.down()) { + case "jan": + return DateMonth.JANUARY; + case "feb": + return DateMonth.FEBRUARY; + case "mar": + return DateMonth.MARCH; + case "apr": + return DateMonth.APRIL; + case "may": + return DateMonth.MAY; + case "jun": + return DateMonth.JUNE; + case "jul": + return DateMonth.JULY; + case "aug": + return DateMonth.AUGUST; + case "sep": + return DateMonth.SEPTEMBER; + case "oct": + return DateMonth.OCTOBER; + case "nov": + return DateMonth.NOVEMBER; + case "dec": + return DateMonth.DECEMBER; + } + return DateMonth.BAD_MONTH; + } + + private ulong get_creation_date_time_for_avi() { + AVIChunk chunk = new AVIChunk(file); + ulong timestamp = 0; + try { + chunk.open_file(); + chunk.nonsection_skip(12); // Advance past 12 byte header. + string sdate = read_section(chunk); + if (null != sdate) { + timestamp = parse_date(sdate.strip()); + } + } catch (GLib.Error e) { + debug("Error while reading AVI file: %s", e.message); + } + + try { + chunk.close_file(); + } catch (GLib.Error e) { + debug("Error while closing AVI file: %s", e.message); + } + return timestamp; + } +} + +private class AVIChunk { + private GLib.File file = null; + private string section_name = ""; + private uint64 section_size = 0; + private uint64 section_offset = 0; + private GLib.DataInputStream input = null; + private AVIChunk? parent = null; + private const int MAX_STRING_TO_SECTION_LENGTH = 1024; + + public AVIChunk(GLib.File file) { + this.file = file; + } + + private AVIChunk.with_input_stream(GLib.DataInputStream input, AVIChunk parent) { + this.input = input; + this.parent = parent; + } + + public void open_file() throws GLib.Error { + close_file(); + input = new GLib.DataInputStream(file.read()); + input.set_byte_order(DataStreamByteOrder.LITTLE_ENDIAN); + section_size = 0; + section_offset = 0; + section_name = ""; + } + + public void close_file() throws GLib.Error { + if (null != input) { + input.close(); + input = null; + } + } + + public void nonsection_skip(uint64 skip_amount) throws GLib.Error { + skip_uint64(input, skip_amount); + } + + public void skip(uint64 skip_amount) throws GLib.Error { + advance_section_offset(skip_amount); + skip_uint64(input, skip_amount); + } + + public AVIChunk get_first_child_chunk() { + return new AVIChunk.with_input_stream(input, this); + } + + private void advance_section_offset(uint64 amount) { + if ((section_offset + amount) > section_size) + amount = section_size - section_offset; + + section_offset += amount; + if (null != parent) { + parent.advance_section_offset(amount); + } + } + + public uchar read_byte() throws GLib.Error { + advance_section_offset(1); + return input.read_byte(); + } + + public uint16 read_uint16() throws GLib.Error { + advance_section_offset(2); + return input.read_uint16(); + } + + public void read_chunk() throws GLib.Error { + // don't use checked reads here because they advance the section offset, which we're trying + // to determine here + GLib.StringBuilder sb = new GLib.StringBuilder(); + sb.append_c((char) input.read_byte()); + sb.append_c((char) input.read_byte()); + sb.append_c((char) input.read_byte()); + sb.append_c((char) input.read_byte()); + section_name = sb.str; + section_size = input.read_uint32(); + section_offset = 0; + } + + public string read_name() throws GLib.Error { + GLib.StringBuilder sb = new GLib.StringBuilder(); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + return sb.str; + } + + public void next_chunk() throws GLib.Error { + skip(section_size_remaining()); + section_size = 0; + section_offset = 0; + } + + public string get_current_chunk_name() { + return section_name; + } + + public bool is_last_chunk() { + return section_size == 0; + } + + public uint64 section_size_remaining() { + assert(section_size >= section_offset); + return section_size - section_offset; + } + + // Reads section contents into a string. + public string section_to_string() throws GLib.Error { + GLib.StringBuilder sb = new GLib.StringBuilder(); + while (section_offset < section_size) { + sb.append_c((char) read_byte()); + if (sb.len > MAX_STRING_TO_SECTION_LENGTH) { + return sb.str; + } + } + return sb.str; + } + +} + |