From d0412e3bf97b72684e680996573aeed971bd6f97 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Fri, 5 Jun 2026 17:54:45 +0200 Subject: [PATCH] Project legacy canvas metadata into documents --- CMakeLists.txt | 1 + docs/modernization/build-inventory.md | 5 + docs/modernization/capability-map.md | 4 +- docs/modernization/debt.md | 9 +- docs/modernization/roadmap.md | 16 ++- src/app_core/document_canvas.h | 146 +++++++++++++++++++++ src/legacy_document_canvas_services.cpp | 76 +++++++++++ src/legacy_document_canvas_services.h | 5 + tests/CMakeLists.txt | 12 ++ tests/app_core/document_canvas_tests.cpp | 152 ++++++++++++++++++++++ tools/pano_cli/main.cpp | 158 +++++++++++++++++++++++ 11 files changed, 580 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e48342a..2df4f99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -291,6 +291,7 @@ target_include_directories(pp_app_core target_link_libraries(pp_app_core PUBLIC pp_foundation + pp_document pp_project_options PRIVATE pp_project_warnings) diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index e35901c..309ab0a 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -261,6 +261,11 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\apple-remote-build.p automation, including the OpenGL command-plan support counts for that upload stream, and is covered by `pano_cli_simulate_document_render_smoke`. +- `pano_cli plan-canvas-document-snapshot` exposes the metadata-only bridge from + live canvas state toward `pp_document::CanvasDocument`, including dimensions, + active layer/frame, layer visibility/opacity/alpha/blend metadata, frame + durations, and pending renderer payload-readback counts, and is covered by + `pano_cli_plan_canvas_document_snapshot_smoke`. - `pano_cli save-document-project` writes that pure document export to a PPI file and is covered by `pano_cli_save_document_project_roundtrip_smoke`, which inspects and loads the generated file. diff --git a/docs/modernization/capability-map.md b/docs/modernization/capability-map.md index 12c77dd..cb0ecb1 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -11,7 +11,7 @@ and validation command. | Capability | Current Area | Target Owner | Required Tests | | --- | --- | --- | --- | -| PPI open/save | `Canvas`, serializer, dialogs | `pp_document`, `pp_assets`, `pano_cli` | Round-trip tiny project, old-version fixture, corrupt/truncated fixture | +| PPI open/save | `Canvas`, serializer, dialogs | `pp_document`, `pp_assets`, `pano_cli` | Round-trip tiny project, old-version fixture, corrupt/truncated fixture, metadata-only live-canvas-to-`pp_document` snapshot projection with pending renderer payload-readback counts | | Open-document routing | `App::open_document` | `pp_app_core`, `pano_cli`, `pp_panopainter_ui`, `pp_document`, `pp_assets` | Project/ABR/PPBR route tests, malformed path tests, open-action plan tests, CLI route/action smoke, app open smoke | | Document session decisions | `App::open_document`, `App::request_close`, save hotkeys, file menu, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open/save/save-as/save-version/save-before-workflow/name/new-document resolution/overwrite/version-target decision tests, CLI session, new-document, document-file, and document-version smoke, app close/open/save/new/browse smoke | | Version metadata | `scripts/pre-build.py`, `version.*` | build system, `pp_foundation` | Generated header smoke test, missing-tag behavior | @@ -46,7 +46,7 @@ and validation command. | Capability | Current Area | Target Owner | Required Tests | | --- | --- | --- | --- | | Layer rename/add/remove/move/merge | `Canvas`, `Layer`, actions | `pp_document`, `pp_app_core` | Rename and operation planning, service-dispatch, no-op preservation, undo/redo invariant tests | -| Blend/opacity/visibility/alpha lock | `Layer`, UI panels, shaders | `pp_document`, `pp_app_core`, `pp_paint_renderer` | Metadata planning, service-dispatch, CPU model and render golden | +| Blend/opacity/visibility/alpha lock | `Layer`, UI panels, shaders | `pp_document`, `pp_app_core`, `pp_paint_renderer` | Metadata planning, service-dispatch, live-canvas-to-`pp_document` snapshot projection, CPU model and render golden | | Selection mask | `Canvas` mask layer | `pp_document`, `pp_paint_renderer` | Mask apply/clear edge cases | | Animation frames | `LayerFrame`, animation panel | `pp_document`, `pp_panopainter_ui` | Duration, duplicate, remove, seek | | MP4/timelapse export | `MP4Encoder`, recording thread, export dialogs | `pp_assets`, `pp_paint_renderer`, `pp_app_core`, app | Recording lifecycle/progress decision tests, smoke export, cancellation, suggested-name tests | diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 1dc5d6d..b4d9188 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -458,6 +458,13 @@ agent or engineer to remove them without reconstructing context from chat. commands through `pp_renderer_gl::plan_recorded_render_commands`, proving the six texture uploads and transitions are accepted by the current OpenGL command planner while live legacy GL execution remains retained. +- 2026-06-05: DEBT-0010 was narrowed again. `pp_app_core` now owns a tested + metadata-only live-canvas-to-`pp_document::CanvasDocument` snapshot planner, + `pano_cli plan-canvas-document-snapshot` exposes the projection as JSON + automation, and `src/legacy_document_canvas_services.*` can build the same + snapshot from retained `Canvas` dimensions, active layer/frame, layer + visibility/opacity/alpha/blend metadata, and frame durations. Renderer-owned + cube-face pixel payload readback remains open under DEBT-0010/DEBT-0036. ## Open Debt @@ -471,7 +478,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0007 | Open | Modernization | `vcpkg.json` and `windows-msvc-vcpkg-headless` are validated for the headless Windows component matrix, and root CMake now exposes a focused `panopainter_platform_build_vcpkg_ui_core` target for the vcpkg-backed `pp_ui_core`/tinyxml2 boundary, but app targets still use vendored libraries and Android/Apple triplets are not proven | Dependency migration must stay incremental while SDK/patched/vendor dependencies remain in use | `cmake --preset windows-msvc-vcpkg-headless`; `ctest --preset desktop-fast-vcpkg --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target panopainter_platform_build_vcpkg_ui_core` | Component targets consume vcpkg packages where reliable and desktop app, Android, and Apple triplets are validated or explicitly documented as permanent vendor exceptions | | DEBT-0008 | Open | Modernization | `windows-msvc-default` and `windows-msvc-vcpkg-headless` explicitly select Visual Studio 18 2026 for local validation, but non-VS2026 CMake executables on PATH may not know that generator | The local machine has VS 2026, but using an older CMake can still default to Ninja or reject the VS 2026 generator | `cmake --preset windows-msvc-default`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter`; `ctest --preset desktop-fast --build-config Debug` | The repo automation invokes or locates a CMake executable that supports `Visual Studio 18 2026`, and VS 2026 generator validation is the normal Windows path without manual tool selection | | DEBT-0009 | Open | Modernization | Android root CMake validation currently builds headless targets only, while retained standard/Quest/Focus package CMake paths now have a refreshed CMake 3.10/C++23 baseline outside root CMake; automation queries `sdkmanager`, installs newer or missing SDK Manager NDK/CMake packages, selects the resulting pair before configure, and reports update decisions; root CMake exposes non-default platform-build and retained native package validation targets | Platform app entrypoints still live in legacy Gradle/CMake projects and need Phase 6 alignment | `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64`; `cmake --build --preset android-x64`; `cmake --build --preset android-quest-arm64`; `cmake --build --preset android-focus-arm64`; `cmake --build --preset windows-msvc-default --config Debug --target panopainter_platform_build_android_assets`; `powershell -ExecutionPolicy Bypass -File scripts\automation\android-legacy-package-build.ps1 -Packages standard`; `powershell -ExecutionPolicy Bypass -File scripts\automation\android-legacy-package-build.ps1 -Packages quest,focus -ConfigureOnly`; `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -ReadinessOnly -AndroidNativeChecks -PackageKinds android-standard-apk,android-quest-apk,android-focus-apk`; `cmake --build --preset windows-msvc-default --config Debug --target panopainter_android_native_package_smoke` | Android standard, Quest, and Focus/Wave package targets consume shared component targets and have package smoke commands | -| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model with alpha-lock metadata, snapshot construction, per-layer frame metadata, renderer-free RGBA8 face payload storage, snapshot-embedded face-payload validation, renderer-free alpha8 selection-mask storage, PPI import/export helpers, stroke-script-to-face-payload CLI automation, `pp_paint_renderer` document face/frame compositors, renderer-neutral six-face texture upload, and OpenGL command-planner validation through CLI render automation, but it is not yet wired to legacy `Canvas`, legacy save, or legacy action commands | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2`; `pano_cli load-project --path tests\data\projects\minimal-project.ppi`; `pano_cli simulate-document-render --width 64 --height 32`; `pp_document_tests`; `pp_document_ppi_import_tests`; `pp_document_ppi_export_tests`; `pp_paint_renderer_compositor_tests`; `pano_cli_simulate_document_edits_smoke`; `pano_cli_simulate_document_export_smoke`; `pano_cli_simulate_document_render_smoke`; `pano_cli_save_document_project_roundtrip_smoke`; `pano_cli_apply_stroke_script_roundtrip_smoke`; `pano_cli_apply_stroke_script_rejects_tiny_canvas` | Legacy document behavior is represented by `pp_document`/`pp_paint_renderer` tests and the app consumes it through a boundary/facade | +| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model with alpha-lock metadata, snapshot construction, per-layer frame metadata, renderer-free RGBA8 face payload storage, snapshot-embedded face-payload validation, renderer-free alpha8 selection-mask storage, PPI import/export helpers, stroke-script-to-face-payload CLI automation, `pp_paint_renderer` document face/frame compositors, renderer-neutral six-face texture upload, OpenGL command-planner validation through CLI render automation, and metadata-only live Canvas snapshot projection through `pp_app_core`/`legacy_document_canvas_services`, but legacy save/action commands and renderer-owned cube-face pixel payload readback are not yet wired | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2`; `pano_cli load-project --path tests\data\projects\minimal-project.ppi`; `pano_cli simulate-document-render --width 64 --height 32`; `pano_cli plan-canvas-document-snapshot --width 64 --height 32`; `pp_document_tests`; `pp_document_ppi_import_tests`; `pp_document_ppi_export_tests`; `pp_paint_renderer_compositor_tests`; `pp_app_core_document_canvas_tests`; `pano_cli_simulate_document_edits_smoke`; `pano_cli_simulate_document_export_smoke`; `pano_cli_simulate_document_render_smoke`; `pano_cli_plan_canvas_document_snapshot_smoke`; `pano_cli_save_document_project_roundtrip_smoke`; `pano_cli_apply_stroke_script_roundtrip_smoke`; `pano_cli_apply_stroke_script_rejects_tiny_canvas` | Legacy document behavior is represented by `pp_document`/`pp_paint_renderer` tests and the app consumes it through a boundary/facade | | DEBT-0011 | Open | Modernization | `package-smoke` validates the Windows CMake app artifact and launch-folder DLL payload, and reports a structured package readiness matrix for Windows AppX, Android standard/Quest/Focus APKs, Apple bundles, Linux app output, and WebGL output; the Windows app smoke passes the configure-time CMake executable so VS 2026 generator validation does not depend on `cmake` from PATH, retained Android package native CMake paths, and retained Linux/WebGL CMake baseline metadata are reachable from package validation and root CMake package-readiness targets, but Windows AppX/APK/Linux/Apple/WebGL package outputs are still `blocked` because root CMake package targets do not exist yet | Platform package targets are not migrated to root CMake yet | `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug`; `cmake --build --preset windows-msvc-default --config Debug --target panopainter_windows_app_package_smoke`; `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -ReadinessOnly -AndroidNativeChecks -PackageKinds android-standard-apk,android-quest-apk,android-focus-apk`; `cmake --build --preset windows-msvc-default --config Debug --target panopainter_android_native_package_smoke`; `cmake --build --preset windows-msvc-default --config Debug --target panopainter_linux_webgl_package_readiness`; `python scripts/dev/check_package_smoke_readiness.py`; `bash -n scripts/automation/package-smoke.sh` | Package-smoke builds and validates Windows AppX, Android APK variants, Linux app, Apple bundles, and WebGL output where local toolchains are present | | DEBT-0012 | Open | Modernization | `pp_ui_core` uses vcpkg tinyxml2 on `windows-msvc-vcpkg-headless`, but retains `pp_vendor_tinyxml2` for default and unproven platform presets | Mobile/AppX/Apple triplets and app packaging still need validation before removing the vendored fallback | `ctest --preset desktop-fast-vcpkg --build-config Debug`; `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | All supported presets consume vcpkg tinyxml2 or document a permanent vendored exception | | DEBT-0013 | Open | Modernization | `pp_assets`, `pp_document`, `pano_cli inspect-project`, `pano_cli load-project`, and `pano_cli save-project` validate the fixed PPI header, thumbnail/body byte layout, generated multi-layer/multi-frame PPI writing with explicit layer opacity/blend/alpha-lock/visibility metadata, per-layer frame durations, metadata-only and targeted dirty-face-payload save/load round-trips, layer/frame index, dirty-face descriptors, dirty-face PNG payload metadata, asset-level RGBA PNG payload decoding, pure document-to-PPI export, CLI document export automation, file-writing document export automation, stroke-script-generated document payload export, and decoded pixel attachment to `pp_document`, but full legacy PPI round-trip parity is not yet extracted | Full PPI save parity requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_image_pixels_tests`; `pp_assets_ppi_header_tests`; `pp_document_ppi_import_tests`; `pp_document_ppi_export_tests`; `pano_cli_inspect_project_layout_smoke`; `pano_cli_load_project_metadata_smoke`; `pano_cli_save_project_roundtrip_smoke`; `pano_cli_save_project_payload_roundtrip_smoke`; `pano_cli_simulate_document_export_smoke`; `pano_cli_save_document_project_roundtrip_smoke`; `pano_cli_apply_stroke_script_roundtrip_smoke`; `pano_cli_apply_stroke_script_rejects_tiny_canvas` | Full PPI load/save fixtures cover thumbnails, decoded layer face payloads attached to documents, frames, corrupt payloads, dirty-face payload saving, arbitrary legacy canvas payload/layout combinations, and legacy app round-trip compatibility | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 7dd4de1..afd7cb1 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -662,6 +662,13 @@ clear-current-layer command, including clear color validation, no-canvas handling, undo recording intent, and dirty-state intent; live toolbar execution and Layer menu clear now dispatch through the shared app-shell document-canvas bridge before the legacy `Canvas::clear` adapter continues. +`pano_cli plan-canvas-document-snapshot` exposes the app-core projection from +live canvas metadata into a pure `pp_document::CanvasDocument`, including +dimensions, active layer/frame, layer visibility/opacity/alpha/blend metadata, +frame durations, and pending renderer payload-readback counts; the retained +`legacy_document_canvas_services` bridge now builds the same metadata-only +snapshot from live `Canvas` state while cube-face pixel payload extraction +remains under `DEBT-0010`/`DEBT-0036`. `pano_cli plan-image-import` exposes app-core planning for File > Import image route decisions, including wide equirectangular images, legacy vertical cube strips, regular transform-placement images, and invalid image dimensions; live @@ -2182,11 +2189,18 @@ Results: - `pp_app_core_document_canvas_tests` passed, covering clear-current-layer undo/dirty intent, no-canvas no-op behavior, and invalid clear color rejection, service dispatch color forwarding, no-op execution preservation, - and invalid execution color rejection. + invalid execution color rejection, and metadata-only canvas-to-`pp_document` + snapshot projection with layer visibility, opacity, alpha-lock, blend mode, + frame duration, active layer/frame, default-name, no-canvas, bad blend, and + bad duration coverage. - `pano_cli_plan_canvas_clear_smoke`, `pano_cli_plan_canvas_clear_no_canvas_smoke`, and `pano_cli_plan_canvas_clear_rejects_bad_color` passed and expose toolbar canvas clear planning as JSON automation. +- `pano_cli_plan_canvas_document_snapshot_smoke` and + `pano_cli_plan_canvas_document_snapshot_no_canvas` passed and expose the + metadata-only live-canvas-to-`pp_document` projection, including pending + renderer payload-readback counts, as JSON automation. - `pp_app_core_document_import_tests` passed, covering wide equirectangular, legacy vertical cube strip, regular transform-placement, and invalid-dimension import route decisions, equirectangular service dispatch, transform import diff --git a/src/app_core/document_canvas.h b/src/app_core/document_canvas.h index 0260c5c..3af3332 100644 --- a/src/app_core/document_canvas.h +++ b/src/app_core/document_canvas.h @@ -1,8 +1,17 @@ #pragma once +#include "document/document.h" #include "foundation/result.h" +#include #include +#include +#include +#include +#include +#include +#include +#include namespace pp::app { @@ -17,6 +26,34 @@ struct DocumentCanvasClearPlan { bool no_op = true; }; +struct DocumentCanvasLayerSnapshotInput { + std::string_view name; + bool visible = true; + bool alpha_locked = false; + float opacity = 1.0F; + int blend_mode = 0; + std::span frame_durations_ms; + std::size_t pending_face_payloads = 0; +}; + +struct DocumentCanvasSnapshotInput { + bool has_canvas = true; + std::uint32_t width = 0; + std::uint32_t height = 0; + std::size_t active_layer_index = 0; + std::size_t active_frame_index = 0; + std::span layers; +}; + +struct DocumentCanvasSnapshotResult { + pp::document::CanvasDocument document; + std::size_t layer_count = 0; + std::size_t frame_count = 0; + std::size_t pending_face_payloads = 0; + bool metadata_only = true; + bool requires_renderer_payload_readback = false; +}; + class DocumentCanvasClearServices { public: virtual ~DocumentCanvasClearServices() = default; @@ -35,6 +72,115 @@ public: return pp::foundation::Status::success(); } +[[nodiscard]] inline pp::foundation::Result plan_document_canvas_snapshot( + DocumentCanvasSnapshotInput input) +{ + if (!input.has_canvas) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document canvas snapshot requires a canvas")); + } + + if (input.layers.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document canvas snapshot requires at least one layer")); + } + + std::size_t frame_count = 1U; + std::size_t pending_face_payloads = 0U; + for (const auto& layer : input.layers) { + frame_count = std::max(frame_count, layer.frame_durations_ms.size()); + pending_face_payloads += layer.pending_face_payloads; + } + + if (input.active_layer_index >= input.layers.size()) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("active canvas layer is outside the document snapshot")); + } + + if (input.active_frame_index >= frame_count) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("active canvas frame is outside the document snapshot")); + } + + std::vector root_frames; + root_frames.reserve(frame_count); + for (std::size_t frame_index = 0; frame_index < frame_count; ++frame_index) { + std::uint32_t duration_ms = 100U; + for (const auto& layer : input.layers) { + if (frame_index < layer.frame_durations_ms.size()) { + duration_ms = layer.frame_durations_ms[frame_index]; + break; + } + } + root_frames.push_back(pp::document::AnimationFrame { .duration_ms = duration_ms }); + } + + std::vector layer_names; + std::vector> layer_frames; + std::vector layer_configs; + layer_names.reserve(input.layers.size()); + layer_frames.reserve(input.layers.size()); + layer_configs.reserve(input.layers.size()); + + for (std::size_t layer_index = 0; layer_index < input.layers.size(); ++layer_index) { + const auto& layer = input.layers[layer_index]; + if (layer.name.empty()) { + layer_names.push_back("Layer " + std::to_string(layer_index + 1U)); + } else { + layer_names.push_back(std::string(layer.name)); + } + + layer_frames.push_back({}); + auto& frames = layer_frames.back(); + frames.reserve(layer.frame_durations_ms.empty() ? root_frames.size() : layer.frame_durations_ms.size()); + if (layer.frame_durations_ms.empty()) { + frames = root_frames; + } else { + for (const auto duration_ms : layer.frame_durations_ms) { + frames.push_back(pp::document::AnimationFrame { .duration_ms = duration_ms }); + } + } + + layer_configs.push_back(pp::document::DocumentLayerConfig { + .name = layer_names.back(), + .visible = layer.visible, + .alpha_locked = layer.alpha_locked, + .opacity = layer.opacity, + .blend_mode = static_cast(layer.blend_mode), + .frames = std::span(frames), + }); + } + + auto document = pp::document::CanvasDocument::create_from_snapshot(pp::document::DocumentSnapshotConfig { + .width = input.width, + .height = input.height, + .layers = std::span(layer_configs), + .frames = std::span(root_frames), + }); + if (!document) { + return pp::foundation::Result::failure(document.status()); + } + + auto active_status = document.value().set_active_layer(input.active_layer_index); + if (!active_status.ok()) { + return pp::foundation::Result::failure(active_status); + } + + active_status = document.value().set_active_frame(input.active_frame_index); + if (!active_status.ok()) { + return pp::foundation::Result::failure(active_status); + } + + return pp::foundation::Result::success(DocumentCanvasSnapshotResult { + .document = std::move(document.value()), + .layer_count = input.layers.size(), + .frame_count = frame_count, + .pending_face_payloads = pending_face_payloads, + .metadata_only = true, + .requires_renderer_payload_readback = pending_face_payloads > 0U, + }); +} + [[nodiscard]] inline pp::foundation::Result plan_document_canvas_clear( bool has_canvas, float r = 0.0F, diff --git a/src/legacy_document_canvas_services.cpp b/src/legacy_document_canvas_services.cpp index c18e4a0..8bfb9a2 100644 --- a/src/legacy_document_canvas_services.cpp +++ b/src/legacy_document_canvas_services.cpp @@ -3,8 +3,15 @@ #include "legacy_document_canvas_services.h" #include "app.h" +#include "canvas.h" #include "legacy_history_services.h" +#include +#include +#include +#include +#include + namespace pp::panopainter { namespace { @@ -58,6 +65,20 @@ private: App& app_; }; +[[nodiscard]] std::uint32_t legacy_u32_or_zero(int value) noexcept +{ + return value <= 0 ? 0U : static_cast(value); +} + +[[nodiscard]] std::size_t pending_face_payload_count(const Layer& layer) noexcept +{ + const auto frame_count = layer.frames_count(); + if (frame_count <= 0) { + return 0U; + } + return static_cast(frame_count) * pp::document::cube_face_count; +} + } // namespace bool legacy_document_canvas_available(const App& app) noexcept @@ -65,6 +86,61 @@ bool legacy_document_canvas_available(const App& app) noexcept return app.canvas != nullptr && app.canvas->m_canvas != nullptr; } +pp::foundation::Result capture_legacy_canvas_document_snapshot( + const Canvas& canvas) +{ + std::vector layer_names; + std::vector> layer_frame_durations; + std::vector layers; + layer_names.reserve(canvas.m_layers.size()); + layer_frame_durations.reserve(canvas.m_layers.size()); + layers.reserve(canvas.m_layers.size()); + + for (const auto& legacy_layer : canvas.m_layers) { + if (!legacy_layer) { + continue; + } + + layer_names.push_back(legacy_layer->m_name); + auto& durations = layer_frame_durations.emplace_back(); + const auto frame_count = legacy_layer->frames_count(); + durations.reserve(static_cast(std::max(frame_count, 0))); + for (int frame_index = 0; frame_index < frame_count; ++frame_index) { + durations.push_back(legacy_u32_or_zero(legacy_layer->frame_duration(frame_index))); + } + + layers.push_back(pp::app::DocumentCanvasLayerSnapshotInput { + .name = layer_names.back(), + .visible = legacy_layer->m_visible, + .alpha_locked = legacy_layer->m_alpha_locked, + .opacity = legacy_layer->m_opacity, + .blend_mode = legacy_layer->m_blend_mode, + .frame_durations_ms = std::span(durations), + .pending_face_payloads = pending_face_payload_count(*legacy_layer), + }); + } + + return pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = true, + .width = legacy_u32_or_zero(canvas.m_width), + .height = legacy_u32_or_zero(canvas.m_height), + .active_layer_index = static_cast(std::max(canvas.m_current_layer_idx, 0)), + .active_frame_index = static_cast(std::max(canvas.m_anim_frame, 0)), + .layers = std::span(layers), + }); +} + +pp::foundation::Result capture_legacy_canvas_document_snapshot( + const App& app) +{ + if (!legacy_document_canvas_available(app)) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("legacy document canvas snapshot requires a canvas")); + } + + return capture_legacy_canvas_document_snapshot(*app.canvas->m_canvas); +} + pp::foundation::Status execute_legacy_document_canvas_clear_plan( App& app, const pp::app::DocumentCanvasClearPlan& plan) diff --git a/src/legacy_document_canvas_services.h b/src/legacy_document_canvas_services.h index 6f7ba30..a09d58a 100644 --- a/src/legacy_document_canvas_services.h +++ b/src/legacy_document_canvas_services.h @@ -5,10 +5,15 @@ #include "foundation/result.h" class App; +class Canvas; namespace pp::panopainter { [[nodiscard]] bool legacy_document_canvas_available(const App& app) noexcept; +[[nodiscard]] pp::foundation::Result +capture_legacy_canvas_document_snapshot(const Canvas& canvas); +[[nodiscard]] pp::foundation::Result +capture_legacy_canvas_document_snapshot(const App& app); [[nodiscard]] pp::foundation::Status execute_legacy_document_canvas_clear_plan( App& app, const pp::app::DocumentCanvasClearPlan& plan); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 373647f..23f4ad0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1470,6 +1470,18 @@ if(TARGET pano_cli) LABELS "app;document;integration;desktop-fast;fuzz" WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_canvas_document_snapshot_smoke + COMMAND pano_cli plan-canvas-document-snapshot --width 128 --height 64 --layers 3 --frames 2 --current-layer 2 --current-frame 1 --hidden-layer 0 --alpha-locked-layer 2 --opacity 0.5 --blend-mode 4 --pending-face-payloads-per-layer 6) + set_tests_properties(pano_cli_plan_canvas_document_snapshot_smoke PROPERTIES + LABELS "app;document;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-canvas-document-snapshot\".*\"width\":128.*\"height\":64.*\"layers\":3.*\"frames\":2.*\"activeLayer\":2.*\"activeFrame\":1.*\"activeLayerName\":\"Layer 3\".*\"activeLayerOpacity\":0.5.*\"activeLayerBlend\":\"overlay\".*\"activeLayerAlphaLocked\":true.*\"pendingFacePayloads\":18.*\"metadataOnly\":true.*\"requiresRendererPayloadReadback\":true.*\"documentFacePayloads\":0") + + add_test(NAME pano_cli_plan_canvas_document_snapshot_no_canvas + COMMAND pano_cli plan-canvas-document-snapshot --no-canvas) + set_tests_properties(pano_cli_plan_canvas_document_snapshot_no_canvas PROPERTIES + LABELS "app;document;integration;desktop-fast;fuzz" + WILL_FAIL TRUE) + add_test(NAME pano_cli_plan_image_import_wide_equirect_smoke COMMAND pano_cli plan-image-import --width 4096 --height 2048) set_tests_properties(pano_cli_plan_image_import_wide_equirect_smoke PROPERTIES diff --git a/tests/app_core/document_canvas_tests.cpp b/tests/app_core/document_canvas_tests.cpp index 33b6fdd..6765a0f 100644 --- a/tests/app_core/document_canvas_tests.cpp +++ b/tests/app_core/document_canvas_tests.cpp @@ -1,7 +1,9 @@ #include "app_core/document_canvas.h" #include "test_harness.h" +#include #include +#include #include namespace { @@ -26,6 +28,153 @@ public: std::string call_order; }; +void snapshot_plan_projects_canvas_metadata(pp::tests::Harness& harness) +{ + const std::uint32_t base_frames[] { 120U, 240U }; + const std::uint32_t paint_frames[] { 180U }; + const pp::app::DocumentCanvasLayerSnapshotInput layers[] { + { + .name = "Base", + .visible = false, + .alpha_locked = true, + .opacity = 0.5F, + .blend_mode = 2, + .frame_durations_ms = std::span(base_frames), + .pending_face_payloads = 6U, + }, + { + .name = "Paint", + .visible = true, + .alpha_locked = false, + .opacity = 0.75F, + .blend_mode = 4, + .frame_durations_ms = std::span(paint_frames), + .pending_face_payloads = 3U, + }, + }; + + const auto result = pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = true, + .width = 64U, + .height = 32U, + .active_layer_index = 1U, + .active_frame_index = 1U, + .layers = std::span(layers), + }); + + PP_EXPECT(harness, result); + if (!result) { + return; + } + + const auto& value = result.value(); + PP_EXPECT(harness, value.layer_count == 2U); + PP_EXPECT(harness, value.frame_count == 2U); + PP_EXPECT(harness, value.pending_face_payloads == 9U); + PP_EXPECT(harness, value.metadata_only); + PP_EXPECT(harness, value.requires_renderer_payload_readback); + PP_EXPECT(harness, value.document.width() == 64U); + PP_EXPECT(harness, value.document.height() == 32U); + PP_EXPECT(harness, value.document.active_layer_index() == 1U); + PP_EXPECT(harness, value.document.active_frame_index() == 1U); + PP_EXPECT(harness, value.document.layers().size() == 2U); + PP_EXPECT(harness, value.document.frames().size() == 2U); + PP_EXPECT(harness, value.document.layers()[0].name == "Base"); + PP_EXPECT(harness, !value.document.layers()[0].visible); + PP_EXPECT(harness, value.document.layers()[0].alpha_locked); + PP_EXPECT(harness, value.document.layers()[0].opacity == 0.5F); + PP_EXPECT(harness, value.document.layers()[0].blend_mode == pp::paint::BlendMode::screen); + PP_EXPECT(harness, value.document.layers()[0].frames[1].duration_ms == 240U); + PP_EXPECT(harness, value.document.layers()[1].name == "Paint"); + PP_EXPECT(harness, value.document.layers()[1].blend_mode == pp::paint::BlendMode::overlay); + PP_EXPECT(harness, value.document.layers()[1].frames.size() == 1U); + PP_EXPECT(harness, value.document.face_pixel_payload_count() == 0U); +} + +void snapshot_plan_defaults_empty_names_and_frames(pp::tests::Harness& harness) +{ + const pp::app::DocumentCanvasLayerSnapshotInput layers[] { + { + .name = "", + .frame_durations_ms = {}, + }, + }; + + const auto result = pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = true, + .width = 16U, + .height = 8U, + .layers = std::span(layers), + }); + + PP_EXPECT(harness, result); + if (!result) { + return; + } + + PP_EXPECT(harness, result.value().frame_count == 1U); + PP_EXPECT(harness, result.value().pending_face_payloads == 0U); + PP_EXPECT(harness, !result.value().requires_renderer_payload_readback); + PP_EXPECT(harness, result.value().document.layers()[0].name == "Layer 1"); + PP_EXPECT(harness, result.value().document.frames()[0].duration_ms == 100U); +} + +void snapshot_plan_rejects_invalid_canvas_state(pp::tests::Harness& harness) +{ + const std::uint32_t frames[] { 100U }; + const pp::app::DocumentCanvasLayerSnapshotInput layers[] { + { + .name = "Layer", + .frame_durations_ms = std::span(frames), + }, + }; + + const auto no_canvas = pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = false, + .width = 16U, + .height = 8U, + .layers = std::span(layers), + }); + const auto bad_layer = pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = true, + .width = 16U, + .height = 8U, + .active_layer_index = 1U, + .layers = std::span(layers), + }); + const pp::app::DocumentCanvasLayerSnapshotInput bad_blend_layers[] { + { + .name = "Layer", + .blend_mode = 64, + .frame_durations_ms = std::span(frames), + }, + }; + const auto bad_blend = pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = true, + .width = 16U, + .height = 8U, + .layers = std::span(bad_blend_layers), + }); + const std::uint32_t bad_frames[] { 0U }; + const pp::app::DocumentCanvasLayerSnapshotInput bad_duration_layers[] { + { + .name = "Layer", + .frame_durations_ms = std::span(bad_frames), + }, + }; + const auto bad_duration = pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = true, + .width = 16U, + .height = 8U, + .layers = std::span(bad_duration_layers), + }); + + PP_EXPECT(harness, !no_canvas); + PP_EXPECT(harness, !bad_layer); + PP_EXPECT(harness, !bad_blend); + PP_EXPECT(harness, !bad_duration); +} + void clear_plan_records_legacy_canvas_effects(pp::tests::Harness& harness) { const auto plan = pp::app::plan_document_canvas_clear(true, 0.0F, 0.1F, 0.2F, 0.3F); @@ -119,6 +268,9 @@ void clear_executor_rejects_invalid_color(pp::tests::Harness& harness) int main() { pp::tests::Harness harness; + harness.run("snapshot plan projects canvas metadata", snapshot_plan_projects_canvas_metadata); + harness.run("snapshot plan defaults empty names and frames", snapshot_plan_defaults_empty_names_and_frames); + harness.run("snapshot plan rejects invalid canvas state", snapshot_plan_rejects_invalid_canvas_state); harness.run("clear plan records legacy canvas effects", clear_plan_records_legacy_canvas_effects); harness.run("clear plan noops without canvas", clear_plan_noops_without_canvas); harness.run("clear plan rejects bad color channels", clear_plan_rejects_bad_color_channels); diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index c23e7f4..2e99e39 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -401,6 +401,22 @@ struct PlanCanvasClearArgs { float a = 0.0F; }; +struct PlanCanvasDocumentSnapshotArgs { + bool has_canvas = true; + std::uint32_t width = 64; + std::uint32_t height = 32; + std::uint32_t layers = 2; + std::uint32_t frames = 2; + std::uint32_t frame_duration_ms = 100; + std::uint32_t current_layer = 1; + std::uint32_t current_frame = 0; + std::uint32_t hidden_layer = 0; + std::uint32_t alpha_locked_layer = 1; + float opacity = 0.75F; + int blend_mode = 4; + std::uint32_t pending_face_payloads_per_layer = pp::document::cube_face_count; +}; + struct PlanImageImportArgs { int width = 0; int height = 0; @@ -2391,6 +2407,7 @@ void print_help() << " plan-tools-panel --panel presets|color|color-advanced|layers|brush|grids|animation [--already-visible]\n" << " plan-about-menu --command help|about|news|crash|performance [--version-major N] [--version-minor N] [--version-fix N] [--no-diagnostics] [--no-canvas]\n" << " plan-canvas-clear [--no-canvas] [--r N] [--g N] [--b N] [--a N]\n" + << " plan-canvas-document-snapshot [--no-canvas] [--width N] [--height N] [--layers N] [--frames N] [--frame-duration-ms N] [--current-layer N] [--current-frame N] [--hidden-layer N] [--alpha-locked-layer N] [--opacity N] [--blend-mode N] [--pending-face-payloads-per-layer N]\n" << " plan-image-import --width N --height N\n" << " plan-document-resize [--current-resolution N] [--selected-resolution-index N]\n" << " plan-layer-rename --old-name NAME --new-name NAME\n" @@ -5838,6 +5855,143 @@ int plan_canvas_clear(int argc, char** argv) return 0; } +pp::foundation::Status parse_plan_canvas_document_snapshot_args( + int argc, + char** argv, + PlanCanvasDocumentSnapshotArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--no-canvas") { + args.has_canvas = false; + } else if (key == "--width" || key == "--height" || key == "--layers" || key == "--frames" + || key == "--frame-duration-ms" || key == "--current-layer" || key == "--current-frame" + || key == "--hidden-layer" || key == "--alpha-locked-layer" + || key == "--pending-face-payloads-per-layer") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + const auto value = pp::foundation::parse_u32(argv[++i]); + if (!value) { + return value.status(); + } + if (key == "--width") { + args.width = value.value(); + } else if (key == "--height") { + args.height = value.value(); + } else if (key == "--layers") { + args.layers = value.value(); + } else if (key == "--frames") { + args.frames = value.value(); + } else if (key == "--frame-duration-ms") { + args.frame_duration_ms = value.value(); + } else if (key == "--current-layer") { + args.current_layer = value.value(); + } else if (key == "--current-frame") { + args.current_frame = value.value(); + } else if (key == "--hidden-layer") { + args.hidden_layer = value.value(); + } else if (key == "--alpha-locked-layer") { + args.alpha_locked_layer = value.value(); + } else { + args.pending_face_payloads_per_layer = value.value(); + } + } else if (key == "--opacity") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + const auto value = parse_float_arg(argv[++i]); + if (!value) { + return value.status(); + } + args.opacity = value.value(); + } else if (key == "--blend-mode") { + 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(); + } + args.blend_mode = value.value(); + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + return pp::foundation::Status::success(); +} + +int plan_canvas_document_snapshot(int argc, char** argv) +{ + PlanCanvasDocumentSnapshotArgs args; + const auto status = parse_plan_canvas_document_snapshot_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-canvas-document-snapshot", status.message); + return 2; + } + + std::vector frame_durations(args.frames, args.frame_duration_ms); + std::vector layer_names; + std::vector layers; + layer_names.reserve(args.layers); + layers.reserve(args.layers); + for (std::uint32_t layer_index = 0; layer_index < args.layers; ++layer_index) { + layer_names.push_back("Layer " + std::to_string(layer_index + 1U)); + layers.push_back(pp::app::DocumentCanvasLayerSnapshotInput { + .name = layer_names.back(), + .visible = layer_index != args.hidden_layer, + .alpha_locked = layer_index == args.alpha_locked_layer, + .opacity = layer_index == args.current_layer ? args.opacity : 1.0F, + .blend_mode = layer_index == args.current_layer ? args.blend_mode : 0, + .frame_durations_ms = std::span(frame_durations), + .pending_face_payloads = args.pending_face_payloads_per_layer, + }); + } + + const auto result = pp::app::plan_document_canvas_snapshot(pp::app::DocumentCanvasSnapshotInput { + .has_canvas = args.has_canvas, + .width = args.width, + .height = args.height, + .active_layer_index = args.current_layer, + .active_frame_index = args.current_frame, + .layers = std::span(layers), + }); + if (!result) { + print_error("plan-canvas-document-snapshot", result.status().message); + return 2; + } + + const auto& value = result.value(); + const auto& document = value.document; + const auto& active_layer = document.layers()[document.active_layer_index()]; + std::cout << "{\"ok\":true,\"command\":\"plan-canvas-document-snapshot\"" + << ",\"state\":{\"hasCanvas\":" << json_bool(args.has_canvas) + << ",\"width\":" << args.width + << ",\"height\":" << args.height + << ",\"layers\":" << args.layers + << ",\"frames\":" << args.frames + << ",\"currentLayer\":" << args.current_layer + << ",\"currentFrame\":" << args.current_frame + << "},\"snapshot\":{\"width\":" << document.width() + << ",\"height\":" << document.height() + << ",\"layers\":" << value.layer_count + << ",\"frames\":" << value.frame_count + << ",\"activeLayer\":" << document.active_layer_index() + << ",\"activeFrame\":" << document.active_frame_index() + << ",\"activeLayerName\":\"" << json_escape(active_layer.name) + << "\",\"activeLayerOpacity\":" << active_layer.opacity + << ",\"activeLayerBlend\":\"" << pp::paint::blend_mode_name(active_layer.blend_mode) + << "\",\"activeLayerAlphaLocked\":" << json_bool(active_layer.alpha_locked) + << ",\"pendingFacePayloads\":" << value.pending_face_payloads + << ",\"metadataOnly\":" << json_bool(value.metadata_only) + << ",\"requiresRendererPayloadReadback\":" + << json_bool(value.requires_renderer_payload_readback) + << ",\"documentFacePayloads\":" << document.face_pixel_payload_count() + << "}}\n"; + return 0; +} + pp::foundation::Status parse_plan_image_import_args( int argc, char** argv, @@ -11727,6 +11881,10 @@ int main(int argc, char** argv) return plan_canvas_clear(argc, argv); } + if (command == "plan-canvas-document-snapshot") { + return plan_canvas_document_snapshot(argc, argv); + } + if (command == "plan-image-import") { return plan_image_import(argc, argv); }