summaryrefslogtreecommitdiff
path: root/src/video-support
diff options
context:
space:
mode:
Diffstat (limited to 'src/video-support')
-rw-r--r--src/video-support/AVIChunk.vala121
-rw-r--r--src/video-support/AVIMetadataLoader.vala227
-rw-r--r--src/video-support/QuickTimeAtom.vala118
-rw-r--r--src/video-support/QuicktimeMetdataLoader.vala127
-rw-r--r--src/video-support/Video.vala703
-rw-r--r--src/video-support/VideoImportParams.vala28
-rw-r--r--src/video-support/VideoMetadata.vala51
-rw-r--r--src/video-support/VideoMetadataReaderProcess.vala66
-rw-r--r--src/video-support/VideoReader.vala317
-rw-r--r--src/video-support/VideoSourceCollection.vala175
-rw-r--r--src/video-support/meson.build36
-rw-r--r--src/video-support/util.vala13
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;
+ }
+ }
+}