Commit 5cd3ad9c authored by Eric Gregory's avatar Eric Gregory

3730 selectable RAW developer

This adds the ability to switch between Shotwell developed RAW images and camera developed RAW images. It will automatically group newly imported RAW+JPEG pairs, using the JPEG as the camera developed image when available.
parent 2ee9167c
......@@ -80,7 +80,6 @@ UNUNITIZED_SRC_FILES = \
Printing.vala \
Tag.vala \
Screensaver.vala \
MimicManager.vala \
Exporter.vala \
DirectoryMonitor.vala \
LibraryMonitor.vala \
......
......@@ -139,6 +139,13 @@ public abstract class BatchImportJob {
public abstract bool is_directory();
public abstract string get_basename();
public abstract string get_path();
// Attaches a sibling job (for RAW+JPEG)
public abstract void set_associated(BatchImportJob associated);
// Returns the file size of the BatchImportJob or returns a file/directory which can be queried
// by BatchImportJob to determine it. Returns true if the size is return, false if the File is
// specified.
......@@ -168,6 +175,7 @@ public abstract class BatchImportJob {
public class FileImportJob : BatchImportJob {
private File file_or_dir;
private bool copy_to_library;
private FileImportJob? associated = null;
public FileImportJob(File file_or_dir, bool copy_to_library) {
this.file_or_dir = file_or_dir;
......@@ -186,6 +194,18 @@ public class FileImportJob : BatchImportJob {
return query_is_directory(file_or_dir);
}
public override string get_basename() {
return file_or_dir.get_basename();
}
public override string get_path() {
return is_directory() ? file_or_dir.get_path() : file_or_dir.get_parent().get_path();
}
public override void set_associated(BatchImportJob associated) {
this.associated = associated as FileImportJob;
}
public override bool determine_file_size(out uint64 filesize, out File file) {
file = file_or_dir;
......@@ -554,9 +574,6 @@ public class BatchImport : Object {
reporter(manifest, import_roll);
import_complete(manifest, import_roll);
// resume the MimicManager
LibraryPhoto.mimic_manager.resume();
}
// This should be called whenever a file's import process is complete, successful or otherwise
......@@ -576,9 +593,6 @@ public class BatchImport : Object {
assert(scheduled == false);
scheduled = true;
// halt the MimicManager will performing import, as it will drag the system down
LibraryPhoto.mimic_manager.pause();
starting();
// fire off a background job to generate all FileToPrepare work
......@@ -926,6 +940,21 @@ public class BatchImport : Object {
} else {
job.ready.batch_result.result = LibraryPhoto.import_create(job.ready.photo_import_params,
out source);
if (job.ready.photo_import_params.final_associated_file != null) {
// Associate RAW+JPEG in database.
BackingPhotoRow bpr = new BackingPhotoRow();
bpr.file_format = PhotoFileFormat.JFIF;
bpr.filepath = job.ready.photo_import_params.final_associated_file.get_path();
debug("Associating %s with sibbling %s", ((Photo) source).get_file().get_path(),
bpr.filepath);
try {
((Photo) source).add_backing_photo_for_development(RawDeveloper.CAMERA, bpr);
} catch (Error e) {
warning("Unable to associate JPEG with RAW. File: %s Error: %s",
bpr.filepath, e.message);
}
}
}
if (job.ready.batch_result.result != ImportResult.SUCCESS) {
......@@ -1219,12 +1248,17 @@ private class FileToPrepare {
public BatchImportJob job;
public File? file;
public bool copy_to_library;
public FileToPrepare? associated = null;
public FileToPrepare(BatchImportJob job, File? file = null, bool copy_to_library = true) {
this.job = job;
this.file = file;
this.copy_to_library = copy_to_library;
}
public void set_associated(FileToPrepare? a) {
associated = a;
}
}
private class WorkSniffer : BackgroundImportJob {
......@@ -1265,6 +1299,65 @@ private class WorkSniffer : BackgroundImportJob {
if (is_cancelled())
break;
}
// Time to handle RAW+JPEG pairs!
// Now we build a new list of all the files (but not folders) we're
// importing and sort it by filename.
Gee.List<FileToPrepare> sorted = new Gee.ArrayList<FileToPrepare>();
foreach (FileToPrepare ftp in files_to_prepare) {
if (!ftp.job.is_directory())
sorted.add(ftp);
}
sorted.sort((a, b) => {
FileToPrepare file_a = (FileToPrepare) a;
FileToPrepare file_b = (FileToPrepare) b;
string sa = file_a.job.get_path() + "/" + file_a.job.get_basename();
string sb = file_b.job.get_path() + "/" + file_b.job.get_basename();
return utf8_cs_compare(sa, sb);
});
// For each file, check if the current file is RAW. If so, check the previous
// and next files to see if they're a "plus jpeg."
for (int i = 0; i < sorted.size; ++i) {
string name, ext;
FileToPrepare ftp = sorted.get(i);
disassemble_filename(ftp.job.get_basename(), out name, out ext);
debug("examining: %s", ftp.job.get_path());
if (is_string_empty(ext))
continue;
if (RawFileFormatProperties.get_instance().is_recognized_extension(ext)) {
// Got a raw file. See if it has a pair. If a pair is found, remove it
// from the list and link it to the RAW file.
if (i > 0 && is_paired(ftp, sorted.get(i - 1))) {
FileToPrepare associated_file = sorted.get(i - 1);
files_to_prepare.remove(associated_file);
ftp.set_associated(associated_file);
} else if (i < sorted.size - 1 && is_paired(ftp, sorted.get(i + 1))) {
FileToPrepare associated_file = sorted.get(i + 1);
files_to_prepare.remove(associated_file);
ftp.set_associated(associated_file);
}
}
}
}
// Check if a file is paired. The raw file must be a raw photo. A file
// is "paired" if it has the same basename as the raw file, is in the same
// directory, and is a JPEG.
private bool is_paired(FileToPrepare raw, FileToPrepare maybe_paired) {
if (raw.job.get_path() != maybe_paired.job.get_path())
return false;
string name, ext, test_name, test_ext;
disassemble_filename(maybe_paired.job.get_basename(), out test_name, out test_ext);
if (!JfifFileFormatProperties.get_instance().is_recognized_extension(test_ext))
return false;
disassemble_filename(raw.job.get_basename(), out name, out ext);
return name == test_name;
}
private void sniff_job(BatchImportJob job) throws Error {
......@@ -1352,6 +1445,7 @@ private class PreparedFile {
public BatchImportJob job;
public ImportResult result;
public File file;
public File? associated_file = null;
public string source_id;
public string dest_id;
public bool copy_to_library;
......@@ -1362,12 +1456,13 @@ private class PreparedFile {
public uint64 filesize;
public bool is_video;
public PreparedFile(BatchImportJob job, File file, string source_id, string dest_id,
public PreparedFile(BatchImportJob job, File file, File? associated_file, string source_id, string dest_id,
bool copy_to_library, string? exif_md5, string? thumbnail_md5, string? full_md5,
PhotoFileFormat file_format, uint64 filesize, bool is_video = false) {
this.job = job;
this.result = ImportResult.SUCCESS;
this.file = file;
this.associated_file = associated_file;
this.source_id = source_id;
this.dest_id = dest_id;
this.copy_to_library = copy_to_library;
......@@ -1436,27 +1531,21 @@ private class PrepareFilesJob : BackgroundImportJob {
BatchImportJob job = file_to_prepare.job;
File? file = file_to_prepare.file;
File? associated = file_to_prepare.associated != null ? file_to_prepare.associated.file : null;
bool copy_to_library = file_to_prepare.copy_to_library;
// if no file seen, then it needs to be offered/generated by the BatchImportJob
if (file == null) {
try {
if (!job.prepare(out file, out copy_to_library)) {
report_failure(job, null, job.get_source_identifier(),
job.get_dest_identifier(), ImportResult.FILE_ERROR);
continue;
}
} catch (Error err) {
report_error(job, null, job.get_source_identifier(), job.get_dest_identifier(),
err, ImportResult.FILE_ERROR);
if (!create_file(job, out file, out copy_to_library))
continue;
}
}
if (associated == null && file_to_prepare.associated != null) {
create_file(file_to_prepare.associated.job, out associated, out copy_to_library);
}
PreparedFile prepared_file;
result = prepare_file(job, file, copy_to_library, out prepared_file);
result = prepare_file(job, file, associated, copy_to_library, out prepared_file);
if (result == ImportResult.SUCCESS) {
prepared_files++;
list.add(prepared_file);
......@@ -1495,8 +1584,26 @@ private class PrepareFilesJob : BackgroundImportJob {
}
}
private ImportResult prepare_file(BatchImportJob job, File file, bool copy_to_library,
out PreparedFile prepared_file) {
// If there's no file, call this function to get it from the batch import job.
private bool create_file(BatchImportJob job, out File file, out bool copy_to_library) {
try {
if (!job.prepare(out file, out copy_to_library)) {
report_failure(job, null, job.get_source_identifier(),
job.get_dest_identifier(), ImportResult.FILE_ERROR);
return false;
}
} catch (Error err) {
report_error(job, null, job.get_source_identifier(), job.get_dest_identifier(),
err, ImportResult.FILE_ERROR);
return false;
}
return true;
}
private ImportResult prepare_file(BatchImportJob job, File file, File? associated_file,
bool copy_to_library, out PreparedFile prepared_file) {
bool is_video = VideoReader.is_supported_video_file(file);
if ((!is_video) && (!Photo.is_file_image(file)))
......@@ -1577,7 +1684,7 @@ private class PrepareFilesJob : BackgroundImportJob {
bool is_in_library_dir = file.has_prefix(library_dir);
// notify the BatchImport this is ready to go
prepared_file = new PreparedFile(job, file, job.get_source_identifier(),
prepared_file = new PreparedFile(job, file, associated_file, job.get_source_identifier(),
job.get_dest_identifier(), copy_to_library && !is_in_library_dir, exif_only_md5,
thumbnail_md5, full_md5, file_format, filesize, is_video);
......@@ -1656,8 +1763,11 @@ private class PreparedFileImportJob : BackgroundJob {
not_ready = null;
File final_file = prepared_file.file;
File? final_associated_file = prepared_file.associated_file;
if (prepared_file.copy_to_library) {
try {
// Copy file.
final_file = LibraryFiles.duplicate(prepared_file.file, null, true);
if (final_file == null) {
failed = new BatchImportResult(prepared_file.job, prepared_file.file,
......@@ -1665,6 +1775,11 @@ private class PreparedFileImportJob : BackgroundJob {
return;
}
// Copy associated file.
if (final_associated_file != null) {
final_associated_file = LibraryFiles.duplicate(prepared_file.associated_file, null, true);
}
} catch (Error err) {
string filename = final_file != null ? final_file.get_path() : prepared_file.source_id;
failed = new BatchImportResult.from_error(prepared_file.job, prepared_file.file,
......@@ -1686,7 +1801,7 @@ private class PreparedFileImportJob : BackgroundJob {
result = VideoReader.prepare_for_import(video_import_params);
} else {
photo_import_params = new PhotoImportParams(final_file, import_id,
photo_import_params = new PhotoImportParams(final_file, final_associated_file, import_id,
PhotoFileSniffer.Options.GET_ALL, prepared_file.exif_md5,
prepared_file.thumbnail_md5, prepared_file.full_md5, new Thumbnails());
......
......@@ -633,7 +633,7 @@ public abstract class CollectionPage : MediaPage {
try {
AppWindow.get_instance().set_busy_cursor();
photo.open_master_with_external_editor();
photo.open_with_raw_external_editor();
AppWindow.get_instance().set_normal_cursor();
} catch (Error err) {
AppWindow.get_instance().set_normal_cursor();
......
......@@ -1084,6 +1084,56 @@ public class SetRatingCommand : MultipleDataSourceCommand {
}
}
public class SetRawDeveloperCommand : MultipleDataSourceCommand {
private Gee.HashMap<Photo, RawDeveloper> last_developer_map;
private RawDeveloper new_developer;
public SetRawDeveloperCommand(Gee.Iterable<DataView> iter, RawDeveloper developer) {
base (iter, _("Setting RAW developer"), _("Restoring previous RAW developer"),
developer.get_label(), "");
new_developer = developer;
save_source_states(iter);
}
private void save_source_states(Gee.Iterable<DataView> iter) {
last_developer_map = new Gee.HashMap<Photo, RawDeveloper>();
foreach (DataView view in iter) {
Photo? photo = view.get_source() as Photo;
if (is_raw_photo(photo))
last_developer_map[photo] = photo.get_raw_developer();
}
}
public override void execute() {
base.execute();
}
public override void undo() {
base.undo();
}
public override void execute_on_source(DataSource source) {
Photo? photo = source as Photo;
if (is_raw_photo(photo)) {
if (new_developer == RawDeveloper.CAMERA && !photo.is_raw_developer_available(RawDeveloper.CAMERA))
photo.set_raw_developer(RawDeveloper.EMBEDDED);
else
photo.set_raw_developer(new_developer);
}
}
public override void undo_on_source(DataSource source) {
Photo? photo = source as Photo;
if (is_raw_photo(photo))
photo.set_raw_developer(last_developer_map[photo]);
}
private bool is_raw_photo(Photo? photo) {
return photo != null && photo.get_master_file_format() == PhotoFileFormat.RAW;
}
}
public class AdjustDateTimePhotoCommand : SingleDataSourceCommand {
private Dateable dateable;
private int64 time_shift;
......
......@@ -17,10 +17,10 @@ public class BackingFileState {
this.md5 = md5;
}
public BackingFileState.from_photo_state(BackingPhotoState photo_state, string? md5) {
this.filepath = photo_state.filepath;
this.filesize = photo_state.filesize;
this.modification_time = photo_state.timestamp;
public BackingFileState.from_photo_row(BackingPhotoRow photo_row, string? md5) {
this.filepath = photo_row.filepath;
this.filesize = photo_row.filesize;
this.modification_time = photo_row.timestamp;
this.md5 = md5;
}
......
......@@ -415,6 +415,22 @@ public abstract class MediaPage : CheckerboardPage {
play.label = _("_Play Video");
play.tooltip = _("Open the selected videos in the system video player");
actions += play;
Gtk.ActionEntry raw_developer = { "RawDeveloper", null, TRANSLATABLE, null, null, null };
raw_developer.label = _("Developer");
actions += raw_developer;
// RAW developers.
Gtk.ActionEntry dev_shotwell = { "RawDeveloperShotwell", null, TRANSLATABLE, null, TRANSLATABLE,
on_raw_developer_shotwell };
dev_shotwell.label = _("Shotwell");
actions += dev_shotwell;
Gtk.ActionEntry dev_camera = { "RawDeveloperCamera", null, TRANSLATABLE, null, TRANSLATABLE,
on_raw_developer_camera };
dev_camera.label = _("Camera");
actions += dev_camera;
return actions;
}
......@@ -439,7 +455,7 @@ public abstract class MediaPage : CheckerboardPage {
tags.label = _("Ta_gs");
tags.tooltip = _("Display each photo's tags");
toggle_actions += tags;
return toggle_actions;
}
......@@ -448,6 +464,7 @@ public abstract class MediaPage : CheckerboardPage {
int sort_by;
get_config_photos_sort(out sort_order, out sort_by);
// Sort criteria.
Gtk.RadioActionEntry[] sort_crit_actions = new Gtk.RadioActionEntry[0];
Gtk.RadioActionEntry by_title = { "SortByTitle", null, TRANSLATABLE, null, TRANSLATABLE,
......@@ -470,6 +487,7 @@ public abstract class MediaPage : CheckerboardPage {
action_group.add_radio_actions(sort_crit_actions, sort_by, on_sort_changed);
// Sort order.
Gtk.RadioActionEntry[] sort_order_actions = new Gtk.RadioActionEntry[0];
Gtk.RadioActionEntry ascending = { "SortAscending", Gtk.Stock.SORT_ASCENDING,
......@@ -506,6 +524,8 @@ public abstract class MediaPage : CheckerboardPage {
set_action_sensitive("Rate", selected_count > 0);
update_rating_sensitivities();
update_development_menu_item_sensitivity();
set_action_sensitive("PlayVideo", selected_count == 1
&& get_view().get_selected_source_at(0) is Video);
......@@ -536,6 +556,44 @@ public abstract class MediaPage : CheckerboardPage {
set_action_sensitive("DecreaseRating", can_decrease_selected_rating());
}
private void update_development_menu_item_sensitivity() {
if (get_view().get_selected().size == 0) {
set_action_sensitive("RawDeveloper", false);
return;
}
// Collect some stats about what's selected.
bool avail_shotwell = false; // True if Shotwell developer is available.
bool avail_camera = false; // True if camera developer is available.
bool is_raw = false; // True if any RAW photos are selected
foreach (DataView view in get_view().get_selected()) {
Photo? photo = ((Thumbnail) view).get_media_source() as Photo;
if (photo != null && photo.get_master_file_format() == PhotoFileFormat.RAW) {
is_raw = true;
if (!avail_shotwell && photo.is_raw_developer_available(RawDeveloper.SHOTWELL))
avail_shotwell = true;
if (!avail_camera && (photo.is_raw_developer_available(RawDeveloper.CAMERA) ||
photo.is_raw_developer_available(RawDeveloper.EMBEDDED)))
avail_camera = true;
if (avail_shotwell && avail_camera)
break; // optimization: break out of loop when all options available
}
}
// Enable/disable menu.
set_action_sensitive("RawDeveloper", is_raw);
if (is_raw) {
// Set which developers are available.
set_action_sensitive("RawDeveloperShotwell", avail_shotwell);
set_action_sensitive("RawDeveloperCamera", avail_camera);
}
}
private void update_flag_action(int selected_count) {
set_action_sensitive("Flag", selected_count > 0);
......@@ -983,6 +1041,23 @@ public abstract class MediaPage : CheckerboardPage {
set_config_photos_sort(get_sort_order() == SORT_ORDER_ASCENDING, get_sort_criteria());
}
public void on_raw_developer_shotwell(Gtk.Action action) {
developer_changed(RawDeveloper.SHOTWELL);
}
public void on_raw_developer_camera(Gtk.Action action) {
developer_changed(RawDeveloper.CAMERA);
}
protected virtual void developer_changed(RawDeveloper rd) {
if (get_view().get_selected_count() == 0)
return;
SetRawDeveloperCommand command = new SetRawDeveloperCommand(get_view().get_selected(), rd);
get_command_manager().execute(command);
update_development_menu_item_sensitivity();
}
protected override void set_display_titles(bool display) {
base.set_display_titles(display);
......
/* Copyright 2010-2011 Yorba Foundation
*
* 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 MimicManager : Object {
// If this changes in the future, the stored files may need to be updated, as the wrong
// adapter may be used.
private const PhotoFileFormat MIMIC_FILE_FORMAT = PhotoFileFormat.JFIF;
private class VerifyJob : BackgroundJob {
public Photo photo;
public PhotoFileWriter writer;
public Error? err = null;
public VerifyJob(MimicManager manager, Photo photo, PhotoFileWriter writer) {
base (manager, manager.on_verify_completed, new Cancellable());
this.photo = photo;
this.writer = writer;
}
public override void execute() {
if (writer.file_exists())
return;
try {
writer.write(photo.get_master_pixbuf(Scaling.for_original(), false), Jpeg.Quality.HIGH);
} catch (Error err) {
this.err = err;
}
}
}
private class DeleteJob : BackgroundJob {
public File file;
public DeleteJob(MimicManager manager, File file) {
base (manager, manager.on_delete_completed, new Cancellable());
set_completion_semaphore(new Semaphore());
this.file = file;
}
public override void execute() {
try {
file.delete(null);
} catch (Error err) {
// ignored
}
}
}
private SourceCollection sources;
private File impersonators_dir;
private Workers workers = new Workers(Workers.thread_per_cpu_minus_one(), false);
private Gee.HashMap<Photo, VerifyJob> verify_jobs = new Gee.HashMap<Photo, VerifyJob>();
private Gee.HashSet<DeleteJob> delete_jobs = new Gee.HashSet<DeleteJob>();
private int pause_count = 0;
private Gee.ArrayList<VerifyJob> paused_list = new Gee.ArrayList<VerifyJob>();
private int completed_jobs = 0;
private int total_jobs = 0;
public signal void progress(int completed, int total);
public MimicManager(SourceCollection sources, File impersonators_dir) {
this.sources = sources;
this.impersonators_dir = impersonators_dir;
on_photos_added(sources.get_all());
sources.items_added.connect(on_photos_added);
sources.item_destroyed.connect(on_photo_destroyed);
sources.unlinked_destroyed.connect(on_photo_destroyed);
Application.get_instance().exiting.connect(on_application_exiting);
}
~MimicManager() {
sources.items_added.disconnect(on_photos_added);
sources.item_destroyed.disconnect(on_photo_destroyed);
sources.unlinked_destroyed.disconnect(on_photo_destroyed);
Application.get_instance().exiting.disconnect(on_application_exiting);
}
public void pause() {
if (pause_count++ == 0)
progress(0, 0);
}
public void resume() {
if (--pause_count > 0)
return;
pause_count = 0;
foreach (VerifyJob job in paused_list)
enqueue_verify_job(job);
paused_list.clear();
}
private void on_application_exiting(bool panicked) {
foreach (VerifyJob job in verify_jobs.values)
job.cancel();
// wait out all the delete jobs, no way to restart these properly because of the way that
// IDs may be reused after destruction
foreach (DeleteJob job in delete_jobs)
job.wait_for_completion();
}
private void enqueue_verify_job(VerifyJob job) {
total_jobs++;
verify_jobs.set(job.photo, job);
workers.enqueue(job);
}
private void on_photos_added(Gee.Iterable<DataObject> added) {
foreach (DataObject object in added) {
Photo photo = (Photo) object;
if (!photo.would_use_mimic())
continue;
PhotoFileWriter writer;
try {
writer = MIMIC_FILE_FORMAT.create_writer(generate_impersonator_filepath(photo));
} catch (PhotoFormatError err) {
error("Unable to create PhotoFileWriter for impersonator: %s", err.message);
}
VerifyJob job = new VerifyJob(this, photo, writer);
if (pause_count > 0) {
paused_list.add(job);
continue;
}
enqueue_verify_job(job);
}
}
private void on_photo_destroyed(DataSource source) {
// remove any outstanding VerifyJob
VerifyJob? outstanding = verify_jobs.get((Photo) source);
if (outstanding != null) {
verify_jobs.unset((Photo) source);
outstanding.cancel();
}
DeleteJob job = new DeleteJob(this, generate_impersonator_file((Photo) source));
total_jobs++;
delete_jobs.add(job);
workers.enqueue(job);
}
private void on_verify_completed(BackgroundJob background_job) {
VerifyJob job = (VerifyJob) background_job;
bool removed = verify_jobs.unset(job.photo);
assert(removed);
report_completed_job();
if (job.err != null) {
critical("Unable to generate impersonator for %s: %s", job.photo.to_string(),
job.err.message);
return;
}
job.photo.set_mimic_reader(job.writer.create_reader());
}
private void on_delete_completed(BackgroundJob background_job) {
bool removed = delete_jobs.remove((DeleteJob) background_job);
assert(removed);
report_completed_job();
}
private void report_completed_job() {
if (++completed_jobs >= total_jobs) {
completed_jobs = 0;
total_jobs = 0;
}
progress(completed_jobs, total_jobs);
}
private string generate_impersonator_filepath(Photo photo) {
return generate_impersonator_file(photo).get_path();
}
private File generate_impersonator_file(Photo photo) {
return impersonators_dir.get_child("mimic%016llx.jpg".printf(photo.get_photo_id().id));
}
}
......@@ -216,6 +216,10 @@ public abstract class Page : Gtk.ScrolledWindow {
return menubar;
}
public virtual unowned Gtk.Widget get_page_ui_widget(string path) {
return ui.get_widget(path);
}
public virtual Gtk.Toolbar get_toolbar() {
return toolbar;
}
......
This diff is collapsed.
......@@ -310,7 +310,7 @@ private class PhotoMonitor : MediaMonitor {
if (info == null)