diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 9af5d73..173af00 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -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 diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 2a8e3f7..6b428df 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -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 diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 81df018..5d7a543 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -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 diff --git a/src/app_core/document_export.h b/src/app_core/document_export.h index b7f56fd..57d1760 100644 --- a/src/app_core/document_export.h +++ b/src/app_core/document_export.h @@ -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(lhs - 'A' + 'a'); + } + auto rhs = right[i]; + if (rhs >= 'A' && rhs <= 'Z') { + rhs = static_cast(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 diff --git a/src/legacy_document_export_services.cpp b/src/legacy_document_export_services.cpp index 16a2dfc..cc2c99d 100644 --- a/src/legacy_document_export_services.cpp +++ b/src/legacy_document_export_services.cpp @@ -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(lhs - 'A' + 'a'); - } - auto rhs = right[i]; - if (rhs >= 'A' && rhs <= 'Z') { - rhs = static_cast(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(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()) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 93066f2..6458b73 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/app_core/document_export_tests.cpp b/tests/app_core/document_export_tests.cpp index d366817..b87554b 100644 --- a/tests/app_core/document_export_tests.cpp +++ b/tests/app_core/document_export_tests.cpp @@ -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); diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 7e687f5..42edab5 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -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)