diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index ac446c2..c4f5bb8 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -115,6 +115,9 @@ 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 metadata-only one-layer/one-frame PPI files + through `pp_assets` and is covered by `pano_cli_save_project_roundtrip_smoke`, + which reloads the generated project through `pano_cli load-project`. - `pano_cli create-document` supports `--frames` and `--frame-duration-ms` and is covered by `pano_cli_create_animation_document_smoke`. - `pano_cli simulate-document-edits` exercises pure document layer/frame edit @@ -271,6 +274,9 @@ 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 metadata-only PPI writing through JSON + automation and is covered by `pano_cli_save_project_roundtrip_smoke`; full + dirty-face payload 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/roadmap.md b/docs/modernization/roadmap.md index 7ec7996..8f68716 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -340,6 +340,8 @@ 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 a metadata-only one-layer/one-frame PPI through +the extracted `pp_assets` writer and round-trips it through `load-project`. `pano_cli create-document` can create simple animation documents with explicit frame count/duration. `pano_cli simulate-document-edits` exercises pure layer metadata, frame reordering, active-index preservation, and tiny @@ -713,6 +715,9 @@ Results: - `pano_cli_load_project_metadata_smoke` passed and reports a `pp_document` 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`. - `pano_cli_parse_layout_smoke` passed. - `pano_cli_simulate_stroke_smoke` passed and reports deterministic stroke sample counts/distances. @@ -777,6 +782,9 @@ 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 a metadata-only PPI writer through JSON + automation and is covered by a save/load round-trip smoke test. Full + dirty-face payload 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 cf5069d..16cb8cd 100644 --- a/src/assets/ppi_header.cpp +++ b/src/assets/ppi_header.cpp @@ -5,6 +5,7 @@ #include #include +#include #include namespace pp::assets { @@ -43,6 +44,26 @@ namespace { return pp::foundation::Result::success(std::bit_cast(bits.value())); } +void append_u32(std::vector& bytes, std::uint32_t value) +{ + bytes.push_back(static_cast(value & 0xffU)); + bytes.push_back(static_cast((value >> 8U) & 0xffU)); + bytes.push_back(static_cast((value >> 16U) & 0xffU)); + bytes.push_back(static_cast((value >> 24U) & 0xffU)); +} + +void append_f32(std::vector& bytes, float value) +{ + append_u32(bytes, std::bit_cast(value)); +} + +void append_ascii(std::vector& bytes, std::string_view value) +{ + for (const auto ch : value) { + bytes.push_back(static_cast(ch)); + } +} + [[nodiscard]] pp::foundation::Status skip_bytes( pp::foundation::ByteReader& reader, std::size_t bytes) noexcept @@ -616,4 +637,66 @@ pp::foundation::Result decode_ppi_project_images(std::s return pp::foundation::Result::success(std::move(decoded)); } +pp::foundation::Result> create_minimal_ppi_project(PpiMinimalProjectConfig config) +{ + const auto canvas_status = validate_canvas_size(config.width, config.height); + if (!canvas_status.ok()) { + return pp::foundation::Result>::failure(canvas_status); + } + + if (config.layer_name.empty()) { + return pp::foundation::Result>::failure( + pp::foundation::Status::invalid_argument("PPI layer name must not be empty")); + } + + if (config.layer_name.size() > max_ppi_layer_name_length) { + return pp::foundation::Result>::failure( + pp::foundation::Status::out_of_range("PPI layer name exceeds the configured limit")); + } + + if (config.frame_duration_ms == 0) { + return pp::foundation::Result>::failure( + pp::foundation::Status::invalid_argument("PPI frame duration must be greater than zero")); + } + + std::vector bytes { + std::byte { 'P' }, + std::byte { 'P' }, + std::byte { 'I' }, + std::byte { 0 }, + }; + append_u32(bytes, 0); + append_u32(bytes, 4); + append_u32(bytes, 0); + append_u32(bytes, 0); + append_u32(bytes, 0); + append_u32(bytes, 0); + append_u32(bytes, 128); + append_u32(bytes, 128); + append_u32(bytes, 4); + + constexpr std::size_t thumbnail_bytes = 128U * 128U * 4U; + bytes.resize(ppi_header_size + thumbnail_bytes, std::byte { 0 }); + + append_u32(bytes, config.width); + append_u32(bytes, config.height); + append_u32(bytes, 1); + append_u32(bytes, 1); + append_u32(bytes, 0); + append_f32(bytes, 1.0F); + append_u32(bytes, static_cast(config.layer_name.size())); + append_ascii(bytes, config.layer_name); + 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) { + append_u32(bytes, 0); + } + append_u32(bytes, 0); + + return pp::foundation::Result>::success(std::move(bytes)); +} + } diff --git a/src/assets/ppi_header.h b/src/assets/ppi_header.h index 4321bd1..721143d 100644 --- a/src/assets/ppi_header.h +++ b/src/assets/ppi_header.h @@ -118,6 +118,13 @@ struct PpiDecodedProjectImages { std::vector faces; }; +struct PpiMinimalProjectConfig { + std::uint32_t width = 0; + std::uint32_t height = 0; + std::string layer_name; + std::uint32_t frame_duration_ms = 100; +}; + [[nodiscard]] pp::foundation::Result parse_ppi_header( std::span bytes) noexcept; @@ -143,4 +150,7 @@ struct PpiDecodedProjectImages { [[nodiscard]] pp::foundation::Result decode_ppi_project_images( std::span bytes); +[[nodiscard]] pp::foundation::Result> create_minimal_ppi_project( + PpiMinimalProjectConfig config); + } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8cd59bb..5a3b697 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -283,6 +283,14 @@ if(TARGET pano_cli) LABELS "assets;document;integration;desktop-fast" PASS_REGULAR_EXPRESSION "\"command\":\"load-project\".*\"pixelDataLoaded\":false.*\"facePayloads\":0.*\"document\":\\{\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"animationDurationMs\":100,\"layerNames\":\\[\"Ink\"\\],\"layerFrameCounts\":\\[1\\],\"layerDurationsMs\":\\[100\\]") + add_test(NAME pano_cli_save_project_roundtrip_smoke + COMMAND "${CMAKE_COMMAND}" + -DPANO_CLI=$ + -DOUTPUT_PATH=${CMAKE_CURRENT_BINARY_DIR}/data/generated/roundtrip.ppi + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/pano_cli_save_project_roundtrip.cmake") + set_tests_properties(pano_cli_save_project_roundtrip_smoke PROPERTIES + LABELS "assets;document;integration;desktop-fast") + add_test(NAME pano_cli_parse_layout_smoke COMMAND pano_cli parse-layout --path "${CMAKE_CURRENT_SOURCE_DIR}/data/layouts/simple-layout.xml") set_tests_properties(pano_cli_parse_layout_smoke PROPERTIES diff --git a/tests/assets/ppi_header_tests.cpp b/tests/assets/ppi_header_tests.cpp index 0c06d31..8eccb4d 100644 --- a/tests/assets/ppi_header_tests.cpp +++ b/tests/assets/ppi_header_tests.cpp @@ -9,6 +9,7 @@ #include using pp::assets::parse_ppi_header; +using pp::assets::create_minimal_ppi_project; using pp::assets::decode_ppi_project_images; using pp::assets::parse_ppi_project_index; using pp::assets::parse_ppi_project_summary; @@ -384,6 +385,59 @@ void rejects_invalid_project_body_summaries(pp::tests::Harness& h) PP_EXPECT(h, bad_layer_name_result.status().code == StatusCode::out_of_range); } +void creates_minimal_project_for_roundtrip_load(pp::tests::Harness& h) +{ + const auto project = create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { + .width = 256, + .height = 128, + .layer_name = "Roundtrip", + .frame_duration_ms = 333, + }); + + PP_EXPECT(h, project.ok()); + const auto index = parse_ppi_project_index(project.value()); + PP_EXPECT(h, index.ok()); + PP_EXPECT(h, index.value().layout.thumbnail_bytes == 128U * 128U * 4U); + PP_EXPECT(h, index.value().body.summary.width == 256U); + PP_EXPECT(h, index.value().body.summary.height == 128U); + PP_EXPECT(h, index.value().body.summary.layer_count == 1U); + PP_EXPECT(h, index.value().body.summary.declared_frame_count == 1U); + PP_EXPECT(h, index.value().body.summary.dirty_face_count == 0U); + PP_EXPECT(h, index.value().body.layers[0].name == "Roundtrip"); + PP_EXPECT(h, index.value().body.layers[0].opacity == 1.0F); + PP_EXPECT(h, index.value().body.layers[0].visible); + PP_EXPECT(h, index.value().body.layers[0].frames[0].duration_ms == 333U); +} + +void rejects_invalid_minimal_project_writer_inputs(pp::tests::Harness& h) +{ + const auto no_size = create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { + .width = 0, + .height = 128, + .layer_name = "Ink", + .frame_duration_ms = 100, + }); + const auto no_name = create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { + .width = 128, + .height = 128, + .layer_name = "", + .frame_duration_ms = 100, + }); + const auto no_duration = create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { + .width = 128, + .height = 128, + .layer_name = "Ink", + .frame_duration_ms = 0, + }); + + PP_EXPECT(h, !no_size.ok()); + PP_EXPECT(h, no_size.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !no_name.ok()); + 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); +} + } int main() @@ -401,5 +455,7 @@ int main() harness.run("rejects_metadata_only_payload_when_decoding_pixels", rejects_metadata_only_payload_when_decoding_pixels); 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("rejects_invalid_minimal_project_writer_inputs", rejects_invalid_minimal_project_writer_inputs); return harness.finish(); } diff --git a/tests/cmake/pano_cli_save_project_roundtrip.cmake b/tests/cmake/pano_cli_save_project_roundtrip.cmake new file mode 100644 index 0000000..ea118dc --- /dev/null +++ b/tests/cmake/pano_cli_save_project_roundtrip.cmake @@ -0,0 +1,59 @@ +if(NOT DEFINED PANO_CLI) + message(FATAL_ERROR "PANO_CLI must be set") +endif() + +if(NOT DEFINED OUTPUT_PATH) + message(FATAL_ERROR "OUTPUT_PATH must be set") +endif() + +get_filename_component(output_dir "${OUTPUT_PATH}" DIRECTORY) +file(MAKE_DIRECTORY "${output_dir}") +file(REMOVE "${OUTPUT_PATH}") + +execute_process( + COMMAND "${PANO_CLI}" save-project + --path "${OUTPUT_PATH}" + --width 96 + --height 48 + --layer-name Roundtrip + --frame-duration-ms 321 + RESULT_VARIABLE save_result + OUTPUT_VARIABLE save_output + ERROR_VARIABLE save_error) + +if(NOT save_result EQUAL 0) + message(FATAL_ERROR "save-project failed: ${save_output}${save_error}") +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) + message(FATAL_ERROR "save-project output did not contain expected summary: ${save_output}") +endif() + +if(NOT EXISTS "${OUTPUT_PATH}") + message(FATAL_ERROR "save-project did not create ${OUTPUT_PATH}") +endif() + +execute_process( + COMMAND "${PANO_CLI}" load-project --path "${OUTPUT_PATH}" + RESULT_VARIABLE load_result + OUTPUT_VARIABLE load_output + ERROR_VARIABLE load_error) + +if(NOT load_result EQUAL 0) + message(FATAL_ERROR "load-project failed after save-project: ${load_output}${load_error}") +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}" "\"layerNames\":[\"Roundtrip\"]" load_layer_index) +if(load_command_index LESS 0 + OR load_width_index LESS 0 + OR load_height_index LESS 0 + OR load_duration_index LESS 0 + OR load_layer_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 090cd31..356877f 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -31,6 +31,14 @@ struct DocumentArgs { std::uint32_t frame_duration_ms = 100; }; +struct SaveProjectArgs { + std::string path; + std::uint32_t width = 0; + std::uint32_t height = 0; + std::string layer_name = "Ink"; + std::uint32_t frame_duration_ms = 100; +}; + struct InspectImageArgs { std::string path; }; @@ -144,6 +152,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]\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" @@ -249,6 +258,108 @@ int create_document(int argc, char** argv) return 0; } +pp::foundation::Status parse_save_project_args(int argc, char** argv, SaveProjectArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--path" || key == "--layer-name") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + + if (key == "--path") { + args.path = argv[++i]; + } else { + args.layer_name = argv[++i]; + } + } else if (key == "--width" || key == "--height" || key == "--frame-duration-ms") { + 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 { + args.frame_duration_ms = value.value(); + } + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + if (args.path.empty()) { + return pp::foundation::Status::invalid_argument("path must not be empty"); + } + + if (args.width == 0 || args.height == 0) { + return pp::foundation::Status::invalid_argument("width and height must be greater than zero"); + } + + if (args.layer_name.empty()) { + return pp::foundation::Status::invalid_argument("layer name must not be empty"); + } + + if (args.frame_duration_ms == 0) { + return pp::foundation::Status::invalid_argument("frame duration must be greater than zero"); + } + + return pp::foundation::Status::success(); +} + +int save_project(int argc, char** argv) +{ + SaveProjectArgs args; + const auto status = parse_save_project_args(argc, argv, args); + if (!status.ok()) { + print_error("save-project", status.message); + return 2; + } + + const auto project = pp::assets::create_minimal_ppi_project(pp::assets::PpiMinimalProjectConfig { + .width = args.width, + .height = args.height, + .layer_name = args.layer_name, + .frame_duration_ms = args.frame_duration_ms, + }); + if (!project) { + print_error("save-project", project.status().message); + return 2; + } + + std::ofstream stream(args.path, std::ios::binary); + if (!stream) { + print_error("save-project", "project file could not be opened for writing"); + return 2; + } + + const auto& bytes = project.value(); + stream.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!stream) { + print_error("save-project", "project file could not be written"); + return 2; + } + + std::cout << "{\"ok\":true,\"command\":\"save-project\"" + << ",\"path\":\"" << json_escape(args.path) << "\"" + << ",\"format\":\"ppi\"" + << ",\"bytes\":" << bytes.size() + << ",\"document\":{\"width\":" << args.width + << ",\"height\":" << args.height + << ",\"layers\":1" + << ",\"frames\":1" + << ",\"layerName\":\"" << json_escape(args.layer_name) << "\"" + << ",\"frameDurationMs\":" << args.frame_duration_ms + << "}}\n"; + return 0; +} + pp::foundation::Status parse_inspect_image_args(int argc, char** argv, InspectImageArgs& args) { for (int i = 2; i < argc; ++i) { @@ -1444,6 +1555,10 @@ int main(int argc, char** argv) return record_render(argc, argv); } + if (command == "save-project") { + return save_project(argc, argv); + } + print_error(command, "unknown command"); return 2; }