Commit 456922c7 authored by Lucas Beeler's avatar Lucas Beeler

Enables Shotwell to import video files from cameras or the filesystem and play...

Enables Shotwell to import video files from cameras or the filesystem and play them back in the system-defined media player. Closes #855.
parent cb1f4e27
......@@ -113,7 +113,7 @@ SRC_FILES = \
MimicManager.vala \
TrashPage.vala \
PngSupport.vala \
PhotoExporter.vala \
Exporter.vala \
DirectoryMonitor.vala \
LibraryMonitor.vala \
OfflinePage.vala \
......@@ -123,6 +123,8 @@ SRC_FILES = \
AlienDatabaseImportDialog.vala \
FSpotDatabaseDriver.vala \
FSpotDatabaseTables.vala \
VideoSupport.vala \
VideosPage.vala \
Tombstone.vala
ifndef LINUX
......@@ -286,7 +288,9 @@ EXT_PKGS += \
webkit-1.0 \
gudev-1.0 \
dbus-glib-1 \
gdk-x11-2.0
gdk-x11-2.0 \
gstreamer-0.10 \
gstreamer-base-0.10
endif
# libraw is handled separately (see note below); when libraw-config is no longer needed, the version
......@@ -311,7 +315,9 @@ EXT_PKG_VERSIONS += \
unique-1.0 >= 1.0.0 \
webkit-1.0 >= 1.1.5 \
gudev-1.0 >= 145 \
dbus-glib-1 >= 0.80
dbus-glib-1 >= 0.80 \
gstreamer-0.10 >= 0.10.28 \
gstreamer-base-0.10 >= 0.10.28
endif
PKGS = $(EXT_PKGS) $(LOCAL_PKGS) $(LIBRAW_PKG)
......
......@@ -125,7 +125,7 @@ public class AlienDatabaseImportJob : BatchImportJob {
src_file = import_source.get_file();
filesize = import_source.get_filesize();
exposure_time = import_source.get_exposure_time();
import_id = PhotoTable.get_instance().generate_import_id();
import_id = ImportID.generate();
}
public time_t get_exposure_time() {
......@@ -153,7 +153,11 @@ public class AlienDatabaseImportJob : BatchImportJob {
return true;
}
public override bool complete(LibraryPhoto photo, ViewCollection generated_events) throws Error {
public override bool complete(ThumbnailSource source, ViewCollection generated_events) throws Error {
if (!(source is LibraryPhoto))
return false;
LibraryPhoto photo = (LibraryPhoto) source;
AlienDatabasePhoto src_photo = import_source.get_photo();
// tags
Gee.Collection<AlienDatabaseTag> src_tags = src_photo.get_tags();
......
This diff is collapsed.
......@@ -70,7 +70,7 @@ public abstract class CollectionPage : CheckerboardPage {
private Gtk.ToolButton publish_button = null;
#endif
private int scale = Thumbnail.DEFAULT_SCALE;
private PhotoExporterUI exporter = null;
private ExporterUI exporter = null;
public CollectionPage(string page_name, string? ui_filename = null,
Gtk.ActionEntry[]? child_actions = null) {
......@@ -136,12 +136,7 @@ public abstract class CollectionPage : CheckerboardPage {
Config.get_instance().get_display_photo_ratings());
get_view().thaw_notifications();
// adjustment which is shared by all sliders in the application
scale = Config.get_instance().get_photo_thumbnail_scale();
if (slider_adjustment == null) {
slider_adjustment = new Gtk.Adjustment(scale_to_slider(scale), 0,
scale_to_slider(Thumbnail.MAX_SCALE), 1, 10, 0);
}
// set up page's toolbar (used by AppWindow for layout)
Gtk.Toolbar toolbar = get_toolbar();
......@@ -227,7 +222,7 @@ public abstract class CollectionPage : CheckerboardPage {
zoom_group.pack_start(zoom_out_box, false, false, 0);
// thumbnail size slider
slider = new Gtk.HScale(slider_adjustment);
slider = new Gtk.HScale(get_global_slider_adjustment());
slider.value_changed.connect(on_slider_changed);
slider.set_draw_value(false);
slider.set_size_request(200, -1);
......@@ -263,6 +258,16 @@ public abstract class CollectionPage : CheckerboardPage {
Config.get_instance().external_app_changed.connect(on_external_app_changed);
}
public static Gtk.Adjustment get_global_slider_adjustment() {
int scale = Config.get_instance().get_photo_thumbnail_scale();
if (slider_adjustment == null) {
slider_adjustment = new Gtk.Adjustment(scale_to_slider(scale), 0,
scale_to_slider(Thumbnail.MAX_SCALE), 1, 10, 0);
}
return slider_adjustment;
}
private Gtk.ActionEntry[] create_actions() {
Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
......@@ -1049,7 +1054,7 @@ public abstract class CollectionPage : CheckerboardPage {
if (export_dir == null)
return;
exporter = new PhotoExporterUI(new PhotoExporter(export_list, export_dir,
exporter = new ExporterUI(new Exporter(export_list, export_dir,
scaling, quality, format));
exporter.export(on_export_completed);
}
......@@ -1677,14 +1682,18 @@ public abstract class CollectionPage : CheckerboardPage {
Config.get_instance().set_display_photo_ratings(display);
}
private static double scale_to_slider(int value) {
// public so that other CollectionPage-like pages (e.g., the Videos page)
// can use the scale-to-slider conversion service
public static double scale_to_slider(int value) {
assert(value >= Thumbnail.MIN_SCALE);
assert(value <= Thumbnail.MAX_SCALE);
return (double) ((value - Thumbnail.MIN_SCALE) / SLIDER_STEPPING);
}
private static int slider_to_scale(double value) {
// public so that other CollectionPage-like pages (e.g., the Videos page)
// can use the slider-to-scale conversion service
public static int slider_to_scale(double value) {
int res = ((int) (value * SLIDER_STEPPING)) + Thumbnail.MIN_SCALE;
assert(res >= Thumbnail.MIN_SCALE);
......@@ -1904,7 +1913,7 @@ public abstract class CollectionPage : CheckerboardPage {
}
public static int get_photo_thumbnail_scale() {
return slider_to_scale(slider_adjustment.get_value());
return slider_to_scale(get_global_slider_adjustment().get_value());
}
}
......
......@@ -931,7 +931,15 @@ public abstract class ThumbnailSource : DataSource {
public abstract PhotoFileFormat get_preferred_thumbnail_format();
}
public abstract class PhotoSource : ThumbnailSource {
public abstract class MediaSource : ThumbnailSource {
public MediaSource(int64 object_id = INVALID_OBJECT_ID) {
base (object_id);
}
public abstract File get_file();
}
public abstract class PhotoSource : MediaSource {
public PhotoSource(int64 object_id = INVALID_OBJECT_ID) {
base (object_id);
}
......@@ -947,6 +955,10 @@ public abstract class PhotoSource : ThumbnailSource {
public abstract Gdk.Pixbuf get_pixbuf(Scaling scaling) throws Error;
}
public abstract class VideoSource : MediaSource {
protected const string THUMBNAIL_NAME_PREFIX = "video";
}
public abstract class EventSource : ThumbnailSource {
public EventSource(int64 object_id = INVALID_OBJECT_ID) {
base (object_id);
......@@ -1297,6 +1309,16 @@ public class PhotoView : ThumbnailView {
}
}
public class VideoView : ThumbnailView {
public VideoView(VideoSource source) {
base(source);
}
public VideoSource get_video_source() {
return (VideoSource) get_source();
}
}
public class EventView : ThumbnailView {
public EventView(EventSource source) {
base(source);
......
......@@ -200,7 +200,19 @@ public class DatabaseTable {
return execute_update_by_id(stmt);
}
protected void update_int_by_id_2(int64 id, string column, int value) throws DatabaseError {
Sqlite.Statement stmt;
prepare_update_by_id(id, column, out stmt);
int res = stmt.bind_int(1, value);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE)
throw_error("DatabaseTable.update_int_by_id_2 %s.%s".printf(table_name, column), res);
}
protected bool update_int64_by_id(int64 id, string column, int64 value) {
Sqlite.Statement stmt;
prepare_update_by_id(id, column, out stmt);
......@@ -598,6 +610,14 @@ public struct ImportID {
this.id = id;
}
public static ImportID generate() {
TimeVal timestamp = TimeVal();
timestamp.get_current_time();
int64 id = timestamp.tv_sec;
return ImportID(id);
}
public bool is_invalid() {
return (id == INVALID);
}
......@@ -730,16 +750,6 @@ public class PhotoTable : DatabaseTable {
return instance;
}
public ImportID generate_import_id() {
// TODO: Use a guid here? Key here is that last imported photos can be easily identified
// by finding the largest value in the PhotoTable
TimeVal timestamp = TimeVal();
timestamp.get_current_time();
int64 id = timestamp.tv_sec;
return ImportID(id);
}
// PhotoRow.photo_id, event_id, master.orientation, flags, and time_created are ignored on input.
// All fields are set on exit with values stored in the database. editable_id field is ignored.
public PhotoID add(ref PhotoRow photo_row) {
......@@ -2295,3 +2305,366 @@ public class TombstoneTable : DatabaseTable {
}
}
public struct VideoID {
public const int64 INVALID = -1;
public int64 id;
public VideoID(int64 id = INVALID) {
this.id = id;
}
public bool is_invalid() {
return (id == INVALID);
}
public bool is_valid() {
return (id != INVALID);
}
public static uint hash(void *a) {
return int64_hash(&((VideoID *) a)->id);
}
public static bool equal(void *a, void *b) {
return ((VideoID *) a)->id == ((VideoID *) b)->id;
}
}
public struct VideoRow {
public VideoID video_id;
public string filepath;
public int64 filesize;
public time_t timestamp;
public int width;
public int height;
public double clip_duration;
public bool is_interpretable;
public time_t exposure_time;
public ImportID import_id;
public EventID event_id;
public string md5;
public time_t time_created;
public Rating rating;
public string title;
public string? backlinks;
public time_t time_reimported;
}
public class VideoTable : DatabaseTable {
private static VideoTable instance = null;
private VideoTable() {
Sqlite.Statement stmt;
int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS VideoTable ("
+ "id INTEGER PRIMARY KEY, "
+ "filename TEXT UNIQUE NOT NULL, "
+ "width INTEGER, "
+ "height INTEGER, "
+ "clip_duration REAL, "
+ "is_interpretable INTEGER, "
+ "filesize INTEGER, "
+ "timestamp INTEGER, "
+ "exposure_time INTEGER, "
+ "import_id INTEGER, "
+ "event_id INTEGER, "
+ "md5 TEXT, "
+ "time_created INTEGER, "
+ "rating INTEGER DEFAULT 0, "
+ "title TEXT, "
+ "backlinks TEXT, "
+ "time_reimported INTEGER "
+ ")", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE)
fatal("VideoTable constructor", res);
// index on event_id
Sqlite.Statement stmt2;
int res2 = db.prepare_v2("CREATE INDEX IF NOT EXISTS VideoEventIDIndex ON VideoTable (event_id)",
-1, out stmt2);
assert(res2 == Sqlite.OK);
res2 = stmt2.step();
if (res2 != Sqlite.DONE)
fatal("VideoTable constructor", res2);
set_table_name("VideoTable");
}
public static VideoTable get_instance() {
if (instance == null)
instance = new VideoTable();
return instance;
}
// VideoRow.video_id, event_id, time_created are ignored on input. All fields are set on exit
// with values stored in the database.
public VideoID add(ref VideoRow video_row) throws DatabaseError {
Sqlite.Statement stmt;
int res = db.prepare_v2(
"INSERT INTO VideoTable (filename, width, height, clip_duration, is_interpretable, "
+ "filesize, timestamp, exposure_time, import_id, event_id, md5, time_created, title) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-1, out stmt);
assert(res == Sqlite.OK);
ulong time_created = now_sec();
res = stmt.bind_text(1, video_row.filepath);
assert(res == Sqlite.OK);
res = stmt.bind_int(2, video_row.width);
assert(res == Sqlite.OK);
res = stmt.bind_int(3, video_row.height);
assert(res == Sqlite.OK);
res = stmt.bind_double(4, video_row.clip_duration);
assert(res == Sqlite.OK);
res = stmt.bind_int(5, (video_row.is_interpretable) ? 1 : 0);
assert(res == Sqlite.OK);
res = stmt.bind_int64(6, video_row.filesize);
assert(res == Sqlite.OK);
res = stmt.bind_int64(7, video_row.timestamp);
assert(res == Sqlite.OK);
res = stmt.bind_int64(8, video_row.exposure_time);
assert(res == Sqlite.OK);
res = stmt.bind_int64(9, video_row.import_id.id);
assert(res == Sqlite.OK);
res = stmt.bind_int64(10, EventID.INVALID);
assert(res == Sqlite.OK);
res = stmt.bind_text(11, video_row.md5);
assert(res == Sqlite.OK);
res = stmt.bind_int64(12, time_created);
assert(res == Sqlite.OK);
res = stmt.bind_text(13, video_row.title);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
if (res != Sqlite.CONSTRAINT)
throw_error("VideoTable.add", res);
}
// fill in ignored fields with database values
video_row.video_id = VideoID(db.last_insert_rowid());
video_row.event_id = EventID();
video_row.time_created = (time_t) time_created;
return video_row.video_id;
}
public VideoRow? get_row(VideoID video_id) {
Sqlite.Statement stmt;
int res = db.prepare_v2(
"SELECT filename, width, height, clip_duration, is_interpretable, filesize, timestamp, "
+ "exposure_time, import_id, event_id, md5, time_created, rating, title, backlinks, "
+ "time_reimported FROM VideoTable WHERE id=?",
-1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_int64(1, video_id.id);
assert(res == Sqlite.OK);
if (stmt.step() != Sqlite.ROW)
return null;
VideoRow row = VideoRow();
row.video_id = video_id;
row.filepath = stmt.column_text(0);
row.width = stmt.column_int(1);
row.height = stmt.column_int(2);
row.clip_duration = stmt.column_double(3);
row.is_interpretable = (stmt.column_int(4) == 1);
row.filesize = stmt.column_int64(5);
row.timestamp = (time_t) stmt.column_int64(6);
row.exposure_time = (time_t) stmt.column_int64(7);
row.import_id.id = stmt.column_int64(8);
row.event_id.id = stmt.column_int64(9);
row.md5 = stmt.column_text(10);
row.time_created = (time_t) stmt.column_int64(11);
row.rating = Rating.unserialize(stmt.column_int(12));
row.title = stmt.column_text(13);
row.backlinks = stmt.column_text(14);
row.time_reimported = (time_t) stmt.column_int64(15);
return row;
}
public Gee.ArrayList<VideoRow?> get_all() {
Sqlite.Statement stmt;
int res = db.prepare_v2(
"SELECT id, filename, width, height, clip_duration, is_interpretable, filesize, "
+ "timestamp, exposure_time, import_id, event_id, md5, time_created, rating, title, "
+ "backlinks, time_reimported FROM VideoTable",
-1, out stmt);
assert(res == Sqlite.OK);
Gee.ArrayList<VideoRow?> all = new Gee.ArrayList<VideoRow?>();
while ((res = stmt.step()) == Sqlite.ROW) {
VideoRow row = VideoRow();
row.video_id.id = stmt.column_int64(0);
row.filepath = stmt.column_text(1);
row.width = stmt.column_int(2);
row.height = stmt.column_int(3);
row.clip_duration = stmt.column_double(4);
row.is_interpretable = (stmt.column_int(5) == 1);
row.filesize = stmt.column_int64(6);
row.timestamp = (time_t) stmt.column_int64(7);
row.exposure_time = (time_t) stmt.column_int64(8);
row.import_id.id = stmt.column_int64(9);
row.event_id.id = stmt.column_int64(10);
row.md5 = stmt.column_text(11);
row.time_created = (time_t) stmt.column_int64(12);
row.rating = Rating.unserialize(stmt.column_int(13));
row.title = stmt.column_text(14);
row.backlinks = stmt.column_text(15);
row.time_reimported = (time_t) stmt.column_int64(16);
all.add(row);
}
return all;
}
public void set_title(VideoID video_id, string? new_title) throws DatabaseError {
update_text_by_id_2(video_id.id, "title", new_title != null ? new_title : "");
}
public void set_exposure_time(VideoID video_id, time_t time) throws DatabaseError {
update_int64_by_id_2(video_id.id, "exposure_time", (int64) time);
}
public void remove_by_file(File file) throws DatabaseError {
Sqlite.Statement stmt;
int res = db.prepare_v2("DELETE FROM VideoTable WHERE filename=?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_text(1, file.get_path());
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE)
throw_error("VideoTable.remove_by_file", res);
}
public void remove(VideoID videoID) throws DatabaseError {
Sqlite.Statement stmt;
int res = db.prepare_v2("DELETE FROM VideoTable WHERE id=?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_int64(1, videoID.id);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE)
throw_error("VideoTable.remove", res);
}
public bool is_video_stored(File file) {
return get_id(file).is_valid();
}
public VideoID get_id(File file) {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT ID FROM VideoTable WHERE filename=?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_text(1, file.get_path());
assert(res == Sqlite.OK);
res = stmt.step();
return (res == Sqlite.ROW) ? VideoID(stmt.column_int64(0)) : VideoID();
}
public Gee.ArrayList<VideoID?> get_videos() throws DatabaseError {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT id FROM VideoTable", -1, out stmt);
assert(res == Sqlite.OK);
Gee.ArrayList<VideoID?> video_ids = new Gee.ArrayList<VideoID?>();
for (;;) {
res = stmt.step();
if (res == Sqlite.DONE) {
break;
} else if (res != Sqlite.ROW) {
throw_error("VideoTable.get_videos", res);
}
video_ids.add(VideoID(stmt.column_int64(0)));
}
return video_ids;
}
private Sqlite.Statement get_duplicate_stmt(File? file, string? md5) {
assert(file != null || md5 != null);
string sql = "SELECT id FROM VideoTable WHERE";
bool first = true;
if (file != null) {
sql += " filename=?";
first = false;
}
if (md5 != null) {
if (!first)
sql += " OR ";
sql += " md5=?";
}
Sqlite.Statement stmt;
int res = db.prepare_v2(sql, -1, out stmt);
assert(res == Sqlite.OK);
int col = 1;
if (file != null) {
res = stmt.bind_text(col++, file.get_path());
assert(res == Sqlite.OK);
}
if (md5 != null) {
res = stmt.bind_text(col++, md5);
assert(res == Sqlite.OK);
}
return stmt;
}
public bool has_duplicate(File? file, string? md5) {
Sqlite.Statement stmt = get_duplicate_stmt(file, md5);
int res = stmt.step();
if (res == Sqlite.DONE) {
// not found
return false;
} else if (res == Sqlite.ROW) {
// at least one found
return true;
} else {
fatal("VideoTable.has_duplicate", res);
}
return false;
}
public VideoID[] get_duplicate_ids(File? file, string? md5) {
Sqlite.Statement stmt = get_duplicate_stmt(file, md5);
VideoID[] ids = new VideoID[0];
int res = stmt.step();
while (res == Sqlite.ROW) {
ids += VideoID(stmt.column_int64(0));
res = stmt.step();
}
return ids;
}
}
......@@ -4,7 +4,7 @@
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class PhotoExporter : Object {
public class Exporter : Object {
public enum Overwrite {
YES,
NO,
......@@ -12,42 +12,56 @@ public class PhotoExporter : Object {
REPLACE_ALL
}
public delegate void CompletionCallback(PhotoExporter exporter);
public delegate void CompletionCallback(Exporter exporter);
public delegate Overwrite OverwriteCallback(PhotoExporter exporter, File file);
public delegate Overwrite OverwriteCallback(Exporter exporter, File file);
public delegate bool ExportFailedCallback(PhotoExporter exporter, File file, int remaining,
public delegate bool ExportFailedCallback(Exporter exporter, File file, int remaining,
Error err);
private class ExportJob : BackgroundJob {
public Photo photo;
public Photo? photo;
public Video? video;
public File dest;
public Scaling scaling;
public Jpeg.Quality quality;
public PhotoFileFormat format;
public Error? err = null;
public ExportJob(PhotoExporter owner, Photo photo, File dest, Scaling scaling,
public ExportJob(Exporter owner, Photo photo, File dest, Scaling scaling,
Jpeg.Quality quality, PhotoFileFormat format, Cancellable? cancellable) {
base (owner, owner.on_exported, cancellable, owner.on_export_cancelled);
this.photo = photo;
this.video = null;
this.dest = dest;
this.scaling = scaling;
this.quality = quality;
this.format = format;
}
public ExportJob.for_video(Exporter owner, Video video, File dest,
Cancellable? cancellable) {
base(owner, owner.on_exported, cancellable, owner.on_export_cancelled);
this.photo = null;
this.video = video;
this.dest = dest;
}
public override void execute() {
try {
photo.export(dest, scaling, quality, format);
if (photo != null)
photo.export(dest, scaling, quality, format);
else
video.export(dest);
} catch (Error err) {
this.err = err;
}
}
}
private Gee.Collection<Photo> photos = new Gee.ArrayList<Photo>();
private Gee.Collection<ThumbnailSource> to_export = new Gee.ArrayList<ThumbnailSource>();
private File dir;
private Scaling scaling;
private Jpeg.Quality quality;
......@@ -62,9 +76,9 @@ public class PhotoExporter : Object {
private bool replace_all = false;
private bool aborted = false;
public PhotoExporter(Gee.Collection<Photo> photos, File dir, Scaling scaling, Jpeg.Quality quality,
PhotoFileFormat file_format) {
this.photos.add_all(photos);
public Exporter(Gee.Collection<ThumbnailSource> to_export, File dir, Scaling scaling,
Jpeg.Quality quality, PhotoFileFormat file_format) {
this.to_export.add_all(to_export);
this.dir = dir;
this.scaling = scaling;
this.quality = quality;
......@@ -91,10 +105,10 @@ public class PhotoExporter : Object {
// because the monitor spins the event loop, and so it's possible this function will be
// re-entered, decide now if this is the last job
bool completed = completed_count == photos.size;
bool completed = completed_count == to_export.size;
if (!aborted && job.err != null) {
if (!error_callback(this, job.dest, photos.size - completed_count, job.err)) {
if (!error_callback(this, job.dest, to_export.size - completed_count, job.err)) {
aborted = true;
if (!completed)
......@@ -103,7 +117,7 @@ public class PhotoExporter : Object {
}
if (!aborted && monitor != null) {
if (!monitor(completed_count, photos.size)) {