Extract animation operation planning

This commit is contained in:
2026-06-03 10:32:06 +02:00
parent fdc1defaba
commit 4f0909f30c
8 changed files with 812 additions and 27 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,291 @@
#pragma once
#include "foundation/result.h"
#include <algorithm>
#include <cstdint>
#include <limits>
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<DocumentAnimationOperationPlan> 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<DocumentAnimationOperationPlan>::failure(count_status);
}
if (current_frame < 0) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::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<DocumentAnimationOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentAnimationOperationPlan> 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<DocumentAnimationOperationPlan>::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<DocumentAnimationOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentAnimationOperationPlan> 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<DocumentAnimationOperationPlan>::failure(index_status);
}
if (frame_count <= 1) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::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<DocumentAnimationOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentAnimationOperationPlan> 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<DocumentAnimationOperationPlan>::failure(index_status);
}
const auto duration_status = validate_animation_frame_duration(current_duration);
if (!duration_status.ok()) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::failure(duration_status);
}
if (delta == 0) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::failure(
pp::foundation::Status::invalid_argument("animation frame duration delta must not be zero"));
}
if (delta > 0 && current_duration > std::numeric_limits<int>::max() - delta) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::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<DocumentAnimationOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentAnimationOperationPlan> 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<DocumentAnimationOperationPlan>::failure(index_status);
}
if (offset == 0) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::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<std::int64_t>(selected_frame) + static_cast<std::int64_t>(offset);
plan.target_frame = static_cast<int>(std::clamp<std::int64_t>(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<DocumentAnimationOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentAnimationOperationPlan> plan_animation_goto_frame(
int total_duration,
int frame)
{
if (total_duration <= 0) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::failure(
pp::foundation::Status::invalid_argument("animation duration must be greater than zero"));
}
if (frame < 0 || frame >= total_duration) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::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<DocumentAnimationOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentAnimationOperationPlan> 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<DocumentAnimationOperationPlan>::failure(current_status.status());
}
if (offset == 0) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::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<std::int64_t>(current_frame) + static_cast<std::int64_t>(offset))
% static_cast<std::int64_t>(total_duration);
if (target < 0) {
target += total_duration;
}
plan.target_frame = static_cast<int>(target);
plan.updates_canvas_animation = true;
plan.reloads_animation_layers = true;
return pp::foundation::Result<DocumentAnimationOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentAnimationOperationPlan> plan_animation_onion_size(int onion_size)
{
if (onion_size < 0) {
return pp::foundation::Result<DocumentAnimationOperationPlan>::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<DocumentAnimationOperationPlan>::success(plan);
}
} // namespace pp::app

View File

@@ -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<NodeText>("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();
}
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,133 @@
#include "app_core/document_animation.h"
#include "test_harness.h"
#include <limits>
#define PP_REQUIRE(harness, expression) \
do { \
const bool pp_require_ok = static_cast<bool>(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<int>::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<int>::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<int>::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();
}

View File

@@ -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<float> parse_float_arg(std::string_view text)
return pp::foundation::Result<float>::success(value);
}
pp::foundation::Result<int> 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<int>::failure(
pp::foundation::Status::invalid_argument("invalid signed integer value"));
}
return pp::foundation::Result<int>::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<pp::app::DocumentAnimationOperationPlan> 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<pp::app::DocumentAnimationOperationPlan>::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);
}