diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 2ed46f0..1333667 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -212,3 +212,15 @@ {"id":"int-3c445157","kind":"field_change","created_at":"2026-08-15T13:13:48.8227584Z","actor":"G.Hirpara","issue_id":"daz-script-server-dyf9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Added DazCamera.f_stop, .aperture_blades, .aperture_blade_rotation (dazpy/_camera.py). Property labels confirmed against a live DAZ Studio instance: 'F/Stop' (name 'Aperature'), 'Aperture Blades', 'Aperture Blade Rotation' -- all present on DzBasicCamera/DzCamera and writable. Verified read/write round-trip live, then cleaned up the test camera. Unit tests added (6 new), full suite (671 tests) passes."}} {"id":"int-292dca17","kind":"field_change","created_at":"2026-08-16T03:04:35.1819713Z","actor":"G.Hirpara","issue_id":"daz-script-server-dwxr","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and pushed: dazpy bone_rotations_quat() + rendering/unreal_headless_render example (daz_exporter.py, ue_headless_render.py, render_orchestrator.py, morph_name_map.py, README). daz_exporter.py verified end-to-end against a live DAZ Studio scene."}} {"id":"int-cd238ba8","kind":"field_change","created_at":"2026-08-21T22:44:44.9981517Z","actor":"G.Hirpara","issue_id":"daz-script-server-jz0e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed in dazpy/_scene_state.py: DazSceneState.capture()/apply() now capture and restore each skeleton's conform/fit-to relationship explicitly via getFollowTarget()/fit_to()/unfit(), independent of DazPose's generic property capture (which was silently desyncing the live follow-target pointer via its FID_* property rewrite). 4 new regression tests added; all 683 unit tests pass; fix live-validated twice against the Jason Cross reference scene (single-skeleton and full 11-skeleton checkpoint/restore)."}} +{"id":"int-c434679a","kind":"field_change","created_at":"2026-08-22T02:11:54.1238669Z","actor":"G.Hirpara","issue_id":"daz-script-server-ed0p","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"re-closing after local branch cleanup (bd state momentarily reverted by an unrelated git reset --hard); both tasks' code/tests are committed on feature/script-call-batching"}} +{"id":"int-6716b471","kind":"field_change","created_at":"2026-08-22T02:11:54.2507725Z","actor":"G.Hirpara","issue_id":"daz-script-server-lly2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"re-closing after local branch cleanup (bd state momentarily reverted by an unrelated git reset --hard); both tasks' code/tests are committed on feature/script-call-batching"}} +{"id":"int-71728ab2","kind":"field_change","created_at":"2026-08-22T02:11:55.1395075Z","actor":"G.Hirpara","issue_id":"daz-script-server-bi4e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"DazNode.set_transform() added; reset_transforms() now issues exactly one execute() call; 4 new tests + updated baseline pass, full suite green (690 tests)"}} +{"id":"int-c60e7e26","kind":"field_change","created_at":"2026-08-22T02:13:30.2839577Z","actor":"G.Hirpara","issue_id":"daz-script-server-gc82","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"_zero_bones_and_morphs() added; zero_figure() default path now issues exactly one execute() call; existing TestZeroFigure tests updated to match, 3 new tests pass, full suite green (692 tests)"}} +{"id":"int-c2ce8f22","kind":"field_change","created_at":"2026-08-22T02:14:11.0183234Z","actor":"G.Hirpara","issue_id":"daz-script-server-yy2u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"set_properties() added; issues exactly one execute() call for any number of labels; 3 new tests pass, full suite green (695 tests)"}} +{"id":"int-eb0eac52","kind":"field_change","created_at":"2026-08-22T02:15:01.3912466Z","actor":"G.Hirpara","issue_id":"daz-script-server-ldst","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"set_state() added; issues exactly one execute() call for any combination of bones/morphs/props; 4 new tests pass, full suite green (699 tests)"}} +{"id":"int-e45ea637","kind":"field_change","created_at":"2026-08-22T02:16:15.6897422Z","actor":"G.Hirpara","issue_id":"daz-script-server-liru","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"add_operation()/add_prelude()/limits added to Batch; BatchLimitExceededError added; 6 new tests pass including a regression guard for existing add() behavior, full suite green (705 tests)"}} +{"id":"int-2a680961","kind":"field_change","created_at":"2026-08-22T02:17:49.0324745Z","actor":"G.Hirpara","issue_id":"daz-script-server-kspb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"execute_batch_async() added to DazClient and AsyncDazClient via new build_operations_script() helper; 4 new tests pass across both suites (707 + 26), no C++ changes needed"}} +{"id":"int-fe0f85e5","kind":"field_change","created_at":"2026-08-22T02:21:15.1652294Z","actor":"G.Hirpara","issue_id":"daz-script-server-72d8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented main-thread wait metrics; both SDK4 and SDK6 builds pass"}} +{"id":"int-edca850d","kind":"field_change","created_at":"2026-08-22T02:23:48.7529662Z","actor":"G.Hirpara","issue_id":"daz-script-server-18q8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Documented add_operation/add_prelude, size limits, whole-batch failure/no-rollback, and execute_batch_async in docs/api/batch.rst; added before/after bulk-update example and sync-vs-async guidance to docs/quickstart.rst"}} +{"id":"int-710d612e","kind":"field_change","created_at":"2026-08-22T02:25:01.2280208Z","actor":"G.Hirpara","issue_id":"daz-script-server-01cg","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All 10 child tasks complete (batching implementation, metrics, and docs)"}} +{"id":"int-fa27ce0c","kind":"field_change","created_at":"2026-08-22T02:28:56.4009827Z","actor":"G.Hirpara","issue_id":"daz-script-server-xtkd","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added retry-with-backoff to DazScene.find_skeleton() for the transient Scene.getSkeletonList() miss described in the bug; likely tied to DAZ Studio's own deferred/background scene-graph work (same family as dpi-mxq), which isn't independently observable from the DazScript/HTTP side. Covered by new unit tests; full test suite green."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ee03ef8..62a4f77 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -59,16 +59,16 @@ {"_type":"issue","id":"daz-script-server-70f","title":"daz-bridge-usd: new plugin project scaffold","description":"Create the daz-bridge-usd C++ DAZ Studio plugin project. Set up CMake build system, add DazBridgeUtils as a git submodule, configure DAZ SDK linkage, and establish the plugin entry point.\\n\\nThis is the foundation all other daz-bridge-usd tasks build on. The plugin should follow the same build conventions as daz-script-server (.env for SDK path, build.sh, MSVC /MD runtime).","status":"closed","priority":1,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-05-22T21:53:26Z","created_by":"G.Hirpara","updated_at":"2026-05-22T22:06:00Z","started_at":"2026-05-22T22:02:50Z","closed_at":"2026-05-22T22:06:00Z","close_reason":"Scaffold complete: CMakeLists.txt, build.sh, pluginmain.cpp, common_version.h, dzbridgeusd.h in y:/working/BlueMoonFoundry/daz-bridge-usd. Builds clean to DazBridgeUsd.dll. DzBridgeUsdAction stub registered; implementation deferred to daz-script-server-5xu.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"daz-script-server-cu2","title":"Route registration interface in daz-script-server","description":"Add a plugin route registration API to daz-script-server so that companion plugins (like daz-bridge-usd) can register their own HTTP handlers on the same port and httplib instance without modifying the server plugin directly.\\n\\nThe server plugin should expose a Qt-accessible registration method (e.g. registerRouteHandler(method, path, handler)) that other DAZ plugins loaded in the same process can call at startup via DAZ's plugin discovery. This keeps the HTTP surface unified on one port while keeping plugin code cleanly separated.","notes":"Implemented registerPluginRoute(method, path, receiver, slotName) and unregisterPluginRoute(method, path) as Q_INVOKABLE methods on DzScriptServerPane. Routes stored in m_pluginRoutes list (QMutex-protected), applied via applyPluginRoutes() at end of setupRoutes() each time the server starts. Companion plugin handler slot must be Q_INVOKABLE with signature HttpResult(QByteArray body, QByteArray clientIP). Uses QPointer\u003cQObject\u003e in the httplib lambda to safely handle plugin unload. Build clean. Routes take effect at next server start — companion plugins register during DAZ startup before the server starts, so timing is correct.","status":"closed","priority":1,"issue_type":"feature","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-05-22T21:53:23Z","created_by":"G.Hirpara","updated_at":"2026-05-22T22:01:08Z","started_at":"2026-05-22T21:56:58Z","closed_at":"2026-05-22T22:01:08Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"daz-script-server-dou","title":"Add POST /render endpoint (single render, async)","description":"VN production workflows need to trigger DAZ renders programmatically. Without a render endpoint, every render requires manual GUI interaction, making batch sprite production impossible to automate.\n\nImplement POST /render that accepts a render job, enqueues it through the existing AsyncRequestManager, executes on the main thread, and returns a request_id immediately.\n\nRequest body schema:\n- output_path (required, absolute path)\n- width / height (optional, override scene render settings)\n- format: png|jpg|tiff|exr (optional, inferred from output_path extension)\n- camera (optional, null = active camera)\n- engine (optional, null = scene default: iray|3delight|filament)\n- figure / morphs (optional, single-figure morph dict)\n- figures (optional, list of {name, morphs} for multi-figure scenes)\n- iray_samples (optional quality override)\n\nRender jobs use id prefix 'rnd-' and flow through the existing processNextAsyncRequest() path as a special request type. The server generates a DazScript snippet from the morph dict, applies morphs via findPropertyByLabel(), then calls App.getRenderMgr().render(opts). Unknown morph names are silently skipped (non-fatal). Status/result polling via existing /requests/:id/status and /requests/:id/result endpoints.\n\nOpen questions to resolve during implementation:\n1. morph reset between variants (reset_morphs flag needed?)\n2. Figure lookup fallback: error vs fall back to primary figure\n3. Add isRender flag to AsyncRequest struct in AsyncRequestManager.h","design":"1. reset_morphs flag: Add reset_morphs: bool to POST /render request body. When true, reset all figure morphs to zero on the main thread before applying the morphs dict and rendering. Required for correct variant rendering when the same figure is reused across batch jobs.\n\n2. Figure lookup failure = error: On figure lookup failure, return an HTTP error (not a silent fallback). Before any render step begins, run a validation pass over the entire figures array to identify all figure references and validate they resolve to scene nodes. This surfaces all bad references at once rather than failing mid-render.\n\n3. RequestType enum instead of isRender bool: Add RequestType enum (REQUEST_TYPE_SCRIPT, REQUEST_TYPE_RENDER) to AsyncRequest struct rather than a boolean isRender flag. Thread it through submit() or a new submitRender() overload. Used in two places: (a) cancelJson() — only invoke killRenderOnMainThread() when cancelling a render request, not any running request; (b) processNextAsyncRequest() — gate render-progress signal wiring for future SSE progress endpoint (owd).","notes":"Implementation complete and tested (2026-05-21). Build clean, installed, verified via live DAZ Studio instance.\n\nAPI corrections confirmed against running DAZ Studio (dzirayrenderer.dll):\n- Image size: new QSize(w, h) — NOT Qt.size() (Qt global undefined in DAZ QScriptEngine)\n- Engine switching: renderMgr.findRenderer(className) + setActiveRenderer() confirmed working. Iray className = 'DzIrayRenderer'. 3Delight/Filament class names follow Dz*Renderer pattern but need verification on systems with those plugins.\n- Camera: Scene.findCameraByLabel() + App.getViewportMgr().setActiveCamera() confirmed.\n- Render: renderMgr.doRender(opts) confirmed.\n- Output path: opts.renderImgFilename + opts.renderImgToId = 2 (DirectToFile) confirmed.\n\nKnown remaining TODO:\n- iray_samples: NOT on DzRenderOptions or DzIrayRenderer object in DazScript. DAZ stores iray-specific settings outside the standard render options API. Field is accepted but silently ignored. Needs further investigation — scene data items path not yet found. Resume investigation in a future session.","status":"closed","priority":1,"issue_type":"feature","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-05-21T20:39:38Z","created_by":"G.Hirpara","updated_at":"2026-05-22T21:56:48Z","started_at":"2026-05-22T01:58:32Z","closed_at":"2026-05-22T21:56:48Z","close_reason":"Closed","dependency_count":0,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"daz-script-server-01cg","title":"Epic: dazpy script-call batching","description":"Reduce independent DazScript evaluations dazpy issues for repeated transforms, property writes, pose ops, and convenience helpers. Implementation plan: docs/superpowers/plans/2026-08-21-script-call-batching-plan.md (spec: docs/superpowers/plans/2026-08-21-script-call-batching.md).","status":"open","priority":2,"issue_type":"epic","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:53Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"daz-script-server-72d8","title":"Batching Task 6: main-thread wait + batch metrics in /metrics (C++)","description":"Add MetricsCollector::recordMainThreadWait()/getAvgMainThreadWaitMs()/getMaxMainThreadWaitMs(); thread the HTTP-thread accepted-at timestamp (QDateTime::currentMSecsSinceEpoch(), captured in RequestHandlers.cpp's ExecuteScriptHandler::handle before the BlockingQueuedConnection dispatch) into DzScriptServerPane::handleExecuteRequest() as a new qint64 parameter; expose avg/max_main_thread_wait_ms as new additive fields in getMetricsJson(). No C++ test harness exists (only test_securerandom.cpp) so verification is: clean build under both SDK4 and SDK6, plus the manual Studio smoke test. See plan Task 6.","acceptance_criteria":"./build.sh build --clean and ./build.sh build --sdk-version 6 --clean both succeed; /metrics gains avg_main_thread_wait_ms/max_main_thread_wait_ms without renaming/removing existing fields; every other caller of handleExecuteRequest() (grep-checked) updated for the new parameter.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:48Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:48Z","dependencies":[{"issue_id":"daz-script-server-72d8","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:19Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"daz-script-server-kspb","title":"Batching Task 5: one-request async batch submission (execute_batch_async)","description":"Add DazClient.execute_batch_async(operations, args=None) and AsyncDazClient.execute_batch_async(...) (dazpy/_client_aio.py) that compose N operations into one script via a new build_operations_script() helper in dazpy/_batch.py and submit via the EXISTING execute_async_submit()/POST /execute/async — no new C++ route needed since AsyncRequestManager already takes one script+args per request. See plan Task 5 (depends on Task 4's Batch primitives).","acceptance_criteria":"execute_batch_async() submits exactly one POST to /execute/async regardless of operation count; args passed through; sync and AsyncDazClient (httpx-backed) both covered; existing test_dazpy_aio.py mocking helpers (_client_with_mock_http/_mock_resp) reused, no live server needed.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:47Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:47Z","dependencies":[{"issue_id":"daz-script-server-kspb","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:18Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-kspb","depends_on_id":"daz-script-server-liru","type":"blocks","created_at":"2026-08-21T22:03:28Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"daz-script-server-liru","title":"Batching Task 4: extend Batch with add_operation(), add_prelude(), size limits","description":"Add Batch.add_operation(body_lines, result_expression) so callers don't need to guess the internal _rN key name (fixes the existing add()/_build_script() key-name mismatch), Batch.add_prelude(key, lines) for shared setup emitted once, and max_operations/max_script_length limits raising a new BatchLimitExceededError (dazpy/exceptions.py) client-side before any HTTP call. Existing raw Batch.add() behavior and tests must remain unchanged. See plan Task 4.","acceptance_criteria":"Existing TestBatch tests stay green; add_operation() resolves without caller guessing key; add_prelude() emits shared setup once for N ops referencing it; read-after-write order preserved; operation-count limit raises before execute(); script-length limit raises on execute() before any HTTP call.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:46Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:46Z","dependencies":[{"issue_id":"daz-script-server-liru","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:18Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-liru","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:28Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"daz-script-server-ldst","title":"Batching Task 3C: DazSkeleton.set_state() combined bone/morph/property write","description":"Add DazSkeleton.set_state(bones=None, morphs=None, props=None) combining bone rotation, morph value, and node-property writes into one script/execute() call. Must review dazpy/_pose.py ~lines 117-125 (DzERCLink comment) first and document that props writes use plain setValue() (like set_property()), NOT apply_full()'s ERC-avoidance path. See plan Task 3C.","acceptance_criteria":"All 3 kinds together issue 1 call; each kind independently optional; omitting all 3 is a no-op; bone/morph names with quotes are JSON-safe; docstring documents the ERC caveat.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:29Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:29Z","dependencies":[{"issue_id":"daz-script-server-ldst","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:17Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-ldst","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:27Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"daz-script-server-gc82","title":"Batching Task 2C: single-call default-mode zero_figure()","description":"Add DazSkeleton._zero_bones_and_morphs() generating one script that iterates bones+non-zero DzMorph modifiers and zeroes them directly in Studio, replacing the current read-all-then-write sequence (bone_rotations()+morph_values()+set_bone_rotations()+set_morph_values() = 4 calls). Only the include_props=False default path changes; include_props=True keeps using DazPose.apply_full() unchanged. See plan Task 2C.","acceptance_criteria":"Default zero_figure() issues exactly 1 execute() call; include_props=True still calls DazPose.apply_full().","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:28Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:28Z","dependencies":[{"issue_id":"daz-script-server-gc82","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:16Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-gc82","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:26Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"daz-script-server-yy2u","title":"Batching Task 3B: DazElement.set_properties() bulk write","description":"Add DazElement.set_properties(values: dict) -\u003e dict[str, bool] issuing one execute() call for multiple property writes (one owner lookup, one property lookup per label inside Studio), returning per-label success. See plan Task 3B.","acceptance_criteria":"Multiple mutations issue 1 execute() call; missing property reported False per-label; labels with quotes/backslashes/newlines are JSON-safe.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:28Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:28Z","dependencies":[{"issue_id":"daz-script-server-yy2u","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:17Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-yy2u","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:27Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"daz-script-server-bi4e","title":"Batching Task 2B/3A: DazNode.set_transform() + single-call reset_transforms()","description":"Add DazNode.set_transform(position, rotation, scale) with all-optional args, one execute() call, omitted components untouched. Rebuild dazpy/poses.py reset_transforms() to call it (currently 3 separate setter calls). See plan Task 2B.","acceptance_criteria":"set_transform() with all 3 args issues 1 call; omitted component absent from generated script; no-args call is a no-op; reset_transforms() issues exactly 1 call.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:27Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:27Z","dependencies":[{"issue_id":"daz-script-server-bi4e","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:16Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-bi4e","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:25Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"daz-script-server-lly2","title":"Batching Task 2A: single-call DazElement.snapshot()","description":"Rewrite DazElement.snapshot() (dazpy/_element.py:115-127) to issue exactly one client.execute() call for multiple fields instead of looping get_property() per field. Preserve missing-owner/missing-property -\u003e None semantics. See plan Task 2A.","acceptance_criteria":"snapshot() issues exactly one execute() call for N fields; labels are JSON-escaped safely; missing owner returns None for all fields.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:26Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:26Z","dependencies":[{"issue_id":"daz-script-server-lly2","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:15Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-lly2","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:25Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"daz-script-server-ed0p","title":"Batching Task 1: call-count baseline characterization tests","description":"Add TestCallCountBaseline to tests/test_dazpy.py pinning current client.execute() call counts for snapshot(), reset_transforms(), zero_figure() default mode, and Batch, before any production code changes. See docs/superpowers/plans/2026-08-21-script-call-batching-plan.md Task 1.","acceptance_criteria":"All 4 baseline tests pass against current (pre-fix) code; python -m pytest tests/test_dazpy.py -k TestCallCountBaseline -v is green.","status":"open","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:12Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:12Z","dependencies":[{"issue_id":"daz-script-server-ed0p","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:14Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":0,"dependent_count":6,"comment_count":0} +{"_type":"issue","id":"daz-script-server-01cg","title":"Epic: dazpy script-call batching","description":"Reduce independent DazScript evaluations dazpy issues for repeated transforms, property writes, pose ops, and convenience helpers. Implementation plan: docs/superpowers/plans/2026-08-21-script-call-batching-plan.md (spec: docs/superpowers/plans/2026-08-21-script-call-batching.md).","status":"closed","priority":2,"issue_type":"epic","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:53Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:25:01Z","closed_at":"2026-08-22T02:25:01Z","close_reason":"All 10 child tasks complete (batching implementation, metrics, and docs)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"daz-script-server-72d8","title":"Batching Task 6: main-thread wait + batch metrics in /metrics (C++)","description":"Add MetricsCollector::recordMainThreadWait()/getAvgMainThreadWaitMs()/getMaxMainThreadWaitMs(); thread the HTTP-thread accepted-at timestamp (QDateTime::currentMSecsSinceEpoch(), captured in RequestHandlers.cpp's ExecuteScriptHandler::handle before the BlockingQueuedConnection dispatch) into DzScriptServerPane::handleExecuteRequest() as a new qint64 parameter; expose avg/max_main_thread_wait_ms as new additive fields in getMetricsJson(). No C++ test harness exists (only test_securerandom.cpp) so verification is: clean build under both SDK4 and SDK6, plus the manual Studio smoke test. See plan Task 6.","acceptance_criteria":"./build.sh build --clean and ./build.sh build --sdk-version 6 --clean both succeed; /metrics gains avg_main_thread_wait_ms/max_main_thread_wait_ms without renaming/removing existing fields; every other caller of handleExecuteRequest() (grep-checked) updated for the new parameter.","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:48Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:21:15Z","started_at":"2026-08-22T02:18:41Z","closed_at":"2026-08-22T02:21:15Z","close_reason":"Implemented main-thread wait metrics; both SDK4 and SDK6 builds pass","dependencies":[{"issue_id":"daz-script-server-72d8","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:19Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"daz-script-server-kspb","title":"Batching Task 5: one-request async batch submission (execute_batch_async)","description":"Add DazClient.execute_batch_async(operations, args=None) and AsyncDazClient.execute_batch_async(...) (dazpy/_client_aio.py) that compose N operations into one script via a new build_operations_script() helper in dazpy/_batch.py and submit via the EXISTING execute_async_submit()/POST /execute/async — no new C++ route needed since AsyncRequestManager already takes one script+args per request. See plan Task 5 (depends on Task 4's Batch primitives).","acceptance_criteria":"execute_batch_async() submits exactly one POST to /execute/async regardless of operation count; args passed through; sync and AsyncDazClient (httpx-backed) both covered; existing test_dazpy_aio.py mocking helpers (_client_with_mock_http/_mock_resp) reused, no live server needed.","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:47Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:17:49Z","started_at":"2026-08-22T02:16:24Z","closed_at":"2026-08-22T02:17:49Z","close_reason":"execute_batch_async() added to DazClient and AsyncDazClient via new build_operations_script() helper; 4 new tests pass across both suites (707 + 26), no C++ changes needed","dependencies":[{"issue_id":"daz-script-server-kspb","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:18Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-kspb","depends_on_id":"daz-script-server-liru","type":"blocks","created_at":"2026-08-21T22:03:28Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"daz-script-server-liru","title":"Batching Task 4: extend Batch with add_operation(), add_prelude(), size limits","description":"Add Batch.add_operation(body_lines, result_expression) so callers don't need to guess the internal _rN key name (fixes the existing add()/_build_script() key-name mismatch), Batch.add_prelude(key, lines) for shared setup emitted once, and max_operations/max_script_length limits raising a new BatchLimitExceededError (dazpy/exceptions.py) client-side before any HTTP call. Existing raw Batch.add() behavior and tests must remain unchanged. See plan Task 4.","acceptance_criteria":"Existing TestBatch tests stay green; add_operation() resolves without caller guessing key; add_prelude() emits shared setup once for N ops referencing it; read-after-write order preserved; operation-count limit raises before execute(); script-length limit raises on execute() before any HTTP call.","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:46Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:16:16Z","started_at":"2026-08-22T02:15:04Z","closed_at":"2026-08-22T02:16:16Z","close_reason":"add_operation()/add_prelude()/limits added to Batch; BatchLimitExceededError added; 6 new tests pass including a regression guard for existing add() behavior, full suite green (705 tests)","dependencies":[{"issue_id":"daz-script-server-liru","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:18Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-liru","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:28Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"daz-script-server-ldst","title":"Batching Task 3C: DazSkeleton.set_state() combined bone/morph/property write","description":"Add DazSkeleton.set_state(bones=None, morphs=None, props=None) combining bone rotation, morph value, and node-property writes into one script/execute() call. Must review dazpy/_pose.py ~lines 117-125 (DzERCLink comment) first and document that props writes use plain setValue() (like set_property()), NOT apply_full()'s ERC-avoidance path. See plan Task 3C.","acceptance_criteria":"All 3 kinds together issue 1 call; each kind independently optional; omitting all 3 is a no-op; bone/morph names with quotes are JSON-safe; docstring documents the ERC caveat.","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:29Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:15:01Z","started_at":"2026-08-22T02:14:14Z","closed_at":"2026-08-22T02:15:01Z","close_reason":"set_state() added; issues exactly one execute() call for any combination of bones/morphs/props; 4 new tests pass, full suite green (699 tests)","dependencies":[{"issue_id":"daz-script-server-ldst","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:17Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-ldst","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:27Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"daz-script-server-gc82","title":"Batching Task 2C: single-call default-mode zero_figure()","description":"Add DazSkeleton._zero_bones_and_morphs() generating one script that iterates bones+non-zero DzMorph modifiers and zeroes them directly in Studio, replacing the current read-all-then-write sequence (bone_rotations()+morph_values()+set_bone_rotations()+set_morph_values() = 4 calls). Only the include_props=False default path changes; include_props=True keeps using DazPose.apply_full() unchanged. See plan Task 2C.","acceptance_criteria":"Default zero_figure() issues exactly 1 execute() call; include_props=True still calls DazPose.apply_full().","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:28Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:13:30Z","started_at":"2026-08-22T02:11:59Z","closed_at":"2026-08-22T02:13:30Z","close_reason":"_zero_bones_and_morphs() added; zero_figure() default path now issues exactly one execute() call; existing TestZeroFigure tests updated to match, 3 new tests pass, full suite green (692 tests)","dependencies":[{"issue_id":"daz-script-server-gc82","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:16Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-gc82","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:26Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"daz-script-server-yy2u","title":"Batching Task 3B: DazElement.set_properties() bulk write","description":"Add DazElement.set_properties(values: dict) -\u003e dict[str, bool] issuing one execute() call for multiple property writes (one owner lookup, one property lookup per label inside Studio), returning per-label success. See plan Task 3B.","acceptance_criteria":"Multiple mutations issue 1 execute() call; missing property reported False per-label; labels with quotes/backslashes/newlines are JSON-safe.","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:28Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:14:11Z","started_at":"2026-08-22T02:13:33Z","closed_at":"2026-08-22T02:14:11Z","close_reason":"set_properties() added; issues exactly one execute() call for any number of labels; 3 new tests pass, full suite green (695 tests)","dependencies":[{"issue_id":"daz-script-server-yy2u","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:17Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-yy2u","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:27Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"daz-script-server-bi4e","title":"Batching Task 2B/3A: DazNode.set_transform() + single-call reset_transforms()","description":"Add DazNode.set_transform(position, rotation, scale) with all-optional args, one execute() call, omitted components untouched. Rebuild dazpy/poses.py reset_transforms() to call it (currently 3 separate setter calls). See plan Task 2B.","acceptance_criteria":"set_transform() with all 3 args issues 1 call; omitted component absent from generated script; no-args call is a no-op; reset_transforms() issues exactly 1 call.","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:27Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:11:55Z","started_at":"2026-08-22T02:10:43Z","closed_at":"2026-08-22T02:11:55Z","close_reason":"DazNode.set_transform() added; reset_transforms() now issues exactly one execute() call; 4 new tests + updated baseline pass, full suite green (690 tests)","dependencies":[{"issue_id":"daz-script-server-bi4e","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:16Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-bi4e","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:25Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"daz-script-server-lly2","title":"Batching Task 2A: single-call DazElement.snapshot()","description":"Rewrite DazElement.snapshot() (dazpy/_element.py:115-127) to issue exactly one client.execute() call for multiple fields instead of looping get_property() per field. Preserve missing-owner/missing-property -\u003e None semantics. See plan Task 2A.","acceptance_criteria":"snapshot() issues exactly one execute() call for N fields; labels are JSON-escaped safely; missing owner returns None for all fields.","status":"closed","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:26Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:11:54Z","closed_at":"2026-08-22T02:11:54Z","close_reason":"re-closing after local branch cleanup (bd state momentarily reverted by an unrelated git reset --hard); both tasks' code/tests are committed on feature/script-call-batching","dependencies":[{"issue_id":"daz-script-server-lly2","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:15Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-lly2","depends_on_id":"daz-script-server-ed0p","type":"blocks","created_at":"2026-08-21T22:03:25Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"daz-script-server-ed0p","title":"Batching Task 1: call-count baseline characterization tests","description":"Add TestCallCountBaseline to tests/test_dazpy.py pinning current client.execute() call counts for snapshot(), reset_transforms(), zero_figure() default mode, and Batch, before any production code changes. See docs/superpowers/plans/2026-08-21-script-call-batching-plan.md Task 1.","acceptance_criteria":"All 4 baseline tests pass against current (pre-fix) code; python -m pytest tests/test_dazpy.py -k TestCallCountBaseline -v is green.","status":"closed","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:12Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:11:54Z","closed_at":"2026-08-22T02:11:54Z","close_reason":"re-closing after local branch cleanup (bd state momentarily reverted by an unrelated git reset --hard); both tasks' code/tests are committed on feature/script-call-batching","dependencies":[{"issue_id":"daz-script-server-ed0p","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:14Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":0,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"daz-script-server-dwxr","title":"Add DAZ-to-UE5 headless render pipeline example","description":"Add rendering/unreal_headless_render example (daz_exporter.py, ue_headless_render.py, render_orchestrator.py) to daz-script-server-examples repo; add DazSkeleton.bone_rotations_quat() to dazpy if needed.","status":"closed","priority":2,"issue_type":"feature","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-16T02:59:10Z","created_by":"G.Hirpara","updated_at":"2026-08-16T03:04:35Z","started_at":"2026-08-16T02:59:13Z","closed_at":"2026-08-16T03:04:35Z","close_reason":"Implemented and pushed: dazpy bone_rotations_quat() + rendering/unreal_headless_render example (daz_exporter.py, ue_headless_render.py, render_orchestrator.py, morph_name_map.py, README). daz_exporter.py verified end-to-end against a live DAZ Studio scene.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"daz-script-server-v6gn","title":"dazpy client wiring for render progress","description":"Add on_progress callback param to _render_api.py's render() (render_variants() already has one), sourced from percent/frame/note fields now flowing through _wait_render_sse's SSE consumption. Add async equivalents (async render()/render_variants()) in a new dazpy/_render_api_aio.py or dazpy/aio.py, built on AsyncDazClient.stream_render_progress() (_client_aio.py:282-303) + get_request_result(wait=True) fallback -- this async surface does not exist yet, only the raw stream_render_progress generator. Extend tests/test_dazpy.py and tests/test_dazpy_aio.py SSE-progress tests to cover new fields and the new async render helpers.","status":"closed","priority":2,"issue_type":"feature","owner":"ghirpara@yahoo.com","created_at":"2026-08-15T12:54:38Z","created_by":"G.Hirpara","updated_at":"2026-08-15T13:08:35Z","closed_at":"2026-08-15T13:08:35Z","close_reason":"Added on_progress callback to _render_api.py's render() (_wait_render_sse now invokes it on 'progress' SSE events). Added dazpy/_render_api_aio.py with async render()/render_variants() mirroring the sync versions, built on AsyncDazClient.stream_render_progress() + get_request_result(wait=True) fallback; exposed via dazpy.aio.render/render_variants. Added tests/test_render_api.py (sync on_progress tests) and extended tests/test_dazpy_aio.py with async render()/render_variants() progress tests. Full suite: 691 passed. Server-side progress events verified live end-to-end (see daz-script-server-zdob).","dependencies":[{"issue_id":"daz-script-server-v6gn","depends_on_id":"daz-script-server-8wt7","type":"parent-child","created_at":"2026-08-15T08:54:55Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-v6gn","depends_on_id":"daz-script-server-zdob","type":"blocks","created_at":"2026-08-15T08:54:56Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"daz-script-server-zdob","title":"Frame-level progress for animation renders","description":"Add a [DAZPY_FRAME] n/total print marker per frame in buildAnimationRenderScript's loop (DzScriptServerPane.cpp:2023+), parse it in onMessagePosted while m_currentRenderId is set, forward to a new RenderProgressBroker::notifyProgress(id, percent, note) (mirrors notifyStarted's pushEvent/makeEvent pattern), and add AsyncRequestManager::updateProgress(id, percent) so the existing-but-unused AsyncRequest::progress field is populated for /requests/:id/status polling too. No SDK dependency -- safe to ship regardless of the Iray-log spike outcome.","notes":"Server-side implemented: buildAnimationRenderScript emits [DAZPY_FRAME] n/total via print() before each frame's doRender(); onMessagePosted parses it (regex, main-thread only, guarded by m_sCurrentRenderId) and forwards to RenderProgressBroker::notifyProgress() (new SSE progress event with percent/frame/total_frames) and AsyncRequestManager::updateProgress() (populates the existing-but-unused progress field for /requests/:id/status polling). Builds cleanly (./build.sh build, SDK4). Not yet tested live (requires restarting the running DAZ Studio instance to load the new DLL). Next: dazpy client wiring (daz-script-server-v6gn), then live verification of both together.","status":"closed","priority":2,"issue_type":"feature","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-15T12:54:37Z","created_by":"G.Hirpara","updated_at":"2026-08-15T13:08:34Z","started_at":"2026-08-15T13:02:33Z","closed_at":"2026-08-15T13:08:34Z","close_reason":"Implemented and live-verified: buildAnimationRenderScript() prints [DAZPY_FRAME] n/total before each frame's doRender(); onMessagePosted() parses it and forwards to RenderProgressBroker::notifyProgress() (new SSE progress event with percent/frame/total_frames) and AsyncRequestManager::updateProgress() (populates the progress field for /requests/:id/status polling). Live end-to-end test: submitted a 10-frame 512x512 animation render, streamed GET /render/:id/progress, and observed real monotonically increasing percent/frame events (10%..90%, frame 2..10) arrive before the terminal complete event; all 10 output frames rendered correctly to disk.","dependencies":[{"issue_id":"daz-script-server-zdob","depends_on_id":"daz-script-server-8wt7","type":"parent-child","created_at":"2026-08-15T08:54:54Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} @@ -161,8 +161,8 @@ {"_type":"issue","id":"daz-script-server-0dt","title":"JSON event serialization for all event types","description":"Implement serializeEvent() in SceneEventBroker for each signal category.\n\nOutput format per event:\n {\"type\":\"node.added\",\"ts\":1716307200123,\"data\":{...}}\n\nEvent types and their data payloads:\n- scene.loading, scene.loaded, scene.saving, scene.saved(filename), scene.clear_starting, scene.cleared\n- node.added(node_id, node_name, node_type), node.removed(node_id, node_name), node.list_changed\n- skeleton.added(node_id, node_name), skeleton.removed(node_id, node_name)\n- light.added(node_id, node_name, node_type), light.removed(node_id, node_name)\n- camera.added(node_id, node_name), camera.removed(node_id, node_name)\n- selection.list_changed, selection.primary_changed(node_id, node_name) -- empty data{} if null\n- time.changed(time_value, fps), playback.started, playback.finished\n- render.started, render.finished\n\nUse existing JsonBuilder for safe serialization. Extract node_id via node-\u003egetElementID() or similar DAZ SDK call -- verify exact method name against SDK headers.","status":"closed","priority":2,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-05-21T17:49:26Z","created_by":"G.Hirpara","updated_at":"2026-05-21T17:55:35Z","closed_at":"2026-05-21T17:55:35Z","close_reason":"Implemented in SceneEventBroker.cpp: makeEvent(), nodeInfoJson(), per-slot JsonBuilder calls for all event types","dependencies":[{"issue_id":"daz-script-server-0dt","depends_on_id":"daz-script-server-2bs","type":"parent-child","created_at":"2026-05-21T13:50:14Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-0dt","depends_on_id":"daz-script-server-azx","type":"blocks","created_at":"2026-05-21T13:49:44Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"daz-script-server-isb","title":"SceneEventBroker and SubscriberQueue classes","description":"Create include/SceneEventBroker.h and src/SceneEventBroker.cpp.\n\nSceneEventBroker (QObject, main thread):\n- Connects to dzApp-\u003egetScene() signals via Qt::DirectConnection on start()\n- Disconnects all signals on stop()\n- Maintains QMutex-protected list of SubscriberQueue*\n- registerSubscriber(queue, filter_mask) and unregisterSubscriber(queue)\n- Internal: serializeEvent(type, data) -\u003e QString JSON\n\nSubscriberQueue (one per SSE client):\n- QMutex + QWaitCondition + std::deque\u003cQString\u003e\n- push(const QString\u0026 event) -- called from main thread\n- pop(int timeout_ms) -\u003e QString -- called from HTTP thread, blocks\n- close() -- signals the queue is done (server stopping)\n\nFilter mask: bitmask of event categories (node, selection, scene, time, render, light, camera, skeleton).","status":"closed","priority":2,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-05-21T17:49:16Z","created_by":"G.Hirpara","updated_at":"2026-05-21T17:55:36Z","started_at":"2026-05-21T17:50:54Z","closed_at":"2026-05-21T17:55:36Z","close_reason":"SceneEventBroker + SubscriberQueue in include/SceneEventBroker.h and src/SceneEventBroker.cpp","dependencies":[{"issue_id":"daz-script-server-isb","depends_on_id":"daz-script-server-0dt","type":"blocks","created_at":"2026-05-21T13:49:25Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-isb","depends_on_id":"daz-script-server-2bs","type":"parent-child","created_at":"2026-05-21T13:50:14Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-isb","depends_on_id":"daz-script-server-8rd","type":"blocks","created_at":"2026-05-21T13:49:34Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"daz-script-server-2bs","title":"Scene Change Callbacks via SSE","description":"Implement real-time scene change notifications over Server-Sent Events (SSE). Clients connect to GET /scene/events and receive push notifications when the DAZ scene changes. Replaces the need for polling. See plan in conversation history for full architecture.","status":"closed","priority":2,"issue_type":"epic","owner":"ghirpara@yahoo.com","created_at":"2026-05-21T17:49:08Z","created_by":"G.Hirpara","updated_at":"2026-05-21T17:58:30Z","closed_at":"2026-05-21T17:58:30Z","close_reason":"Scene change callbacks via SSE fully implemented: SceneEventBroker + SubscriberQueue + GET /scene/events endpoint with auth, filter, debounce, keepalive, and lifecycle wiring","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"daz-script-server-18q8","title":"Batching Task 7: document batching, limits, and async batch submission","description":"Update docs/api/batch.rst (add_operation/add_prelude, whole-batch failure/no-rollback, size limits, execute_batch_async) and docs/quickstart.rst (before/after bulk-update example, when to prefer async batch over sync). See plan Task 7.","acceptance_criteria":"docs/api/batch.rst documents one-HTTP-request-vs-one-DazScript-evaluation, add_operation()/add_prelude(), BatchLimitExceededError, and execute_batch_async(); docs/quickstart.rst has a before/after bulk-update example.","status":"open","priority":3,"issue_type":"task","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:49Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:02:49Z","dependencies":[{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:19Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-72d8","type":"blocks","created_at":"2026-08-21T22:03:34Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-bi4e","type":"blocks","created_at":"2026-08-21T22:03:30Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-gc82","type":"blocks","created_at":"2026-08-21T22:03:31Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-kspb","type":"blocks","created_at":"2026-08-21T22:03:33Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-ldst","type":"blocks","created_at":"2026-08-21T22:03:32Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-liru","type":"blocks","created_at":"2026-08-21T22:03:33Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-lly2","type":"blocks","created_at":"2026-08-21T22:03:29Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-yy2u","type":"blocks","created_at":"2026-08-21T22:03:32Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"daz-script-server-xtkd","title":"scene.find_skeleton() intermittently fails to resolve a valid skeleton name under restore() load","description":"Observed twice while live-validating daz-script-server-jz0e's fix (Genesis 9\n\"Jason Cross\" scene, DazSceneState.apply() driving all 11 skeletons through\npose-restore + verify + follow-target-fix in one call -- ~20-30+ HTTP\nround trips in quick succession):\n\nDazSceneState.apply()'s per-skeleton loop reported \"Skeleton not found:\nGenesis9Eyelashes\" (once) and, on a later run, both \"Genesis9Eyelashes\" and\n\"Genesis9Tear\" (same run). In both cases, calling scene.find_skeleton() for\nthe exact same name immediately afterward, standalone, succeeded -- so the\nskeleton was never actually missing; the lookup transiently failed only\nunder the load of the surrounding restore() call.\n\nImpact so far: benign. Both affected items had no pose data to restore in\neither occurrence (their own capture() was empty), so no visible scene\ncorruption resulted -- but this is separate flakiness from\ndaz-script-server-jz0e's actual bug (which is confirmed fixed: fit-to\nrelationships held correctly across all these same runs, including the\nones with this find_skeleton flakiness). Worth investigating if it starts\naffecting a skeleton that *does* have real pose data to restore -- that\nwould show up as an incorrectly-reported-error (or worse, a silently\nunrestored pose) for a skeleton that's actually fine.\n\nPossibly related to DzScriptServerPane::isMainThreadBusy()'s documented\nlazy-content-streaming tail (see mainThreadBusyMessage()'s new\nDzBackgroundProgress/DzProgress distinction, added same session) -- but\nfind_skeleton() itself has no retry_on_busy option and this failure mode\ndoesn't surface as a 503 at all (Scene.getSkeletonList() just returns an\nincomplete list momentarily), so it isn't caught by the existing busy\ndetection/retry machinery either way.\n\nACCEPTANCE CRITERIA\n- Root cause understood (scene-graph iteration racing with concurrent\n main-thread mutation from other skeletons' apply_full() calls in the\n same burst, most likely) or reproduced in isolation.\n- find_skeleton() (or DazSceneState.apply()'s use of it) made reliable\n under this load pattern -- retry-with-backoff on a momentary miss, or a\n fix to whatever race causes Scene.getSkeletonList() to be incomplete.","status":"open","priority":3,"issue_type":"bug","owner":"ghirpara@yahoo.com","created_at":"2026-08-21T22:56:00Z","created_by":"G.Hirpara","updated_at":"2026-08-21T22:56:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"daz-script-server-18q8","title":"Batching Task 7: document batching, limits, and async batch submission","description":"Update docs/api/batch.rst (add_operation/add_prelude, whole-batch failure/no-rollback, size limits, execute_batch_async) and docs/quickstart.rst (before/after bulk-update example, when to prefer async batch over sync). See plan Task 7.","acceptance_criteria":"docs/api/batch.rst documents one-HTTP-request-vs-one-DazScript-evaluation, add_operation()/add_prelude(), BatchLimitExceededError, and execute_batch_async(); docs/quickstart.rst has a before/after bulk-update example.","status":"closed","priority":3,"issue_type":"task","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-22T02:02:49Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:23:49Z","started_at":"2026-08-22T02:22:12Z","closed_at":"2026-08-22T02:23:49Z","close_reason":"Documented add_operation/add_prelude, size limits, whole-batch failure/no-rollback, and execute_batch_async in docs/api/batch.rst; added before/after bulk-update example and sync-vs-async guidance to docs/quickstart.rst","dependencies":[{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-01cg","type":"parent-child","created_at":"2026-08-21T22:03:19Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-72d8","type":"blocks","created_at":"2026-08-21T22:03:34Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-bi4e","type":"blocks","created_at":"2026-08-21T22:03:30Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-gc82","type":"blocks","created_at":"2026-08-21T22:03:31Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-kspb","type":"blocks","created_at":"2026-08-21T22:03:33Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-ldst","type":"blocks","created_at":"2026-08-21T22:03:32Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-liru","type":"blocks","created_at":"2026-08-21T22:03:33Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-lly2","type":"blocks","created_at":"2026-08-21T22:03:29Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-18q8","depends_on_id":"daz-script-server-yy2u","type":"blocks","created_at":"2026-08-21T22:03:32Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"daz-script-server-xtkd","title":"scene.find_skeleton() intermittently fails to resolve a valid skeleton name under restore() load","description":"Observed twice while live-validating daz-script-server-jz0e's fix (Genesis 9\n\"Jason Cross\" scene, DazSceneState.apply() driving all 11 skeletons through\npose-restore + verify + follow-target-fix in one call -- ~20-30+ HTTP\nround trips in quick succession):\n\nDazSceneState.apply()'s per-skeleton loop reported \"Skeleton not found:\nGenesis9Eyelashes\" (once) and, on a later run, both \"Genesis9Eyelashes\" and\n\"Genesis9Tear\" (same run). In both cases, calling scene.find_skeleton() for\nthe exact same name immediately afterward, standalone, succeeded -- so the\nskeleton was never actually missing; the lookup transiently failed only\nunder the load of the surrounding restore() call.\n\nImpact so far: benign. Both affected items had no pose data to restore in\neither occurrence (their own capture() was empty), so no visible scene\ncorruption resulted -- but this is separate flakiness from\ndaz-script-server-jz0e's actual bug (which is confirmed fixed: fit-to\nrelationships held correctly across all these same runs, including the\nones with this find_skeleton flakiness). Worth investigating if it starts\naffecting a skeleton that *does* have real pose data to restore -- that\nwould show up as an incorrectly-reported-error (or worse, a silently\nunrestored pose) for a skeleton that's actually fine.\n\nPossibly related to DzScriptServerPane::isMainThreadBusy()'s documented\nlazy-content-streaming tail (see mainThreadBusyMessage()'s new\nDzBackgroundProgress/DzProgress distinction, added same session) -- but\nfind_skeleton() itself has no retry_on_busy option and this failure mode\ndoesn't surface as a 503 at all (Scene.getSkeletonList() just returns an\nincomplete list momentarily), so it isn't caught by the existing busy\ndetection/retry machinery either way.\n\nACCEPTANCE CRITERIA\n- Root cause understood (scene-graph iteration racing with concurrent\n main-thread mutation from other skeletons' apply_full() calls in the\n same burst, most likely) or reproduced in isolation.\n- find_skeleton() (or DazSceneState.apply()'s use of it) made reliable\n under this load pattern -- retry-with-backoff on a momentary miss, or a\n fix to whatever race causes Scene.getSkeletonList() to be incomplete.","design":"Root cause: Scene.getSkeletonList() races against DAZ Studio's own deferred/background scene-graph work (see mainThreadBusyMessage()'s documented lazy-content-streaming tail and the related dpi-mxq partial-main-thread-execution-under-contention issue) -- neither is observable or controllable from the DazScript/HTTP side, and the only externally visible symptom is a momentary false miss on an item that is confirmed present a moment later. True root-cause fix would require DAZ Studio SDK-side instrumentation we don't have; per this issue's own acceptance criteria, resolved via retry-with-backoff instead.","notes":"Implemented in dazpy/_scene.py DazScene.find_skeleton(): up to 3 getSkeletonList() lookups (configurable via retry_attempts/retry_delay), short linear backoff (0.15s * attempt) between misses, before falling through to the existing hint-building/NodeNotFoundError path. find_skeleton_by_label() was left untouched -- same underlying race is plausible there but no failure was observed/reported against it; can extend later if it recurs. Not root-caused in the sense of a live Studio repro (would require the same restore() burst load that has previously frozen Studio -- avoided per prior session guidance on not running the full live integration suite); the fix targets the class of transient miss regardless of exact internal DAZ Studio mechanism.","status":"closed","priority":3,"issue_type":"bug","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-21T22:56:00Z","created_by":"G.Hirpara","updated_at":"2026-08-22T02:28:56Z","started_at":"2026-08-22T02:25:05Z","closed_at":"2026-08-22T02:28:56Z","close_reason":"Added retry-with-backoff to DazScene.find_skeleton() for the transient Scene.getSkeletonList() miss described in the bug; likely tied to DAZ Studio's own deferred/background scene-graph work (same family as dpi-mxq), which isn't independently observable from the DazScript/HTTP side. Covered by new unit tests; full test suite green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"daz-script-server-dyf9","title":"Expose camera aperture / bokeh controls (DOF blur intensity)","description":"GitHub issue #25 (split from #20, last remaining item). DzCamera exposed depth_of_field/focal_length/focal_distance but nothing controlling DOF blur intensity or bokeh shape.","status":"closed","priority":3,"issue_type":"feature","owner":"ghirpara@yahoo.com","created_at":"2026-08-15T13:13:44Z","created_by":"G.Hirpara","updated_at":"2026-08-15T13:13:49Z","closed_at":"2026-08-15T13:13:49Z","close_reason":"Added DazCamera.f_stop, .aperture_blades, .aperture_blade_rotation (dazpy/_camera.py). Property labels confirmed against a live DAZ Studio instance: 'F/Stop' (name 'Aperature'), 'Aperture Blades', 'Aperture Blade Rotation' -- all present on DzBasicCamera/DzCamera and writable. Verified read/write round-trip live, then cleaned up the test camera. Unit tests added (6 new), full suite (671 tests) passes.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"daz-script-server-s9qe","title":"Iray intra-frame percent via log parsing (conditional on spike)","description":"Only if the spike issue confirms Iray posts parseable progress text through onMessagePosted: add a small regex-based RenderLogProgressParser invoked from onMessagePosted while m_currentRenderId is set. Throttle emission (\u003e=1% change or \u003e=500ms) via lastPercent/lastEmitMs tracked per request in RenderProgressBroker's RequestWatch. Feed into notifyProgress(), combined with frame info for animation renders.","status":"closed","priority":3,"issue_type":"feature","owner":"ghirpara@yahoo.com","created_at":"2026-08-15T12:54:37Z","created_by":"G.Hirpara","updated_at":"2026-08-15T13:00:02Z","closed_at":"2026-08-15T13:00:02Z","close_reason":"Closed","dependencies":[{"issue_id":"daz-script-server-s9qe","depends_on_id":"daz-script-server-88wm","type":"blocks","created_at":"2026-08-15T08:54:55Z","created_by":"G.Hirpara","metadata":"{}"},{"issue_id":"daz-script-server-s9qe","depends_on_id":"daz-script-server-8wt7","type":"parent-child","created_at":"2026-08-15T08:54:54Z","created_by":"G.Hirpara","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"daz-script-server-no7b","title":"dazpy.materials: IrayMaterial/TextureMap/SurfaceProperty submodule (GH #31 item 2c)","description":"GitHub issue #31 (item 2) proposes dazpy.materials as a first-class submodule wrapping Iray PBR material setup, alongside the sibling submodules dazpy.cinematics/lighting/poses that already ship. Currently dazpy/ has no materials.py -- only the low-level DazMaterial primitive (_material.py) exists, so downstream users setting up Iray materials (Base Color, texture maps, Metallic/Roughness/Glossy, opacity, etc.) still have to hand-write raw DazScript or chain low-level primitive calls. Scope, per the GH issue's proposed API shape: IrayMaterial (declarative material spec dataclass, mirroring ThreePointLightSetup/HDRIEnvironment's apply_* pattern), TextureMap (texture-slot assignment: diffuse/normal/bump/roughness/metallic/opacity maps with file-existence validation, matching the precedent set by HDRIEnvironment's environment-map path validation), SurfaceProperty (generic named Iray surface-channel get/set, for channels not covered by the typed IrayMaterial fields). Should build on the existing DazMaterial primitive rather than raw script strings, matching the codebase convention (see dazpy/lighting.py, dazpy/poses.py for the apply_*(scene_or_target, spec) pattern to follow).","design":"Follow the same design-spec -\u003e plan -\u003e implementation workflow used for dazpy.poses (docs/superpowers/specs/2026-08-10-dazpy-poses-design.md) and dazpy.lighting -- start with a design spec doc before writing code.","acceptance_criteria":"dazpy/materials.py exists exporting IrayMaterial, TextureMap, SurfaceProperty (or equivalently-scoped names) with an apply_* entrypoint; symbols exported from dazpy/__init__.py top-level package like the other submodules; live-verified against a real DAZ Studio instance per this project's convention (see bu86, GH #32 fixes); unit tests added; CHANGELOG.md entry added.","status":"closed","priority":3,"issue_type":"feature","assignee":"G.Hirpara","owner":"ghirpara@yahoo.com","created_at":"2026-08-15T11:52:40Z","created_by":"G.Hirpara","updated_at":"2026-08-15T12:02:56Z","started_at":"2026-08-15T11:54:16Z","closed_at":"2026-08-15T12:02:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} diff --git a/CHANGELOG.md b/CHANGELOG.md index b5200a1..0f8c09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to DazScript Server are documented here. ## [Unreleased] +## [2.9.2] - 2026-08-21 + +### dazpy script-call batching + +`Batch` gains `add_operation()`/`add_prelude()` with configurable size +limits, plus `DazElement.snapshot()`/`set_properties()`, +`DazNode.set_transform()`, `DazSkeleton.set_state()`, a single-call +`zero_figure()` default path, and one-request async batch submission via +`execute_batch_async()`. Main-thread wait time is now exposed in +`GET /metrics`. See `docs/api/batch.rst` and `docs/quickstart.rst` for +usage and the whole-batch failure/no-rollback semantics. + +### Fixed + +`DazScene.find_skeleton()` now retries `Scene.getSkeletonList()` on a +transient miss instead of immediately raising `NodeNotFoundError` -- the +lookup has been observed to momentarily omit a skeleton that is actually +present, under load from a burst of other main-thread script calls. + ## [2.9.1] - 2026-08-20 ### Async script-file jobs diff --git a/README.md b/README.md index 0fdc096..fd61800 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DazScript Server -**Version 2.9.1** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS +**Version 2.9.2** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS [![Docs](https://img.shields.io/badge/docs-dazpy%20SDK-blue)](https://bluemoonfoundry.github.io/daz-script-server/) [![HTTP API](https://img.shields.io/badge/docs-HTTP%20API%20reference-blue)](https://bluemoonfoundry.github.io/daz-script-server/api-reference/) diff --git a/dazpy/__init__.py b/dazpy/__init__.py index c017aca..fe553fd 100644 --- a/dazpy/__init__.py +++ b/dazpy/__init__.py @@ -13,7 +13,7 @@ figure.find_bone("r_forearm").set_local_rotation(0, 0, 45) """ -__version__ = "2.9.1" +__version__ = "2.9.2" from ._client import DazClient from ._scene import DazScene diff --git a/dazpy/_batch.py b/dazpy/_batch.py index a9692e3..085f69e 100644 --- a/dazpy/_batch.py +++ b/dazpy/_batch.py @@ -1,13 +1,37 @@ from __future__ import annotations from ._client import DazClient +from .exceptions import BatchLimitExceededError + +DEFAULT_MAX_OPERATIONS = 500 +DEFAULT_MAX_SCRIPT_LENGTH = 900_000 # stays under the server's default 1MB script cap + + +def build_operations_script(operations: list[tuple[list[str], str]]) -> str: + """Build one IIFE script from a list of (body_lines, result_expression) pairs. + + Shared by :meth:`Batch._build_script` and + :meth:`~dazpy.DazClient.execute_batch_async` so both produce scripts with + identical shape (keyed return object over ``_r0``, ``_r1``, ...). + """ + body_lines = [] + return_parts = [] + for i, (lines, result_expression) in enumerate(operations): + key = f"_r{i}" + body_lines.extend(lines) + body_lines.append(f"var {key} = {result_expression};") + return_parts.append(f'"{key}": {key}') + return_obj = "{" + ", ".join(return_parts) + "}" + body_lines.append(f"return {return_obj};") + body = "\n".join(body_lines) + return f"(function(){{\n{body}\n}})()" class BatchFuture: """Placeholder for a single result within a :class:`Batch` execution. - Created by :meth:`Batch.add`; the :attr:`value` property blocks until the - batch has been executed. + Created by :meth:`Batch.add` or :meth:`Batch.add_operation`; the + :attr:`value` property blocks until the batch has been executed. """ def __init__(self, key: str): @@ -50,20 +74,42 @@ class Batch: b.execute() print(f.value) + High-level helpers that generate operations programmatically should use + :meth:`add_operation` instead of :meth:`add` — it does not require the + caller to know the internally generated result-variable name, and + :meth:`add_prelude` lets multiple operations share one setup block (e.g. + a node lookup) emitted only once. + Args: client: The :class:`~dazpy.DazClient` to use. + max_operations: Maximum number of queued operations before + :meth:`add_operation` raises :class:`~dazpy.exceptions.BatchLimitExceededError`. + max_script_length: Maximum generated script length (characters) + before :meth:`execute` raises :class:`~dazpy.exceptions.BatchLimitExceededError`. """ - def __init__(self, client: DazClient): + def __init__( + self, + client: DazClient, + max_operations: int = DEFAULT_MAX_OPERATIONS, + max_script_length: int = DEFAULT_MAX_SCRIPT_LENGTH, + ): self._client = client self._ops: list[tuple[str, list[str], BatchFuture]] = [] + self._preludes: dict[str, list[str]] = {} + self._prelude_order: list[str] = [] self._counter = 0 + self._max_operations = max_operations + self._max_script_length = max_script_length def add(self, lines: list[str]) -> BatchFuture: """Queue a list of DazScript lines to be included in the batch. - The last line in *lines* should assign the desired result to a variable - named after the key that will be referenced internally. + The last line in *lines* should assign the desired result to a + variable named after the internally generated key (``_r0``, ``_r1``, + ... in call order) — inspect a prior :meth:`execute` call's generated + script if the exact naming matters, or prefer :meth:`add_operation`, + which does not require guessing the key name. Args: lines: DazScript source lines (no ``return`` needed). @@ -77,8 +123,64 @@ def add(self, lines: list[str]) -> BatchFuture: self._ops.append((key, lines, future)) return future + def add_prelude(self, prelude_key: str, lines: list[str]) -> None: + """Register a shared setup block, emitted once per unique *prelude_key*. + + Call this before :meth:`add_operation` calls whose bodies depend on + the prelude's bound variable(s) (e.g. a node lookup bound to + ``_node_Fig``). Repeated calls with the same *prelude_key* are no-ops + after the first — use this instead of re-emitting an identical lookup + once per operation. + + Args: + prelude_key: Stable identifier for this setup block (e.g. + ``"node:Fig"``). Callers must pick keys that collide exactly + when — and only when — the generated lines are identical. + lines: DazScript source lines for the shared setup. + """ + if prelude_key not in self._preludes: + self._preludes[prelude_key] = list(lines) + self._prelude_order.append(prelude_key) + + def add_operation(self, body_lines: list[str], result_expression: str) -> BatchFuture: + """Queue an operation whose result the builder assigns internally. + + Unlike :meth:`add`, the caller does not need to know the generated + key name — pass the JS expression that yields the result + (*result_expression*, e.g. a variable set inside *body_lines*, or a + literal expression), and the builder emits + ``var _rN = ;`` itself. + + Args: + body_lines: DazScript source lines with no trailing result + assignment (side effects only, e.g. property writes). + result_expression: A JS expression evaluated once, immediately + after *body_lines* run, and used as this operation's result. + Mutation-only operations should pass ``"null"``. + + Returns: + A :class:`BatchFuture` that resolves after :meth:`execute`. + + Raises: + BatchLimitExceededError: If this call would exceed the batch's + configured ``max_operations``. + """ + if len(self._ops) >= self._max_operations: + raise BatchLimitExceededError( + f"Batch already has {len(self._ops)} operations " + f"(max_operations={self._max_operations})" + ) + key = f"_r{self._counter}" + self._counter += 1 + future = BatchFuture(key) + lines = list(body_lines) + [f"var {key} = {result_expression};"] + self._ops.append((key, lines, future)) + return future + def _build_script(self) -> str: body_lines = [] + for prelude_key in self._prelude_order: + body_lines.extend(self._preludes[prelude_key]) return_parts = [] for key, lines, _ in self._ops: body_lines.extend(lines) @@ -89,10 +191,20 @@ def _build_script(self) -> str: return f"(function(){{\n{body}\n}})()" def execute(self) -> None: - """Execute all queued operations in a single HTTP request and resolve all futures.""" + """Execute all queued operations in a single HTTP request and resolve all futures. + + Raises: + BatchLimitExceededError: If the generated script exceeds + ``max_script_length``. Raised before any HTTP call. + """ if not self._ops: return script = self._build_script() + if len(script) > self._max_script_length: + raise BatchLimitExceededError( + f"Generated batch script is {len(script)} characters " + f"(max_script_length={self._max_script_length})" + ) result = self._client.execute(script) data = result.value or {} for key, _, future in self._ops: diff --git a/dazpy/_client.py b/dazpy/_client.py index 9a60a0c..a89c28f 100644 --- a/dazpy/_client.py +++ b/dazpy/_client.py @@ -321,6 +321,25 @@ def _do(): return self._with_busy_retry(_do, retry_on_busy, max_wait) + def execute_batch_async(self, operations: list[dict], args: object = None) -> str: + """Submit multiple operations as one async request (one queue slot, one script). + + Args: + operations: List of ``{"body_lines": [...], "result_expression": "..."}`` + dicts — same shape as :meth:`~dazpy.Batch.add_operation`'s arguments. + args: Optional argument passed to the combined script. + + Returns: + The server-assigned ``request_id``. Poll it like any other async + request; the result's ``result`` field is a dict keyed ``"_r0"``, + ``"_r1"``, ... in submission order. + """ + from ._batch import build_operations_script + + pairs = [(op["body_lines"], op["result_expression"]) for op in operations] + script = build_operations_script(pairs) + return self.execute_async_submit(script, args=args) + def get_request_status(self, request_id: str) -> dict: """Return the current status of an async request. diff --git a/dazpy/_client_aio.py b/dazpy/_client_aio.py index c6050bd..21756b7 100644 --- a/dazpy/_client_aio.py +++ b/dazpy/_client_aio.py @@ -159,6 +159,14 @@ async def _do(): return await self._with_busy_retry(_do, retry_on_busy, max_wait) + async def execute_batch_async(self, operations: list[dict], args: object = None) -> str: + """Submit multiple operations as one async request. See :meth:`dazpy.DazClient.execute_batch_async`.""" + from ._batch import build_operations_script + + pairs = [(op["body_lines"], op["result_expression"]) for op in operations] + script = build_operations_script(pairs) + return await self.execute_async_submit(script, args=args) + async def get_request_status(self, request_id: str) -> dict: """See :meth:`dazpy.DazClient.get_request_status`.""" resp = await self._get(f"/requests/{request_id}/status") diff --git a/dazpy/_element.py b/dazpy/_element.py index ff554be..a7dc614 100644 --- a/dazpy/_element.py +++ b/dazpy/_element.py @@ -59,6 +59,36 @@ def set_property(self, label: str, value: object) -> None: """) self._client.execute(script) + def set_properties(self, values: dict[str, object]) -> dict[str, bool]: + """Set multiple property values by display label in one call. + + Args: + values: ``{label: value}``. Each value must be JSON-serialisable. + + Returns: + ``{label: True}`` for labels that resolved to a real property and + were written, ``{label: False}`` for labels that did not resolve. + """ + data_json = json.dumps(values) + script = ScriptBuilder.iife(f""" + var obj = {self._locator}; + if (!obj) return null; + var _data = {data_json}; + var _result = {{}}; + for (var _label in _data) {{ + if (!_data.hasOwnProperty(_label)) continue; + var prop = obj.findPropertyByLabel(_label); + if (prop) {{ + prop.setValue(_data[_label]); + _result[_label] = true; + }} else {{ + _result[_label] = false; + }} + }} + return _result; + """) + return self._client.execute(script).value or {} + def list_properties(self) -> list[dict]: """Return metadata for every property on this element. @@ -119,11 +149,25 @@ def snapshot(self, fields: list[str]) -> dict: fields: Property labels to read. Returns: - A dict mapping each label to its current value. + A dict mapping each label to its current value. Missing owner or + missing property both resolve to ``None`` for the affected label(s). """ cache = object.__getattribute__(self, "_cache") + fields_json = json.dumps(fields) + script = ScriptBuilder.iife(f""" + var obj = {self._locator}; + if (!obj) return null; + var _fields = {fields_json}; + var _result = {{}}; + for (var i = 0; i < _fields.length; i++) {{ + var prop = obj.findPropertyByLabel(_fields[i]); + _result[_fields[i]] = prop ? prop.getValue() : null; + }} + return _result; + """) + values = self._client.execute(script).value or {} for field in fields: - cache[field] = self.get_property(field) + cache[field] = values.get(field) return {f: cache[f] for f in fields} def refresh(self) -> None: diff --git a/dazpy/_node.py b/dazpy/_node.py index c5a032f..3fbe1d9 100644 --- a/dazpy/_node.py +++ b/dazpy/_node.py @@ -144,6 +144,46 @@ def set_scale(self, x: float, y: float, z: float) -> None: ) self._client.execute(script) + def set_transform( + self, + position: tuple[float, float, float] | None = None, + rotation: tuple[float, float, float] | None = None, + scale: tuple[float, float, float] | None = None, + ) -> None: + """Set any combination of local position, rotation, and scale in one call. + + Every argument is optional. Omitted components are left untouched. + Equivalent to calling :meth:`set_local_position`, :meth:`set_local_rotation`, + and/or :meth:`set_scale` individually, but round-trips only once. + + Args: + position: ``(x, y, z)`` local-space position, or ``None`` to leave unchanged. + rotation: ``(x, y, z)`` Euler rotation in degrees, or ``None`` to leave unchanged. + scale: ``(x, y, z)`` per-axis scale, or ``None`` to leave unchanged. + """ + lines = [] + if position is not None: + x, y, z = position + lines.append(f"_node.setLocalPos(new DzVec3({x}, {y}, {z}));") + if rotation is not None: + x, y, z = rotation + lines.append( + f"_node.getXRotControl().setValue({x}); " + f"_node.getYRotControl().setValue({y}); " + f"_node.getZRotControl().setValue({z});" + ) + if scale is not None: + x, y, z = scale + lines.append( + f"_node.getXScaleControl().setValue({x}); " + f"_node.getYScaleControl().setValue({y}); " + f"_node.getZScaleControl().setValue({z});" + ) + if not lines: + return + script = ScriptBuilder.node_body(self._identifier, "\n".join(lines)) + self._client.execute(script) + @property def visible(self) -> bool | None: """General visibility flag (read/write). diff --git a/dazpy/_scene.py b/dazpy/_scene.py index d7431fb..8e7d85a 100644 --- a/dazpy/_scene.py +++ b/dazpy/_scene.py @@ -260,20 +260,34 @@ def skeletons(self) -> list["DazSkeleton"]: # noqa: F821 names = self._client.execute(script).value or [] return [DazSkeleton(self._client, NodeIdentifier(n)) for n in names] - def find_skeleton(self, name: str) -> "DazSkeleton": # noqa: F821 + def find_skeleton( + self, name: str, *, retry_attempts: int = 3, retry_delay: float = 0.15 + ) -> "DazSkeleton": # noqa: F821 """Find a skeleton by its internal name. Args: name: Internal name of the skeleton node (e.g. ``"Genesis9"``). To look up by the user-visible label shown in the Scene panel (e.g. ``"Genesis 9"``), use :meth:`find_skeleton_by_label`. + retry_attempts: Number of ``Scene.getSkeletonList()`` lookups to + try before concluding the skeleton is really absent. + ``Scene.getSkeletonList()`` has been observed to transiently + omit a skeleton that is present under load from a burst of + other main-thread script calls (see daz-script-server-xtkd) + -- a momentary miss is retried rather than immediately + raised. Set to ``1`` to disable retrying. + retry_delay: Base seconds to sleep between retries (each attempt + after the first waits ``retry_delay * attempt_number``). Returns: A :class:`~dazpy.DazSkeleton` proxy. Raises: - NodeNotFoundError: If no skeleton with that name exists. + NodeNotFoundError: If no skeleton with that name exists after + *retry_attempts* lookups. """ + import time + from ._skeleton import DazSkeleton from .exceptions import NodeNotFoundError lookup = ScriptBuilder.iife(f""" @@ -283,7 +297,14 @@ def find_skeleton(self, name: str) -> "DazSkeleton": # noqa: F821 }} return false; """) - if not self._client.execute(lookup).value: + found = False + for attempt in range(retry_attempts): + if self._client.execute(lookup).value: + found = True + break + if attempt < retry_attempts - 1: + time.sleep(retry_delay * (attempt + 1)) + if not found: hint = ScriptBuilder.iife(""" var info = []; var skels = Scene.getSkeletonList(); diff --git a/dazpy/_scene_state.py b/dazpy/_scene_state.py index 7c0a1b0..aff8253 100644 --- a/dazpy/_scene_state.py +++ b/dazpy/_scene_state.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import time from typing import TYPE_CHECKING +from .exceptions import DazBusyError from ._pose import _ZERO3, DazPose from ._script_builder import ScriptBuilder @@ -13,6 +15,32 @@ _LIGHT_EXTRA_KEYS = ["Flux", "Shadow Softness", "Spread Angle"] _DEFAULT_VERIFY_TOLERANCE = 0.05 _DEFAULT_MAX_VERIFY_RETRIES = 2 +_FOLLOW_TARGET_RETRY_MAX_WAIT = 30.0 + + +def _retry_on_busy(fn, max_wait: float = _FOLLOW_TARGET_RETRY_MAX_WAIT): + """Run *fn* (a zero-arg callable), retrying with backoff on DazBusyError. + + follow_target()/fit_to()/unfit() (unlike DazPose.apply_full(), which + takes its own retry_on_busy param) don't expose retry-on-busy directly, + but apply()'s follow-target restoration pass makes several of these + calls back-to-back per skeleton on top of the pose-restore/verify calls + already made for every skeleton in the scene -- under that load a + single skeleton's follow-target fix can transiently hit StudioBusyError + even though the overall restore succeeds for every other skeleton. This + mirrors DazClient._with_busy_retry's backoff without depending on it. + """ + deadline = time.monotonic() + max_wait + backoff = 1.0 + while True: + try: + return fn() + except DazBusyError: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise + time.sleep(min(backoff, remaining)) + backoff = min(backoff + 1.0, 5.0) def _pose_mismatches(expected: DazPose, actual: DazPose, tolerance: float) -> list[str]: @@ -86,11 +114,23 @@ def __init__( camera_transforms: dict[str, dict[str, float]], light_transforms: dict[str, dict[str, float]], light_extra: dict[str, dict[str, float]], + follow_targets: dict[str, str | None] | None = None, ) -> None: self.skeleton_poses = skeleton_poses self.camera_transforms = camera_transforms self.light_transforms = light_transforms self.light_extra = light_extra + # Each skeleton's conform/fit-to relationship (by target skeleton + # name, or None if unfitted), captured and restored independently + # of DazPose's generic bones/morphs/props. DazPose.apply_full() + # writes back every property on a skeleton, including the internal + # "FID_" property DAZ Studio uses to persist a conforming + # item's fit registration -- rewriting that property (even to the + # exact value captured) desyncs the live getFollowTarget() pointer + # without raising any error. See daz-script-server-jz0e: a + # dedicated capture/restore pass via the real + # getFollowTarget()/setFollowTarget() API is the only reliable fix. + self.follow_targets = follow_targets or {} # ── construction ────────────────────────────────────────────────────────── @@ -104,9 +144,13 @@ def capture(cls, scene: "DazScene") -> "DazSceneState": Returns: A new :class:`DazSceneState`. """ - skeleton_poses = { - skel._identifier.value: DazPose.capture(skel) for skel in scene.skeletons() - } + skeleton_poses: dict[str, DazPose] = {} + follow_targets: dict[str, str | None] = {} + for skel in scene.skeletons(): + name = skel._identifier.value + skeleton_poses[name] = DazPose.capture(skel) + target = skel.follow_target() + follow_targets[name] = target._identifier.value if target is not None else None cam_script = ScriptBuilder.iife(f""" var _keys = {json.dumps(_TRANSFORM_KEYS)}; @@ -162,6 +206,7 @@ def capture(cls, scene: "DazScene") -> "DazSceneState": camera_transforms=camera_transforms, light_transforms=light_result.get("transforms", {}), light_extra=light_result.get("extra", {}), + follow_targets=follow_targets, ) # ── serialisation ───────────────────────────────────────────────────────── @@ -172,6 +217,7 @@ def to_dict(self) -> dict: "skeletons": {name: pose.to_dict() for name, pose in self.skeleton_poses.items()}, "cameras": self.camera_transforms, "lights": {"transforms": self.light_transforms, "extra": self.light_extra}, + "follow_targets": self.follow_targets, } @classmethod @@ -187,6 +233,7 @@ def from_dict(cls, data: dict) -> "DazSceneState": camera_transforms=data.get("cameras", {}), light_transforms=lights.get("transforms", {}), light_extra=lights.get("extra", {}), + follow_targets=data.get("follow_targets", {}), ) # ── apply ───────────────────────────────────────────────────────────────── @@ -230,10 +277,12 @@ def apply( """ restored: list[str] = [] errors: list[str] = [] + resolved_skeletons: dict[str, "DazSkeleton"] = {} # noqa: F821 for name, pose in self.skeleton_poses.items(): try: skel = scene.find_skeleton(name) + resolved_skeletons[name] = skel except Exception: errors.append(f"Skeleton not found: {name}") continue @@ -271,6 +320,30 @@ def apply( continue restored.append(name) + # Restore each successfully-restored skeleton's conform/fit-to + # relationship explicitly via the real getFollowTarget()/ + # setFollowTarget() API -- apply_full() above already wrote back + # this skeleton's own "FID_*" property as part of its generic + # props restore, but doing so does not reliably re-resolve the + # live follow-target pointer (see daz-script-server-jz0e). Only + # touches skeletons whose current follow-target doesn't already + # match what was captured, to avoid redundant calls. + for name in restored: + skel = resolved_skeletons[name] + target_name = self.follow_targets.get(name) + try: + current = _retry_on_busy(skel.follow_target) + current_name = current._identifier.value if current is not None else None + if current_name == target_name: + continue + if target_name is None: + _retry_on_busy(skel.unfit) + else: + target_skel = resolved_skeletons.get(target_name) or scene.find_skeleton(target_name) + _retry_on_busy(lambda: skel.fit_to(target_skel)) + except Exception as exc: + errors.append(f"Failed to restore follow-target for skeleton {name}: {exc}") + restore_script = ScriptBuilder.iife(f""" var _camTransforms = {json.dumps(self.camera_transforms)}; var _lightTransforms = {json.dumps(self.light_transforms)}; @@ -306,7 +379,9 @@ def apply( return {{restored: restored, errors: errors}}; """) - node_result = scene._client.execute(restore_script).value or {"restored": [], "errors": []} + node_result = scene._client.execute( + restore_script, retry_on_busy=True, max_wait=_FOLLOW_TARGET_RETRY_MAX_WAIT + ).value or {"restored": [], "errors": []} restored.extend(node_result.get("restored", [])) errors.extend(node_result.get("errors", [])) diff --git a/dazpy/_skeleton.py b/dazpy/_skeleton.py index e725909..c6b6568 100644 --- a/dazpy/_skeleton.py +++ b/dazpy/_skeleton.py @@ -249,6 +249,110 @@ def set_bone_rotations(self, data: dict[str, tuple | list]) -> None: """) self._client.execute(script) + def _zero_bones_and_morphs(self) -> None: + """Drive every bone rotation to 0 and every non-zero DzMorph to 0, in + one DazScript evaluation. Used by :func:`~dazpy.poses.zero_figure`'s + default (``include_props=False``) path — does not touch node-level + properties or the figure root transform. + """ + script = self._skeleton_body(""" + var _bones = _node.getAllBones(); + for (var i = 0; i < _bones.length; i++) { + var _b = _bones[i]; + _b.getXRotControl().setValue(0); + _b.getYRotControl().setValue(0); + _b.getZRotControl().setValue(0); + } + var _obj = _node.getObject(); + if (_obj) { + for (var j = 0; j < _obj.getNumModifiers(); j++) { + var _m = _obj.getModifier(j); + if (_m.className() === "DzMorph") { + var _ch = _m.getValueChannel(); + if (Math.abs(_ch.getValue()) > 0.0001) { + _ch.setValue(0); + } + } + } + } + """) + self._client.execute(script) + + def set_state( + self, + bones: dict[str, tuple | list] | None = None, + morphs: dict[str, float] | None = None, + props: dict[str, object] | None = None, + ) -> None: + """Set bone rotations, morph values, and/or node properties in one call. + + Equivalent to calling :meth:`set_bone_rotations`, :meth:`set_morph_values`, + and/or repeated :meth:`~dazpy.DazElement.set_property` calls, but + round-trips only once. Each argument is independently optional. + + ``bones``/``morphs`` use plain ``setValue()`` writes, matching + :meth:`set_bone_rotations`/:meth:`set_morph_values` exactly — this method + does not change which write path those two use. ``props`` also uses plain + ``setValue()`` (like :meth:`~dazpy.DazElement.set_property`); it is NOT + routed through :meth:`~dazpy.DazPose.apply_full`'s ``DzERCLink``-avoidance + logic (see ``dazpy/_pose.py`` ~lines 117-125), so on ERC-driven node + properties this can double-apply a controller contribution the same way + :meth:`~dazpy.DazElement.set_property` already can. + + Args: + bones: ``{bone_name: (x, y, z)}`` Euler degrees. Bones not named are unchanged. + morphs: ``{morph_name: float}``. Morphs not named are unchanged. + props: ``{property_label: value}`` node-level properties. Properties + not named are unchanged. + """ + lines = [] + if bones: + bones_json = json.dumps({k: list(v) for k, v in bones.items()}) + lines.append(f""" + var _bonesData = {bones_json}; + var _allBones = _node.getAllBones(); + for (var i = 0; i < _allBones.length; i++) {{ + var _b = _allBones[i]; + var _bn = _b.getName(); + if (_bonesData.hasOwnProperty(_bn)) {{ + var _r = _bonesData[_bn]; + _b.getXRotControl().setValue(_r[0]); + _b.getYRotControl().setValue(_r[1]); + _b.getZRotControl().setValue(_r[2]); + }} + }} + """) + if morphs: + morphs_json = json.dumps(morphs) + lines.append(f""" + var _morphsData = {morphs_json}; + var _obj = _node.getObject(); + if (_obj) {{ + for (var j = 0; j < _obj.getNumModifiers(); j++) {{ + var _m = _obj.getModifier(j); + if (_m.className() === "DzMorph" && _morphsData.hasOwnProperty(_m.getName())) {{ + _m.getValueChannel().setValue(_morphsData[_m.getName()]); + }} + }} + }} + """) + if props: + props_json = json.dumps(props) + lines.append(f""" + var _propsData = {props_json}; + for (var k = 0; k < _node.getNumProperties(); k++) {{ + var _p = _node.getProperty(k); + var _pl = _p.getLabel(); + if (_propsData.hasOwnProperty(_pl)) {{ + _p.setValue(_propsData[_pl]); + }} + }} + """) + if not lines: + return + script = self._skeleton_body("\n".join(lines)) + self._client.execute(script) + def evaluate_pose( self, rotations: dict[str, tuple | list], diff --git a/dazpy/exceptions.py b/dazpy/exceptions.py index 408b196..5fed59d 100644 --- a/dazpy/exceptions.py +++ b/dazpy/exceptions.py @@ -112,6 +112,16 @@ def __init__(self, message: str, request_id: str = ""): self.request_id = request_id +class BatchLimitExceededError(DazError): + """Raised by :meth:`~dazpy.Batch.execute` (or ``add_operation``, for the + operation-count limit) when a batch would exceed its configured + operation-count or generated-script-length limit. + + Raised client-side before any HTTP call, so an oversized batch never + reaches Studio's main thread. + """ + + class MaterialError(DazError): """Raised when an Iray material/surface-property operation fails. diff --git a/dazpy/poses.py b/dazpy/poses.py index aee4905..2fe16b6 100644 --- a/dazpy/poses.py +++ b/dazpy/poses.py @@ -35,13 +35,16 @@ def reset_transforms(node: "DazNode") -> None: """Reset *node*'s local position and rotation to zero, and scale to 1.0. Works on any :class:`~dazpy.DazNode` — camera, prop, or figure root. + Uses a single DazScript evaluation via :meth:`~dazpy.DazNode.set_transform`. Args: node: The node to reset. """ - node.set_local_position(0.0, 0.0, 0.0) - node.set_local_rotation(0.0, 0.0, 0.0) - node.set_scale(1.0, 1.0, 1.0) + node.set_transform( + position=(0.0, 0.0, 0.0), + rotation=(0.0, 0.0, 0.0), + scale=(1.0, 1.0, 1.0), + ) def zero_figure(skeleton: "DazSkeleton", *, include_props: bool = False) -> None: @@ -82,7 +85,4 @@ def zero_figure(skeleton: "DazSkeleton", *, include_props: bool = False) -> None pose.apply_full(skeleton) return - zero_bones = {name: (0.0, 0.0, 0.0) for name in skeleton.bone_rotations()} - zero_morphs = {name: 0.0 for name in skeleton.morph_values(nonzero_only=True)} - skeleton.set_bone_rotations(zero_bones) - skeleton.set_morph_values(zero_morphs) + skeleton._zero_bones_and_morphs() diff --git a/docs/api/batch.rst b/docs/api/batch.rst index a563d54..c6d7799 100644 --- a/docs/api/batch.rst +++ b/docs/api/batch.rst @@ -1,6 +1,31 @@ Batch & Async Execution ======================= +One HTTP request, one DazScript evaluation +------------------------------------------- + +A :class:`~dazpy.Batch` combines every queued operation into a single +generated DazScript IIFE and sends it as **one** ``/execute`` request. A +batch of 20 operations is one evaluation on Studio's main thread; two +separate :meth:`~dazpy.Batch.execute` calls are two evaluations, even if +each only holds one operation. This is what batching buys you: fewer +main-thread handoffs, fewer JSON parses, fewer HTTP round-trips per +operation — not parallelism. Studio still runs every operation's generated +JS serially, in submission order, inside that one script; scene mutations +are not parallelized or reordered. + +Whole-batch failure, no rollback +--------------------------------- + +If any operation in the batch throws, the entire ``/execute`` call fails — +:meth:`~dazpy.Batch.execute` raises the same +:class:`~dazpy.exceptions.ScriptError` a single failing call would, and no +partial per-operation results are available. Earlier operations in the same +script that already mutated the scene are **not** rolled back — a batch is +not a transaction. Order operations so that a failure midway through leaves +the scene in a state you can reason about, and keep destructive operations +late in the batch if a partial application would be hard to recover from. + Batch ----- @@ -9,6 +34,48 @@ Batch :undoc-members: :show-inheritance: +``add()`` vs ``add_operation()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`~dazpy.Batch.add` requires the caller's script lines to assign the +result to an internally generated ``_rN`` variable name, which means the +caller has to know or guess that name. :meth:`~dazpy.Batch.add_operation` +avoids this: pass ``body_lines`` (side effects only) and a +``result_expression`` (a JS expression evaluated once immediately after), +and the builder emits the ``var _rN = ;`` assignment +itself. Prefer :meth:`~dazpy.Batch.add_operation` for anything generated +programmatically (loops building operations from data); reach for +:meth:`~dazpy.Batch.add` only for a handful of hand-written, one-off +operations where the exact key name doesn't matter. + +``add_prelude()`` +~~~~~~~~~~~~~~~~~~ + +:meth:`~dazpy.Batch.add_prelude` registers a shared setup block — e.g. a +node lookup — under a stable key. It is emitted once per unique key no +matter how many times it's called with that key, so several operations that +all need the same lookup (say, several property writes on one node) can +share one lookup instead of repeating it per operation. Pick keys that +collide exactly when — and only when — the generated lines are identical +(e.g. ``f"node:{node_name}"``). + +Size limits +~~~~~~~~~~~ + +``Batch(client, max_operations=..., max_script_length=...)`` bounds a batch +in two ways: + +- ``max_operations`` (default 500) — :meth:`~dazpy.Batch.add_operation` + raises :class:`~dazpy.exceptions.BatchLimitExceededError` once the queue + would exceed this count. +- ``max_script_length`` (default 900,000 characters, comfortably under the + server's default 1 MB script cap) — :meth:`~dazpy.Batch.execute` raises + :class:`~dazpy.exceptions.BatchLimitExceededError` if the *generated* + script would exceed this length. + +Both checks happen client-side before any HTTP call, so an oversized batch +never reaches Studio's main thread. + BatchFuture ----------- @@ -22,6 +89,37 @@ execute_long .. autofunction:: dazpy.execute_long +execute_batch_async +-------------------- + +:meth:`~dazpy.DazClient.execute_batch_async` gives the one-script guarantee +of :class:`~dazpy.Batch` without holding an HTTP worker thread and a +blocking client call for the duration. Instead of ``add_operation()`` calls +on a ``Batch`` instance, pass a list of +``{"body_lines": [...], "result_expression": "..."}`` dicts directly — the +same shape :meth:`~dazpy.Batch.add_operation` takes, minus the futures. It +builds the identical combined script internally and submits it to +``/execute/async`` as a single queue item, returning a ``request_id`` +immediately:: + + from dazpy import DazClient + + client = DazClient() + request_id = client.execute_batch_async([ + {"body_lines": ["var n = Scene.getNumNodes();"], "result_expression": "n"}, + {"body_lines": [], "result_expression": "Scene.getNumCameras()"}, + ]) + + data = client.get_request_result(request_id, wait=True, wait_timeout=30) + print(data["result"]["_r0"], data["result"]["_r1"]) + +Poll it like any other async request, with +:meth:`~dazpy.DazClient.get_request_status` / +:meth:`~dazpy.DazClient.get_request_result`. The completed result's +``result`` field is a dict keyed ``"_r0"``, ``"_r1"``, ... in submission +order — the same key scheme :class:`~dazpy.Batch` uses internally, just +without a ``BatchFuture`` wrapper resolving each one. + UndoGroup --------- diff --git a/docs/conf.py b/docs/conf.py index 32e0a99..a3f1249 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,7 @@ project = "dazpy" copyright = "2024, Blue Moon Foundry" author = "Blue Moon Foundry" -release = "2.9.1" +release = "2.9.2" version = release # ── Extensions ──────────────────────────────────────────────────────────────── diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 5a0815d..cb689e3 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -141,6 +141,46 @@ Collect multiple reads into one HTTP round-trip:: print(n_nodes.value, n_cameras.value) +Before: a bulk pose update issuing one HTTP round-trip per bone/property:: + + for name, rot in bone_rotations.items(): + skeleton.set_bone_rotations({name: rot}) + for name, val in morph_values.items(): + skeleton.set_morph_values({name: val}) + skeleton.set_property("Scale", 100.0) + +After: the same update as one HTTP round-trip and one DazScript evaluation, +via :meth:`~dazpy.DazSkeleton.set_state`:: + + skeleton.set_state(bones=bone_rotations, morphs=morph_values, props={"Scale": 100.0}) + +For updates that don't map onto ``set_state()`` (mixed node types, custom +expressions), build the same shape with :class:`~dazpy.Batch`:: + + from dazpy import Batch + + with Batch(client) as b: + futures = {} + for name, rot in bone_rotations.items(): + futures[name] = b.add_operation( + [f'Scene.findNode("Figure").findBone("{name}").setRotation({list(rot)});'], + "null", + ) + # all bones written in one round-trip; futures[name].value is None (mutation-only) + +See :doc:`api/batch` for the full semantics — in particular, a ``Batch`` is +not a transaction: a failing operation fails the whole call, and earlier +mutations in the same script are not rolled back. + +Prefer a synchronous ``Batch`` when the combined script will finish quickly +(sub-second to a few seconds) and the caller can afford to block. For a +combined script expected to run longer, submit it via +:meth:`~dazpy.DazClient.execute_batch_async` instead of blocking an HTTP +worker thread on it — poll ``/requests/:id/status`` and +``/requests/:id/result?wait=true`` (see :doc:`api/batch`'s +``execute_batch_async`` section and the async endpoints in the project's +``CLAUDE.md``) rather than holding a synchronous connection open. + Error Handling -------------- diff --git a/include/DzScriptServerPane.h b/include/DzScriptServerPane.h index 418593c..e783100 100644 --- a/include/DzScriptServerPane.h +++ b/include/DzScriptServerPane.h @@ -112,7 +112,7 @@ public slots: // Called on main thread via BlockingQueuedConnection from httplib handler threads. // Returns HttpResult (status, jsonBody) so handlers can set the correct HTTP status code. - Q_INVOKABLE HttpResult handleExecuteRequest(const QByteArray& jsonBody, const QByteArray& clientIP); + Q_INVOKABLE HttpResult handleExecuteRequest(const QByteArray& jsonBody, const QByteArray& clientIP, qint64 acceptedAtMs); Q_INVOKABLE HttpResult handleRegisterScript(const QByteArray& jsonBody, const QByteArray& clientIP); Q_INVOKABLE HttpResult handleRegistryExecuteRequest(const QByteArray& scriptText, const QByteArray& scriptId, const QByteArray& requestBody, const QByteArray& clientIP); diff --git a/include/MetricsCollector.h b/include/MetricsCollector.h index 02a7de8..47ef302 100644 --- a/include/MetricsCollector.h +++ b/include/MetricsCollector.h @@ -15,6 +15,14 @@ class MetricsCollector { void recordRequest(bool success); void recordAuthFailure(); + // Records how long a synchronous /execute request waited between the + // HTTP thread issuing the BlockingQueuedConnection call and + // handleExecuteRequest() starting to run on the main thread. + void recordMainThreadWait(qint64 waitMs); + + qint64 getAvgMainThreadWaitMs() const; + qint64 getMaxMainThreadWaitMs() const; + void saveToSettings(); void loadFromSettings(); @@ -35,5 +43,8 @@ class MetricsCollector { int m_nFailed; int m_nAuthFailures; QDateTime m_startTime; + qint64 m_nMainThreadWaitSamples; + qint64 m_nMainThreadWaitSumMs; + qint64 m_nMainThreadWaitMaxMs; mutable QMutex m_mutex; }; diff --git a/include/common_version.h b/include/common_version.h index b79508a..2d46348 100644 --- a/include/common_version.h +++ b/include/common_version.h @@ -4,8 +4,8 @@ // Version number for DazScriptServer #define DZSRV_MAJOR 2 #define DZSRV_MINOR 9 -#define DZSRV_REV 1 +#define DZSRV_REV 2 #define DZSRV_BUILD 0 #define DZSRV_VERSION DZ_MAKE_VERSION( DZSRV_MAJOR, DZSRV_MINOR, DZSRV_REV, DZSRV_BUILD ) -#define DZSRV_VERSION_STR "2.9.1" +#define DZSRV_VERSION_STR "2.9.2" diff --git a/pyproject.toml b/pyproject.toml index e5665fd..7b701f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dazpy" -version = "2.9.1" +version = "2.9.2" description = "Python SDK for the DAZ Studio Script Server" readme = "README.md" requires-python = ">=3.10" diff --git a/src/DzScriptServerPane.cpp b/src/DzScriptServerPane.cpp index c4da053..71ba207 100644 --- a/src/DzScriptServerPane.cpp +++ b/src/DzScriptServerPane.cpp @@ -1524,9 +1524,11 @@ std::string DzScriptServerPane::listAsyncRequestsJson(const std::string& statusF // ─── Main-thread request handler ────────────────────────────────────────────── -HttpResult DzScriptServerPane::handleExecuteRequest(const QByteArray& jsonBody, const QByteArray& clientIP) +HttpResult DzScriptServerPane::handleExecuteRequest(const QByteArray& jsonBody, const QByteArray& clientIP, qint64 acceptedAtMs) { QTime startTime = QTime::currentTime(); + qint64 dispatchedAtMs = QDateTime::currentMSecsSinceEpoch(); + m_metrics.recordMainThreadWait(dispatchedAtMs - acceptedAtMs); QString clientIPStr = QString::fromUtf8(clientIP.constData(), clientIP.size()); QString requestId = MetricsCollector::generateRequestId(); @@ -2903,6 +2905,10 @@ std::string DzScriptServerPane::getMetricsJson() const s += std::to_string(uptime); s += ",\"success_rate_percent\":"; s += rateBuf; + s += ",\"avg_main_thread_wait_ms\":"; + s += std::to_string((long long)m_metrics.getAvgMainThreadWaitMs()); + s += ",\"max_main_thread_wait_ms\":"; + s += std::to_string((long long)m_metrics.getMaxMainThreadWaitMs()); s += "}"; return s; } @@ -2921,8 +2927,23 @@ bool DzScriptServerPane::isMainThreadBusy() const std::string DzScriptServerPane::mainThreadBusyMessage() const { - if (!m_pEventBroker) return "DAZ Studio is busy"; - return MainThreadBusy::reasonMessage(m_pEventBroker->busyReason()); + if (m_pEventBroker && m_pEventBroker->busyReason() != MainThreadBusy::Idle) { + return MainThreadBusy::reasonMessage(m_pEventBroker->busyReason()); + } + // isMainThreadBusy()'s second check (DzProgress/DzBackgroundProgress) has + // no matching SceneEventBroker reason -- distinguish the two paths so a + // caller isn't left with a generic message for what's usually several + // seconds of DAZ Studio streaming in lazy-loaded content after a scene + // load, not a real hang. Neither DzProgress nor DzBackgroundProgress + // exposes a readable status/info string (setInfo() has no getter), so + // this is the most specific message obtainable from the SDK. + if (DzBackgroundProgress::isActive()) { + return "DAZ Studio is busy loading background content (e.g. lazy-loaded/referenced scene assets)"; + } + if (DzProgress::isActive()) { + return "DAZ Studio is showing a progress dialog for a foreground operation"; + } + return "DAZ Studio is busy"; } // ─── Async Execution (main thread) ─────────────────────────────────────────── diff --git a/src/MetricsCollector.cpp b/src/MetricsCollector.cpp index 0b0fe20..b8c1078 100644 --- a/src/MetricsCollector.cpp +++ b/src/MetricsCollector.cpp @@ -9,6 +9,9 @@ MetricsCollector::MetricsCollector() , m_nFailed(0) , m_nAuthFailures(0) , m_startTime(QDateTime::currentDateTime()) + , m_nMainThreadWaitSamples(0) + , m_nMainThreadWaitSumMs(0) + , m_nMainThreadWaitMaxMs(0) {} void MetricsCollector::recordRequest(bool success) @@ -33,6 +36,26 @@ void MetricsCollector::recordAuthFailure() m_nAuthFailures++; } +void MetricsCollector::recordMainThreadWait(qint64 waitMs) +{ + QMutexLocker lock(&m_mutex); + m_nMainThreadWaitSamples++; + m_nMainThreadWaitSumMs += waitMs; + if (waitMs > m_nMainThreadWaitMaxMs) m_nMainThreadWaitMaxMs = waitMs; +} + +qint64 MetricsCollector::getAvgMainThreadWaitMs() const +{ + QMutexLocker lock(&m_mutex); + return m_nMainThreadWaitSamples > 0 ? m_nMainThreadWaitSumMs / m_nMainThreadWaitSamples : 0; +} + +qint64 MetricsCollector::getMaxMainThreadWaitMs() const +{ + QMutexLocker lock(&m_mutex); + return m_nMainThreadWaitMaxMs; +} + void MetricsCollector::saveToSettings() { int total, successful, failed, authFail; diff --git a/src/RequestHandlers.cpp b/src/RequestHandlers.cpp index 382b136..3c96e9a 100644 --- a/src/RequestHandlers.cpp +++ b/src/RequestHandlers.cpp @@ -3,6 +3,7 @@ #include "RequestHandler.h" #include "ErrorResponse.h" #include +#include // ─── MiddlewareChain ────────────────────────────────────────────────────────── @@ -152,6 +153,7 @@ ExecuteScriptHandler::ExecuteScriptHandler(DzScriptServerPane* pane) : m_pPane(p void ExecuteScriptHandler::handle(HttpContext& ctx) { if (respondIfMainThreadBusy(m_pPane, ctx)) return; + qint64 acceptedAtMs = QDateTime::currentMSecsSinceEpoch(); QByteArray bodyBytes(ctx.body.c_str(), (int)ctx.body.size()); QByteArray ipBytes(ctx.remoteAddr.c_str(), (int)ctx.remoteAddr.size()); HttpResult result; @@ -159,7 +161,8 @@ void ExecuteScriptHandler::handle(HttpContext& ctx) Qt::BlockingQueuedConnection, Q_RETURN_ARG(HttpResult, result), Q_ARG(QByteArray, bodyBytes), - Q_ARG(QByteArray, ipBytes)); + Q_ARG(QByteArray, ipBytes), + Q_ARG(qint64, acceptedAtMs)); ctx.respond(result.first, std::string(result.second.constData(), result.second.size())); } diff --git a/tests/test_dazpy.py b/tests/test_dazpy.py index 690daf0..2a551ff 100644 --- a/tests/test_dazpy.py +++ b/tests/test_dazpy.py @@ -432,6 +432,47 @@ def test_find_node_not_found(self): with self.assertRaises(exceptions.NodeNotFoundError): scene.find_node("NonExistent") + def test_find_skeleton_success_first_try(self): + client = _make_client(True) + scene = DazScene(client) + skel = scene.find_skeleton("Genesis9") + self.assertEqual(skel._identifier.value, "Genesis9") + self.assertEqual(client.execute.call_count, 1) + + def test_find_skeleton_retries_on_transient_miss(self): + # Regression for daz-script-server-xtkd: Scene.getSkeletonList() has + # been observed to transiently omit a skeleton that is actually + # present, under load from a burst of other main-thread script + # calls. A momentary lookup miss should be retried, not immediately + # reported as NodeNotFoundError. + client = MagicMock(spec=DazClient) + client.execute.side_effect = [ + ExecutionResult(value=False, output=[], request_id="a"), + ExecutionResult(value=False, output=[], request_id="b"), + ExecutionResult(value=True, output=[], request_id="c"), + ] + scene = DazScene(client) + with patch("time.sleep"): + skel = scene.find_skeleton("Genesis9Eyelashes") + self.assertEqual(skel._identifier.value, "Genesis9Eyelashes") + self.assertEqual(client.execute.call_count, 3) + + def test_find_skeleton_raises_after_exhausting_retries(self): + client = MagicMock(spec=DazClient) + client.execute.side_effect = [ + ExecutionResult(value=False, output=[], request_id="a"), + ExecutionResult(value=False, output=[], request_id="b"), + ExecutionResult(value=False, output=[], request_id="c"), + ExecutionResult( + value=["Genesis9|Genesis 9"], output=[], request_id="d", + ), + ] + scene = DazScene(client) + with patch("time.sleep"): + with self.assertRaises(exceptions.NodeNotFoundError): + scene.find_skeleton("NonExistent") + self.assertEqual(client.execute.call_count, 4) + def test_all_node_transforms(self): data = [{"name": "n1", "label": "Node 1", "position": [0, 0, 0], "rotation": [0, 0, 0], "visible": True}] client = _make_client(data) @@ -5059,6 +5100,148 @@ def test_apply_verification_tolerates_small_float_differences(self): self.assertIn("Fuzzy", result["restored"]) self.assertEqual(skel._client.execute.call_count, 2) + def _skel_for_follow_target_tests(self, name, execute_value): + skel = MagicMock() + skel._identifier.value = name + skel._client.execute.return_value = execute_value + return skel + + def test_apply_restores_conforming_items_follow_target(self): + # Regression for daz-script-server-jz0e: DazPose.apply_full() writes + # back every property on a skeleton, including the internal "FID_*" + # property DAZ Studio uses to persist a conforming item's fit + # registration -- rewriting it (even to its own captured value) + # desyncs the live getFollowTarget() pointer without raising. apply() + # must independently re-fit any skeleton whose follow-target no + # longer matches what capture() recorded. + from dazpy._scene_state import DazSceneState + + base_pose = MagicMock() + boots_pose = MagicMock() + exec_result = ExecutionResult( + value={"bones": {}, "morphs": {}, "props": {}}, output=[], request_id="x", + ) + + base_skel = self._skel_for_follow_target_tests("Genesis9", exec_result) + boots_skel = self._skel_for_follow_target_tests("Boots", exec_result) + base_skel.follow_target.return_value = None # base figure has no follow-target + # apply_full() desynced the follow-target pointer even though the + # captured value ("Genesis9") is what we'll assert gets restored. + boots_skel.follow_target.return_value = None + + scene = MagicMock() + scene.find_skeleton.side_effect = lambda name: { + "Genesis9": base_skel, "Boots": boots_skel, + }[name] + scene._client.execute.return_value = ExecutionResult( + value={"restored": [], "errors": []}, output=[], request_id="x", + ) + + state = DazSceneState( + skeleton_poses={"Genesis9": base_pose, "Boots": boots_pose}, + camera_transforms={}, light_transforms={}, light_extra={}, + follow_targets={"Genesis9": None, "Boots": "Genesis9"}, + ) + result = state.apply(scene) + + boots_skel.fit_to.assert_called_once_with(base_skel) + base_skel.fit_to.assert_not_called() + base_skel.unfit.assert_not_called() + self.assertEqual(result["errors"], []) + + def test_apply_does_not_refit_when_follow_target_already_correct(self): + # A skeleton whose follow-target already matches the captured value + # (the common case -- apply_full() doesn't always desync it) should + # not get a redundant fit_to() call. + from dazpy._scene_state import DazSceneState + + exec_result = ExecutionResult( + value={"bones": {}, "morphs": {}, "props": {}}, output=[], request_id="x", + ) + base_skel = self._skel_for_follow_target_tests("Genesis9", exec_result) + boots_skel = self._skel_for_follow_target_tests("Boots", exec_result) + base_skel.follow_target.return_value = None + already_correct_target = MagicMock() + already_correct_target._identifier.value = "Genesis9" + boots_skel.follow_target.return_value = already_correct_target + + scene = MagicMock() + scene.find_skeleton.side_effect = lambda name: { + "Genesis9": base_skel, "Boots": boots_skel, + }[name] + scene._client.execute.return_value = ExecutionResult( + value={"restored": [], "errors": []}, output=[], request_id="x", + ) + + state = DazSceneState( + skeleton_poses={"Genesis9": MagicMock(), "Boots": MagicMock()}, + camera_transforms={}, light_transforms={}, light_extra={}, + follow_targets={"Genesis9": None, "Boots": "Genesis9"}, + ) + state.apply(scene) + + boots_skel.fit_to.assert_not_called() + boots_skel.unfit.assert_not_called() + + def test_apply_unfits_skeleton_captured_with_no_follow_target(self): + from dazpy._scene_state import DazSceneState + + exec_result = ExecutionResult( + value={"bones": {}, "morphs": {}, "props": {}}, output=[], request_id="x", + ) + skel = self._skel_for_follow_target_tests("Prop", exec_result) + stale_target = MagicMock() + stale_target._identifier.value = "SomeFigure" + skel.follow_target.return_value = stale_target + + scene = MagicMock() + scene.find_skeleton.return_value = skel + scene._client.execute.return_value = ExecutionResult( + value={"restored": [], "errors": []}, output=[], request_id="x", + ) + + state = DazSceneState( + skeleton_poses={"Prop": MagicMock()}, + camera_transforms={}, light_transforms={}, light_extra={}, + follow_targets={"Prop": None}, + ) + state.apply(scene) + + skel.unfit.assert_called_once() + skel.fit_to.assert_not_called() + + def test_apply_retries_follow_target_restore_on_busy_error(self): + from dazpy._scene_state import DazSceneState + from dazpy.exceptions import StudioBusyError + + exec_result = ExecutionResult( + value={"bones": {}, "morphs": {}, "props": {}}, output=[], request_id="x", + ) + base_skel = self._skel_for_follow_target_tests("Genesis9", exec_result) + boots_skel = self._skel_for_follow_target_tests("Boots", exec_result) + base_skel.follow_target.return_value = None + boots_skel.follow_target.side_effect = [StudioBusyError("busy"), None] + + scene = MagicMock() + scene.find_skeleton.side_effect = lambda name: { + "Genesis9": base_skel, "Boots": boots_skel, + }[name] + scene._client.execute.return_value = ExecutionResult( + value={"restored": [], "errors": []}, output=[], request_id="x", + ) + + state = DazSceneState( + skeleton_poses={"Genesis9": MagicMock(), "Boots": MagicMock()}, + camera_transforms={}, light_transforms={}, light_extra={}, + follow_targets={"Genesis9": None, "Boots": "Genesis9"}, + ) + with patch("dazpy._scene_state.time.sleep"): + result = state.apply(scene) + + self.assertEqual(boots_skel.follow_target.call_count, 2) + boots_skel.fit_to.assert_called_once_with(base_skel) + self.assertEqual(result["errors"], []) + class TestSceneSnapshot(unittest.TestCase): """Tests for DazScene.scene_snapshot() and build_rig_profiles_from_snapshot().""" @@ -5340,39 +5523,27 @@ def test_include_props_true_does_not_touch_root_transform(self): def test_default_zeroes_only_bones_and_morphs(self): """The new default (include_props omitted) must take the bones/morphs-only path, - never the apply_full path — that's what guarantees root transform is untouched.""" + never the apply_full path — that's what guarantees root transform is untouched. + Since Task 2C, this path is a single generated script (see + TestZeroFigureDefaultPath), not per-bone/per-morph client calls.""" from dazpy.poses import zero_figure skeleton = MagicMock() - skeleton.bone_rotations.return_value = {"hip": (1.0, 2.0, 3.0), "chest": (0.0, 5.0, 0.0)} - skeleton.morph_values.return_value = {"PHMSmile": 0.8} zero_figure(skeleton) - skeleton.set_bone_rotations.assert_called_once_with( - {"hip": (0.0, 0.0, 0.0), "chest": (0.0, 0.0, 0.0)} - ) - skeleton.morph_values.assert_called_once_with(nonzero_only=True) - skeleton.set_morph_values.assert_called_once_with({"PHMSmile": 0.0}) + skeleton._zero_bones_and_morphs.assert_called_once_with() def test_include_props_false_zeroes_only_bones_and_morphs(self): from dazpy.poses import zero_figure skeleton = MagicMock() - skeleton.bone_rotations.return_value = {"hip": (1.0, 2.0, 3.0), "chest": (0.0, 5.0, 0.0)} - skeleton.morph_values.return_value = {"PHMSmile": 0.8} zero_figure(skeleton, include_props=False) - skeleton.set_bone_rotations.assert_called_once_with( - {"hip": (0.0, 0.0, 0.0), "chest": (0.0, 0.0, 0.0)} - ) - skeleton.morph_values.assert_called_once_with(nonzero_only=True) - skeleton.set_morph_values.assert_called_once_with({"PHMSmile": 0.0}) + skeleton._zero_bones_and_morphs.assert_called_once_with() def test_include_props_false_does_not_use_dazpose(self): from dazpy.poses import zero_figure skeleton = MagicMock() - skeleton.bone_rotations.return_value = {} - skeleton.morph_values.return_value = {} zero_figure(skeleton, include_props=False) @@ -7034,5 +7205,316 @@ def test_materials_symbols_importable_from_top_level_package(self): self.assertIn("MaterialError", dazpy.__all__) +def _result(value): + r = MagicMock() + r.value = value + return r + + +class TestDazElementSnapshot(unittest.TestCase): + def test_snapshot_issues_exactly_one_call(self): + client = _make_client({"Smile": 0.8, "Blink": 0.0, "EyesClosed": None}) + from dazpy._element import DazElement + el = DazElement(client, "Scene.findNode('Fig')") + result = el.snapshot(["Smile", "Blink", "EyesClosed"]) + self.assertEqual(client.execute.call_count, 1) + self.assertEqual(result, {"Smile": 0.8, "Blink": 0.0, "EyesClosed": None}) + + def test_snapshot_escapes_labels_with_quotes_and_backslashes(self): + client = _make_client({}) + from dazpy._element import DazElement + el = DazElement(client, "Scene.findNode('Fig')") + el.snapshot(['Weird "Label"', "Back\\slash"]) + script = client.execute.call_args[0][0] + self.assertIn(json.dumps('Weird "Label"'), script) + self.assertIn(json.dumps("Back\\slash"), script) + + def test_snapshot_missing_owner_returns_none_for_all_fields(self): + client = _make_client(None) # script's top-level `if (!obj) return null;` + from dazpy._element import DazElement + el = DazElement(client, "Scene.findNode('Missing')") + result = el.snapshot(["Smile", "Blink"]) + self.assertEqual(result, {"Smile": None, "Blink": None}) + + +class TestDazNodeSetTransform(unittest.TestCase): + def test_all_components_issue_one_call(self): + client = _make_client(None) + from dazpy._node import DazNode, NodeIdentifier + node = DazNode(client, NodeIdentifier("name", "Camera")) + node.set_transform(position=(1.0, 2.0, 3.0), rotation=(4.0, 5.0, 6.0), scale=(1.5, 1.5, 1.5)) + self.assertEqual(client.execute.call_count, 1) + script = client.execute.call_args[0][0] + self.assertIn("setLocalPos", script) + self.assertIn("getXRotControl", script) + self.assertIn("getXScaleControl", script) + + def test_omitted_component_not_present_in_script(self): + client = _make_client(None) + from dazpy._node import DazNode, NodeIdentifier + node = DazNode(client, NodeIdentifier("name", "Camera")) + node.set_transform(position=(1.0, 2.0, 3.0)) + script = client.execute.call_args[0][0] + self.assertIn("setLocalPos", script) + self.assertNotIn("getXRotControl", script) + self.assertNotIn("getXScaleControl", script) + + def test_no_arguments_is_a_noop(self): + client = _make_client(None) + from dazpy._node import DazNode, NodeIdentifier + node = DazNode(client, NodeIdentifier("name", "Camera")) + node.set_transform() + client.execute.assert_not_called() + + +class TestResetTransforms(unittest.TestCase): + def test_reset_transforms_issues_exactly_one_call(self): + client = _make_client(None) + from dazpy._node import DazNode, NodeIdentifier + from dazpy.poses import reset_transforms + node = DazNode(client, NodeIdentifier("name", "Camera")) + reset_transforms(node) + self.assertEqual(client.execute.call_count, 1) + script = client.execute.call_args[0][0] + self.assertIn("setLocalPos", script) + self.assertIn("getXRotControl", script) + self.assertIn("getXScaleControl", script) + + +class TestZeroFigureDefaultPath(unittest.TestCase): + def test_default_mode_issues_exactly_one_call(self): + client = _make_client(None) + from dazpy._skeleton import DazSkeleton + from dazpy._node import NodeIdentifier + from dazpy.poses import zero_figure + skel = DazSkeleton(client, NodeIdentifier("name", "Genesis9")) + zero_figure(skel) + self.assertEqual(client.execute.call_count, 1) + script = client.execute.call_args[0][0] + self.assertIn("getAllBones", script) + self.assertIn("DzMorph", script) + + def test_default_mode_zeroes_bones_and_nonzero_morphs_only(self): + client = _make_client(None) + from dazpy._skeleton import DazSkeleton + from dazpy._node import NodeIdentifier + from dazpy.poses import zero_figure + skel = DazSkeleton(client, NodeIdentifier("name", "Genesis9")) + zero_figure(skel) + script = client.execute.call_args[0][0] + self.assertIn("setValue(0)", script) + + def test_include_props_true_still_uses_apply_full(self): + client = _make_client(None) + from dazpy._skeleton import DazSkeleton + from dazpy._node import NodeIdentifier + from dazpy.poses import zero_figure + skel = DazSkeleton(client, NodeIdentifier("name", "Genesis9")) + with patch("dazpy._pose.DazPose.apply_full") as mock_apply_full: + zero_figure(skel, include_props=True) + mock_apply_full.assert_called_once() + + +class TestDazElementSetProperties(unittest.TestCase): + def test_multiple_mutations_issue_one_call(self): + client = _make_client({"Smile": True, "Blink": True}) + from dazpy._element import DazElement + el = DazElement(client, "Scene.findNode('Fig')") + result = el.set_properties({"Smile": 0.8, "Blink": 0.2}) + self.assertEqual(client.execute.call_count, 1) + self.assertEqual(result, {"Smile": True, "Blink": True}) + + def test_missing_property_reported_false(self): + client = _make_client({"Smile": True, "Nonexistent": False}) + from dazpy._element import DazElement + el = DazElement(client, "Scene.findNode('Fig')") + result = el.set_properties({"Smile": 0.8, "Nonexistent": 1.0}) + self.assertEqual(result, {"Smile": True, "Nonexistent": False}) + + def test_labels_with_quotes_backslashes_newlines_are_json_safe(self): + client = _make_client({}) + from dazpy._element import DazElement + el = DazElement(client, "Scene.findNode('Fig')") + el.set_properties({'Weird "Label"': 1, "Multi\nLine": 2, "Back\\Slash": 3}) + script = client.execute.call_args[0][0] + payload = json.dumps({'Weird "Label"': 1, "Multi\nLine": 2, "Back\\Slash": 3}) + self.assertIn(payload, script) + self.assertIn("hasOwnProperty", script) + + +class TestDazSkeletonSetState(unittest.TestCase): + def test_all_three_kinds_issue_one_call(self): + client = _make_client(None) + from dazpy._skeleton import DazSkeleton + from dazpy._node import NodeIdentifier + skel = DazSkeleton(client, NodeIdentifier("name", "Genesis9")) + skel.set_state( + bones={"Hip": (0.0, 0.0, 0.0)}, + morphs={"Smile": 0.8}, + props={"Scale": 100.0}, + ) + self.assertEqual(client.execute.call_count, 1) + script = client.execute.call_args[0][0] + self.assertIn("getAllBones", script) + self.assertIn("DzMorph", script) + self.assertIn("getNumProperties", script) + + def test_omitted_kind_not_present_in_script(self): + client = _make_client(None) + from dazpy._skeleton import DazSkeleton + from dazpy._node import NodeIdentifier + skel = DazSkeleton(client, NodeIdentifier("name", "Genesis9")) + skel.set_state(bones={"Hip": (0.0, 0.0, 0.0)}) + script = client.execute.call_args[0][0] + self.assertIn("getAllBones", script) + self.assertNotIn("DzMorph", script) + self.assertNotIn("getNumProperties", script) + + def test_all_omitted_is_a_noop(self): + client = _make_client(None) + from dazpy._skeleton import DazSkeleton + from dazpy._node import NodeIdentifier + skel = DazSkeleton(client, NodeIdentifier("name", "Genesis9")) + skel.set_state() + client.execute.assert_not_called() + + def test_bone_and_morph_names_with_quotes_are_json_safe(self): + client = _make_client(None) + from dazpy._skeleton import DazSkeleton + from dazpy._node import NodeIdentifier + skel = DazSkeleton(client, NodeIdentifier("name", "Genesis9")) + skel.set_state(bones={'Weird "Bone"': (1.0, 2.0, 3.0)}, morphs={"Back\\Slash": 0.5}) + script = client.execute.call_args[0][0] + self.assertIn(json.dumps({'Weird "Bone"': [1.0, 2.0, 3.0]}), script) + self.assertIn(json.dumps({"Back\\Slash": 0.5}), script) + + +class TestBatchAddOperation(unittest.TestCase): + def test_add_operation_resolves_without_caller_guessing_key(self): + client = _make_client({"_r0": 42}) + from dazpy._batch import Batch + batch = Batch(client) + future = batch.add_operation( + body_lines=["var x = 20 + 22;"], + result_expression="x", + ) + batch.execute() + self.assertEqual(future.value, 42) + script = client.execute.call_args[0][0] + self.assertIn("var _r0 = x;", script) + + def test_shared_prelude_emitted_once_for_two_operations(self): + client = _make_client({"_r0": 1, "_r1": 2}) + from dazpy._batch import Batch + batch = Batch(client) + batch.add_prelude("node:Fig", ["var _node_Fig = Scene.findNode('Fig');"]) + batch.add_operation(body_lines=[], result_expression="_node_Fig.getXRotControl().getValue()") + batch.add_prelude("node:Fig", ["var _node_Fig = Scene.findNode('Fig');"]) # same key, second call + batch.add_operation(body_lines=[], result_expression="_node_Fig.getYRotControl().getValue()") + batch.execute() + script = client.execute.call_args[0][0] + self.assertEqual(script.count("Scene.findNode('Fig')"), 1) + self.assertEqual(client.execute.call_count, 1) + + def test_read_after_write_order_preserved(self): + client = _make_client({"_r0": None, "_r1": 99}) + from dazpy._batch import Batch + batch = Batch(client) + write_future = batch.add_operation(body_lines=["var _v = 99;"], result_expression="null") + read_future = batch.add_operation(body_lines=[], result_expression="_v") + batch.execute() + script = client.execute.call_args[0][0] + self.assertLess(script.index("var _v = 99;"), script.index("var _r1 = _v;")) + + def test_operation_count_limit_raises_before_execute(self): + client = _make_client({}) + from dazpy._batch import Batch + from dazpy.exceptions import BatchLimitExceededError + batch = Batch(client, max_operations=2) + batch.add_operation(body_lines=[], result_expression="1") + batch.add_operation(body_lines=[], result_expression="2") + with self.assertRaises(BatchLimitExceededError): + batch.add_operation(body_lines=[], result_expression="3") + client.execute.assert_not_called() + + def test_script_length_limit_raises_on_execute(self): + client = _make_client({}) + from dazpy._batch import Batch + from dazpy.exceptions import BatchLimitExceededError + batch = Batch(client, max_script_length=50) + batch.add_operation(body_lines=["var x = 1;" * 20], result_expression="x") + with self.assertRaises(BatchLimitExceededError): + batch.execute() + client.execute.assert_not_called() + + def test_existing_raw_add_still_works_unmodified(self): + # Regression guard: add_operation()/add_prelude() must not change add()'s behavior. + client = _make_client({"_r0": 10, "_r1": 5}) + from dazpy._batch import Batch + with Batch(client) as batch: + f_count = batch.add(["var _r0 = Scene.getNumNodes();"]) + f_frame = batch.add(["var _r1 = Scene.getFrame();"]) + self.assertEqual(f_count.value, 10) + self.assertEqual(f_frame.value, 5) + client.execute.assert_called_once() + + +class TestExecuteBatchAsync(unittest.TestCase): + def test_submits_one_request_for_multiple_operations(self): + client = DazClient(token="") + client._session = MagicMock() + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"request_id": "batch-async-1", "status": "queued"} + response.headers = {} + client._session.post.return_value = response + + request_id = client.execute_batch_async([ + {"body_lines": ["var x = 1;"], "result_expression": "x"}, + {"body_lines": ["var y = 2;"], "result_expression": "y"}, + ]) + + self.assertEqual(request_id, "batch-async-1") + client._session.post.assert_called_once() + call_args = client._session.post.call_args + self.assertEqual(call_args[0][0], "http://127.0.0.1:18811/execute/async") + submitted_script = call_args[1]["json"]["script"] + self.assertIn("var x = 1;", submitted_script) + self.assertIn("var y = 2;", submitted_script) + self.assertIn('"_r0"', submitted_script) + self.assertIn('"_r1"', submitted_script) + + def test_passes_args_through(self): + client = DazClient(token="") + client._session = MagicMock() + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"request_id": "batch-async-2", "status": "queued"} + response.headers = {} + client._session.post.return_value = response + + client.execute_batch_async( + [{"body_lines": [], "result_expression": "1"}], + args={"mode": "probe"}, + ) + + submitted_payload = client._session.post.call_args[1]["json"] + self.assertEqual(submitted_payload["args"], {"mode": "probe"}) + + +class TestCallCountBaseline(unittest.TestCase): + """Pins down pre-batching call counts. Update these assertions in the + same commit that fixes the corresponding helper in Task 2/3 — do not + let this class silently mask a regression by staying loose.""" + + def test_batch_add_issues_one_call_for_two_ops(self): + client = _make_client({"_r0": 10, "_r1": 5}) + from dazpy._batch import Batch + with Batch(client) as b: + b.add(["var _r0 = Scene.getNumNodes();"]) + b.add(["var _r1 = Scene.getFrame();"]) + self.assertEqual(client.execute.call_count, 1) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_dazpy_aio.py b/tests/test_dazpy_aio.py index 1c0674e..5975fc5 100644 --- a/tests/test_dazpy_aio.py +++ b/tests/test_dazpy_aio.py @@ -148,6 +148,45 @@ async def test_timeout_error_maps_to_dazpy_timeout_error(self): await client.execute("1;") +class TestAsyncDazClientExecuteBatchAsync: + @pytest.mark.asyncio + async def test_submits_one_request_for_multiple_operations(self): + client, mock_http = _client_with_mock_http() + mock_http.post.return_value = _mock_resp( + json_data={"request_id": "batch-async-1", "status": "queued"} + ) + + request_id = await client.execute_batch_async([ + {"body_lines": ["var x = 1;"], "result_expression": "x"}, + {"body_lines": ["var y = 2;"], "result_expression": "y"}, + ]) + + assert request_id == "batch-async-1" + mock_http.post.assert_awaited_once() + args, kwargs = mock_http.post.call_args + assert args[0] == "http://127.0.0.1:18811/execute/async" + submitted_script = kwargs["json"]["script"] + assert "var x = 1;" in submitted_script + assert "var y = 2;" in submitted_script + assert '"_r0"' in submitted_script + assert '"_r1"' in submitted_script + + @pytest.mark.asyncio + async def test_passes_args_through(self): + client, mock_http = _client_with_mock_http() + mock_http.post.return_value = _mock_resp( + json_data={"request_id": "batch-async-2", "status": "queued"} + ) + + await client.execute_batch_async( + [{"body_lines": [], "result_expression": "1"}], + args={"mode": "probe"}, + ) + + _, kwargs = mock_http.post.call_args + assert kwargs["json"]["args"] == {"mode": "probe"} + + class TestAsyncDazClientRetryOnBusy: @pytest.mark.asyncio async def test_retries_then_succeeds(self, monkeypatch):