davinci-resolve-mcp 2.57.4 → 2.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/AGENTS.md +20 -0
  2. package/CHANGELOG.md +197 -2
  3. package/LICENSE +1 -1
  4. package/README.md +73 -1
  5. package/bin/davinci-resolve-advanced-mcp.mjs +20 -0
  6. package/docs/README.md +19 -0
  7. package/docs/SKILL.md +62 -1
  8. package/docs/contributing.md +1 -1
  9. package/docs/guides/control-panel.md +9 -0
  10. package/docs/kernels/README.md +21 -0
  11. package/docs/kernels/audio-fairlight-kernel.md +22 -0
  12. package/docs/kernels/color-grade-kernel.md +51 -0
  13. package/docs/kernels/fusion-composition-kernel.md +17 -0
  14. package/docs/kernels/media-pool-ingest-kernel.md +20 -0
  15. package/docs/kernels/render-deliver-kernel.md +32 -0
  16. package/docs/kernels/timeline-conform-interchange-kernel.md +39 -0
  17. package/docs/kernels/timeline-edit-kernel.md +17 -0
  18. package/docs/process/release-process.md +29 -7
  19. package/docs/reference/api-coverage.md +1 -1
  20. package/docs/reference/api-limitations.md +38 -1
  21. package/install.py +27 -4
  22. package/package.json +23 -2
  23. package/resolve-advanced/README.md +194 -0
  24. package/resolve-advanced/package.json +50 -0
  25. package/resolve-advanced/server/black-balance.mjs +92 -0
  26. package/resolve-advanced/server/capabilities.mjs +86 -0
  27. package/resolve-advanced/server/cdl-io.mjs +76 -0
  28. package/resolve-advanced/server/contrast-normalize.mjs +180 -0
  29. package/resolve-advanced/server/db-patch.mjs +91 -0
  30. package/resolve-advanced/server/deliverable-entities.mjs +86 -0
  31. package/resolve-advanced/server/deliverable-qc.mjs +270 -0
  32. package/resolve-advanced/server/editorial.mjs +370 -0
  33. package/resolve-advanced/server/exposure-level.mjs +129 -0
  34. package/resolve-advanced/server/extract-frames.mjs +196 -0
  35. package/resolve-advanced/server/ffprobe-media.mjs +88 -0
  36. package/resolve-advanced/server/gamut-legal.mjs +112 -0
  37. package/resolve-advanced/server/grade-body-patch.mjs +246 -0
  38. package/resolve-advanced/server/grade-transfer.mjs +66 -0
  39. package/resolve-advanced/server/group-grade-read.mjs +137 -0
  40. package/resolve-advanced/server/index.mjs +105 -0
  41. package/resolve-advanced/server/lib.mjs +84 -0
  42. package/resolve-advanced/server/libs.mjs +65 -0
  43. package/resolve-advanced/server/lineage-db.mjs +479 -0
  44. package/resolve-advanced/server/lut-apply.mjs +61 -0
  45. package/resolve-advanced/server/match-to-reference.mjs +173 -0
  46. package/resolve-advanced/server/media-inventory.mjs +169 -0
  47. package/resolve-advanced/server/media-ops.mjs +243 -0
  48. package/resolve-advanced/server/node-meta-db.mjs +62 -0
  49. package/resolve-advanced/server/node-provenance.mjs +102 -0
  50. package/resolve-advanced/server/offline-ref-db.mjs +420 -0
  51. package/resolve-advanced/server/offline-ref.mjs +179 -0
  52. package/resolve-advanced/server/project-db.mjs +233 -0
  53. package/resolve-advanced/server/provenance-audit.mjs +210 -0
  54. package/resolve-advanced/server/qc-frame.mjs +224 -0
  55. package/resolve-advanced/server/qc-sampler.mjs +67 -0
  56. package/resolve-advanced/server/readback.mjs +77 -0
  57. package/resolve-advanced/server/render-manifest.mjs +93 -0
  58. package/resolve-advanced/server/reverse-clip-db.mjs +270 -0
  59. package/resolve-advanced/server/runner-apply-contract.mjs +64 -0
  60. package/resolve-advanced/server/runner.mjs +244 -0
  61. package/resolve-advanced/server/saturation-match.mjs +108 -0
  62. package/resolve-advanced/server/scope-read.mjs +257 -0
  63. package/resolve-advanced/server/season-look.mjs +96 -0
  64. package/resolve-advanced/server/shot-intent.mjs +82 -0
  65. package/resolve-advanced/server/shot-match.mjs +147 -0
  66. package/resolve-advanced/server/skin-match.mjs +205 -0
  67. package/resolve-advanced/server/spec-compile.mjs +217 -0
  68. package/resolve-advanced/server/tone-curve-transfer.mjs +184 -0
  69. package/resolve-advanced/server/tool-catalog.mjs +265 -0
  70. package/resolve-advanced/server/tools/audio.mjs +54 -0
  71. package/resolve-advanced/server/tools/audio_plan.mjs +58 -0
  72. package/resolve-advanced/server/tools/capabilities.mjs +16 -0
  73. package/resolve-advanced/server/tools/color_trace.mjs +165 -0
  74. package/resolve-advanced/server/tools/conform.mjs +291 -0
  75. package/resolve-advanced/server/tools/deliverable.mjs +105 -0
  76. package/resolve-advanced/server/tools/drp.mjs +232 -0
  77. package/resolve-advanced/server/tools/drt.mjs +200 -0
  78. package/resolve-advanced/server/tools/drx.mjs +943 -0
  79. package/resolve-advanced/server/tools/editorial.mjs +69 -0
  80. package/resolve-advanced/server/tools/fairlight.mjs +108 -0
  81. package/resolve-advanced/server/tools/fusion.mjs +64 -0
  82. package/resolve-advanced/server/tools/media.mjs +102 -0
  83. package/resolve-advanced/server/tools/offline_ref.mjs +123 -0
  84. package/resolve-advanced/server/tools/pipeline.mjs +137 -0
  85. package/resolve-advanced/server/tools/project_db.mjs +200 -0
  86. package/resolve-advanced/server/tools/project_read.mjs +212 -0
  87. package/resolve-advanced/server/tools/provenance.mjs +73 -0
  88. package/resolve-advanced/server/verify-grade.mjs +113 -0
  89. package/resolve-advanced/server/white-balance-match.mjs +122 -0
  90. package/resolve-advanced/vendor/audio/format-converter.js +330 -0
  91. package/resolve-advanced/vendor/audio/split.js +173 -0
  92. package/resolve-advanced/vendor/audio/trim.js +188 -0
  93. package/resolve-advanced/vendor/audio-fairlight/index.js +391 -0
  94. package/resolve-advanced/vendor/conform-qc/adapters/frame-sampler.js +56 -0
  95. package/resolve-advanced/vendor/conform-qc/adapters/local-ffmpeg-sampler.js +65 -0
  96. package/resolve-advanced/vendor/conform-qc/adapters/rendernode-ffmpeg-sampler.js +50 -0
  97. package/resolve-advanced/vendor/conform-qc/adapters/resolve/driver.py +176 -0
  98. package/resolve-advanced/vendor/conform-qc/adapters/resolve-driver.js +61 -0
  99. package/resolve-advanced/vendor/conform-qc/adapters/resolve-headless-driver.js +86 -0
  100. package/resolve-advanced/vendor/conform-qc/adapters/vision-validator.js +75 -0
  101. package/resolve-advanced/vendor/conform-qc/cli.js +63 -0
  102. package/resolve-advanced/vendor/conform-qc/compare/advisory.js +106 -0
  103. package/resolve-advanced/vendor/conform-qc/compare/decode.js +36 -0
  104. package/resolve-advanced/vendor/conform-qc/compare/index.js +61 -0
  105. package/resolve-advanced/vendor/conform-qc/compare/locate.js +70 -0
  106. package/resolve-advanced/vendor/conform-qc/compare/metrics.js +409 -0
  107. package/resolve-advanced/vendor/conform-qc/docs/runbook.md +72 -0
  108. package/resolve-advanced/vendor/conform-qc/index.js +49 -0
  109. package/resolve-advanced/vendor/conform-qc/knowledge/index.js +110 -0
  110. package/resolve-advanced/vendor/conform-qc/ops/conform-core.js +137 -0
  111. package/resolve-advanced/vendor/conform-qc/ops/conform-diff.js +109 -0
  112. package/resolve-advanced/vendor/conform-qc/ops/insert.js +108 -0
  113. package/resolve-advanced/vendor/conform-qc/ops/local-conform.js +61 -0
  114. package/resolve-advanced/vendor/conform-qc/ops/patch.js +80 -0
  115. package/resolve-advanced/vendor/conform-qc/ops/trigger.js +32 -0
  116. package/resolve-advanced/vendor/conform-qc/ops/verify.js +105 -0
  117. package/resolve-advanced/vendor/conform-qc/ops/workflow.js +30 -0
  118. package/resolve-advanced/vendor/conform-qc/oracle/emulate.js +155 -0
  119. package/resolve-advanced/vendor/conform-qc/oracle/index.js +38 -0
  120. package/resolve-advanced/vendor/conform-qc/oracle/resolve.js +196 -0
  121. package/resolve-advanced/vendor/conform-qc/package.json +16 -0
  122. package/resolve-advanced/vendor/conform-qc/packaging/emit-aaf.js +30 -0
  123. package/resolve-advanced/vendor/conform-qc/packaging/emit-fcp7.js +64 -0
  124. package/resolve-advanced/vendor/conform-qc/packaging/index.js +169 -0
  125. package/resolve-advanced/vendor/conform-qc/packaging/media-ops.js +56 -0
  126. package/resolve-advanced/vendor/conform-qc/packaging/otio.js +50 -0
  127. package/resolve-advanced/vendor/conform-qc/packaging/relink.js +50 -0
  128. package/resolve-advanced/vendor/conform-qc/packaging/surgical-relink.js +195 -0
  129. package/resolve-advanced/vendor/conform-qc/parse/index.js +54 -0
  130. package/resolve-advanced/vendor/conform-qc/parse/merge-dto.js +27 -0
  131. package/resolve-advanced/vendor/conform-qc/parse/xmeml-geometry.js +256 -0
  132. package/resolve-advanced/vendor/conform-qc/reference/burnin-ocr.js +100 -0
  133. package/resolve-advanced/vendor/conform-qc/reference/sidecar.js +37 -0
  134. package/resolve-advanced/vendor/conform-qc/repair/analysis.js +62 -0
  135. package/resolve-advanced/vendor/conform-qc/repair/index.js +78 -0
  136. package/resolve-advanced/vendor/conform-qc/repair/media-analysis.js +139 -0
  137. package/resolve-advanced/vendor/conform-qc/repair/media-index.js +135 -0
  138. package/resolve-advanced/vendor/conform-qc/repair/strategies.js +250 -0
  139. package/resolve-advanced/vendor/conform-qc/report/index.js +133 -0
  140. package/resolve-advanced/vendor/conform-qc/roles/index.js +96 -0
  141. package/resolve-advanced/vendor/conform-qc/synthetic/generate.js +233 -0
  142. package/resolve-advanced/vendor/conform-qc/test/advisory.test.js +85 -0
  143. package/resolve-advanced/vendor/conform-qc/test/cli.test.js +39 -0
  144. package/resolve-advanced/vendor/conform-qc/test/compare.test.js +152 -0
  145. package/resolve-advanced/vendor/conform-qc/test/conform-diff.test.js +70 -0
  146. package/resolve-advanced/vendor/conform-qc/test/emulate.test.js +51 -0
  147. package/resolve-advanced/vendor/conform-qc/test/frame-sampler.test.js +30 -0
  148. package/resolve-advanced/vendor/conform-qc/test/geometry.test.js +112 -0
  149. package/resolve-advanced/vendor/conform-qc/test/knowledge.test.js +56 -0
  150. package/resolve-advanced/vendor/conform-qc/test/lifecycle.test.js +110 -0
  151. package/resolve-advanced/vendor/conform-qc/test/local-ffmpeg-sampler.test.js +66 -0
  152. package/resolve-advanced/vendor/conform-qc/test/locate.test.js +63 -0
  153. package/resolve-advanced/vendor/conform-qc/test/media-analysis.test.js +63 -0
  154. package/resolve-advanced/vendor/conform-qc/test/media-index-normalized.test.js +65 -0
  155. package/resolve-advanced/vendor/conform-qc/test/media-index-origin.test.js +53 -0
  156. package/resolve-advanced/vendor/conform-qc/test/metrics-fast.test.js +91 -0
  157. package/resolve-advanced/vendor/conform-qc/test/oracle-reverse.test.js +86 -0
  158. package/resolve-advanced/vendor/conform-qc/test/oracle.test.js +136 -0
  159. package/resolve-advanced/vendor/conform-qc/test/p0-acceptance.test.js +60 -0
  160. package/resolve-advanced/vendor/conform-qc/test/p1-acceptance.test.js +75 -0
  161. package/resolve-advanced/vendor/conform-qc/test/p45-patch-acceptance.test.js +102 -0
  162. package/resolve-advanced/vendor/conform-qc/test/packaging.test.js +133 -0
  163. package/resolve-advanced/vendor/conform-qc/test/parse-stubs.test.js +28 -0
  164. package/resolve-advanced/vendor/conform-qc/test/repair-strategies.test.js +128 -0
  165. package/resolve-advanced/vendor/conform-qc/test/repair.test.js +84 -0
  166. package/resolve-advanced/vendor/conform-qc/test/report.test.js +52 -0
  167. package/resolve-advanced/vendor/conform-qc/test/resolve-readback.test.js +71 -0
  168. package/resolve-advanced/vendor/conform-qc/test/roles.test.js +107 -0
  169. package/resolve-advanced/vendor/conform-qc/test/runbook.test.js +30 -0
  170. package/resolve-advanced/vendor/conform-qc/test/scale-residual.test.js +40 -0
  171. package/resolve-advanced/vendor/conform-qc/test/sidecar.test.js +51 -0
  172. package/resolve-advanced/vendor/conform-qc/test/smoke.test.js +87 -0
  173. package/resolve-advanced/vendor/conform-qc/test/surgical-relink.test.js +73 -0
  174. package/resolve-advanced/vendor/conform-qc/test/synthetic.test.js +98 -0
  175. package/resolve-advanced/vendor/conform-qc/test/verify.test.js +93 -0
  176. package/resolve-advanced/vendor/conform-qc/test/vision-validator.test.js +43 -0
  177. package/resolve-advanced/vendor/conform-qc/test/workflow-driver.test.js +46 -0
  178. package/resolve-advanced/vendor/conform-qc/toolset/index.js +53 -0
  179. package/resolve-advanced/vendor/drp-format/README.md +116 -0
  180. package/resolve-advanced/vendor/drp-format/__tests__/_resolve-verify.js +39 -0
  181. package/resolve-advanced/vendor/drp-format/__tests__/add-media-clip.test.js +30 -0
  182. package/resolve-advanced/vendor/drp-format/__tests__/assemble-timeline.test.js +68 -0
  183. package/resolve-advanced/vendor/drp-format/__tests__/author-project.test.js +57 -0
  184. package/resolve-advanced/vendor/drp-format/__tests__/composition-text.test.js +98 -0
  185. package/resolve-advanced/vendor/drp-format/__tests__/diff.test.js +272 -0
  186. package/resolve-advanced/vendor/drp-format/__tests__/effect-filters-compressed.test.js +143 -0
  187. package/resolve-advanced/vendor/drp-format/__tests__/fixtures/p0-3-session30-empty-effectfilters.drp +0 -0
  188. package/resolve-advanced/vendor/drp-format/__tests__/fixtures/retimed-timemap-50pct.hex +1 -0
  189. package/resolve-advanced/vendor/drp-format/__tests__/fixtures/retimed-timemap-dynamic.hex +1 -0
  190. package/resolve-advanced/vendor/drp-format/__tests__/inject-grades.test.js +238 -0
  191. package/resolve-advanced/vendor/drp-format/__tests__/keyed-dict.test.js +85 -0
  192. package/resolve-advanced/vendor/drp-format/__tests__/lut-refs.test.js +119 -0
  193. package/resolve-advanced/vendor/drp-format/__tests__/media-blobs.test.js +25 -0
  194. package/resolve-advanced/vendor/drp-format/__tests__/media-timemap.test.js +119 -0
  195. package/resolve-advanced/vendor/drp-format/__tests__/place-fusion-title.test.js +52 -0
  196. package/resolve-advanced/vendor/drp-format/__tests__/place-generator.test.js +40 -0
  197. package/resolve-advanced/vendor/drp-format/__tests__/place-transition.test.js +48 -0
  198. package/resolve-advanced/vendor/drp-format/__tests__/protobuf-wire.test.js +64 -0
  199. package/resolve-advanced/vendor/drp-format/__tests__/relink-media.test.js +106 -0
  200. package/resolve-advanced/vendor/drp-format/__tests__/splice-clips.test.js +256 -0
  201. package/resolve-advanced/vendor/drp-format/assemble-timeline.js +65 -0
  202. package/resolve-advanced/vendor/drp-format/author-project.js +118 -0
  203. package/resolve-advanced/vendor/drp-format/composition-text.js +213 -0
  204. package/resolve-advanced/vendor/drp-format/curves-encoder.js +763 -0
  205. package/resolve-advanced/vendor/drp-format/diff.js +310 -0
  206. package/resolve-advanced/vendor/drp-format/drp-packager.js +201 -0
  207. package/resolve-advanced/vendor/drp-format/drp-validator.js +809 -0
  208. package/resolve-advanced/vendor/drp-format/effect-encoder.js +640 -0
  209. package/resolve-advanced/vendor/drp-format/extract-lut-refs.js +119 -0
  210. package/resolve-advanced/vendor/drp-format/grade-encoder.example.js +133 -0
  211. package/resolve-advanced/vendor/drp-format/grade-encoder.js +401 -0
  212. package/resolve-advanced/vendor/drp-format/grade-encoder.test.js +171 -0
  213. package/resolve-advanced/vendor/drp-format/grade-encoder.verify.js +249 -0
  214. package/resolve-advanced/vendor/drp-format/grade-node-extractor.js +495 -0
  215. package/resolve-advanced/vendor/drp-format/grade-parameter-decoder.js +774 -0
  216. package/resolve-advanced/vendor/drp-format/index.js +174 -0
  217. package/resolve-advanced/vendor/drp-format/inject-grades.js +219 -0
  218. package/resolve-advanced/vendor/drp-format/keyed-dict.js +227 -0
  219. package/resolve-advanced/vendor/drp-format/marker-encoder.js +532 -0
  220. package/resolve-advanced/vendor/drp-format/media-blobs.js +53 -0
  221. package/resolve-advanced/vendor/drp-format/media-timemap.js +164 -0
  222. package/resolve-advanced/vendor/drp-format/mp-folder-builder.js +293 -0
  223. package/resolve-advanced/vendor/drp-format/node-tree-encoder.js +1246 -0
  224. package/resolve-advanced/vendor/drp-format/package.json +18 -0
  225. package/resolve-advanced/vendor/drp-format/place-fusion-title.js +131 -0
  226. package/resolve-advanced/vendor/drp-format/place-generator.js +77 -0
  227. package/resolve-advanced/vendor/drp-format/place-transition.js +91 -0
  228. package/resolve-advanced/vendor/drp-format/power-window-encoder.js +776 -0
  229. package/resolve-advanced/vendor/drp-format/protobuf-wire.js +111 -0
  230. package/resolve-advanced/vendor/drp-format/qualifier-encoder.js +706 -0
  231. package/resolve-advanced/vendor/drp-format/relink-media.js +218 -0
  232. package/resolve-advanced/vendor/drp-format/rich-title-encoder.js +447 -0
  233. package/resolve-advanced/vendor/drp-format/seq-container-builder.js +572 -0
  234. package/resolve-advanced/vendor/drp-format/seq-surgery.js +155 -0
  235. package/resolve-advanced/vendor/drp-format/splice-clips.js +445 -0
  236. package/resolve-advanced/vendor/drp-format/templates/empty-project.drp +0 -0
  237. package/resolve-advanced/vendor/drp-format/templates/fusion-title.xml +56 -0
  238. package/resolve-advanced/vendor/drp-format/templates/generator-solid-color.xml +21 -0
  239. package/resolve-advanced/vendor/drp-format/templates/media-clip-h264.drp +0 -0
  240. package/resolve-advanced/vendor/drp-format/templates/transition-cross-dissolve.xml +22 -0
  241. package/resolve-advanced/vendor/drp-format/tool-registry.descriptor.js +80 -0
  242. package/resolve-advanced/vendor/drp-format/utils/safe-archive.js +54 -0
  243. package/resolve-advanced/vendor/drp-format/xml-builder.js +759 -0
  244. package/resolve-advanced/vendor/drt-format/__tests__/_resolve-verify.js +21 -0
  245. package/resolve-advanced/vendor/drt-format/__tests__/build.test.js +113 -0
  246. package/resolve-advanced/vendor/drt-format/__tests__/multi-format.test.js +93 -0
  247. package/resolve-advanced/vendor/drt-format/__tests__/parse-real-resolve21.test.js +51 -0
  248. package/resolve-advanced/vendor/drt-format/__tests__/parse.test.js +134 -0
  249. package/resolve-advanced/vendor/drt-format/__tests__/resolve-versions.test.js +120 -0
  250. package/resolve-advanced/vendor/drt-format/__tests__/schema-fingerprint.test.js +62 -0
  251. package/resolve-advanced/vendor/drt-format/__tests__/smoke.test.js +27 -0
  252. package/resolve-advanced/vendor/drt-format/__tests__/validate.test.js +109 -0
  253. package/resolve-advanced/vendor/drt-format/capabilities.js +69 -0
  254. package/resolve-advanced/vendor/drt-format/capability-domains.js +56 -0
  255. package/resolve-advanced/vendor/drt-format/drt-builder.js +42 -0
  256. package/resolve-advanced/vendor/drt-format/drt-parser.js +164 -0
  257. package/resolve-advanced/vendor/drt-format/drt-validator.js +98 -0
  258. package/resolve-advanced/vendor/drt-format/index.js +45 -0
  259. package/resolve-advanced/vendor/drt-format/package.json +17 -0
  260. package/resolve-advanced/vendor/drt-format/resolve-versions.js +153 -0
  261. package/resolve-advanced/vendor/drt-format/schema-fingerprint.js +74 -0
  262. package/resolve-advanced/vendor/drt-format/templates/drt-project-shell.xml +124 -0
  263. package/resolve-advanced/vendor/drx-codec/__tests__/curves-roundtrip.test.js +148 -0
  264. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-face-refinement.drx +58 -0
  265. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-filmgrain.drx +58 -0
  266. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-glow.drx +58 -0
  267. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-lens-flare.drx +58 -0
  268. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-3-beauty-25.drx +58 -0
  269. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-3-beauty-75.drx +58 -0
  270. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p5-1-node-no-lut.drx +58 -0
  271. package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p5-1-node-with-lut.drx +58 -0
  272. package/resolve-advanced/vendor/drx-codec/__tests__/hsl-curves-roundtrip.test.js +117 -0
  273. package/resolve-advanced/vendor/drx-codec/__tests__/lut-refs-roundtrip.test.js +112 -0
  274. package/resolve-advanced/vendor/drx-codec/__tests__/matte-roundtrip.test.js +167 -0
  275. package/resolve-advanced/vendor/drx-codec/__tests__/ofx-roundtrip.test.js +215 -0
  276. package/resolve-advanced/vendor/drx-codec/__tests__/ofx-slugs-discovery.test.js +149 -0
  277. package/resolve-advanced/vendor/drx-codec/__tests__/power-window-roundtrip.test.js +230 -0
  278. package/resolve-advanced/vendor/drx-codec/__tests__/qualifier-roundtrip.test.js +226 -0
  279. package/resolve-advanced/vendor/drx-codec/cdl-exporter.js +470 -0
  280. package/resolve-advanced/vendor/drx-codec/color-nlp-parser.js +833 -0
  281. package/resolve-advanced/vendor/drx-codec/data/color-nlp-action-mappings.json +2232 -0
  282. package/resolve-advanced/vendor/drx-codec/drx-generator.js +4123 -0
  283. package/resolve-advanced/vendor/drx-codec/drx-merger.js +320 -0
  284. package/resolve-advanced/vendor/drx-codec/drx-parser.js +1808 -0
  285. package/resolve-advanced/vendor/drx-codec/extract-custom-curves.js +215 -0
  286. package/resolve-advanced/vendor/drx-codec/extract-hsl-curves.js +180 -0
  287. package/resolve-advanced/vendor/drx-codec/extract-lut-refs.js +158 -0
  288. package/resolve-advanced/vendor/drx-codec/extract-matte-finesse.js +83 -0
  289. package/resolve-advanced/vendor/drx-codec/extract-ofx-params.js +226 -0
  290. package/resolve-advanced/vendor/drx-codec/extract-power-window.js +184 -0
  291. package/resolve-advanced/vendor/drx-codec/extract-qualifier.js +99 -0
  292. package/resolve-advanced/vendor/drx-codec/index.js +109 -0
  293. package/resolve-advanced/vendor/drx-codec/node-layout.js +249 -0
  294. package/resolve-advanced/vendor/drx-codec/resolved-drx-generator.js +393 -0
  295. package/resolve-advanced/vendor/drx-codec/tool-resolver.js +974 -0
  296. package/resolve-advanced/vendor/drx-codec/training-examples.js +548 -0
  297. package/resolve-advanced/vendor/drx-codec/vocabulary-intents.js +539 -0
  298. package/resolve-advanced/vendor/drx-parameters/CALIBRATION-STATUS.md +319 -0
  299. package/resolve-advanced/vendor/drx-parameters/DRX-VALUE-SCALING.md +251 -0
  300. package/resolve-advanced/vendor/drx-parameters/calibrated-ranges.json +17737 -0
  301. package/resolve-advanced/vendor/drx-parameters/corrector-types.js +120 -0
  302. package/resolve-advanced/vendor/drx-parameters/index.js +193 -0
  303. package/resolve-advanced/vendor/drx-parameters/parameter-codec.js +451 -0
  304. package/resolve-advanced/vendor/drx-parameters/parameter-ids.js +1292 -0
  305. package/resolve-advanced/vendor/drx-parameters/parameter-ranges.js +570 -0
  306. package/resolve-advanced/vendor/drx-parameters/parameter-validator.js +392 -0
  307. package/resolve-advanced/vendor/drx-parameters/resolvefx-registry.json +14973 -0
  308. package/resolve-advanced/vendor/drx-parameters/tool-registry.descriptor.js +38 -0
  309. package/resolve-advanced/vendor/drx-parameters/tools/drx-analyzer.js +371 -0
  310. package/resolve-advanced/vendor/fairlight/index.js +833 -0
  311. package/resolve-advanced/vendor/fusion-codec/composition-generator.js +401 -0
  312. package/resolve-advanced/vendor/fusion-codec/templates/blur-region.js +103 -0
  313. package/resolve-advanced/vendor/fusion-codec/templates/color-correct.js +87 -0
  314. package/resolve-advanced/vendor/fusion-codec/templates/film-grain.js +106 -0
  315. package/resolve-advanced/vendor/fusion-codec/templates/lower-third.js +241 -0
  316. package/resolve-advanced/vendor/fusion-codec/templates/picture-in-picture.js +188 -0
  317. package/resolve-advanced/vendor/fusion-codec/templates/text-overlay.js +161 -0
  318. package/resolve-advanced/vendor/fusion-codec/templates/title-card.js +184 -0
  319. package/resolve-advanced/vendor/fusion-codec/templates/vignette.js +107 -0
  320. package/resolve-advanced/vendor/fusion-codec/templates/watermark.js +101 -0
  321. package/scripts/agent-rules/README.md +68 -0
  322. package/src/analysis_dashboard.py +233 -1
  323. package/src/granular/common.py +1 -1
  324. package/src/server.py +479 -29
  325. package/src/utils/api_truth.py +117 -0
  326. package/src/utils/cut_ir.py +1 -1
  327. package/src/utils/failure_tracker.py +0 -2
  328. package/src/utils/timeline_xml.py +405 -0
