1 | // Aseprite |
2 | // Copyright (C) 2019-2022 Igara Studio S.A. |
3 | // Copyright (C) 2001-2018 David Capello |
4 | // |
5 | // This program is distributed under the terms of |
6 | // the End-User License Agreement for Aseprite. |
7 | |
8 | #ifdef HAVE_CONFIG_H |
9 | #include "config.h" |
10 | #endif |
11 | |
12 | #include "app/commands/cmd_save_file.h" |
13 | |
14 | #include "app/app.h" |
15 | #include "app/commands/command.h" |
16 | #include "app/commands/commands.h" |
17 | #include "app/commands/params.h" |
18 | #include "app/console.h" |
19 | #include "app/context_access.h" |
20 | #include "app/doc.h" |
21 | #include "app/doc_undo.h" |
22 | #include "app/file/file.h" |
23 | #include "app/file/gif_format.h" |
24 | #include "app/file/png_format.h" |
25 | #include "app/file_selector.h" |
26 | #include "app/i18n/strings.h" |
27 | #include "app/job.h" |
28 | #include "app/modules/gui.h" |
29 | #include "app/pref/preferences.h" |
30 | #include "app/recent_files.h" |
31 | #include "app/restore_visible_layers.h" |
32 | #include "app/ui/export_file_window.h" |
33 | #include "app/ui/layer_frame_comboboxes.h" |
34 | #include "app/ui/optional_alert.h" |
35 | #include "app/ui/status_bar.h" |
36 | #include "base/convert_to.h" |
37 | #include "base/fs.h" |
38 | #include "base/scoped_value.h" |
39 | #include "base/thread.h" |
40 | #include "doc/mask.h" |
41 | #include "doc/sprite.h" |
42 | #include "doc/tag.h" |
43 | #include "fmt/format.h" |
44 | #include "ui/ui.h" |
45 | #include "undo/undo_state.h" |
46 | |
47 | namespace app { |
48 | |
49 | class SaveFileJob : public Job, public IFileOpProgress { |
50 | public: |
51 | SaveFileJob(FileOp* fop) |
52 | : Job(Strings::save_file_saving().c_str()) |
53 | , m_fop(fop) |
54 | { |
55 | } |
56 | |
57 | void showProgressWindow() { |
58 | startJob(); |
59 | |
60 | if (isCanceled()) { |
61 | m_fop->stop(); |
62 | } |
63 | |
64 | waitJob(); |
65 | } |
66 | |
67 | private: |
68 | |
69 | // Thread to do the hard work: save the file to the disk. |
70 | virtual void onJob() override { |
71 | try { |
72 | m_fop->operate(this); |
73 | } |
74 | catch (const std::exception& e) { |
75 | m_fop->setError("Error saving file:\n%s" , e.what()); |
76 | } |
77 | m_fop->done(); |
78 | } |
79 | |
80 | virtual void ackFileOpProgress(double progress) override { |
81 | jobProgress(progress); |
82 | } |
83 | |
84 | FileOp* m_fop; |
85 | }; |
86 | |
87 | ////////////////////////////////////////////////////////////////////// |
88 | |
89 | SaveFileBaseCommand::SaveFileBaseCommand(const char* id, CommandFlags flags) |
90 | : CommandWithNewParams<SaveFileParams>(id, flags) |
91 | { |
92 | } |
93 | |
94 | void SaveFileBaseCommand::onLoadParams(const Params& params) |
95 | { |
96 | CommandWithNewParams<SaveFileParams>::onLoadParams(params); |
97 | |
98 | if (this->params().fromFrame.isSet() || |
99 | this->params().toFrame.isSet()) { |
100 | doc::frame_t fromFrame = this->params().fromFrame(); |
101 | doc::frame_t toFrame = this->params().toFrame(); |
102 | m_selFrames.insert(fromFrame, toFrame); |
103 | m_adjustFramesByTag = true; |
104 | } |
105 | else { |
106 | m_selFrames.clear(); |
107 | m_adjustFramesByTag = false; |
108 | } |
109 | } |
110 | |
111 | // Returns true if there is a current sprite to save. |
112 | // [main thread] |
113 | bool SaveFileBaseCommand::onEnabled(Context* context) |
114 | { |
115 | return context->checkFlags(ContextFlags::ActiveDocumentIsWritable); |
116 | } |
117 | |
118 | std::string SaveFileBaseCommand::saveAsDialog( |
119 | Context* context, |
120 | const std::string& dlgTitle, |
121 | const std::string& initialFilename, |
122 | const MarkAsSaved markAsSaved, |
123 | const SaveInBackground saveInBackground, |
124 | const std::string& forbiddenFilename) |
125 | { |
126 | Doc* document = context->activeDocument(); |
127 | |
128 | // Before we change the document filename to the copy, we save its |
129 | // preferences so in a future export operation the values persist, |
130 | // and we can re-export the original document with the same |
131 | // preferences. |
132 | Preferences::instance().save(); |
133 | |
134 | std::string filename = params().filename(); |
135 | if (filename.empty() || params().ui()) { |
136 | base::paths exts = get_writable_extensions(); |
137 | filename = initialFilename; |
138 | |
139 | #ifdef ENABLE_UI |
140 | if (context->isUIAvailable()) { |
141 | again:; |
142 | base::paths newfilename; |
143 | if (!params().ui() || |
144 | !app::show_file_selector( |
145 | dlgTitle, filename, exts, |
146 | FileSelectorType::Save, |
147 | newfilename)) { |
148 | return std::string(); |
149 | } |
150 | |
151 | filename = newfilename.front(); |
152 | if (!forbiddenFilename.empty() && |
153 | base::normalize_path(forbiddenFilename) == |
154 | base::normalize_path(filename)) { |
155 | ui::Alert::show(Strings::alerts_cannot_file_overwrite_on_export()); |
156 | goto again; |
157 | } |
158 | } |
159 | #endif // ENABLE_UI |
160 | } |
161 | |
162 | if (filename.empty()) |
163 | return std::string(); |
164 | |
165 | if (saveInBackground == SaveInBackground::On) { |
166 | saveDocumentInBackground( |
167 | context, document, |
168 | filename, markAsSaved); |
169 | |
170 | // Reset the "saveCopy" document preferences of the new document |
171 | // (here "document" contains the new filename), because these |
172 | // preferences make sense only for the original document that was |
173 | // exported/copied, not for the new one. |
174 | // |
175 | // The new document (the copy) must have the default preferences |
176 | // just in case the user want to export it to other file (so a |
177 | // proper default export filename is calculated). This scenario is |
178 | // described here: |
179 | // |
180 | // https://github.com/aseprite/aseprite/issues/1964 |
181 | // |
182 | auto& docPref = Preferences::instance().document(document); |
183 | docPref.saveCopy.clearSection(); |
184 | } |
185 | |
186 | return filename; |
187 | } |
188 | |
189 | void SaveFileBaseCommand::saveDocumentInBackground( |
190 | const Context* context, |
191 | Doc* document, |
192 | const std::string& filename, |
193 | const MarkAsSaved markAsSaved, |
194 | const ResizeOnTheFly resizeOnTheFly, |
195 | const gfx::PointF& scale) |
196 | { |
197 | if (params().aniDir.isSet()) { |
198 | switch (params().aniDir()) { |
199 | case AniDir::REVERSE: |
200 | m_selFrames = m_selFrames.makeReverse(); |
201 | break; |
202 | case AniDir::PING_PONG: |
203 | m_selFrames = m_selFrames.makePingPong(); |
204 | break; |
205 | } |
206 | } |
207 | |
208 | gfx::Rect bounds; |
209 | if (params().bounds.isSet()) |
210 | bounds = params().bounds(); |
211 | |
212 | FileOpROI roi(document, bounds, |
213 | params().slice(), params().tag(), |
214 | m_selFrames, m_adjustFramesByTag); |
215 | |
216 | std::unique_ptr<FileOp> fop( |
217 | FileOp::createSaveDocumentOperation( |
218 | context, |
219 | roi, |
220 | filename, |
221 | params().filenameFormat(), |
222 | params().ignoreEmpty())); |
223 | if (!fop) |
224 | return; |
225 | |
226 | if (resizeOnTheFly == ResizeOnTheFly::On) |
227 | fop->setOnTheFlyScale(scale); |
228 | |
229 | SaveFileJob job(fop.get()); |
230 | job.showProgressWindow(); |
231 | |
232 | if (fop->hasError()) { |
233 | Console console; |
234 | console.printf(fop->error().c_str()); |
235 | |
236 | // We don't know if the file was saved correctly or not. So mark |
237 | // it as it should be saved again. |
238 | document->impossibleToBackToSavedState(); |
239 | } |
240 | // If the job was cancelled, mark the document as modified. |
241 | else if (fop->isStop()) { |
242 | document->impossibleToBackToSavedState(); |
243 | } |
244 | else { |
245 | if (context->isUIAvailable() && params().ui()) |
246 | App::instance()->recentFiles()->addRecentFile(filename); |
247 | |
248 | if (markAsSaved == MarkAsSaved::On) { |
249 | document->markAsSaved(); |
250 | document->setFilename(filename); |
251 | document->incrementVersion(); |
252 | } |
253 | |
254 | #ifdef ENABLE_UI |
255 | if (context->isUIAvailable() && params().ui()) { |
256 | StatusBar::instance()->setStatusText( |
257 | 2000, fmt::format(Strings::save_file_saved(), |
258 | base::get_file_name(filename))); |
259 | } |
260 | #endif |
261 | } |
262 | } |
263 | |
264 | ////////////////////////////////////////////////////////////////////// |
265 | |
266 | class SaveFileCommand : public SaveFileBaseCommand { |
267 | public: |
268 | SaveFileCommand(); |
269 | |
270 | protected: |
271 | void onExecute(Context* context) override; |
272 | }; |
273 | |
274 | SaveFileCommand::SaveFileCommand() |
275 | : SaveFileBaseCommand(CommandId::SaveFile(), CmdRecordableFlag) |
276 | { |
277 | } |
278 | |
279 | // Saves the active document in a file. |
280 | // [main thread] |
281 | void SaveFileCommand::onExecute(Context* context) |
282 | { |
283 | Doc* document = context->activeDocument(); |
284 | |
285 | // If the document is associated to a file in the file-system, we can |
286 | // save it directly without user interaction. |
287 | if (document->isAssociatedToFile()) { |
288 | const ContextReader reader(context); |
289 | const Doc* documentReader = reader.document(); |
290 | |
291 | saveDocumentInBackground( |
292 | context, document, |
293 | (params().filename.isSet() ? params().filename(): |
294 | documentReader->filename()), |
295 | MarkAsSaved::On); |
296 | } |
297 | // If the document isn't associated to a file, we must to show the |
298 | // save-as dialog to the user to select for first time the file-name |
299 | // for this document. |
300 | else { |
301 | saveAsDialog(context, Strings::save_file_title(), |
302 | (params().filename.isSet() ? params().filename(): |
303 | document->filename()), |
304 | MarkAsSaved::On); |
305 | } |
306 | } |
307 | |
308 | class SaveFileAsCommand : public SaveFileBaseCommand { |
309 | public: |
310 | SaveFileAsCommand(); |
311 | |
312 | protected: |
313 | void onExecute(Context* context) override; |
314 | }; |
315 | |
316 | SaveFileAsCommand::SaveFileAsCommand() |
317 | : SaveFileBaseCommand(CommandId::SaveFileAs(), CmdRecordableFlag) |
318 | { |
319 | } |
320 | |
321 | void SaveFileAsCommand::onExecute(Context* context) |
322 | { |
323 | Doc* document = context->activeDocument(); |
324 | saveAsDialog(context, Strings::save_file_save_as(), |
325 | (params().filename.isSet() ? params().filename(): |
326 | document->filename()), |
327 | MarkAsSaved::On); |
328 | } |
329 | |
330 | class SaveFileCopyAsCommand : public SaveFileBaseCommand { |
331 | public: |
332 | SaveFileCopyAsCommand(); |
333 | |
334 | protected: |
335 | void onExecute(Context* context) override; |
336 | |
337 | private: |
338 | void moveToUndoState(Doc* doc, |
339 | const undo::UndoState* state); |
340 | }; |
341 | |
342 | SaveFileCopyAsCommand::SaveFileCopyAsCommand() |
343 | : SaveFileBaseCommand(CommandId::SaveFileCopyAs(), CmdRecordableFlag) |
344 | { |
345 | } |
346 | |
347 | void SaveFileCopyAsCommand::onExecute(Context* context) |
348 | { |
349 | Doc* doc = context->activeDocument(); |
350 | std::string outputFilename = params().filename(); |
351 | std::string layers = kAllLayers; |
352 | int layersIndex = -1; |
353 | std::string frames = kAllFrames; |
354 | bool applyPixelRatio = false; |
355 | double scale = params().scale(); |
356 | gfx::Rect bounds = params().bounds(); |
357 | doc::AniDir aniDirValue = params().aniDir(); |
358 | bool = false; |
359 | |
360 | #if ENABLE_UI |
361 | if (params().ui() && context->isUIAvailable()) { |
362 | ExportFileWindow win(doc); |
363 | bool askOverwrite = true; |
364 | |
365 | win.SelectOutputFile.connect( |
366 | [this, &win, &askOverwrite, context, doc]() -> std::string { |
367 | std::string result = |
368 | saveAsDialog( |
369 | context, Strings::save_file_export(), |
370 | win.outputFilenameValue(), |
371 | MarkAsSaved::Off, |
372 | SaveInBackground::Off, |
373 | (doc->isAssociatedToFile() ? doc->filename(): |
374 | std::string())); |
375 | if (!result.empty()) |
376 | askOverwrite = false; // Already asked in the file selector dialog |
377 | |
378 | return result; |
379 | }); |
380 | |
381 | if (params().filename.isSet()) { |
382 | std::string outputPath = base::get_file_path(outputFilename); |
383 | if (outputPath.empty()) { |
384 | outputPath = base::get_file_path(doc->filename()); |
385 | outputFilename = base::join_path(outputPath, outputFilename); |
386 | } |
387 | win.setOutputFilename(outputFilename); |
388 | } |
389 | |
390 | if (params().scale.isSet()) win.setResizeScale(scale); |
391 | if (params().aniDir.isSet()) win.setAniDir(aniDirValue); |
392 | |
393 | if (params().slice.isSet()) win.setArea(params().slice()); |
394 | else if (params().bounds.isSet() && |
395 | doc->isMaskVisible() && |
396 | doc->mask()->bounds() == params().bounds()) { |
397 | win.setArea(kSelectedCanvas); |
398 | } |
399 | |
400 | win.remapWindow(); |
401 | load_window_pos(&win, "ExportFile" ); |
402 | again:; |
403 | const bool result = win.show(); |
404 | save_window_pos(&win, "ExportFile" ); |
405 | if (!result) |
406 | return; |
407 | |
408 | outputFilename = win.outputFilenameValue(); |
409 | |
410 | if (askOverwrite && |
411 | base::is_file(outputFilename)) { |
412 | int ret = OptionalAlert::show( |
413 | Preferences::instance().exportFile.showOverwriteFilesAlert, |
414 | 1, // Yes is the default option when the alert dialog is disabled |
415 | fmt::format(Strings::alerts_overwrite_files_on_export(), |
416 | outputFilename)); |
417 | if (ret != 1) |
418 | goto again; |
419 | } |
420 | |
421 | // Save the preferences used to export the file, so if we open the |
422 | // window again, we will have the same options. |
423 | win.savePref(); |
424 | |
425 | layers = win.layersValue(); |
426 | layersIndex = win.layersIndex(); |
427 | frames = win.framesValue(); |
428 | scale = win.resizeValue(); |
429 | params().slice(win.areaValue()); // Set slice |
430 | if (win.areaValue() == kSelectedCanvas && doc->isMaskVisible()) |
431 | bounds = doc->mask()->bounds(); |
432 | applyPixelRatio = win.applyPixelRatio(); |
433 | aniDirValue = win.aniDirValue(); |
434 | isForTwitter = win.isForTwitter(); |
435 | } |
436 | #endif |
437 | |
438 | gfx::PointF scaleXY(scale, scale); |
439 | |
440 | // Pixel ratio |
441 | if (applyPixelRatio) { |
442 | doc::PixelRatio pr = doc->sprite()->pixelRatio(); |
443 | scaleXY.x *= pr.w; |
444 | scaleXY.y *= pr.h; |
445 | } |
446 | |
447 | // First of all we'll try to use the "on the fly" scaling, to avoid |
448 | // using a resize command to apply the export scale. |
449 | const undo::UndoState* undoState = nullptr; |
450 | bool undoResize = false; |
451 | const bool resizeOnTheFly = FileOp::checkIfFormatSupportResizeOnTheFly(outputFilename); |
452 | if (!resizeOnTheFly && (scaleXY.x != 1.0 || |
453 | scaleXY.y != 1.0)) { |
454 | Command* resizeCmd = Commands::instance()->byId(CommandId::SpriteSize()); |
455 | ASSERT(resizeCmd); |
456 | if (resizeCmd) { |
457 | int width = doc->sprite()->width(); |
458 | int height = doc->sprite()->height(); |
459 | int newWidth = int(double(width) * scaleXY.x); |
460 | int newHeight = int(double(height) * scaleXY.y); |
461 | if (newWidth < 1) newWidth = 1; |
462 | if (newHeight < 1) newHeight = 1; |
463 | if (width != newWidth || height != newHeight) { |
464 | doc->setInhibitBackup(true); |
465 | undoState = doc->undoHistory()->currentState(); |
466 | undoResize = true; |
467 | |
468 | Params params; |
469 | params.set("use-ui" , "false" ); |
470 | params.set("width" , base::convert_to<std::string>(newWidth).c_str()); |
471 | params.set("height" , base::convert_to<std::string>(newHeight).c_str()); |
472 | params.set("resize-method" , "nearest-neighbor" ); // TODO add algorithm in the UI? |
473 | context->executeCommand(resizeCmd, params); |
474 | } |
475 | } |
476 | } |
477 | |
478 | { |
479 | RestoreVisibleLayers layersVisibility; |
480 | if (context->isUIAvailable()) { |
481 | Site site = context->activeSite(); |
482 | |
483 | // Selected layers to export |
484 | calculate_visible_layers(site, |
485 | layers, |
486 | layersIndex, |
487 | layersVisibility); |
488 | |
489 | // m_selFrames is not empty if fromFrame/toFrame parameters are |
490 | // specified. |
491 | if (m_selFrames.empty()) { |
492 | // Selected frames to export |
493 | SelectedFrames selFrames; |
494 | Tag* tag = calculate_selected_frames( |
495 | site, frames, selFrames); |
496 | if (tag) |
497 | params().tag(tag->name()); |
498 | m_selFrames = selFrames; |
499 | } |
500 | m_adjustFramesByTag = false; |
501 | } |
502 | |
503 | // Set other parameters |
504 | params().aniDir(aniDirValue); |
505 | if (!bounds.isEmpty()) |
506 | params().bounds(bounds); |
507 | |
508 | // TODO This should be set as options for the specific encoder |
509 | GifEncoderDurationFix fixGif(isForTwitter); |
510 | PngEncoderOneAlphaPixel fixPng(isForTwitter); |
511 | |
512 | saveDocumentInBackground( |
513 | context, doc, outputFilename, |
514 | MarkAsSaved::Off, |
515 | (resizeOnTheFly ? ResizeOnTheFly::On: |
516 | ResizeOnTheFly::Off), |
517 | scaleXY); |
518 | } |
519 | |
520 | // Undo resize |
521 | if (undoResize && |
522 | undoState != doc->undoHistory()->currentState()) { |
523 | moveToUndoState(doc, undoState); |
524 | doc->setInhibitBackup(false); |
525 | } |
526 | } |
527 | |
528 | void SaveFileCopyAsCommand::moveToUndoState(Doc* doc, |
529 | const undo::UndoState* state) |
530 | { |
531 | try { |
532 | DocWriter writer(doc, 100); |
533 | doc->undoHistory()->moveToState(state); |
534 | doc->generateMaskBoundaries(); |
535 | doc->notifyGeneralUpdate(); |
536 | } |
537 | catch (const std::exception& ex) { |
538 | Console::showException(ex); |
539 | } |
540 | } |
541 | |
542 | Command* CommandFactory::createSaveFileCommand() |
543 | { |
544 | return new SaveFileCommand; |
545 | } |
546 | |
547 | Command* CommandFactory::createSaveFileAsCommand() |
548 | { |
549 | return new SaveFileAsCommand; |
550 | } |
551 | |
552 | Command* CommandFactory::createSaveFileCopyAsCommand() |
553 | { |
554 | return new SaveFileCopyAsCommand; |
555 | } |
556 | |
557 | } // namespace app |
558 | |