diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index b743e7f..0a5ddb7 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -115,7 +115,7 @@ Known local toolchain state: frame counts, durations, and decoded face-pixel payloads when present; the metadata-only minimal fixture remains covered by `pano_cli_load_project_metadata_smoke`. -- `pano_cli save-project` writes one-layer/one-frame PPI files through +- `pano_cli save-project` writes single-layer multi-frame PPI files through `pp_assets` and is covered by `pano_cli_save_project_roundtrip_smoke` and `pano_cli_save_project_payload_roundtrip_smoke`, which reload generated metadata-only and dirty-face-payload projects through `pano_cli load-project`. @@ -275,9 +275,10 @@ Known local toolchain state: face-payload attachment through JSON automation and is covered by `pano_cli_import_image_rejects_truncated_png`; a checked-in decodable image fixture and export/round-trip automation remain future CLI tasks. -- `pano_cli save-project` exposes one-layer/one-frame PPI writing through JSON +- `pano_cli save-project` exposes single-layer multi-frame PPI writing through JSON automation and is covered by metadata-only and dirty-face-payload round-trip - smoke tests; full legacy canvas save parity remains tracked by DEBT-0013. + smoke tests; full multi-layer legacy canvas save parity remains tracked by + DEBT-0013. - `pp_ui_core` consumes vcpkg tinyxml2 only when `PP_USE_VCPKG_TINYXML2=ON` through the vcpkg preset; default and Android validation still use the retained vendored fallback tracked by DEBT-0012. diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index b631476..fd7f12a 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -31,7 +31,7 @@ agent or engineer to remove them without reconstructing context from chat. | 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, and renderer-free RGBA8 face payload storage, but it is not yet wired to legacy `Canvas`, selection masks, 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`; `pp_document_ppi_import_tests` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade | | DEBT-0011 | Open | Modernization | `package-smoke` validates the Windows CMake app artifact only, not AppX/APK/Apple/WebGL package outputs | 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` | Package-smoke covers Windows AppX, Android APK variants, 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`, `pano_cli inspect-project`, `pano_cli load-project`, and `pano_cli save-project` validate the fixed PPI header, thumbnail/body byte layout, one-layer/one-frame PPI writing, metadata-only and dirty-face-payload save/load round-trips, layer/frame index, dirty-face descriptors, dirty-face PNG payload metadata, asset-level RGBA PNG payload decoding, 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`; `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` | Full PPI load/save fixtures cover thumbnails, decoded layer face payloads attached to documents, frames, metadata, corrupt payloads, dirty-face payload saving, multiple layers/frames, and legacy app round-trip compatibility | +| DEBT-0013 | Open | Modernization | `pp_assets`, `pano_cli inspect-project`, `pano_cli load-project`, and `pano_cli save-project` validate the fixed PPI header, thumbnail/body byte layout, single-layer multi-frame PPI writing, metadata-only and dirty-face-payload save/load round-trips, layer/frame index, dirty-face descriptors, dirty-face PNG payload metadata, asset-level RGBA PNG payload decoding, 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`; `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` | Full PPI load/save fixtures cover thumbnails, decoded layer face payloads attached to documents, frames, metadata, corrupt payloads, dirty-face payload saving, multiple layers, and legacy app round-trip compatibility | | DEBT-0014 | Open | Modernization | `windows-clangcl-asan` now configures as a headless Ninja/clang-cl preset and uses the release MSVC runtime required by ASan, but local builds still fail because installed clang-cl 18.1.8 is paired with VS 2026-preview STL headers that require Clang 20 or newer | Sanitizer validation should be local and repeatable, but this machine's compiler/header pairing is incompatible | `cmake --fresh --preset windows-clangcl-asan`; `cmake --build --preset windows-clangcl-asan --target pp_foundation` | Install/use Clang 20+ with the VS 2026 STL, or point the preset at a compatible VS 2022 toolchain, then make `platform-build.ps1 -Presets windows-clangcl-asan` pass for the headless matrix | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 970fe88..3d8e0a8 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -340,7 +340,7 @@ asset-level decode coverage, and `pano_cli load-project` creates a `pp_document` projection with per-layer frame counts, durations, and decoded face-pixel payload attachment when PPI image payloads are present. -`pano_cli save-project` writes one-layer/one-frame PPI files through the +`pano_cli save-project` writes single-layer multi-frame PPI files through the extracted `pp_assets` writer and round-trips metadata-only and test dirty-face-payload variants through `load-project`. `pano_cli create-document` can create simple animation documents with explicit @@ -717,8 +717,8 @@ Results: projection with per-layer frame counts, durations, and zero loaded face payloads for the minimal PPI fixture. - `pano_cli_save_project_roundtrip_smoke` passed and proves the metadata-only - `pp_assets` PPI writer can save a generated PPI and reload it through - `pano_cli load-project`. + `pp_assets` PPI writer can save a generated multi-frame PPI and reload it + through `pano_cli load-project`. - `pano_cli_save_project_payload_roundtrip_smoke` passed and proves the `pp_assets` PPI writer can save a compressed RGBA PNG dirty-face payload and reload it as decoded `pp_document` face-pixel data. @@ -786,10 +786,10 @@ Results: `pp_assets`, attaches them to a pure `pp_document` face payload, and has a truncated-PNG rejection smoke test. A checked-in decodable image fixture and full export/round-trip automation remain future `pano_cli` tasks. -- `pano_cli save-project` exposes one-layer/one-frame PPI writing through JSON +- `pano_cli save-project` exposes single-layer multi-frame PPI writing through JSON automation and is covered by metadata-only and dirty-face-payload save/load - round-trip smoke tests. Full legacy canvas save parity remains tracked by - DEBT-0013. + round-trip smoke tests. Full multi-layer legacy canvas save parity remains + tracked by DEBT-0013. - PowerShell package-smoke wrapper validates the Windows CMake app executable and runtime `data/` copy. - Android arm64 configured with NDK 29.0.14206865 through the platform-build diff --git a/src/assets/ppi_header.cpp b/src/assets/ppi_header.cpp index 9edd3bc..10b4aab 100644 --- a/src/assets/ppi_header.cpp +++ b/src/assets/ppi_header.cpp @@ -659,6 +659,11 @@ pp::foundation::Result> create_minimal_ppi_project(PpiMin pp::foundation::Status::invalid_argument("PPI frame duration must be greater than zero")); } + if (config.frame_count == 0 || config.frame_count > max_ppi_frame_count) { + return pp::foundation::Result>::failure( + pp::foundation::Status::out_of_range("PPI frame count is outside the configured range")); + } + bool seen_faces[6] {}; std::uint64_t total_payload_bytes = 0; for (const auto& face : config.dirty_faces) { @@ -729,7 +734,7 @@ pp::foundation::Result> create_minimal_ppi_project(PpiMin append_u32(bytes, config.width); append_u32(bytes, config.height); append_u32(bytes, 1); - append_u32(bytes, 1); + append_u32(bytes, config.frame_count); append_u32(bytes, 0); append_f32(bytes, 1.0F); append_u32(bytes, static_cast(config.layer_name.size())); @@ -737,29 +742,33 @@ pp::foundation::Result> create_minimal_ppi_project(PpiMin append_u32(bytes, 0); bytes.push_back(std::byte { 0 }); bytes.push_back(std::byte { 1 }); - append_u32(bytes, 1); - append_u32(bytes, config.frame_duration_ms); - for (std::uint32_t face = 0; face < 6U; ++face) { - const PpiDirtyFacePayloadConfig* dirty_face = nullptr; - for (const auto& candidate : config.dirty_faces) { - if (candidate.face_index == face) { - dirty_face = &candidate; - break; + append_u32(bytes, config.frame_count); + for (std::uint32_t frame = 0; frame < config.frame_count; ++frame) { + append_u32(bytes, config.frame_duration_ms); + for (std::uint32_t face = 0; face < 6U; ++face) { + const PpiDirtyFacePayloadConfig* dirty_face = nullptr; + if (frame == 0U) { + for (const auto& candidate : config.dirty_faces) { + if (candidate.face_index == face) { + dirty_face = &candidate; + break; + } + } } - } - if (dirty_face == nullptr) { - append_u32(bytes, 0); - continue; - } + if (dirty_face == nullptr) { + append_u32(bytes, 0); + continue; + } - append_u32(bytes, 1); - append_u32(bytes, dirty_face->x); - append_u32(bytes, dirty_face->y); - append_u32(bytes, dirty_face->x + dirty_face->width); - append_u32(bytes, dirty_face->y + dirty_face->height); - append_u32(bytes, static_cast(dirty_face->png_rgba8.size())); - bytes.insert(bytes.end(), dirty_face->png_rgba8.begin(), dirty_face->png_rgba8.end()); + append_u32(bytes, 1); + append_u32(bytes, dirty_face->x); + append_u32(bytes, dirty_face->y); + append_u32(bytes, dirty_face->x + dirty_face->width); + append_u32(bytes, dirty_face->y + dirty_face->height); + append_u32(bytes, static_cast(dirty_face->png_rgba8.size())); + bytes.insert(bytes.end(), dirty_face->png_rgba8.begin(), dirty_face->png_rgba8.end()); + } } append_u32(bytes, 0); diff --git a/src/assets/ppi_header.h b/src/assets/ppi_header.h index 6c38326..43e3e76 100644 --- a/src/assets/ppi_header.h +++ b/src/assets/ppi_header.h @@ -131,6 +131,7 @@ struct PpiMinimalProjectConfig { std::uint32_t width = 0; std::uint32_t height = 0; std::string layer_name; + std::uint32_t frame_count = 1; std::uint32_t frame_duration_ms = 100; std::span dirty_faces; }; diff --git a/tests/assets/ppi_header_tests.cpp b/tests/assets/ppi_header_tests.cpp index 863368f..c45a9cd 100644 --- a/tests/assets/ppi_header_tests.cpp +++ b/tests/assets/ppi_header_tests.cpp @@ -392,6 +392,7 @@ void creates_minimal_project_for_roundtrip_load(pp::tests::Harness& h) .width = 256, .height = 128, .layer_name = "Roundtrip", + .frame_count = 1, .frame_duration_ms = 333, .dirty_faces = {}, }); @@ -411,6 +412,28 @@ void creates_minimal_project_for_roundtrip_load(pp::tests::Harness& h) PP_EXPECT(h, index.value().body.layers[0].frames[0].duration_ms == 333U); } +void creates_minimal_project_with_multiple_frames(pp::tests::Harness& h) +{ + const auto project = create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { + .width = 256, + .height = 128, + .layer_name = "Frames", + .frame_count = 3, + .frame_duration_ms = 111, + .dirty_faces = {}, + }); + + PP_EXPECT(h, project.ok()); + const auto index = parse_ppi_project_index(project.value()); + PP_EXPECT(h, index.ok()); + PP_EXPECT(h, index.value().body.summary.declared_frame_count == 3U); + PP_EXPECT(h, index.value().body.summary.total_layer_frames == 3U); + PP_EXPECT(h, index.value().body.layers[0].frames.size() == 3U); + PP_EXPECT(h, index.value().body.layers[0].frames[0].duration_ms == 111U); + PP_EXPECT(h, index.value().body.layers[0].frames[1].duration_ms == 111U); + PP_EXPECT(h, index.value().body.layers[0].frames[2].duration_ms == 111U); +} + void creates_minimal_project_with_dirty_face_payload(pp::tests::Harness& h) { const auto png_payload = transparent_png_1x1(); @@ -428,6 +451,7 @@ void creates_minimal_project_with_dirty_face_payload(pp::tests::Harness& h) .width = 256, .height = 128, .layer_name = "Payload", + .frame_count = 1, .frame_duration_ms = 333, .dirty_faces = std::span(dirty_faces, 1), }); @@ -472,6 +496,7 @@ void rejects_invalid_minimal_project_writer_inputs(pp::tests::Harness& h) .width = 0, .height = 128, .layer_name = "Ink", + .frame_count = 1, .frame_duration_ms = 100, .dirty_faces = {}, }); @@ -479,6 +504,7 @@ void rejects_invalid_minimal_project_writer_inputs(pp::tests::Harness& h) .width = 128, .height = 128, .layer_name = "", + .frame_count = 1, .frame_duration_ms = 100, .dirty_faces = {}, }); @@ -486,13 +512,23 @@ void rejects_invalid_minimal_project_writer_inputs(pp::tests::Harness& h) .width = 128, .height = 128, .layer_name = "Ink", + .frame_count = 1, .frame_duration_ms = 0, .dirty_faces = {}, }); + const auto no_frames = create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { + .width = 128, + .height = 128, + .layer_name = "Ink", + .frame_count = 0, + .frame_duration_ms = 100, + .dirty_faces = {}, + }); const auto duplicate_dirty_face = create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { .width = 128, .height = 128, .layer_name = "Ink", + .frame_count = 1, .frame_duration_ms = 100, .dirty_faces = std::span(duplicate_faces, 2), }); @@ -503,6 +539,8 @@ void rejects_invalid_minimal_project_writer_inputs(pp::tests::Harness& h) PP_EXPECT(h, no_name.status().code == StatusCode::invalid_argument); PP_EXPECT(h, !no_duration.ok()); PP_EXPECT(h, no_duration.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !no_frames.ok()); + PP_EXPECT(h, no_frames.status().code == StatusCode::out_of_range); PP_EXPECT(h, !duplicate_dirty_face.ok()); PP_EXPECT(h, duplicate_dirty_face.status().code == StatusCode::invalid_argument); } @@ -525,6 +563,7 @@ int main() harness.run("rejects_invalid_dirty_face_png_payloads", rejects_invalid_dirty_face_png_payloads); harness.run("rejects_invalid_project_body_summaries", rejects_invalid_project_body_summaries); harness.run("creates_minimal_project_for_roundtrip_load", creates_minimal_project_for_roundtrip_load); + harness.run("creates_minimal_project_with_multiple_frames", creates_minimal_project_with_multiple_frames); harness.run("creates_minimal_project_with_dirty_face_payload", creates_minimal_project_with_dirty_face_payload); harness.run("rejects_invalid_minimal_project_writer_inputs", rejects_invalid_minimal_project_writer_inputs); return harness.finish(); diff --git a/tests/cmake/pano_cli_save_project_payload_roundtrip.cmake b/tests/cmake/pano_cli_save_project_payload_roundtrip.cmake index a93261f..f31091d 100644 --- a/tests/cmake/pano_cli_save_project_payload_roundtrip.cmake +++ b/tests/cmake/pano_cli_save_project_payload_roundtrip.cmake @@ -16,6 +16,7 @@ execute_process( --width 96 --height 48 --layer-name Payload + --frames 2 --frame-duration-ms 321 --include-test-face-payload RESULT_VARIABLE save_result @@ -51,12 +52,18 @@ string(FIND "${load_output}" "\"pixelDataLoaded\":true" load_pixels_index) string(FIND "${load_output}" "\"facePayloads\":1" load_payload_index) string(FIND "${load_output}" "\"width\":96" load_width_index) string(FIND "${load_output}" "\"height\":48" load_height_index) +string(FIND "${load_output}" "\"frames\":2" load_frames_index) +string(FIND "${load_output}" "\"animationDurationMs\":642" load_duration_index) string(FIND "${load_output}" "\"layerNames\":[\"Payload\"]" load_layer_index) +string(FIND "${load_output}" "\"layerFrameCounts\":[2]" load_layer_frames_index) if(load_command_index LESS 0 OR load_pixels_index LESS 0 OR load_payload_index LESS 0 OR load_width_index LESS 0 OR load_height_index LESS 0 - OR load_layer_index LESS 0) + OR load_frames_index LESS 0 + OR load_duration_index LESS 0 + OR load_layer_index LESS 0 + OR load_layer_frames_index LESS 0) message(FATAL_ERROR "load-project output did not contain expected payload summary: ${load_output}") endif() diff --git a/tests/cmake/pano_cli_save_project_roundtrip.cmake b/tests/cmake/pano_cli_save_project_roundtrip.cmake index ea118dc..9f5f1cd 100644 --- a/tests/cmake/pano_cli_save_project_roundtrip.cmake +++ b/tests/cmake/pano_cli_save_project_roundtrip.cmake @@ -16,6 +16,7 @@ execute_process( --width 96 --height 48 --layer-name Roundtrip + --frames 3 --frame-duration-ms 321 RESULT_VARIABLE save_result OUTPUT_VARIABLE save_output @@ -26,8 +27,9 @@ if(NOT save_result EQUAL 0) endif() string(FIND "${save_output}" "\"command\":\"save-project\"" save_command_index) -string(FIND "${save_output}" "\"bytes\":65655" save_bytes_index) -if(save_command_index LESS 0 OR save_bytes_index LESS 0) +string(FIND "${save_output}" "\"bytes\":65711" save_bytes_index) +string(FIND "${save_output}" "\"frames\":3" save_frames_index) +if(save_command_index LESS 0 OR save_bytes_index LESS 0 OR save_frames_index LESS 0) message(FATAL_ERROR "save-project output did not contain expected summary: ${save_output}") endif() @@ -48,12 +50,18 @@ endif() string(FIND "${load_output}" "\"command\":\"load-project\"" load_command_index) string(FIND "${load_output}" "\"width\":96" load_width_index) string(FIND "${load_output}" "\"height\":48" load_height_index) -string(FIND "${load_output}" "\"animationDurationMs\":321" load_duration_index) +string(FIND "${load_output}" "\"frames\":3" load_frames_index) +string(FIND "${load_output}" "\"animationDurationMs\":963" load_duration_index) string(FIND "${load_output}" "\"layerNames\":[\"Roundtrip\"]" load_layer_index) +string(FIND "${load_output}" "\"layerFrameCounts\":[3]" load_layer_frames_index) +string(FIND "${load_output}" "\"layerDurationsMs\":[963]" load_layer_durations_index) if(load_command_index LESS 0 OR load_width_index LESS 0 OR load_height_index LESS 0 + OR load_frames_index LESS 0 OR load_duration_index LESS 0 - OR load_layer_index LESS 0) + OR load_layer_index LESS 0 + OR load_layer_frames_index LESS 0 + OR load_layer_durations_index LESS 0) message(FATAL_ERROR "load-project output did not contain expected round-trip summary: ${load_output}") endif() diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 75e7c73..315f390 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -36,6 +36,7 @@ struct SaveProjectArgs { std::uint32_t width = 0; std::uint32_t height = 0; std::string layer_name = "Ink"; + std::uint32_t frames = 1; std::uint32_t frame_duration_ms = 100; bool include_test_face_payload = false; }; @@ -177,7 +178,7 @@ void print_help() << " load-project --path FILE\n" << " parse-layout --path FILE\n" << " record-render [--width N] [--height N]\n" - << " save-project --path FILE --width N --height N [--layer-name NAME] [--frame-duration-ms N] [--include-test-face-payload]\n" + << " save-project --path FILE --width N --height N [--layer-name NAME] [--frames N] [--frame-duration-ms N] [--include-test-face-payload]\n" << " simulate-document-edits [--width N] [--height N]\n" << " simulate-document-history [--width N] [--height N] [--history N]\n" << " simulate-image-import [--width N] [--height N]\n" @@ -297,7 +298,7 @@ pp::foundation::Status parse_save_project_args(int argc, char** argv, SaveProjec } else { args.layer_name = argv[++i]; } - } else if (key == "--width" || key == "--height" || key == "--frame-duration-ms") { + } else if (key == "--width" || key == "--height" || key == "--frames" || key == "--frame-duration-ms") { if (i + 1 >= argc) { return pp::foundation::Status::invalid_argument("missing value for option"); } @@ -311,6 +312,8 @@ pp::foundation::Status parse_save_project_args(int argc, char** argv, SaveProjec args.width = value.value(); } else if (key == "--height") { args.height = value.value(); + } else if (key == "--frames") { + args.frames = value.value(); } else { args.frame_duration_ms = value.value(); } @@ -333,6 +336,10 @@ pp::foundation::Status parse_save_project_args(int argc, char** argv, SaveProjec return pp::foundation::Status::invalid_argument("layer name must not be empty"); } + if (args.frames == 0) { + return pp::foundation::Status::invalid_argument("frame count must be greater than zero"); + } + if (args.frame_duration_ms == 0) { return pp::foundation::Status::invalid_argument("frame duration must be greater than zero"); } @@ -364,6 +371,7 @@ int save_project(int argc, char** argv) .width = args.width, .height = args.height, .layer_name = args.layer_name, + .frame_count = args.frames, .frame_duration_ms = args.frame_duration_ms, .dirty_faces = args.include_test_face_payload ? std::span(dirty_faces, 1) @@ -394,7 +402,7 @@ int save_project(int argc, char** argv) << ",\"document\":{\"width\":" << args.width << ",\"height\":" << args.height << ",\"layers\":1" - << ",\"frames\":1" + << ",\"frames\":" << args.frames << ",\"layerName\":\"" << json_escape(args.layer_name) << "\"" << ",\"frameDurationMs\":" << args.frame_duration_ms << ",\"facePayloads\":" << (args.include_test_face_payload ? 1 : 0)