diff options
Diffstat (limited to 'src/video-support')
-rw-r--r-- | src/video-support/AVIChunk.vala | 121 | ||||
-rw-r--r-- | src/video-support/AVIMetadataLoader.vala | 227 | ||||
-rw-r--r-- | src/video-support/QuickTimeAtom.vala | 118 | ||||
-rw-r--r-- | src/video-support/QuicktimeMetdataLoader.vala | 127 | ||||
-rw-r--r-- | src/video-support/Video.vala | 703 | ||||
-rw-r--r-- | src/video-support/VideoImportParams.vala | 28 | ||||
-rw-r--r-- | src/video-support/VideoMetadata.vala | 51 | ||||
-rw-r--r-- | src/video-support/VideoMetadataReaderProcess.vala | 66 | ||||
-rw-r--r-- | src/video-support/VideoReader.vala | 317 | ||||
-rw-r--r-- | src/video-support/VideoSourceCollection.vala | 175 | ||||
-rw-r--r-- | src/video-support/meson.build | 36 | ||||
-rw-r--r-- | src/video-support/util.vala | 13 |
12 files changed, 1982 insertions, 0 deletions
diff --git a/src/video-support/AVIChunk.vala b/src/video-support/AVIChunk.vala new file mode 100644 index 0000000..970f443 --- /dev/null +++ b/src/video-support/AVIChunk.vala @@ -0,0 +1,121 @@ +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; + } + +} diff --git a/src/video-support/AVIMetadataLoader.vala b/src/video-support/AVIMetadataLoader.vala new file mode 100644 index 0000000..2b507e2 --- /dev/null +++ b/src/video-support/AVIMetadataLoader.vala @@ -0,0 +1,227 @@ +public 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(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 DateTime? parse_date(string sdate) { + if (sdate.length == 0) { + return null; + } + + int year, month, day, hour, min, sec; + char weekday[4]; + char monthstr[4]; + DateTime parsed_date; + + 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 null; + } + + parsed_date = new DateTime.utc(year, month, day, hour, min, sec); + } 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 null; // Error + } + parsed_date = new DateTime.local(year, month_from_string((string)monthstr), day, hour, min, sec); + } + + return parsed_date; + } + + 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 DateTime? get_creation_date_time_for_avi() { + AVIChunk chunk = new AVIChunk(file); + DateTime? timestamp = null; + 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; + } +} diff --git a/src/video-support/QuickTimeAtom.vala b/src/video-support/QuickTimeAtom.vala new file mode 100644 index 0000000..996046a --- /dev/null +++ b/src/video-support/QuickTimeAtom.vala @@ -0,0 +1,118 @@ +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; + } + +} diff --git a/src/video-support/QuicktimeMetdataLoader.vala b/src/video-support/QuicktimeMetdataLoader.vala new file mode 100644 index 0000000..0a831d2 --- /dev/null +++ b/src/video-support/QuicktimeMetdataLoader.vala @@ -0,0 +1,127 @@ +public 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 int64 QUICKTIME_EPOCH_ADJUSTMENT = 2082844800; + + private File file = null; + + public QuickTimeMetadataLoader(File file) { + this.file = file; + } + + public MetadataDateTime? get_creation_date_time() { + var dt = get_creation_date_time_for_quicktime(); + if (dt == null) { + return null; + } else { + return new MetadataDateTime(dt); + } + } + + 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 DateTime? get_creation_date_time_for_quicktime() { + QuickTimeAtom test = new QuickTimeAtom(file); + DateTime? timestamp = null; + + 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. + + // 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 + // (https://bugzilla.gnome.org/show_bug.cgi?id=717384) for more information. + + if ((child.read_uint32() - QUICKTIME_EPOCH_ADJUSTMENT) < 0) { + timestamp = new DateTime.from_unix_utc(child.read_uint32()); + } else { + timestamp = new DateTime.from_unix_utc(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); + } + + return timestamp; + } +} diff --git a/src/video-support/Video.vala b/src/video-support/Video.vala new file mode 100644 index 0000000..0238d7f --- /dev/null +++ b/src/video-support/Video.vala @@ -0,0 +1,703 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public class Video : VideoSource, Flaggable, Monitorable, Dateable { + public const string TYPENAME = "video"; + + public const uint64 FLAG_TRASH = 0x0000000000000001; + public const uint64 FLAG_OFFLINE = 0x0000000000000002; + public const uint64 FLAG_FLAGGED = 0x0000000000000004; + + public class InterpretableResults { + internal Video video; + internal bool update_interpretable = false; + internal bool is_interpretable = false; + internal Gdk.Pixbuf? new_thumbnail = null; + + public InterpretableResults(Video video) { + this.video = video; + } + + public void foreground_finish() { + if (update_interpretable) + video.set_is_interpretable(is_interpretable); + + if (new_thumbnail != null) { + try { + ThumbnailCache.replace(video, ThumbnailCache.Size.BIG, new_thumbnail); + ThumbnailCache.replace(video, ThumbnailCache.Size.MEDIUM, new_thumbnail); + + video.notify_thumbnail_altered(); + } catch (Error err) { + message("Unable to update video thumbnails for %s: %s", video.to_string(), + err.message); + } + } + } + } + + private static bool normal_regen_complete; + private static bool offline_regen_complete; + public static VideoSourceCollection global; + + private VideoRow backing_row; + + public Video(VideoRow row) { + this.backing_row = row; + + // normalize user text + this.backing_row.title = prep_title(this.backing_row.title); + + if (((row.flags & FLAG_TRASH) != 0) || ((row.flags & FLAG_OFFLINE) != 0)) + rehydrate_backlinks(global, row.backlinks); + } + + public static void init(ProgressMonitor? monitor = null) { + // Must initialize static variables here. + // TODO: set values at declaration time once the following Vala bug is fixed: + // https://bugzilla.gnome.org/show_bug.cgi?id=655594 + normal_regen_complete = false; + offline_regen_complete = false; + + // initialize GStreamer, but don't pass it our actual command line arguments -- we don't + // want our end users to be able to parameterize the GStreamer configuration + unowned string[] args = null; + Gst.init(ref args); + + var registry = Gst.Registry.@get (); + + /* Update our local registr to not include vaapi stuff. This is basically to + * work-around concurrent access to VAAPI/X11 which it doesn't like, cf + * https://bugzilla.gnome.org/show_bug.cgi?id=762416 + */ + + var features = registry.feature_filter ((f) => { + return f.get_name ().has_prefix ("vaapi"); + }, false); + + foreach (var feature in features) { + debug ("Removing registry feature %s", feature.get_name ()); + registry.remove_feature (feature); + } + + global = new VideoSourceCollection(); + + Gee.ArrayList<VideoRow?> all = VideoTable.get_instance().get_all(); + Gee.ArrayList<Video> all_videos = new Gee.ArrayList<Video>(); + Gee.ArrayList<Video> trashed_videos = new Gee.ArrayList<Video>(); + Gee.ArrayList<Video> offline_videos = new Gee.ArrayList<Video>(); + int count = all.size; + for (int ctr = 0; ctr < count; ctr++) { + Video video = new Video(all.get(ctr)); + + if (video.is_trashed()) + trashed_videos.add(video); + else if (video.is_offline()) + offline_videos.add(video); + else + all_videos.add(video); + + if (monitor != null) + monitor(ctr, count); + } + + global.add_many_to_trash(trashed_videos); + global.add_many_to_offline(offline_videos); + global.add_many(all_videos); + } + + public static void notify_normal_thumbs_regenerated() { + if (normal_regen_complete) + return; + + message("normal video thumbnail regeneration completed"); + + normal_regen_complete = true; + } + + public static void notify_offline_thumbs_regenerated() { + if (offline_regen_complete) + return; + + message("offline video thumbnail regeneration completed"); + + offline_regen_complete = true; + } + + public static void terminate() { + } + + public static ExporterUI? export_many(Gee.Collection<Video> videos, Exporter.CompletionCallback done, + bool export_in_place = false) { + if (videos.size == 0) + return null; + + // in place export is relatively easy -- provide a fast, separate code path for it + if (export_in_place) { + ExporterUI temp_exporter = new ExporterUI(new Exporter.for_temp_file(videos, + Scaling.for_original(), ExportFormatParameters.unmodified())); + temp_exporter.export(done); + return temp_exporter; + } + + // one video + if (videos.size == 1) { + Video video = null; + foreach (Video v in videos) { + video = v; + break; + } + + File save_as = ExportUI.choose_file(video.get_basename()); + if (save_as == null) + return null; + + try { + AppWindow.get_instance().set_busy_cursor(); + video.export(save_as); + AppWindow.get_instance().set_normal_cursor(); + } catch (Error err) { + AppWindow.get_instance().set_normal_cursor(); + export_error_dialog(save_as, false); + } + + return null; + } + + // multiple videos + File export_dir = ExportUI.choose_dir(_("Export Videos")); + if (export_dir == null) + return null; + + ExporterUI exporter = new ExporterUI(new Exporter(videos, export_dir, + Scaling.for_original(), ExportFormatParameters.unmodified())); + exporter.export(done); + + return exporter; + } + + protected override void commit_backlinks(SourceCollection? sources, string? backlinks) { + try { + VideoTable.get_instance().update_backlinks(get_video_id(), backlinks); + lock (backing_row) { + backing_row.backlinks = backlinks; + } + } catch (DatabaseError err) { + warning("Unable to update link state for %s: %s", to_string(), err.message); + } + } + + protected override bool set_event_id(EventID event_id) { + lock (backing_row) { + bool committed = VideoTable.get_instance().set_event(backing_row.video_id, event_id); + + if (committed) + backing_row.event_id = event_id; + + return committed; + } + } + + public static bool is_duplicate(File? file, string? full_md5) { + assert(file != null || full_md5 != null); +#if !NO_DUPE_DETECTION + return VideoTable.get_instance().has_duplicate(file, full_md5); +#else + return false; +#endif + } + + public static ImportResult import_create(VideoImportParams params, out Video video) { + video = null; + + // add to the database + try { + if (VideoTable.get_instance().add(params.row).is_invalid()) + return ImportResult.DATABASE_ERROR; + } catch (DatabaseError err) { + return ImportResult.DATABASE_ERROR; + } + + // create local object but don't add to global until thumbnails generated + video = new Video(params.row); + + return ImportResult.SUCCESS; + } + + public static void import_failed(Video video) { + try { + VideoTable.get_instance().remove(video.get_video_id()); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + } + + public override BackingFileState[] get_backing_files_state() { + BackingFileState[] backing = new BackingFileState[1]; + lock (backing_row) { + backing[0] = new BackingFileState(backing_row.filepath, backing_row.filesize, + backing_row.timestamp, backing_row.md5); + } + + return backing; + } + + public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error { + return ThumbnailCache.fetch(this, scale); + } + + public override string get_master_md5() { + lock (backing_row) { + return backing_row.md5; + } + } + + public override Gdk.Pixbuf get_preview_pixbuf(Scaling scaling) throws Error { + Gdk.Pixbuf pixbuf = get_thumbnail(ThumbnailCache.Size.BIG); + + return scaling.perform_on_pixbuf(pixbuf, Gdk.InterpType.NEAREST, true); + } + + public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error { + VideoReader reader = new VideoReader(get_file()); + Gdk.Pixbuf? frame = reader.read_preview_frame(); + + return (frame != null) ? frame : Resources.get_noninterpretable_badge_pixbuf().copy(); + } + + public override string get_typename() { + return TYPENAME; + } + + public override int64 get_instance_id() { + return get_video_id().id; + } + + public override ImportID get_import_id() { + lock (backing_row) { + return backing_row.import_id; + } + } + + public override PhotoFileFormat get_preferred_thumbnail_format() { + return PhotoFileFormat.get_system_default_format(); + } + + public override string? get_title() { + lock (backing_row) { + return backing_row.title; + } + } + + public override void set_title(string? title) { + string? new_title = prep_title(title); + + lock (backing_row) { + if (backing_row.title == new_title) + return; + + try { + VideoTable.get_instance().set_title(backing_row.video_id, new_title); + } catch (DatabaseError e) { + AppWindow.database_error(e); + return; + } + // if we didn't short-circuit return in the catch clause above, then the change was + // successfully committed to the database, so update it in the in-memory row cache + backing_row.title = new_title; + } + + notify_altered(new Alteration("metadata", "name")); + } + + public override string? get_comment() { + lock (backing_row) { + return backing_row.comment; + } + } + + public override bool set_comment(string? comment) { + string? new_comment = prep_title(comment); + + lock (backing_row) { + if (backing_row.comment == new_comment) + return true; + + try { + VideoTable.get_instance().set_comment(backing_row.video_id, new_comment); + } catch (DatabaseError e) { + AppWindow.database_error(e); + return false; + } + // if we didn't short-circuit return in the catch clause above, then the change was + // successfully committed to the database, so update it in the in-memory row cache + backing_row.comment = new_comment; + } + + notify_altered(new Alteration("metadata", "comment")); + + return true; + } + + + public override Rating get_rating() { + lock (backing_row) { + return backing_row.rating; + } + } + + public override void set_rating(Rating rating) { + lock (backing_row) { + if ((!rating.is_valid()) || (rating == backing_row.rating)) + return; + + try { + VideoTable.get_instance().set_rating(get_video_id(), rating); + } catch (DatabaseError e) { + AppWindow.database_error(e); + return; + } + // if we didn't short-circuit return in the catch clause above, then the change was + // successfully committed to the database, so update it in the in-memory row cache + backing_row.rating = rating; + } + + notify_altered(new Alteration("metadata", "rating")); + } + + public override void increase_rating() { + lock (backing_row) { + set_rating(backing_row.rating.increase()); + } + } + + public override void decrease_rating() { + lock (backing_row) { + set_rating(backing_row.rating.decrease()); + } + } + + public override bool is_trashed() { + return is_flag_set(FLAG_TRASH); + } + + public override bool is_offline() { + return is_flag_set(FLAG_OFFLINE); + } + + public override void mark_offline() { + add_flags(FLAG_OFFLINE); + } + + public override void mark_online() { + remove_flags(FLAG_OFFLINE); + + if ((!get_is_interpretable())) + check_is_interpretable().foreground_finish(); + } + + public override void trash() { + add_flags(FLAG_TRASH); + } + + public override void untrash() { + remove_flags(FLAG_TRASH); + } + + public bool is_flagged() { + return is_flag_set(FLAG_FLAGGED); + } + + public void mark_flagged() { + add_flags(FLAG_FLAGGED, new Alteration("metadata", "flagged")); + } + + public void mark_unflagged() { + remove_flags(FLAG_FLAGGED, new Alteration("metadata", "flagged")); + } + + public override EventID get_event_id() { + lock (backing_row) { + return backing_row.event_id; + } + } + + public override string to_string() { + lock (backing_row) { + return "[%s] %s".printf(backing_row.video_id.id.to_string(), backing_row.filepath); + } + } + + public VideoID get_video_id() { + lock (backing_row) { + return backing_row.video_id; + } + } + + public override DateTime? get_exposure_time() { + lock (backing_row) { + return backing_row.exposure_time; + } + } + + public void set_exposure_time(DateTime time) { + lock (backing_row) { + try { + VideoTable.get_instance().set_exposure_time(backing_row.video_id, time); + } catch (Error e) { + debug("Warning - %s", e.message); + } + backing_row.exposure_time = time; + } + + notify_altered(new Alteration("metadata", "exposure-time")); + } + + public Dimensions get_frame_dimensions() { + lock (backing_row) { + return Dimensions(backing_row.width, backing_row.height); + } + } + + public override Dimensions get_dimensions(Photo.Exception disallowed_steps = Photo.Exception.NONE) { + return get_frame_dimensions(); + } + + public override uint64 get_filesize() { + return get_master_filesize(); + } + + public override uint64 get_master_filesize() { + lock (backing_row) { + return backing_row.filesize; + } + } + + public override DateTime? get_timestamp() { + lock (backing_row) { + return backing_row.timestamp; + } + } + + public void set_master_timestamp(FileInfo info) { + var time_val = info.get_modification_date_time(); + + try { + lock (backing_row) { + if (backing_row.timestamp.equal(time_val)) + return; + + VideoTable.get_instance().set_timestamp(backing_row.video_id, time_val); + backing_row.timestamp = time_val; + } + } catch (Error err) { + AppWindow.database_error(err); + + return; + } + + notify_altered(new Alteration("metadata", "master-timestamp")); + } + + public string get_filename() { + lock (backing_row) { + return backing_row.filepath; + } + } + + public override File get_file() { + return File.new_for_path(get_filename()); + } + + public override File get_master_file() { + return get_file(); + } + + public void export(File dest_file) throws Error { + File source_file = File.new_for_path(get_filename()); + source_file.copy(dest_file, FileCopyFlags.OVERWRITE | FileCopyFlags.TARGET_DEFAULT_PERMS, + null, null); + } + + public double get_clip_duration() { + lock (backing_row) { + return backing_row.clip_duration; + } + } + + public bool get_is_interpretable() { + lock (backing_row) { + return backing_row.is_interpretable; + } + } + + private void set_is_interpretable(bool is_interpretable) { + lock (backing_row) { + if (backing_row.is_interpretable == is_interpretable) + return; + + backing_row.is_interpretable = is_interpretable; + } + + try { + VideoTable.get_instance().update_is_interpretable(get_video_id(), is_interpretable); + } catch (DatabaseError e) { + AppWindow.database_error(e); + } + } + + // Intended to be called from a background thread but can be called from foreground as well. + // Caller should call InterpretableResults.foreground_process() only from foreground thread, + // however + public InterpretableResults check_is_interpretable() { + InterpretableResults results = new InterpretableResults(this); + + double clip_duration = -1.0; + Gdk.Pixbuf? preview_frame = null; + + VideoReader backing_file_reader = new VideoReader(get_file()); + try { + clip_duration = backing_file_reader.read_clip_duration(); + preview_frame = backing_file_reader.read_preview_frame(); + } catch (VideoError e) { + // if we catch an error on an interpretable video here, then this video is + // non-interpretable (e.g. its codec is not present on the users system). + results.update_interpretable = get_is_interpretable(); + results.is_interpretable = false; + + return results; + } + + // if already marked interpretable, this is only confirming what we already knew + if (get_is_interpretable()) { + results.update_interpretable = false; + results.is_interpretable = true; + + return results; + } + + debug("video %s has become interpretable", get_file().get_basename()); + + // save this here, this can be done in background thread + lock (backing_row) { + backing_row.clip_duration = clip_duration; + } + + results.update_interpretable = true; + results.is_interpretable = true; + results.new_thumbnail = preview_frame; + + return results; + } + + public override void destroy() { + VideoID video_id = get_video_id(); + + ThumbnailCache.remove(this); + + try { + VideoTable.get_instance().remove(video_id); + } catch (DatabaseError err) { + error("failed to remove video %s from video table", to_string()); + } + + base.destroy(); + } + + protected override bool internal_delete_backing() throws Error { + bool ret = delete_original_file(); + + // Return false if parent method failed. + return base.internal_delete_backing() && ret; + } + + private void notify_flags_altered(Alteration? additional_alteration) { + Alteration alteration = new Alteration("metadata", "flags"); + if (additional_alteration != null) + alteration = alteration.compress(additional_alteration); + + notify_altered(alteration); + } + + public uint64 add_flags(uint64 flags_to_add, Alteration? additional_alteration = null) { + uint64 new_flags; + lock (backing_row) { + new_flags = internal_add_flags(backing_row.flags, flags_to_add); + if (backing_row.flags == new_flags) + return backing_row.flags; + + try { + VideoTable.get_instance().set_flags(get_video_id(), new_flags); + } catch (DatabaseError e) { + AppWindow.database_error(e); + return backing_row.flags; + } + + backing_row.flags = new_flags; + } + + notify_flags_altered(additional_alteration); + + return new_flags; + } + + public uint64 remove_flags(uint64 flags_to_remove, Alteration? additional_alteration = null) { + uint64 new_flags; + lock (backing_row) { + new_flags = internal_remove_flags(backing_row.flags, flags_to_remove); + if (backing_row.flags == new_flags) + return backing_row.flags; + + try { + VideoTable.get_instance().set_flags(get_video_id(), new_flags); + } catch (DatabaseError e) { + AppWindow.database_error(e); + return backing_row.flags; + } + + backing_row.flags = new_flags; + } + + notify_flags_altered(additional_alteration); + + return new_flags; + } + + public bool is_flag_set(uint64 flag) { + lock (backing_row) { + return internal_is_flag_set(backing_row.flags, flag); + } + } + + public void set_master_file(File file) { + string new_filepath = file.get_path(); + string? old_filepath = null; + try { + lock (backing_row) { + if (backing_row.filepath == new_filepath) + return; + + old_filepath = backing_row.filepath; + + VideoTable.get_instance().set_filepath(backing_row.video_id, new_filepath); + backing_row.filepath = new_filepath; + } + } catch (Error err) { + AppWindow.database_error(err); + + return; + } + + assert(old_filepath != null); + notify_master_replaced(File.new_for_path(old_filepath), file); + + notify_altered(new Alteration.from_list("backing:master,metadata:name")); + } + + public VideoMetadata read_metadata() throws Error { + return (new VideoReader(get_file())).read_metadata(); + } +} diff --git a/src/video-support/VideoImportParams.vala b/src/video-support/VideoImportParams.vala new file mode 100644 index 0000000..6804c53 --- /dev/null +++ b/src/video-support/VideoImportParams.vala @@ -0,0 +1,28 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public class VideoImportParams { + // IN: + public File file; + public ImportID import_id = ImportID(); + public string? md5; + public DateTime? exposure_time_override; + + // IN/OUT: + public Thumbnails? thumbnails; + + // OUT: + public VideoRow row = new VideoRow(); + + public VideoImportParams(File file, ImportID import_id, string? md5, + Thumbnails? thumbnails = null, DateTime? exposure_time_override = null) { + this.file = file; + this.import_id = import_id; + this.md5 = md5; + this.thumbnails = thumbnails; + this.exposure_time_override = exposure_time_override; + } +} diff --git a/src/video-support/VideoMetadata.vala b/src/video-support/VideoMetadata.vala new file mode 100644 index 0000000..02580f8 --- /dev/null +++ b/src/video-support/VideoMetadata.vala @@ -0,0 +1,51 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * 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; + } + +} diff --git a/src/video-support/VideoMetadataReaderProcess.vala b/src/video-support/VideoMetadataReaderProcess.vala new file mode 100644 index 0000000..26d61a6 --- /dev/null +++ b/src/video-support/VideoMetadataReaderProcess.vala @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +using Gst; +using Gst.PbUtils; + +int main(string[] args) { + Intl.setlocale(GLib.LocaleCategory.NUMERIC, "C"); + + var option_context = new OptionContext("- shotwell video metadata reader helper binary"); + option_context.set_help_enabled(true); + option_context.add_group(Gst.init_get_option_group()); + + double clip_duration; + GLib.DateTime timestamp = null; + + try { + option_context.parse(ref args); + + if (args.length < 2) + throw new IOError.INVALID_ARGUMENT("Missing URI"); + + var f = File.new_for_commandline_arg (args[1]); + + Gst.PbUtils.Discoverer d = new Gst.PbUtils.Discoverer((Gst.ClockTime) (Gst.SECOND * 5)); + Gst.PbUtils.DiscovererInfo info = d.discover_uri(f.get_uri()); + + clip_duration = ((double) info.get_duration()) / 1000000000.0; + + // Get creation time. + // TODO: Note that TAG_DATE can be changed to TAG_DATE_TIME in the future + // (and the corresponding output struct) in order to implement #2836. + Gst.DateTime? video_date = null; + + Gst.TagList? tags = null; + + var stream_info = info.get_stream_info(); + if (stream_info is Gst.PbUtils.DiscovererContainerInfo) { + tags = ((Gst.PbUtils.DiscovererContainerInfo)stream_info).get_tags(); + } + else if (stream_info is Gst.PbUtils.DiscovererStreamInfo) { + tags = ((Gst.PbUtils.DiscovererStreamInfo)stream_info).get_tags(); + } + + if (tags != null && tags.get_date_time(Gst.Tags.DATE_TIME, out video_date)) { + // possible for get_date() to return true and a null Date + if (video_date != null) { + timestamp = video_date.to_g_date_time().to_local(); + } + } + + print("%.3f\n", clip_duration); + if (timestamp != null) { + print("%s\n", timestamp.format_iso8601()); + } else { + print("none\n"); + } + } catch (Error error) { + critical("Failed to parse options: %s", error.message); + + return 1; + } + + return 0; +} diff --git a/src/video-support/VideoReader.vala b/src/video-support/VideoReader.vala new file mode 100644 index 0000000..11f11e1 --- /dev/null +++ b/src/video-support/VideoReader.vala @@ -0,0 +1,317 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public errordomain VideoError { + FILE, // there's a problem reading the video container file (doesn't exist, no read + // permission, etc.) + + CONTENTS, // we can read the container file but its contents are indecipherable (no codec, + // malformed data, etc.) +} + +public class VideoReader { + private const double UNKNOWN_CLIP_DURATION = -1.0; + private const uint THUMBNAILER_TIMEOUT = 10000; // In milliseconds. + + // File extensions for video containers that pack only metadata as per the AVCHD spec + private const string[] METADATA_ONLY_FILE_EXTENSIONS = { "bdm", "bdmv", "cpi", "mpl" }; + + private double clip_duration = UNKNOWN_CLIP_DURATION; + private Gdk.Pixbuf preview_frame = null; + private File file = null; + private Subprocess thumbnailer_process = null; + public DateTime? timestamp { get; private set; default = null; } + + public VideoReader(File file) { + this.file = file; + } + + public static bool is_supported_video_file(File file) { + var mime_type = ContentType.guess(file.get_basename(), new uchar[0], null); + // special case: deep-check content-type of files ending with .ogg + if (mime_type == "audio/ogg" && file.has_uri_scheme("file")) { + try { + var info = file.query_info(FileAttribute.STANDARD_CONTENT_TYPE, + FileQueryInfoFlags.NONE); + var content_type = info.get_content_type(); + if (content_type != null && content_type.has_prefix ("video/")) { + return true; + } + } catch (Error error) { + debug("Failed to query content type: %s", error.message); + } + } + + return is_supported_video_filename(file.get_basename()); + } + + public static bool is_supported_video_filename(string filename) { + string mime_type; + mime_type = ContentType.guess(filename, new uchar[0], null); + // Guessed mp4/mxf from filename has application/ as prefix, so check for mp4/mxf in the end + if (mime_type.has_prefix ("video/") || + mime_type.has_suffix("mp4") || + mime_type.has_suffix("mxf")) { + string? extension = null; + string? name = null; + disassemble_filename(filename, out name, out extension); + + if (extension == null) + return true; + + foreach (string s in METADATA_ONLY_FILE_EXTENSIONS) { + if (utf8_ci_compare(s, extension) == 0) + return false; + } + + return true; + } else { + debug("Skipping %s, unsupported mime type %s", filename, mime_type); + return false; + } + } + + public static ImportResult prepare_for_import(VideoImportParams params) { +#if MEASURE_IMPORT + Timer total_time = new Timer(); +#endif + File file = params.file; + + FileInfo info = null; + try { + info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + } catch (Error err) { + return ImportResult.FILE_ERROR; + } + + if (info.get_file_type() != FileType.REGULAR) + return ImportResult.NOT_A_FILE; + + if (!is_supported_video_file(file)) { + message("Not importing %s: file is marked as a video file but doesn't have a" + + "supported extension", file.get_path()); + + return ImportResult.UNSUPPORTED_FORMAT; + } + + var timestamp = info.get_modification_date_time(); + + // make sure params has a valid md5 + assert(params.md5 != null); + + DateTime exposure_time = params.exposure_time_override; + string title = ""; + string comment = ""; + + VideoReader reader = new VideoReader(file); + bool is_interpretable = true; + double clip_duration = 0.0; + Gdk.Pixbuf preview_frame = reader.read_preview_frame(); + try { + clip_duration = reader.read_clip_duration(); + } catch (VideoError err) { + if (err is VideoError.FILE) { + return ImportResult.FILE_ERROR; + } else if (err is VideoError.CONTENTS) { + is_interpretable = false; + clip_duration = 0.0; + } else { + error("can't prepare video for import: an unknown kind of video error occurred"); + } + } + + try { + VideoMetadata metadata = reader.read_metadata(); + MetadataDateTime? creation_date_time = metadata.get_creation_date_time(); + + if (creation_date_time != null && creation_date_time.get_timestamp() != null) + exposure_time = creation_date_time.get_timestamp(); + + string? video_title = metadata.get_title(); + string? video_comment = metadata.get_comment(); + if (video_title != null) + title = video_title; + if (video_comment != null) + comment = video_comment; + } catch (Error err) { + warning("Unable to read video metadata: %s", err.message); + } + + if (exposure_time == null) { + // Use time reported by Gstreamer, if available. + exposure_time = reader.timestamp; + } + + params.row.video_id = VideoID(); + params.row.filepath = file.get_path(); + params.row.filesize = info.get_size(); + params.row.timestamp = timestamp; + params.row.width = preview_frame.width; + params.row.height = preview_frame.height; + params.row.clip_duration = clip_duration; + params.row.is_interpretable = is_interpretable; + params.row.exposure_time = exposure_time; + params.row.import_id = params.import_id; + params.row.event_id = EventID(); + params.row.md5 = params.md5; + params.row.time_created = 0; + params.row.title = title; + params.row.comment = comment; + params.row.backlinks = ""; + params.row.time_reimported = 0; + params.row.flags = 0; + + if (params.thumbnails != null) { + params.thumbnails = new Thumbnails(); + ThumbnailCache.generate_for_video_frame(params.thumbnails, preview_frame); + } + +#if MEASURE_IMPORT + debug("IMPORT: total time to import video = %lf", total_time.elapsed()); +#endif + return ImportResult.SUCCESS; + } + + private void read_internal() throws VideoError { + if (!does_file_exist()) + throw new VideoError.FILE("video file '%s' does not exist or is inaccessible".printf( + file.get_path())); + + uint id = 0; + try { + var cancellable = new Cancellable(); + + id = Timeout.add_seconds(10, () => { + cancellable.cancel(); + id = 0; + + return false; + }); + + Bytes stdout_buf = null; + Bytes stderr_buf = null; + + var process = new GLib.Subprocess(GLib.SubprocessFlags.STDOUT_PIPE, AppDirs.get_metadata_helper().get_path(), file.get_uri()); + var result = process.communicate(null, cancellable, out stdout_buf, out stderr_buf); + if (result && process.get_if_exited() && process.get_exit_status () == 0 && stdout_buf != null && stdout_buf.get_size() > 0) { + string[] lines = ((string) stdout_buf.get_data()).split("\n"); + + var old = Intl.setlocale(GLib.LocaleCategory.NUMERIC, "C"); + clip_duration = double.parse(lines[0]); + Intl.setlocale(GLib.LocaleCategory.NUMERIC, old); + if (lines[1] != "none") + timestamp = new DateTime.from_iso8601(lines[1], null); + } else { + string message = ""; + if (stderr_buf != null && stderr_buf.get_size() > 0) { + message = (string) stderr_buf.get_data(); + } + warning ("External Metadata helper failed"); + } + } catch (Error e) { + debug("Video read error: %s", e.message); + throw new VideoError.CONTENTS("GStreamer couldn't extract clip information: %s" + .printf(e.message)); + } + + if (id != 0) { + Source.remove(id); + } + } + + // Used by thumbnailer() to kill the external process if need be. + private bool on_thumbnailer_timer() { + debug("Thumbnailer timer called"); + if (thumbnailer_process != null) { + thumbnailer_process.force_exit(); + } + return false; // Don't call again. + } + + // Performs video thumbnailing. + // Note: not thread-safe if called from the same instance of the class. + private Gdk.Pixbuf? thumbnailer(string video_file) { + // Use Shotwell's thumbnailer, redirect output to stdout. + debug("Launching thumbnailer process: %s", AppDirs.get_thumbnailer_bin().get_path()); + FileIOStream stream; + File output_file; + try { + output_file = File.new_tmp(null, out stream); + } catch (Error e) { + debug("Failed to create temporary file: %s", e.message); + return null; + } + + try { + thumbnailer_process = new Subprocess(SubprocessFlags.NONE, + AppDirs.get_thumbnailer_bin().get_path(), video_file, output_file.get_path()); + } catch (Error e) { + debug("Error spawning process: %s", e.message); + return null; + } + + // Start timer. + Timeout.add(THUMBNAILER_TIMEOUT, on_thumbnailer_timer); + + // Make sure process exited properly. + try { + thumbnailer_process.wait_check(); + + // Read pixbuf from stream. + Gdk.Pixbuf? buf = null; + try { + buf = new Gdk.Pixbuf.from_stream(stream.get_input_stream(), null); + return buf; + } catch (Error e) { + debug("Error creating pixbuf: %s", e.message); + } + } catch (Error err) { + debug("Thumbnailer process exited with error: %s", err.message); + } + + try { + output_file.delete(null); + } catch (Error err) { + debug("Failed to remove temporary file: %s", err.message); + } + + return null; + } + + private bool does_file_exist() { + return FileUtils.test(file.get_path(), FileTest.EXISTS | FileTest.IS_REGULAR); + } + + public Gdk.Pixbuf? read_preview_frame() { + if (preview_frame != null) + return preview_frame; + + if (!does_file_exist()) + return null; + + // Get preview frame from thumbnailer. + preview_frame = thumbnailer(file.get_path()); + if (null == preview_frame) + preview_frame = Resources.get_noninterpretable_badge_pixbuf(); + + return preview_frame; + } + + public double read_clip_duration() throws VideoError { + if (clip_duration == UNKNOWN_CLIP_DURATION) + read_internal(); + + return clip_duration; + } + + public VideoMetadata read_metadata() throws Error { + VideoMetadata metadata = new VideoMetadata(); + metadata.read_from_file(File.new_for_path(file.get_path())); + + return metadata; + } +} diff --git a/src/video-support/VideoSourceCollection.vala b/src/video-support/VideoSourceCollection.vala new file mode 100644 index 0000000..89daad3 --- /dev/null +++ b/src/video-support/VideoSourceCollection.vala @@ -0,0 +1,175 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public class VideoSourceCollection : MediaSourceCollection { + public enum State { + UNKNOWN, + ONLINE, + OFFLINE, + TRASH + } + + public override TransactionController transaction_controller { + get { + if (_transaction_controller == null) + _transaction_controller = new MediaSourceTransactionController(this); + + return _transaction_controller; + } + } + + private TransactionController _transaction_controller = null; + private Gee.MultiMap<uint64?, Video> filesize_to_video = + new Gee.TreeMultiMap<uint64?, Video>(uint64_compare); + + public VideoSourceCollection() { + base("VideoSourceCollection", get_video_key); + + get_trashcan().contents_altered.connect(on_trashcan_contents_altered); + get_offline_bin().contents_altered.connect(on_offline_contents_altered); + } + + protected override MediaSourceHoldingTank create_trashcan() { + return new MediaSourceHoldingTank(this, is_video_trashed, get_video_key); + } + + protected override MediaSourceHoldingTank create_offline_bin() { + return new MediaSourceHoldingTank(this, is_video_offline, get_video_key); + } + + public override MediaMonitor create_media_monitor(Workers workers, Cancellable cancellable) { + return new VideoMonitor(cancellable); + } + + public override bool holds_type_of_source(DataSource source) { + return source is Video; + } + + public override string get_typename() { + return Video.TYPENAME; + } + + public override bool is_file_recognized(File file) { + return VideoReader.is_supported_video_file(file); + } + + private void on_trashcan_contents_altered(Gee.Collection<DataSource>? added, + Gee.Collection<DataSource>? removed) { + trashcan_contents_altered((Gee.Collection<Video>?) added, + (Gee.Collection<Video>?) removed); + } + + private void on_offline_contents_altered(Gee.Collection<DataSource>? added, + Gee.Collection<DataSource>? removed) { + offline_contents_altered((Gee.Collection<Video>?) added, + (Gee.Collection<Video>?) removed); + } + + protected override MediaSource? fetch_by_numeric_id(int64 numeric_id) { + return fetch(VideoID(numeric_id)); + } + + public static int64 get_video_key(DataSource source) { + Video video = (Video) source; + VideoID video_id = video.get_video_id(); + + return video_id.id; + } + + public static bool is_video_trashed(DataSource source) { + return ((Video) source).is_trashed(); + } + + public static bool is_video_offline(DataSource source) { + return ((Video) source).is_offline(); + } + + public Video fetch(VideoID video_id) { + return (Video) fetch_by_key(video_id.id); + } + + public override Gee.Collection<string> get_event_source_ids(EventID event_id){ + return VideoTable.get_instance().get_event_source_ids(event_id); + } + + public Video? get_state_by_file(File file, out State state) { + Video? video = (Video?) fetch_by_master_file(file); + if (video != null) { + state = State.ONLINE; + + return video; + } + + video = (Video?) get_trashcan().fetch_by_master_file(file); + if (video != null) { + state = State.TRASH; + + return video; + } + + video = (Video?) get_offline_bin().fetch_by_master_file(file); + if (video != null) { + state = State.OFFLINE; + + return video; + } + + state = State.UNKNOWN; + + return null; + } + + private void compare_backing(Video video, FileInfo info, Gee.Collection<Video> matching_master) { + if (video.get_filesize() != info.get_size()) + return; + + if (video.get_timestamp().equal(info.get_modification_date_time())) + matching_master.add(video); + } + + public void fetch_by_matching_backing(FileInfo info, Gee.Collection<Video> matching_master) { + foreach (DataObject object in get_all()) + compare_backing((Video) object, info, matching_master); + + foreach (MediaSource media in get_offline_bin_contents()) + compare_backing((Video) media, info, matching_master); + } + + protected override void notify_contents_altered(Gee.Iterable<DataObject>? added, + Gee.Iterable<DataObject>? removed) { + if (added != null) { + foreach (DataObject object in added) { + Video video = (Video) object; + + filesize_to_video.set(video.get_master_filesize(), video); + } + } + + if (removed != null) { + foreach (DataObject object in removed) { + Video video = (Video) object; + + filesize_to_video.remove(video.get_master_filesize(), video); + } + } + + base.notify_contents_altered(added, removed); + } + + public VideoID get_basename_filesize_duplicate(string basename, uint64 filesize) { + foreach (Video video in filesize_to_video.get(filesize)) { + if (utf8_ci_compare(video.get_master_file().get_basename(), basename) == 0) + return video.get_video_id(); + } + + return VideoID(); // the default constructor of the VideoID struct creates an invalid + // video id, which is just what we want in this case + } + + public bool has_basename_filesize_duplicate(string basename, uint64 filesize) { + return get_basename_filesize_duplicate(basename, filesize).is_valid(); + } +} diff --git a/src/video-support/meson.build b/src/video-support/meson.build new file mode 100644 index 0000000..da3f9d7 --- /dev/null +++ b/src/video-support/meson.build @@ -0,0 +1,36 @@ +executable( + 'shotwell-video-metadata-handler', + [ + 'VideoMetadataReaderProcess.vala' + ], + dependencies : [ + gio, + gstreamer, + gstreamer_pbu + ], + c_args : '-DGST_PB_UTILS_IS_DISCOVERER_INFO=GST_IS_DISCOVERER_INFO' + # Work-around for wrong type-check macro generated by valac +) + +libvideometadata_handling = static_library( + 'video_metadata_handling', + [ + 'AVIChunk.vala', + 'AVIMetadataLoader.vala', + 'QuickTimeAtom.vala', + 'QuicktimeMetdataLoader.vala', + 'util.vala' + ], + vala_header : 'shotwell-internal-video-metadata-handling.h', + vala_vapi : 'shotwell-internal-video-metadata-handling.vapi', + include_directories : config_incdir, + dependencies : [ + gio, + metadata + ] +) + +metadata_handling = declare_dependency( + include_directories : include_directories('.'), + link_with : libvideometadata_handling +) diff --git a/src/video-support/util.vala b/src/video-support/util.vala new file mode 100644 index 0000000..ad06680 --- /dev/null +++ b/src/video-support/util.vala @@ -0,0 +1,13 @@ +// Breaks a uint64 skip amount into several smaller skips. +public void skip_uint64(InputStream input, uint64 skip_amount) throws GLib.Error { + while (skip_amount > 0) { + // skip() throws an error if the amount is too large, so check against ssize_t.MAX + if (skip_amount >= ssize_t.MAX) { + input.skip(ssize_t.MAX); + skip_amount -= ssize_t.MAX; + } else { + input.skip((size_t) skip_amount); + skip_amount = 0; + } + } +} |