/* 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 VideoImportParams { // IN: public File file; public ImportID import_id = ImportID(); public string? md5; public time_t 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, time_t exposure_time_override = 0) { this.file = file; this.import_id = import_id; this.md5 = md5; this.thumbnails = thumbnails; this.exposure_time_override = exposure_time_override; } } 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 GLib.Pid thumbnailer_pid = 0; 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 from filename has application/ as prefix, so check for mp4 in the end if (mime_type.has_prefix ("video/") || mime_type.has_suffix("mp4")) { 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; } TimeVal timestamp = info.get_modification_time(); // make sure params has a valid md5 assert(params.md5 != null); time_t 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() != 0) 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 == 0) { // Use time reported by Gstreamer, if available. exposure_time = (time_t) (reader.timestamp != null ? reader.timestamp.to_unix() : 0); } params.row.video_id = VideoID(); params.row.filepath = file.get_path(); params.row.filesize = info.get_size(); params.row.timestamp = timestamp.tv_sec; 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())); try { Gst.PbUtils.Discoverer d = new Gst.PbUtils.Discoverer((Gst.ClockTime) (Gst.SECOND * 5)); Gst.PbUtils.DiscovererInfo info = d.discover_uri(file.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. Date? video_date = null; if (info.get_tags() != null && info.get_tags().get_date(Gst.Tags.DATE, out video_date)) { // possible for get_date() to return true and a null Date if (video_date != null) { timestamp = new DateTime.local(video_date.get_year(), video_date.get_month(), video_date.get_day(), 0, 0, 0); } } } catch (Error e) { debug("Video read error: %s", e.message); throw new VideoError.CONTENTS("GStreamer couldn't extract clip information: %s" .printf(e.message)); } } // Used by thumbnailer() to kill the external process if need be. private bool on_thumbnailer_timer() { debug("Thumbnailer timer called"); if (thumbnailer_pid != 0) { debug("Killing thumbnailer process: %d", thumbnailer_pid); Posix.kill(thumbnailer_pid, Posix.SIGKILL); } 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()); string[] argv = {AppDirs.get_thumbnailer_bin().get_path(), video_file}; int child_stdout; try { GLib.Process.spawn_async_with_pipes(null, argv, null, GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD, null, out thumbnailer_pid, null, out child_stdout, null); debug("Spawned thumbnailer, child pid: %d", (int) thumbnailer_pid); } catch (Error e) { debug("Error spawning process: %s", e.message); if (thumbnailer_pid != 0) GLib.Process.close_pid(thumbnailer_pid); return null; } // Start timer. Timeout.add(THUMBNAILER_TIMEOUT, on_thumbnailer_timer); // Read pixbuf from stream. Gdk.Pixbuf? buf = null; try { GLib.UnixInputStream unix_input = new GLib.UnixInputStream(child_stdout, true); buf = new Gdk.Pixbuf.from_stream(unix_input, null); } catch (Error e) { debug("Error creating pixbuf: %s", e.message); buf = null; } // Make sure process exited properly. int child_status = 0; int ret_waitpid = Posix.waitpid(thumbnailer_pid, out child_status, 0); if (ret_waitpid < 0) { debug("waitpid returned error code: %d", ret_waitpid); buf = null; } else if (0 != Process.exit_status(child_status)) { debug("Thumbnailer exited with error code: %d", Process.exit_status(child_status)); buf = null; } GLib.Process.close_pid(thumbnailer_pid); thumbnailer_pid = 0; return buf; } 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; } } 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 interpreter_state_changed; private static int current_state; 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 interpreter_state_changed = false; current_state = -1; 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 (); int saved_state = Config.Facade.get_instance().get_video_interpreter_state_cookie(); current_state = (int) registry.get_feature_list_cookie(); if (saved_state == Config.Facade.NO_VIDEO_INTERPRETER_STATE) { message("interpreter state cookie not found; assuming all video thumbnails are out of date"); interpreter_state_changed = true; } else if (saved_state != current_state) { message("interpreter state has changed; video thumbnails may be out of date"); interpreter_state_changed = true; } /* First do the cookie state handling, then update our local registry * 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 all = VideoTable.get_instance().get_all(); Gee.ArrayList