Move export snapshot target support to app core

This commit is contained in:
2026-06-06 11:10:26 +02:00
parent 7575f51c45
commit 41279c8743
8 changed files with 208 additions and 110 deletions

View File

@@ -863,7 +863,8 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\apple-remote-build.p
- `pano_cli plan-export-snapshot-route` exposes the `pp_app_core` route policy
that decides whether payload-complete document snapshots use pure
document/renderer export writers or retained legacy exporters, including
target/platform unsupported and pending renderer-readback fallback reasons.
PNG/JPEG equirectangular target support, collection/cube target support,
platform unsupported, and pending renderer-readback fallback reasons.
- `pano_cli plan-export-start` exposes `pp_app_core` export availability
planning for license-gated, demo-blocked, and missing-canvas states as JSON;
the live image, layer, animation-frame, depth, and cube-face export dialogs

View File

@@ -585,11 +585,12 @@ agent or engineer to remove them without reconstructing context from chat.
- 2026-06-06: DEBT-0010/DEBT-0036/DEBT-0043 were narrowed again.
`pp_app_core` now owns the document-snapshot export route decision used by
live equirectangular, layer, animation-frame, and cube-face export bridges,
including payload-complete writer use, target/platform unsupported fallback,
and pending renderer-readback fallback reasons. `pano_cli
plan-export-snapshot-route` exposes the same route policy. Retained writers,
Web handoff, video/depth export execution, and renderer-owned payload readback
remain open.
including payload-complete writer use, PNG/JPEG equirectangular target
support, collection/cube target support, target/platform unsupported
fallback, and pending renderer-readback fallback reasons. `pano_cli
plan-export-snapshot-route` exposes the same route policy, including
unsupported target paths. Retained writers, Web handoff, video/depth export
execution, and renderer-owned payload readback remain open.
- 2026-06-05: DEBT-0010/DEBT-0036/DEBT-0043 were narrowed again.
`pp_paint_renderer` now exports independent layer equirectangular PNGs and
merged animation-frame equirectangular PNGs from payload-complete

View File

