From 4d7a23a1fd39b7b2ff5781ef135ef552312cbd6d Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 16 Jun 2026 07:10:09 +0200 Subject: [PATCH] Own canvas async work and thin NodeCanvas composite --- docs/modernization/debt.md | 17 +++- docs/modernization/roadmap.md | 2 +- docs/modernization/tasks.md | 17 ++-- src/canvas.cpp | 128 ++++++++++++++++++------ src/legacy_canvas_draw_merge_services.h | 31 ++++++ src/legacy_document_export_services.cpp | 96 ++++++++++++++++-- src/node_canvas.cpp | 66 ++++++------ 7 files changed, 279 insertions(+), 78 deletions(-) diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index e73ca9ab..889e890b 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -18,6 +18,21 @@ agent or engineer to remove them without reconstructing context from chat. ## 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. `src/platform_apple/apple_platform_services.*` no longer reaches `App::I` 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-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-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-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 | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 18229af5..d0e5b209 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -107,7 +107,7 @@ Current architecture mismatches that must be treated as real blockers: rather than thin composition/binding surfaces. - `App`, `Canvas`, `Node`, retained workers, and platform entrypoints still use 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. - Modern C++23 usage exists in extracted components, especially `std::span`, explicit result/status objects, and a few concepts, but the live app still diff --git a/docs/modernization/tasks.md b/docs/modernization/tasks.md index cc4c923d..cf91b3d4 100644 --- a/docs/modernization/tasks.md +++ b/docs/modernization/tasks.md @@ -49,8 +49,10 @@ Completed, blocked, and superseded task history moved to - `platform_legacy` is still part of the live app shell - The app runtime boundary is not finished: - render/UI queues are static `App` state - - detached workers still launch from canvas, preview, document export, and - recording code + - detached workers still launch from preview and 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 explicit runtime contracts - The UI ownership boundary is not finished: @@ -127,9 +129,9 @@ Current slice: - `NodeStrokePreview` final composite plus preview-texture copy now route through `legacy_node_stroke_preview_execution_services.h`, but the preview node still owns most live-pass and retained GL resource execution. -- `NodeCanvas` display resolve for the `m_density != 1.f` path now routes - through `legacy_canvas_draw_merge_services.h`, but the cache-to-screen - composite block and broader canvas draw orchestration are still inline. +- `NodeCanvas` display resolve plus cache-to-screen checkerboard/cache-texture + composite now route through `legacy_canvas_draw_merge_services.h`, but + broader canvas draw orchestration is still inline. Write scope: - `src/node_stroke_preview.cpp` @@ -355,8 +357,9 @@ Current slice: UI-thread completion handoff - prepared-file save work and grid lightmap launch now also use service-owned workers with explicit UI-thread handoff -- canvas, preview, document export, and recording-side detached work are still - open +- canvas async import/export/save/open and timelapse export now also use owned + worker queues instead of detached threads +- preview and recording-side detached work are still open Write scope: - `src/canvas.cpp` diff --git a/src/canvas.cpp b/src/canvas.cpp index 0032af22..aa51f2ea 100644 --- a/src/canvas.cpp +++ b/src/canvas.cpp @@ -20,6 +20,10 @@ #include "renderer_gl/opengl_capabilities.h" #include "util.h" #include +#include +#include +#include +#include #include #include #include @@ -31,6 +35,79 @@ namespace { +class LegacyCanvasAsyncWorker final { +public: + LegacyCanvasAsyncWorker() + : worker_([this](std::stop_token stop_token) { + run(stop_token); + }) + { + } + + ~LegacyCanvasAsyncWorker() + { + shutdown(); + } + + void post(std::function task) + { + { + std::lock_guard lock(mutex_); + if (stopping_) + return; + tasks_.push_back(std::move(task)); + } + cv_.notify_one(); + } + +private: + void shutdown() + { + { + std::lock_guard lock(mutex_); + stopping_ = true; + } + cv_.notify_all(); + } + + void run(std::stop_token stop_token) + { + for (;;) { + std::function task; + { + std::unique_lock 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> tasks_; + bool stopping_ = false; + std::jthread worker_; +}; + +LegacyCanvasAsyncWorker& canvas_async_worker() +{ + static LegacyCanvasAsyncWorker worker; + return worker; +} + GLint current_canvas_stroke_internal_format() { const auto renderer_features = ShaderManager::render_device_features(); @@ -2769,11 +2846,10 @@ void Canvas::import_equirectangular(std::string file_path, std::shared_ptrcheck_license()) { - std::thread t([=] { + canvas_async_worker().post([this, file_path = std::move(file_path), layer = std::move(layer)] { BT_SetTerminate(); import_equirectangular_thread(file_path, layer); }); - t.detach(); } } @@ -2862,13 +2938,12 @@ void Canvas::export_equirectangular(std::string file_path, std::function { 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(); export_equirectangular_thread(file_path); 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 on_comple { 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(); export_depth_thread(file_name); 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 on_complete) { 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(); export_layers_thread(path); 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 on_compl { 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(); export_anim_frames_thread(path); 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 on_complete { 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(); export_anim_mp4_thread(path); 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 on_c { 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(); export_cube_faces_thread(file_name); 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 on_complete) { 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(); - bool ret = project_save_thread(App::I->doc_path, true); + bool ret = project_save_thread(file_path, true); 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 on_co LOG("saving %s", file_path.c_str()); 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(); bool ret = project_save_thread(file_path, true); if (on_complete) - on_complete(ret); + App::I->ui_task([on_complete = std::move(on_complete), ret]() mutable { on_complete(ret); }); }); - t.detach(); } 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 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(); bool result = project_open_thread(file_path); 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) diff --git a/src/legacy_canvas_draw_merge_services.h b/src/legacy_canvas_draw_merge_services.h index 69935ba6..88223c81 100644 --- a/src/legacy_canvas_draw_merge_services.h +++ b/src/legacy_canvas_draw_merge_services.h @@ -175,6 +175,20 @@ struct LegacyCanvasDrawMergeFinalPlaneCompositeExecution { std::function unbind_merged_texture; }; +struct LegacyCanvasDrawMergeCacheToScreenCompositeUniforms { + LegacyCanvasDrawMergeCheckerboardUniforms checkerboard; + LegacyCanvasDrawMergeTextureUniforms texture; +}; + +struct LegacyCanvasDrawMergeCacheToScreenCompositeExecution { + std::function enable_blend; + std::function draw_checkerboard_plane; + std::function bind_sampler; + std::function bind_cache_texture; + std::function draw_cache_texture; + std::function unbind_cache_texture; +}; + struct LegacyCanvasDrawMergeDisplayResolveUniforms { LegacyCanvasDrawMergeTextureUniforms texture; }; @@ -447,6 +461,23 @@ inline void execute_legacy_canvas_draw_merge_final_plane_composite( 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( const LegacyCanvasDrawMergeDisplayResolveUniforms& uniforms, const LegacyCanvasDrawMergeDisplayResolveExecution& execution) diff --git a/src/legacy_document_export_services.cpp b/src/legacy_document_export_services.cpp index 3401fb8b..dadedf08 100644 --- a/src/legacy_document_export_services.cpp +++ b/src/legacy_document_export_services.cpp @@ -7,10 +7,15 @@ #include "paint_renderer/compositor.h" #include +#include #include +#include +#include +#include #include #include #include +#include #include namespace pp::panopainter { @@ -30,6 +35,79 @@ struct LegacyDocumentExportSnapshotReports { 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 task) + { + { + std::lock_guard lock(mutex_); + if (stopping_) + return; + tasks_.push_back(std::move(task)); + } + cv_.notify_one(); + } + +private: + void shutdown() + { + { + std::lock_guard lock(mutex_); + stopping_ = true; + } + cv_.notify_all(); + } + + void run(std::stop_token stop_token) + { + for (;;) { + std::function task; + { + std::unique_lock 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> 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 bytes) { if (path.empty()) { @@ -746,16 +824,18 @@ public: auto* app = &app_; auto path_string = std::string(path); if (asynchronous_) { - std::thread([app, path_string] { + document_video_export_worker().post([app, path_string = std::move(path_string)]() mutable { BT_SetTerminate(); app->rec_export(path_string); - show_export_success_dialog( - *app, - pp::app::plan_document_export_success_dialog( - pp::app::DocumentExportSuccessKind::timelapse, - pp::app::DocumentExportSuccessDestination::path, - path_string)); - }).detach(); + app->ui_task([app, path_string = std::move(path_string)]() mutable { + show_export_success_dialog( + *app, + pp::app::plan_document_export_success_dialog( + pp::app::DocumentExportSuccessKind::timelapse, + pp::app::DocumentExportSuccessDestination::path, + path_string)); + }); + }); return; } diff --git a/src/node_canvas.cpp b/src/node_canvas.cpp index d09f53c4..f34e247e 100644 --- a/src/node_canvas.cpp +++ b/src/node_canvas.cpp @@ -676,40 +676,44 @@ void NodeCanvas::draw() apply_node_canvas_viewport(0, 0, m_rtt.getWidth(), m_rtt.getHeight()); else apply_node_canvas_viewport(c.x + App::I->off_x, c.y + App::I->off_y, c.z, c.w); - } - - // draw the grid behind the layers using a temporary copy - 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, + pp::panopainter::execute_legacy_canvas_draw_merge_cache_to_screen_composite( + pp::panopainter::LegacyCanvasDrawMergeCacheToScreenCompositeUniforms { + .checkerboard = { .colorize = false, - }); - m_face_plane.draw_fill(); - } + }, + .texture = { + .mvp = glm::ortho(-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 - m_sampler.bind(0); - set_active_texture_unit(0); - m_cache_rtt.bindTexture(); - pp::panopainter::setup_legacy_canvas_draw_merge_texture_shader( - pp::panopainter::LegacyCanvasDrawMergeTextureUniforms { - .mvp = glm::ortho(-1, 1, -1, 1), - .texture_slot = 0, + pp::panopainter::setup_legacy_canvas_draw_merge_checkerboard_shader(checkerboard_uniforms); + m_face_plane.draw_fill(); + }, + .bind_sampler = [&] { + m_sampler.bind(0); + set_active_texture_unit(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(); } }