Add metadata-only PPI save automation

This commit is contained in:
2026-06-02 10:10:30 +02:00
parent b0445382dd
commit 374cb5b075
8 changed files with 345 additions and 0 deletions

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
#include <bit>
#include <limits>
#include <string_view>
#include <utility>
namespace pp::assets {
@@ -43,6 +44,26 @@ namespace {
return pp::foundation::Result<float>::success(std::bit_cast<float>(bits.value()));
}
void append_u32(std::vector<std::byte>& bytes, std::uint32_t value)
{
bytes.push_back(static_cast<std::byte>(value & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 8U) & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 16U) & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 24U) & 0xffU));
}
void append_f32(std::vector<std::byte>& bytes, float value)
{
append_u32(bytes, std::bit_cast<std::uint32_t>(value));
}
void append_ascii(std::vector<std::byte>& bytes, std::string_view value)
{
for (const auto ch : value) {
bytes.push_back(static_cast<std::byte>(ch));
}
}
[[nodiscard]] pp::foundation::Status skip_bytes(
pp::foundation::ByteReader& reader,
std::size_t bytes) noexcept
@@ -616,4 +637,66 @@ pp::foundation::Result<PpiDecodedProjectImages> decode_ppi_project_images(std::s
return pp::foundation::Result<PpiDecodedProjectImages>::success(std::move(decoded));
}
pp::foundation::Result<std::vector<std::byte>> 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<std::vector<std::byte>>::failure(canvas_status);
}
if (config.layer_name.empty()) {
return pp::foundation::Result<std::vector<std::byte>>::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<std::vector<std::byte>>::failure(
pp::foundation::Status::out_of_range("PPI layer name exceeds the configured limit"));
}
if (config.frame_duration_ms == 0) {
return pp::foundation::Result<std::vector<std::byte>>::failure(
pp::foundation::Status::invalid_argument("PPI frame duration must be greater than zero"));
}
std::vector<std::byte> 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<std::uint32_t>(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<std::vector<std::byte>>::success(std::move(bytes));
}
}

View File

@@ -118,6 +118,13 @@ struct PpiDecodedProjectImages {
std::vector<PpiDecodedFacePayload> 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<PpiHeaderInfo> parse_ppi_header(
std::span<const std::byte> bytes) noexcept;
@@ -143,4 +150,7 @@ struct PpiDecodedProjectImages {
[[nodiscard]] pp::foundation::Result<PpiDecodedProjectImages> decode_ppi_project_images(
std::span<const std::byte> bytes);
[[nodiscard]] pp::foundation::Result<std::vector<std::byte>> create_minimal_ppi_project(
PpiMinimalProjectConfig config);
}

View File

@@ -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=$<TARGET_FILE:pano_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

View File

@@ -9,6 +9,7 @@
#include <vector>
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();
}

View File

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

View File

@@ -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<const char*>(bytes.data()), static_cast<std::streamsize>(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;
}