@@ -731,10 +731,11 @@ desktop JPEG equirectangular export now uses the same projection through
`pp_paint_renderer`, `pp_assets` JPEG encoding, and GPano XMP injection before
retained fallback. `pp_app_core` now owns the document-snapshot export route
decision used by those live adapters, covering platform support, target support,
and incomplete renderer-payload fallback; `pano_cli plan-export-snapshot-route`
exposes the same decision for automation. Web handoff, video, depth writer
replacement, and incomplete-readback cases still delegate to retained `Canvas`
writers after route/readiness reporting.
PNG/JPEG equirectangular target support, collection/cube target support, and
incomplete renderer-payload fallback; `pano_cli plan-export-snapshot-route`
exposes the same decision for automation, including unsupported target paths.
Web handoff, video, depth writer replacement, and incomplete-readback cases
still delegate to retained `Canvas` writers after route/readiness reporting.
Depth export now also plans the retained image/depth file targets in
`pp_app_core` and logs a `pp_paint_renderer` document depth render plan for the
legacy 1024x1024 perspective render plus per-layer depth pass before falling
@@ -2302,8 +2303,9 @@ Results:
failures are logged and retained export still continues to preserve behavior.
- `pp_app_core` now owns the document-snapshot export route decision for
equirectangular, layer, animation-frame, and cube-face writers, including
target/platform support and incomplete renderer-payload fallback reasons.
`pano_cli plan-export-snapshot-route` exposes the same policy as JSON.
PNG/JPEG target support, collection/cube target support, platform support,
and incomplete renderer-payload fallback reasons. `pano_cli
plan-export-snapshot-route` exposes the same policy as JSON.
- `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

View File

@@ -424,6 +424,71 @@ public:
return "Document export failed";
}
[[nodiscard]] constexpr bool ascii_iequals(std::string_view left, std::string_view right) noexcept
{
if (left.size() != right.size()) {
return false;
}
for (std::size_t i = 0; i < left.size(); ++i) {
auto lhs = left[i];
if (lhs >= 'A' && lhs <= 'Z') {
lhs = static_cast<char>(lhs - 'A' + 'a');
}
auto rhs = right[i];
if (rhs >= 'A' && rhs <= 'Z') {
rhs = static_cast<char>(rhs - 'A' + 'a');
}
if (lhs != rhs) {
return false;
}
}
return true;
}
[[nodiscard]] constexpr bool document_export_path_has_extension(
std::string_view path,
std::string_view extension) noexcept
{
return path.size() >= extension.size()
&& ascii_iequals(path.substr(path.size() - extension.size()), extension);
}
[[nodiscard]] constexpr bool document_export_path_is_png_target(std::string_view path) noexcept
{
return document_export_path_has_extension(path, ".png");
}
[[nodiscard]] constexpr bool document_export_path_is_jpeg_target(std::string_view path) noexcept
{
return document_export_path_has_extension(path, ".jpg")
|| document_export_path_has_extension(path, ".jpeg");
}
[[nodiscard]] constexpr bool document_export_snapshot_target_supported(
DocumentExportExecutionKind kind,
std::string_view target_path = {}) noexcept
{
switch (kind) {
case DocumentExportExecutionKind::equirectangular_file:
return document_export_path_is_png_target(target_path)
|| document_export_path_is_jpeg_target(target_path);
case DocumentExportExecutionKind::layers_collection:
case DocumentExportExecutionKind::layers_stem:
case DocumentExportExecutionKind::animation_frames_collection:
case DocumentExportExecutionKind::animation_frames_stem:
case DocumentExportExecutionKind::cube_faces:
return true;
case DocumentExportExecutionKind::depth:
case DocumentExportExecutionKind::animation_mp4:
case DocumentExportExecutionKind::timelapse:
return false;
}
return false;
}
[[nodiscard]] constexpr DocumentExportSnapshotRoutePlan plan_document_export_snapshot_route(
DocumentExportExecutionKind kind,
DocumentCanvasSaveSnapshotReport report,
@@ -456,6 +521,19 @@ public:
return plan;
}
[[nodiscard]] constexpr DocumentExportSnapshotRoutePlan plan_document_export_snapshot_route_for_target(
DocumentExportExecutionKind kind,
DocumentCanvasSaveSnapshotReport report,
std::string_view target_path,
bool platform_supported) noexcept
{
return plan_document_export_snapshot_route(
kind,
report,
document_export_snapshot_target_supported(kind, target_path),
platform_supported);
}
[[nodiscard]] constexpr DocumentExportCollectionTargetPlan plan_document_export_collection_target(
DocumentExportCollectionKind kind,
bool use_work_directory_collection) noexcept

View File

@@ -25,53 +25,6 @@ void show_export_success_dialog(
}
}
bool is_png_export_target(std::string_view path) noexcept
{
if (path.size() < 4U) {
return false;
}
const auto extension = path.substr(path.size() - 4U);
return extension[0] == '.'
&& (extension[1] == 'p' || extension[1] == 'P')
&& (extension[2] == 'n' || extension[2] == 'N')
&& (extension[3] == 'g' || extension[3] == 'G');
}
bool ascii_iequals(std::string_view left, std::string_view right) noexcept
{
if (left.size() != right.size()) {
return false;
}
for (std::size_t i = 0; i < left.size(); ++i) {
auto lhs = left[i];
if (lhs >= 'A' && lhs <= 'Z') {
lhs = static_cast<char>(lhs - 'A' + 'a');
}
auto rhs = right[i];
if (rhs >= 'A' && rhs <= 'Z') {
rhs = static_cast<char>(rhs - 'A' + 'a');
}
if (lhs != rhs) {
return false;
}
}
return true;
}
bool has_extension(std::string_view path, std::string_view extension) noexcept
{
return path.size() >= extension.size()
&& ascii_iequals(path.substr(path.size() - extension.size()), extension);
}
bool is_jpeg_export_target(std::string_view path) noexcept
{
return has_extension(path, ".jpg") || has_extension(path, ".jpeg");
}
struct LegacyDocumentExportSnapshotReports {
pp::app::DocumentCanvasSnapshotResult snapshot;
pp::paint_renderer::DocumentFrameFacePngExportResult face_pngs;
@@ -202,18 +155,18 @@ void prepare_legacy_document_export_snapshot_or_continue(App& app, const char* c
}
}
bool use_legacy_document_snapshot_writer(
bool should_use_document_snapshot_writer(
const char* context,
pp::app::DocumentExportExecutionKind kind,
const LegacyDocumentExportSnapshotReports& reports,
bool target_supported,
std::string_view target_path,
bool platform_supported)
{
const auto report = pp::app::make_document_canvas_save_snapshot_report(reports.snapshot);
const auto route = pp::app::plan_document_export_snapshot_route(
const auto route = pp::app::plan_document_export_snapshot_route_for_target(
kind,
report,
target_supported,
target_path,
platform_supported);
if (!route.uses_document_snapshot_writer) {
LOG(
@@ -344,7 +297,7 @@ pp::foundation::Status export_equirectangular_from_document_snapshot(
pp::paint_renderer::DocumentFrameEquirectangularPngExportResult png_export;
pp::paint_renderer::DocumentFrameEquirectangularJpegExportResult jpeg_export;
if (is_png_export_target(target.path)) {
if (pp::app::document_export_path_is_png_target(target.path)) {
auto exported = pp::paint_renderer::export_document_frame_equirectangular_png(reports.face_pngs.composite);
if (!exported) {
return exported.status();
@@ -359,7 +312,7 @@ pp::foundation::Status export_equirectangular_from_document_snapshot(
png_export.face_payload_count,
png_export.composited_layer_face_count);
bytes = std::span<const std::byte>(png_export.png.data(), png_export.png.size());
} else if (is_jpeg_export_target(target.path)) {
} else if (pp::app::document_export_path_is_jpeg_target(target.path)) {
auto exported = pp::paint_renderer::export_document_frame_equirectangular_jpeg(reports.face_pngs.composite);
if (!exported) {
return exported.status();
@@ -405,40 +358,37 @@ public:
{
auto* app = &app_;
#if !__WEB__
if (is_png_export_target(target.path) || is_jpeg_export_target(target.path)) {
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-equirectangular");
if (prepared) {
if (use_legacy_document_snapshot_writer(
"export-equirectangular",
pp::app::DocumentExportExecutionKind::equirectangular_file,
prepared.value(),
true,
true)) {
const auto exported = export_equirectangular_from_document_snapshot(app_, target, prepared.value());
if (exported.ok()) {
show_export_success_dialog(
app_,
pp::app::plan_document_export_success_dialog(
pp::app::DocumentExportSuccessKind::equirectangular,
pp::app::document_export_equirectangular_platform_destination(),
app_.work_path));
return;
}
LOG(
"export-equirectangular document export writer retained legacy export after failure: %s",
exported.message);
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-equirectangular");
if (prepared) {
if (should_use_document_snapshot_writer(
"export-equirectangular",
pp::app::DocumentExportExecutionKind::equirectangular_file,
prepared.value(),
target.path,
true)) {
const auto exported = export_equirectangular_from_document_snapshot(app_, target, prepared.value());
if (exported.ok()) {
show_export_success_dialog(
app_,
pp::app::plan_document_export_success_dialog(
pp::app::DocumentExportSuccessKind::equirectangular,
pp::app::document_export_equirectangular_platform_destination(),
app_.work_path));
return;
}
} else {
LOG(
"export-equirectangular document export snapshot bridge retained legacy export after failure: %s",
prepared.status().message);
"export-equirectangular document export writer retained legacy export after failure: %s",
exported.message);
}
} else
#endif
{
prepare_legacy_document_export_snapshot_or_continue(app_, "export-equirectangular");
} else {
LOG(
"export-equirectangular document export snapshot bridge retained legacy export after failure: %s",
prepared.status().message);
}
#else
prepare_legacy_document_export_snapshot_or_continue(app_, "export-equirectangular");
#endif
app_.canvas->m_canvas->export_equirectangular(target.path, [app, target] {
#if __WEB__
app->ui_task([app, target] {
@@ -465,11 +415,11 @@ public:
const auto collection_target = pp::app::DocumentExportCollectionTarget {
.stem_path = target.stem_path,
};
if (use_legacy_document_snapshot_writer(
if (should_use_document_snapshot_writer(
"export-layers",
pp::app::DocumentExportExecutionKind::layers_stem,
prepared.value(),
true,
{},
true)) {
const auto exported = export_layers_from_document_snapshot(app_, collection_target, prepared.value());
if (exported.ok()) {
@@ -508,11 +458,11 @@ public:
#if !__WEB__
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-layers");
if (prepared) {
if (use_legacy_document_snapshot_writer(
if (should_use_document_snapshot_writer(
"export-layers",
pp::app::DocumentExportExecutionKind::layers_collection,
prepared.value(),
true,
{},
true)) {
const auto exported = export_layers_from_document_snapshot(app_, target, prepared.value());
if (exported.ok()) {
@@ -552,11 +502,11 @@ public:
const auto collection_target = pp::app::DocumentExportCollectionTarget {
.stem_path = target.stem_path,
};
if (use_legacy_document_snapshot_writer(
if (should_use_document_snapshot_writer(
"export-animation-frames",
pp::app::DocumentExportExecutionKind::animation_frames_stem,
prepared.value(),
true,
{},
true)) {
const auto exported = export_animation_frames_from_document_snapshot(
app_,
@@ -600,11 +550,11 @@ public:
#if !__WEB__
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-animation-frames");
if (prepared) {
if (use_legacy_document_snapshot_writer(
if (should_use_document_snapshot_writer(
"export-animation-frames",
pp::app::DocumentExportExecutionKind::animation_frames_collection,
prepared.value(),
true,
{},
true)) {
const auto exported = export_animation_frames_from_document_snapshot(app_, target, prepared.value());
if (exported.ok()) {
@@ -697,11 +647,11 @@ public:
auto* app = &app_;
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-cube-faces");
if (prepared) {
if (use_legacy_document_snapshot_writer(
if (should_use_document_snapshot_writer(
"export-cube-faces",
pp::app::DocumentExportExecutionKind::cube_faces,
prepared.value(),
true,
{},
true)) {
const auto exported = export_cube_faces_from_document_snapshot(app_, document_name, prepared.value());
if (exported.ok()) {

View File

@@ -913,10 +913,10 @@ if(TARGET pano_cli)
PASS_REGULAR_EXPRESSION "\"command\":\"plan-export-snapshot-route\".*\"kind\":\"layers-collection\".*\"payloadComplete\":false.*\"action\":\"use-legacy-export\".*\"usesDocumentSnapshotWriter\":false.*\"fallbackReason\":\"document snapshot still requires renderer payload readback\"")
add_test(NAME pano_cli_plan_export_snapshot_route_unsupported_target_smoke
COMMAND pano_cli plan-export-snapshot-route --kind depth --unsupported-target)
COMMAND pano_cli plan-export-snapshot-route --kind equirectangular --target-path D:/Paint/demo.tif)
set_tests_properties(pano_cli_plan_export_snapshot_route_unsupported_target_smoke PROPERTIES
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-export-snapshot-route\".*\"kind\":\"depth\".*\"targetSupported\":false.*\"action\":\"use-legacy-export\".*\"fallbackReason\":\"document snapshot export does not support this target\"")
PASS_REGULAR_EXPRESSION "\"command\":\"plan-export-snapshot-route\".*\"kind\":\"equirectangular\".*\"targetPath\":\"D:/Paint/demo.tif\".*\"targetSupported\":false.*\"action\":\"use-legacy-export\".*\"fallbackReason\":\"document snapshot export does not support this target\"")
add_test(NAME pano_cli_plan_export_menu_rejects_unknown
COMMAND pano_cli plan-export-menu --kind unknown)

View File

@@ -775,6 +775,56 @@ void export_snapshot_route_uses_document_writer_when_ready(pp::tests::Harness& h
PP_EXPECT(harness, plan.fallback_reason.empty());
}
void export_snapshot_target_support_covers_document_writer_formats(pp::tests::Harness& harness)
{
PP_EXPECT(
harness,
pp::app::document_export_snapshot_target_supported(
pp::app::DocumentExportExecutionKind::equirectangular_file,
"D:/Paint/demo.PNG"));
PP_EXPECT(
harness,
pp::app::document_export_snapshot_target_supported(
pp::app::DocumentExportExecutionKind::equirectangular_file,
"D:/Paint/demo.jpeg"));
PP_EXPECT(
harness,
!pp::app::document_export_snapshot_target_supported(
pp::app::DocumentExportExecutionKind::equirectangular_file,
"D:/Paint/demo.tif"));
PP_EXPECT(
harness,
pp::app::document_export_snapshot_target_supported(
pp::app::DocumentExportExecutionKind::layers_collection));
PP_EXPECT(
harness,
pp::app::document_export_snapshot_target_supported(
pp::app::DocumentExportExecutionKind::cube_faces));
PP_EXPECT(
harness,
!pp::app::document_export_snapshot_target_supported(
pp::app::DocumentExportExecutionKind::depth));
}
void export_snapshot_route_for_target_rejects_unsupported_extension(pp::tests::Harness& harness)
{
pp::app::DocumentCanvasSaveSnapshotReport report;
report.payload_complete = true;
report.can_export_ppi = true;
const auto plan = pp::app::plan_document_export_snapshot_route_for_target(
pp::app::DocumentExportExecutionKind::equirectangular_file,
report,
"D:/Paint/demo.tif",
true);
PP_EXPECT(harness, !plan.uses_document_snapshot_writer);
PP_EXPECT(harness, !plan.target_supported);
PP_EXPECT(
harness,
plan.fallback_reason == "document snapshot export does not support this target");
}
void export_snapshot_route_falls_back_for_pending_renderer_payloads(pp::tests::Harness& harness)
{
pp::app::DocumentCanvasSaveSnapshotReport report;
@@ -1187,6 +1237,12 @@ int main()
harness.run("export license disabled dialog preserves legacy warning", export_license_disabled_dialog_preserves_legacy_warning);
harness.run("export execution log messages cover legacy paths", export_execution_log_messages_cover_legacy_paths);
harness.run("export snapshot route uses document writer when ready", export_snapshot_route_uses_document_writer_when_ready);
harness.run(
"export snapshot target support covers document writer formats",
export_snapshot_target_support_covers_document_writer_formats);
harness.run(
"export snapshot route for target rejects unsupported extension",
export_snapshot_route_for_target_rejects_unsupported_extension);
harness.run(
"export snapshot route falls back for pending renderer payloads",
export_snapshot_route_falls_back_for_pending_renderer_payloads);

View File

@@ -193,6 +193,7 @@ struct PlanExportMenuArgs {
struct PlanExportSnapshotRouteArgs {
std::string kind = "equirectangular";
std::string target_path;
std::uint32_t captured_face_payloads = 6;
std::uint32_t pending_face_payloads = 6;
bool target_supported = true;
@@ -2475,7 +2476,7 @@ void print_help()
<< " plan-document-session-prompt --kind close-unsaved|save-before-workflow|new-document-overwrite|file-overwrite|save-error [--name NAME]\n"
<< " plan-export-start [--requires-license] [--demo] [--no-canvas]\n"
<< " plan-export-menu --kind jpeg|png|layers|cube-faces|depth|animation-frames|animation-mp4|timelapse [--demo] [--no-canvas]\n"
<< " plan-export-snapshot-route --kind equirectangular|layers-collection|layers-stem|animation-frames-collection|animation-frames-stem|cube-faces|depth [--captured-face-payloads N] [--pending-face-payloads N] [--unsupported-target] [--unsupported-platform]\n"
<< " plan-export-snapshot-route --kind equirectangular|layers-collection|layers-stem|animation-frames-collection|animation-frames-stem|cube-faces|depth [--target-path FILE] [--captured-face-payloads N] [--pending-face-payloads N] [--unsupported-target] [--unsupported-platform]\n"
<< " plan-export-target --kind file|collection|stem|cube-faces|name --doc-name NAME [--work-dir DIR] [--directory DIR] [--extension EXT] [--suffix SUFFIX]\n"
<< " plan-export-message --kind equirectangular|layers|animation-frames|depth|cube-faces|animation-mp4|timelapse --destination photos|pictures|files|work|path|success|suppressed [--detail TEXT]\n"
<< " plan-export-report --kind license-disabled|equirectangular|layers|animation-frames|depth|cube-faces|animation-mp4|timelapse [--message TEXT]\n"
@@ -3847,6 +3848,11 @@ pp::foundation::Status parse_plan_export_snapshot_route_args(
return pp::foundation::Status::invalid_argument("missing value for option");
}
args.kind = argv[++i];
} else if (key == "--target-path") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
args.target_path = argv[++i];
} else if (key == "--captured-face-payloads") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
@@ -3898,18 +3904,22 @@ int plan_export_snapshot_route(int argc, char** argv)
report.payload_complete = args.captured_face_payloads >= args.pending_face_payloads;
report.can_export_ppi = report.payload_complete;
const bool target_supported = args.target_supported
&& (args.target_path.empty()
|| pp::app::document_export_snapshot_target_supported(kind.value(), args.target_path));
const auto plan = pp::app::plan_document_export_snapshot_route(
kind.value(),
report,
args.target_supported,
target_supported,
args.platform_supported);
std::cout << "{\"ok\":true,\"command\":\"plan-export-snapshot-route\""
<< ",\"state\":{\"kind\":\"" << json_escape(args.kind)
<< "\",\"targetPath\":\"" << json_escape(args.target_path)
<< "\",\"capturedFacePayloads\":" << args.captured_face_payloads
<< ",\"pendingFacePayloads\":" << args.pending_face_payloads
<< ",\"payloadComplete\":" << json_bool(report.payload_complete)
<< ",\"targetSupported\":" << json_bool(args.target_supported)
<< ",\"targetSupported\":" << json_bool(target_supported)
<< ",\"platformSupported\":" << json_bool(args.platform_supported)
<< "},\"plan\":{\"kind\":\"" << document_export_execution_kind_name(plan.kind)
<< "\",\"action\":\"" << document_export_snapshot_route_action_name(plan.action)