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; +        } +    } +}  | 
