Own canvas async work and thin NodeCanvas composite

This commit is contained in:
2026-06-16 07:10:09 +02:00
parent 75f57213ca
commit 4d7a23a1fd
7 changed files with 279 additions and 78 deletions

View File

@@ -18,6 +18,21 @@ agent or engineer to remove them without reconstructing context from chat.
## Reductions ## Reductions
- 2026-06-16: `DEBT-0043`/`DEBT-0040`/`DEBT-0042` were narrowed again. The
retained `Canvas` async import/export/save/open entrypoints in
`src/canvas.cpp` no longer launch detached threads; they now use an owned
local `std::jthread` queue and return completion callbacks to the UI thread,
while retained progress-node mutation, render-task orchestration, and canvas
storage/export ownership remain.
- 2026-06-16: `DEBT-0044` was narrowed again. The retained asynchronous
timelapse export path in `src/legacy_document_export_services.cpp` no longer
launches a detached worker; it now uses a service-owned `std::jthread`
queue and returns the success dialog to the UI thread, while retained
recording/export execution still stays behind the legacy bridge.
- 2026-06-16: `DEBT-0036` was narrowed again. `NodeCanvas` cache-to-screen
checkerboard plus cache-texture composite now route through
`legacy_canvas_draw_merge_services.h` instead of living inline in
`NodeCanvas::draw()`; broader canvas draw orchestration remains retained.
- 2026-06-16: `DEBT-0051`/`DEBT-0052`/`DEBT-0055` were narrowed again. - 2026-06-16: `DEBT-0051`/`DEBT-0052`/`DEBT-0055` were narrowed again.
`src/platform_apple/apple_platform_services.*` no longer reaches `App::I` `src/platform_apple/apple_platform_services.*` no longer reaches `App::I`
for clipboard, display/share, cursor-visibility, or save-ui-state behavior; for clipboard, display/share, cursor-visibility, or save-ui-state behavior;
@@ -2161,7 +2176,7 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0041 | Open | Modernization | Accepted new-document planning/execution dispatch and new-document overwrite prompt metadata now consume pure `pp_app_core` through `App::dialog_newdoc`, `pano_cli plan-new-document`, `pano_cli plan-document-session-prompt`, `NewDocumentServices`, and `src/legacy_document_session_services.*`; new-document overwrite prompt creation now uses `src/legacy_app_dialog_services.*`, and New Document dialog working-directory picker visibility/path formatting now dispatches through `PlatformServices`, but the bridge still mutates legacy app document fields, clears legacy layer UI, resizes legacy `Canvas`, clears legacy history, creates the default layer through legacy UI, mutates unsaved/new-document flags, updates the title, wires overwrite callbacks directly, and handles keyboard/dialog cleanup directly | Preserve current New Document dialog behavior while document creation moves toward app/document/UI services | `pp_app_core_document_session_tests`; `pp_platform_api_tests`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-session-prompt --kind new-document-overwrite`; `pano_cli simulate-app-session --save-intent save`; `ctest --preset desktop-fast --build-config Debug` | New document creation, overwrite confirmation, canvas/document allocation, default layer creation, history clearing, title updates, dirty/new-document state, and keyboard/dialog cleanup are owned by injected app/document/UI services with `App::dialog_newdoc` acting only as a UI adapter | | DEBT-0041 | Open | Modernization | Accepted new-document planning/execution dispatch and new-document overwrite prompt metadata now consume pure `pp_app_core` through `App::dialog_newdoc`, `pano_cli plan-new-document`, `pano_cli plan-document-session-prompt`, `NewDocumentServices`, and `src/legacy_document_session_services.*`; new-document overwrite prompt creation now uses `src/legacy_app_dialog_services.*`, and New Document dialog working-directory picker visibility/path formatting now dispatches through `PlatformServices`, but the bridge still mutates legacy app document fields, clears legacy layer UI, resizes legacy `Canvas`, clears legacy history, creates the default layer through legacy UI, mutates unsaved/new-document flags, updates the title, wires overwrite callbacks directly, and handles keyboard/dialog cleanup directly | Preserve current New Document dialog behavior while document creation moves toward app/document/UI services | `pp_app_core_document_session_tests`; `pp_platform_api_tests`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-session-prompt --kind new-document-overwrite`; `pano_cli simulate-app-session --save-intent save`; `ctest --preset desktop-fast --build-config Debug` | New document creation, overwrite confirmation, canvas/document allocation, default layer creation, history clearing, title updates, dirty/new-document state, and keyboard/dialog cleanup are owned by injected app/document/UI services with `App::dialog_newdoc` acting only as a UI adapter |
| DEBT-0042 | Open | Modernization | Accepted Save As and Save Version planning/execution dispatch, Save As overwrite prompt metadata, and Save As/Save Version history effects now consume pure `pp_app_core` through `App::dialog_save`, `App::dialog_save_ver`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-document-session-prompt`, `DocumentFileSaveServices`, `DocumentVersionSaveServices`, and `src/legacy_document_session_services.*`; Save As overwrite prompt creation now uses `src/legacy_app_dialog_services.*`, and accepted Save As/Save Version execution prepares a payload-bearing canvas snapshot report before retained saving, but the bridge still wires overwrite callbacks directly, delegates actual writing to legacy `Canvas::project_save`, mutates app document name/path/directory fields, marks version saves dirty before saving, updates the title, and handles keyboard/dialog cleanup directly | Preserve current Save As and Save Version behavior while document persistence moves toward app/document/storage/UI services | `pp_app_core_document_session_tests`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-session-prompt --kind file-overwrite --name demo`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli simulate-app-session --save-intent save-as`; `pano_cli simulate-app-session --save-intent save-version`; `ctest --preset desktop-fast --build-config Debug` | Save As overwrite prompting, project-save execution, app document metadata updates, title updates, version-save dirty-state handling, and keyboard/dialog cleanup are owned by injected app/document/storage/UI services with `App::dialog_save` and `App::dialog_save_ver` acting only as UI adapters | | DEBT-0042 | Open | Modernization | Accepted Save As and Save Version planning/execution dispatch, Save As overwrite prompt metadata, and Save As/Save Version history effects now consume pure `pp_app_core` through `App::dialog_save`, `App::dialog_save_ver`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-document-session-prompt`, `DocumentFileSaveServices`, `DocumentVersionSaveServices`, and `src/legacy_document_session_services.*`; Save As overwrite prompt creation now uses `src/legacy_app_dialog_services.*`, and accepted Save As/Save Version execution prepares a payload-bearing canvas snapshot report before retained saving, but the bridge still wires overwrite callbacks directly, delegates actual writing to legacy `Canvas::project_save`, mutates app document name/path/directory fields, marks version saves dirty before saving, updates the title, and handles keyboard/dialog cleanup directly | Preserve current Save As and Save Version behavior while document persistence moves toward app/document/storage/UI services | `pp_app_core_document_session_tests`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-session-prompt --kind file-overwrite --name demo`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli simulate-app-session --save-intent save-as`; `pano_cli simulate-app-session --save-intent save-version`; `ctest --preset desktop-fast --build-config Debug` | Save As overwrite prompting, project-save execution, app document metadata updates, title updates, version-save dirty-state handling, and keyboard/dialog cleanup are owned by injected app/document/storage/UI services with `App::dialog_save` and `App::dialog_save_ver` acting only as UI adapters |
| DEBT-0043 | Open | Modernization | Equirectangular, layer, animation-frame, depth, and cube-face export planning/execution dispatch now consumes pure `pp_app_core` through `App::dialog_export`, `App::dialog_export_layers`, `App::dialog_export_anim_frames`, `App::dialog_export_depth`, `App::dialog_export_cube_faces`, `pano_cli plan-export-*`, `DocumentExportServices`, and `src/legacy_document_export_services.*`; layer/frame dialogs also consume `plan_document_export_collection_target` plus `PlatformServices::uses_work_directory_document_export_collections()` instead of spelling local iOS branches, export success/failure/license dialog metadata plus execution log labels now come from `pp_app_core`, equirectangular/layer/animation-frame/depth/cube-face execution prepares a payload-bearing document snapshot plus the shared `pp_paint_renderer::prepare_document_frame_export_readiness` report, document-snapshot writer-versus-retained fallback routing now comes from tested `pp_app_core` policy including current-platform support consumed by the live bridge, depth export target naming and two-payload write order are covered by tested `pp_app_core` helpers, cube-face export writes the pure face PNG bytes to `pp_app_core` planned work-directory face paths through `execute_document_cube_face_export_write` before falling back to retained Canvas execution on failure, PNG/JPEG equirectangular export writes the pure `pp_paint_renderer` equirectangular payload through `execute_document_export_file_write` before retained fallback, and payload-complete layer/animation-frame collections write pure `pp_paint_renderer` PNG sequences through `execute_document_export_collection_write` before retained fallback, but the bridge still adapts retained filesystem writes/exported-image publishing locally, still calls legacy `Canvas` export methods for Web/incomplete-readback collection exports and depth rendering, creates export directories, handles picker-selected stems, performs Web prepared-file handoff directly, and leaves depth render/readback plus the legacy `.png`/JPEG payload mismatch on the retained path | Preserve current image/collection/depth/cube export behavior while export execution moves toward document/renderer/platform/storage services | `pp_app_core_document_export_tests`; `pp_platform_api_tests`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind layers`; `pano_cli plan-export-target --kind collection --work-dir D:/Paint --doc-name demo --suffix _layers`; `pano_cli plan-export-target --kind cube-faces --work-dir D:/Paint --doc-name demo`; `pano_cli plan-export-message --kind equirectangular --destination work --detail D:/Paint`; `pano_cli plan-export-report --kind license-disabled`; `pano_cli plan-export-snapshot-route --kind layers-collection --captured-face-payloads 3 --pending-face-payloads 6`; `pano_cli simulate-document-export`; `ctest --preset desktop-fast --build-config Debug` | File, collection, stem, depth, and remaining retained export execution, export-directory creation, Web file handoff, picker-selected stem handling, and legacy canvas export calls are owned by injected document/renderer/platform/storage services with export dialogs acting only as UI adapters | | DEBT-0043 | Open | Modernization | Equirectangular, layer, animation-frame, depth, and cube-face export planning/execution dispatch now consumes pure `pp_app_core` through `App::dialog_export`, `App::dialog_export_layers`, `App::dialog_export_anim_frames`, `App::dialog_export_depth`, `App::dialog_export_cube_faces`, `pano_cli plan-export-*`, `DocumentExportServices`, and `src/legacy_document_export_services.*`; layer/frame dialogs also consume `plan_document_export_collection_target` plus `PlatformServices::uses_work_directory_document_export_collections()` instead of spelling local iOS branches, export success/failure/license dialog metadata plus execution log labels now come from `pp_app_core`, equirectangular/layer/animation-frame/depth/cube-face execution prepares a payload-bearing document snapshot plus the shared `pp_paint_renderer::prepare_document_frame_export_readiness` report, document-snapshot writer-versus-retained fallback routing now comes from tested `pp_app_core` policy including current-platform support consumed by the live bridge, depth export target naming and two-payload write order are covered by tested `pp_app_core` helpers, cube-face export writes the pure face PNG bytes to `pp_app_core` planned work-directory face paths through `execute_document_cube_face_export_write` before falling back to retained Canvas execution on failure, PNG/JPEG equirectangular export writes the pure `pp_paint_renderer` equirectangular payload through `execute_document_export_file_write` before retained fallback, and payload-complete layer/animation-frame collections write pure `pp_paint_renderer` PNG sequences through `execute_document_export_collection_write` before retained fallback, but the bridge still adapts retained filesystem writes/exported-image publishing locally, still calls legacy `Canvas` export methods for Web/incomplete-readback collection exports and depth rendering, creates export directories, handles picker-selected stems, performs Web prepared-file handoff directly, and leaves depth render/readback plus the legacy `.png`/JPEG payload mismatch on the retained path | Preserve current image/collection/depth/cube export behavior while export execution moves toward document/renderer/platform/storage services | `pp_app_core_document_export_tests`; `pp_platform_api_tests`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind layers`; `pano_cli plan-export-target --kind collection --work-dir D:/Paint --doc-name demo --suffix _layers`; `pano_cli plan-export-target --kind cube-faces --work-dir D:/Paint --doc-name demo`; `pano_cli plan-export-message --kind equirectangular --destination work --detail D:/Paint`; `pano_cli plan-export-report --kind license-disabled`; `pano_cli plan-export-snapshot-route --kind layers-collection --captured-face-payloads 3 --pending-face-payloads 6`; `pano_cli simulate-document-export`; `ctest --preset desktop-fast --build-config Debug` | File, collection, stem, depth, and remaining retained export execution, export-directory creation, Web file handoff, picker-selected stem handling, and legacy canvas export calls are owned by injected document/renderer/platform/storage services with export dialogs acting only as UI adapters |
| DEBT-0044 | Open | Modernization | Timelapse and animation MP4 export execution dispatch now consumes pure `pp_app_core` through `App::dialog_timelapse_export`, `App::dialog_export_mp4`, `pano_cli plan-export-menu`, `pano_cli plan-export-target --kind name`, `pano_cli plan-export-message`, `pano_cli plan-export-report`, `DocumentVideoExportServices`, and `src/legacy_document_export_services.*`, and success/failure/license dialog metadata plus execution log labels now come from `pp_app_core`, but the bridge still launches legacy desktop timelapse worker threads, calls `App::rec_export`, calls `Canvas::export_anim_mp4`, and owns mobile/Web save callbacks | Preserve current MP4/timelapse export behavior while video export moves toward app/document/renderer/video/platform/storage services | `pp_app_core_document_export_tests`; `pano_cli plan-export-menu --kind animation-mp4`; `pano_cli plan-export-menu --kind timelapse`; `pano_cli plan-export-target --kind name --doc-name demo --suffix -animation`; `pano_cli plan-export-target --kind name --doc-name demo --suffix -timelapse`; `pano_cli plan-export-message --kind timelapse --destination success`; `pano_cli plan-export-report --kind animation-mp4 --message "video export path must not be empty"`; `ctest --preset desktop-fast --build-config Debug` | Timelapse and animation MP4 execution, desktop worker threading, frame readback/video encoding handoff, and mobile/Web save callbacks are owned by injected app/document/renderer/video/platform/storage services with export dialogs acting only as UI adapters | | DEBT-0044 | Open | Modernization | Timelapse and animation MP4 export execution dispatch now consumes pure `pp_app_core` through `App::dialog_timelapse_export`, `App::dialog_export_mp4`, `pano_cli plan-export-menu`, `pano_cli plan-export-target --kind name`, `pano_cli plan-export-message`, `pano_cli plan-export-report`, `DocumentVideoExportServices`, and `src/legacy_document_export_services.*`, and success/failure/license dialog metadata plus execution log labels now come from `pp_app_core`; the asynchronous timelapse path now uses a service-owned `std::jthread` queue with UI-thread success-dialog handoff instead of a detached worker, but the bridge still calls `App::rec_export`, calls `Canvas::export_anim_mp4`, and owns mobile/Web save callbacks | Preserve current MP4/timelapse export behavior while video export moves toward app/document/renderer/video/platform/storage services | `pp_app_core_document_export_tests`; `pano_cli plan-export-menu --kind animation-mp4`; `pano_cli plan-export-menu --kind timelapse`; `pano_cli plan-export-target --kind name --doc-name demo --suffix -animation`; `pano_cli plan-export-target --kind name --doc-name demo --suffix -timelapse`; `pano_cli plan-export-message --kind timelapse --destination success`; `pano_cli plan-export-report --kind animation-mp4 --message "video export path must not be empty"`; `ctest --preset desktop-fast --build-config Debug` | Timelapse and animation MP4 execution, desktop worker threading, frame readback/video encoding handoff, and mobile/Web save callbacks are owned by injected app/document/renderer/video/platform/storage services with export dialogs acting only as UI adapters |
| DEBT-0045 | Open | Modernization | Options-menu preference execution now consumes pure `pp_app_core` through UI scale, viewport scale, RTL direction, VR mode, VR-controller, auto-timelapse, and canvas cursor-mode callbacks plus `AppPreferenceServices` and `src/legacy_app_preference_services.*`; viewport-density and cursor-mode execution now delegate to `src/legacy_canvas_view_services.*`, and retained preference reads/writes for UI scale, UI-state/RTL, whats-new dialog state, viewport density, cursor mode, VR controllers, and auto-timelapse now route through `src/legacy_preference_storage.*` snapshots/helpers without direct `settings.h` includes or retained preference keys in the UI/dialog/canvas call sites, but the bridges still call legacy `App::set_ui_scale`, `App::set_ui_rtl`, `App::rec_start`, `App::rec_stop`, retained canvas view mutation, and retained `Settings` storage through that adapter; VR mode callbacks now call `App` VR wrappers that dispatch to `PlatformServices`, whose desktop runtime policy prefers OpenXR while the actual Windows OpenVR SDK bridge still lives in `WindowsPlatformServices` under DEBT-0061 | Preserve current options-menu behavior while preferences move toward app/UI/platform/storage services | `pp_app_core_app_preferences_tests`; `pp_app_core_canvas_view_tests`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-canvas-view-density --density 1.5`; `pano_cli plan-canvas-view-cursor-mode --mode 3`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Preference persistence, UI/layout direction, viewport density, cursor mode, VR mode start/stop/failure handling, VR-controller state, and auto-timelapse recording side effects are owned by injected app/UI/platform/storage services with options-menu callbacks acting only as UI adapters | | DEBT-0045 | Open | Modernization | Options-menu preference execution now consumes pure `pp_app_core` through UI scale, viewport scale, RTL direction, VR mode, VR-controller, auto-timelapse, and canvas cursor-mode callbacks plus `AppPreferenceServices` and `src/legacy_app_preference_services.*`; viewport-density and cursor-mode execution now delegate to `src/legacy_canvas_view_services.*`, and retained preference reads/writes for UI scale, UI-state/RTL, whats-new dialog state, viewport density, cursor mode, VR controllers, and auto-timelapse now route through `src/legacy_preference_storage.*` snapshots/helpers without direct `settings.h` includes or retained preference keys in the UI/dialog/canvas call sites, but the bridges still call legacy `App::set_ui_scale`, `App::set_ui_rtl`, `App::rec_start`, `App::rec_stop`, retained canvas view mutation, and retained `Settings` storage through that adapter; VR mode callbacks now call `App` VR wrappers that dispatch to `PlatformServices`, whose desktop runtime policy prefers OpenXR while the actual Windows OpenVR SDK bridge still lives in `WindowsPlatformServices` under DEBT-0061 | Preserve current options-menu behavior while preferences move toward app/UI/platform/storage services | `pp_app_core_app_preferences_tests`; `pp_app_core_canvas_view_tests`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-canvas-view-density --density 1.5`; `pano_cli plan-canvas-view-cursor-mode --mode 3`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Preference persistence, UI/layout direction, viewport density, cursor mode, VR mode start/stop/failure handling, VR-controller state, and auto-timelapse recording side effects are owned by injected app/UI/platform/storage services with options-menu callbacks acting only as UI adapters |
| DEBT-0046 | Open | Modernization | Startup preference/runtime execution and startup resource sequencing now consume pure `pp_app_core` through `App::init`, `pano_cli plan-app-startup`, `pano_cli plan-app-startup-resources`, `AppStartupServices`, `AppStartupResourceServices`, and `src/legacy_app_startup_services.*`, and startup preference load/read/write now routes through `src/legacy_preference_storage.*` with retained startup keys hidden behind `LegacyStartupPreferenceSnapshot`, but the bridge still calls legacy `Settings` storage through that adapter, `App::rec_start`, app VR-controller state mutation, message-box license warning execution, shader loading, asset initialization, layout creation, title updates, and UI render-target creation directly | Preserve current startup behavior while app startup moves toward app/preferences/storage/recording/UI/renderer services | `pp_app_core_app_startup_tests`; `pano_cli plan-app-startup --run-counter 7 --vr-controllers-disabled --license-invalid`; `pano_cli plan-app-startup --run-counter -1`; `pano_cli plan-app-startup-resources --width 1280 --height 720`; `pano_cli plan-app-startup-resources --bad-size`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Startup preference persistence, auto-timelapse startup, stored VR-controller state, license validation/warning, startup resource initialization, title updates, and UI render-target allocation are owned by injected app/preferences/storage/recording/UI/renderer services with `App::init` acting only as orchestration | | DEBT-0046 | Open | Modernization | Startup preference/runtime execution and startup resource sequencing now consume pure `pp_app_core` through `App::init`, `pano_cli plan-app-startup`, `pano_cli plan-app-startup-resources`, `AppStartupServices`, `AppStartupResourceServices`, and `src/legacy_app_startup_services.*`, and startup preference load/read/write now routes through `src/legacy_preference_storage.*` with retained startup keys hidden behind `LegacyStartupPreferenceSnapshot`, but the bridge still calls legacy `Settings` storage through that adapter, `App::rec_start`, app VR-controller state mutation, message-box license warning execution, shader loading, asset initialization, layout creation, title updates, and UI render-target creation directly | Preserve current startup behavior while app startup moves toward app/preferences/storage/recording/UI/renderer services | `pp_app_core_app_startup_tests`; `pano_cli plan-app-startup --run-counter 7 --vr-controllers-disabled --license-invalid`; `pano_cli plan-app-startup --run-counter -1`; `pano_cli plan-app-startup-resources --width 1280 --height 720`; `pano_cli plan-app-startup-resources --bad-size`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Startup preference persistence, auto-timelapse startup, stored VR-controller state, license validation/warning, startup resource initialization, title updates, and UI render-target allocation are owned by injected app/preferences/storage/recording/UI/renderer services with `App::init` acting only as orchestration |
| DEBT-0047 | Open | Modernization | PPBR brush package export request validation, success-dialog metadata, and execution dispatch now consume pure `pp_app_core` through `App::dialog_ppbr_export`, `pano_cli plan-brush-package-export`, `BrushPackageExportServices`, and `src/legacy_brush_package_export_services.*`; PPBR header/path planning now consumes `pp_assets::brush_package`, the macOS data-directory override now routes through `PlatformServices`, and the desktop async path now uses a service-owned `std::jthread` worker with UI-thread dialog close/message handoff, but the bridge still reads `NodeDialogExportPPBR`, carries the legacy `Image` header object outside the pure request, converts to `NodePanelBrushPreset::PPBRInfo`, calls `NodePanelBrushPreset::export_ppbr`, and handles mobile/Web completion directly | Preserve current PPBR export behavior while brush assets, PPBR serialization, picker completion, and UI lifetime move toward asset/storage/UI/platform services | `pp_assets_brush_package_tests`; `pp_app_core_brush_package_export_tests`; `pp_platform_api_tests`; `pano_cli plan-brush-package-export --path D:/Paint/clouds.ppbr --author Artist --dest-path D:/Paint/BrushPreviews --export-data --header-image`; `pano_cli plan-brush-package-export --path D:/Paint/clouds.ppbr`; `pano_cli plan-brush-package-export`; `pano_cli plan-brush-package-export --path clouds`; `pano_cli plan-brush-package-export --path D:/Paint/clouds.ppbr --dest-path D:/Paint/BrushPreviews --no-export-data`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | PPBR metadata collection, header-image ownership, serialization, picker-selected path execution, desktop threading, dialog lifetime, and mobile/Web completion are owned by injected brush asset/storage/UI/platform services with `App::dialog_ppbr_export` acting only as a UI adapter | | DEBT-0047 | Open | Modernization | PPBR brush package export request validation, success-dialog metadata, and execution dispatch now consume pure `pp_app_core` through `App::dialog_ppbr_export`, `pano_cli plan-brush-package-export`, `BrushPackageExportServices`, and `src/legacy_brush_package_export_services.*`; PPBR header/path planning now consumes `pp_assets::brush_package`, the macOS data-directory override now routes through `PlatformServices`, and the desktop async path now uses a service-owned `std::jthread` worker with UI-thread dialog close/message handoff, but the bridge still reads `NodeDialogExportPPBR`, carries the legacy `Image` header object outside the pure request, converts to `NodePanelBrushPreset::PPBRInfo`, calls `NodePanelBrushPreset::export_ppbr`, and handles mobile/Web completion directly | Preserve current PPBR export behavior while brush assets, PPBR serialization, picker completion, and UI lifetime move toward asset/storage/UI/platform services | `pp_assets_brush_package_tests`; `pp_app_core_brush_package_export_tests`; `pp_platform_api_tests`; `pano_cli plan-brush-package-export --path D:/Paint/clouds.ppbr --author Artist --dest-path D:/Paint/BrushPreviews --export-data --header-image`; `pano_cli plan-brush-package-export --path D:/Paint/clouds.ppbr`; `pano_cli plan-brush-package-export`; `pano_cli plan-brush-package-export --path clouds`; `pano_cli plan-brush-package-export --path D:/Paint/clouds.ppbr --dest-path D:/Paint/BrushPreviews --no-export-data`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | PPBR metadata collection, header-image ownership, serialization, picker-selected path execution, desktop threading, dialog lifetime, and mobile/Web completion are owned by injected brush asset/storage/UI/platform services with `App::dialog_ppbr_export` acting only as a UI adapter |

