Add metadata-only PPI save automation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
59
tests/cmake/pano_cli_save_project_roundtrip.cmake
Normal file
59
tests/cmake/pano_cli_save_project_roundtrip.cmake
Normal 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()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user