@@ -0,0 +1,4123 @@
1
+ /**
2
+ * DRX Generator - Programmatic DaVinci Resolve Grade Creation
3
+ *
4
+ * Generates DRX (DaVinci Resolve eXchange) files from color parameters.
5
+ * Based on reverse-engineered format: [0x81] + [ZSTD-compressed protobuf]
6
+ *
7
+ * Uses shared DRX parameter library for consistent parameter handling
8
+ * across the The project platform.
9
+ *
10
+ * @module drx/drx-generator
11
+ */
12
+
13
+ // ZSTD compression backend selection — priority:
14
+ // 1. Node's built-in zlib.zstdCompressSync (Node 22+, available on Vercel)
15
+ // — reliable, no buffer truncation bugs
16
+ // 2. zstd-codec (WASM) — falls back here for older Node (Electron dev)
17
+ // 3. fzstd (pure JS) — decompress only; compress is undefined, so this
18
+ // fallback exists only for completeness and will throw if reached
19
+ //
20
+ // HISTORY: zstd-codec's Simple.compress() was observed truncating frames on
21
+ // Vercel's Node 22 runtime (body would be valid zstd magic but the compressed
22
+ // stream would end prematurely, producing a DRX Resolve couldn't parse and
23
+ // silently applied as an empty node graph — the clip grade would not change).
24
+ // Using node:zlib.zstdCompressSync when available eliminates that class of
25
+ // bug entirely.
26
+ // Debug tracing is stdout-hostile inside a stdio MCP server — gate it behind DRX_DEBUG.
27
+ const debugLog = process.env.DRX_DEBUG ? console.log.bind(console) : () => {};
28
+ const nodeZlib = require('zlib');
29
+ const HAS_NATIVE_ZSTD = typeof nodeZlib.zstdCompressSync === 'function';
30
+
31
+ let ZstdCodec;
32
+ try {
33
+ ZstdCodec = require('zstd-codec').ZstdCodec;
34
+ } catch {
35
+ ZstdCodec = null;
36
+ }
37
+ const { v4: uuidv4 } = require('uuid');
38
+
39
+ // Import shared DRX parameter library
40
+ const drxParams = require('../drx-parameters');
41
+
42
+ // ZSTD codec instance (initialized lazily)
43
+ let zstdCompressor = null;
44
+
45
+ async function getZstdCompressor() {
46
+ if (zstdCompressor) return zstdCompressor;
47
+
48
+ // Native Node zstd (Node 22+). Preferred path on Vercel.
49
+ if (HAS_NATIVE_ZSTD) {
50
+ zstdCompressor = {
51
+ compress(data) {
52
+ const buf = data instanceof Buffer
53
+ ? data
54
+ : Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data));
55
+ return nodeZlib.zstdCompressSync(buf);
56
+ },
57
+ };
58
+ return zstdCompressor;
59
+ }
60
+
61
+ // zstd-codec (WASM) — Electron/older-Node fallback
62
+ if (ZstdCodec) {
63
+ return new Promise((resolve) => {
64
+ ZstdCodec.run((zstd) => {
65
+ zstdCompressor = new zstd.Simple();
66
+ resolve(zstdCompressor);
67
+ });
68
+ });
69
+ }
70
+
71
+ // Final fallback — fzstd only exposes decompress, so this path will
72
+ // throw at call time. Kept so the error surfaces clearly rather than
73
+ // silently returning undefined and producing a malformed DRX.
74
+ const fzstd = require('fzstd');
75
+ zstdCompressor = {
76
+ compress(data) {
77
+ if (typeof fzstd.compress !== 'function') {
78
+ throw new Error('No zstd compressor available: node:zlib zstd requires Node 22+, zstd-codec failed to load, and fzstd does not support compression.');
79
+ }
80
+ return Buffer.from(fzstd.compress(data instanceof Uint8Array ? data : new Uint8Array(data)));
81
+ },
82
+ };
83
+ return zstdCompressor;
84
+ }
85
+
86
+ /**
87
+ * Parameter ID mappings from shared library
88
+ * See: /lib/drx-parameters for full documentation
89
+ */
90
+ const PARAM_IDS = {
91
+ // Primary Corrector - Lift
92
+ LIFT_R: drxParams.LIFT.R,
93
+ LIFT_G: drxParams.LIFT.G,
94
+ LIFT_B: drxParams.LIFT.B,
95
+ LIFT_MASTER: drxParams.LIFT.MASTER,
96
+
97
+ // Primary Corrector - Gain
98
+ GAIN_R: drxParams.GAIN.R,
99
+ GAIN_G: drxParams.GAIN.G,
100
+ GAIN_B: drxParams.GAIN.B,
101
+ GAIN_MASTER: drxParams.GAIN.MASTER,
102
+
103
+ // Primary Corrector - Gamma
104
+ GAMMA_R: drxParams.GAMMA.R,
105
+ GAMMA_G: drxParams.GAMMA.G,
106
+ GAMMA_B: drxParams.GAMMA.B,
107
+ GAMMA_MASTER: drxParams.GAMMA.MASTER,
108
+
109
+ // Primary Corrector - Offset
110
+ OFFSET_R: drxParams.OFFSET.R,
111
+ OFFSET_G: drxParams.OFFSET.G,
112
+ OFFSET_B: drxParams.OFFSET.B,
113
+
114
+ // Saturation
115
+ SATURATION_PRIMARY: drxParams.SATURATION.PRIMARY,
116
+ // Hue Rotate — uses SATURATION.LEGACY ID (0x06000004) with nested {F1: float} encoding
117
+ // Encoding: DRX = (UI - 50) / 50. UI range 0-100, default 50.
118
+ // Confirmed 2026-03-18: UI 92 → DRX 0.84, UI 75 → DRX 0.50
119
+ HUE_ROTATE: drxParams.SATURATION.LEGACY, // 0x06000004
120
+
121
+ // Contrast / Pivot (shared controls range — corrected 2026-03-22)
122
+ // NOTE: The old CONTRAST block (0x0830xxxx) was a misidentification of HSL Qualifier params.
123
+ // Real contrast/pivot are in the 0x860000C0 range, already mapped as LOG_CONTRAST and PIVOT_PRIMARY below.
124
+
125
+ // Temperature/Tint (all in Primary corrector, Type 1)
126
+ TEMPERATURE: drxParams.TEMP_TINT.TEMPERATURE,
127
+ TINT: drxParams.TEMP_TINT.TINT,
128
+ COLOR_BOOST: drxParams.TEMP_TINT.COLOR_BOOST,
129
+ MIDTONE_DETAIL: drxParams.TEMP_TINT.MIDTONE_DETAIL,
130
+ LOG_CONTRAST: drxParams.TEMP_TINT.CONTRAST,
131
+ // Pivot — TEMP_TINT.CONTRAST - 1, confirmed by manual DRX capture 2026-03-17
132
+ PIVOT_PRIMARY: 2248147136,
133
+
134
+ // Soft Clip — per-channel R/G/B in Primary corrector
135
+ // Confirmed by manual DRX capture 2026-03-17: SoftClipHigh=70 → IDs 100663350-352 at 0.70
136
+ SOFT_CLIP_HIGH_R: 100663350,
137
+ SOFT_CLIP_HIGH_G: 100663351,
138
+ SOFT_CLIP_HIGH_B: 100663352,
139
+ // Soft Clip Low — CORRECTED 2026-03-24 via manual DRX capture autoresearch.
140
+ // Original extrapolation had B at 100663349 (0x35) — WRONG. Actual B is at 100663346 (0x32).
141
+ // Layout: B(0x32), R(0x33), G(0x34) — B is BEFORE R, not after G.
142
+ SOFT_CLIP_LOW_R: 100663347, // 0x06000033
143
+ SOFT_CLIP_LOW_G: 100663348, // 0x06000034
144
+ SOFT_CLIP_LOW_B: 100663346, // 0x06000032 (was incorrectly 100663349)
145
+
146
+ // Soft Clip Soft (softness controls) — captured 2026-03-18
147
+ // Encoding: UI / 50 (so UI 25 → DRX 0.5, UI 50 → DRX 1.0)
148
+ SOFT_CLIP_LOW_SOFT_R: 100663354, // 0x0600003A
149
+ SOFT_CLIP_LOW_SOFT_G: 100663355, // 0x0600003B
150
+ SOFT_CLIP_LOW_SOFT_B: 100663356, // 0x0600003C
151
+ SOFT_CLIP_HIGH_SOFT_R: 100663358, // 0x0600003E
152
+ SOFT_CLIP_HIGH_SOFT_G: 100663359, // 0x0600003F
153
+ SOFT_CLIP_HIGH_SOFT_B: 100663360, // 0x06000040
154
+
155
+ // Contrast Range — confirmed by manual DRX captures 2026-03-18
156
+ // Low Range: 0x860000CB, direct encoding (UI value = DRX value)
157
+ // High Range: 0x860000CC, INVERTED encoding (DRX = 1.0 - UI)
158
+ CONTRAST_LOW_RANGE_PRIMARY: 2248147147, // 0x860000CB
159
+ CONTRAST_HIGH_RANGE_PRIMARY: 2248147148, // 0x860000CC
160
+
161
+ // HDR Global
162
+ HDR_BLACK_OFFSET: drxParams.HDR_ZONE.BLACK_OFFSET,
163
+
164
+ // HDR Zone
165
+ HDR_ZONE_ADJUSTMENTS: drxParams.HDR_ZONE.ZONE_ADJUSTMENTS,
166
+ HDR_ZONE_DEFINITIONS: drxParams.HDR_ZONE.ZONE_DEFINITIONS,
167
+ HDR_ZONE_METADATA: drxParams.HDR_ZONE.ZONE_METADATA,
168
+ HDR_ZONE_PARAM: drxParams.HDR_ZONE.PARAM_1,
169
+
170
+ // Log Wheels (R/G/B only - no master channel)
171
+ // Corrected 2026-01-14: Shadow=0xc2-c4, Midtone=0xc5-c7, Highlight=0xc8-ca
172
+ LOG_SHADOW_R: drxParams.LOG_WHEELS.SHADOW_R,
173
+ LOG_SHADOW_G: drxParams.LOG_WHEELS.SHADOW_G,
174
+ LOG_SHADOW_B: drxParams.LOG_WHEELS.SHADOW_B,
175
+ LOG_MIDTONE_R: drxParams.LOG_WHEELS.MIDTONE_R,
176
+ LOG_MIDTONE_G: drxParams.LOG_WHEELS.MIDTONE_G,
177
+ LOG_MIDTONE_B: drxParams.LOG_WHEELS.MIDTONE_B,
178
+ LOG_HIGHLIGHT_R: drxParams.LOG_WHEELS.HIGHLIGHT_R,
179
+ LOG_HIGHLIGHT_G: drxParams.LOG_WHEELS.HIGHLIGHT_G,
180
+ LOG_HIGHLIGHT_B: drxParams.LOG_WHEELS.HIGHLIGHT_B,
181
+
182
+ // RGB Mixer (trained 2026-03-17 from rgbmixer_1.1.2.drx)
183
+ RGB_MIXER_RR: drxParams.RGB_MIXER?.RR,
184
+ RGB_MIXER_GR: drxParams.RGB_MIXER?.GR,
185
+ RGB_MIXER_BR: drxParams.RGB_MIXER?.BR,
186
+ RGB_MIXER_RG: drxParams.RGB_MIXER?.RG,
187
+ RGB_MIXER_GG: drxParams.RGB_MIXER?.GG,
188
+ RGB_MIXER_BG: drxParams.RGB_MIXER?.BG,
189
+ RGB_MIXER_RB: drxParams.RGB_MIXER?.RB,
190
+ RGB_MIXER_GB: drxParams.RGB_MIXER?.GB,
191
+ RGB_MIXER_BB: drxParams.RGB_MIXER?.BB,
192
+
193
+ // Custom Curves YRGB (trained 2026-03-17, CORRECTED 2026-03-18)
194
+ // Round-trip verification proved drx-parameters mapping was shifted:
195
+ // 0x86000506 = R (not Y), 0x86000507 = G (not R), 0x86000508 = B (not G), 0x86000509 = Y (not B)
196
+ // Metadata IDs: 0xBB=Y, 0xBD=R, 0xBE=G, 0xBF=B (unchanged)
197
+ CURVES_Y_META: drxParams.CUSTOM_CURVES?.Y_META, // 0x860000BB
198
+ CURVES_R_META: drxParams.CUSTOM_CURVES?.R_META, // 0x860000BD
199
+ CURVES_G_META: drxParams.CUSTOM_CURVES?.G_META, // 0x860000BE
200
+ CURVES_B_META: drxParams.CUSTOM_CURVES?.B_META, // 0x860000BF
201
+ CURVES_Y_SPLINE: 2248148233, // 0x86000509 — was incorrectly mapped to B_SPLINE
202
+ CURVES_R_SPLINE: 2248148230, // 0x86000506 — was incorrectly mapped to Y_SPLINE
203
+ CURVES_G_SPLINE: 2248148231, // 0x86000507 — was incorrectly mapped to R_SPLINE
204
+ CURVES_B_SPLINE: 2248148232, // 0x86000508 — was incorrectly mapped to G_SPLINE
205
+
206
+ // HSL Curves (trained 2026-03-17)
207
+ HSL_COMMON_FLAG: drxParams.HSL_CURVES?.COMMON_FLAG,
208
+ HSL_HUE_VS_HUE_SPLINE: drxParams.HSL_CURVES?.HUE_VS_HUE_SPLINE,
209
+ HSL_HUE_VS_SAT_SPLINE: drxParams.HSL_CURVES?.HUE_VS_SAT_SPLINE,
210
+ HSL_HUE_VS_LUM_SPLINE: drxParams.HSL_CURVES?.HUE_VS_LUM_SPLINE,
211
+ HSL_LUM_VS_SAT_SPLINE: drxParams.HSL_CURVES?.LUM_VS_SAT_SPLINE,
212
+ HSL_SAT_VS_SAT_SPLINE: drxParams.HSL_CURVES?.SAT_VS_SAT_SPLINE,
213
+ HSL_SAT_VS_LUM_SPLINE: drxParams.HSL_CURVES?.SAT_VS_LUM_SPLINE,
214
+
215
+ // Additional controls (trained 2026-03-17)
216
+ HIGHLIGHTS: drxParams.ADDITIONAL?.HIGHLIGHTS,
217
+ SHADOWS: drxParams.ADDITIONAL?.SHADOWS,
218
+ LUM_MIX_SLIDER: drxParams.ADDITIONAL?.LUM_MIX_SLIDER, // 0x8600000B
219
+ };
220
+
221
+ /**
222
+ * Corrector Type IDs from shared library
223
+ */
224
+ const CORRECTOR_TYPES = drxParams.CORRECTOR_TYPES;
225
+
226
+ /**
227
+ * Presence marker IDs from shared library
228
+ */
229
+ const PRESENCE_MARKER_IDS = drxParams.PRESENCE_MARKER_IDS;
230
+
231
+ /**
232
+ * Default neutral grade values from shared library
233
+ */
234
+ const NEUTRAL_GRADE = {
235
+ // Color Wheels
236
+ lift: {
237
+ r: drxParams.getDefault('lift', 'r'),
238
+ g: drxParams.getDefault('lift', 'g'),
239
+ b: drxParams.getDefault('lift', 'b'),
240
+ master: drxParams.getDefault('lift', 'master'),
241
+ },
242
+ gamma: {
243
+ r: drxParams.getDefault('gamma', 'r'),
244
+ g: drxParams.getDefault('gamma', 'g'),
245
+ b: drxParams.getDefault('gamma', 'b'),
246
+ master: drxParams.getDefault('gamma', 'master'),
247
+ },
248
+ gain: {
249
+ r: drxParams.getDefault('gain', 'r'),
250
+ g: drxParams.getDefault('gain', 'g'),
251
+ b: drxParams.getDefault('gain', 'b'),
252
+ master: drxParams.getDefault('gain', 'master'),
253
+ },
254
+ offset: {
255
+ r: drxParams.getDefault('offset', 'r'),
256
+ g: drxParams.getDefault('offset', 'g'),
257
+ b: drxParams.getDefault('offset', 'b'),
258
+ },
259
+
260
+ // Primary adjustments
261
+ saturation: drxParams.getDefault('saturation', 'master'),
262
+ contrast: drxParams.getDefault('contrast', 'master'),
263
+ pivot: drxParams.getDefault('pivotFine', 'master'),
264
+
265
+ // Temperature/Tint
266
+ temperature: drxParams.getDefault('temperature', 'master'),
267
+ tint: drxParams.getDefault('tint', 'master'),
268
+
269
+ // Detail
270
+ midtoneDetail: drxParams.getDefault('midtoneDetail', 'master'),
271
+
272
+ // Soft clip
273
+ softClipHigh: drxParams.getDefault('softClipHigh', 'master'),
274
+ softClipLow: drxParams.getDefault('softClipLow', 'master'),
275
+ };
276
+
277
+ /**
278
+ * Encode a varint (variable-length integer) for protobuf
279
+ */
280
+ function encodeVarint(value) {
281
+ const bytes = [];
282
+ while (value > 0x7f) {
283
+ bytes.push((value & 0x7f) | 0x80);
284
+ value >>>= 7;
285
+ }
286
+ bytes.push(value & 0x7f);
287
+ return Buffer.from(bytes);
288
+ }
289
+
290
+ /**
291
+ * Encode a float32 as little-endian bytes
292
+ */
293
+ function encodeFloat32(value) {
294
+ const buf = Buffer.alloc(4);
295
+ buf.writeFloatLE(value, 0);
296
+ return buf;
297
+ }
298
+
299
+ /**
300
+ * Create a protobuf field with varint wire type (0)
301
+ */
302
+ function protoVarint(fieldNum, value) {
303
+ const tag = (fieldNum << 3) | 0;
304
+ return Buffer.concat([encodeVarint(tag), encodeVarint(value)]);
305
+ }
306
+
307
+ /**
308
+ * Create a protobuf field with fixed32 wire type (5) for floats
309
+ */
310
+ function protoFloat32(fieldNum, value) {
311
+ const tag = (fieldNum << 3) | 5;
312
+ return Buffer.concat([encodeVarint(tag), encodeFloat32(value)]);
313
+ }
314
+
315
+ /**
316
+ * Create a protobuf field with fixed64 wire type (1) for doubles
317
+ */
318
+ function protoFloat64(fieldNum, value) {
319
+ const tag = (fieldNum << 3) | 1;
320
+ const buf = Buffer.alloc(8);
321
+ buf.writeDoubleLE(value, 0);
322
+ return Buffer.concat([encodeVarint(tag), buf]);
323
+ }
324
+
325
+ /**
326
+ * Create a protobuf field with length-delimited wire type (2)
327
+ */
328
+ function protoBytes(fieldNum, data) {
329
+ const tag = (fieldNum << 3) | 2;
330
+ return Buffer.concat([encodeVarint(tag), encodeVarint(data.length), data]);
331
+ }
332
+
333
+ /**
334
+ * Create a color parameter entry
335
+ * Format: Field 3 { Field 1 (ID), Field 2 { Field 1 (float value) } }
336
+ */
337
+ function createParameterEntry(paramId, value) {
338
+ // ── Universal range clamp ──────────────────────────────────────────────────
339
+ // Every float that enters the DRX body passes through here.
340
+ // Look up the param ID in the shared parameter library to get valid range,
341
+ // then hard-clamp. This protects ALL builder functions (Primary, HDR, Curves,
342
+ // Qualifier, Windows, ResolveFX) with zero per-builder code.
343
+ // Unknown param IDs pass through unchanged (future-proof).
344
+ const paramInfo = drxParams.getParamInfo(paramId);
345
+ if (paramInfo) {
346
+ const range = drxParams.getRange(paramInfo.control, paramInfo.channel);
347
+ if (range) {
348
+ const clamped = Math.max(range.min, Math.min(range.max, value));
349
+ if (clamped !== value) {
350
+ console.warn(`[DRX] CLAMPED ${paramInfo.control}.${paramInfo.channel}: ${value} → ${clamped} (range ${range.min}–${range.max})`);
351
+ }
352
+ value = clamped;
353
+ }
354
+ }
355
+
356
+ // Inner value message: Field 1 = float
357
+ const valueMsg = protoFloat32(1, value);
358
+
359
+ // Outer entry: Field 1 = ID (varint), Field 2 = value message
360
+ const entry = Buffer.concat([
361
+ protoVarint(1, paramId),
362
+ protoBytes(2, valueMsg),
363
+ ]);
364
+
365
+ // Wrap in Field 3
366
+ return protoBytes(3, entry);
367
+ }
368
+
369
+ /**
370
+ * Create a varint-valued parameter entry: F3 { F1 (ID), F2 { F2 (varint) } }.
371
+ * Live Resolve stores flag/mode params (window type 0x88500008, qualifier mode,
372
+ * gradient subtype 0x08F00001) in a varint envelope — NOT the float32 F2.F1
373
+ * envelope createParameterEntry writes. Wire-confirmed 2026-07-01 against the
374
+ * power-window/gradient/qualifier fixtures (all carry {F2: <varint>}).
375
+ * No range clamp — these are discrete flags, not ranged floats.
376
+ */
377
+ function createVarintParameterEntry(paramId, value) {
378
+ const valueMsg = protoVarint(2, value);
379
+ const entry = Buffer.concat([
380
+ protoVarint(1, paramId),
381
+ protoBytes(2, valueMsg),
382
+ ]);
383
+ return protoBytes(3, entry);
384
+ }
385
+
386
+ /**
387
+ * Create a curve control point
388
+ * Format: Field 1 { Field 1 (x float), Field 2 (y float) }
389
+ */
390
+ function createCurvePoint(x, y) {
391
+ const point = Buffer.concat([
392
+ protoFloat32(1, x),
393
+ protoFloat32(2, y),
394
+ ]);
395
+ return protoBytes(1, point);
396
+ }
397
+
398
+ /**
399
+ * Encode a YRGB curve spline as nested protobuf for DRX.
400
+ * Coordinate space: 0–1023. Sentinel: (-1024, -1024). End: (1023, 1023).
401
+ * @param {Array<{x: number, y: number}>} points - Control points in 0–1 normalized space
402
+ * @returns {Buffer} Encoded spline data
403
+ */
404
+ function encodeCurveSpline(points) {
405
+ const SCALE = 1023;
406
+ const bufs = [];
407
+ bufs.push(createCurvePoint(-1024, -1024)); // sentinel
408
+ bufs.push(protoBytes(1, Buffer.alloc(0))); // empty separator
409
+ for (const pt of points) {
410
+ bufs.push(createCurvePoint(
411
+ Math.max(0, Math.min(SCALE, pt.x * SCALE)),
412
+ Math.max(0, Math.min(SCALE, pt.y * SCALE)),
413
+ ));
414
+ }
415
+ bufs.push(createCurvePoint(SCALE, SCALE)); // end
416
+ return protoBytes(8, Buffer.concat(bufs));
417
+ }
418
+
419
+ /**
420
+ * Encode an HSL curve spline as nested protobuf for DRX.
421
+ * Coordinate space: raw floats (X = 0.0–1.0 hue fraction with wrap, Y = 0.0–1.0 where 0.5 = neutral).
422
+ * Training samples show X ranges from ~-0.08 to ~1.08 (wrap-around continuity).
423
+ * @param {Array<{x: number, y: number}>} points - Control points in raw float space
424
+ * @returns {Buffer} Encoded spline data
425
+ */
426
+ function encodeHSLCurveSpline(points) {
427
+ // HSL curves use a different convention from YRGB:
428
+ // - NO sentinel (-1024, -1024)
429
+ // - NO empty separator
430
+ // - NO end marker
431
+ // - Just raw float data points in F8 wrapper
432
+ // - Points must cover wrap-around range (~-0.08 to ~1.08 in X)
433
+ // - Y: 0.5 = neutral, >0.5 = boost, <0.5 = cut
434
+ // Training evidence: hue_vs_sat_1.1.2.drx has 40 raw points, no sentinels.
435
+ const bufs = [];
436
+ for (const pt of points) {
437
+ bufs.push(createCurvePoint(pt.x, pt.y));
438
+ }
439
+ return protoBytes(8, Buffer.concat(bufs));
440
+ }
441
+
442
+ /**
443
+ * Encode curve metadata (point count) as nested protobuf { F2: varint count }
444
+ */
445
+ function encodeCurveMeta(count) {
446
+ return protoVarint(2, count);
447
+ }
448
+
449
+ /**
450
+ * Build Custom Curves (YRGB) parameters.
451
+ * Input: colorParams.customCurves = { y: [{x,y}], r: [{x,y}], g: [{x,y}], b: [{x,y}] }
452
+ * Returns pre-wrapped F3 entries for the Primary corrector.
453
+ */
454
+ function buildCustomCurveParams(colorParams) {
455
+ const entries = [];
456
+ if (!colorParams.customCurves) return entries;
457
+ const curves = colorParams.customCurves;
458
+ const channels = [
459
+ { key: 'y', metaId: PARAM_IDS.CURVES_Y_META, splineId: PARAM_IDS.CURVES_Y_SPLINE },
460
+ { key: 'r', metaId: PARAM_IDS.CURVES_R_META, splineId: PARAM_IDS.CURVES_R_SPLINE },
461
+ { key: 'g', metaId: PARAM_IDS.CURVES_G_META, splineId: PARAM_IDS.CURVES_G_SPLINE },
462
+ { key: 'b', metaId: PARAM_IDS.CURVES_B_META, splineId: PARAM_IDS.CURVES_B_SPLINE },
463
+ ];
464
+
465
+ // Training evidence (curve_y_scurve_1.1.2.drx): Resolve ALWAYS writes all 4 channels.
466
+ // Untouched channels get identity splines. All 4 metadata entries present.
467
+ // Determine which channels have user data vs identity
468
+ const hasAny = channels.some(({ key }) => curves[key]?.length > 0);
469
+ if (!hasAny) return entries;
470
+
471
+ // Identity curve: two points at (0,0) and (1,1) in normalized space → (0,0) and (1023,1023) after scaling
472
+ const IDENTITY_POINTS = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
473
+
474
+ for (const { key, metaId, splineId } of channels) {
475
+ const pts = curves[key]?.length > 0 ? curves[key] : IDENTITY_POINTS;
476
+ // Metadata entry — F2 value = point count including sentinel + endpoint
477
+ entries.push(protoBytes(3, Buffer.concat([
478
+ protoVarint(1, metaId),
479
+ protoBytes(2, encodeCurveMeta(pts.length + 2)),
480
+ ])));
481
+ // Spline data entry
482
+ entries.push(protoBytes(3, Buffer.concat([
483
+ protoVarint(1, splineId),
484
+ protoBytes(2, encodeCurveSpline(pts)),
485
+ ])));
486
+ const isIdentity = !curves[key]?.length;
487
+ debugLog('[DRX] Custom Curve ' + key.toUpperCase() + ': ' + pts.length + ' points' + (isIdentity ? ' (identity)' : ''));
488
+ }
489
+ return entries;
490
+ }
491
+
492
+ /**
493
+ * Build HSL Curve parameters.
494
+ * Input: colorParams.hslCurves = { hueVsHue: [{x,y}], hueVsSat: [{x,y}], ... }
495
+ */
496
+ function buildHSLCurveParams(colorParams) {
497
+ const entries = [];
498
+ if (!colorParams.hslCurves) return entries;
499
+ const hsl = colorParams.hslCurves;
500
+ const types = [
501
+ { key: 'hueVsHue', splineId: PARAM_IDS.HSL_HUE_VS_HUE_SPLINE, metaId: drxParams.HSL_CURVES.HUE_VS_HUE_META },
502
+ { key: 'hueVsSat', splineId: PARAM_IDS.HSL_HUE_VS_SAT_SPLINE, metaId: drxParams.HSL_CURVES.HUE_VS_SAT_META },
503
+ { key: 'hueVsLum', splineId: PARAM_IDS.HSL_HUE_VS_LUM_SPLINE, metaId: drxParams.HSL_CURVES.HUE_VS_LUM_META },
504
+ { key: 'lumVsSat', splineId: PARAM_IDS.HSL_LUM_VS_SAT_SPLINE, metaId: drxParams.HSL_CURVES.LUM_VS_SAT_META },
505
+ { key: 'satVsSat', splineId: PARAM_IDS.HSL_SAT_VS_SAT_SPLINE, metaId: drxParams.HSL_CURVES.SAT_VS_SAT_META },
506
+ { key: 'satVsLum', splineId: PARAM_IDS.HSL_SAT_VS_LUM_SPLINE, metaId: drxParams.HSL_CURVES.SAT_VS_LUM_META },
507
+ ];
508
+ let hasAny = false;
509
+ // Per-curve meta values observed in LIVE R21 fixtures vary by curve (hueVsHue → 0,
510
+ // satVsSat → 2; the old always-6 came from older training samples). Semantics not fully
511
+ // decoded — callers can override per curve via colorParams.hslCurveMeta = {satVsSat: 2}.
512
+ const metaOverrides = colorParams.hslCurveMeta || {};
513
+ for (const { key, splineId, metaId } of types) {
514
+ const pts = hsl[key];
515
+ if (!pts || pts.length === 0) continue;
516
+ hasAny = true;
517
+ const metaVal = metaOverrides[key] !== undefined ? metaOverrides[key] : 6;
518
+ entries.push(protoBytes(3, Buffer.concat([
519
+ protoVarint(1, metaId),
520
+ protoBytes(2, encodeCurveMeta(metaVal)),
521
+ ])));
522
+ // Spline data — HSL uses raw float coordinate space (NOT 0-1023)
523
+ // X = hue fraction (0.0-1.0 with wrap), Y = value (0.5 = neutral)
524
+ entries.push(protoBytes(3, Buffer.concat([
525
+ protoVarint(1, splineId),
526
+ protoBytes(2, encodeHSLCurveSpline(pts)),
527
+ ])));
528
+ debugLog('[DRX] HSL Curve ' + key + ': ' + pts.length + ' control points (float space)');
529
+ }
530
+ if (hasAny) {
531
+ // Common flag (always {F2: 2} when any HSL curve is active)
532
+ entries.push(protoBytes(3, Buffer.concat([
533
+ protoVarint(1, PARAM_IDS.HSL_COMMON_FLAG),
534
+ protoBytes(2, protoVarint(2, 2)),
535
+ ])));
536
+ }
537
+ return entries;
538
+ }
539
+
540
+ /**
541
+ * Create a basic node structure
542
+ *
543
+ * @param {number} nodeId - Unique node ID
544
+ * @param {number} xPos - X position in node graph
545
+ * @param {number} yPos - Y position in node graph
546
+ * @param {Object} colorParams - Color grading parameters
547
+ * @param {Object} options - Additional options (label, enabled, correctorTypes)
548
+ */
549
+ function createNode(nodeId, xPos, yPos, colorParams = null, options = {}) {
550
+ const { label = '', enabled = true, nodeIndex = nodeId } = options;
551
+
552
+ // Build node header with fields in order: F1, F2, F4, F5, F6, F7?, F8
553
+ // Native Resolve analysis shows: id(F1), index(F2), pos(F4,F5), label?(F6), F8=44
554
+ // F7 (enabled) only appears when disabled - not included for enabled nodes
555
+ const parts = [
556
+ protoVarint(1, nodeId), // F1: Node ID (unique identifier, e.g., 36, 37)
557
+ protoVarint(2, nodeIndex), // F2: Node index (sequential, e.g., 1, 2)
558
+ protoVarint(4, xPos), // F4: X position
559
+ protoVarint(5, yPos), // F5: Y position
560
+ ];
561
+
562
+ // Add label if provided (F6 comes before F7/F8)
563
+ if (label) {
564
+ parts.push(protoBytes(6, Buffer.from(label, 'utf-8')));
565
+ }
566
+
567
+ // F7 = 1 for ENABLED nodes (native Resolve analysis shows F7=1 when enabled)
568
+ // F7 = 0 for DISABLED nodes
569
+ parts.push(protoVarint(7, enabled ? 1 : 0));
570
+
571
+ parts.push(protoVarint(8, 44)); // F8: Unknown flag (always 44)
572
+
573
+ // Build corrector structure matching Resolve's native format
574
+ // Resolve uses:
575
+ // F9.F1[] = actual corrector blocks with parameters
576
+ // F9.F2 = compact byte array of presence marker types [3, 4, 5, 6, 18]
577
+ debugLog('[DRX] createNode called with colorParams:', colorParams ? 'present' : 'null');
578
+ const correctorBlocks = []; // F1 entries (actual correctors with params)
579
+ const presenceTypes = []; // Types for compact F2 presence list
580
+
581
+ if (colorParams) {
582
+ debugLog('[DRX] colorParams keys:', Object.keys(colorParams));
583
+
584
+ // Type 1: Primary Corrector - Lift/Gamma/Gain/Offset/Saturation
585
+ // Only include if there are actual params (Resolve omits neutral Primary)
586
+ const primaryParams = buildPrimaryCorrectorParams(colorParams);
587
+ debugLog('[DRX] Primary params built, length:', primaryParams.length);
588
+
589
+ // Type 2: Contrast Corrector - ALWAYS include as F1 block (Resolve pattern)
590
+ // Even when neutral, it gets a presence marker param, NOT the F2 list
591
+ const contrastParams = buildContrastCorrectorParams(colorParams);
592
+ debugLog('[DRX] Contrast params built, length:', contrastParams.length);
593
+
594
+ // Custom Curves (YRGB) — spline data goes in Primary corrector as additional F3 entries
595
+ const curveParams = buildCustomCurveParams(colorParams);
596
+ if (curveParams.length > 0) {
597
+ primaryParams.push(...curveParams);
598
+ debugLog('[DRX] Added', curveParams.length, 'custom curve entries to Primary corrector');
599
+ }
600
+
601
+ // HSL Curves — spline data also goes in Primary corrector
602
+ const hslParams = buildHSLCurveParams(colorParams);
603
+ if (hslParams.length > 0) {
604
+ primaryParams.push(...hslParams);
605
+ debugLog('[DRX] Added', hslParams.length, 'HSL curve entries to Primary corrector');
606
+ }
607
+
608
+ // HDR zones — ALL zones share ONE ZONE_ADJUSTMENTS (0x86000305) param in the
609
+ // Primary corrector, as repeated F16.F1[] zone sub-messages (wire-confirmed on the
610
+ // hdr-zones-grid fixture: 5 zones in a single param, same ct1 block as other params).
611
+ // The old code emitted one param per zone in SEPARATE corrector blocks, so any grade
612
+ // with 2+ zones silently lost all but the last on decode/apply.
613
+ const hdrParams = buildHDRWheelParams(colorParams);
614
+ if (hdrParams.length > 0) {
615
+ const zoneMsgs = hdrParams
616
+ .filter((z) => z.isNested && z.nestedData)
617
+ .map((z) => protoBytes(1, z.nestedData));
618
+ if (zoneMsgs.length > 0) {
619
+ const field16Wrapper = protoBytes(16, Buffer.concat(zoneMsgs));
620
+ const paramEntry = Buffer.concat([
621
+ protoVarint(1, hdrParams[0].paramId),
622
+ protoBytes(2, field16Wrapper),
623
+ ]);
624
+ primaryParams.push(protoBytes(3, paramEntry));
625
+ debugLog('[DRX] Added', zoneMsgs.length, 'HDR zone(s) in one ZONE_ADJUSTMENTS param');
626
+ }
627
+ }
628
+
629
+ // HDR zone DEFINITIONS (0x86000306) — custom zone boundary (Max Range) + falloff.
630
+ // Structure decoded 2026-07-03 by two-point capture sweep (see
631
+ // test/hdr-zone-definitions.test.mjs): param value = repeated F17 { F1: record },
632
+ // record = { F1 name (str) · F2 DEFAULT boundary f32 · F3 CURRENT boundary f32 ·
633
+ // F4 DEFAULT falloff f32 · F5 CURRENT falloff f32 }. Defaults ship in native files.
634
+ // STOCK table verification status: Dark [-1.5, 0.2] is capture-VERIFIED; Shadow/
635
+ // Light falloffs 0.22 were read off the panel; the remaining boundaries are
636
+ // UNVERIFIED placeholders — pass explicit defaultBoundary/defaultFalloff for
637
+ // non-Dark zones when display fidelity of the zone editor matters. Only F3/F5
638
+ // (the CURRENT values) affect rendering.
639
+ if (colorParams && Array.isArray(colorParams.hdrZoneDefinitions) && colorParams.hdrZoneDefinitions.length) {
640
+ const STOCK = { Black: [-4.0, 0.2], Dark: [-1.5, 0.2], Shadow: [0.0, 0.22], Light: [0.0, 0.22], Highlight: [2.0, 0.25], Specular: [4.0, 0.25] };
641
+ const f32 = (tag, v) => { const b = Buffer.alloc(5); b[0] = tag; b.writeFloatLE(v, 1); return b; };
642
+ const zoneMsgs = [];
643
+ for (const z of colorParams.hdrZoneDefinitions) {
644
+ if (!z || !z.name) continue;
645
+ const stock = STOCK[z.name] || [z.boundary ?? 0, z.falloff ?? 0.2];
646
+ const defB = z.defaultBoundary ?? stock[0];
647
+ const defF = z.defaultFalloff ?? stock[1];
648
+ const name = Buffer.from(String(z.name), 'utf8');
649
+ const rec = Buffer.concat([
650
+ Buffer.from([0x0a, name.length]), name,
651
+ f32(0x15, defB),
652
+ f32(0x1d, z.boundary ?? defB),
653
+ f32(0x25, defF),
654
+ f32(0x2d, z.falloff ?? defF),
655
+ ]);
656
+ zoneMsgs.push(protoBytes(17, protoBytes(1, rec)));
657
+ }
658
+ if (zoneMsgs.length) {
659
+ primaryParams.push(protoBytes(3, Buffer.concat([
660
+ protoVarint(1, drxParams.HDR_ZONE.ZONE_DEFINITIONS),
661
+ protoBytes(2, Buffer.concat(zoneMsgs)),
662
+ ])));
663
+ debugLog('[DRX] Added', zoneMsgs.length, 'HDR zone DEFINITION record(s)');
664
+ }
665
+ }
666
+
667
+ // Add Primary corrector block if we have params
668
+ if (primaryParams.length > 0) {
669
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.PRIMARY, primaryParams));
670
+ }
671
+
672
+ // COMPANION BLOCK + PRESENCE MARKERS
673
+ // Training evidence: Native Resolve DRX exports with curves have NO companion block
674
+ // (Type 2 with 0x88300001). Only Primary (Type 1) + F2 presence markers.
675
+ // The companion block causes errant L.Mix=100 when combined with curve splines
676
+ // and complete desaturation when combined with HSL curves.
677
+ // For non-curve grades (CDL, wheels, etc.) the companion block is harmless but unnecessary.
678
+ const hasCurves = curveParams.length > 0 || hslParams.length > 0;
679
+ if (primaryParams.length > 0 && !hasCurves) {
680
+ // Companion block: corrector type 2 with param 0x88300001 = {F2: 0}
681
+ // Only include for non-curve grades (matches Resolve behavior for simple CDL grades)
682
+ const companionParam = protoBytes(3, Buffer.concat([
683
+ protoVarint(1, 2284847105), // 0x88300001
684
+ protoBytes(2, protoVarint(2, 0)),
685
+ ]));
686
+ correctorBlocks.push(buildCorrectorBlock(2, [companionParam]));
687
+ }
688
+ if (primaryParams.length > 0) {
689
+ // Presence marker list: types 3, 4, 5, 6, 18 (always present per training evidence)
690
+ presenceTypes.push(3, 4, 5, 6, 18);
691
+ }
692
+
693
+ if (contrastParams.length > 0) {
694
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.CONTRAST, contrastParams));
695
+ }
696
+ // NOTE: Do NOT add presence marker corrector blocks when there are no contrast params.
697
+ // Presence markers cause Resolve to show qualifier/sub-corrector panel indicators,
698
+ // making nodes appear to have HSL qualifiers set when they don't.
699
+ // Resolve adds its own presence markers when it imports the grade.
700
+
701
+ // (HDR zones are folded into primaryParams above — single ZONE_ADJUSTMENTS param.)
702
+ } else {
703
+ debugLog('[DRX] No colorParams - using presence marker for Contrast');
704
+ // Contrast still needs F1 block with presence marker
705
+ const contrastPresenceBlock = buildPresenceMarkerCorrectorBlock(CORRECTOR_TYPES.CONTRAST);
706
+ if (contrastPresenceBlock && contrastPresenceBlock.length > 0) {
707
+ correctorBlocks.push(contrastPresenceBlock);
708
+ }
709
+ }
710
+
711
+ // Type 3: Saturation vs Saturation
712
+ if (colorParams) {
713
+ const satVsSatParams = buildSatVsSatCorrectorParams(colorParams);
714
+ if (satVsSatParams.length > 0) {
715
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.SATURATION, satVsSatParams));
716
+ }
717
+ }
718
+
719
+ // Type 4: Hue Corrector
720
+ if (colorParams) {
721
+ const hueParams = buildHueCorrectorParams(colorParams);
722
+ if (hueParams.length > 0) {
723
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.HUE, hueParams));
724
+ }
725
+ }
726
+
727
+ // Type 5: Luma vs Saturation
728
+ if (colorParams) {
729
+ const lumMixParams = buildLumMixCorrectorParams(colorParams);
730
+ if (lumMixParams.length > 0) {
731
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.LUM_MIX, lumMixParams));
732
+ }
733
+ }
734
+
735
+ // Type 2 (HSL Qualifier), Type 4 (Power Window), Type 9 (Matte Finesse).
736
+ // Wired in Session 25 after extractQualifier/extractPowerWindow/
737
+ // extractMatteFinesse (P1.3/P1.4/P1.5) had to use synthesized inputs
738
+ // because these emissions never reached createNode. Each is gated on
739
+ // the corresponding param block being non-empty.
740
+ if (colorParams) {
741
+ // One qualifier per node (Resolve has a single qualifier with a mode). Precedence:
742
+ // HSL (`qualifier`) > RGB (`rgbQualifier`) > luma (`lumaQualifier`). RGB/luma wired
743
+ // 2026-07-02 — the builders existed with live-corrected ids but were unreachable.
744
+ let qualParams = buildQualifierParams(colorParams.qualifier);
745
+ if (qualParams.length === 0) qualParams = buildRGBQualifierParams(colorParams.rgbQualifier);
746
+ if (qualParams.length === 0) qualParams = buildLumaQualifierParams(colorParams.lumaQualifier);
747
+ if (qualParams.length > 0) {
748
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.QUALIFIER, qualParams));
749
+ }
750
+ }
751
+ if (colorParams) {
752
+ const win = buildWindowParams(colorParams.window);
753
+ if (win.transform.length > 0) {
754
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.POWER_WINDOW, win.transform));
755
+ }
756
+ if (win.softMask.length > 0) {
757
+ // Linear-window softness mask (0x0870xxxx) lives under corrector type 3 in live data.
758
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.WINDOW_SOFTNESS, win.softMask));
759
+ }
760
+ if (win.gradient.length > 0) {
761
+ // Gradient window (0x08F0xxxx) lives under compound corrector type 65554.
762
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.GRADIENT_WINDOW, win.gradient));
763
+ }
764
+ if (win.shape.length > 0) {
765
+ // Polygon/curve freeform shape (0x08D0xxxx vertex rings) lives under corrector
766
+ // type 6 in live data (the registry's ct6 "OFFSET" label predates that finding).
767
+ correctorBlocks.push(buildCorrectorBlock(6, win.shape));
768
+ }
769
+ }
770
+ if (colorParams && (colorParams.matteFinesse || colorParams.key)) {
771
+ // ct9 hosts BOTH Matte Finesse (0x0C30002x, DRX = UI/100) and the Key palette
772
+ // (0x0C30001x, identity — live-swept 2026-07-02). One shared block.
773
+ const { MATTE_FINESSE, KEY_PALETTE } = drxParams;
774
+ const mf = colorParams.matteFinesse || {};
775
+ const mfMap = [
776
+ [MATTE_FINESSE.DENOISE, mf.denoise],
777
+ [MATTE_FINESSE.BLACK_CLIP, mf.blackClip],
778
+ [MATTE_FINESSE.WHITE_CLIP, mf.whiteClip],
779
+ [MATTE_FINESSE.IN_OUT_RATIO, mf.inOutRatio],
780
+ [MATTE_FINESSE.CLEAN_BLACK, mf.cleanBlack],
781
+ [MATTE_FINESSE.CLEAN_WHITE, mf.cleanWhite],
782
+ [MATTE_FINESSE.MORPH_RADIUS, mf.morphRadius],
783
+ [MATTE_FINESSE.PRE_FILTER, mf.preFilter],
784
+ [MATTE_FINESSE.POST_FILTER, mf.postFilter],
785
+ [MATTE_FINESSE.SHADOW, mf.shadow],
786
+ [MATTE_FINESSE.MIDTONE, mf.midtone],
787
+ [MATTE_FINESSE.HIGHLIGHT, mf.highlight],
788
+ ];
789
+ const ct9Params = [];
790
+ for (const [id, uiVal] of mfMap) {
791
+ if (uiVal !== undefined) ct9Params.push(createParameterEntry(id, uiVal / 100));
792
+ }
793
+ const key = colorParams.key || {};
794
+ const keyMap = [
795
+ [KEY_PALETTE.INPUT_GAIN, key.inputGain, 1],
796
+ [KEY_PALETTE.INPUT_OFFSET, key.inputOffset, 0],
797
+ [KEY_PALETTE.OUTPUT_GAIN, key.outputGain, 1],
798
+ [KEY_PALETTE.OUTPUT_OFFSET, key.outputOffset, 0],
799
+ ];
800
+ for (const [id, uiVal, def] of keyMap) {
801
+ if (uiVal !== undefined && uiVal !== def) ct9Params.push(createParameterEntry(id, uiVal));
802
+ }
803
+ if (ct9Params.length > 0) {
804
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.MATTE_FINESSE, ct9Params));
805
+ }
806
+ }
807
+
808
+ // Blur palette (ct1 scalars, live-swept 2026-07-02): radius/hvRatio stored (UI−0.5)×2,
809
+ // scaling identity. Input: colorParams.blur = { radius, hvRatio, scaling } (UI units,
810
+ // 0.5-neutral for radius/hvRatio, 0.25 default for scaling). Written per-RGB (linked).
811
+ if (colorParams && colorParams.blur) {
812
+ const { BLUR_PALETTE } = drxParams;
813
+ const b = colorParams.blur;
814
+ const blurParams = [];
815
+ if (b.radius !== undefined && b.radius !== 0.5) {
816
+ const v = (b.radius - 0.5) * 2;
817
+ for (const id of [BLUR_PALETTE.RADIUS_R, BLUR_PALETTE.RADIUS_G, BLUR_PALETTE.RADIUS_B]) blurParams.push(createParameterEntry(id, v));
818
+ }
819
+ if (b.hvRatio !== undefined && b.hvRatio !== 0.5) {
820
+ const v = (b.hvRatio - 0.5) * 2;
821
+ for (const id of [BLUR_PALETTE.HV_RATIO_R, BLUR_PALETTE.HV_RATIO_G, BLUR_PALETTE.HV_RATIO_B]) blurParams.push(createParameterEntry(id, v));
822
+ }
823
+ if (b.scaling !== undefined && b.scaling !== 0.25) {
824
+ for (const id of [BLUR_PALETTE.SCALING_R, BLUR_PALETTE.SCALING_G, BLUR_PALETTE.SCALING_B]) blurParams.push(createParameterEntry(id, b.scaling));
825
+ }
826
+ if (blurParams.length > 0) {
827
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.PRIMARY, blurParams));
828
+ }
829
+ }
830
+
831
+ // Motion Effects palette (NEW corrector type ct15, live-swept 2026-07-02). Identity
832
+ // params write 1:1; frames writes the observed varint form (UI frames × 2 —
833
+ // single-point hypothesis, matches the keyframe half-frame convention).
834
+ // temporalMotion/motionBlur scales CONFIRMED by three-point panel capture 2026-07-03:
835
+ // temporalMotion: stored = 0.28×UI + 2.0 (UI 35→11.8, 60→18.8, 80→24.4 exact)
836
+ // motionBlur: stored = 0.0099×UI (UI 50→0.495, 25→0.2475 exact)
837
+ // Callers pass UI panel values; the mapping is applied here.
838
+ if (colorParams && colorParams.motionEffects) {
839
+ const { MOTION_EFFECTS } = drxParams;
840
+ const m = colorParams.motionEffects;
841
+ const meParams = [];
842
+ const meMap = [
843
+ [MOTION_EFFECTS.SPATIAL_LUMA, m.spatialLuma],
844
+ [MOTION_EFFECTS.SPATIAL_CHROMA, m.spatialChroma],
845
+ [MOTION_EFFECTS.SPATIAL_BLEND, m.spatialBlend],
846
+ [MOTION_EFFECTS.TEMPORAL_LUMA, m.temporalLuma],
847
+ [MOTION_EFFECTS.TEMPORAL_CHROMA, m.temporalChroma],
848
+ [MOTION_EFFECTS.TEMPORAL_BLEND, m.temporalBlend],
849
+ [MOTION_EFFECTS.TEMPORAL_MOTION, m.temporalMotion === undefined ? undefined : 0.28 * m.temporalMotion + 2.0],
850
+ [MOTION_EFFECTS.MOTION_BLUR, m.motionBlur === undefined ? undefined : 0.0099 * m.motionBlur],
851
+ ];
852
+ for (const [id, uiVal] of meMap) {
853
+ if (uiVal !== undefined && uiVal !== 0) meParams.push(createParameterEntry(id, uiVal));
854
+ }
855
+ if (m.frames !== undefined && m.frames !== 0) {
856
+ meParams.push(createVarintParameterEntry(MOTION_EFFECTS.FRAMES_FLAG, m.frames * 2));
857
+ }
858
+ if (meParams.length > 0) {
859
+ correctorBlocks.push(buildCorrectorBlock(CORRECTOR_TYPES.MOTION_EFFECTS, meParams));
860
+ }
861
+ }
862
+
863
+ // NOTE: Types 3, 4, 5, 6, 18 are OMITTED from the F2 presence list.
864
+ // While native Resolve DRX files include these as markers for sub-corrector
865
+ // panels (Sat vs Sat, Hue, Lum vs Sat, Offset, Curves), our encoding was
866
+ // causing Resolve to interpret nodes as having HSL qualifiers set.
867
+ // The F1 corrector blocks (Primary + Contrast) are sufficient for the
868
+ // corrections to apply. Users can manually add sub-correctors in Resolve.
869
+ // If we need to encode these in the future, the wire format needs to match
870
+ // Resolve's native packed-varint encoding exactly.
871
+
872
+ // Build F9 node settings: F1[] for corrector blocks, F2 for presence list
873
+ const f9Parts = [];
874
+
875
+ // Add corrector blocks as F1 entries
876
+ for (const block of correctorBlocks) {
877
+ if (block && block.length > 0) {
878
+ f9Parts.push(block); // Already wrapped as F1
879
+ }
880
+ }
881
+
882
+ // Add compact presence list as F2 (raw byte array of type IDs)
883
+ if (presenceTypes.length > 0) {
884
+ const presenceBytes = Buffer.from(presenceTypes);
885
+ f9Parts.push(protoBytes(2, presenceBytes));
886
+ debugLog('[DRX] Added F2 presence list:', presenceTypes);
887
+ }
888
+
889
+ debugLog('[DRX] Total F1 corrector blocks:', correctorBlocks.filter(b => b && b.length > 0).length);
890
+ debugLog('[DRX] Presence types in F2:', presenceTypes.length);
891
+
892
+ if (f9Parts.length > 0) {
893
+ const nodeSettings = protoBytes(9, Buffer.concat(f9Parts));
894
+ parts.push(nodeSettings);
895
+ } else {
896
+ debugLog('[DRX] WARNING: No correctors generated for this node');
897
+ }
898
+
899
+ // F10: node tool list. Default = the bare node marker; when the node carries an
900
+ // OFX spec ({ofx:{pluginId, params, options?}}), emit the full OFX container instead
901
+ // (params are self-describing name/value pairs on the wire — see extract-ofx-params).
902
+ if (colorParams && colorParams.ofx && colorParams.ofx.pluginId) {
903
+ parts.push(buildOFXToolEntry(colorParams.ofx.pluginId, colorParams.ofx.params || {}, colorParams.ofx.options || {}));
904
+ } else {
905
+ // Structure: F10 = {F1 = {F1=0xC0000001, F2={F2=2}}}
906
+ const f10InnerInner = Buffer.concat([
907
+ protoVarint(1, 0xC0000001), // F1 = 0xC0000001
908
+ protoBytes(2, protoVarint(2, 2)), // F2 = {F2=2}
909
+ ]);
910
+ const f10Outer = protoBytes(1, f10InnerInner);
911
+ parts.push(protoBytes(10, f10Outer));
912
+ }
913
+
914
+ // F12: Timestamp - native Resolve analysis shows this IS present in nodes
915
+ const timestamp = Math.floor(Date.now() / 1000);
916
+ parts.push(protoVarint(12, timestamp));
917
+
918
+ return protoBytes(7, Buffer.concat(parts));
919
+ }
920
+
921
+ /**
922
+ * Build Primary Corrector parameters (Lift/Gamma/Gain/Offset)
923
+ */
924
+ function buildPrimaryCorrectorParams(colorParams) {
925
+ const paramEntries = [];
926
+
927
+ debugLog('[DRX] Building primary corrector from params:', JSON.stringify(colorParams, null, 2));
928
+
929
+ // Lift (default 0 — guard against undefined to prevent NaN in protobuf)
930
+ if (colorParams.lift) {
931
+ if (colorParams.lift.r != null && colorParams.lift.r !== 0) { debugLog('[DRX] Adding lift.r:', colorParams.lift.r); paramEntries.push(createParameterEntry(PARAM_IDS.LIFT_R, colorParams.lift.r)); }
932
+ if (colorParams.lift.g != null && colorParams.lift.g !== 0) { debugLog('[DRX] Adding lift.g:', colorParams.lift.g); paramEntries.push(createParameterEntry(PARAM_IDS.LIFT_G, colorParams.lift.g)); }
933
+ if (colorParams.lift.b != null && colorParams.lift.b !== 0) { debugLog('[DRX] Adding lift.b:', colorParams.lift.b); paramEntries.push(createParameterEntry(PARAM_IDS.LIFT_B, colorParams.lift.b)); }
934
+ if (colorParams.lift.master != null && colorParams.lift.master !== 0) { debugLog('[DRX] Adding lift.master:', colorParams.lift.master); paramEntries.push(createParameterEntry(PARAM_IDS.LIFT_MASTER, colorParams.lift.master)); }
935
+ }
936
+
937
+ // Gain (default 1.0 — guard against undefined)
938
+ if (colorParams.gain) {
939
+ if (colorParams.gain.r != null && colorParams.gain.r !== 1) { debugLog('[DRX] Adding gain.r:', colorParams.gain.r); paramEntries.push(createParameterEntry(PARAM_IDS.GAIN_R, colorParams.gain.r)); }
940
+ if (colorParams.gain.g != null && colorParams.gain.g !== 1) { debugLog('[DRX] Adding gain.g:', colorParams.gain.g); paramEntries.push(createParameterEntry(PARAM_IDS.GAIN_G, colorParams.gain.g)); }
941
+ if (colorParams.gain.b != null && colorParams.gain.b !== 1) { debugLog('[DRX] Adding gain.b:', colorParams.gain.b); paramEntries.push(createParameterEntry(PARAM_IDS.GAIN_B, colorParams.gain.b)); }
942
+ if (colorParams.gain.master != null && colorParams.gain.master !== 1) { debugLog('[DRX] Adding gain.master:', colorParams.gain.master); paramEntries.push(createParameterEntry(PARAM_IDS.GAIN_MASTER, colorParams.gain.master)); }
943
+ }
944
+
945
+ // Gamma (default 0 — guard against undefined)
946
+ if (colorParams.gamma) {
947
+ if (colorParams.gamma.r != null && colorParams.gamma.r !== 0) paramEntries.push(createParameterEntry(PARAM_IDS.GAMMA_R, colorParams.gamma.r));
948
+ if (colorParams.gamma.g != null && colorParams.gamma.g !== 0) paramEntries.push(createParameterEntry(PARAM_IDS.GAMMA_G, colorParams.gamma.g));
949
+ if (colorParams.gamma.b != null && colorParams.gamma.b !== 0) paramEntries.push(createParameterEntry(PARAM_IDS.GAMMA_B, colorParams.gamma.b));
950
+ if (colorParams.gamma.master != null && colorParams.gamma.master !== 0) paramEntries.push(createParameterEntry(PARAM_IDS.GAMMA_MASTER, colorParams.gamma.master));
951
+ }
952
+
953
+ // Offset (default 0)
954
+ if (colorParams.offset) {
955
+ if (colorParams.offset.r != null && colorParams.offset.r !== 0) paramEntries.push(createParameterEntry(PARAM_IDS.OFFSET_R, colorParams.offset.r));
956
+ if (colorParams.offset.g != null && colorParams.offset.g !== 0) paramEntries.push(createParameterEntry(PARAM_IDS.OFFSET_G, colorParams.offset.g));
957
+ if (colorParams.offset.b != null && colorParams.offset.b !== 0) paramEntries.push(createParameterEntry(PARAM_IDS.OFFSET_B, colorParams.offset.b));
958
+ }
959
+
960
+ // Saturation (Resolve Primaries UI: 0-100, unity=50)
961
+ // DRX ENCODING: float multiplier where 1.0 = unity (50 on UI)
962
+ // Conversion: UI value / 50 = DRX float (0→0.0, 50→1.0, 70→1.4, 100→2.0)
963
+ // Discovered via DRX AutoResearch 2026-03-17: manual Sat 70 in Resolve → 1.4 in DRX body.
964
+ if (colorParams.saturation !== undefined && colorParams.saturation !== 50) {
965
+ const satFloat = colorParams.saturation / 50;
966
+ debugLog('[DRX] Adding saturation:', colorParams.saturation, '→ DRX float:', satFloat);
967
+ paramEntries.push(createParameterEntry(PARAM_IDS.SATURATION_PRIMARY, satFloat));
968
+ } else {
969
+ debugLog('[DRX] Skipping saturation - value:', colorParams.saturation, '(unity=50)');
970
+ }
971
+
972
+ // Hue Rotate (0-100 UI, default 50, DRX encoding: (UI - 50) / 50)
973
+ if (colorParams.hueRotate !== undefined && colorParams.hueRotate !== 50) {
974
+ const hueFloat = (colorParams.hueRotate - 50) / 50;
975
+ debugLog('[DRX] Adding hueRotate:', colorParams.hueRotate, '→ DRX float:', hueFloat);
976
+ paramEntries.push(createParameterEntry(PARAM_IDS.HUE_ROTATE, hueFloat));
977
+ }
978
+
979
+ // Temperature (default 0)
980
+ if (colorParams.temperature !== undefined && colorParams.temperature !== 0) {
981
+ paramEntries.push(createParameterEntry(PARAM_IDS.TEMPERATURE, colorParams.temperature));
982
+ }
983
+
984
+ // Tint (default 0)
985
+ if (colorParams.tint !== undefined && colorParams.tint !== 0) {
986
+ paramEntries.push(createParameterEntry(PARAM_IDS.TINT, colorParams.tint));
987
+ }
988
+
989
+ // Midtone Detail (default 0)
990
+ // Scaling happens in parseAdjustments (simple mode ×100, direct mode pass-through)
991
+ if (colorParams.midtoneDetail !== undefined && colorParams.midtoneDetail !== 0) {
992
+ debugLog('[DRX] Adding midtoneDetail:', colorParams.midtoneDetail);
993
+ paramEntries.push(createParameterEntry(PARAM_IDS.MIDTONE_DETAIL, colorParams.midtoneDetail));
994
+ }
995
+
996
+ // Contrast (default 1.0)
997
+ if (colorParams.contrast !== undefined && colorParams.contrast !== 1) {
998
+ debugLog('[DRX] Adding contrast to Primary block via LOG_CONTRAST:', colorParams.contrast);
999
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_CONTRAST, colorParams.contrast));
1000
+ }
1001
+
1002
+ // Pivot (default 0.435)
1003
+ if (colorParams.pivot !== undefined && colorParams.pivot !== 0.435) {
1004
+ debugLog('[DRX] Adding pivot to Primary block:', colorParams.pivot);
1005
+ paramEntries.push(createParameterEntry(PARAM_IDS.PIVOT_PRIMARY, colorParams.pivot));
1006
+ }
1007
+
1008
+ // Color Boost (default 0)
1009
+ if (colorParams.colorBoost !== undefined && colorParams.colorBoost !== 0) {
1010
+ debugLog('[DRX] Adding colorBoost to Primary block:', colorParams.colorBoost);
1011
+ paramEntries.push(createParameterEntry(PARAM_IDS.COLOR_BOOST, colorParams.colorBoost));
1012
+ }
1013
+
1014
+ // Soft Clip High (default 1.0)
1015
+ if (colorParams.softClipHigh !== undefined && colorParams.softClipHigh !== 1) {
1016
+ const scVal = colorParams.softClipHigh;
1017
+ debugLog('[DRX] Adding softClipHigh R/G/B to Primary block:', scVal);
1018
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_HIGH_R, scVal));
1019
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_HIGH_G, scVal));
1020
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_HIGH_B, scVal));
1021
+ }
1022
+
1023
+ // Soft Clip Low (default 0)
1024
+ if (colorParams.softClipLow !== undefined && colorParams.softClipLow !== 0) {
1025
+ const scVal = colorParams.softClipLow;
1026
+ debugLog('[DRX] Adding softClipLow R/G/B to Primary block:', scVal);
1027
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_LOW_R, scVal));
1028
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_LOW_G, scVal));
1029
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_LOW_B, scVal));
1030
+ }
1031
+
1032
+ // Soft Clip Low Soft (default 0, encoding: UI / 50)
1033
+ if (colorParams.softClipLowSoft !== undefined && colorParams.softClipLowSoft !== 0) {
1034
+ const scVal = colorParams.softClipLowSoft / 50;
1035
+ debugLog('[DRX] Adding softClipLowSoft R/G/B:', colorParams.softClipLowSoft, '→', scVal);
1036
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_LOW_SOFT_R, scVal));
1037
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_LOW_SOFT_G, scVal));
1038
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_LOW_SOFT_B, scVal));
1039
+ }
1040
+
1041
+ // Soft Clip High Soft (default 0, encoding: UI / 50)
1042
+ if (colorParams.softClipHighSoft !== undefined && colorParams.softClipHighSoft !== 0) {
1043
+ const scVal = colorParams.softClipHighSoft / 50;
1044
+ debugLog('[DRX] Adding softClipHighSoft R/G/B:', colorParams.softClipHighSoft, '→', scVal);
1045
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_HIGH_SOFT_R, scVal));
1046
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_HIGH_SOFT_G, scVal));
1047
+ paramEntries.push(createParameterEntry(PARAM_IDS.SOFT_CLIP_HIGH_SOFT_B, scVal));
1048
+ }
1049
+
1050
+ // Contrast High Range (default 0.550 in UI, INVERTED encoding: DRX = 1.0 - UI)
1051
+ if (colorParams.contrastHighRange !== undefined && Math.abs(colorParams.contrastHighRange - 0.55) > 0.001) {
1052
+ const drxVal = 1.0 - colorParams.contrastHighRange;
1053
+ debugLog('[DRX] Adding contrastHighRange to Primary block: UI', colorParams.contrastHighRange, '→ DRX', drxVal);
1054
+ paramEntries.push(createParameterEntry(PARAM_IDS.CONTRAST_HIGH_RANGE_PRIMARY, drxVal));
1055
+ }
1056
+
1057
+ // Contrast Low Range (default 0.333)
1058
+ if (colorParams.contrastLowRange !== undefined && Math.abs(colorParams.contrastLowRange - 0.333) > 0.001) {
1059
+ debugLog('[DRX] Adding contrastLowRange to Primary block:', colorParams.contrastLowRange);
1060
+ paramEntries.push(createParameterEntry(PARAM_IDS.CONTRAST_LOW_RANGE_PRIMARY, colorParams.contrastLowRange));
1061
+ }
1062
+
1063
+ // Black Offset (default 0)
1064
+ if (colorParams.blackOffset !== undefined && colorParams.blackOffset !== 0) {
1065
+ debugLog('[DRX] Adding blackOffset:', colorParams.blackOffset);
1066
+ paramEntries.push(createParameterEntry(PARAM_IDS.HDR_BLACK_OFFSET, colorParams.blackOffset));
1067
+ }
1068
+
1069
+ // ═══════════════════════════════════════════════════════════════════════════
1070
+ // LOG WHEELS (offset-style grading) - correctorType: 1 (Primary)
1071
+ // These are part of the Primary corrector, NOT contrast corrector
1072
+ // ═══════════════════════════════════════════════════════════════════════════
1073
+
1074
+ // Log Shadow (Low)
1075
+ if (colorParams.logShadow) {
1076
+ if (colorParams.logShadow.r != null && colorParams.logShadow.r !== 0) {
1077
+ debugLog('[DRX] Adding logShadow.r:', colorParams.logShadow.r);
1078
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_SHADOW_R, colorParams.logShadow.r));
1079
+ }
1080
+ if (colorParams.logShadow.g != null && colorParams.logShadow.g !== 0) {
1081
+ debugLog('[DRX] Adding logShadow.g:', colorParams.logShadow.g);
1082
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_SHADOW_G, colorParams.logShadow.g));
1083
+ }
1084
+ if (colorParams.logShadow.b != null && colorParams.logShadow.b !== 0) {
1085
+ debugLog('[DRX] Adding logShadow.b:', colorParams.logShadow.b);
1086
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_SHADOW_B, colorParams.logShadow.b));
1087
+ }
1088
+ }
1089
+
1090
+ // Log Midtone (Mid)
1091
+ if (colorParams.logMid) {
1092
+ if (colorParams.logMid.r != null && colorParams.logMid.r !== 0) {
1093
+ debugLog('[DRX] Adding logMid.r:', colorParams.logMid.r);
1094
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_MIDTONE_R, colorParams.logMid.r));
1095
+ }
1096
+ if (colorParams.logMid.g != null && colorParams.logMid.g !== 0) {
1097
+ debugLog('[DRX] Adding logMid.g:', colorParams.logMid.g);
1098
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_MIDTONE_G, colorParams.logMid.g));
1099
+ }
1100
+ if (colorParams.logMid.b != null && colorParams.logMid.b !== 0) {
1101
+ debugLog('[DRX] Adding logMid.b:', colorParams.logMid.b);
1102
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_MIDTONE_B, colorParams.logMid.b));
1103
+ }
1104
+ }
1105
+
1106
+ // Log Highlight (High)
1107
+ if (colorParams.logHigh) {
1108
+ if (colorParams.logHigh.r != null && colorParams.logHigh.r !== 0) {
1109
+ debugLog('[DRX] Adding logHigh.r:', colorParams.logHigh.r);
1110
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_HIGHLIGHT_R, colorParams.logHigh.r));
1111
+ }
1112
+ if (colorParams.logHigh.g != null && colorParams.logHigh.g !== 0) {
1113
+ debugLog('[DRX] Adding logHigh.g:', colorParams.logHigh.g);
1114
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_HIGHLIGHT_G, colorParams.logHigh.g));
1115
+ }
1116
+ if (colorParams.logHigh.b != null && colorParams.logHigh.b !== 0) {
1117
+ debugLog('[DRX] Adding logHigh.b:', colorParams.logHigh.b);
1118
+ paramEntries.push(createParameterEntry(PARAM_IDS.LOG_HIGHLIGHT_B, colorParams.logHigh.b));
1119
+ }
1120
+ }
1121
+
1122
+ // ═══════════════════════════════════════════════════════════════════════════
1123
+ // RGB MIXER (3x3 channel mixing matrix)
1124
+ // Param IDs trained 2026-03-17 from rgbmixer_1.1.2.drx
1125
+ // ═══════════════════════════════════════════════════════════════════════════
1126
+ if (colorParams.rgbMixer) {
1127
+ const mx = colorParams.rgbMixer;
1128
+ // Identity matrix defaults: diagonal=1.0, off-diagonal=0.0
1129
+ const entries = [
1130
+ { id: PARAM_IDS.RGB_MIXER_RR, val: mx.rr, def: 1.0 },
1131
+ { id: PARAM_IDS.RGB_MIXER_GR, val: mx.gr, def: 0.0 },
1132
+ { id: PARAM_IDS.RGB_MIXER_BR, val: mx.br, def: 0.0 },
1133
+ { id: PARAM_IDS.RGB_MIXER_RG, val: mx.rg, def: 0.0 },
1134
+ { id: PARAM_IDS.RGB_MIXER_GG, val: mx.gg, def: 1.0 },
1135
+ { id: PARAM_IDS.RGB_MIXER_BG, val: mx.bg, def: 0.0 },
1136
+ { id: PARAM_IDS.RGB_MIXER_RB, val: mx.rb, def: 0.0 },
1137
+ { id: PARAM_IDS.RGB_MIXER_GB, val: mx.gb, def: 0.0 },
1138
+ { id: PARAM_IDS.RGB_MIXER_BB, val: mx.bb, def: 1.0 },
1139
+ ];
1140
+ // Always write all 9 values when RGB mixer is specified (Resolve expects complete matrix)
1141
+ for (const { id, val, def } of entries) {
1142
+ const v = val !== undefined ? val : def;
1143
+ paramEntries.push(createParameterEntry(id, v));
1144
+ }
1145
+ debugLog('[DRX] RGB Mixer: RR=' + (mx.rr ?? 1) + ' GG=' + (mx.gg ?? 1) + ' BB=' + (mx.bb ?? 1));
1146
+ }
1147
+
1148
+ // Highlights slider (default 0)
1149
+ if (colorParams.highlights !== undefined && colorParams.highlights !== 0) {
1150
+ debugLog('[DRX] Adding highlights:', colorParams.highlights);
1151
+ paramEntries.push(createParameterEntry(PARAM_IDS.HIGHLIGHTS, colorParams.highlights));
1152
+ }
1153
+
1154
+ // Shadows slider (direct value, e.g., 50)
1155
+ if (colorParams.shadows !== undefined && colorParams.shadows !== 0) {
1156
+ debugLog('[DRX] Adding shadows:', colorParams.shadows);
1157
+ paramEntries.push(createParameterEntry(PARAM_IDS.SHADOWS, colorParams.shadows));
1158
+ }
1159
+
1160
+ // Lum Mix slider (0.0-1.0, default 0)
1161
+ if (colorParams.lumMixSlider !== undefined && colorParams.lumMixSlider !== 0) {
1162
+ debugLog('[DRX] Adding lumMixSlider:', colorParams.lumMixSlider);
1163
+ paramEntries.push(createParameterEntry(PARAM_IDS.LUM_MIX_SLIDER, colorParams.lumMixSlider));
1164
+ }
1165
+
1166
+ // ═══════════════════════════════════════════════════════════════════════════
1167
+ // COLORSLICE global controls (0x86000600–605, ct1) — WRITE PATH ADDED 2026-07-02.
1168
+ // Identity scale for all except Hue, which Resolve stores NEGATED (UI +X → −X);
1169
+ // calibrated live 2026-06-22. Per-vector grid (0x86000606 blob) is decode-only.
1170
+ // ═══════════════════════════════════════════════════════════════════════════
1171
+ if (colorParams.colorSlice) {
1172
+ const CS = drxParams.COLORSLICE;
1173
+ const cs = colorParams.colorSlice;
1174
+ const csMap = [
1175
+ [CS.DENSITY, cs.density, 0],
1176
+ [CS.DENSITY_DEPTH, cs.densityDepth, 0],
1177
+ [CS.SAT, cs.sat, 1],
1178
+ [CS.SAT_BALANCE, cs.satBalance, 0],
1179
+ [CS.SAT_DEPTH, cs.satDepth, 0],
1180
+ [CS.HUE, cs.hue !== undefined ? -cs.hue : undefined, 0], // NEGATED
1181
+ ];
1182
+ let n = 0;
1183
+ for (const [id, val, def] of csMap) {
1184
+ if (val !== undefined && val !== def) { paramEntries.push(createParameterEntry(id, val)); n++; }
1185
+ }
1186
+ if (n) debugLog('[DRX] ColorSlice global params:', n);
1187
+ }
1188
+
1189
+ // ═══════════════════════════════════════════════════════════════════════════
1190
+ // COLOR WARPER (chroma-warp pin list, ct1) — WRITE PATH ADDED 2026-07-02.
1191
+ // Wire (RE'd live 2026-06-22): mode/config varints 0x86000133/136/137 + pins at
1192
+ // 0x86000138 = { F2: { F27: { F1: [ <pin>, … ] } } }; pin = { F1=id, F2/F3=src XY,
1193
+ // F4/F5=dst XY, F6=chromaRange, F7=exposure (omit 0), F8/F9/F10=tonal low/high/pivot }.
1194
+ // All floats identity-scale vs the UI Pin controls.
1195
+ // ═══════════════════════════════════════════════════════════════════════════
1196
+ if (colorParams.colorWarper && Array.isArray(colorParams.colorWarper.pins) && colorParams.colorWarper.pins.length > 0) {
1197
+ const CW = drxParams.COLOR_WARPER;
1198
+ const w = colorParams.colorWarper;
1199
+ // The live chroma-warp fixture carries configA=2 + configB=2 and NO MODE_FLAG param —
1200
+ // mirror it exactly (a mode override is still possible via w.mode).
1201
+ if (w.mode !== undefined) paramEntries.push(createVarintParameterEntry(CW.MODE_FLAG, w.mode));
1202
+ paramEntries.push(createVarintParameterEntry(CW.CONFIG_A, w.configA !== undefined ? w.configA : 2));
1203
+ paramEntries.push(createVarintParameterEntry(CW.CONFIG_B, w.configB !== undefined ? w.configB : 2));
1204
+ const pinMsgs = [];
1205
+ for (const [i, pin] of w.pins.entries()) {
1206
+ const parts = [protoVarint(1, pin.id !== undefined ? pin.id : i + 1)];
1207
+ const f32 = (fieldNum, v) => { if (typeof v === 'number') parts.push(protoFloat32(fieldNum, v)); };
1208
+ f32(2, pin.srcX); f32(3, pin.srcY);
1209
+ f32(4, pin.dstX); f32(5, pin.dstY);
1210
+ f32(6, pin.chromaRange);
1211
+ if (typeof pin.exposure === 'number' && pin.exposure !== 0) f32(7, pin.exposure);
1212
+ f32(8, pin.tonalLow); f32(9, pin.tonalHigh); f32(10, pin.tonalPivot);
1213
+ pinMsgs.push(protoBytes(1, Buffer.concat(parts)));
1214
+ }
1215
+ const envelope = protoBytes(27, Buffer.concat(pinMsgs)); // F27 = pin list container
1216
+ const pinsEntry = protoBytes(3, Buffer.concat([
1217
+ protoVarint(1, CW.PINS),
1218
+ protoBytes(2, envelope),
1219
+ ]));
1220
+ paramEntries.push(pinsEntry);
1221
+ debugLog('[DRX] Color Warper pins:', w.pins.length);
1222
+ }
1223
+
1224
+ debugLog('[DRX] Primary corrector entries count:', paramEntries.length);
1225
+ return paramEntries;
1226
+ }
1227
+
1228
+ /**
1229
+ * Build Contrast Corrector parameters (Contrast, Pivot, Soft Clip)
1230
+ */
1231
+ function buildContrastCorrectorParams(colorParams) {
1232
+ const paramEntries = [];
1233
+
1234
+ // NOTE: ALL params that were formerly in this Type 2 block have been moved to
1235
+ // buildPrimaryCorrectorParams() (Type 1). DRX AutoResearch 2026-03-17 confirmed
1236
+ // that Resolve stores contrast, pivot, colorBoost, softClip, and range controls
1237
+ // in the Primary corrector with TEMP_TINT-style param IDs. Putting them in the
1238
+ // Type 2 Contrast corrector causes Resolve to misinterpret the block as an
1239
+ // HSL Qualifier, corrupting the grade.
1240
+ //
1241
+ // This function now returns empty — the Type 2 corrector block will not be generated.
1242
+ debugLog('[DRX] Contrast corrector (Type 2) empty - all params in Primary (Type 1)');
1243
+ return paramEntries;
1244
+ }
1245
+
1246
+ /**
1247
+ * Build Hue Corrector parameters (Type 4).
1248
+ *
1249
+ * Hue correction adjusts hue rotation per tonal range. The 6 parameter IDs
1250
+ * have been discovered from DRX analysis but their semantic mapping (which
1251
+ * param controls which hue range) is NOT yet confirmed from training data.
1252
+ *
1253
+ * HYPOTHESIZED semantic mapping (needs training session to confirm):
1254
+ * PARAM_1 (139460609) — possibly master hue rotate or red hue shift
1255
+ * PARAM_2 (139460612) — possibly yellow or green hue shift
1256
+ * PARAM_3 (139460613) — possibly green or cyan hue shift
1257
+ * PARAM_4 (139460614) — possibly cyan or blue hue shift
1258
+ * PARAM_5 (139460619) — possibly blue or magenta hue shift
1259
+ * PARAM_6 (139460620) — possibly magenta hue shift or mix
1260
+ *
1261
+ * Usage: Pass raw param values keyed by ID until semantics are confirmed:
1262
+ * { hue: { [HUE.PARAM_1]: 0.5, [HUE.PARAM_3]: -0.2 } }
1263
+ *
1264
+ * @param {object} colorParams - contains `hue` object with paramId keys
1265
+ * @returns {Buffer[]} - encoded parameter entries
1266
+ */
1267
+ function buildHueCorrectorParams(colorParams) {
1268
+ const paramEntries = [];
1269
+ if (!colorParams.hue) return paramEntries;
1270
+
1271
+ const hueParams = colorParams.hue;
1272
+ for (const [paramId, value] of Object.entries(hueParams)) {
1273
+ const id = Number(paramId);
1274
+ if (isNaN(id) || value === undefined || value === 0) continue;
1275
+ paramEntries.push(createParameterEntry(id, value));
1276
+ }
1277
+
1278
+ if (paramEntries.length > 0) {
1279
+ debugLog('[DRX] Hue corrector entries:', paramEntries.length);
1280
+ }
1281
+ return paramEntries;
1282
+ }
1283
+
1284
+ /**
1285
+ * Build Luma vs Saturation parameters (Type 5).
1286
+ *
1287
+ * Controls how saturation is modulated based on luminance. The 11 parameter IDs
1288
+ * have been discovered but their semantic mapping is NOT yet confirmed.
1289
+ *
1290
+ * HYPOTHESIZED: These likely represent control points on a curve where:
1291
+ * - X axis = luminance (dark to bright)
1292
+ * - Y axis = saturation multiplier
1293
+ * - 11 points define the spline shape
1294
+ *
1295
+ * Usage: Pass raw param values keyed by ID:
1296
+ * { lumMix: { [LUM_MIX.PARAM_1]: 0.8, [LUM_MIX.PARAM_5]: 1.2 } }
1297
+ *
1298
+ * @param {object} colorParams - contains `lumMix` object with paramId keys
1299
+ * @returns {Buffer[]} - encoded parameter entries
1300
+ */
1301
+ function buildLumMixCorrectorParams(colorParams) {
1302
+ const paramEntries = [];
1303
+ if (!colorParams.lumMix) return paramEntries;
1304
+
1305
+ const lumParams = colorParams.lumMix;
1306
+ for (const [paramId, value] of Object.entries(lumParams)) {
1307
+ const id = Number(paramId);
1308
+ if (isNaN(id) || value === undefined) continue;
1309
+ paramEntries.push(createParameterEntry(id, value));
1310
+ }
1311
+
1312
+ if (paramEntries.length > 0) {
1313
+ debugLog('[DRX] LumMix corrector entries:', paramEntries.length);
1314
+ }
1315
+ return paramEntries;
1316
+ }
1317
+
1318
+ /**
1319
+ * Build Saturation vs Saturation parameters (Type 3).
1320
+ *
1321
+ * Controls how saturation is modulated based on existing saturation level.
1322
+ * The 7 parameter IDs are discovered but semantics NOT confirmed.
1323
+ *
1324
+ * Usage: Pass raw param values keyed by ID:
1325
+ * { satVsSat: { [SAT_VS_SAT.PARAM_1]: 0.5 } }
1326
+ *
1327
+ * @param {object} colorParams - contains `satVsSat` object with paramId keys
1328
+ * @returns {Buffer[]} - encoded parameter entries
1329
+ */
1330
+ function buildSatVsSatCorrectorParams(colorParams) {
1331
+ const paramEntries = [];
1332
+ if (!colorParams.satVsSat) return paramEntries;
1333
+
1334
+ const satParams = colorParams.satVsSat;
1335
+ for (const [paramId, value] of Object.entries(satParams)) {
1336
+ const id = Number(paramId);
1337
+ if (isNaN(id) || value === undefined) continue;
1338
+ paramEntries.push(createParameterEntry(id, value));
1339
+ }
1340
+
1341
+ if (paramEntries.length > 0) {
1342
+ debugLog('[DRX] SatVsSat corrector entries:', paramEntries.length);
1343
+ }
1344
+ return paramEntries;
1345
+ }
1346
+
1347
+ /**
1348
+ * Build a presence marker parameter entry for corrector types 2-18
1349
+ * These markers tell Resolve the corrector exists but is at neutral/bypass state
1350
+ *
1351
+ * Real Resolve format: F3 { F1=presenceId, F2={ F2=0 } }
1352
+ * Much simpler than originally thought - just a single nested F2=0
1353
+ */
1354
+ function createPresenceMarkerEntry(correctorType) {
1355
+ const presenceId = PRESENCE_MARKER_IDS[correctorType];
1356
+ if (!presenceId) return null;
1357
+
1358
+ // Value message: F2 = 0 (simple varint, matching real Resolve format)
1359
+ // Real Resolve uses: 10 00 = F2 = 0
1360
+ const valueMsg = protoVarint(2, 0);
1361
+
1362
+ // Outer entry: F1=presenceId, F2=valueMsg
1363
+ const entry = Buffer.concat([
1364
+ protoVarint(1, presenceId),
1365
+ protoBytes(2, valueMsg),
1366
+ ]);
1367
+
1368
+ return protoBytes(3, entry);
1369
+ }
1370
+
1371
+ /**
1372
+ * Build a presence marker corrector block (for types 2, 3, 4, 5, 6, 18)
1373
+ * These are minimal correctors that tell Resolve the node has these capabilities
1374
+ */
1375
+ function buildPresenceMarkerCorrectorBlock(correctorType) {
1376
+ const presenceEntry = createPresenceMarkerEntry(correctorType);
1377
+ if (!presenceEntry) return Buffer.alloc(0);
1378
+
1379
+ // Parameter container: F1=1 (header), F3=presence entry
1380
+ const field2Content = Buffer.concat([
1381
+ protoVarint(1, 1),
1382
+ presenceEntry,
1383
+ ]);
1384
+ const field2 = protoBytes(2, field2Content);
1385
+
1386
+ // Outer container
1387
+ const field6 = protoBytes(6, field2);
1388
+
1389
+ // Build corrector block
1390
+ const correctorBlock = Buffer.concat([
1391
+ protoVarint(1, correctorType),
1392
+ protoVarint(3, 1), // Enabled
1393
+ field6,
1394
+ ]);
1395
+
1396
+ return protoBytes(1, correctorBlock);
1397
+ }
1398
+
1399
+ /**
1400
+ * Build a corrector block (wraps parameters in proper protobuf structure)
1401
+ *
1402
+ * Based on training-analyzer.js parser, the structure is:
1403
+ * Field 1 (corrector block wrapper):
1404
+ * Field 1: corrector type ID
1405
+ * Field 3: enabled flag (1 = enabled)
1406
+ * Field 6: outer container
1407
+ * Field 2: parameter container
1408
+ * Field 1: header (value 1)
1409
+ * Field 3[]: parameter entries (Field 1=ID, Field 2={Field 1=float})
1410
+ */
1411
+ function buildCorrectorBlock(correctorType, paramEntries) {
1412
+ if (paramEntries.length === 0) return Buffer.alloc(0);
1413
+
1414
+ // Parameter container: Field 1 = 1 (header), then Field 3[] for each param
1415
+ const field2Content = Buffer.concat([
1416
+ protoVarint(1, 1), // Required header field (F1 = 1)
1417
+ ...paramEntries, // Each param is wrapped in Field 3
1418
+ ]);
1419
+ const field2 = protoBytes(2, field2Content);
1420
+
1421
+ // Outer container: Field 6 wraps Field 2
1422
+ const field6 = protoBytes(6, field2);
1423
+
1424
+ // Build corrector block
1425
+ const correctorBlock = Buffer.concat([
1426
+ protoVarint(1, correctorType), // Corrector type ID
1427
+ protoVarint(3, 1), // Enabled flag
1428
+ field6, // Parameters in F6->F2
1429
+ ]);
1430
+
1431
+ debugLog('[DRX] Built corrector block type:', correctorType, 'with', paramEntries.length, 'params');
1432
+ return protoBytes(1, correctorBlock);
1433
+ }
1434
+
1435
+ /**
1436
+ * Create a node connection
1437
+ */
1438
+ function createConnection(sourceNode, targetNode, index) {
1439
+ const conn = Buffer.concat([
1440
+ protoVarint(1, sourceNode),
1441
+ protoVarint(3, targetNode),
1442
+ protoVarint(5, 64),
1443
+ protoVarint(6, 64),
1444
+ protoVarint(7, index),
1445
+ ]);
1446
+ return protoBytes(8, conn);
1447
+ }
1448
+
1449
+ /**
1450
+ * Create resolution/transform message
1451
+ */
1452
+ function createResolution(width = 1920, height = 1080) {
1453
+ return protoBytes(3, Buffer.concat([
1454
+ protoVarint(1, width),
1455
+ protoVarint(2, height),
1456
+ protoFloat32(3, 1.0),
1457
+ protoVarint(4, width),
1458
+ protoVarint(5, height),
1459
+ protoFloat32(6, 1.0),
1460
+ protoVarint(7, width),
1461
+ protoVarint(8, height),
1462
+ protoVarint(9, 0xFFFFFFFF), // F9 = -1 (required by Resolve, matches native exports)
1463
+ ]));
1464
+ }
1465
+
1466
+ /**
1467
+ * Create Input/Output node markers (Field 9 and Field 10 in main container)
1468
+ * These are required for Resolve to recognize the grade structure
1469
+ *
1470
+ * Based on ULTRA-DEEP analysis of Resolve's 1-6 node DRX exports:
1471
+ * Pattern discovered (using firstNodeId=36 as example):
1472
+ * - Input (F9): F1=66(+30), F2=80, F3={F1=69(+33), F2=64, F3=66(+30), F4=firstNodeId}
1473
+ * - Output (F10): F1=67(+31), F2=64, F3={F1=69+nodeCount, F2=64, F3=67(+31), F4=lastNodeId}
1474
+ *
1475
+ * The input marker values are CONSTANT (don't change with node count)
1476
+ * Only output F3.F1 and F3.F4 change based on node count
1477
+ *
1478
+ * @param {number} firstNodeId - ID of the first node (input connects TO this)
1479
+ * @param {number} lastNodeId - ID of the last node (output connects FROM this)
1480
+ * @param {number} nodeCount - Total number of nodes
1481
+ */
1482
+ function createInputOutputMarkers(firstNodeId = 1, lastNodeId = 1, nodeCount = 1) {
1483
+ // CRITICAL: F9/F10 values must be calculated from actual node IDs
1484
+ //
1485
+ // Analysis of Resolve's native DRX export for node ID 66:
1486
+ // F9.F1 = 72 = 66 + 6
1487
+ // F10.F1 = 73 = 66 + 7
1488
+ // F9.F3.F1 = 88 = 66 + 22
1489
+ // F10.F3.F1 = 89 = 66 + 23
1490
+ //
1491
+ // The formula is:
1492
+ // F9.F1 = firstNodeId + 6
1493
+ // F10.F1 = lastNodeId + 7
1494
+ // F9.F3.F1 = firstNodeId + 22
1495
+ // F10.F3.F1 = lastNodeId + 23
1496
+ //
1497
+ // Using actual node IDs ensures Resolve recognizes the grade structure
1498
+
1499
+ const inputF1 = firstNodeId + 6; // F9.F1 = firstNodeId + 6
1500
+ const outputF1 = lastNodeId + 7; // F10.F1 = lastNodeId + 7
1501
+ const inputInnerF1 = firstNodeId + 22; // F9.F3.F1 = firstNodeId + 22
1502
+ const outputInnerF1 = lastNodeId + 23; // F10.F3.F1 = lastNodeId + 23
1503
+
1504
+ // Field 9: Input node marker
1505
+ // Structure: F1=inputF1, F2=80, F3={F1=inputInnerF1, F2=64, F3=inputF1, F4=firstNodeId}
1506
+ const inputInner = Buffer.concat([
1507
+ protoVarint(1, inputInnerF1), // F1 = firstNodeId + 22
1508
+ protoVarint(2, 64), // F2 = 64
1509
+ protoVarint(3, inputF1), // F3 = same as outer F1
1510
+ protoVarint(4, firstNodeId), // F4 = first node ID (chainStart)
1511
+ ]);
1512
+ const inputContent = Buffer.concat([
1513
+ protoVarint(1, inputF1), // F1 = firstNodeId + 6
1514
+ protoVarint(2, 80), // F2 = 80 (0x50)
1515
+ protoBytes(3, inputInner), // F3 = connection info
1516
+ ]);
1517
+ const inputMarker = protoBytes(9, inputContent);
1518
+
1519
+ // Field 10: Output node marker
1520
+ // Structure: F1=outputF1, F2=64, F3={F1=outputInnerF1, F2=64, F3=outputF1, F4=lastNodeId}
1521
+ const outputInner = Buffer.concat([
1522
+ protoVarint(1, outputInnerF1), // F1 = lastNodeId + 23
1523
+ protoVarint(2, 64), // F2 = 64
1524
+ protoVarint(3, outputF1), // F3 = same as outer F1
1525
+ protoVarint(4, lastNodeId), // F4 = last node ID (chainEnd)
1526
+ ]);
1527
+ const outputContent = Buffer.concat([
1528
+ protoVarint(1, outputF1), // F1 = lastNodeId + 7
1529
+ protoVarint(2, 64), // F2 = 64 (0x40)
1530
+ protoBytes(3, outputInner), // F3 = connection info
1531
+ ]);
1532
+ const outputMarker = protoBytes(10, outputContent);
1533
+
1534
+ return { inputMarker, outputMarker };
1535
+ }
1536
+
1537
+ /**
1538
+ * Generate a timestamp for DRX files (used in Field 12 and top-level Field 4)
1539
+ * Based on analysis, this appears to be a time-based value
1540
+ */
1541
+ function generateTimestamp() {
1542
+ // Use current time in a format similar to what Resolve uses
1543
+ // The sample value was 149252784 which seems to be seconds from some epoch
1544
+ const now = Math.floor(Date.now() / 1000);
1545
+ return now;
1546
+ }
1547
+
1548
+ /**
1549
+ * Generate the static pTrackVer body
1550
+ * From analysis: pTrackVer is IDENTICAL across 1-6 node DRX files
1551
+ * Only F1.F12 (timestamp) changes
1552
+ *
1553
+ * Structure:
1554
+ * - F1.F1 = 0xFFFFFFFF (no version)
1555
+ * - F1.F2 = 1
1556
+ * - F1.F3 = resolution (1920x1080)
1557
+ * - F1.F4 = 310722
1558
+ * - F1.F9 = {F1=2, F2=64} (input marker)
1559
+ * - F1.F10 = {F1=3, F2=64} (output marker)
1560
+ * - F1.F11 = 1
1561
+ * - F1.F12 = timestamp
1562
+ * - F3 = {F4=190, F5=180, F7=1, F8=85} (node position info)
1563
+ */
1564
+ async function generatePTrackVerBody(width = 1920, height = 1080) {
1565
+ const timestamp = generateTimestamp();
1566
+
1567
+ // F1 container
1568
+ const f1Parts = [
1569
+ protoVarint(1, 0xFFFFFFFF), // F1.F1 = 0xFFFFFFFF (no version)
1570
+ protoVarint(2, 1), // F1.F2 = 1
1571
+ createResolution(width, height), // F1.F3 = resolution
1572
+ protoVarint(4, 310722), // F1.F4 = 310722 (constant)
1573
+ ];
1574
+
1575
+ // F1.F9 = input marker (simplified)
1576
+ const f1f9 = protoBytes(9, Buffer.concat([
1577
+ protoVarint(1, 2),
1578
+ protoVarint(2, 64),
1579
+ ]));
1580
+ f1Parts.push(f1f9);
1581
+
1582
+ // F1.F10 = output marker (simplified)
1583
+ const f1f10 = protoBytes(10, Buffer.concat([
1584
+ protoVarint(1, 3),
1585
+ protoVarint(2, 64),
1586
+ ]));
1587
+ f1Parts.push(f1f10);
1588
+
1589
+ f1Parts.push(protoVarint(11, 1)); // F1.F11 = 1
1590
+ f1Parts.push(protoVarint(12, timestamp)); // F1.F12 = timestamp
1591
+
1592
+ const f1Content = protoBytes(1, Buffer.concat(f1Parts));
1593
+
1594
+ // F3 = node position info (matches native Resolve export)
1595
+ const f3Parts = [
1596
+ protoVarint(4, 190), // F3.F4 = 190 (x pos)
1597
+ protoVarint(5, 180), // F3.F5 = 180 (y pos)
1598
+ protoVarint(7, 1), // F3.F7 = 1
1599
+ protoVarint(8, 85), // F3.F8 = 85 (0x55, matches native Resolve export)
1600
+ protoBytes(9, Buffer.alloc(0)), // F3.F9 = empty
1601
+ ];
1602
+
1603
+ // F3.F10 = node metadata
1604
+ const f3f10Inner = protoBytes(1, Buffer.concat([
1605
+ protoVarint(1, 0xC0000001), // F1 = 0xC0000001
1606
+ protoBytes(2, protoVarint(2, 2)), // F2 = {F2=2}
1607
+ ]));
1608
+ f3Parts.push(protoBytes(10, f3f10Inner));
1609
+ f3Parts.push(protoVarint(12, timestamp + 100)); // F3.F12 = timestamp + offset
1610
+
1611
+ const f3Content = protoBytes(3, Buffer.concat(f3Parts));
1612
+
1613
+ // Combine F1 and F3
1614
+ const trackBody = Buffer.concat([f1Content, f3Content]);
1615
+
1616
+ // Compress
1617
+ const zstd = await getZstdCompressor();
1618
+ const compressed = zstd.compress(trackBody);
1619
+ return Buffer.concat([Buffer.from([0x81]), Buffer.from(compressed)]).toString('hex');
1620
+ }
1621
+
1622
+ /**
1623
+ * Generate the complete protobuf body for a DRX grade
1624
+ *
1625
+ * @param {Object} gradeParams - Color grading parameters (single node) OR array of nodes
1626
+ * @param {Object} options - Generation options
1627
+ * @returns {Buffer} - Uncompressed protobuf data
1628
+ */
1629
+ function generateProtobuf(gradeParams, options = {}) {
1630
+ const {
1631
+ width = 1920,
1632
+ height = 1080,
1633
+ label = '',
1634
+ } = options;
1635
+
1636
+ const timestamp = generateTimestamp();
1637
+
1638
+ // Build main content for Field 1 container
1639
+ const containerParts = [
1640
+ protoVarint(1, 16), // Version (always 16)
1641
+ protoVarint(2, 1), // Flag (always 1)
1642
+ createResolution(width, height),
1643
+ ];
1644
+
1645
+ // Determine first and last node IDs for input/output markers
1646
+ let firstNodeId = 1;
1647
+ let lastNodeId = 1;
1648
+
1649
+ // Check if we have multiple nodes or a single grade
1650
+ if (Array.isArray(gradeParams)) {
1651
+ // Multi-node mode
1652
+ const nodeSpacing = 270;
1653
+ const baseX = 190;
1654
+ const baseY = 180;
1655
+
1656
+ lastNodeId = gradeParams.length; // Last node is the count of nodes
1657
+
1658
+ gradeParams.forEach((nodeConfig, index) => {
1659
+ const nodeId = index + 1;
1660
+ const xPos = baseX + (index * nodeSpacing);
1661
+ const yPos = nodeConfig.yPos || baseY;
1662
+ const node = createNode(nodeId, xPos, yPos, nodeConfig.params, {
1663
+ label: nodeConfig.label || `Node ${nodeId}`,
1664
+ enabled: nodeConfig.enabled !== false,
1665
+ });
1666
+ containerParts.push(node);
1667
+ });
1668
+
1669
+ // Create serial connections between nodes
1670
+ for (let i = 1; i < gradeParams.length; i++) {
1671
+ const conn = createConnection(i, i + 1, i);
1672
+ containerParts.push(conn);
1673
+ }
1674
+ } else {
1675
+ // Single node mode (backwards compatible)
1676
+ // SAFETY: if gradeParams hasn't been through parseAdjustments yet (e.g. has
1677
+ // a 'mode' key or semantic-range temperature like 0.5), warn — callers should
1678
+ // always run parseAdjustments() first so scaling and clamping are applied.
1679
+ if (gradeParams.temperature !== undefined && Math.abs(gradeParams.temperature) <= 1 && gradeParams.temperature !== 0) {
1680
+ console.warn('[DRX] WARNING: gradeParams.temperature=' + gradeParams.temperature + ' looks like semantic range (-1 to +1) — did you forget to call parseAdjustments() first?');
1681
+ }
1682
+ const node = createNode(1, 500, 300, gradeParams, {
1683
+ label: label,
1684
+ });
1685
+ containerParts.push(node);
1686
+ // firstNodeId and lastNodeId both stay 1
1687
+ }
1688
+
1689
+ // Determine node count for markers
1690
+ const nodeCount = Array.isArray(gradeParams) ? gradeParams.length : 1;
1691
+
1692
+ // Create input/output markers with correct node IDs and count
1693
+ const { inputMarker, outputMarker } = createInputOutputMarkers(firstNodeId, lastNodeId, nodeCount);
1694
+
1695
+ // Add required input/output markers and metadata
1696
+ containerParts.push(inputMarker);
1697
+ containerParts.push(outputMarker);
1698
+ containerParts.push(protoVarint(11, 1)); // Unknown flag (always 1)
1699
+ containerParts.push(protoVarint(12, timestamp)); // Timestamp
1700
+
1701
+ // Wrap in root Field 1
1702
+ const mainContent = protoBytes(1, Buffer.concat(containerParts));
1703
+
1704
+ // Add root-level F3 (node position info) - matches native format
1705
+ // Structure: F3 = {F4=190, F5=180, F7=1, F8=84, F9=empty, F10={F1={F1=0xC0000001, F2={F2=2}}}, F12=timestamp}
1706
+ const rootF3InnerInner = Buffer.concat([
1707
+ protoVarint(1, 0xC0000001), // F1 = 0xC0000001
1708
+ protoBytes(2, protoVarint(2, 2)), // F2 = {F2=2}
1709
+ ]);
1710
+ const rootF3F10 = protoBytes(1, rootF3InnerInner);
1711
+
1712
+ const rootF3Parts = [
1713
+ protoVarint(4, 190), // F4 = 190 (x pos)
1714
+ protoVarint(5, 180), // F5 = 180 (y pos)
1715
+ protoVarint(7, 1), // F7 = 1
1716
+ protoVarint(8, 84), // F8 = 84 (native analysis shows 0x54)
1717
+ protoBytes(9, Buffer.alloc(0)), // F9 = empty
1718
+ protoBytes(10, rootF3F10), // F10 = node metadata
1719
+ protoVarint(12, timestamp), // F12 = timestamp
1720
+ ];
1721
+ const rootF3 = protoBytes(3, Buffer.concat(rootF3Parts));
1722
+
1723
+ // Combine F1 and F3 at root level
1724
+ const topLevel = Buffer.concat([mainContent, rootF3]);
1725
+
1726
+ return topLevel;
1727
+ }
1728
+
1729
+ /**
1730
+ * Generate a multi-node DRX with custom node graph
1731
+ *
1732
+ * @param {Array} nodes - Array of node configurations
1733
+ * @param {Array} connections - Array of connection objects {from, to}
1734
+ * @param {Object} metadata - DRX metadata
1735
+ * @returns {Promise<string>} - Complete DRX XML content
1736
+ */
1737
+ async function generateMultiNodeDRX(nodes, connections, metadata = {}) {
1738
+ const {
1739
+ label = 'AI Multi-Node Grade',
1740
+ width = 1920,
1741
+ height = 1080,
1742
+ sourceTimeline = 'Timeline 1',
1743
+ sourceTC = '00:00:00:00',
1744
+ recordTC = '01:00:00:00',
1745
+ pTrackVerXml = null, // Preserved from original DRX during merge
1746
+ preserveNodeIds = false, // When true, use node.id as-is (for merge mode)
1747
+ baseNodeId: passedBaseNodeId,
1748
+ firstNodeId: passedFirstNodeId,
1749
+ lastNodeId: passedLastNodeId,
1750
+ } = metadata;
1751
+
1752
+ const timestamp = generateTimestamp();
1753
+
1754
+ // Determine node ID scheme
1755
+ let baseNodeId, firstNodeId, lastNodeId;
1756
+ if (preserveNodeIds && passedBaseNodeId !== undefined) {
1757
+ // MERGE MODE: use preserved IDs from original grade
1758
+ baseNodeId = passedBaseNodeId;
1759
+ firstNodeId = passedFirstNodeId;
1760
+ lastNodeId = passedLastNodeId;
1761
+ debugLog('[DRX] generateMultiNodeDRX: MERGE MODE - preserving node IDs', firstNodeId, '-', lastNodeId);
1762
+ } else {
1763
+ // FRESH MODE: use standard ID scheme starting from 2 (matches production DRX)
1764
+ // Production DRX files NEVER use node ID 1 - minimum is always 2
1765
+ // Analysis of 240 production files shows minNodeId is always >= 2
1766
+ baseNodeId = 1; // So first node is 2 (production pattern)
1767
+ firstNodeId = baseNodeId + 1;
1768
+ lastNodeId = baseNodeId + nodes.length;
1769
+ debugLog('[DRX] generateMultiNodeDRX: FRESH MODE - generating IDs', firstNodeId, '-', lastNodeId);
1770
+ }
1771
+
1772
+ const version = lastNodeId; // Resolve sets version = lastNodeId
1773
+ const nodeCount = nodes.length;
1774
+
1775
+ // Build main content for Field 1 container
1776
+ const containerParts = [
1777
+ protoVarint(1, version), // Version = lastNodeId (Resolve pattern)
1778
+ protoVarint(2, 1), // Flag
1779
+ createResolution(width, height),
1780
+ ];
1781
+
1782
+ // Add all nodes
1783
+ debugLog('[DRX] generateMultiNodeDRX: processing', nodes.length, 'nodes');
1784
+ nodes.forEach((nodeConfig, index) => {
1785
+ // Use node's own ID if preserving, otherwise compute from baseNodeId
1786
+ const nodeId = preserveNodeIds ? nodeConfig.id : (baseNodeId + index + 1);
1787
+ const nodeIndex = index + 1; // 1, 2, 3, ... (F2 field)
1788
+ debugLog(`[DRX] Node ${nodeIndex}: id=${nodeId}, label="${nodeConfig.label}", params:`, nodeConfig.params ? 'present' : 'null');
1789
+ if (nodeConfig.params) {
1790
+ debugLog(`[DRX] Node ${nodeIndex} params keys:`, Object.keys(nodeConfig.params));
1791
+ }
1792
+ const node = createNode(
1793
+ nodeId,
1794
+ nodeConfig.xPos || 190 + (index * 270),
1795
+ nodeConfig.yPos || 180,
1796
+ nodeConfig.params,
1797
+ {
1798
+ label: nodeConfig.label || `Node ${nodeIndex}`,
1799
+ enabled: nodeConfig.enabled !== false,
1800
+ nodeIndex: nodeIndex, // Pass separate index for F2 field
1801
+ }
1802
+ );
1803
+ containerParts.push(node);
1804
+ });
1805
+
1806
+ // Add all connections using Resolve's ID scheme
1807
+ // Connection F7 starts at (firstNodeId - 40) and increments (discovered from Resolve export analysis)
1808
+ // Example: 10-node export with firstNodeId=70 uses F7 = 30, 31, 32...
1809
+ const connBaseF7 = preserveNodeIds ? Math.max(firstNodeId - 40, 3) : 3;
1810
+ connections.forEach((conn, index) => {
1811
+ // In merge mode, connections already have actual node IDs
1812
+ // In fresh mode, connections are 1-based and need baseNodeId offset
1813
+ const fromId = preserveNodeIds ? conn.from : (baseNodeId + conn.from);
1814
+ const toId = preserveNodeIds ? conn.to : (baseNodeId + conn.to);
1815
+ const connIndex = connBaseF7 + index;
1816
+ const connection = createConnection(fromId, toId, connIndex);
1817
+ containerParts.push(connection);
1818
+ });
1819
+
1820
+ // Create input/output markers with correct node IDs and node count
1821
+ const { inputMarker, outputMarker } = createInputOutputMarkers(firstNodeId, lastNodeId, nodeCount);
1822
+
1823
+ // Add required input/output markers and metadata
1824
+ containerParts.push(inputMarker);
1825
+ containerParts.push(outputMarker);
1826
+ containerParts.push(protoVarint(11, 1)); // Unknown flag (always 1)
1827
+ containerParts.push(protoVarint(12, timestamp)); // Timestamp
1828
+
1829
+ // Wrap in root Field 1
1830
+ const mainContent = protoBytes(1, Buffer.concat(containerParts));
1831
+
1832
+ // Add root-level F3 (node position info) - native Resolve includes this
1833
+ // Structure from native analysis:
1834
+ // F3 = {F4=190, F5=180, F7=1, F8=84, F9=empty, F10={F1={F1=0xC0000001, F2={F2=2}}}, F12=timestamp}
1835
+ const rootF3InnerInner = Buffer.concat([
1836
+ protoVarint(1, 0xC0000001), // F1 = 0xC0000001
1837
+ protoBytes(2, protoVarint(2, 2)), // F2 = {F2=2}
1838
+ ]);
1839
+ const rootF3F10 = protoBytes(1, rootF3InnerInner);
1840
+
1841
+ const rootF3Parts = [
1842
+ protoVarint(4, 190), // F4 = 190 (x pos)
1843
+ protoVarint(5, 180), // F5 = 180 (y pos)
1844
+ protoVarint(7, 1), // F7 = 1
1845
+ protoVarint(8, 84), // F8 = 84 (NOT 85 - native analysis shows 0x54 = 84)
1846
+ protoBytes(9, Buffer.alloc(0)), // F9 = empty (native has empty F9)
1847
+ protoBytes(10, rootF3F10), // F10 = node metadata
1848
+ protoVarint(12, timestamp), // F12 = timestamp
1849
+ ];
1850
+ const rootF3 = protoBytes(3, Buffer.concat(rootF3Parts));
1851
+
1852
+ // Combine F1 and F3 at root level
1853
+ const topLevel = Buffer.concat([mainContent, rootF3]);
1854
+
1855
+ // Compress and generate DRX
1856
+ const zstd = await getZstdCompressor();
1857
+ const compressed = zstd.compress(topLevel);
1858
+ const body = Buffer.concat([Buffer.from([0x81]), Buffer.from(compressed)]);
1859
+ const bodyHex = body.toString('hex');
1860
+
1861
+ // Generate UUIDs
1862
+ const { v4: uuidv4 } = require('uuid');
1863
+ const stillId = uuidv4();
1864
+ const clipVersionId = uuidv4();
1865
+
1866
+ const now = new Date().toISOString().replace('Z', '');
1867
+
1868
+ // Build pTrackVer section - always include it (native Resolve DRX always has pTrackVer)
1869
+ // Use preserved one if available, otherwise generate a default one
1870
+ let pTrackVerSection;
1871
+ if (pTrackVerXml) {
1872
+ pTrackVerSection = `
1873
+ ${pTrackVerXml}`;
1874
+ } else {
1875
+ // Generate default pTrackVer body
1876
+ const trackVersionId = require('uuid').v4();
1877
+ const trackBodyHex = await generatePTrackVerBody(width, height);
1878
+ pTrackVerSection = `
1879
+ <pTrackVer>
1880
+ <ListMgt::LmVersion DbId="${trackVersionId}">
1881
+ <FieldsBlob/>
1882
+ <Name/>
1883
+ <HasCorrection>false</HasCorrection>
1884
+ <VerType>1</VerType>
1885
+ <ImplVersion>1</ImplVersion>
1886
+ <IncludedInRecording>true</IncludedInRecording>
1887
+ <FlatPassEnabled>false</FlatPassEnabled>
1888
+ <RGBAOutputEnabled>false</RGBAOutputEnabled>
1889
+ <Body>${trackBodyHex}</Body>
1890
+ <UseVersionClipProcParams>true</UseVersionClipProcParams>
1891
+ </ListMgt::LmVersion>
1892
+ </pTrackVer>`;
1893
+ }
1894
+
1895
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1896
+ <!--DbAppVer="19.1.3.0007" DbPrjVer="14"-->
1897
+ <Gallery::GyStill DbId="${stillId}">
1898
+ <FieldsBlob/>
1899
+ <SrcHint>${sourceTimeline}</SrcHint>
1900
+ <SrcType>1</SrcType>
1901
+ <GalleryPath/>
1902
+ <Label>${label}</Label>
1903
+ <RecTC>${recordTC}</RecTC>
1904
+ <SrcTC>${sourceTC}</SrcTC>
1905
+ <DpxDescriptor>50</DpxDescriptor>
1906
+ <Width>${width}</Width>
1907
+ <Height>${height}</Height>
1908
+ <BitDepth>10</BitDepth>
1909
+ <PAR>1</PAR>
1910
+ <Endianship>1</Endianship>
1911
+ <CreateTime>${now}</CreateTime>
1912
+ <pClipFullVer>
1913
+ <ListMgt::LmVersion DbId="${clipVersionId}">
1914
+ <FieldsBlob/>
1915
+ <Name/>
1916
+ <HasCorrection>true</HasCorrection>
1917
+ <VerType>0</VerType>
1918
+ <ImplVersion>1</ImplVersion>
1919
+ <IncludedInRecording>true</IncludedInRecording>
1920
+ <FlatPassEnabled>false</FlatPassEnabled>
1921
+ <RGBAOutputEnabled>false</RGBAOutputEnabled>
1922
+ <Body>${bodyHex}</Body>
1923
+ <UseVersionClipProcParams>true</UseVersionClipProcParams>
1924
+ </ListMgt::LmVersion>
1925
+ </pClipFullVer>${pTrackVerSection}
1926
+ <PrimaryCCMode>0</PrimaryCCMode>
1927
+ </Gallery::GyStill>
1928
+ `;
1929
+
1930
+ return xml;
1931
+ }
1932
+
1933
+ /**
1934
+ * Generate a DRX Body (compressed format)
1935
+ *
1936
+ * @param {Object} gradeParams - Color grading parameters
1937
+ * @param {Object} options - Generation options
1938
+ * @returns {Promise<Buffer>} - Complete DRX body ([0x81] + ZSTD compressed protobuf)
1939
+ */
1940
+ async function generateDRXBody(gradeParams, options = {}) {
1941
+ // Generate protobuf
1942
+ const protobufData = generateProtobuf(gradeParams, options);
1943
+
1944
+ // Get ZSTD compressor
1945
+ const zstd = await getZstdCompressor();
1946
+
1947
+ // Compress with ZSTD (returns Uint8Array)
1948
+ const compressed = zstd.compress(protobufData);
1949
+
1950
+ // Prepend type byte
1951
+ return Buffer.concat([Buffer.from([0x81]), Buffer.from(compressed)]);
1952
+ }
1953
+
1954
+ /**
1955
+ * Generate complete DRX XML file
1956
+ *
1957
+ * @param {Object} gradeParams - Color grading parameters
1958
+ * @param {Object} metadata - DRX metadata (label, source, etc.)
1959
+ * @returns {Promise<string>} - Complete DRX XML content
1960
+ */
1961
+ async function generateDRX(gradeParams, metadata = {}) {
1962
+ const {
1963
+ label = 'AI Generated Grade',
1964
+ width = 1920,
1965
+ height = 1080,
1966
+ sourceTimeline = 'Timeline 1',
1967
+ sourceTC = '00:00:00:00',
1968
+ recordTC = '01:00:00:00',
1969
+ } = metadata;
1970
+
1971
+ // Generate the body (pass label for single-node mode)
1972
+ const body = await generateDRXBody(gradeParams, { width, height, label });
1973
+ const bodyHex = body.toString('hex');
1974
+
1975
+ // Generate UUIDs
1976
+ const stillId = uuidv4();
1977
+ const versionId = uuidv4();
1978
+ const thumbnailId = uuidv4();
1979
+
1980
+ // Current timestamp
1981
+ const now = new Date().toISOString().replace('Z', '');
1982
+
1983
+ // Build DRX XML
1984
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1985
+ <!--DbAppVer="19.1.3.0007" DbPrjVer="14"-->
1986
+ <Gallery::GyStill DbId="${stillId}">
1987
+ <FieldsBlob/>
1988
+ <SrcHint>${sourceTimeline}</SrcHint>
1989
+ <SrcType>1</SrcType>
1990
+ <GalleryPath/>
1991
+ <Label>${label}</Label>
1992
+ <RecTC>${recordTC}</RecTC>
1993
+ <SrcTC>${sourceTC}</SrcTC>
1994
+ <DpxDescriptor>50</DpxDescriptor>
1995
+ <Width>${width}</Width>
1996
+ <Height>${height}</Height>
1997
+ <BitDepth>10</BitDepth>
1998
+ <PAR>1</PAR>
1999
+ <Endianship>1</Endianship>
2000
+ <CreateTime>${now}</CreateTime>
2001
+ <pClipFullVer>
2002
+ <ListMgt::LmVersion DbId="${versionId}">
2003
+ <FieldsBlob/>
2004
+ <Name/>
2005
+ <HasCorrection>true</HasCorrection>
2006
+ <VerType>0</VerType>
2007
+ <ImplVersion>1</ImplVersion>
2008
+ <IncludedInRecording>true</IncludedInRecording>
2009
+ <FlatPassEnabled>false</FlatPassEnabled>
2010
+ <RGBAOutputEnabled>false</RGBAOutputEnabled>
2011
+ <Body>${bodyHex}</Body>
2012
+ <UseVersionClipProcParams>true</UseVersionClipProcParams>
2013
+ </ListMgt::LmVersion>
2014
+ </pClipFullVer>
2015
+ <PrimaryCCMode>0</PrimaryCCMode>
2016
+ </Gallery::GyStill>
2017
+ `;
2018
+
2019
+ return xml;
2020
+ }
2021
+
2022
+ /**
2023
+ * Parse cinematographer language into grade parameters
2024
+ * Supports both simple mode (temperature, exposure) and per-channel mode (liftR, gainB)
2025
+ *
2026
+ * @param {Object} adjustments - High-level adjustments from AI
2027
+ * @returns {Object} - Grade parameters for DRX generation
2028
+ */
2029
+
2030
+ /**
2031
+ * Hard-clamp all grade parameters to valid DRX ranges.
2032
+ * Uses parameter-ranges.js as the single source of truth.
2033
+ * Called at the end of parseAdjustments for both SIMPLE and DIRECT modes
2034
+ * to prevent out-of-range values from reaching the encoder.
2035
+ *
2036
+ * @param {Object} params - Mutable params object (clamped in-place)
2037
+ */
2038
+ function clampAllParams(params) {
2039
+ // Helper for params without a parameter-ranges entry — uses inline min/max
2040
+ const sc = (val, min, max) => Math.max(min, Math.min(max, val));
2041
+
2042
+ // ── Color Wheels ──────────────────────────────────────────────────────────
2043
+ for (const ch of ['r', 'g', 'b', 'master']) {
2044
+ if (params.lift[ch] !== undefined) params.lift[ch] = drxParams.clamp('lift', ch, params.lift[ch]);
2045
+ if (params.gamma[ch] !== undefined) params.gamma[ch] = drxParams.clamp('gamma', ch, params.gamma[ch]);
2046
+ if (params.gain[ch] !== undefined) params.gain[ch] = drxParams.clamp('gain', ch, params.gain[ch]);
2047
+ }
2048
+ for (const ch of ['r', 'g', 'b']) {
2049
+ if (params.offset[ch] !== undefined) params.offset[ch] = drxParams.clamp('offset', ch, params.offset[ch]);
2050
+ }
2051
+
2052
+ // ── Primary Adjustments ───────────────────────────────────────────────────
2053
+ params.saturation = drxParams.clamp('saturation', 'master', params.saturation);
2054
+ params.temperature = drxParams.clamp('temperature', 'master', params.temperature);
2055
+ params.tint = drxParams.clamp('tint', 'master', params.tint);
2056
+ params.contrast = drxParams.clamp('contrast', 'master', params.contrast);
2057
+ params.pivot = drxParams.clamp('pivotFine', 'master', params.pivot);
2058
+ params.midtoneDetail = drxParams.clamp('midtoneDetail', 'master', params.midtoneDetail);
2059
+
2060
+ // ── Color Boost: 0-100 ────────────────────────────────────────────────────
2061
+ if (params.colorBoost !== undefined) {
2062
+ params.colorBoost = drxParams.clamp('colorBoost', 'master', params.colorBoost);
2063
+ }
2064
+
2065
+ // ── Hue Rotate: 0-100 (UI), default 50 ───────────────────────────────────
2066
+ if (params.hueRotate !== undefined) {
2067
+ params.hueRotate = sc(params.hueRotate, 0, 100);
2068
+ }
2069
+
2070
+ // ── Highlights/Shadows sliders: -100 to +100 ─────────────────────────────
2071
+ if (params.highlights !== undefined) params.highlights = sc(params.highlights, -100, 100);
2072
+ if (params.shadows !== undefined) params.shadows = sc(params.shadows, -100, 100);
2073
+
2074
+ // ── Soft Clips: 0-1 ──────────────────────────────────────────────────────
2075
+ if (params.softClipHigh !== undefined) params.softClipHigh = drxParams.clamp('softClipHigh', 'master', params.softClipHigh);
2076
+ if (params.softClipLow !== undefined) params.softClipLow = drxParams.clamp('softClipLow', 'master', params.softClipLow);
2077
+ if (params.softClipHighSoft !== undefined) params.softClipHighSoft = sc(params.softClipHighSoft, 0, 100);
2078
+ if (params.softClipLowSoft !== undefined) params.softClipLowSoft = sc(params.softClipLowSoft, 0, 100);
2079
+
2080
+ // ── Contrast Range: 0-1 ──────────────────────────────────────────────────
2081
+ if (params.contrastHighRange !== undefined) params.contrastHighRange = drxParams.clamp('highRange', 'master', params.contrastHighRange);
2082
+ if (params.contrastLowRange !== undefined) params.contrastLowRange = drxParams.clamp('lowRange', 'master', params.contrastLowRange);
2083
+
2084
+ // ── Log Wheels: -1 to +1 per channel ─────────────────────────────────────
2085
+ if (params.logShadow) {
2086
+ for (const ch of ['r', 'g', 'b']) {
2087
+ if (params.logShadow[ch] !== undefined) params.logShadow[ch] = drxParams.clamp('logShadow', ch, params.logShadow[ch]);
2088
+ }
2089
+ }
2090
+ if (params.logMid) {
2091
+ for (const ch of ['r', 'g', 'b']) {
2092
+ if (params.logMid[ch] !== undefined) params.logMid[ch] = drxParams.clamp('logMidtone', ch, params.logMid[ch]);
2093
+ }
2094
+ }
2095
+ if (params.logHigh) {
2096
+ for (const ch of ['r', 'g', 'b']) {
2097
+ if (params.logHigh[ch] !== undefined) params.logHigh[ch] = drxParams.clamp('logHighlight', ch, params.logHigh[ch]);
2098
+ }
2099
+ }
2100
+
2101
+ // ── HDR Black Offset: -1 to +1 ───────────────────────────────────────────
2102
+ if (params.blackOffset !== undefined) params.blackOffset = drxParams.clamp('hdrBlackOffset', 'master', params.blackOffset);
2103
+
2104
+ // ── RGB Mixer: each channel -2 to +2 (identity diagonal=1.0) ─────────────
2105
+ if (params.rgbMixer) {
2106
+ for (const key of ['rr', 'gr', 'br', 'rg', 'gg', 'bg', 'rb', 'gb', 'bb']) {
2107
+ if (params.rgbMixer[key] !== undefined) params.rgbMixer[key] = sc(params.rgbMixer[key], -2, 2);
2108
+ }
2109
+ }
2110
+ }
2111
+
2112
+ function parseAdjustments(adjustments) {
2113
+ // Deep copy to avoid mutating NEUTRAL_GRADE
2114
+ const params = {
2115
+ lift: { ...NEUTRAL_GRADE.lift },
2116
+ gamma: { ...NEUTRAL_GRADE.gamma },
2117
+ gain: { ...NEUTRAL_GRADE.gain },
2118
+ offset: { ...NEUTRAL_GRADE.offset },
2119
+ saturation: NEUTRAL_GRADE.saturation,
2120
+ contrast: NEUTRAL_GRADE.contrast,
2121
+ pivot: NEUTRAL_GRADE.pivot,
2122
+ temperature: NEUTRAL_GRADE.temperature,
2123
+ tint: NEUTRAL_GRADE.tint,
2124
+ midtoneDetail: NEUTRAL_GRADE.midtoneDetail,
2125
+ softClipHigh: NEUTRAL_GRADE.softClipHigh,
2126
+ softClipLow: NEUTRAL_GRADE.softClipLow,
2127
+ // Log wheels (offset-style grading)
2128
+ logShadow: { r: 0, g: 0, b: 0 },
2129
+ logMid: { r: 0, g: 0, b: 0 },
2130
+ logHigh: { r: 0, g: 0, b: 0 },
2131
+ // Additional controls
2132
+ colorBoost: 0,
2133
+ contrastHighRange: 0.55, // Resolve default ↑ Rng
2134
+ contrastLowRange: 0.333, // Resolve default ↓ Rng
2135
+ // HDR Global controls
2136
+ blackOffset: 0,
2137
+ };
2138
+
2139
+ // ═══════════════════════════════════════════════════════════════════════════
2140
+ // DIRECT MODE — absolute Resolve UI values (calibration-informed)
2141
+ // When mode='direct', values are Resolve-scale: saturation 0-100 (50=unity),
2142
+ // contrast 0-2 (1=unity), lift/gamma/offset -0.5 to +0.5, gain 0.5-2.0, etc.
2143
+ // ═══════════════════════════════════════════════════════════════════════════
2144
+
2145
+ if (adjustments.mode === 'direct') {
2146
+ // Lift (shadows) — R/G/B/Master: -0.5 to +0.5, unity=0
2147
+ if (adjustments.lift) {
2148
+ if (adjustments.lift.r !== undefined) params.lift.r = adjustments.lift.r;
2149
+ if (adjustments.lift.g !== undefined) params.lift.g = adjustments.lift.g;
2150
+ if (adjustments.lift.b !== undefined) params.lift.b = adjustments.lift.b;
2151
+ if (adjustments.lift.master !== undefined) params.lift.master = adjustments.lift.master;
2152
+ }
2153
+ // Gamma (midtones) — R/G/B/Master: -0.5 to +0.5, unity=0
2154
+ if (adjustments.gamma) {
2155
+ if (adjustments.gamma.r !== undefined) params.gamma.r = adjustments.gamma.r;
2156
+ if (adjustments.gamma.g !== undefined) params.gamma.g = adjustments.gamma.g;
2157
+ if (adjustments.gamma.b !== undefined) params.gamma.b = adjustments.gamma.b;
2158
+ if (adjustments.gamma.master !== undefined) params.gamma.master = adjustments.gamma.master;
2159
+ }
2160
+ // Gain (highlights) — R/G/B/Master: 0.5 to 2.0, unity=1.0
2161
+ if (adjustments.gain) {
2162
+ if (adjustments.gain.r !== undefined) params.gain.r = adjustments.gain.r;
2163
+ if (adjustments.gain.g !== undefined) params.gain.g = adjustments.gain.g;
2164
+ if (adjustments.gain.b !== undefined) params.gain.b = adjustments.gain.b;
2165
+ if (adjustments.gain.master !== undefined) params.gain.master = adjustments.gain.master;
2166
+ }
2167
+ // Offset — R/G/B: -0.2 to +0.2, unity=0
2168
+ if (adjustments.offset) {
2169
+ if (adjustments.offset.r !== undefined) params.offset.r = adjustments.offset.r;
2170
+ if (adjustments.offset.g !== undefined) params.offset.g = adjustments.offset.g;
2171
+ if (adjustments.offset.b !== undefined) params.offset.b = adjustments.offset.b;
2172
+ }
2173
+ // Saturation — 0-100, unity=50
2174
+ if (adjustments.saturation !== undefined) params.saturation = adjustments.saturation;
2175
+ // Contrast — 0-2, unity=1.0 (usable range roughly 0.5-1.1 before clipping)
2176
+ if (adjustments.contrast !== undefined) params.contrast = adjustments.contrast;
2177
+ // Pivot — 0-1, default=0.435
2178
+ if (adjustments.pivot !== undefined) params.pivot = adjustments.pivot;
2179
+ // Temperature — -4000 to +4000, unity=0
2180
+ if (adjustments.temperature !== undefined) params.temperature = adjustments.temperature;
2181
+ // Tint — -100 to +100, unity=0
2182
+ if (adjustments.tint !== undefined) params.tint = adjustments.tint;
2183
+ // Midtone Detail — -100 to +100, unity=0
2184
+ if (adjustments.midtoneDetail !== undefined) params.midtoneDetail = adjustments.midtoneDetail;
2185
+ // Log wheels
2186
+ if (adjustments.logShadow) Object.assign(params.logShadow, adjustments.logShadow);
2187
+ if (adjustments.logMid) Object.assign(params.logMid, adjustments.logMid);
2188
+ if (adjustments.logHigh) Object.assign(params.logHigh, adjustments.logHigh);
2189
+ // RGB Mixer
2190
+ if (adjustments.rgbMixer) params.rgbMixer = adjustments.rgbMixer;
2191
+ // Color Boost — 0-100, unity=0
2192
+ if (adjustments.colorBoost !== undefined) params.colorBoost = adjustments.colorBoost;
2193
+ // Hue Rotate — 0-100, unity=50
2194
+ if (adjustments.hueRotate !== undefined) params.hueRotate = adjustments.hueRotate;
2195
+ // Soft Clips — 0-1 (DRX space)
2196
+ if (adjustments.softClipHigh !== undefined) params.softClipHigh = adjustments.softClipHigh;
2197
+ if (adjustments.softClipLow !== undefined) params.softClipLow = adjustments.softClipLow;
2198
+ // Soft Clip Softness — 0-100 UI (÷50 happens in buildPrimaryCorrectorParams)
2199
+ if (adjustments.softClipHighSoft !== undefined) params.softClipHighSoft = adjustments.softClipHighSoft;
2200
+ if (adjustments.softClipLowSoft !== undefined) params.softClipLowSoft = adjustments.softClipLowSoft;
2201
+ // Contrast Range — 0-1
2202
+ if (adjustments.contrastHighRange !== undefined) params.contrastHighRange = adjustments.contrastHighRange;
2203
+ if (adjustments.contrastLowRange !== undefined) params.contrastLowRange = adjustments.contrastLowRange;
2204
+ // HDR Black Offset — -1 to +1
2205
+ if (adjustments.blackOffset !== undefined) params.blackOffset = adjustments.blackOffset;
2206
+ // Highlights/Shadows sliders — direct values
2207
+ if (adjustments.highlights !== undefined) params.highlights = adjustments.highlights;
2208
+ if (adjustments.shadows !== undefined) params.shadows = adjustments.shadows;
2209
+ // Custom Curves
2210
+ if (adjustments.customCurves) params.customCurves = adjustments.customCurves;
2211
+ // HSL Curves
2212
+ if (adjustments.hslCurves) params.hslCurves = adjustments.hslCurves;
2213
+
2214
+ // Hard clamp all values to valid DRX ranges (same as SIMPLE mode)
2215
+ clampAllParams(params);
2216
+
2217
+ return params;
2218
+ }
2219
+
2220
+ // ═══════════════════════════════════════════════════════════════════════════
2221
+ // SIMPLE MODE (backwards compatible — relative adjustments -1 to +1)
2222
+ // ═══════════════════════════════════════════════════════════════════════════
2223
+
2224
+ // Apply temperature shift (warm/cool)
2225
+ // Sets the actual Resolve Temp parameter (-4000 to +4000, unity=0)
2226
+ // Also applies subtle gain/lift tinting for per-channel looks (unless per-channel specified)
2227
+ if (adjustments.temperature) {
2228
+ const t = adjustments.temperature; // -1 (cool) to +1 (warm)
2229
+ // Map to Resolve's Temp parameter range: -4000 to +4000
2230
+ params.temperature = t * 4000;
2231
+ // Also apply subtle gain/lift tinting unless per-channel overrides are present
2232
+ if (!hasPerChannelGain(adjustments)) {
2233
+ params.gain.r += t * 0.1;
2234
+ params.gain.b -= t * 0.1;
2235
+ params.lift.r += t * 0.05;
2236
+ params.lift.b -= t * 0.05;
2237
+ }
2238
+ }
2239
+
2240
+ // Apply tint shift (green/magenta)
2241
+ // Sets the actual Resolve Tint parameter (-100 to +100, unity=0)
2242
+ if (adjustments.tint) {
2243
+ const t = adjustments.tint; // -1 (green) to +1 (magenta)
2244
+ // Map to Resolve's Tint parameter range: -100 to +100
2245
+ params.tint = t * 100;
2246
+ params.gain.g -= t * 0.1;
2247
+ params.lift.g -= t * 0.05;
2248
+ }
2249
+
2250
+ // Apply contrast
2251
+ // Resolve Primaries Contrast: floating point, 0.0-2.0, unity=1.0
2252
+ if (adjustments.contrast) {
2253
+ params.contrast = 1.0 + (adjustments.contrast * 1.0);
2254
+ }
2255
+
2256
+ // Apply saturation
2257
+ // Resolve Primaries Saturation: 0-100, unity=50
2258
+ if (adjustments.saturation) {
2259
+ params.saturation = 50 + (adjustments.saturation * 50);
2260
+ }
2261
+
2262
+ // Apply exposure (overall brightness) - only if per-channel gain master not specified
2263
+ if (adjustments.exposure && adjustments.gainMaster === undefined) {
2264
+ const e = adjustments.exposure;
2265
+ params.gain.master += e;
2266
+ }
2267
+
2268
+ // Apply shadow lift - only if per-channel lift not specified
2269
+ if (adjustments.shadowLift && !hasPerChannelLift(adjustments)) {
2270
+ const s = adjustments.shadowLift;
2271
+ params.lift.r += s * 0.1;
2272
+ params.lift.g += s * 0.1;
2273
+ params.lift.b += s * 0.1;
2274
+ }
2275
+
2276
+ // Apply highlight compression - only if per-channel gain not specified
2277
+ if (adjustments.highlightCompression && !hasPerChannelGain(adjustments)) {
2278
+ const h = adjustments.highlightCompression;
2279
+ params.gain.r -= h * 0.1;
2280
+ params.gain.g -= h * 0.1;
2281
+ params.gain.b -= h * 0.1;
2282
+ }
2283
+
2284
+ // ═══════════════════════════════════════════════════════════════════════════
2285
+ // PER-CHANNEL PRIMARY WHEELS (for looks like teal/orange)
2286
+ // ═══════════════════════════════════════════════════════════════════════════
2287
+
2288
+ // Lift (shadows) per-channel
2289
+ if (adjustments.liftR !== undefined) params.lift.r += adjustments.liftR;
2290
+ if (adjustments.liftG !== undefined) params.lift.g += adjustments.liftG;
2291
+ if (adjustments.liftB !== undefined) params.lift.b += adjustments.liftB;
2292
+ if (adjustments.liftMaster !== undefined) params.lift.master += adjustments.liftMaster;
2293
+
2294
+ // Gamma (midtones) per-channel
2295
+ if (adjustments.gammaR !== undefined) params.gamma.r += adjustments.gammaR;
2296
+ if (adjustments.gammaG !== undefined) params.gamma.g += adjustments.gammaG;
2297
+ if (adjustments.gammaB !== undefined) params.gamma.b += adjustments.gammaB;
2298
+ if (adjustments.gammaMaster !== undefined) params.gamma.master += adjustments.gammaMaster;
2299
+
2300
+ // Gain (highlights) per-channel
2301
+ if (adjustments.gainR !== undefined) params.gain.r += adjustments.gainR;
2302
+ if (adjustments.gainG !== undefined) params.gain.g += adjustments.gainG;
2303
+ if (adjustments.gainB !== undefined) params.gain.b += adjustments.gainB;
2304
+ if (adjustments.gainMaster !== undefined) params.gain.master += adjustments.gainMaster;
2305
+
2306
+ // Offset (exposure compensation) per-channel
2307
+ // Note: Tool resolver outputs offsetR/offsetG/offsetB
2308
+ if (adjustments.offsetR !== undefined) params.offset.r += adjustments.offsetR;
2309
+ if (adjustments.offsetG !== undefined) params.offset.g += adjustments.offsetG;
2310
+ if (adjustments.offsetB !== undefined) params.offset.b += adjustments.offsetB;
2311
+
2312
+ // ═══════════════════════════════════════════════════════════════════════════
2313
+ // LOG WHEELS (offset-style grading)
2314
+ // ═══════════════════════════════════════════════════════════════════════════
2315
+
2316
+ // Log Shadow
2317
+ if (adjustments.logShadowR !== undefined) params.logShadow.r = adjustments.logShadowR;
2318
+ if (adjustments.logShadowG !== undefined) params.logShadow.g = adjustments.logShadowG;
2319
+ if (adjustments.logShadowB !== undefined) params.logShadow.b = adjustments.logShadowB;
2320
+
2321
+ // Log Mid
2322
+ if (adjustments.logMidR !== undefined) params.logMid.r = adjustments.logMidR;
2323
+ if (adjustments.logMidG !== undefined) params.logMid.g = adjustments.logMidG;
2324
+ if (adjustments.logMidB !== undefined) params.logMid.b = adjustments.logMidB;
2325
+
2326
+ // Log High
2327
+ if (adjustments.logHighR !== undefined) params.logHigh.r = adjustments.logHighR;
2328
+ if (adjustments.logHighG !== undefined) params.logHigh.g = adjustments.logHighG;
2329
+ if (adjustments.logHighB !== undefined) params.logHigh.b = adjustments.logHighB;
2330
+
2331
+ // ═══════════════════════════════════════════════════════════════════════════
2332
+ // ADDITIONAL CONTROLS
2333
+ // ═══════════════════════════════════════════════════════════════════════════
2334
+
2335
+ // Color Boost (intelligent saturation / vibrance)
2336
+ // DRX AutoResearch 2026-03-17: DRX stores raw value (e.g. 50.0 for UI +50).
2337
+ // User-space in autoresearch experiments: -1 to +1, needs scaling to -100 to +100.
2338
+ if (adjustments.colorBoost !== undefined) {
2339
+ params.colorBoost = adjustments.colorBoost * 100;
2340
+ }
2341
+
2342
+ // Contrast range controls
2343
+ if (adjustments.contrastHighRange !== undefined) {
2344
+ params.contrastHighRange = adjustments.contrastHighRange;
2345
+ }
2346
+ if (adjustments.contrastLowRange !== undefined) {
2347
+ params.contrastLowRange = adjustments.contrastLowRange;
2348
+ }
2349
+
2350
+ // Pivot (contrast center point)
2351
+ if (adjustments.pivot !== undefined) {
2352
+ params.pivot = adjustments.pivot;
2353
+ }
2354
+
2355
+ // Midtone detail — scale from user-space (-1 to +1) to Resolve range (-100 to +100)
2356
+ // Matches temperature/tint pattern: all scaling in parseAdjustments, not buildPrimaryCorrectorParams
2357
+ if (adjustments.midtoneDetail !== undefined) {
2358
+ params.midtoneDetail = adjustments.midtoneDetail * 100;
2359
+ }
2360
+
2361
+ // Black Offset (HDR global control)
2362
+ // Resolve range: -1.0 to +1.0, default 0. User-space same as Resolve-space.
2363
+ if (adjustments.blackOffset !== undefined) {
2364
+ params.blackOffset = adjustments.blackOffset;
2365
+ }
2366
+
2367
+ // RGB Mixer (3x3 channel mixing matrix)
2368
+ // User provides { rr, gr, br, rg, gg, bg, rb, gb, bb } in DRX-space (identity = diagonal 1.0)
2369
+ if (adjustments.rgbMixer) {
2370
+ params.rgbMixer = adjustments.rgbMixer;
2371
+ }
2372
+
2373
+ // Highlights/Shadows sliders (direct values, e.g., 50)
2374
+ if (adjustments.highlights !== undefined) {
2375
+ params.highlights = adjustments.highlights;
2376
+ }
2377
+ if (adjustments.shadows !== undefined) {
2378
+ params.shadows = adjustments.shadows;
2379
+ }
2380
+
2381
+ // Custom Curves (YRGB) — pass through control points
2382
+ // Format: { y: [{x, y}], r: [{x, y}], g: [{x, y}], b: [{x, y}] }
2383
+ if (adjustments.customCurves) {
2384
+ params.customCurves = adjustments.customCurves;
2385
+ }
2386
+
2387
+ // HSL Curves — pass through control points
2388
+ // Format: { hueVsHue: [{x, y}], hueVsSat: [{x, y}], ... }
2389
+ if (adjustments.hslCurves) {
2390
+ params.hslCurves = adjustments.hslCurves;
2391
+ }
2392
+
2393
+ // ═══════════════════════════════════════════════════════════════════════════
2394
+ // HARD CLAMP — prevent out-of-range values from reaching the DRX encoder.
2395
+ // Uses parameter-ranges.js as the single source of truth for valid ranges.
2396
+ // This catches overflow from additive stacking (temperature + exposure on gain,
2397
+ // tint side-effects on lift, etc.) and out-of-range user inputs.
2398
+ // ═══════════════════════════════════════════════════════════════════════════
2399
+ clampAllParams(params);
2400
+
2401
+ return params;
2402
+ }
2403
+
2404
+ /**
2405
+ * Check if per-channel lift parameters are specified
2406
+ */
2407
+ function hasPerChannelLift(adjustments) {
2408
+ return adjustments.liftR !== undefined ||
2409
+ adjustments.liftG !== undefined ||
2410
+ adjustments.liftB !== undefined ||
2411
+ adjustments.liftMaster !== undefined;
2412
+ }
2413
+
2414
+ // ═══════════════════════════════════════════════════════════════════════════
2415
+ // STUB ENCODING FUNCTIONS (Awaiting DRX Training)
2416
+ // These functions are ready to encode when parameter IDs are discovered
2417
+ // ═══════════════════════════════════════════════════════════════════════════
2418
+
2419
+ /**
2420
+ * Check if a parameter ID is available (not null/undefined)
2421
+ * @param {*} paramId - Parameter ID to check
2422
+ * @param {string} name - Human-readable name for logging
2423
+ * @returns {boolean}
2424
+ */
2425
+ function isParamIdAvailable(paramId, name) {
2426
+ if (paramId === null || paramId === undefined) {
2427
+ console.warn(`[DRX] Parameter ID not yet captured: ${name} (needs DRX training sample)`);
2428
+ return false;
2429
+ }
2430
+ return true;
2431
+ }
2432
+
2433
+ /**
2434
+ * Encode a single HDR zone's nested structure
2435
+ * HDR zones use nested protobuf with zone name + exposure/saturation/hue fields
2436
+ *
2437
+ * @param {string} zoneName - Zone name ("Dark", "Shadow", "Light", "Highlight", "Global")
2438
+ * @param {Object} zoneParams - { exposure, saturation, colorBalanceX, colorBalanceY }
2439
+ * (also accepts legacy hueAngle/hueSat as aliases for colorBalanceY/colorBalanceX)
2440
+ * @returns {Buffer} - Encoded zone data
2441
+ */
2442
+ function encodeHDRZoneData(zoneName, zoneParams) {
2443
+ const parts = [];
2444
+ const { HDR_ZONE } = drxParams;
2445
+
2446
+ // Zone name string (wire type 0x0a = length-delimited)
2447
+ const nameBytes = Buffer.from(zoneName, 'utf8');
2448
+ parts.push(Buffer.from([HDR_ZONE.WIRE.ZONE_NAME, nameBytes.length]));
2449
+ parts.push(nameBytes);
2450
+
2451
+ // Exposure (wire type 0x15 = fixed32)
2452
+ if (zoneParams.exposure !== undefined && zoneParams.exposure !== 0) {
2453
+ const expBuf = Buffer.alloc(5);
2454
+ expBuf[0] = HDR_ZONE.WIRE.EXPOSURE;
2455
+ expBuf.writeFloatLE(zoneParams.exposure, 1);
2456
+ parts.push(expBuf);
2457
+ }
2458
+
2459
+ // Color Balance Y (wire type 0x1d = F3 fixed32)
2460
+ // Accept both new name (colorBalanceY) and legacy (hueAngle)
2461
+ const cbY = zoneParams.colorBalanceY ?? zoneParams.hueAngle;
2462
+ if (cbY !== undefined && cbY !== 0) {
2463
+ const cbYBuf = Buffer.alloc(5);
2464
+ cbYBuf[0] = HDR_ZONE.WIRE.COLOR_BALANCE_Y;
2465
+ cbYBuf.writeFloatLE(cbY, 1);
2466
+ parts.push(cbYBuf);
2467
+ }
2468
+
2469
+ // Color Balance X (wire type 0x25 = F4 fixed32)
2470
+ // Accept both new name (colorBalanceX) and legacy (hueSat)
2471
+ const cbX = zoneParams.colorBalanceX ?? zoneParams.hueSat;
2472
+ if (cbX !== undefined && cbX !== 0) {
2473
+ const cbXBuf = Buffer.alloc(5);
2474
+ cbXBuf[0] = HDR_ZONE.WIRE.COLOR_BALANCE_X;
2475
+ cbXBuf.writeFloatLE(cbX, 1);
2476
+ parts.push(cbXBuf);
2477
+ }
2478
+
2479
+ // Falloff (wire type 0x2d = F5 fixed32, default 0.2)
2480
+ // Confirmed 2026-03-18: Dark zone falloff=0.50 → field 5 = 0.5, default field 4 = 0.2
2481
+ if (zoneParams.falloff !== undefined) {
2482
+ const falloffBuf = Buffer.alloc(5);
2483
+ falloffBuf[0] = 0x2d; // Field 5, wire type 5 (fixed32)
2484
+ falloffBuf.writeFloatLE(zoneParams.falloff, 1);
2485
+ parts.push(falloffBuf);
2486
+ }
2487
+
2488
+ // Saturation (wire type 0x3d = fixed32)
2489
+ // FIXED 2026-01-17: Always include saturation even at default (1.0) - Resolve expects it
2490
+ const satValue = zoneParams.saturation !== undefined ? zoneParams.saturation : 1.0;
2491
+ const satBuf = Buffer.alloc(5);
2492
+ satBuf[0] = HDR_ZONE.WIRE.SATURATION;
2493
+ satBuf.writeFloatLE(satValue, 1);
2494
+ parts.push(satBuf);
2495
+
2496
+ return Buffer.concat(parts);
2497
+ }
2498
+
2499
+ /**
2500
+ * Build HDR Wheel parameters using nested protobuf structure
2501
+ * TRAINED 2026-01-14 from 13 HDR DRX samples
2502
+ *
2503
+ * HDR Zones use a fundamentally different encoding than standard parameters:
2504
+ * - Zone adjustments container (0x86000305): exposure, color balance X/Y, saturation
2505
+ * - Zone definitions container (0x86000306): range boundaries, falloff
2506
+ * - Zone differentiation via embedded zone name strings
2507
+ *
2508
+ * @param {Object} colorParams - Color parameters containing hdrDark, hdrShadow, etc.
2509
+ * @returns {Array} - Array of parameter entries with nested zone data
2510
+ */
2511
+ function buildHDRWheelParams(colorParams) {
2512
+ const paramEntries = [];
2513
+ const { HDR_ZONE } = drxParams;
2514
+
2515
+ // Map semantic names to DRX zone names
2516
+ const zoneMapping = {
2517
+ hdrBlack: HDR_ZONE.ZONE_NAMES.BLACK,
2518
+ hdrDark: HDR_ZONE.ZONE_NAMES.DARK,
2519
+ hdrShadow: HDR_ZONE.ZONE_NAMES.SHADOW,
2520
+ hdrLight: HDR_ZONE.ZONE_NAMES.LIGHT,
2521
+ hdrHighlight: HDR_ZONE.ZONE_NAMES.HIGHLIGHT,
2522
+ hdrSpecular: HDR_ZONE.ZONE_NAMES.SPECULAR,
2523
+ hdrGlobal: HDR_ZONE.ZONE_NAMES.GLOBAL,
2524
+ };
2525
+
2526
+ // Check each zone and encode if it has non-default values
2527
+ for (const [semanticKey, zoneName] of Object.entries(zoneMapping)) {
2528
+ const zoneParams = colorParams[semanticKey];
2529
+ if (!zoneParams) continue;
2530
+
2531
+ // Check if zone has any non-default values
2532
+ const hasExposure = zoneParams.exposure !== undefined && zoneParams.exposure !== 0;
2533
+ const hasSaturation = zoneParams.saturation !== undefined && zoneParams.saturation !== 1.0;
2534
+ const hasCBX = (zoneParams.colorBalanceX ?? zoneParams.hueSat) !== undefined && (zoneParams.colorBalanceX ?? zoneParams.hueSat ?? 0) !== 0;
2535
+ const hasCBY = (zoneParams.colorBalanceY ?? zoneParams.hueAngle) !== undefined && (zoneParams.colorBalanceY ?? zoneParams.hueAngle ?? 0) !== 0;
2536
+
2537
+ if (hasExposure || hasSaturation || hasCBX || hasCBY) {
2538
+ // Encode this zone's nested structure
2539
+ const zoneData = encodeHDRZoneData(zoneName, zoneParams);
2540
+
2541
+ // Wrap in parameter entry with ZONE_DATA ID
2542
+ // The createParameterEntry function needs to handle this specially for nested data
2543
+ paramEntries.push({
2544
+ paramId: HDR_ZONE.ZONE_DATA,
2545
+ nestedData: zoneData,
2546
+ zoneName: zoneName,
2547
+ isNested: true,
2548
+ });
2549
+
2550
+ debugLog(`[DRX] HDR Zone ${zoneName}: exp=${zoneParams.exposure || 0}, sat=${zoneParams.saturation || 1}, cbX=${zoneParams.colorBalanceX || 0}, cbY=${zoneParams.colorBalanceY || 0}`);
2551
+ }
2552
+ }
2553
+
2554
+ if (paramEntries.length > 0) {
2555
+ debugLog('[DRX] HDR Wheel zones built:', paramEntries.length);
2556
+ }
2557
+ return paramEntries;
2558
+ }
2559
+
2560
+ /**
2561
+ * Build HSL Qualifier parameters (STUB - awaiting training)
2562
+ * Will encode once HSL_QUALIFIER parameter IDs are captured from DRX samples
2563
+ *
2564
+ * @param {Object} qualifierParams - Qualifier settings (hueCenter, hueWidth, satLow, etc.)
2565
+ * @returns {Array} - Array of parameter entries (empty until trained)
2566
+ */
2567
+ /**
2568
+ * Build HSL Qualifier parameters (Corrector Type 2).
2569
+ *
2570
+ * All values should be in UI units (0-100 for percentages, 0-360 for hue).
2571
+ * Internally converts to DRX encoding (÷100).
2572
+ *
2573
+ * TRAINED 2026-03-22: All 12 qualifier IDs + mode flag + softness confirmed.
2574
+ *
2575
+ * @param {Object} qualifierParams - Qualifier settings in UI values
2576
+ * @param {number} [qualifierParams.hueCenter] - Hue center (0-100, maps to 0-360°)
2577
+ * @param {number} [qualifierParams.hueWidth] - Hue width
2578
+ * @param {number} [qualifierParams.hueSoft] - Hue edge softness
2579
+ * @param {number} [qualifierParams.hueSymmetry=50] - Hue symmetry
2580
+ * @param {number} [qualifierParams.satLow] - Saturation low threshold
2581
+ * @param {number} [qualifierParams.satHigh] - Saturation high threshold
2582
+ * @param {number} [qualifierParams.satLowSoft] - Sat low softness
2583
+ * @param {number} [qualifierParams.satHighSoft] - Sat high softness
2584
+ * @param {number} [qualifierParams.lumLow] - Luminance low threshold
2585
+ * @param {number} [qualifierParams.lumHigh] - Luminance high threshold
2586
+ * @param {number} [qualifierParams.lumLowSoft] - Lum low softness
2587
+ * @param {number} [qualifierParams.lumHighSoft] - Lum high softness
2588
+ * @param {number} [qualifierParams.blur] - Blur radius (on primary corrector)
2589
+ * @returns {Array} - Parameter entries for corrector type 2
2590
+ */
2591
+ function buildQualifierParams(qualifierParams) {
2592
+ const paramEntries = [];
2593
+ const { HSL_QUALIFIER } = drxParams;
2594
+
2595
+ if (!qualifierParams) return paramEntries;
2596
+
2597
+ // All qualifier params encode as UI ÷ 100
2598
+ const q = qualifierParams;
2599
+ const add = (id, val) => {
2600
+ if (val !== undefined) paramEntries.push(createParameterEntry(id, val / 100));
2601
+ };
2602
+
2603
+ // Hue selection
2604
+ add(HSL_QUALIFIER.HUE_CENTER, q.hueCenter);
2605
+ add(HSL_QUALIFIER.HUE_WIDTH, q.hueWidth);
2606
+ add(HSL_QUALIFIER.HUE_SYM, q.hueSymmetry !== undefined ? q.hueSymmetry : (q.hueCenter !== undefined ? 50 : undefined));
2607
+ add(HSL_QUALIFIER.HUE_SOFT, q.hueSoft);
2608
+
2609
+ // Saturation selection
2610
+ add(HSL_QUALIFIER.SAT_HIGH, q.satHigh);
2611
+ add(HSL_QUALIFIER.SAT_LOW, q.satLow);
2612
+ add(HSL_QUALIFIER.SAT_HIGH_SOFT, q.satHighSoft);
2613
+ add(HSL_QUALIFIER.SAT_LOW_SOFT, q.satLowSoft);
2614
+
2615
+ // Luminance selection
2616
+ add(HSL_QUALIFIER.LUM_HIGH, q.lumHigh);
2617
+ add(HSL_QUALIFIER.LUM_LOW, q.lumLow);
2618
+ add(HSL_QUALIFIER.LUM_HIGH_SOFT, q.lumHighSoft);
2619
+ add(HSL_QUALIFIER.LUM_LOW_SOFT, q.lumLowSoft);
2620
+
2621
+ // Duplicate hue params (Resolve stores these)
2622
+ if (q.hueWidth !== undefined) add(HSL_QUALIFIER.HUE_WIDTH_DUP, q.hueWidth);
2623
+ if (q.hueSoft !== undefined) add(HSL_QUALIFIER.HUE_SOFT_DUP, q.hueSoft);
2624
+
2625
+ // Duplicate hue params removed from above — already handled
2626
+
2627
+ // Mode flags (wire-confirmed 2026-07-01): BOTH are varint envelopes {F2: n} in live
2628
+ // data — MODE_FLAG (0x0830006F) observed 4; QUALIFIER_MODE (0x88300001) carries the
2629
+ // qualifier mode (HSL=0, RGB=2, luma=4, 3D=6). The old float32 encoding decoded as
2630
+ // null/garbage and was wire-unfaithful.
2631
+ if (paramEntries.length > 0) {
2632
+ paramEntries.push(createVarintParameterEntry(HSL_QUALIFIER.MODE_FLAG, 4));
2633
+ paramEntries.push(createVarintParameterEntry(HSL_QUALIFIER.QUALIFIER_MODE, 0));
2634
+ }
2635
+
2636
+ if (paramEntries.length > 0) {
2637
+ debugLog('[DRX] HSL Qualifier params built:', paramEntries.length);
2638
+ }
2639
+ return paramEntries;
2640
+ }
2641
+
2642
+ /**
2643
+ * Build RGB Qualifier parameters (Corrector Type 2, Mode 2).
2644
+ *
2645
+ * Uses same corrector type as HSL but with QUALIFIER_MODE=2.
2646
+ * Params at 0x08300002-0x0830000D for per-channel R/G/B Low/High/Soft.
2647
+ * All values in UI units (0-100), internally converts ÷100.
2648
+ *
2649
+ * TRAINED 2026-03-16: IDs confirmed in calibration session.
2650
+ *
2651
+ * @param {Object} rgbParams - RGB qualifier settings
2652
+ * @param {number} [rgbParams.rLow] - Red low threshold
2653
+ * @param {number} [rgbParams.rHigh] - Red high threshold
2654
+ * @param {number} [rgbParams.rLowSoft] - Red low softness
2655
+ * @param {number} [rgbParams.rHighSoft] - Red high softness
2656
+ * @param {number} [rgbParams.gLow] - Green low threshold (etc.)
2657
+ * @param {number} [rgbParams.bLow] - Blue low threshold (etc.)
2658
+ * @returns {Array} - Parameter entries for corrector type 2 with mode=2
2659
+ */
2660
+ function buildRGBQualifierParams(rgbParams) {
2661
+ const paramEntries = [];
2662
+ const { HSL_QUALIFIER } = drxParams;
2663
+
2664
+ if (!rgbParams) return paramEntries;
2665
+
2666
+ const q = rgbParams;
2667
+ const add = (id, val) => {
2668
+ if (val !== undefined) paramEntries.push(createParameterEntry(id, val / 100));
2669
+ };
2670
+
2671
+ // Red channel
2672
+ add(HSL_QUALIFIER.RGB_R_LOW, q.rLow);
2673
+ add(HSL_QUALIFIER.RGB_R_HIGH, q.rHigh);
2674
+ add(HSL_QUALIFIER.RGB_R_LOW_SOFT, q.rLowSoft);
2675
+ add(HSL_QUALIFIER.RGB_R_HIGH_SOFT, q.rHighSoft);
2676
+
2677
+ // Green channel
2678
+ add(HSL_QUALIFIER.RGB_G_LOW, q.gLow);
2679
+ add(HSL_QUALIFIER.RGB_G_HIGH, q.gHigh);
2680
+ add(HSL_QUALIFIER.RGB_G_LOW_SOFT, q.gLowSoft);
2681
+ add(HSL_QUALIFIER.RGB_G_HIGH_SOFT, q.gHighSoft);
2682
+
2683
+ // Blue channel
2684
+ add(HSL_QUALIFIER.RGB_B_LOW, q.bLow);
2685
+ add(HSL_QUALIFIER.RGB_B_HIGH, q.bHigh);
2686
+ add(HSL_QUALIFIER.RGB_B_LOW_SOFT, q.bLowSoft);
2687
+ add(HSL_QUALIFIER.RGB_B_HIGH_SOFT, q.bHighSoft);
2688
+
2689
+ // Mode = RGB (2) — varint envelope (wire-confirmed: qualifier-rgb fixture {F2:2})
2690
+ if (paramEntries.length > 0) {
2691
+ paramEntries.push(createVarintParameterEntry(HSL_QUALIFIER.QUALIFIER_MODE, 2));
2692
+ }
2693
+
2694
+ if (paramEntries.length > 0) {
2695
+ debugLog('[DRX] RGB Qualifier params built:', paramEntries.length);
2696
+ }
2697
+ return paramEntries;
2698
+ }
2699
+
2700
+ /**
2701
+ * Build Luma Qualifier parameters (Corrector Type 2, Mode 4).
2702
+ *
2703
+ * Uses same corrector type with QUALIFIER_MODE=4.
2704
+ * Reuses Lum Low/High/Soft params from HSL qualifier.
2705
+ * All values in UI units (0-100), internally converts ÷100.
2706
+ *
2707
+ * @param {Object} lumaParams - Luma qualifier settings
2708
+ * @param {number} [lumaParams.lumLow] - Luminance low threshold
2709
+ * @param {number} [lumaParams.lumHigh] - Luminance high threshold
2710
+ * @param {number} [lumaParams.lumLowSoft] - Lum low softness
2711
+ * @param {number} [lumaParams.lumHighSoft] - Lum high softness
2712
+ * @returns {Array} - Parameter entries for corrector type 2 with mode=4
2713
+ */
2714
+ function buildLumaQualifierParams(lumaParams) {
2715
+ const paramEntries = [];
2716
+ const { HSL_QUALIFIER } = drxParams;
2717
+
2718
+ if (!lumaParams) return paramEntries;
2719
+
2720
+ const q = lumaParams;
2721
+ const add = (id, val) => {
2722
+ if (val !== undefined) paramEntries.push(createParameterEntry(id, val / 100));
2723
+ };
2724
+
2725
+ add(HSL_QUALIFIER.LUM_HIGH, q.lumHigh);
2726
+ add(HSL_QUALIFIER.LUM_LOW, q.lumLow);
2727
+ add(HSL_QUALIFIER.LUM_HIGH_SOFT, q.lumHighSoft);
2728
+ add(HSL_QUALIFIER.LUM_LOW_SOFT, q.lumLowSoft);
2729
+
2730
+ // Mode = Luma (4) — varint envelope (same wire form as the confirmed HSL/RGB modes)
2731
+ if (paramEntries.length > 0) {
2732
+ paramEntries.push(createVarintParameterEntry(HSL_QUALIFIER.QUALIFIER_MODE, 4));
2733
+ }
2734
+
2735
+ if (paramEntries.length > 0) {
2736
+ debugLog('[DRX] Luma Qualifier params built:', paramEntries.length);
2737
+ }
2738
+ return paramEntries;
2739
+ }
2740
+
2741
+ /**
2742
+ * Build Power Window parameters (STUB - awaiting training)
2743
+ * Will encode once POWER_WINDOWS parameter IDs are captured from DRX samples
2744
+ *
2745
+ * @param {Object} windowParams - Window settings (type, centerX, centerY, width, etc.)
2746
+ * @returns {Array} - Array of parameter entries (empty until trained)
2747
+ */
2748
+ /**
2749
+ * Build Power Window parameters (Corrector Type 4 for circle, 65539 for linear, 65554 for gradient).
2750
+ *
2751
+ * TRAINED 2026-03-22: All window types confirmed with correct scaling.
2752
+ * - Circle: type 4, pan/tilt in pixels, softness via SoftRef (UI × 16)
2753
+ * - Linear: type 65539 (0x10003), Soft1-4 in 0x0870xxxx range (UI × 16)
2754
+ * - Gradient: type 65554 (0x10012), 0x08F0xxxx range, softness (UI × 100)
2755
+ *
2756
+ * @param {Object} windowParams - Window settings in UI values
2757
+ * @param {number} windowParams.type - 1=Circle, 2=Linear, 5=Gradient
2758
+ * @param {number} [windowParams.pan=50] - Horizontal position (UI 0-100, 50=center)
2759
+ * @param {number} [windowParams.tilt=50] - Vertical position (UI 0-100, 50=center)
2760
+ * @param {number} [windowParams.size=50] - Size (UI 0-100, 50=default)
2761
+ * @param {number} [windowParams.aspect=50] - Aspect ratio (UI 0-100, 50=1:1)
2762
+ * @param {number} [windowParams.rotate=0] - Rotation degrees
2763
+ * @param {number} [windowParams.soft1=0] - Softness 1
2764
+ * @param {number} [windowParams.soft2=0] - Softness 2 (linear only)
2765
+ * @param {number} [windowParams.soft3=0] - Softness 3 (linear only)
2766
+ * @param {number} [windowParams.soft4=0] - Softness 4 (linear only)
2767
+ * @returns {Array} - Parameter entries for window corrector
2768
+ */
2769
+ function buildWindowParams(windowParams) {
2770
+ const groups = { transform: [], softMask: [], gradient: [], shape: [] };
2771
+ const paramEntries = groups.transform;
2772
+ const { POWER_WINDOWS } = drxParams;
2773
+
2774
+ if (!windowParams || windowParams.type === 0) return groups;
2775
+
2776
+ const w = windowParams;
2777
+
2778
+ // SHAPE MODEL (wire-confirmed 2026-07-01 against the live fixtures): Resolve does NOT
2779
+ // discriminate window shape via 0x88500008 — that flag is a CONSTANT varint {F2:2} in
2780
+ // every shape's fixture (circle/linear/gradient/polygon/curve). Shape is expressed by
2781
+ // WHICH corrector blocks exist: circle = ct4 transform only; linear = ct4 + ct3
2782
+ // softness mask (0x0870xxxx, UI×16); gradient = ct65554 (0x08F0xxxx); polygon/curve =
2783
+ // ct4 + ct6 vertex ring (no write support — decode-only).
2784
+ // The ct4 type flag accompanies circle/linear windows. For vertex shapes (polygon/
2785
+ // curve) it spawns a SEPARATE empty circle window row (live-observed 2026-07-02), so
2786
+ // skip it — the ct6 shape block alone should define the window.
2787
+ if (w.type !== undefined && w.type !== 5 && !(w.type === 3 || w.type === 4)) {
2788
+ paramEntries.push(createVarintParameterEntry(POWER_WINDOWS.WINDOW_TYPE, 2));
2789
+ }
2790
+
2791
+ // TRUE transform scales — live multi-point fit 2026-06-22 (§16a), applied to the
2792
+ // generator 2026-07-01 after the registry window ranges were widened to the real
2793
+ // DRX-internal spans (they previously clamped pan/tilt/softRef to ±1, which is why
2794
+ // the generator had kept placeholder conventions). The same scales are locked in
2795
+ // test/power-window-transform-calibration.test.mjs and mirrored by
2796
+ // extract-power-window.js, so UI-in → UI-out round-trips AND matches Resolve.
2797
+ // rotate = −UI°/180 · aspect = (50−UI)/50 · size = 1+(UI−50)×0.08 ·
2798
+ // pan/tilt = (UI−50)/50 × 4096 (frame-pixel space).
2799
+
2800
+ const isGradient = w.type === 5;
2801
+ const isLinear = w.type === 2;
2802
+ const isVertexShape = (w.type === 3 || w.type === 4) && Array.isArray(w.vertices) && w.vertices.length >= 3;
2803
+
2804
+ if (isVertexShape) {
2805
+ // Polygon (3) / Curve (4) windows carry their freeform shape in a ct6 block
2806
+ // (live-RE'd 2026-06-22, write path added 2026-07-02 — PENDING live rig verify):
2807
+ // 0x08D00002 varint {F2:2} · 0x08D00004 = 0 · three vertex RINGS at
2808
+ // 0x08D00006/07/08 (value envelope F9.F1[] of f32 {x,y} points, FRAME PIXELS from
2809
+ // center) · 0x08D00009/0A = 0 · 0x08D0000B/0C = 1 · identity 3×3 at 0x88D00014
2810
+ // (F10.F1–F9). Ring = bezier corner repetition: first corner ×2, others ×3,
2811
+ // closed with the first corner ×2 (4-corner fixture → 13 points, all three rings
2812
+ // identical for straight edges). 0x08D00010/11 (unknown floats) omitted in v1.
2813
+ const s = groups.shape;
2814
+ const ring = [];
2815
+ w.vertices.forEach((v, i) => {
2816
+ const reps = i === 0 ? 2 : 3;
2817
+ for (let r = 0; r < reps; r++) ring.push(createCurvePoint(v.x, v.y));
2818
+ });
2819
+ ring.push(createCurvePoint(w.vertices[0].x, w.vertices[0].y));
2820
+ ring.push(createCurvePoint(w.vertices[0].x, w.vertices[0].y));
2821
+ const ringEnvelope = protoBytes(9, Buffer.concat(ring)); // F9 { F1[] points }
2822
+ s.push(createVarintParameterEntry(0x08d00002, 2));
2823
+ s.push(createParameterEntry(0x08d00004, 0));
2824
+ for (const ringId of [0x08d00006, 0x08d00007, 0x08d00008]) {
2825
+ s.push(protoBytes(3, Buffer.concat([protoVarint(1, ringId), protoBytes(2, ringEnvelope)])));
2826
+ }
2827
+ s.push(createParameterEntry(0x08d00009, 0));
2828
+ s.push(createParameterEntry(0x08d0000a, 0));
2829
+ s.push(createParameterEntry(0x08d0000b, 1));
2830
+ s.push(createParameterEntry(0x08d0000c, 1));
2831
+ const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1];
2832
+ const matrixMsg = Buffer.concat(IDENTITY.map((v, i) => protoFloat32(i + 1, v)));
2833
+ s.push(protoBytes(3, Buffer.concat([protoVarint(1, 0x88d00014), protoBytes(2, protoBytes(10, matrixMsg))])));
2834
+ debugLog('[DRX] Window vertex shape (ct6):', w.vertices.length, 'corners →', 13, 'ring points');
2835
+ }
2836
+
2837
+ if (isGradient) {
2838
+ // Gradient windows live ENTIRELY in a ct65554 block (0x08F0xxxx). The old code wrote
2839
+ // these ids into the ct4 transform block, where live data never puts them.
2840
+ const GW = drxParams.GRADIENT_WINDOW;
2841
+ const g = groups.gradient;
2842
+ g.push(createVarintParameterEntry(GW.TYPE, 2));
2843
+ if (w.rotate !== undefined && w.rotate !== 0) {
2844
+ g.push(createParameterEntry(GW.ROTATION, -w.rotate / 180));
2845
+ }
2846
+ // Handle positions carry the gradient's placement in the same (UI−50)/50 × 4096
2847
+ // frame-pixel space as the ct4 pan/tilt (fixture: Pan 81 → 2539.52, Tilt 82 → 2621.44).
2848
+ if (w.pan !== undefined && w.pan !== 50) {
2849
+ g.push(createParameterEntry(GW.HANDLE_1_POS, ((w.pan - 50) / 50) * 4096));
2850
+ }
2851
+ if (w.tilt !== undefined && w.tilt !== 50) {
2852
+ g.push(createParameterEntry(GW.HANDLE_2_POS, ((w.tilt - 50) / 50) * 4096));
2853
+ }
2854
+ if (w.soft1 !== undefined && w.soft1 !== 0) {
2855
+ g.push(createParameterEntry(GW.SOFTNESS, w.soft1 * 100));
2856
+ }
2857
+ if (w.opacity !== undefined && w.opacity !== 100) {
2858
+ g.push(createParameterEntry(GW.OPACITY, w.opacity / 100));
2859
+ }
2860
+ debugLog('[DRX] Window params built:', g.length, '(gradient, ct65554)');
2861
+ return groups;
2862
+ }
2863
+
2864
+ // Transform: rotation, stored = −UI°/180 (neutral 0)
2865
+ if (w.rotate !== undefined && w.rotate !== 0) {
2866
+ paramEntries.push(createParameterEntry(POWER_WINDOWS.ROTATE, -w.rotate / 180));
2867
+ }
2868
+
2869
+ // Size: UI 50 = default = DRX 1.0; stored = 1 + (UI−50)×0.08
2870
+ if (w.size !== undefined && w.size !== 50) {
2871
+ paramEntries.push(createParameterEntry(POWER_WINDOWS.SIZE, 1 + (w.size - 50) * 0.08));
2872
+ }
2873
+
2874
+ // Aspect: UI 50 = 1:1 = DRX 0.0; stored = (50−UI)/50
2875
+ if (w.aspect !== undefined && w.aspect !== 50) {
2876
+ paramEntries.push(createParameterEntry(POWER_WINDOWS.ASPECT, (50 - w.aspect) / 50));
2877
+ }
2878
+
2879
+ // Pan/Tilt: stored = (UI−50)/50 × 4096 (frame-pixel offset from center)
2880
+ if (w.pan !== undefined && w.pan !== 50) {
2881
+ paramEntries.push(createParameterEntry(POWER_WINDOWS.PAN, ((w.pan - 50) / 50) * 4096));
2882
+ }
2883
+ if (w.tilt !== undefined && w.tilt !== 50) {
2884
+ paramEntries.push(createParameterEntry(POWER_WINDOWS.TILT, ((w.tilt - 50) / 50) * 4096));
2885
+ }
2886
+
2887
+ // Opacity: /100 (live-confirmed: UI 66 → 0.66)
2888
+ if (w.opacity !== undefined && w.opacity !== 100) {
2889
+ paramEntries.push(createParameterEntry(POWER_WINDOWS.OPACITY, w.opacity / 100));
2890
+ }
2891
+
2892
+ // Softness — UI × 16 (live-confirmed both circle SoftRef and linear Soft1–4).
2893
+ if (isLinear) {
2894
+ // Linear softness mask lives in a ct3 block (0x08700009–0C), NOT the ct4 transform
2895
+ // (linear-window-softness fixture: ct3, ÷16 exact). The old code wrote Soft1–4 into ct4.
2896
+ const m = groups.softMask;
2897
+ if (w.soft1 !== undefined && w.soft1 !== 0) m.push(createParameterEntry(POWER_WINDOWS.SOFT_1, w.soft1 * 16));
2898
+ if (w.soft2 !== undefined && w.soft2 !== 0) m.push(createParameterEntry(POWER_WINDOWS.SOFT_2, w.soft2 * 16));
2899
+ if (w.soft3 !== undefined && w.soft3 !== 0) m.push(createParameterEntry(POWER_WINDOWS.SOFT_3, w.soft3 * 16));
2900
+ if (w.soft4 !== undefined && w.soft4 !== 0) m.push(createParameterEntry(POWER_WINDOWS.SOFT_4, w.soft4 * 16));
2901
+ } else if (w.soft1 !== undefined && w.soft1 !== 0) {
2902
+ // Circle: softness rides SoftRef on the ct4 transform (live: Soft1 67 → 1072).
2903
+ paramEntries.push(createParameterEntry(POWER_WINDOWS.SOFT_REF, w.soft1 * 16));
2904
+ }
2905
+
2906
+ if (paramEntries.length > 0 || groups.softMask.length > 0) {
2907
+ debugLog('[DRX] Window params built:', paramEntries.length, '+', groups.softMask.length, isLinear ? '(linear: ct4 + ct3 mask)' : '(circle: ct4)');
2908
+ }
2909
+ return groups;
2910
+ }
2911
+
2912
+ /**
2913
+ * Check if any secondary correction tools are used (qualifier, window, HDR wheels)
2914
+ * Used to determine if we need to add those corrector blocks
2915
+ *
2916
+ * @param {Object} colorParams - Full color parameters object
2917
+ * @returns {Object} - { hasQualifier, hasWindow, hasHDRWheels }
2918
+ */
2919
+ function hasSecondaryCorrections(colorParams) {
2920
+ const hasQualifier = colorParams.qualifier &&
2921
+ (colorParams.qualifier.hueCenter !== undefined ||
2922
+ colorParams.qualifier.satLow !== undefined ||
2923
+ colorParams.qualifier.lumLow !== undefined);
2924
+
2925
+ const hasWindow = colorParams.window &&
2926
+ colorParams.window.type !== undefined &&
2927
+ colorParams.window.type !== 0;
2928
+
2929
+ const hasHDRWheels = colorParams.hdrDark || colorParams.hdrShadow ||
2930
+ colorParams.hdrLight || colorParams.hdrHighlight || colorParams.hdrGlobal ||
2931
+ colorParams.hdrSpecular;
2932
+
2933
+ return { hasQualifier, hasWindow, hasHDRWheels };
2934
+ }
2935
+
2936
+ /**
2937
+ * Check if per-channel gain parameters are specified
2938
+ */
2939
+ function hasPerChannelGain(adjustments) {
2940
+ return adjustments.gainR !== undefined ||
2941
+ adjustments.gainG !== undefined ||
2942
+ adjustments.gainB !== undefined ||
2943
+ adjustments.gainMaster !== undefined;
2944
+ }
2945
+
2946
+ /**
2947
+ * PNT1: Create parallel node structure for skin protection workflow
2948
+ * Creates: Input -> Splitter -> [Node A, Node B] -> Combiner -> Output
2949
+ *
2950
+ * Parallel nodes allow processing the same input through multiple paths
2951
+ * that are then combined. Common use cases:
2952
+ * - Skin protection: Keep skin tones on path A, apply heavy grade on path B
2953
+ * - Selective processing: Different treatments for shadows vs highlights
2954
+ * - Creative blending: Mix multiple looks together
2955
+ *
2956
+ * @param {Object} options - Configuration options
2957
+ * @param {Object} options.nodeA - First parallel node configuration
2958
+ * @param {Object} options.nodeA.params - Color parameters for node A
2959
+ * @param {string} options.nodeA.label - Label for node A (default: 'Path A')
2960
+ * @param {Object} options.nodeB - Second parallel node configuration
2961
+ * @param {Object} options.nodeB.params - Color parameters for node B
2962
+ * @param {string} options.nodeB.label - Label for node B (default: 'Path B')
2963
+ * @param {number} options.mixRatio - Mix ratio between paths (0=all A, 1=all B, 0.5=equal, default: 0.5)
2964
+ * @param {string} options.combineMode - How to combine paths ('mix' | 'add' | 'multiply', default: 'mix')
2965
+ * @returns {Object} - Parallel node graph structure with nodes and connections
2966
+ */
2967
+ function createParallelNodes(options = {}) {
2968
+ const {
2969
+ nodeA = { params: null, label: 'Path A' },
2970
+ nodeB = { params: null, label: 'Path B' },
2971
+ mixRatio = 0.5,
2972
+ combineMode = 'mix',
2973
+ } = options;
2974
+
2975
+ // Node IDs in the parallel structure (these will be offset by baseNodeId in generateMultiNodeDRX)
2976
+ // Structure: Splitter (1) -> [Node A (2), Node B (3)] -> Combiner (4)
2977
+ const nodes = [
2978
+ {
2979
+ id: 1,
2980
+ label: 'Splitter',
2981
+ type: 'splitter',
2982
+ params: null, // Splitter has no color params
2983
+ xPos: 100,
2984
+ yPos: 180,
2985
+ },
2986
+ {
2987
+ id: 2,
2988
+ label: nodeA.label || 'Path A',
2989
+ type: 'corrector',
2990
+ params: nodeA.params || null,
2991
+ xPos: 370,
2992
+ yPos: 100, // Upper path
2993
+ },
2994
+ {
2995
+ id: 3,
2996
+ label: nodeB.label || 'Path B',
2997
+ type: 'corrector',
2998
+ params: nodeB.params || null,
2999
+ xPos: 370,
3000
+ yPos: 260, // Lower path
3001
+ },
3002
+ {
3003
+ id: 4,
3004
+ label: 'Combiner',
3005
+ type: 'combiner',
3006
+ params: null, // Combiner has no color params
3007
+ mixRatio,
3008
+ combineMode,
3009
+ xPos: 640,
3010
+ yPos: 180,
3011
+ },
3012
+ ];
3013
+
3014
+ // Connections for parallel structure
3015
+ // Splitter outputs to both paths, both paths feed into combiner
3016
+ const connections = [
3017
+ { from: 1, to: 2 }, // Splitter -> Path A
3018
+ { from: 1, to: 3 }, // Splitter -> Path B
3019
+ { from: 2, to: 4 }, // Path A -> Combiner
3020
+ { from: 3, to: 4 }, // Path B -> Combiner
3021
+ ];
3022
+
3023
+ return {
3024
+ type: 'parallel',
3025
+ nodes,
3026
+ connections,
3027
+ metadata: {
3028
+ mixRatio,
3029
+ combineMode,
3030
+ nodeALabel: nodeA.label,
3031
+ nodeBLabel: nodeB.label,
3032
+ },
3033
+ };
3034
+ }
3035
+
3036
+ /**
3037
+ * PNT2: Create layer mixer node for blending
3038
+ * Layer mixers allow blending foreground and background nodes with various blend modes.
3039
+ *
3040
+ * Blend modes:
3041
+ * - normal: Standard opacity blend (foreground over background)
3042
+ * - overlay: Increases contrast, combines multiply and screen
3043
+ * - softLight: Softer version of overlay, good for subtle adjustments
3044
+ * - multiply: Darkens by multiplying colors (black stays black)
3045
+ * - screen: Lightens by inverting, multiplying, inverting again
3046
+ * - add: Additive blending (can blow out highlights)
3047
+ * - subtract: Subtractive blending (can crush blacks)
3048
+ *
3049
+ * @param {string} blendMode - Blend mode ('normal' | 'overlay' | 'softLight' | 'multiply' | 'screen' | 'add' | 'subtract')
3050
+ * @param {number} opacity - Opacity of blend (0-1, where 1 = full effect)
3051
+ * @param {Object} foregroundNode - Foreground node configuration
3052
+ * @param {Object} foregroundNode.params - Color parameters for foreground
3053
+ * @param {string} foregroundNode.label - Label for foreground node
3054
+ * @param {Object} backgroundNode - Background node configuration
3055
+ * @param {Object} backgroundNode.params - Color parameters for background
3056
+ * @param {string} backgroundNode.label - Label for background node
3057
+ * @returns {Object} - Layer mixer node configuration with nodes and connections
3058
+ */
3059
+ function createLayerMixer(blendMode, opacity, foregroundNode, backgroundNode) {
3060
+ // Validate blend mode
3061
+ const validBlendModes = ['normal', 'overlay', 'softLight', 'multiply', 'screen', 'add', 'subtract'];
3062
+ if (!validBlendModes.includes(blendMode)) {
3063
+ console.warn(`[DRX] Invalid blend mode '${blendMode}', defaulting to 'normal'`);
3064
+ blendMode = 'normal';
3065
+ }
3066
+
3067
+ // Clamp opacity to valid range
3068
+ opacity = Math.max(0, Math.min(1, opacity));
3069
+
3070
+ // Map blend mode to DaVinci Resolve layer mixer type ID
3071
+ // These are approximations based on Resolve's internal blend mode encoding
3072
+ const blendModeIds = {
3073
+ normal: 0,
3074
+ overlay: 1,
3075
+ softLight: 2,
3076
+ multiply: 3,
3077
+ screen: 4,
3078
+ add: 5,
3079
+ subtract: 6,
3080
+ };
3081
+
3082
+ const nodes = [
3083
+ {
3084
+ id: 1,
3085
+ label: backgroundNode?.label || 'Background',
3086
+ type: 'corrector',
3087
+ params: backgroundNode?.params || null,
3088
+ xPos: 100,
3089
+ yPos: 180,
3090
+ },
3091
+ {
3092
+ id: 2,
3093
+ label: foregroundNode?.label || 'Foreground',
3094
+ type: 'corrector',
3095
+ params: foregroundNode?.params || null,
3096
+ xPos: 370,
3097
+ yPos: 100, // Foreground above background in node graph
3098
+ },
3099
+ {
3100
+ id: 3,
3101
+ label: `Layer Mixer (${blendMode})`,
3102
+ type: 'layer_mixer',
3103
+ blendMode,
3104
+ blendModeId: blendModeIds[blendMode],
3105
+ opacity,
3106
+ xPos: 640,
3107
+ yPos: 180,
3108
+ },
3109
+ ];
3110
+
3111
+ // Connections: Background and Foreground both feed into Layer Mixer
3112
+ const connections = [
3113
+ { from: 1, to: 3 }, // Background -> Layer Mixer (as background input)
3114
+ { from: 2, to: 3 }, // Foreground -> Layer Mixer (as foreground input)
3115
+ ];
3116
+
3117
+ return {
3118
+ type: 'layer_mixer',
3119
+ nodes,
3120
+ connections,
3121
+ metadata: {
3122
+ blendMode,
3123
+ blendModeId: blendModeIds[blendMode],
3124
+ opacity,
3125
+ foregroundLabel: foregroundNode?.label || 'Foreground',
3126
+ backgroundLabel: backgroundNode?.label || 'Background',
3127
+ },
3128
+ };
3129
+ }
3130
+
3131
+ /**
3132
+ * PNT3: Create outside node (inverse of qualified/windowed area)
3133
+ * Used for: grade everything EXCEPT the qualified area
3134
+ *
3135
+ * Outside nodes are essential for workflows like:
3136
+ * - Sky replacement: Grade the sky, leave the rest untouched
3137
+ * - Subject isolation: Protect skin tones, grade everything else
3138
+ * - Vignette effects: Grade the edges, leave center clean
3139
+ *
3140
+ * The outside node inverts the effect of a qualifier or power window,
3141
+ * applying corrections to the non-selected area.
3142
+ *
3143
+ * @param {Object} qualifierNode - The node containing the qualifier/window
3144
+ * @param {Object} qualifierNode.params - Color parameters of the qualified node
3145
+ * @param {string} qualifierNode.label - Label for the qualified node
3146
+ * @param {Object} qualifierNode.qualifier - HSL qualifier settings (optional)
3147
+ * @param {Object} qualifierNode.window - Power window settings (optional)
3148
+ * @param {Object} outsideParams - Color parameters to apply to the outside (non-qualified) area
3149
+ * @param {Object} outsideParams.params - Color correction parameters for outside
3150
+ * @param {string} outsideParams.label - Label for the outside node
3151
+ * @returns {Object} - Outside node configuration with paired qualifier and outside nodes
3152
+ */
3153
+ function createOutsideNode(qualifierNode, outsideParams) {
3154
+ // Default configurations
3155
+ const qualifiedLabel = qualifierNode?.label || 'Qualified Area';
3156
+ const outsideLabel = outsideParams?.label || 'Outside (Inverse)';
3157
+
3158
+ const nodes = [
3159
+ {
3160
+ id: 1,
3161
+ label: qualifiedLabel,
3162
+ type: 'corrector',
3163
+ params: qualifierNode?.params || null,
3164
+ qualifier: qualifierNode?.qualifier || null, // HSL qualifier settings
3165
+ window: qualifierNode?.window || null, // Power window settings
3166
+ xPos: 100,
3167
+ yPos: 180,
3168
+ },
3169
+ {
3170
+ id: 2,
3171
+ label: outsideLabel,
3172
+ type: 'outside', // Special type indicating this is an outside node
3173
+ params: outsideParams?.params || null,
3174
+ // The outside node references the qualifier from node 1
3175
+ outsideOf: 1, // Reference to the qualified node
3176
+ invertQualifier: true, // Flag indicating this inverts the qualifier
3177
+ xPos: 370,
3178
+ yPos: 180,
3179
+ },
3180
+ ];
3181
+
3182
+ // Serial connection - the outside node follows the qualified node
3183
+ const connections = [
3184
+ { from: 1, to: 2 }, // Qualified -> Outside
3185
+ ];
3186
+
3187
+ return {
3188
+ type: 'outside',
3189
+ nodes,
3190
+ connections,
3191
+ metadata: {
3192
+ qualifiedNodeId: 1,
3193
+ outsideNodeId: 2,
3194
+ qualifiedLabel,
3195
+ outsideLabel,
3196
+ hasQualifier: !!(qualifierNode?.qualifier),
3197
+ hasWindow: !!(qualifierNode?.window),
3198
+ },
3199
+ };
3200
+ }
3201
+
3202
+ /**
3203
+ * Helper: Create a neutral node base for professional node structures
3204
+ * @returns {Object} - Neutral grade parameters
3205
+ */
3206
+ function createNeutralNodeParams() {
3207
+ return {
3208
+ lift: { r: 0, g: 0, b: 0, master: 0 },
3209
+ gamma: { r: 0, g: 0, b: 0, master: 0 },
3210
+ gain: { r: 1, g: 1, b: 1, master: 1 },
3211
+ offset: { r: 0, g: 0, b: 0 },
3212
+ saturation: 1.0,
3213
+ contrast: 1.0,
3214
+ pivot: 0.435,
3215
+ };
3216
+ }
3217
+
3218
+ /**
3219
+ * Parse AI adjustments into separate labeled nodes for modular grading
3220
+ * Each adjustment type gets its own node with a descriptive label
3221
+ *
3222
+ * @param {Object} adjustments - AI adjustment parameters
3223
+ * @param {Object} options - Optional settings
3224
+ * @param {boolean} options.forceNeutralIfEmpty - If true, return a neutral "Reset" node when all values are near-neutral
3225
+ * (useful for tweaks that cancel out, e.g., "warm" then "cool")
3226
+ * @returns {Array} - Array of node definitions for multi-node DRX
3227
+ */
3228
+ function parseAdjustmentsToNodes(adjustments, options = {}) {
3229
+ const { forceNeutralIfEmpty = false } = options;
3230
+ debugLog('[DRX] parseAdjustmentsToNodes input:', JSON.stringify(adjustments, null, 2));
3231
+ debugLog('[DRX] parseAdjustmentsToNodes options:', { forceNeutralIfEmpty });
3232
+
3233
+ // ═══════════════════════════════════════════════════════════════════════════
3234
+ // SEMANTIC RANGE DETECTION — auto-scale values sent in -1/+1 range
3235
+ // LLMs sometimes send semantic values (saturation: 0.6) instead of UI values
3236
+ // (saturation: 60). Detect this and correct it to prevent catastrophic results
3237
+ // like near-zero saturation (B&W image) or silent temperature drops.
3238
+ // ═══════════════════════════════════════════════════════════════════════════
3239
+ adjustments = { ...adjustments }; // shallow copy to avoid mutating caller's object
3240
+
3241
+ // Saturation: UI range 0-100 (50=neutral). Semantic range -1 to +1.
3242
+ // If value is between -1.5 and +1.5 and not exactly 0, it's almost certainly semantic.
3243
+ if (adjustments.saturation !== undefined && Math.abs(adjustments.saturation) <= 1.5 && adjustments.saturation !== 0) {
3244
+ const original = adjustments.saturation;
3245
+ // Convert: semantic -1→0, 0→50, +1→100
3246
+ adjustments.saturation = 50 + (adjustments.saturation * 50);
3247
+ console.warn(`[DRX] AUTO-SCALED saturation: ${original} (semantic) → ${adjustments.saturation} (UI). Caller should send UI values 0-100 where 50=neutral.`);
3248
+ }
3249
+
3250
+ // Temperature: UI range -4000 to +4000 (0=neutral). Semantic range -1 to +1.
3251
+ // If abs(value) <= 1.5, it's semantic (a real UI temp of 1 is invisible).
3252
+ if (adjustments.temperature !== undefined && Math.abs(adjustments.temperature) <= 1.5 && adjustments.temperature !== 0) {
3253
+ const original = adjustments.temperature;
3254
+ adjustments.temperature = adjustments.temperature * 4000;
3255
+ console.warn(`[DRX] AUTO-SCALED temperature: ${original} (semantic) → ${adjustments.temperature} (UI). Caller should send UI values -4000 to +4000.`);
3256
+ }
3257
+
3258
+ // Tint: UI range -100 to +100 (0=neutral). Semantic range -1 to +1.
3259
+ // If abs(value) <= 1.5, it's semantic.
3260
+ if (adjustments.tint !== undefined && Math.abs(adjustments.tint) <= 1.5 && adjustments.tint !== 0) {
3261
+ const original = adjustments.tint;
3262
+ adjustments.tint = adjustments.tint * 100;
3263
+ console.warn(`[DRX] AUTO-SCALED tint: ${original} (semantic) → ${adjustments.tint} (UI). Caller should send UI values -100 to +100.`);
3264
+ }
3265
+
3266
+ // MidtoneDetail: UI range -100 to +100. Semantic range -1 to +1.
3267
+ if (adjustments.midtoneDetail !== undefined && Math.abs(adjustments.midtoneDetail) <= 1.5 && adjustments.midtoneDetail !== 0) {
3268
+ const original = adjustments.midtoneDetail;
3269
+ adjustments.midtoneDetail = adjustments.midtoneDetail * 100;
3270
+ console.warn(`[DRX] AUTO-SCALED midtoneDetail: ${original} (semantic) → ${adjustments.midtoneDetail} (UI). Caller should send UI values -100 to +100.`);
3271
+ }
3272
+
3273
+ const nodes = [];
3274
+
3275
+ // Helper to create a neutral node base
3276
+ const createNeutralParams = () => ({
3277
+ lift: { r: 0, g: 0, b: 0, master: 0 },
3278
+ gamma: { r: 0, g: 0, b: 0, master: 0 },
3279
+ gain: { r: 1, g: 1, b: 1, master: 1 },
3280
+ offset: { r: 0, g: 0, b: 0 },
3281
+ saturation: 50, // Resolve Primaries: 0-100, unity=50
3282
+ contrast: 1.0, // Resolve Primaries: 0.0-2.0, unity=1.0
3283
+ pivot: 0.435,
3284
+ temperature: 0, // Resolve Primaries: -4000 to +4000, unity=0
3285
+ tint: 0, // Resolve Primaries: -100 to +100, unity=0
3286
+ });
3287
+
3288
+ // Node 1: Exposure (if adjusted)
3289
+ if (adjustments.exposure && Math.abs(adjustments.exposure) > 0.01) {
3290
+ const params = createNeutralParams();
3291
+ params.gain.master = 1 + adjustments.exposure;
3292
+ debugLog('[DRX] Exposure node: input=', adjustments.exposure, 'output gain.master=', params.gain.master);
3293
+ const direction = adjustments.exposure > 0 ? 'Brighter' : 'Darker';
3294
+ nodes.push({
3295
+ label: `Exposure (${direction})`,
3296
+ params,
3297
+ });
3298
+ }
3299
+
3300
+ // Node 2: Temperature/Tint (if adjusted)
3301
+ // Input: Resolve UI values — Temperature: -4000 to +4000, Tint: -100 to +100
3302
+ if ((adjustments.temperature && Math.abs(adjustments.temperature) > 1) ||
3303
+ (adjustments.tint && Math.abs(adjustments.tint) > 0.5)) {
3304
+ const params = createNeutralParams();
3305
+ if (adjustments.temperature) {
3306
+ const t = adjustments.temperature;
3307
+ // Pass through as Resolve UI value (already -4000 to +4000)
3308
+ params.temperature = t;
3309
+ // Also apply subtle gain/lift tinting for richer look
3310
+ // Normalize to -1..+1 range for tinting multipliers
3311
+ const tNorm = t / 4000;
3312
+ params.gain.r += tNorm * 0.15;
3313
+ params.gain.b -= tNorm * 0.15;
3314
+ params.lift.r += tNorm * 0.08;
3315
+ params.lift.b -= tNorm * 0.08;
3316
+ debugLog('[DRX] Color Balance node: temp=', t, '(UI value, normalized=', tNorm, ') gain.r=', params.gain.r);
3317
+ }
3318
+ if (adjustments.tint) {
3319
+ const t = adjustments.tint;
3320
+ // Pass through as Resolve UI value (already -100 to +100)
3321
+ params.tint = t;
3322
+ // Normalize to -1..+1 range for tinting multipliers
3323
+ const tNorm = t / 100;
3324
+ params.gain.g -= tNorm * 0.15;
3325
+ params.lift.g -= tNorm * 0.08;
3326
+ }
3327
+ const tempLabel = adjustments.temperature > 0 ? 'Warm' : adjustments.temperature < 0 ? 'Cool' : '';
3328
+ nodes.push({
3329
+ label: `Color Balance${tempLabel ? ` (${tempLabel})` : ''}`,
3330
+ params,
3331
+ });
3332
+ }
3333
+
3334
+ // Node 2b: Shadow/Highlight Color Split (Teal/Orange effect)
3335
+ // This handles shadowTemperature, highlightTemperature, shadowTint, highlightTint
3336
+ const hasShadowTemp = adjustments.shadowTemperature && Math.abs(adjustments.shadowTemperature) > 0.01;
3337
+ const hasHighlightTemp = adjustments.highlightTemperature && Math.abs(adjustments.highlightTemperature) > 0.01;
3338
+ const hasShadowTint = adjustments.shadowTint && Math.abs(adjustments.shadowTint) > 0.01;
3339
+ const hasHighlightTint = adjustments.highlightTint && Math.abs(adjustments.highlightTint) > 0.01;
3340
+
3341
+ if (hasShadowTemp || hasHighlightTemp || hasShadowTint || hasHighlightTint) {
3342
+ const params = createNeutralParams();
3343
+
3344
+ // Calibrated multipliers for zone-based temperature
3345
+ // These convert normalized (-1 to +1) parameters to Resolve lift/gain values
3346
+ // Calibrated for visible but tasteful color shifts
3347
+ const SHADOW_TEMP_MULTIPLIER = 0.40; // Lift RGB shift per unit temp
3348
+ const HIGHLIGHT_TEMP_MULTIPLIER = 0.35; // Gain RGB shift per unit temp
3349
+ const TINT_MULTIPLIER = 0.30; // Green channel shift for tint
3350
+
3351
+ // Shadow temperature affects lift (shadows)
3352
+ // Negative = teal/cool (more blue, less red)
3353
+ // Positive = warm (more red, less blue)
3354
+ if (hasShadowTemp) {
3355
+ const st = adjustments.shadowTemperature;
3356
+ // Temperature shifts R and B in opposite directions
3357
+ params.lift.r += st * SHADOW_TEMP_MULTIPLIER;
3358
+ params.lift.b -= st * SHADOW_TEMP_MULTIPLIER;
3359
+ // Add green for true teal/cyan (cyan = blue + green)
3360
+ params.lift.g -= st * (SHADOW_TEMP_MULTIPLIER * 0.35);
3361
+ }
3362
+
3363
+ // Highlight temperature affects gain (highlights)
3364
+ // Positive = orange/warm, Negative = cool
3365
+ if (hasHighlightTemp) {
3366
+ const ht = adjustments.highlightTemperature;
3367
+ // Orange = red boost + blue reduction
3368
+ params.gain.r += ht * HIGHLIGHT_TEMP_MULTIPLIER;
3369
+ params.gain.b -= ht * (HIGHLIGHT_TEMP_MULTIPLIER * 0.85);
3370
+ // Slight green for gold/yellow tone
3371
+ params.gain.g += ht * (HIGHLIGHT_TEMP_MULTIPLIER * 0.15);
3372
+ }
3373
+
3374
+ // Shadow tint affects lift green (magenta/green in shadows)
3375
+ if (hasShadowTint) {
3376
+ const stint = adjustments.shadowTint;
3377
+ params.lift.g -= stint * TINT_MULTIPLIER;
3378
+ }
3379
+
3380
+ // Highlight tint affects gain green (magenta/green in highlights)
3381
+ if (hasHighlightTint) {
3382
+ const htint = adjustments.highlightTint;
3383
+ params.gain.g -= htint * TINT_MULTIPLIER;
3384
+ }
3385
+
3386
+ // Create descriptive label
3387
+ let splitLabel = 'Color Split';
3388
+ if (hasShadowTemp && hasHighlightTemp) {
3389
+ const shadowDir = adjustments.shadowTemperature < 0 ? 'Teal' : 'Orange';
3390
+ const highlightDir = adjustments.highlightTemperature > 0 ? 'Orange' : 'Teal';
3391
+ splitLabel = `${shadowDir} Shadows / ${highlightDir} Highlights`;
3392
+ } else if (hasShadowTemp) {
3393
+ splitLabel = adjustments.shadowTemperature < 0 ? 'Teal Shadows' : 'Warm Shadows';
3394
+ } else if (hasHighlightTemp) {
3395
+ splitLabel = adjustments.highlightTemperature > 0 ? 'Orange Highlights' : 'Cool Highlights';
3396
+ }
3397
+
3398
+ nodes.push({
3399
+ label: splitLabel,
3400
+ params,
3401
+ });
3402
+ }
3403
+
3404
+ // Node 3: Contrast (if adjusted)
3405
+ // Input: Resolve UI value — 0 to 2, unity=1.0
3406
+ // NOTE: We implement contrast using Lift/Gain instead of the Contrast Corrector (Type 2)
3407
+ // because Type 2 was causing HSL qualifier issues. Lift/Gain achieves the same effect
3408
+ // and works reliably with the Primary Corrector (Type 1).
3409
+ if (adjustments.contrast !== undefined && Math.abs(adjustments.contrast - 1.0) > 0.01) {
3410
+ const params = createNeutralParams();
3411
+ const c = adjustments.contrast;
3412
+
3413
+ // Implement contrast using Lift (shadows) and Gain (highlights)
3414
+ // Offset from unity (1.0): positive = more contrast, negative = less
3415
+ const contrastAmount = (c - 1.0) * 0.15; // Scale for subtlety
3416
+
3417
+ // Adjust lift (shadows) - lower for more contrast
3418
+ params.lift.r = -contrastAmount;
3419
+ params.lift.g = -contrastAmount;
3420
+ params.lift.b = -contrastAmount;
3421
+ params.lift.master = -contrastAmount * 0.5;
3422
+
3423
+ // Adjust gain (highlights) - raise for more contrast
3424
+ params.gain.r = 1.0 + contrastAmount;
3425
+ params.gain.g = 1.0 + contrastAmount;
3426
+ params.gain.b = 1.0 + contrastAmount;
3427
+ params.gain.master = 1.0 + (contrastAmount * 0.5);
3428
+
3429
+ debugLog('[DRX] Contrast node: input=', c, '(offset from unity=', c - 1.0, ') lift=', params.lift.r, 'gain=', params.gain.r);
3430
+
3431
+ const direction = adjustments.contrast > 1.0 ? 'More' : 'Less';
3432
+ nodes.push({
3433
+ label: `Contrast (${direction})`,
3434
+ params,
3435
+ });
3436
+ }
3437
+
3438
+ // Node 4: Shadows (if adjusted)
3439
+ if (adjustments.shadowLift && Math.abs(adjustments.shadowLift) > 0.01) {
3440
+ const params = createNeutralParams();
3441
+ const s = adjustments.shadowLift;
3442
+ // Stronger multiplier for visible shadow changes (0.25 instead of 0.1)
3443
+ params.lift.r += s * 0.25;
3444
+ params.lift.g += s * 0.25;
3445
+ params.lift.b += s * 0.25;
3446
+ params.lift.master += s * 0.15; // Also adjust master for overall effect
3447
+ debugLog('[DRX] Shadows node: input=', s, 'output lift.r=', params.lift.r, 'lift.master=', params.lift.master);
3448
+ const direction = adjustments.shadowLift > 0 ? 'Lifted' : 'Crushed';
3449
+ nodes.push({
3450
+ label: `Shadows (${direction})`,
3451
+ params,
3452
+ });
3453
+ }
3454
+
3455
+ // Node 5: Highlights (if adjusted)
3456
+ if (adjustments.highlightCompression && Math.abs(adjustments.highlightCompression) > 0.01) {
3457
+ const params = createNeutralParams();
3458
+ const h = adjustments.highlightCompression;
3459
+ params.gain.r -= h * 0.1;
3460
+ params.gain.g -= h * 0.1;
3461
+ params.gain.b -= h * 0.1;
3462
+ nodes.push({
3463
+ label: 'Highlights (Rolled)',
3464
+ params,
3465
+ });
3466
+ }
3467
+
3468
+ // Node 6: Saturation (if adjusted)
3469
+ // Input: Resolve UI value — 0 to 100, unity=50
3470
+ if (adjustments.saturation !== undefined && Math.abs(adjustments.saturation - 50) > 0.5) {
3471
+ const params = createNeutralParams();
3472
+ params.saturation = adjustments.saturation;
3473
+ debugLog('[DRX] Saturation node: input=', adjustments.saturation, '(UI value, unity=50)');
3474
+ const direction = adjustments.saturation > 50 ? 'Vibrant' : 'Muted';
3475
+ nodes.push({
3476
+ label: `Saturation (${direction})`,
3477
+ params,
3478
+ });
3479
+ }
3480
+
3481
+ // Node 7: Midtone Detail (if adjusted)
3482
+ // Input: Resolve UI value — -100 to +100, unity=0
3483
+ // buildPrimaryCorrectorParams now passes through (no ×100 scaling)
3484
+ if (adjustments.midtoneDetail !== undefined && Math.abs(adjustments.midtoneDetail) > 0.5) {
3485
+ const params = createNeutralParams();
3486
+ // Midtone Detail maps to DaVinci's Mid/Detail slider in the Primaries panel
3487
+ // Positive values = sharper midtones, negative = softer midtones
3488
+ // Pass through directly — buildPrimary clamps to [-100, +100]
3489
+ params.midtoneDetail = adjustments.midtoneDetail;
3490
+ debugLog('[DRX] Midtone Detail node: input=', adjustments.midtoneDetail, '(UI value)');
3491
+ const direction = adjustments.midtoneDetail > 0 ? 'Sharper' : 'Softer';
3492
+ nodes.push({
3493
+ label: `Midtone Detail (${direction})`,
3494
+ params,
3495
+ });
3496
+ }
3497
+
3498
+ // Node 8: HDR Zones + Black Offset (if adjusted)
3499
+ // Check for hdrDark, hdrShadow, hdrLight, hdrHighlight, hdrGlobal
3500
+ const hdrZones = ['hdrDark', 'hdrShadow', 'hdrLight', 'hdrHighlight', 'hdrGlobal'];
3501
+ const activeHdrZones = hdrZones.filter(zone => {
3502
+ const zoneParams = adjustments[zone];
3503
+ if (!zoneParams) return false;
3504
+ // Check if zone has non-default exposure (non-zero) or saturation (non-1)
3505
+ const hasExposure = zoneParams.exposure !== undefined && Math.abs(zoneParams.exposure) > 0.01;
3506
+ const hasSaturation = zoneParams.saturation !== undefined && Math.abs(zoneParams.saturation - 1) > 0.01;
3507
+ return hasExposure || hasSaturation;
3508
+ });
3509
+ const hasBlackOffset = adjustments.blackOffset !== undefined && Math.abs(adjustments.blackOffset) > 0.001;
3510
+
3511
+ if (activeHdrZones.length > 0 || hasBlackOffset) {
3512
+ const params = createNeutralParams();
3513
+ // Copy HDR zone params to the node params so buildHDRWheelParams can find them
3514
+ for (const zone of activeHdrZones) {
3515
+ params[zone] = adjustments[zone];
3516
+ }
3517
+ // Black Offset is a global HDR control, encoded as flat param in Primary corrector
3518
+ if (hasBlackOffset) {
3519
+ params.blackOffset = adjustments.blackOffset;
3520
+ }
3521
+ // Generate descriptive label
3522
+ const zoneDescriptions = activeHdrZones.map(zone => {
3523
+ const zoneParams = adjustments[zone];
3524
+ const zoneName = zone.replace('hdr', '');
3525
+ const exp = zoneParams.exposure || 0;
3526
+ return `${zoneName} ${exp > 0 ? '+' : ''}${exp.toFixed(2)}`;
3527
+ });
3528
+ if (hasBlackOffset) {
3529
+ zoneDescriptions.push(`BlackOffset ${adjustments.blackOffset > 0 ? '+' : ''}${adjustments.blackOffset.toFixed(3)}`);
3530
+ }
3531
+ debugLog('[DRX] HDR Zones node: zones=', activeHdrZones.join(', '));
3532
+ nodes.push({
3533
+ label: `HDR (${zoneDescriptions.join(', ')})`,
3534
+ params,
3535
+ hasHDRWheels: true, // Flag for createNode to call buildHDRWheelParams
3536
+ });
3537
+ }
3538
+
3539
+ // Node 9: LOG Wheels (if adjusted)
3540
+ // Check for logShadowR/G/B, logMidR/G/B, logHighR/G/B
3541
+ const logChannels = ['logShadowR', 'logShadowG', 'logShadowB', 'logMidR', 'logMidG', 'logMidB', 'logHighR', 'logHighG', 'logHighB'];
3542
+ const activeLogChannels = logChannels.filter(ch => {
3543
+ return adjustments[ch] !== undefined && Math.abs(adjustments[ch]) > 0.005;
3544
+ });
3545
+
3546
+ if (activeLogChannels.length > 0) {
3547
+ const params = createNeutralParams();
3548
+ // Convert flat logShadowR/G/B to nested logShadow: {r, g, b} structure for encoding
3549
+ params.logShadow = { r: 0, g: 0, b: 0 };
3550
+ params.logMid = { r: 0, g: 0, b: 0 };
3551
+ params.logHigh = { r: 0, g: 0, b: 0 };
3552
+
3553
+ for (const ch of activeLogChannels) {
3554
+ if (ch.startsWith('logShadow')) {
3555
+ const channel = ch.replace('logShadow', '').toLowerCase();
3556
+ params.logShadow[channel] = adjustments[ch];
3557
+ } else if (ch.startsWith('logMid')) {
3558
+ const channel = ch.replace('logMid', '').toLowerCase();
3559
+ params.logMid[channel] = adjustments[ch];
3560
+ } else if (ch.startsWith('logHigh')) {
3561
+ const channel = ch.replace('logHigh', '').toLowerCase();
3562
+ params.logHigh[channel] = adjustments[ch];
3563
+ }
3564
+ }
3565
+ debugLog('[DRX] LOG Wheels node: logShadow=', params.logShadow, 'logMid=', params.logMid, 'logHigh=', params.logHigh);
3566
+ nodes.push({
3567
+ label: 'LOG Wheels',
3568
+ params,
3569
+ });
3570
+ }
3571
+
3572
+ // Node 9b: RGB Mixer (if adjusted)
3573
+ if (adjustments.rgbMixer) {
3574
+ const params = createNeutralParams();
3575
+ params.rgbMixer = adjustments.rgbMixer;
3576
+ debugLog('[DRX] RGB Mixer node');
3577
+ nodes.push({
3578
+ label: 'RGB Mixer',
3579
+ params,
3580
+ });
3581
+ }
3582
+
3583
+ // Node 9c: Custom Curves YRGB (if adjusted)
3584
+ if (adjustments.customCurves) {
3585
+ const params = createNeutralParams();
3586
+ params.customCurves = adjustments.customCurves;
3587
+ const channelList = Object.keys(adjustments.customCurves).filter(k => adjustments.customCurves[k]?.length > 0);
3588
+ debugLog('[DRX] Custom Curves node: channels=', channelList.join(','));
3589
+ nodes.push({
3590
+ label: 'Curves (' + channelList.map(c => c.toUpperCase()).join('/') + ')',
3591
+ params,
3592
+ });
3593
+ }
3594
+
3595
+ // Node 9d: HSL Curves (if adjusted)
3596
+ if (adjustments.hslCurves) {
3597
+ const params = createNeutralParams();
3598
+ params.hslCurves = adjustments.hslCurves;
3599
+ const curveList = Object.keys(adjustments.hslCurves).filter(k => adjustments.hslCurves[k]?.length > 0);
3600
+ debugLog('[DRX] HSL Curves node: curves=', curveList.join(','));
3601
+ nodes.push({
3602
+ label: 'HSL Curves',
3603
+ params,
3604
+ });
3605
+ }
3606
+
3607
+ // Node 10: Per-channel primaries (liftR/G/B, gammaR/G/B, gainR/G/B)
3608
+ const perChannelKeys = [
3609
+ 'liftR', 'liftG', 'liftB', 'liftMaster',
3610
+ 'gammaR', 'gammaG', 'gammaB', 'gammaMaster',
3611
+ 'gainR', 'gainG', 'gainB', 'gainMaster',
3612
+ ];
3613
+ const activePerChannel = perChannelKeys.filter(k => {
3614
+ return adjustments[k] !== undefined && Math.abs(adjustments[k]) > 0.005;
3615
+ });
3616
+
3617
+ if (activePerChannel.length > 0) {
3618
+ const params = createNeutralParams();
3619
+ // Apply per-channel adjustments to the nested structure
3620
+ for (const key of activePerChannel) {
3621
+ if (key.startsWith('lift')) {
3622
+ const ch = key.replace('lift', '').toLowerCase();
3623
+ params.lift[ch] = (params.lift[ch] || 0) + adjustments[key];
3624
+ } else if (key.startsWith('gamma')) {
3625
+ const ch = key.replace('gamma', '').toLowerCase();
3626
+ params.gamma[ch] = (params.gamma[ch] || 0) + adjustments[key];
3627
+ } else if (key.startsWith('gain')) {
3628
+ const ch = key.replace('gain', '').toLowerCase();
3629
+ // Gain defaults to 1, so add to existing
3630
+ params.gain[ch] = (params.gain[ch] || 1) + adjustments[key];
3631
+ }
3632
+ }
3633
+ debugLog('[DRX] Per-channel primaries node:', activePerChannel.join(', '));
3634
+ nodes.push({
3635
+ label: 'Color Wheels',
3636
+ params,
3637
+ });
3638
+ }
3639
+
3640
+ // Node 11: ColorBoost (if adjusted)
3641
+ if (adjustments.colorBoost !== undefined && Math.abs(adjustments.colorBoost) > 0.5) {
3642
+ const params = createNeutralParams();
3643
+ params.colorBoost = adjustments.colorBoost;
3644
+ debugLog('[DRX] ColorBoost node: input=', adjustments.colorBoost);
3645
+ nodes.push({
3646
+ label: `Color Boost (${adjustments.colorBoost > 0 ? '+' : ''}${adjustments.colorBoost})`,
3647
+ params,
3648
+ });
3649
+ }
3650
+
3651
+ // Node 12: Pivot/Contrast Range (if adjusted)
3652
+ const hasPivot = adjustments.pivot !== undefined && Math.abs(adjustments.pivot - 0.435) > 0.01;
3653
+ const hasHighRange = adjustments.contrastHighRange !== undefined && Math.abs(adjustments.contrastHighRange - 0.75) > 0.01;
3654
+ const hasLowRange = adjustments.contrastLowRange !== undefined && Math.abs(adjustments.contrastLowRange - 0.25) > 0.01;
3655
+
3656
+ if (hasPivot || hasHighRange || hasLowRange) {
3657
+ const params = createNeutralParams();
3658
+ if (hasPivot) params.pivot = adjustments.pivot;
3659
+ if (hasHighRange) params.contrastHighRange = adjustments.contrastHighRange;
3660
+ if (hasLowRange) params.contrastLowRange = adjustments.contrastLowRange;
3661
+ debugLog('[DRX] Pivot/Range node: pivot=', params.pivot, 'highRange=', params.contrastHighRange, 'lowRange=', params.contrastLowRange);
3662
+ nodes.push({
3663
+ label: 'Contrast Range',
3664
+ params,
3665
+ });
3666
+ }
3667
+
3668
+ // If no adjustments detected
3669
+ if (nodes.length === 0) {
3670
+ if (forceNeutralIfEmpty) {
3671
+ // Caller explicitly wants a neutral grade (e.g., tweak that cancelled out to neutral)
3672
+ // Generate a Reset node that clears all adjustments
3673
+ debugLog('[DRX] No adjustments but forceNeutralIfEmpty=true - generating Reset node');
3674
+ const neutralParams = createNeutralParams();
3675
+ return [{
3676
+ label: 'Reset (Neutral)',
3677
+ params: neutralParams,
3678
+ }];
3679
+ }
3680
+ debugLog('[DRX] No adjustments detected - returning empty nodes to avoid overwriting existing grade');
3681
+ // Previously we returned a "Base Grade" neutral node, but that was overwriting existing grades
3682
+ // Now we return empty so the caller can decide whether to error or proceed
3683
+ return [];
3684
+ }
3685
+
3686
+ debugLog('[DRX] parseAdjustmentsToNodes output: created', nodes.length, 'nodes');
3687
+ nodes.forEach((n, i) => {
3688
+ debugLog(`[DRX] Output node ${i + 1}: "${n.label}"`, 'saturation:', n.params.saturation, 'contrast:', n.params.contrast, 'gain.master:', n.params.gain.master);
3689
+ });
3690
+ return nodes;
3691
+ }
3692
+
3693
+ /**
3694
+ * Generate a delta-only DRX containing ONLY adjustment nodes (no base grade).
3695
+ * Used for storing deltas that can be re-applied to any base.
3696
+ *
3697
+ * This function is part of the DRX Audit Trail System:
3698
+ * - Produces a DRX file that contains only the new adjustments
3699
+ * - Can be merged with any base grade to produce the same result
3700
+ * - Used for debugging and auditing grade changes
3701
+ *
3702
+ * @param {Object} deltaParams - Adjustment parameters (from tweak or LLM)
3703
+ * @param {Object} metadata - Optional metadata for the DRX
3704
+ * @param {string} metadata.label - Label for the delta DRX
3705
+ * @param {boolean} metadata.skipNeutralNodes - Skip nodes that have no effect (default: true)
3706
+ * @returns {Promise<string|null>} - DRX XML content, or null if no adjustments
3707
+ */
3708
+ async function generateDeltaDRX(deltaParams, metadata = {}) {
3709
+ const {
3710
+ label = 'Delta Grade',
3711
+ skipNeutralNodes = true,
3712
+ width = 1920,
3713
+ height = 1080,
3714
+ } = metadata;
3715
+
3716
+ debugLog('[DRX] generateDeltaDRX input:', JSON.stringify(deltaParams, null, 2));
3717
+
3718
+ // Parse adjustments into nodes
3719
+ const nodes = parseAdjustmentsToNodes(deltaParams, { forceNeutralIfEmpty: false });
3720
+
3721
+ // If no adjustments, return null (caller can decide what to do)
3722
+ if (!nodes || nodes.length === 0) {
3723
+ debugLog('[DRX] generateDeltaDRX: no adjustments - returning null');
3724
+ return null;
3725
+ }
3726
+
3727
+ // Optionally filter out neutral nodes
3728
+ let filteredNodes = nodes;
3729
+ if (skipNeutralNodes) {
3730
+ filteredNodes = nodes.filter(node => {
3731
+ if (!node.params) return false;
3732
+ const p = node.params;
3733
+
3734
+ // Check if any parameter is non-neutral
3735
+ const hasLift = p.lift && (
3736
+ Math.abs(p.lift.r) > 0.001 ||
3737
+ Math.abs(p.lift.g) > 0.001 ||
3738
+ Math.abs(p.lift.b) > 0.001 ||
3739
+ Math.abs(p.lift.master) > 0.001
3740
+ );
3741
+ const hasGamma = p.gamma && (
3742
+ Math.abs(p.gamma.r) > 0.001 ||
3743
+ Math.abs(p.gamma.g) > 0.001 ||
3744
+ Math.abs(p.gamma.b) > 0.001 ||
3745
+ Math.abs(p.gamma.master) > 0.001
3746
+ );
3747
+ const hasGain = p.gain && (
3748
+ Math.abs(p.gain.r - 1) > 0.001 ||
3749
+ Math.abs(p.gain.g - 1) > 0.001 ||
3750
+ Math.abs(p.gain.b - 1) > 0.001 ||
3751
+ Math.abs(p.gain.master - 1) > 0.001
3752
+ );
3753
+ const hasOffset = p.offset && (
3754
+ Math.abs(p.offset.r) > 0.001 ||
3755
+ Math.abs(p.offset.g) > 0.001 ||
3756
+ Math.abs(p.offset.b) > 0.001
3757
+ );
3758
+ const hasSaturation = p.saturation !== undefined && Math.abs(p.saturation - 50) > 0.001;
3759
+ const hasContrast = p.contrast !== undefined && Math.abs(p.contrast - 1.0) > 0.001;
3760
+ const hasMidtoneDetail = p.midtoneDetail !== undefined && Math.abs(p.midtoneDetail) > 0.001;
3761
+
3762
+ return hasLift || hasGamma || hasGain || hasOffset || hasSaturation || hasContrast || hasMidtoneDetail;
3763
+ });
3764
+
3765
+ if (filteredNodes.length === 0) {
3766
+ debugLog('[DRX] generateDeltaDRX: all nodes filtered as neutral - returning null');
3767
+ return null;
3768
+ }
3769
+ }
3770
+
3771
+ debugLog('[DRX] generateDeltaDRX: generating DRX with', filteredNodes.length, 'delta nodes');
3772
+
3773
+ // Create connections for serial node chain
3774
+ const connections = [];
3775
+ for (let i = 0; i < filteredNodes.length - 1; i++) {
3776
+ connections.push({ from: i + 1, to: i + 2 });
3777
+ }
3778
+
3779
+ // Generate the delta DRX using multi-node generator
3780
+ const drxContent = await generateMultiNodeDRX(filteredNodes, connections, {
3781
+ label: `[DELTA] ${label}`,
3782
+ width,
3783
+ height,
3784
+ isDelta: true, // Flag for identification
3785
+ });
3786
+
3787
+ return drxContent;
3788
+ }
3789
+
3790
+ /**
3791
+ * Compute SHA-256 hash of DRX content for sync verification
3792
+ *
3793
+ * @param {string} drxContent - DRX XML content
3794
+ * @returns {string} - SHA-256 hash (first 16 characters)
3795
+ */
3796
+ function computeDrxHash(drxContent) {
3797
+ if (!drxContent) return null;
3798
+ const crypto = require('crypto');
3799
+ return crypto
3800
+ .createHash('sha256')
3801
+ .update(drxContent)
3802
+ .digest('hex')
3803
+ .slice(0, 16);
3804
+ }
3805
+
3806
+ /**
3807
+ * Validate a generated DRX body hex string
3808
+ *
3809
+ * Decompresses (0x81 + zstd) and parses the protobuf to verify that
3810
+ * node(s) contain corrector blocks (F9) with parameter entries (F3).
3811
+ * Run this after every DRX generation to catch encoding failures before
3812
+ * applying broken DRXs to Resolve.
3813
+ *
3814
+ * @param {string} bodyHex - The hex-encoded DRX body (0x81 + zstd data)
3815
+ * @returns {Promise<{valid: boolean, summary: string, nodes: Array}>}
3816
+ */
3817
+ async function validateDRXBody(bodyHex) {
3818
+ const { parseDRXBody } = require('./drx-parser');
3819
+
3820
+ try {
3821
+ const result = await parseDRXBody(bodyHex);
3822
+ const { nodes } = result;
3823
+
3824
+ if (!nodes || nodes.length === 0) {
3825
+ return { valid: false, summary: 'No nodes found in DRX body', nodes: [] };
3826
+ }
3827
+
3828
+ const nodeDetails = [];
3829
+ let totalParams = 0;
3830
+ let nodesWithCorrectors = 0;
3831
+
3832
+ for (const node of nodes) {
3833
+ const detail = {
3834
+ id: node.id,
3835
+ label: node.label || '(unlabeled)',
3836
+ hasCorrectors: false,
3837
+ paramCount: 0,
3838
+ params: [],
3839
+ };
3840
+
3841
+ // Check if the node has any non-default parameters
3842
+ if (node.params) {
3843
+ // Count non-default primary params
3844
+ const checks = [
3845
+ { name: 'lift.r', val: node.params.lift?.r, def: 0 },
3846
+ { name: 'lift.g', val: node.params.lift?.g, def: 0 },
3847
+ { name: 'lift.b', val: node.params.lift?.b, def: 0 },
3848
+ { name: 'lift.master', val: node.params.lift?.master, def: 0 },
3849
+ { name: 'gamma.r', val: node.params.gamma?.r, def: 0 },
3850
+ { name: 'gamma.g', val: node.params.gamma?.g, def: 0 },
3851
+ { name: 'gamma.b', val: node.params.gamma?.b, def: 0 },
3852
+ { name: 'gamma.master', val: node.params.gamma?.master, def: 0 },
3853
+ { name: 'gain.r', val: node.params.gain?.r, def: 1 },
3854
+ { name: 'gain.g', val: node.params.gain?.g, def: 1 },
3855
+ { name: 'gain.b', val: node.params.gain?.b, def: 1 },
3856
+ { name: 'gain.master', val: node.params.gain?.master, def: 1 },
3857
+ { name: 'offset.r', val: node.params.offset?.r, def: 0 },
3858
+ { name: 'offset.g', val: node.params.offset?.g, def: 0 },
3859
+ { name: 'offset.b', val: node.params.offset?.b, def: 0 },
3860
+ { name: 'saturation', val: node.params.saturation, def: 50 },
3861
+ { name: 'temperature', val: node.params.temperature, def: 0 },
3862
+ { name: 'tint', val: node.params.tint, def: 0 },
3863
+ { name: 'contrast', val: node.params.contrast, def: 1 },
3864
+ ];
3865
+
3866
+ for (const { name, val, def } of checks) {
3867
+ if (val != null && Math.abs(val - def) > 0.001) {
3868
+ detail.params.push(`${name}=${val}`);
3869
+ detail.paramCount++;
3870
+ }
3871
+ }
3872
+
3873
+ // Check for NaN values (the bug we're fixing)
3874
+ for (const { name, val } of checks) {
3875
+ if (val != null && isNaN(val)) {
3876
+ return {
3877
+ valid: false,
3878
+ summary: `NaN detected in parameter ${name} on node "${detail.label}" — undefined value leaked into protobuf`,
3879
+ nodes: nodeDetails,
3880
+ };
3881
+ }
3882
+ }
3883
+ }
3884
+
3885
+ // Check raw corrector data from the parser
3886
+ if (node.correctors && node.correctors.length > 0) {
3887
+ detail.hasCorrectors = true;
3888
+ nodesWithCorrectors++;
3889
+ } else if (detail.paramCount > 0) {
3890
+ detail.hasCorrectors = true;
3891
+ nodesWithCorrectors++;
3892
+ }
3893
+
3894
+ totalParams += detail.paramCount;
3895
+ nodeDetails.push(detail);
3896
+ }
3897
+
3898
+ const summary = `${nodes.length} node(s), ${nodesWithCorrectors} with correctors, ${totalParams} non-default param(s): ${nodeDetails.map(n => `[${n.label}: ${n.params.join(', ') || 'neutral'}]`).join(' ')}`;
3899
+
3900
+ return {
3901
+ valid: totalParams > 0 || nodesWithCorrectors > 0,
3902
+ summary,
3903
+ nodes: nodeDetails,
3904
+ };
3905
+ } catch (err) {
3906
+ return {
3907
+ valid: false,
3908
+ summary: `DRX parse failed: ${err.message}`,
3909
+ nodes: [],
3910
+ };
3911
+ }
3912
+ }
3913
+
3914
+ // ── OFX / ResolveFX generic encoder ─────────────────────────────────────────
3915
+
3916
+ /**
3917
+ * Build a ResolveFX OFX tool entry for inclusion in F7.F10 (node tool list).
3918
+ *
3919
+ * TRAINED 2026-03-22: OFX container format fully decoded from Film Grain DRX.
3920
+ * Generic for ANY ResolveFX plugin — just pass the plugin ID and param name/value pairs.
3921
+ *
3922
+ * OFX container structure:
3923
+ * F7.F10.F1 (tool entry):
3924
+ * F2.F21 (OFX container):
3925
+ * F1 = 0x4F4659 ("OFY" marker)
3926
+ * F2 = plugin ID string (e.g., "com.blackmagicdesign.resolvefx.filmgrain")
3927
+ * F3 = instance ID string
3928
+ * F4 = 1 (enabled)
3929
+ * F5[] (repeated) = param entries:
3930
+ * F1 = param name (string)
3931
+ * F2.F2 = param value (double) OR F2.F5 = param value (string)
3932
+ *
3933
+ * @param {string} pluginId — OFX plugin identifier (e.g., "com.blackmagicdesign.resolvefx.filmgrain")
3934
+ * @param {Object} params — Map of param name → value (number or string)
3935
+ * @param {Object} [options]
3936
+ * @param {string} [options.instanceId] — custom instance ID (auto-generated if omitted)
3937
+ * @param {string} [options.version='3.2'] — ResolveFX version
3938
+ * @returns {Object} — OFX plugin definition for inclusion in DRX adjustments
3939
+ */
3940
+ function buildResolveFXParams(pluginId, params, options = {}) {
3941
+ const version = options.version || '3.2';
3942
+ const instanceId = options.instanceId || `OfxImageEffectContext_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3943
+
3944
+ return {
3945
+ _ofxPlugin: true,
3946
+ pluginId,
3947
+ instanceId,
3948
+ version,
3949
+ params: { ...params, resolvefxVersion: version },
3950
+ };
3951
+ }
3952
+
3953
+ /**
3954
+ * Encode an OFX plugin tool entry as protobuf bytes for F7.F10.
3955
+ *
3956
+ * Generates the complete OFX container structure:
3957
+ * F10.F1[0] = { F1=0xC0000001, F2={F2=2} } (standard node marker)
3958
+ * F10.F1[1] = { F1=toolId, F2.F5=pluginId } (plugin identifier)
3959
+ * F10.F1[2] = { F1=toolId+3, F2.F5=instanceId } (instance)
3960
+ * F10.F1[3] = { F1=toolId+6, F2.F4=enabled } (enable flag)
3961
+ * F10.F1[4] = { F1=toolId+42, F2.F21=OFXContainer } (params)
3962
+ * F10.F1[5] = { F1=toolId+63, F2.F4=0 } (end marker)
3963
+ *
3964
+ * @param {string} pluginId — e.g., "com.blackmagicdesign.resolvefx.filmgrain"
3965
+ * @param {Object} params — name→value pairs (number or string)
3966
+ * @param {Object} [options]
3967
+ * @returns {Buffer} — F10 field bytes (length-delimited)
3968
+ */
3969
+ function buildOFXToolEntry(pluginId, params, options = {}) {
3970
+ // Entry ids are UNIVERSAL CONSTANTS across every plugin (verified 2026-07-03 by
3971
+ // byte-diffing native tool lists for filmgrain/CST/acestransform/deflicker/NeatVideo:
3972
+ // all use marker 0xC0000001 · pluginId 0xC0000049 · instanceId 0xC000005E ·
3973
+ // enable 0xC0000063 · OFX container 0xC0000087 · end 0xC00000D2). The old
3974
+ // "Film Grain toolId 0xC0000087 + offsets" scheme misplaced every entry — Resolve
3975
+ // hard-CRASHED deserializing a string where it expected the container. Resolve keys
3976
+ // plugins by the pluginId STRING; there is no per-plugin tool id.
3977
+ const ID_PLUGIN = 0xC0000049, ID_INSTANCE = 0xC000005E, ID_ENABLE = 0xC0000063,
3978
+ ID_CONTAINER = 0xC0000087, ID_END = 0xC00000D2;
3979
+ // F3 is the OFX CONTEXT name, not a unique instance id — native captures carry the
3980
+ // standardized "OfxImageEffectContextFilter" (a synthesized unique string leaves the
3981
+ // plugin un-instantiated: node applies but the effect never engages — found live
3982
+ // 2026-07-03). Callers can still override for generator/transition contexts.
3983
+ const instanceId = options.instanceId || 'OfxImageEffectContextFilter';
3984
+
3985
+ // Build F5 repeated param entries. Native containers always carry resolvefxVersion
3986
+ // and serialize params in name order — mirror both.
3987
+ const withVersion = { resolvefxVersion: options.version || '3.2', ...params };
3988
+ const paramEntries = [];
3989
+ for (const name of Object.keys(withVersion).sort()) {
3990
+ const value = withVersion[name];
3991
+ const nameBuf = Buffer.from(name, 'utf-8');
3992
+ let valueBuf;
3993
+ if (typeof value === 'string') {
3994
+ const strBuf = Buffer.from(value, 'utf-8');
3995
+ valueBuf = protoBytes(5, strBuf);
3996
+ } else {
3997
+ valueBuf = protoFloat64(2, value);
3998
+ }
3999
+ paramEntries.push(protoBytes(5, Buffer.concat([
4000
+ protoBytes(1, nameBuf),
4001
+ protoBytes(2, valueBuf),
4002
+ ])));
4003
+ }
4004
+
4005
+ // Build F2.F21 OFX container
4006
+ const ofxContainer = protoBytes(21, Buffer.concat([
4007
+ protoVarint(1, 0x4F4659), // "OFY" marker
4008
+ protoBytes(2, Buffer.from(pluginId, 'utf-8')),
4009
+ protoBytes(3, Buffer.from(instanceId, 'utf-8')),
4010
+ protoVarint(4, 1), // enabled
4011
+ ...paramEntries,
4012
+ ]));
4013
+
4014
+ // Build tool list entries
4015
+ const entries = [
4016
+ // Standard node marker
4017
+ protoBytes(1, Buffer.concat([
4018
+ protoVarint(1, 0xC0000001),
4019
+ protoBytes(2, protoVarint(2, 2)),
4020
+ ])),
4021
+ // Plugin ID
4022
+ protoBytes(1, Buffer.concat([
4023
+ protoVarint(1, ID_PLUGIN),
4024
+ protoBytes(2, protoBytes(5, Buffer.from(pluginId, 'utf-8'))),
4025
+ ])),
4026
+ // Instance ID
4027
+ protoBytes(1, Buffer.concat([
4028
+ protoVarint(1, ID_INSTANCE),
4029
+ protoBytes(2, protoBytes(5, Buffer.from(instanceId, 'utf-8'))),
4030
+ ])),
4031
+ // Enable flag (native captures carry 0 here for enabled plugins)
4032
+ protoBytes(1, Buffer.concat([
4033
+ protoVarint(1, ID_ENABLE),
4034
+ protoBytes(2, protoVarint(4, 0)),
4035
+ ])),
4036
+ // OFX container with params
4037
+ protoBytes(1, Buffer.concat([
4038
+ protoVarint(1, ID_CONTAINER),
4039
+ protoBytes(2, ofxContainer),
4040
+ ])),
4041
+ // End marker
4042
+ protoBytes(1, Buffer.concat([
4043
+ protoVarint(1, ID_END),
4044
+ protoBytes(2, protoVarint(4, 0)),
4045
+ ])),
4046
+ ];
4047
+
4048
+ return protoBytes(10, Buffer.concat(entries));
4049
+ }
4050
+
4051
+ /**
4052
+ * Known ResolveFX plugin IDs and their parameter names.
4053
+ * Populated from OFX calibration autoresearch data.
4054
+ */
4055
+ const RESOLVEFX_PLUGINS = {
4056
+ filmGrain: {
4057
+ pluginId: 'com.blackmagicdesign.resolvefx.filmgrain',
4058
+ params: {
4059
+ grainMean: 'GrainMean', // 0.0-1.0, default ~0.482
4060
+ grainSize: 'GrainSize', // 0.0-1.0, default ~0.5
4061
+ grainSkew: 'GrainSkew', // 0.0-1.0, default 0.5
4062
+ grainStrength: 'GrainStrength', // 0.0-1.0, default ~0.149
4063
+ textureness: 'Textureness', // 0.0-1.0, default ~0.704
4064
+ saturation: 'saturation', // 0.0-1.0, default 0.0
4065
+ softness: 'softness', // 0.0-1.0, default ~0.298
4066
+ },
4067
+ },
4068
+ // Add more plugins as they're calibrated
4069
+ };
4070
+
4071
+ // CR3: Import CDL exporter for re-export
4072
+ const cdlExporter = require('./cdl-exporter');
4073
+
4074
+ module.exports = {
4075
+ generateDRX,
4076
+ generateDRXBody,
4077
+ generateProtobuf,
4078
+ generateMultiNodeDRX,
4079
+ // DRX Audit Trail System
4080
+ generateDeltaDRX,
4081
+ computeDrxHash,
4082
+ parseAdjustments,
4083
+ parseAdjustmentsToNodes,
4084
+ createNode,
4085
+ createConnection,
4086
+ createResolution,
4087
+ buildPrimaryCorrectorParams,
4088
+ buildContrastCorrectorParams,
4089
+ buildCorrectorBlock,
4090
+ // PNT1: Parallel node support
4091
+ createParallelNodes,
4092
+ // PNT2: Layer mixer support
4093
+ createLayerMixer,
4094
+ // PNT3: Outside node support
4095
+ createOutsideNode,
4096
+ // Helper for professional node structures
4097
+ createNeutralNodeParams,
4098
+ // CR3: CDL export functionality
4099
+ ...cdlExporter,
4100
+ // Stub encoding functions (awaiting DRX training)
4101
+ buildHDRWheelParams,
4102
+ buildHueCorrectorParams,
4103
+ buildLumMixCorrectorParams,
4104
+ buildSatVsSatCorrectorParams,
4105
+ buildQualifierParams,
4106
+ buildRGBQualifierParams,
4107
+ buildLumaQualifierParams,
4108
+ buildWindowParams,
4109
+ buildCustomCurveParams,
4110
+ buildHSLCurveParams,
4111
+ hasSecondaryCorrections,
4112
+ isParamIdAvailable,
4113
+ // OFX / ResolveFX
4114
+ buildResolveFXParams,
4115
+ buildOFXToolEntry,
4116
+ RESOLVEFX_PLUGINS,
4117
+ // Validation
4118
+ validateDRXBody,
4119
+ // Constants
4120
+ PARAM_IDS,
4121
+ CORRECTOR_TYPES,
4122
+ NEUTRAL_GRADE,
4123
+ };