View File

@@ -107,7 +107,7 @@ Current architecture mismatches that must be treated as real blockers:
rather than thin composition/binding surfaces. rather than thin composition/binding surfaces.
- `App`, `Canvas`, `Node`, retained workers, and platform entrypoints still use - `App`, `Canvas`, `Node`, retained workers, and platform entrypoints still use
global singleton reach, raw observer pointers, detached `std::thread` global singleton reach, raw observer pointers, detached `std::thread`
launches in several canvas/export/preview paths, and ad hoc launches in preview/recording paths, and ad hoc
mutex/condition-variable ownership. mutex/condition-variable ownership.
- Modern C++23 usage exists in extracted components, especially `std::span`, - Modern C++23 usage exists in extracted components, especially `std::span`,
explicit result/status objects, and a few concepts, but the live app still explicit result/status objects, and a few concepts, but the live app still

View File

@@ -49,8 +49,10 @@ Completed, blocked, and superseded task history moved to
- `platform_legacy` is still part of the live app shell - `platform_legacy` is still part of the live app shell
- The app runtime boundary is not finished: - The app runtime boundary is not finished:
- render/UI queues are static `App` state - render/UI queues are static `App` state
- detached workers still launch from canvas, preview, document export, and - detached workers still launch from preview and recording code
recording code - canvas async import/export/save/open now run through an owned in-file
worker, but their retained progress execution is still not a clean runtime
service boundary
- thread-affinity rules are enforced by convention and asserts instead of - thread-affinity rules are enforced by convention and asserts instead of
explicit runtime contracts explicit runtime contracts
- The UI ownership boundary is not finished: - The UI ownership boundary is not finished:
@@ -127,9 +129,9 @@ Current slice:
- `NodeStrokePreview` final composite plus preview-texture copy now route - `NodeStrokePreview` final composite plus preview-texture copy now route
through `legacy_node_stroke_preview_execution_services.h`, but the preview through `legacy_node_stroke_preview_execution_services.h`, but the preview
node still owns most live-pass and retained GL resource execution. node still owns most live-pass and retained GL resource execution.
- `NodeCanvas` display resolve for the `m_density != 1.f` path now routes - `NodeCanvas` display resolve plus cache-to-screen checkerboard/cache-texture
through `legacy_canvas_draw_merge_services.h`, but the cache-to-screen composite now route through `legacy_canvas_draw_merge_services.h`, but
composite block and broader canvas draw orchestration are still inline. broader canvas draw orchestration is still inline.
Write scope: Write scope:
- `src/node_stroke_preview.cpp` - `src/node_stroke_preview.cpp`
@@ -355,8 +357,9 @@ Current slice:
UI-thread completion handoff UI-thread completion handoff
- prepared-file save work and grid lightmap launch now also use service-owned - prepared-file save work and grid lightmap launch now also use service-owned
workers with explicit UI-thread handoff workers with explicit UI-thread handoff
- canvas, preview, document export, and recording-side detached work are still - canvas async import/export/save/open and timelapse export now also use owned
open worker queues instead of detached threads
- preview and recording-side detached work are still open
Write scope: Write scope:
- `src/canvas.cpp` - `src/canvas.cpp`

