diff --git a/CMakeLists.txt b/CMakeLists.txt index d110f26..4043707 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,7 @@ target_link_libraries(pp_platform_api add_library(pp_app_core STATIC src/app_core/app_preferences.h src/app_core/app_status.h + src/app_core/document_animation.h src/app_core/document_cloud.h src/app_core/document_export.cpp src/app_core/document_layer.h @@ -411,6 +412,7 @@ if(PP_BUILD_APP) target_link_libraries(pp_legacy_ui_core PUBLIC + pp_app_core pp_legacy_engine pp_project_options PRIVATE diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index dc78d9b..35a1f90 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -39,6 +39,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0019 | Open | Modernization | MSVC warning C4100 is muted globally through `pp_project_warnings` with `/wd4100` | Legacy callbacks, virtual hooks, serializer methods, and platform/API compatibility functions carry many intentionally unused parameters during the component split; muting this keeps stricter warning builds focused on higher-signal migration issues | `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter`; `ctest --preset desktop-fast --build-config Debug` | Remove `/wd4100`, mark intentionally unused parameters with names/comments or `[[maybe_unused]]`, and make the Windows app and headless tests pass without C4100 warnings | | DEBT-0020 | Open | Modernization | Document resize dialog state and selected-resolution planning now consume pure `pp_app_core` through `NodeDialogResize`, `App::dialog_resize`, and `pano_cli plan-document-resize`, but live resize execution still calls legacy `Canvas::resize` and clears legacy `ActionManager` history directly | Preserve existing layer/frame GPU resize behavior while the document model and canvas execution boundary are extracted incrementally | `pp_app_core_document_resize_tests`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `ctest --preset desktop-fast --build-config Debug` | Document resize execution is owned by a document/app boundary with legacy `Canvas` acting only as an adapter or removed entirely | | DEBT-0021 | Open | Modernization | Layer rename and layer panel operation planning now consume pure `pp_app_core` through `App::dialog_layer_rename`, `App::init_sidebar` layer callbacks, `pano_cli plan-layer-rename`, and `pano_cli plan-layer-operation`, but live execution still mutates legacy `Canvas` layer state, `NodeLayer`/`NodePanelLayer`, and `ActionManager` undo entries directly | Preserve existing UI/canvas behavior while document layer commands and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint`; `ctest --preset desktop-fast --build-config Debug` | Layer command execution is owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely | +| DEBT-0022 | Open | Modernization | Animation panel frame command planning now consumes pure `pp_app_core` through `NodePanelAnimation` and `pano_cli plan-animation-operation`, and `pp_legacy_ui_core` temporarily links `pp_app_core`, but live execution still mutates legacy `Canvas`/`Layer` frame state and animation playback state directly | Preserve existing animation panel behavior while timeline/frame commands move toward the document/app command boundary | `pp_app_core_document_animation_tests`; `pano_cli plan-animation-operation --kind add --frame-count 2 --current-frame 0`; `pano_cli plan-animation-operation --kind next --total-duration 5 --current-frame 4`; `ctest --preset desktop-fast --build-config Debug` | Animation frame/timeline execution is owned by the document/app command boundary with legacy `Canvas`/`Layer`/UI nodes acting only as adapters or removed entirely | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index d3b45a2..699af96 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -490,6 +490,10 @@ the live layer rename dialog before legacy `Canvas` layer mutation and duplicate, select, reorder, remove, opacity, visibility, alpha-lock, blend-mode, and highlight actions used by the live layer panel before legacy `Canvas` and UI layer execution continue. +`pano_cli plan-animation-operation` exposes app-core planning for animation +frame add, duplicate, remove, duration adjustment, timeline moves, timeline +goto/next/previous, and onion-size updates used by the live animation panel +before legacy `Canvas`/`Layer` frame execution continues. `pp_platform_api` now owns a headless `PlatformServices` interface for startup storage path preparation, clipboard text, cursor visibility, virtual-keyboard visibility, UI-thread lifecycle hooks, render-context @@ -1110,6 +1114,15 @@ Results: `pano_cli_plan_layer_operation_highlight_smoke`, and `pano_cli_plan_layer_operation_rejects_bad_opacity` passed and expose live layer-panel operation planning as JSON automation. +- `pp_app_core_document_animation_tests` passed, covering animation frame + add/duplicate/remove planning, selected-frame rejection, last-frame remove + rejection, duration floor/overflow handling, timeline move edge behavior, + goto/next/previous wrapping, and onion-size rejection. +- `pano_cli_plan_animation_operation_add_smoke`, + `pano_cli_plan_animation_operation_duration_floor_smoke`, + `pano_cli_plan_animation_operation_next_wrap_smoke`, and + `pano_cli_plan_animation_operation_rejects_remove_last_frame` passed and + expose live animation-panel planning as JSON automation. - `pp_app_core_document_sharing_tests` passed, covering saved-path gating before platform share execution. - `pano_cli_plan_share_file_unsaved_smoke` and diff --git a/src/app_core/document_animation.h b/src/app_core/document_animation.h new file mode 100644 index 0000000..fccffb3 --- /dev/null +++ b/src/app_core/document_animation.h @@ -0,0 +1,291 @@ +#pragma once + +#include "foundation/result.h" + +#include +#include +#include + +namespace pp::app { + +inline constexpr int document_animation_default_frame_duration = 1; + +enum class DocumentAnimationOperation { + add_frame, + duplicate_frame, + remove_frame, + adjust_duration, + move_frame, + goto_frame, + goto_next, + goto_previous, + set_onion_size, +}; + +struct DocumentAnimationOperationPlan { + DocumentAnimationOperation operation = DocumentAnimationOperation::goto_frame; + int frame_count = 1; + int current_frame = 0; + int selected_frame = 0; + int target_frame = 0; + int frame_duration = document_animation_default_frame_duration; + int duration_delta = 0; + int move_offset = 0; + int onion_size = 1; + bool requires_selected_frame = false; + bool mutates_document = false; + bool reloads_animation_layers = false; + bool updates_canvas_animation = false; + bool marks_unsaved = false; +}; + +[[nodiscard]] inline pp::foundation::Status validate_animation_frame_count(int frame_count) noexcept +{ + if (frame_count <= 0) { + return pp::foundation::Status::invalid_argument("animation layer must contain at least one frame"); + } + + return pp::foundation::Status::success(); +} + +[[nodiscard]] inline pp::foundation::Status validate_animation_frame_index( + int frame_count, + int index) noexcept +{ + const auto count_status = validate_animation_frame_count(frame_count); + if (!count_status.ok()) { + return count_status; + } + + if (index < 0 || index >= frame_count) { + return pp::foundation::Status::out_of_range("animation frame index is outside the layer"); + } + + return pp::foundation::Status::success(); +} + +[[nodiscard]] inline pp::foundation::Status validate_animation_frame_duration(int duration) noexcept +{ + if (duration < 1) { + return pp::foundation::Status::invalid_argument("animation frame duration must be at least 1"); + } + + return pp::foundation::Status::success(); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_add_frame( + int frame_count, + int current_frame) +{ + const auto count_status = validate_animation_frame_count(frame_count); + if (!count_status.ok()) { + return pp::foundation::Result::failure(count_status); + } + + if (current_frame < 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("current animation frame must not be negative")); + } + + DocumentAnimationOperationPlan plan; + plan.operation = DocumentAnimationOperation::add_frame; + plan.frame_count = frame_count; + plan.current_frame = current_frame; + plan.selected_frame = frame_count; + plan.target_frame = current_frame; + plan.mutates_document = true; + plan.reloads_animation_layers = true; + plan.updates_canvas_animation = true; + plan.marks_unsaved = true; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_duplicate_frame( + int frame_count, + int selected_frame) +{ + const auto index_status = validate_animation_frame_index(frame_count, selected_frame); + if (!index_status.ok()) { + return pp::foundation::Result::failure(index_status); + } + + DocumentAnimationOperationPlan plan; + plan.operation = DocumentAnimationOperation::duplicate_frame; + plan.frame_count = frame_count; + plan.selected_frame = selected_frame; + plan.target_frame = selected_frame + 1; + plan.requires_selected_frame = true; + plan.mutates_document = true; + plan.reloads_animation_layers = true; + plan.updates_canvas_animation = true; + plan.marks_unsaved = true; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_remove_frame( + int frame_count, + int selected_frame) +{ + const auto index_status = validate_animation_frame_index(frame_count, selected_frame); + if (!index_status.ok()) { + return pp::foundation::Result::failure(index_status); + } + + if (frame_count <= 1) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("animation layer must keep at least one frame")); + } + + DocumentAnimationOperationPlan plan; + plan.operation = DocumentAnimationOperation::remove_frame; + plan.frame_count = frame_count; + plan.selected_frame = selected_frame; + plan.target_frame = std::min(selected_frame, frame_count - 2); + plan.requires_selected_frame = true; + plan.mutates_document = true; + plan.reloads_animation_layers = true; + plan.updates_canvas_animation = true; + plan.marks_unsaved = true; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_adjust_duration( + int frame_count, + int selected_frame, + int current_duration, + int delta) +{ + const auto index_status = validate_animation_frame_index(frame_count, selected_frame); + if (!index_status.ok()) { + return pp::foundation::Result::failure(index_status); + } + + const auto duration_status = validate_animation_frame_duration(current_duration); + if (!duration_status.ok()) { + return pp::foundation::Result::failure(duration_status); + } + + if (delta == 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("animation frame duration delta must not be zero")); + } + + if (delta > 0 && current_duration > std::numeric_limits::max() - delta) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("animation frame duration would overflow")); + } + + DocumentAnimationOperationPlan plan; + plan.operation = DocumentAnimationOperation::adjust_duration; + plan.frame_count = frame_count; + plan.selected_frame = selected_frame; + plan.target_frame = selected_frame; + plan.frame_duration = std::max(current_duration + delta, 1); + plan.duration_delta = delta; + plan.requires_selected_frame = true; + plan.mutates_document = plan.frame_duration != current_duration; + plan.reloads_animation_layers = plan.mutates_document; + plan.updates_canvas_animation = plan.mutates_document; + plan.marks_unsaved = plan.mutates_document; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_move_frame( + int frame_count, + int selected_frame, + int offset) +{ + const auto index_status = validate_animation_frame_index(frame_count, selected_frame); + if (!index_status.ok()) { + return pp::foundation::Result::failure(index_status); + } + + if (offset == 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("animation frame move offset must not be zero")); + } + + DocumentAnimationOperationPlan plan; + plan.operation = DocumentAnimationOperation::move_frame; + plan.frame_count = frame_count; + plan.selected_frame = selected_frame; + const auto unclamped_target = static_cast(selected_frame) + static_cast(offset); + plan.target_frame = static_cast(std::clamp(unclamped_target, 0, frame_count - 1)); + plan.move_offset = offset; + plan.requires_selected_frame = true; + plan.mutates_document = plan.target_frame != selected_frame; + plan.reloads_animation_layers = true; + plan.updates_canvas_animation = true; + plan.marks_unsaved = plan.mutates_document; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_goto_frame( + int total_duration, + int frame) +{ + if (total_duration <= 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("animation duration must be greater than zero")); + } + + if (frame < 0 || frame >= total_duration) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("animation timeline frame is outside the document")); + } + + DocumentAnimationOperationPlan plan; + plan.operation = DocumentAnimationOperation::goto_frame; + plan.frame_count = total_duration; + plan.current_frame = frame; + plan.target_frame = frame; + plan.reloads_animation_layers = true; + plan.updates_canvas_animation = true; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_step_frame( + int total_duration, + int current_frame, + int offset) +{ + const auto current_status = plan_animation_goto_frame(total_duration, current_frame); + if (!current_status) { + return pp::foundation::Result::failure(current_status.status()); + } + + if (offset == 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("animation frame step offset must not be zero")); + } + + DocumentAnimationOperationPlan plan; + plan.operation = offset > 0 ? DocumentAnimationOperation::goto_next : DocumentAnimationOperation::goto_previous; + plan.frame_count = total_duration; + plan.current_frame = current_frame; + auto target = (static_cast(current_frame) + static_cast(offset)) + % static_cast(total_duration); + if (target < 0) { + target += total_duration; + } + plan.target_frame = static_cast(target); + plan.updates_canvas_animation = true; + plan.reloads_animation_layers = true; + return pp::foundation::Result::success(plan); +} + +[[nodiscard]] inline pp::foundation::Result plan_animation_onion_size(int onion_size) +{ + if (onion_size < 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("animation onion size must not be negative")); + } + + DocumentAnimationOperationPlan plan; + plan.operation = DocumentAnimationOperation::set_onion_size; + plan.onion_size = onion_size; + plan.updates_canvas_animation = true; + return pp::foundation::Result::success(plan); +} + +} // namespace pp::app diff --git a/src/node_panel_animation.cpp b/src/node_panel_animation.cpp index f52a215..68a2a30 100644 --- a/src/node_panel_animation.cpp +++ b/src/node_panel_animation.cpp @@ -1,5 +1,6 @@ #include "pch.h" #include "node_panel_animation.h" +#include "app_core/document_animation.h" #include "node_button.h" #include "node_button_custom.h" #include "renderer_gl/opengl_capabilities.h" @@ -45,62 +46,180 @@ void NodePanelAnimation::init_controls() m_frame_label = find("frame-index"); btn_add->on_click = [this](Node*) { + const auto plan = pp::app::plan_animation_add_frame( + Canvas::I->layer().frames_count(), + Canvas::I->m_anim_frame); + if (!plan) + return; Canvas::I->layer().add_frame(); - load_layers(); + if (plan.value().marks_unsaved) + Canvas::I->m_unsaved = true; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_update(); + if (plan.value().reloads_animation_layers) + load_layers(); }; btn_duplicate->on_click = [this](Node*) { - Canvas::I->layer().duplicate_frame(m_selected_frame_index); - load_layers(); + if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) + { + const auto plan = pp::app::plan_animation_duplicate_frame( + layer->frames_count(), + m_selected_frame_index); + if (!plan) + return; + layer->duplicate_frame(plan.value().selected_frame); + if (plan.value().marks_unsaved) + Canvas::I->m_unsaved = true; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_update(); + if (plan.value().reloads_animation_layers) + load_layers(); + } }; btn_remove->on_click = [this](Node*) { - Canvas::I->layer_with_id(m_selected_frame_layer_id)->remove_frame(m_selected_frame_index); - load_layers(); + if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) + { + const auto plan = pp::app::plan_animation_remove_frame( + layer->frames_count(), + m_selected_frame_index); + if (!plan) + return; + layer->remove_frame(plan.value().selected_frame); + m_selected_frame_index = plan.value().target_frame; + if (plan.value().marks_unsaved) + Canvas::I->m_unsaved = true; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_goto_frame(plan.value().target_frame); + if (plan.value().reloads_animation_layers) + load_layers(); + } }; btn_up->on_click = [this](Node*) { if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) - layer->set_frame_duration(m_selected_frame_index, glm::max(layer->frame_duration(m_selected_frame_index) + 1, 1)); - load_layers(); + { + const auto index_status = pp::app::validate_animation_frame_index( + layer->frames_count(), + m_selected_frame_index); + if (!index_status.ok()) + return; + const auto plan = pp::app::plan_animation_adjust_duration( + layer->frames_count(), + m_selected_frame_index, + layer->frame_duration(m_selected_frame_index), + 1); + if (!plan) + return; + layer->set_frame_duration(plan.value().selected_frame, plan.value().frame_duration); + if (plan.value().marks_unsaved) + Canvas::I->m_unsaved = true; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_update(); + if (plan.value().reloads_animation_layers) + load_layers(); + } }; btn_down->on_click = [this](Node*) { if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) - layer->set_frame_duration(m_selected_frame_index, glm::max(layer->frame_duration(m_selected_frame_index) - 1, 1)); - load_layers(); + { + const auto index_status = pp::app::validate_animation_frame_index( + layer->frames_count(), + m_selected_frame_index); + if (!index_status.ok()) + return; + const auto plan = pp::app::plan_animation_adjust_duration( + layer->frames_count(), + m_selected_frame_index, + layer->frame_duration(m_selected_frame_index), + -1); + if (!plan) + return; + layer->set_frame_duration(plan.value().selected_frame, plan.value().frame_duration); + if (plan.value().marks_unsaved) + Canvas::I->m_unsaved = true; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_update(); + if (plan.value().reloads_animation_layers) + load_layers(); + } }; btn_left->on_click = [this](Node*) { if (!m_selected_frame) return; if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) - m_selected_frame_index = layer->move_frame_offset(m_selected_frame_index, -1); - Canvas::I->anim_goto_frame(m_selected_frame_index); - load_layers(); + { + const auto plan = pp::app::plan_animation_move_frame( + layer->frames_count(), + m_selected_frame_index, + -1); + if (!plan) + return; + m_selected_frame_index = layer->move_frame_offset(plan.value().selected_frame, plan.value().move_offset); + if (plan.value().marks_unsaved) + Canvas::I->m_unsaved = true; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_goto_frame(m_selected_frame_index); + if (plan.value().reloads_animation_layers) + load_layers(); + } }; btn_right->on_click = [this](Node*) { if (!m_selected_frame) return; if (auto layer = Canvas::I->layer_with_id(m_selected_frame_layer_id)) - m_selected_frame_index = layer->move_frame_offset(m_selected_frame_index, +1); - Canvas::I->anim_goto_frame(m_selected_frame_index); - load_layers(); + { + const auto plan = pp::app::plan_animation_move_frame( + layer->frames_count(), + m_selected_frame_index, + 1); + if (!plan) + return; + m_selected_frame_index = layer->move_frame_offset(plan.value().selected_frame, plan.value().move_offset); + if (plan.value().marks_unsaved) + Canvas::I->m_unsaved = true; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_goto_frame(m_selected_frame_index); + if (plan.value().reloads_animation_layers) + load_layers(); + } }; m_onion->on_select = [this] (Node* target, int index) { - m_timeline->m_onion_size = m_onion->get_int(); - Canvas::I->anim_update(); + const auto plan = pp::app::plan_animation_onion_size(m_onion->get_int()); + if (!plan) + return; + m_timeline->m_onion_size = plan.value().onion_size; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_update(); }; m_timeline->on_frame_changed = [this] (NodeAnimationTimeline* target, int frame) { - LOG("goto frame %d", frame); - Canvas::I->anim_goto_frame(frame); - load_layers(); + const auto plan = pp::app::plan_animation_goto_frame(Canvas::I->anim_duration(), frame); + if (!plan) + return; + LOG("goto frame %d", plan.value().target_frame); + if (plan.value().updates_canvas_animation) + Canvas::I->anim_goto_frame(plan.value().target_frame); + if (plan.value().reloads_animation_layers) + load_layers(); }; btn_next->on_click = [this] (Node* target) { - Canvas::I->anim_goto_next(); - load_layers(); + const auto plan = pp::app::plan_animation_step_frame(Canvas::I->anim_duration(), Canvas::I->m_anim_frame, 1); + if (!plan) + return; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_goto_frame(plan.value().target_frame); + if (plan.value().reloads_animation_layers) + load_layers(); }; btn_prev->on_click = [this](Node* target) { - Canvas::I->anim_goto_prev(); - load_layers(); + const auto plan = pp::app::plan_animation_step_frame(Canvas::I->anim_duration(), Canvas::I->m_anim_frame, -1); + if (!plan) + return; + if (plan.value().updates_canvas_animation) + Canvas::I->anim_goto_frame(plan.value().target_frame); + if (plan.value().reloads_animation_layers) + load_layers(); }; btn_play->on_click = [this] (Node* target) { static auto mode = Canvas::I->m_current_mode; @@ -183,9 +302,13 @@ void NodePanelAnimation::on_tick(float dt) if (m_playback_timer > (1.f / m_fps->get_float())) { m_playback_timer = 0; - Canvas::I->anim_goto_next(); - m_timeline->m_frame = Canvas::I->m_anim_frame; - update_frames(); + const auto plan = pp::app::plan_animation_step_frame(Canvas::I->anim_duration(), Canvas::I->m_anim_frame, 1); + if (plan) + { + Canvas::I->anim_goto_frame(plan.value().target_frame); + m_timeline->m_frame = Canvas::I->m_anim_frame; + update_frames(); + } } } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e42e6b2..34eb225 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -318,6 +318,16 @@ add_test(NAME pp_app_core_document_recording_tests COMMAND pp_app_core_document_ set_tests_properties(pp_app_core_document_recording_tests PROPERTIES LABELS "app;desktop-fast;fuzz") +add_executable(pp_app_core_document_animation_tests + app_core/document_animation_tests.cpp) +target_link_libraries(pp_app_core_document_animation_tests PRIVATE + pp_app_core + pp_test_harness) + +add_test(NAME pp_app_core_document_animation_tests COMMAND pp_app_core_document_animation_tests) +set_tests_properties(pp_app_core_document_animation_tests PROPERTIES + LABELS "app;desktop-fast;fuzz") + add_executable(pp_app_core_document_layer_tests app_core/document_layer_tests.cpp) target_link_libraries(pp_app_core_document_layer_tests PRIVATE @@ -742,6 +752,30 @@ if(TARGET pano_cli) LABELS "app;integration;desktop-fast;fuzz" WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_animation_operation_add_smoke + COMMAND pano_cli plan-animation-operation --kind add --frame-count 2 --current-frame 0) + set_tests_properties(pano_cli_plan_animation_operation_add_smoke PROPERTIES + LABELS "app;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-animation-operation\".*\"operation\":\"add-frame\".*\"selectedFrame\":2.*\"mutatesDocument\":true.*\"updatesCanvasAnimation\":true.*\"marksUnsaved\":true") + + add_test(NAME pano_cli_plan_animation_operation_duration_floor_smoke + COMMAND pano_cli plan-animation-operation --kind duration --frame-count 2 --selected-frame 1 --current-duration 1 --delta -1) + set_tests_properties(pano_cli_plan_animation_operation_duration_floor_smoke PROPERTIES + LABELS "app;integration;desktop-fast;fuzz" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-animation-operation\".*\"operation\":\"adjust-duration\".*\"selectedFrame\":1.*\"frameDuration\":1.*\"mutatesDocument\":false.*\"marksUnsaved\":false") + + add_test(NAME pano_cli_plan_animation_operation_next_wrap_smoke + COMMAND pano_cli plan-animation-operation --kind next --total-duration 5 --current-frame 4) + set_tests_properties(pano_cli_plan_animation_operation_next_wrap_smoke PROPERTIES + LABELS "app;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-animation-operation\".*\"operation\":\"goto-next\".*\"currentFrame\":4.*\"targetFrame\":0.*\"updatesCanvasAnimation\":true") + + add_test(NAME pano_cli_plan_animation_operation_rejects_remove_last_frame + COMMAND pano_cli plan-animation-operation --kind remove --frame-count 1 --selected-frame 0) + set_tests_properties(pano_cli_plan_animation_operation_rejects_remove_last_frame PROPERTIES + LABELS "app;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_share_file_unsaved_smoke COMMAND pano_cli plan-share-file) set_tests_properties(pano_cli_plan_share_file_unsaved_smoke PROPERTIES diff --git a/tests/app_core/document_animation_tests.cpp b/tests/app_core/document_animation_tests.cpp new file mode 100644 index 0000000..eccc9f5 --- /dev/null +++ b/tests/app_core/document_animation_tests.cpp @@ -0,0 +1,133 @@ +#include "app_core/document_animation.h" +#include "test_harness.h" + +#include + +#define PP_REQUIRE(harness, expression) \ + do { \ + const bool pp_require_ok = static_cast(expression); \ + (harness).expect(pp_require_ok, #expression, __FILE__, __LINE__); \ + if (!pp_require_ok) { \ + return; \ + } \ + } while (false) + +namespace { + +void add_duplicate_and_remove_validate_frame_bounds(pp::tests::Harness& harness) +{ + const auto add = pp::app::plan_animation_add_frame(2, 0); + PP_REQUIRE(harness, add); + PP_EXPECT(harness, add.value().operation == pp::app::DocumentAnimationOperation::add_frame); + PP_EXPECT(harness, add.value().selected_frame == 2); + PP_EXPECT(harness, add.value().mutates_document); + PP_EXPECT(harness, add.value().reloads_animation_layers); + PP_EXPECT(harness, add.value().updates_canvas_animation); + PP_EXPECT(harness, add.value().marks_unsaved); + + const auto duplicate = pp::app::plan_animation_duplicate_frame(3, 1); + PP_REQUIRE(harness, duplicate); + PP_EXPECT(harness, duplicate.value().operation == pp::app::DocumentAnimationOperation::duplicate_frame); + PP_EXPECT(harness, duplicate.value().target_frame == 2); + PP_EXPECT(harness, duplicate.value().requires_selected_frame); + + const auto remove = pp::app::plan_animation_remove_frame(3, 2); + PP_REQUIRE(harness, remove); + PP_EXPECT(harness, remove.value().operation == pp::app::DocumentAnimationOperation::remove_frame); + PP_EXPECT(harness, remove.value().target_frame == 1); + + PP_EXPECT(harness, !pp::app::plan_animation_add_frame(0, 0)); + PP_EXPECT(harness, !pp::app::plan_animation_add_frame(1, -1)); + PP_EXPECT(harness, !pp::app::plan_animation_duplicate_frame(2, 2)); + PP_EXPECT(harness, !pp::app::plan_animation_remove_frame(1, 0)); +} + +void duration_plans_clamp_floor_and_reject_overflow(pp::tests::Harness& harness) +{ + const auto up = pp::app::plan_animation_adjust_duration(2, 1, 4, 1); + PP_REQUIRE(harness, up); + PP_EXPECT(harness, up.value().operation == pp::app::DocumentAnimationOperation::adjust_duration); + PP_EXPECT(harness, up.value().frame_duration == 5); + PP_EXPECT(harness, up.value().mutates_document); + + const auto down = pp::app::plan_animation_adjust_duration(2, 1, 1, -1); + PP_REQUIRE(harness, down); + PP_EXPECT(harness, down.value().frame_duration == 1); + PP_EXPECT(harness, !down.value().mutates_document); + PP_EXPECT(harness, !down.value().marks_unsaved); + + PP_EXPECT(harness, !pp::app::plan_animation_adjust_duration(2, 1, 0, 1)); + PP_EXPECT(harness, !pp::app::plan_animation_adjust_duration(2, 1, 2, 0)); + PP_EXPECT( + harness, + !pp::app::plan_animation_adjust_duration(2, 1, std::numeric_limits::max(), 1)); +} + +void move_and_timeline_plans_handle_edges(pp::tests::Harness& harness) +{ + const auto left_edge = pp::app::plan_animation_move_frame(3, 0, -1); + PP_REQUIRE(harness, left_edge); + PP_EXPECT(harness, left_edge.value().operation == pp::app::DocumentAnimationOperation::move_frame); + PP_EXPECT(harness, left_edge.value().target_frame == 0); + PP_EXPECT(harness, !left_edge.value().mutates_document); + PP_EXPECT(harness, left_edge.value().reloads_animation_layers); + + const auto right = pp::app::plan_animation_move_frame(3, 1, 1); + PP_REQUIRE(harness, right); + PP_EXPECT(harness, right.value().target_frame == 2); + PP_EXPECT(harness, right.value().mutates_document); + + const auto huge_left = pp::app::plan_animation_move_frame(3, 1, std::numeric_limits::min()); + PP_REQUIRE(harness, huge_left); + PP_EXPECT(harness, huge_left.value().target_frame == 0); + + const auto huge_right = pp::app::plan_animation_move_frame(3, 1, std::numeric_limits::max()); + PP_REQUIRE(harness, huge_right); + PP_EXPECT(harness, huge_right.value().target_frame == 2); + + const auto go = pp::app::plan_animation_goto_frame(5, 3); + PP_REQUIRE(harness, go); + PP_EXPECT(harness, go.value().operation == pp::app::DocumentAnimationOperation::goto_frame); + PP_EXPECT(harness, go.value().target_frame == 3); + PP_EXPECT(harness, !go.value().mutates_document); + PP_EXPECT(harness, go.value().updates_canvas_animation); + + const auto next = pp::app::plan_animation_step_frame(5, 4, 1); + PP_REQUIRE(harness, next); + PP_EXPECT(harness, next.value().operation == pp::app::DocumentAnimationOperation::goto_next); + PP_EXPECT(harness, next.value().target_frame == 0); + + const auto prev = pp::app::plan_animation_step_frame(5, 0, -1); + PP_REQUIRE(harness, prev); + PP_EXPECT(harness, prev.value().operation == pp::app::DocumentAnimationOperation::goto_previous); + PP_EXPECT(harness, prev.value().target_frame == 4); + + PP_EXPECT(harness, !pp::app::plan_animation_move_frame(3, 1, 0)); + PP_EXPECT(harness, !pp::app::plan_animation_goto_frame(5, 5)); + PP_EXPECT(harness, !pp::app::plan_animation_step_frame(0, 0, 1)); +} + +void onion_size_updates_canvas_without_document_mutation(pp::tests::Harness& harness) +{ + const auto plan = pp::app::plan_animation_onion_size(2); + PP_REQUIRE(harness, plan); + PP_EXPECT(harness, plan.value().operation == pp::app::DocumentAnimationOperation::set_onion_size); + PP_EXPECT(harness, plan.value().onion_size == 2); + PP_EXPECT(harness, plan.value().updates_canvas_animation); + PP_EXPECT(harness, !plan.value().mutates_document); + PP_EXPECT(harness, !plan.value().marks_unsaved); + + PP_EXPECT(harness, !pp::app::plan_animation_onion_size(-1)); +} + +} // namespace + +int main() +{ + pp::tests::Harness harness; + harness.run("add duplicate and remove validate frame bounds", add_duplicate_and_remove_validate_frame_bounds); + harness.run("duration plans clamp floor and reject overflow", duration_plans_clamp_floor_and_reject_overflow); + harness.run("move and timeline plans handle edges", move_and_timeline_plans_handle_edges); + harness.run("onion size updates canvas without document mutation", onion_size_updates_canvas_without_document_mutation); + return harness.finish(); +} diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 111a268..44d6c53 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -1,5 +1,6 @@ #include "app_core/app_preferences.h" #include "app_core/app_status.h" +#include "app_core/document_animation.h" #include "app_core/document_export.h" #include "app_core/document_cloud.h" #include "app_core/document_layer.h" @@ -238,6 +239,18 @@ struct PlanLayerOperationArgs { int blend_mode = 0; }; +struct PlanAnimationOperationArgs { + std::string kind = "goto"; + int frame_count = 1; + int total_duration = 1; + int current_frame = 0; + int selected_frame = 0; + int current_duration = 1; + int delta = 1; + int offset = 1; + int onion_size = 1; +}; + struct SimulateAppSessionArgs { bool has_canvas = true; bool new_document = false; @@ -511,6 +524,32 @@ const char* document_layer_operation_name(pp::app::DocumentLayerOperation operat return "select"; } +const char* document_animation_operation_name(pp::app::DocumentAnimationOperation operation) noexcept +{ + switch (operation) { + case pp::app::DocumentAnimationOperation::add_frame: + return "add-frame"; + case pp::app::DocumentAnimationOperation::duplicate_frame: + return "duplicate-frame"; + case pp::app::DocumentAnimationOperation::remove_frame: + return "remove-frame"; + case pp::app::DocumentAnimationOperation::adjust_duration: + return "adjust-duration"; + case pp::app::DocumentAnimationOperation::move_frame: + return "move-frame"; + case pp::app::DocumentAnimationOperation::goto_frame: + return "goto-frame"; + case pp::app::DocumentAnimationOperation::goto_next: + return "goto-next"; + case pp::app::DocumentAnimationOperation::goto_previous: + return "goto-previous"; + case pp::app::DocumentAnimationOperation::set_onion_size: + return "set-onion-size"; + } + + return "goto-frame"; +} + const char* document_file_write_decision_name(pp::app::DocumentFileWriteDecision decision) noexcept { switch (decision) { @@ -724,6 +763,20 @@ pp::foundation::Result parse_float_arg(std::string_view text) return pp::foundation::Result::success(value); } +pp::foundation::Result parse_i32_arg(std::string_view text) +{ + int value = 0; + const auto* begin = text.data(); + const auto* end = begin + text.size(); + const auto [ptr, ec] = std::from_chars(begin, end, value); + if (ec != std::errc {} || ptr != end) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("invalid signed integer value")); + } + + return pp::foundation::Result::success(value); +} + void print_help() { std::cout @@ -750,6 +803,7 @@ void print_help() << " plan-document-resize [--current-resolution N] [--selected-resolution-index N]\n" << " plan-layer-rename --old-name NAME --new-name NAME\n" << " plan-layer-operation --kind add|duplicate|select|reorder|remove|opacity|visibility|alpha-lock|blend-mode|highlight [--layer-count N] [--index N] [--from-index N] [--to-index N] [--source-index N] [--name NAME] [--opacity N] [--blend-mode N] [--enabled]\n" + << " plan-animation-operation --kind add|duplicate|remove|duration|move|goto|next|prev|onion [--frame-count N] [--total-duration N] [--current-frame N] [--selected-frame N] [--current-duration N] [--delta N] [--offset N] [--onion-size N]\n" << " plan-share-file [--path FILE]\n" << " plan-picked-path [--path FILE]\n" << " plan-display-file [--path FILE]\n" @@ -2535,6 +2589,136 @@ int plan_layer_operation(int argc, char** argv) return 0; } +pp::foundation::Status parse_plan_animation_operation_args( + int argc, + char** argv, + PlanAnimationOperationArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--kind") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + args.kind = argv[++i]; + } else if (key == "--frame-count" || key == "--total-duration" || key == "--current-frame" + || key == "--selected-frame" || key == "--current-duration" || key == "--delta" + || key == "--offset" || key == "--onion-size") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + const auto value = parse_i32_arg(argv[++i]); + if (!value) { + return value.status(); + } + if (key == "--frame-count") { + args.frame_count = value.value(); + } else if (key == "--total-duration") { + args.total_duration = value.value(); + } else if (key == "--current-frame") { + args.current_frame = value.value(); + } else if (key == "--selected-frame") { + args.selected_frame = value.value(); + } else if (key == "--current-duration") { + args.current_duration = value.value(); + } else if (key == "--delta") { + args.delta = value.value(); + } else if (key == "--offset") { + args.offset = value.value(); + } else { + args.onion_size = value.value(); + } + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +pp::foundation::Result make_animation_operation_plan( + const PlanAnimationOperationArgs& args) +{ + if (args.kind == "add") { + return pp::app::plan_animation_add_frame(args.frame_count, args.current_frame); + } + if (args.kind == "duplicate") { + return pp::app::plan_animation_duplicate_frame(args.frame_count, args.selected_frame); + } + if (args.kind == "remove") { + return pp::app::plan_animation_remove_frame(args.frame_count, args.selected_frame); + } + if (args.kind == "duration") { + return pp::app::plan_animation_adjust_duration( + args.frame_count, + args.selected_frame, + args.current_duration, + args.delta); + } + if (args.kind == "move") { + return pp::app::plan_animation_move_frame(args.frame_count, args.selected_frame, args.offset); + } + if (args.kind == "goto") { + return pp::app::plan_animation_goto_frame(args.total_duration, args.current_frame); + } + if (args.kind == "next") { + return pp::app::plan_animation_step_frame(args.total_duration, args.current_frame, 1); + } + if (args.kind == "prev") { + return pp::app::plan_animation_step_frame(args.total_duration, args.current_frame, -1); + } + if (args.kind == "onion") { + return pp::app::plan_animation_onion_size(args.onion_size); + } + + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("unknown animation operation kind")); +} + +int plan_animation_operation(int argc, char** argv) +{ + PlanAnimationOperationArgs args; + const auto status = parse_plan_animation_operation_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-animation-operation", status.message); + return 2; + } + + const auto plan = make_animation_operation_plan(args); + if (!plan) { + print_error("plan-animation-operation", plan.status().message); + return 2; + } + + const auto& value = plan.value(); + std::cout << "{\"ok\":true,\"command\":\"plan-animation-operation\"" + << ",\"state\":{\"kind\":\"" << json_escape(args.kind) + << "\",\"frameCount\":" << args.frame_count + << ",\"totalDuration\":" << args.total_duration + << ",\"currentFrame\":" << args.current_frame + << ",\"selectedFrame\":" << args.selected_frame + << ",\"currentDuration\":" << args.current_duration + << ",\"delta\":" << args.delta + << ",\"offset\":" << args.offset + << ",\"onionSize\":" << args.onion_size + << "},\"plan\":{\"operation\":\"" << document_animation_operation_name(value.operation) + << "\",\"frameCount\":" << value.frame_count + << ",\"currentFrame\":" << value.current_frame + << ",\"selectedFrame\":" << value.selected_frame + << ",\"targetFrame\":" << value.target_frame + << ",\"frameDuration\":" << value.frame_duration + << ",\"durationDelta\":" << value.duration_delta + << ",\"moveOffset\":" << value.move_offset + << ",\"onionSize\":" << value.onion_size + << ",\"requiresSelectedFrame\":" << json_bool(value.requires_selected_frame) + << ",\"mutatesDocument\":" << json_bool(value.mutates_document) + << ",\"reloadsAnimationLayers\":" << json_bool(value.reloads_animation_layers) + << ",\"updatesCanvasAnimation\":" << json_bool(value.updates_canvas_animation) + << ",\"marksUnsaved\":" << json_bool(value.marks_unsaved) + << "}}\n"; + return 0; +} + pp::foundation::Status parse_plan_share_file_args( int argc, char** argv, @@ -4947,6 +5131,10 @@ int main(int argc, char** argv) return plan_layer_operation(argc, argv); } + if (command == "plan-animation-operation") { + return plan_animation_operation(argc, argv); + } + if (command == "plan-share-file") { return plan_share_file(argc, argv); }