diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 253543f..da0fb56 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -563,7 +563,8 @@ Known local toolchain state: invalid export naming inputs, plus export-start license/canvas availability decisions, export menu executor dispatch, file/stem/collection export execution dispatch, failed directory creation preservation, named depth/cube - export dispatch, and malformed export target rejection. + export dispatch, malformed export target rejection, video export dispatch for + animation MP4/timelapse paths, and empty video-path rejection. - `pp_app_core_document_recording_tests` covers recording start/stop, clear, platform recorded-file cleanup, frame-count reset, export progress totals, and oversized progress-total clamping. @@ -612,7 +613,11 @@ Known local toolchain state: layers, animation-frame, depth, and cube-face export calls. It preserves platform-specific export messages, directory creation, picker-selected stem exports, Web prepared-file handoff, and legacy `Canvas` export execution while - retained renderer/document/platform ownership is tracked by `DEBT-0043`. + retained renderer/document/platform ownership is tracked by `DEBT-0043`. It + also bridges timelapse and animation MP4 export picker-selected paths while + preserving desktop worker-thread timelapse behavior, mobile/Web save + callbacks, `App::rec_export`, animation `Canvas::export_anim_mp4`, and + success messages; retained video/export ownership is tracked by `DEBT-0044`. - `src/legacy_history_services.*` is the current app-shell bridge between `pp_app_core` history plans and legacy `ActionManager`; toolbar and `NodeCanvas` hotkeys share it while document-history extraction remains diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 02ae38d..5b1ab4b 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -61,6 +61,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0041 | Open | Modernization | Accepted new-document planning/execution dispatch now consumes pure `pp_app_core` through `App::dialog_newdoc`, `pano_cli plan-new-document`, `NewDocumentServices`, and `src/legacy_document_session_services.*`, but the bridge still mutates legacy app document fields, clears legacy layer UI, resizes legacy `Canvas`, clears legacy history, creates the default layer through legacy UI, mutates unsaved/new-document flags, updates the title, and handles keyboard/dialog cleanup directly | Preserve current New Document dialog behavior while document creation moves toward app/document/UI services | `pp_app_core_document_session_tests`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli simulate-app-session --save-intent save`; `ctest --preset desktop-fast --build-config Debug` | New document creation, overwrite confirmation, canvas/document allocation, default layer creation, history clearing, title updates, dirty/new-document state, and keyboard/dialog cleanup are owned by injected app/document/UI services with `App::dialog_newdoc` acting only as a UI adapter | | DEBT-0042 | Open | Modernization | Accepted Save As and Save Version planning/execution dispatch now consumes pure `pp_app_core` through `App::dialog_save`, `App::dialog_save_ver`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `DocumentFileSaveServices`, `DocumentVersionSaveServices`, and `src/legacy_document_session_services.*`, but the bridge still opens legacy overwrite prompts, calls legacy `Canvas::project_save`, mutates app document name/path/directory fields, marks version saves dirty before saving, updates the title, and handles keyboard/dialog cleanup directly | Preserve current Save As and Save Version behavior while document persistence moves toward app/document/storage/UI services | `pp_app_core_document_session_tests`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli simulate-app-session --save-intent save-as`; `pano_cli simulate-app-session --save-intent save-version`; `ctest --preset desktop-fast --build-config Debug` | Save As overwrite prompting, project-save execution, app document metadata updates, title updates, version-save dirty-state handling, and keyboard/dialog cleanup are owned by injected app/document/storage/UI services with `App::dialog_save` and `App::dialog_save_ver` acting only as UI adapters | | DEBT-0043 | Open | Modernization | Equirectangular, layer, animation-frame, depth, and cube-face export planning/execution dispatch now consumes pure `pp_app_core` through `App::dialog_export`, `App::dialog_export_layers`, `App::dialog_export_anim_frames`, `App::dialog_export_depth`, `App::dialog_export_cube_faces`, `pano_cli plan-export-*`, `DocumentExportServices`, and `src/legacy_document_export_services.*`, but the bridge still calls legacy `Canvas` export methods, owns platform-specific export success messages, creates export directories, handles picker-selected stems, and performs Web prepared-file handoff directly | Preserve current image/collection/depth/cube export behavior while export execution moves toward document/renderer/platform/storage services | `pp_app_core_document_export_tests`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind layers`; `pano_cli plan-export-target --kind collection --work-dir D:/Paint --doc-name demo --suffix _layers`; `pano_cli simulate-document-export`; `ctest --preset desktop-fast --build-config Debug` | File, collection, stem, depth, and cube export execution, export-directory creation, platform success reporting, Web file handoff, and legacy canvas export calls are owned by injected document/renderer/platform/storage services with export dialogs acting only as UI adapters | +| DEBT-0044 | Open | Modernization | Timelapse and animation MP4 export execution dispatch now consumes pure `pp_app_core` through `App::dialog_timelapse_export`, `App::dialog_export_mp4`, `pano_cli plan-export-menu`, `pano_cli plan-export-target --kind name`, `DocumentVideoExportServices`, and `src/legacy_document_export_services.*`, but the bridge still launches legacy desktop timelapse worker threads, calls `App::rec_export`, calls `Canvas::export_anim_mp4`, owns mobile/Web save callbacks, and emits success messages directly | Preserve current MP4/timelapse export behavior while video export moves toward app/document/renderer/video/platform/storage services | `pp_app_core_document_export_tests`; `pano_cli plan-export-menu --kind animation-mp4`; `pano_cli plan-export-menu --kind timelapse`; `pano_cli plan-export-target --kind name --doc-name demo --suffix -animation`; `pano_cli plan-export-target --kind name --doc-name demo --suffix -timelapse`; `ctest --preset desktop-fast --build-config Debug` | Timelapse and animation MP4 execution, desktop worker threading, frame readback/video encoding handoff, mobile/Web save callbacks, and success reporting are owned by injected app/document/renderer/video/platform/storage services with export dialogs acting only as UI adapters | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index b383259..db9943b 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -758,6 +758,12 @@ named export work through app-core document export executors and directory creation, picker-selected stems, Web prepared-file handoff, and legacy `Canvas` export calls while retained execution remains tracked under `DEBT-0043`. +`App::dialog_timelapse_export` and `App::dialog_export_mp4` now route +picker-selected MP4 export paths through the app-core document video export +executor and `src/legacy_document_export_services.*`, preserving mobile/Web +suggested-name save callbacks, desktop worker-thread timelapse export, +`App::rec_export`, animation `Canvas::export_anim_mp4` dispatch, and existing +success messages while retained execution remains tracked under `DEBT-0044`. Implementation tasks: @@ -1330,6 +1336,15 @@ Results: - Focused export CTest coverage passed for `pp_app_core_document_export_tests`, `pano_cli_plan_export_start/menu/target_*`, and `pano_cli_simulate_document_export_smoke` after the live bridge split. +- `PanoPainter`, `pp_app_core_document_export_tests`, and `pano_cli` built + after timelapse and animation MP4 export execution moved behind document + video export services. A clean rebuild was required once because MSVC + reported the known Debug PDB `LNK1103` corruption, after which the app, + export tests, and `pano_cli` targets built cleanly. +- Focused video export CTest coverage passed for + `pp_app_core_document_export_tests`, `pano_cli_plan_export_menu_*`, + `pano_cli_plan_export_target_name_smoke`, and + `pano_cli_simulate_document_export_smoke`. - `pp_app_core_document_recording_tests` passed, covering recording start/stop, clear, platform recorded-file cleanup, frame-count reset, export progress totals, and oversized progress-total clamping. diff --git a/src/app_core/document_export.h b/src/app_core/document_export.h index 961321a..3b19b3c 100644 --- a/src/app_core/document_export.h +++ b/src/app_core/document_export.h @@ -61,6 +61,11 @@ enum class DocumentExportCollectionKind { animation_frames, }; +enum class DocumentVideoExportKind { + animation_mp4, + timelapse, +}; + struct DocumentExportMenuPlan { DocumentExportMenuKind kind = DocumentExportMenuKind::jpeg; DocumentExportMenuAction action = DocumentExportMenuAction::show_jpeg_dialog; @@ -96,6 +101,16 @@ public: virtual void export_cube_faces(std::string_view document_name) = 0; }; +class DocumentVideoExportServices { +public: + virtual ~DocumentVideoExportServices() = default; + + virtual void export_animation_mp4(std::string_view path) = 0; + virtual void export_timelapse_mp4(std::string_view path) = 0; + virtual void show_animation_export_success(std::string_view path) = 0; + virtual void show_timelapse_export_success(std::string_view path) = 0; +}; + [[nodiscard]] constexpr DocumentExportStartDecision plan_document_export_start( bool requires_license, bool license_valid, @@ -342,6 +357,29 @@ public: return pp::foundation::Status::success(); } +[[nodiscard]] inline pp::foundation::Status execute_document_video_export( + DocumentVideoExportKind kind, + std::string_view path, + DocumentVideoExportServices& services) +{ + if (path.empty()) { + return pp::foundation::Status::invalid_argument("video export path must not be empty"); + } + + switch (kind) { + case DocumentVideoExportKind::animation_mp4: + services.export_animation_mp4(path); + services.show_animation_export_success(path); + return pp::foundation::Status::success(); + case DocumentVideoExportKind::timelapse: + services.export_timelapse_mp4(path); + services.show_timelapse_export_success(path); + return pp::foundation::Status::success(); + } + + return pp::foundation::Status::invalid_argument("unknown document video export kind"); +} + [[nodiscard]] inline pp::foundation::Status execute_document_export_menu_plan( const DocumentExportMenuPlan& plan, DocumentExportMenuServices& services) diff --git a/src/app_dialogs.cpp b/src/app_dialogs.cpp index 76342a2..d7b36d3 100644 --- a/src/app_dialogs.cpp +++ b/src/app_dialogs.cpp @@ -566,19 +566,28 @@ void App::dialog_timelapse_export() pick_file_save("mp4", target.value().name, [this](std::string path) { - rec_export(path); + const auto status = pp::panopainter::execute_legacy_document_video_export( + *this, + pp::app::DocumentVideoExportKind::timelapse, + path, + false); + if (!status.ok()) + LOG("Document timelapse export failed: %s", status.message); }, - [this](const std::string& path, bool saved) { - message_box("Export Timelapse", "Timelapse exported successfully."); + [](const std::string& path, bool saved) { + (void)path; + (void)saved; } ); #else pick_file_save({ "mp4" }, [this](std::string path) { - std::thread([this, path] { - BT_SetTerminate(); - rec_export(path); - message_box("Export Timelapse", "Timelapse exported to: " + path); - }).detach(); + const auto status = pp::panopainter::execute_legacy_document_video_export( + *this, + pp::app::DocumentVideoExportKind::timelapse, + path, + true); + if (!status.ok()) + LOG("Document timelapse export failed: %s", status.message); }); #endif } @@ -597,17 +606,28 @@ void App::dialog_export_mp4() pick_file_save("mp4", target.value().name, [this](std::string path) { - export_anim_mp4(path); + const auto status = pp::panopainter::execute_legacy_document_video_export( + *this, + pp::app::DocumentVideoExportKind::animation_mp4, + path, + false); + if (!status.ok()) + LOG("Document animation export failed: %s", status.message); }, - [this](const std::string& path, bool saved) { - message_box("Export Animation", "Animation exported successfully."); + [](const std::string& path, bool saved) { + (void)path; + (void)saved; } ); #else pick_file_save({ "mp4" }, [this](std::string path) { - Canvas::I->export_anim_mp4(path, [this, path] { - message_box("Export Animation", "Animation exported to: " + path); - }); + const auto status = pp::panopainter::execute_legacy_document_video_export( + *this, + pp::app::DocumentVideoExportKind::animation_mp4, + path, + true); + if (!status.ok()) + LOG("Document animation export failed: %s", status.message); }); #endif } diff --git a/src/legacy_document_export_services.cpp b/src/legacy_document_export_services.cpp index e21dad6..6baa088 100644 --- a/src/legacy_document_export_services.cpp +++ b/src/legacy_document_export_services.cpp @@ -4,6 +4,9 @@ #include "app.h" +#include +#include + namespace pp::panopainter { namespace { @@ -105,6 +108,65 @@ private: App& app_; }; +class LegacyDocumentVideoExportServices final : public pp::app::DocumentVideoExportServices { +public: + LegacyDocumentVideoExportServices(App& app, bool asynchronous) noexcept + : app_(app) + , asynchronous_(asynchronous) + { + } + + void export_animation_mp4(std::string_view path) override + { + auto* app = &app_; + auto path_string = std::string(path); + if (asynchronous_) { + Canvas::I->export_anim_mp4(path_string, [app, path_string] { + app->message_box("Export Animation", "Animation exported to: " + path_string); + }); + return; + } + + Canvas::I->export_anim_mp4(path_string, [app] { + app->message_box("Export Animation", "Animation exported successfully."); + }); + } + + void export_timelapse_mp4(std::string_view path) override + { + auto* app = &app_; + auto path_string = std::string(path); + if (asynchronous_) { + std::thread([app, path_string] { + BT_SetTerminate(); + app->rec_export(path_string); + app->message_box("Export Timelapse", "Timelapse exported to: " + path_string); + }).detach(); + return; + } + + app->rec_export(path_string); + } + + void show_animation_export_success(std::string_view path) override + { + (void)path; + } + + void show_timelapse_export_success(std::string_view path) override + { + if (asynchronous_) { + (void)path; + } else { + app_.message_box("Export Timelapse", "Timelapse exported successfully."); + } + } + +private: + App& app_; + bool asynchronous_ = false; +}; + } // namespace pp::foundation::Status execute_legacy_document_export_file( @@ -149,4 +211,14 @@ pp::foundation::Status execute_legacy_document_export_cube_faces( return pp::app::execute_document_export_cube_faces(document_name, services); } +pp::foundation::Status execute_legacy_document_video_export( + App& app, + pp::app::DocumentVideoExportKind kind, + std::string_view path, + bool asynchronous) +{ + LegacyDocumentVideoExportServices services(app, asynchronous); + return pp::app::execute_document_video_export(kind, path, services); +} + } // namespace pp::panopainter diff --git a/src/legacy_document_export_services.h b/src/legacy_document_export_services.h index d7619a3..a6662a9 100644 --- a/src/legacy_document_export_services.h +++ b/src/legacy_document_export_services.h @@ -29,4 +29,10 @@ namespace pp::panopainter { App& app, std::string_view document_name); +[[nodiscard]] pp::foundation::Status execute_legacy_document_video_export( + App& app, + pp::app::DocumentVideoExportKind kind, + std::string_view path, + bool asynchronous); + } // namespace pp::panopainter diff --git a/tests/app_core/document_export_tests.cpp b/tests/app_core/document_export_tests.cpp index effe01f..4ed0ac1 100644 --- a/tests/app_core/document_export_tests.cpp +++ b/tests/app_core/document_export_tests.cpp @@ -110,6 +110,45 @@ public: std::string call_order; }; +class FakeDocumentVideoExportServices final : public pp::app::DocumentVideoExportServices { +public: + void export_animation_mp4(std::string_view path) override + { + animation_exports += 1; + last_path = std::string(path); + call_order += "animation-export;"; + } + + void export_timelapse_mp4(std::string_view path) override + { + timelapse_exports += 1; + last_path = std::string(path); + call_order += "timelapse-export;"; + } + + void show_animation_export_success(std::string_view path) override + { + animation_messages += 1; + last_message_path = std::string(path); + call_order += "animation-message;"; + } + + void show_timelapse_export_success(std::string_view path) override + { + timelapse_messages += 1; + last_message_path = std::string(path); + call_order += "timelapse-message;"; + } + + int animation_exports = 0; + int timelapse_exports = 0; + int animation_messages = 0; + int timelapse_messages = 0; + std::string last_path; + std::string last_message_path; + std::string call_order; +}; + void equirectangular_export_builds_file_target(pp::tests::Harness& harness) { const auto target = pp::app::make_document_export_file_target("D:/Paint", "demo", ".png"); @@ -431,6 +470,51 @@ void export_executor_rejects_malformed_targets(pp::tests::Harness& harness) PP_EXPECT(harness, services.call_order.empty()); } +void video_export_executor_dispatches_animation_and_timelapse(pp::tests::Harness& harness) +{ + FakeDocumentVideoExportServices services; + + PP_EXPECT( + harness, + pp::app::execute_document_video_export( + pp::app::DocumentVideoExportKind::animation_mp4, + "D:/Paint/animation.mp4", + services) + .ok()); + PP_EXPECT( + harness, + pp::app::execute_document_video_export( + pp::app::DocumentVideoExportKind::timelapse, + "D:/Paint/timelapse.mp4", + services) + .ok()); + + PP_EXPECT(harness, services.animation_exports == 1); + PP_EXPECT(harness, services.timelapse_exports == 1); + PP_EXPECT(harness, services.animation_messages == 1); + PP_EXPECT(harness, services.timelapse_messages == 1); + PP_EXPECT(harness, services.last_path == "D:/Paint/timelapse.mp4"); + PP_EXPECT(harness, services.last_message_path == "D:/Paint/timelapse.mp4"); + PP_EXPECT( + harness, + services.call_order + == "animation-export;animation-message;timelapse-export;timelapse-message;"); +} + +void video_export_executor_rejects_empty_paths(pp::tests::Harness& harness) +{ + FakeDocumentVideoExportServices services; + + PP_EXPECT( + harness, + !pp::app::execute_document_video_export( + pp::app::DocumentVideoExportKind::animation_mp4, + "", + services) + .ok()); + PP_EXPECT(harness, services.call_order.empty()); +} + } int main() @@ -454,5 +538,7 @@ int main() export_executor_dispatches_file_stem_collection_and_named_exports); harness.run("export executor preserves failed directory creation", export_executor_preserves_failed_directory_creation); harness.run("export executor rejects malformed targets", export_executor_rejects_malformed_targets); + harness.run("video export executor dispatches animation and timelapse", video_export_executor_dispatches_animation_and_timelapse); + harness.run("video export executor rejects empty paths", video_export_executor_rejects_empty_paths); return harness.finish(); }