View File

@@ -20,6 +20,10 @@
#include "renderer_gl/opengl_capabilities.h" #include "renderer_gl/opengl_capabilities.h"
#include "util.h" #include "util.h"
#include <array> #include <array>
#include <condition_variable>
#include <deque>
#include <mutex>
#include <stop_token>
#include <thread> #include <thread>
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
@@ -31,6 +35,79 @@
namespace { namespace {
class LegacyCanvasAsyncWorker final {
public:
LegacyCanvasAsyncWorker()
: worker_([this](std::stop_token stop_token) {
run(stop_token);
})
{
}
~LegacyCanvasAsyncWorker()
{
shutdown();
}
void post(std::function<void()> task)
{
{
std::lock_guard<std::mutex> lock(mutex_);
if (stopping_)
return;
tasks_.push_back(std::move(task));
}
cv_.notify_one();
}
private:
void shutdown()
{
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
}
cv_.notify_all();
}
void run(std::stop_token stop_token)
{
for (;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [&] {
return stopping_ || stop_token.stop_requested() || !tasks_.empty();
});
if ((stopping_ || stop_token.stop_requested()) && tasks_.empty())
break;
task = std::move(tasks_.front());
tasks_.pop_front();
}
if (task) {
try {
task();
} catch (...) {
LOG("canvas async worker task failed");
}
}
}
}
std::mutex mutex_;
std::condition_variable cv_;
std::deque<std::function<void()>> tasks_;
bool stopping_ = false;
std::jthread worker_;
};
LegacyCanvasAsyncWorker& canvas_async_worker()
{
static LegacyCanvasAsyncWorker worker;
return worker;
}
GLint current_canvas_stroke_internal_format() GLint current_canvas_stroke_internal_format()
{ {
const auto renderer_features = ShaderManager::render_device_features(); const auto renderer_features = ShaderManager::render_device_features();
@@ -2769,11 +2846,10 @@ void Canvas::import_equirectangular(std::string file_path, std::shared_ptr<Layer
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, file_path = std::move(file_path), layer = std::move(layer)] {
BT_SetTerminate(); BT_SetTerminate();
import_equirectangular_thread(file_path, layer); import_equirectangular_thread(file_path, layer);
}); });
t.detach();
} }
} }
@@ -2862,13 +2938,12 @@ void Canvas::export_equirectangular(std::string file_path, std::function<void()>
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, file_path = std::move(file_path), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
export_equirectangular_thread(file_path); export_equirectangular_thread(file_path);
if (on_complete) if (on_complete)
on_complete(); App::I->ui_task([on_complete = std::move(on_complete)]() mutable { on_complete(); });
}); });
t.detach();
} }
} }
@@ -2949,13 +3024,12 @@ void Canvas::export_depth(std::string file_name, std::function<void()> on_comple
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, file_name = std::move(file_name), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
export_depth_thread(file_name); export_depth_thread(file_name);
if (on_complete) if (on_complete)
on_complete(); App::I->ui_task([on_complete = std::move(on_complete)]() mutable { on_complete(); });
}); });
t.detach();
} }
} }
@@ -3057,13 +3131,12 @@ void Canvas::export_layers(std::string path, std::function<void()> on_complete)
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, path = std::move(path), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
export_layers_thread(path); export_layers_thread(path);
if (on_complete) if (on_complete)
on_complete(); App::I->ui_task([on_complete = std::move(on_complete)]() mutable { on_complete(); });
}); });
t.detach();
} }
} }
@@ -3084,13 +3157,12 @@ void Canvas::export_anim_frames(std::string path, std::function<void()> on_compl
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, path = std::move(path), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
export_anim_frames_thread(path); export_anim_frames_thread(path);
if (on_complete) if (on_complete)
on_complete(); App::I->ui_task([on_complete = std::move(on_complete)]() mutable { on_complete(); });
}); });
t.detach();
} }
} }
@@ -3110,13 +3182,12 @@ void Canvas::export_anim_mp4(std::string path, std::function<void()> on_complete
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, path = std::move(path), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
export_anim_mp4_thread(path); export_anim_mp4_thread(path);
if (on_complete) if (on_complete)
on_complete(); App::I->ui_task([on_complete = std::move(on_complete)]() mutable { on_complete(); });
}); });
t.detach();
} }
} }
@@ -3149,13 +3220,12 @@ void Canvas::export_cube_faces(std::string file_name, std::function<void()> on_c
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, file_name = std::move(file_name), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
export_cube_faces_thread(file_name); export_cube_faces_thread(file_name);
if (on_complete) if (on_complete)
on_complete(); App::I->ui_task([on_complete = std::move(on_complete)]() mutable { on_complete(); });
}); });
t.detach();
} }
} }
@@ -3202,13 +3272,13 @@ void Canvas::project_save(std::function<void(bool)> on_complete)
{ {
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { const auto file_path = App::I->doc_path;
canvas_async_worker().post([this, file_path, on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
bool ret = project_save_thread(App::I->doc_path, true); bool ret = project_save_thread(file_path, true);
if (on_complete) if (on_complete)
on_complete(ret); App::I->ui_task([on_complete = std::move(on_complete), ret]() mutable { on_complete(ret); });
}); });
t.detach();
} }
} }
@@ -3217,13 +3287,12 @@ void Canvas::project_save(std::string file_path, std::function<void(bool)> on_co
LOG("saving %s", file_path.c_str()); LOG("saving %s", file_path.c_str());
if (App::I->check_license()) if (App::I->check_license())
{ {
std::thread t([=] { canvas_async_worker().post([this, file_path = std::move(file_path), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
bool ret = project_save_thread(file_path, true); bool ret = project_save_thread(file_path, true);
if (on_complete) if (on_complete)
on_complete(ret); App::I->ui_task([on_complete = std::move(on_complete), ret]() mutable { on_complete(ret); });
}); });
t.detach();
} }
else else
{ {
@@ -3499,13 +3568,12 @@ bool Canvas::project_save_thread(std::string file_path, bool show_progress)
void Canvas::project_open(std::string file_path, std::function<void(bool)> on_complete) void Canvas::project_open(std::string file_path, std::function<void(bool)> on_complete)
{ {
std::thread t([=] { canvas_async_worker().post([this, file_path = std::move(file_path), on_complete = std::move(on_complete)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
bool result = project_open_thread(file_path); bool result = project_open_thread(file_path);
if (on_complete) if (on_complete)
on_complete(result); App::I->ui_task([on_complete = std::move(on_complete), result]() mutable { on_complete(result); });
}); });
t.detach();
} }
bool Canvas::project_open_thread(std::string file_path) bool Canvas::project_open_thread(std::string file_path)

View File

@@ -175,6 +175,20 @@ struct LegacyCanvasDrawMergeFinalPlaneCompositeExecution {
std::function<void()> unbind_merged_texture; std::function<void()> unbind_merged_texture;
}; };
struct LegacyCanvasDrawMergeCacheToScreenCompositeUniforms {
LegacyCanvasDrawMergeCheckerboardUniforms checkerboard;
LegacyCanvasDrawMergeTextureUniforms texture;
};
struct LegacyCanvasDrawMergeCacheToScreenCompositeExecution {
std::function<void()> enable_blend;
std::function<void(const LegacyCanvasDrawMergeCheckerboardUniforms&, int)> draw_checkerboard_plane;
std::function<void()> bind_sampler;
std::function<void()> bind_cache_texture;
std::function<void()> draw_cache_texture;
std::function<void()> unbind_cache_texture;
};
struct LegacyCanvasDrawMergeDisplayResolveUniforms { struct LegacyCanvasDrawMergeDisplayResolveUniforms {
LegacyCanvasDrawMergeTextureUniforms texture; LegacyCanvasDrawMergeTextureUniforms texture;
}; };
@@ -447,6 +461,23 @@ inline void execute_legacy_canvas_draw_merge_final_plane_composite(
execution.unbind_merged_texture(); execution.unbind_merged_texture();
} }
inline void execute_legacy_canvas_draw_merge_cache_to_screen_composite(
const LegacyCanvasDrawMergeCacheToScreenCompositeUniforms& uniforms,
const LegacyCanvasDrawMergeCacheToScreenCompositeExecution& execution)
{
execution.enable_blend();
for (int plane_index = 0; plane_index < 6; ++plane_index) {
execution.draw_checkerboard_plane(uniforms.checkerboard, plane_index);
}
execution.bind_sampler();
execution.bind_cache_texture();
setup_legacy_canvas_draw_merge_texture_shader(uniforms.texture);
execution.draw_cache_texture();
execution.unbind_cache_texture();
}
inline void execute_legacy_canvas_draw_merge_display_resolve( inline void execute_legacy_canvas_draw_merge_display_resolve(
const LegacyCanvasDrawMergeDisplayResolveUniforms& uniforms, const LegacyCanvasDrawMergeDisplayResolveUniforms& uniforms,
const LegacyCanvasDrawMergeDisplayResolveExecution& execution) const LegacyCanvasDrawMergeDisplayResolveExecution& execution)

View File

@@ -7,10 +7,15 @@
#include "paint_renderer/compositor.h" #include "paint_renderer/compositor.h"
#include <array> #include <array>
#include <condition_variable>
#include <fstream> #include <fstream>
#include <deque>
#include <functional>
#include <mutex>
#include <limits> #include <limits>
#include <span> #include <span>
#include <string> #include <string>
#include <stop_token>
#include <thread> #include <thread>
namespace pp::panopainter { namespace pp::panopainter {
@@ -30,6 +35,79 @@ struct LegacyDocumentExportSnapshotReports {
pp::paint_renderer::DocumentFrameFacePngExportResult face_pngs; pp::paint_renderer::DocumentFrameFacePngExportResult face_pngs;
}; };
class LegacyDocumentVideoExportWorker final {
public:
LegacyDocumentVideoExportWorker()
: worker_([this](std::stop_token stop_token) {
run(stop_token);
})
{
}
~LegacyDocumentVideoExportWorker()
{
shutdown();
}
void post(std::function<void()> task)
{
{
std::lock_guard<std::mutex> lock(mutex_);
if (stopping_)
return;
tasks_.push_back(std::move(task));
}
cv_.notify_one();
}
private:
void shutdown()
{
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
}
cv_.notify_all();
}
void run(std::stop_token stop_token)
{
for (;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [&] {
return stopping_ || stop_token.stop_requested() || !tasks_.empty();
});
if ((stopping_ || stop_token.stop_requested()) && tasks_.empty())
break;
task = std::move(tasks_.front());
tasks_.pop_front();
}
if (task) {
try {
task();
} catch (...) {
LOG("document video export worker task failed");
}
}
}
}
std::mutex mutex_;
std::condition_variable cv_;
std::deque<std::function<void()>> tasks_;
bool stopping_ = false;
std::jthread worker_;
};
LegacyDocumentVideoExportWorker& document_video_export_worker()
{
static LegacyDocumentVideoExportWorker worker;
return worker;
}
pp::foundation::Status write_export_binary_file(std::string_view path, std::span<const std::byte> bytes) pp::foundation::Status write_export_binary_file(std::string_view path, std::span<const std::byte> bytes)
{ {
if (path.empty()) { if (path.empty()) {
@@ -746,16 +824,18 @@ public:
auto* app = &app_; auto* app = &app_;
auto path_string = std::string(path); auto path_string = std::string(path);
if (asynchronous_) { if (asynchronous_) {
std::thread([app, path_string] { document_video_export_worker().post([app, path_string = std::move(path_string)]() mutable {
BT_SetTerminate(); BT_SetTerminate();
app->rec_export(path_string); app->rec_export(path_string);
show_export_success_dialog( app->ui_task([app, path_string = std::move(path_string)]() mutable {
*app, show_export_success_dialog(
pp::app::plan_document_export_success_dialog( *app,
pp::app::DocumentExportSuccessKind::timelapse, pp::app::plan_document_export_success_dialog(
pp::app::DocumentExportSuccessDestination::path, pp::app::DocumentExportSuccessKind::timelapse,
path_string)); pp::app::DocumentExportSuccessDestination::path,
}).detach(); path_string));
});
});
return; return;
} }

View File

@@ -676,40 +676,44 @@ void NodeCanvas::draw()
apply_node_canvas_viewport(0, 0, m_rtt.getWidth(), m_rtt.getHeight()); apply_node_canvas_viewport(0, 0, m_rtt.getWidth(), m_rtt.getHeight());
else else
apply_node_canvas_viewport(c.x + App::I->off_x, c.y + App::I->off_y, c.z, c.w); apply_node_canvas_viewport(c.x + App::I->off_x, c.y + App::I->off_y, c.z, c.w);
} pp::panopainter::execute_legacy_canvas_draw_merge_cache_to_screen_composite(
pp::panopainter::LegacyCanvasDrawMergeCacheToScreenCompositeUniforms {
// draw the grid behind the layers using a temporary copy .checkerboard = {
if (use_blend)
{
apply_node_canvas_capability(pp::renderer::gl::blend_state(), true);
//draw the grid
for (int plane_index = 0; plane_index < 6; plane_index++)
{
auto plane_mvp = proj * camera *
glm::scale(glm::vec3(m_canvas->m_layers.size() + 500.f)) *
m_canvas->m_plane_transform[plane_index] *
glm::translate(glm::vec3(0, 0, -1.f));
pp::panopainter::setup_legacy_canvas_draw_merge_checkerboard_shader(
pp::panopainter::LegacyCanvasDrawMergeCheckerboardUniforms {
.mvp = plane_mvp,
.colorize = false, .colorize = false,
}); },
m_face_plane.draw_fill(); .texture = {
} .mvp = glm::ortho<float>(-1, 1, -1, 1),
.texture_slot = 0,
},
},
{
.enable_blend = [&] {
apply_node_canvas_capability(pp::renderer::gl::blend_state(), true);
},
.draw_checkerboard_plane = [&](const pp::panopainter::LegacyCanvasDrawMergeCheckerboardUniforms& uniforms, int plane_index) {
auto checkerboard_uniforms = uniforms;
checkerboard_uniforms.mvp = proj * camera *
glm::scale(glm::vec3(m_canvas->m_layers.size() + 500.f)) *
m_canvas->m_plane_transform[plane_index] *
glm::translate(glm::vec3(0, 0, -1.f));
// draw the layers pp::panopainter::setup_legacy_canvas_draw_merge_checkerboard_shader(checkerboard_uniforms);
m_sampler.bind(0); m_face_plane.draw_fill();
set_active_texture_unit(0); },
m_cache_rtt.bindTexture(); .bind_sampler = [&] {
pp::panopainter::setup_legacy_canvas_draw_merge_texture_shader( m_sampler.bind(0);
pp::panopainter::LegacyCanvasDrawMergeTextureUniforms { set_active_texture_unit(0);
.mvp = glm::ortho<float>(-1, 1, -1, 1), },
.texture_slot = 0, .bind_cache_texture = [&] {
m_cache_rtt.bindTexture();
},
.draw_cache_texture = [&] {
m_face_plane.draw_fill();
},
.unbind_cache_texture = [&] {
m_cache_rtt.unbindTexture();
},
}); });
m_face_plane.draw_fill();
m_cache_rtt.unbindTexture();
} }
} }