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,1808 @@
1
+ /**
2
+ * DRX Parser - Extract node structures from DaVinci Resolve DRX files
3
+ *
4
+ * Parses DRX files to extract:
5
+ * - Node definitions (id, position, label, enabled, parameters)
6
+ * - Connections between nodes
7
+ * - Metadata (resolution, timestamps)
8
+ *
9
+ * Uses shared DRX parameter library for consistent parameter handling
10
+ * across the the pipeline platform.
11
+ *
12
+ * @module drx/drx-parser
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const crypto = require('crypto');
17
+ // zstd-codec (WASM) with fzstd (pure JS) fallback for Electron asar bundles
18
+ let ZstdCodec;
19
+ try {
20
+ ZstdCodec = require('zstd-codec').ZstdCodec;
21
+ } catch {
22
+ ZstdCodec = null;
23
+ }
24
+
25
+ // Import shared DRX parameter library
26
+ const drxParams = require('../drx-parameters');
27
+
28
+ // Build PARAM_IDS from shared library for backwards compatibility
29
+ // This maps parameter IDs to their semantic meaning
30
+ const PARAM_IDS = {};
31
+ for (const [paramId, info] of Object.entries(drxParams.PARAM_ID_MAP)) {
32
+ PARAM_IDS[paramId] = {
33
+ name: `${info.control}.${info.channel}`,
34
+ control: info.control,
35
+ channel: info.channel,
36
+ };
37
+ }
38
+
39
+ // Re-export corrector types from shared library for backwards compatibility
40
+ const CORRECTOR_TYPES = drxParams.CORRECTOR_NAMES;
41
+
42
+ // ZSTD decoder — prefer Node's built-in zstd (Node 22+, available on Vercel)
43
+ // over zstd-codec (WASM) over fzstd (pure JS). See drx-generator.js for the
44
+ // reason: zstd-codec produced truncated frames on Vercel's runtime, and we
45
+ // want parser + generator to agree on which backend is authoritative.
46
+ const _nodeZlib = require('zlib');
47
+ const _HAS_NATIVE_ZSTD = typeof _nodeZlib.zstdDecompressSync === 'function';
48
+
49
+ let zstdCodec = null;
50
+
51
+ async function getZstdCodec() {
52
+ if (zstdCodec) return zstdCodec;
53
+
54
+ if (_HAS_NATIVE_ZSTD) {
55
+ zstdCodec = {
56
+ decompress(data, _size) {
57
+ const buf = data instanceof Buffer
58
+ ? data
59
+ : Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data));
60
+ return _nodeZlib.zstdDecompressSync(buf);
61
+ },
62
+ };
63
+ return zstdCodec;
64
+ }
65
+
66
+ if (ZstdCodec) {
67
+ return new Promise((resolve) => {
68
+ ZstdCodec.run((zstd) => {
69
+ zstdCodec = new zstd.Streaming();
70
+ resolve(zstdCodec);
71
+ });
72
+ });
73
+ }
74
+
75
+ const fzstd = require('fzstd');
76
+ zstdCodec = {
77
+ decompress(data, _size) {
78
+ return fzstd.decompress(data instanceof Uint8Array ? data : new Uint8Array(data));
79
+ },
80
+ };
81
+ return zstdCodec;
82
+ }
83
+
84
+ // P4A: LRU cache for parsed DRX content (keyed by SHA-256 hash of input)
85
+ // Avoids re-decompression + re-parsing for identical DRX data
86
+ const DRX_PARSE_CACHE_MAX = 50;
87
+ const drxParseCache = new Map();
88
+
89
+ function drxCacheGet(key) {
90
+ const entry = drxParseCache.get(key);
91
+ if (!entry) return undefined;
92
+ // Move to end (most recently used)
93
+ drxParseCache.delete(key);
94
+ drxParseCache.set(key, entry);
95
+ return entry;
96
+ }
97
+
98
+ function drxCacheSet(key, value) {
99
+ if (drxParseCache.size >= DRX_PARSE_CACHE_MAX) {
100
+ // Evict oldest (first) entry
101
+ const firstKey = drxParseCache.keys().next().value;
102
+ drxParseCache.delete(firstKey);
103
+ }
104
+ drxParseCache.set(key, value);
105
+ }
106
+
107
+ /**
108
+ * Protobuf parser for DRX body content
109
+ */
110
+ class ProtobufParser {
111
+ constructor(buffer) {
112
+ this.buffer = buffer;
113
+ this.pos = 0;
114
+ }
115
+
116
+ readVarint() {
117
+ let result = 0;
118
+ let shift = 0;
119
+ while (this.pos < this.buffer.length) {
120
+ const byte = this.buffer[this.pos++];
121
+ result |= (byte & 0x7f) << shift;
122
+ if ((byte & 0x80) === 0) break;
123
+ shift += 7;
124
+ if (shift > 35) throw new Error('Varint too long');
125
+ }
126
+ return result >>> 0;
127
+ }
128
+
129
+ readFixed32() {
130
+ const val = this.buffer.readUInt32LE(this.pos);
131
+ this.pos += 4;
132
+ return val;
133
+ }
134
+
135
+ readFloat32() {
136
+ const val = this.buffer.readFloatLE(this.pos);
137
+ this.pos += 4;
138
+ return val;
139
+ }
140
+
141
+ readFixed64() {
142
+ const low = this.buffer.readUInt32LE(this.pos);
143
+ const high = this.buffer.readUInt32LE(this.pos + 4);
144
+ this.pos += 8;
145
+ return { low, high };
146
+ }
147
+
148
+ readBytes(length) {
149
+ const bytes = this.buffer.slice(this.pos, this.pos + length);
150
+ this.pos += length;
151
+ return bytes;
152
+ }
153
+
154
+ parse(maxDepth = 10) {
155
+ const result = { _fields: [] };
156
+
157
+ while (this.pos < this.buffer.length && maxDepth > 0) {
158
+ const startPos = this.pos;
159
+ let tag;
160
+ try {
161
+ tag = this.readVarint();
162
+ } catch {
163
+ break;
164
+ }
165
+
166
+ if (tag === 0) break;
167
+
168
+ const fieldNum = tag >>> 3;
169
+ const wireType = tag & 0x7;
170
+
171
+ let value;
172
+ let rawValue;
173
+
174
+ try {
175
+ switch (wireType) {
176
+ case 0: // Varint
177
+ rawValue = this.readVarint();
178
+ value = rawValue;
179
+ break;
180
+ case 1: // 64-bit
181
+ rawValue = this.readFixed64();
182
+ value = rawValue;
183
+ break;
184
+ case 2: // Length-delimited
185
+ const length = this.readVarint();
186
+ rawValue = this.readBytes(length);
187
+ // Try to parse as nested protobuf
188
+ try {
189
+ const nestedParser = new ProtobufParser(rawValue);
190
+ value = nestedParser.parse(maxDepth - 1);
191
+ if (!value._fields || value._fields.length === 0) {
192
+ value = rawValue;
193
+ }
194
+ } catch {
195
+ value = rawValue;
196
+ }
197
+ break;
198
+ case 5: // 32-bit (fixed32/float)
199
+ rawValue = this.readFixed32();
200
+ const floatBuf = Buffer.alloc(4);
201
+ floatBuf.writeUInt32LE(rawValue);
202
+ value = floatBuf.readFloatLE(0);
203
+ break;
204
+ default:
205
+ break;
206
+ }
207
+ } catch (e) {
208
+ break;
209
+ }
210
+
211
+ const field = {
212
+ fieldNum,
213
+ wireType,
214
+ value,
215
+ rawValue,
216
+ pos: startPos,
217
+ };
218
+
219
+ result._fields.push(field);
220
+
221
+ const key = `F${fieldNum}`;
222
+ if (result[key] !== undefined) {
223
+ if (!Array.isArray(result[key])) {
224
+ result[key] = [result[key]];
225
+ }
226
+ result[key].push(value);
227
+ } else {
228
+ result[key] = value;
229
+ }
230
+ }
231
+
232
+ return result;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Extract string from a field that may have been incorrectly parsed as nested protobuf
238
+ * @param {Object} parentObj - The parent object containing the field
239
+ * @param {number} fieldNum - The field number to extract
240
+ * @returns {string} - The extracted string or empty string
241
+ */
242
+ function extractStringField(parentObj, fieldNum) {
243
+ const fieldKey = `F${fieldNum}`;
244
+ const fieldValue = parentObj[fieldKey];
245
+
246
+ // If it's already a Buffer, convert to string
247
+ if (Buffer.isBuffer(fieldValue)) {
248
+ return fieldValue.toString('utf8');
249
+ }
250
+
251
+ // If it's already a string, use it directly
252
+ if (typeof fieldValue === 'string') {
253
+ return fieldValue;
254
+ }
255
+
256
+ // If it's an object (incorrectly parsed as nested protobuf), extract rawValue from _fields
257
+ if (fieldValue && typeof fieldValue === 'object' && parentObj._fields) {
258
+ const field = parentObj._fields.find(f => f.fieldNum === fieldNum);
259
+ if (field && Buffer.isBuffer(field.rawValue)) {
260
+ return field.rawValue.toString('utf8');
261
+ }
262
+ }
263
+
264
+ return '';
265
+ }
266
+
267
+ // ColorSlice per-vector grid (Tier-2, RE'd 2026-06-22 — design note §16a). The grid
268
+ // param 0x86000606 (COLORSLICE.VECTOR_DATA) carries 7 repeated sub-messages at F24.F1,
269
+ // one per color vector, each: F1=enabled(varint), F3=sat(float32, default 1.0),
270
+ // F4=hue(float32, stored NEGATED of the UI value). Previously surfaced as an opaque
271
+ // NaN scalar; this lifts it into named per-vector params. (The per-vector "Center"
272
+ // values live in the sibling GRID_DATA_2 0x86000607 as a packed binary blob — not
273
+ // clean protobuf — so they are NOT decoded here; see §16a follow-up.)
274
+ const COLORSLICE_VECTOR_DATA_ID = 2248148486; // 0x86000606
275
+ const COLORSLICE_GRID2_ID = 2248148487; // 0x86000607 — per-vector "Center" values
276
+ const COLORSLICE_VECTORS = ['red', 'skin', 'yellow', 'green', 'cyan', 'blue', 'magenta'];
277
+
278
+ // Tier-2: HDR Zone adjustments (0x86000305). F16 → F1[] repeated zone sub-messages, each a
279
+ // raw buffer: 0a<len><name> 15<f32 exposure> 1d<f32 cbalY> 25<f32 cbalX> 3d<f32 saturation>.
280
+ // Zones differentiate by embedded name string, not param ID (RE'd 2026-06-22 on DRX_CALIB bars).
281
+ const HDR_ZONE_ADJUSTMENTS_ID = 2248147717; // 0x86000305
282
+
283
+ // Corrector-type-aware naming for the GRADIENT_WINDOW ↔ SAT_VS_SAT id collision (both 0x08F000xx,
284
+ // distinguished only by corrector type: gradient = 65554, sat-vs-sat = 3). The flat PARAM_IDS map
285
+ // resolves these to satVsSat.*; when the corrector is a gradient window, override to gradientWindow.*.
286
+ const GRADIENT_CORRECTOR_TYPE = 65554;
287
+ const GRADIENT_NAME_BY_ID = {
288
+ 149946369: 'gradientWindow.type', // 0x08F00001
289
+ 149946371: 'gradientWindow.rotation', // 0x08F00003
290
+ 149946373: 'gradientWindow.handle1Pos', // 0x08F00005
291
+ 149946374: 'gradientWindow.handle2Pos', // 0x08F00006
292
+ 149946377: 'gradientWindow.offsetX', // 0x08F00009
293
+ 149946378: 'gradientWindow.offsetY', // 0x08F0000A
294
+ 149946379: 'gradientWindow.softness', // 0x08F0000B
295
+ };
296
+
297
+ // ct-aware relabel for the polygon window (corrector type 6). The 0x08D000xx param ids are registered
298
+ // in PARAM_ID_MAP under LUM_MIX.PARAM_1–11 (control 'lumMix', correctorType 5) — but live R21 data
299
+ // (Session 4, §16a) shows they are the POLYGON-WINDOW geometry, appearing on a ct6 corrector. Rather
300
+ // than a sweeping LUM_MIX symbol rename (the LUM_MIX/ct5 pathway is woven through the generator +
301
+ // drp-format and the ct5↔ct6 id reuse was NOT measured to be safe), relabel ONLY when the id appears
302
+ // under ct6 — exactly the collision-fix pattern used for GRADIENT_NAME_BY_ID. The actual shape geometry
303
+ // is still surfaced structurally via node.params.polygonVertices/polygonMatrix regardless of this label.
304
+ const POLYGON_CORRECTOR_TYPE = 6;
305
+ const POLYGON_NAME_BY_ID = {
306
+ 147849218: 'polygonWindow.param1', // 0x08D00002
307
+ 147849220: 'polygonWindow.param2', // 0x08D00004
308
+ 147849222: 'polygonWindow.param3', // 0x08D00006 (shape vertex ring — see node.params.polygonVertices)
309
+ 147849223: 'polygonWindow.param4', // 0x08D00007 (soft-edge vertex ring)
310
+ 147849224: 'polygonWindow.param5', // 0x08D00008 (soft-edge vertex ring)
311
+ 147849225: 'polygonWindow.param6', // 0x08D00009
312
+ 147849226: 'polygonWindow.param7', // 0x08D0000A
313
+ 147849227: 'polygonWindow.param8', // 0x08D0000B
314
+ 147849228: 'polygonWindow.param9', // 0x08D0000C
315
+ 147849232: 'polygonWindow.param10', // 0x08D00010
316
+ 147849233: 'polygonWindow.param11', // 0x08D00011
317
+ };
318
+
319
+ // The per-vector "Center" values live in GRID_DATA_2 (0x86000607) as a PACKED float32
320
+ // array — structure F12{F1{<packed 7×float32>}} — which is NOT valid nested protobuf
321
+ // (the generic parser misreads the packed bytes), so read it from the raw F2 bytes.
322
+ // (RE'd 2026-06-22 — design note §16a, C.) Returns the 7 center floats (identity scale).
323
+ function decodeColorSliceCenters(f2Bytes) {
324
+ if (!Buffer.isBuffer(f2Bytes) && !(f2Bytes instanceof Uint8Array)) return null;
325
+ const buf = Buffer.from(f2Bytes);
326
+ // Walk length-delimited fields to descend F12 → F1, then read the packed float32s.
327
+ const findField = (start, end, target) => {
328
+ let p = start;
329
+ while (p < end) {
330
+ const tag = buf[p++]; const fnum = tag >>> 3; const wt = tag & 7;
331
+ if (wt === 2) {
332
+ let len = 0, s = 0, b;
333
+ do { b = buf[p++]; len |= (b & 0x7f) << s; s += 7; } while (b & 0x80);
334
+ const cs = p; p += len;
335
+ if (fnum === target) return [cs, cs + len];
336
+ } else if (wt === 0) { while (buf[p] & 0x80) p++; p++; }
337
+ else if (wt === 5) { p += 4; } else if (wt === 1) { p += 8; } else return null;
338
+ }
339
+ return null;
340
+ };
341
+ const f12 = findField(0, buf.length, 12); if (!f12) return null;
342
+ const f1 = findField(f12[0], f12[1], 1); if (!f1) return null;
343
+ const [s, e] = f1; const n = Math.floor((e - s) / 4); const out = [];
344
+ for (let i = 0; i < n; i++) out.push(buf.readFloatLE(s + i * 4));
345
+ return out;
346
+ }
347
+
348
+ // The grid sits ~11 levels deep but the generic parser stops at maxDepth=10, so the
349
+ // per-vector sub-messages arrive as raw Buffers — re-parse them on demand here.
350
+ function ensureParsed(x) {
351
+ if (Buffer.isBuffer(x) || x instanceof Uint8Array) {
352
+ try {
353
+ const p = new ProtobufParser(Buffer.from(x)).parse(40);
354
+ return p && p._fields && p._fields.length ? p : null;
355
+ } catch { return null; }
356
+ }
357
+ return x && typeof x === 'object' ? x : null;
358
+ }
359
+
360
+ function decodeColorSliceGrid(gridMsg) {
361
+ const msg = ensureParsed(gridMsg);
362
+ if (!msg) return null;
363
+ const f24 = ensureParsed(msg.F24);
364
+ if (!f24 || f24.F1 === undefined) return null;
365
+ const vecs = Array.isArray(f24.F1) ? f24.F1 : [f24.F1];
366
+ return vecs.map((raw, i) => {
367
+ const v = ensureParsed(raw) || {};
368
+ return {
369
+ vector: COLORSLICE_VECTORS[i] || `vec${i}`,
370
+ enabled: v.F1 !== undefined ? v.F1 === 1 : true,
371
+ sat: typeof v.F3 === 'number' ? v.F3 : 1, // F3 = saturation (default 1.0)
372
+ hue: typeof v.F4 === 'number' ? -v.F4 : 0, // F4 = hue, stored negated → report UI value
373
+ };
374
+ });
375
+ }
376
+
377
+ // Byte-walk one HDR zone sub-message buffer → {name, exposure, saturation, colorBalanceX/Y}.
378
+ // Fields: F1(0x0a)=name str, F2(0x15)=exposure f32, F3(0x1d)=cbalY f32, F4(0x25)=cbalX f32,
379
+ // F7(0x3d)=saturation f32. Default exposure 0, saturation 1.
380
+ function parseHdrZone(raw) {
381
+ if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) return null;
382
+ const buf = Buffer.from(raw);
383
+ const z = { name: null, exposure: 0, saturation: 1, colorBalanceX: 0, colorBalanceY: 0 };
384
+ let p = 0;
385
+ while (p < buf.length) {
386
+ const tag = buf[p++]; const fnum = tag >>> 3; const wt = tag & 7;
387
+ if (wt === 2) {
388
+ let len = 0, s = 0, b; do { b = buf[p++]; len |= (b & 0x7f) << s; s += 7; } while (b & 0x80);
389
+ if (fnum === 1) z.name = buf.toString('utf8', p, p + len);
390
+ p += len;
391
+ } else if (wt === 5) {
392
+ const f = buf.readFloatLE(p); p += 4;
393
+ if (fnum === 2) z.exposure = f;
394
+ else if (fnum === 3) z.colorBalanceY = f;
395
+ else if (fnum === 4) z.colorBalanceX = f;
396
+ else if (fnum === 7) z.saturation = f;
397
+ } else if (wt === 0) { while (buf[p] & 0x80) p++; p++; }
398
+ else if (wt === 1) { p += 8; }
399
+ else break;
400
+ }
401
+ return z.name ? z : null;
402
+ }
403
+
404
+ // Tier-2: 3D-qualifier selection volume (0x0830002A) — byte-level RE'd 2026-07-02 off the
405
+ // qualifier-3d fixture (key stroke across the bars, 8 samples). Layout: value envelope
406
+ // { F5: <packed buffer> } where the buffer = 9 × uint64 BIG-endian header (fields 0-7
407
+ // semantics unconfirmed — observed [2,1,1,0x88,7,0,0x40,0xBA]; field 8 = SAMPLE COUNT)
408
+ // followed by count × 3 float32 LITTLE-endian samples (x, y, radius) — the keyer's
409
+ // sampled chroma-plane point cloud (stroke path; radius observed constant 0.015).
410
+ const QUALIFIER_3D_VOLUME_ID = 0x0830002a; // 137363498
411
+ function decodeQualifier3dVolume(env) {
412
+ const m = ensureParsed(env);
413
+ if (!m || !Buffer.isBuffer(m.F5)) return null;
414
+ const buf = m.F5;
415
+ if (buf.length < 72) return null;
416
+ const header = [];
417
+ for (let i = 0; i < 9; i++) header.push(Number(buf.readBigUInt64BE(i * 8)));
418
+ const count = header[8];
419
+ const samples = [];
420
+ for (let i = 0; i < count && 72 + (i + 1) * 12 <= buf.length; i++) {
421
+ const off = 72 + i * 12;
422
+ samples.push({ x: buf.readFloatLE(off), y: buf.readFloatLE(off + 4), radius: buf.readFloatLE(off + 8) });
423
+ }
424
+ return samples.length ? { header, count, samples } : null;
425
+ }
426
+
427
+ // Tier-2: decode the HDR ZONE_ADJUSTMENTS blob (0x86000305) into per-zone named params.
428
+ function decodeHdrZones(adjMsg) {
429
+ const msg = ensureParsed(adjMsg);
430
+ if (!msg) return null;
431
+ const f16 = ensureParsed(msg.F16);
432
+ if (!f16 || f16.F1 === undefined) return null;
433
+ const zones = Array.isArray(f16.F1) ? f16.F1 : [f16.F1];
434
+ return zones.map((raw) => parseHdrZone(raw)).filter(Boolean);
435
+ }
436
+
437
+ // Node "Input Sizing" (RE'd 2026-06-22, harness §16a). Stored outside the F9 corrector list in a
438
+ // transform structure whose param entries have a fixed signature in the decompressed body:
439
+ // 1a 0d 08 <kx> 80 c0 <kg> 01 12 05 0d <f32>
440
+ // where <kg>=group (0x81=A pan/tilt/zoom/rotate/width/height, 0x85=B pitch/yaw) and <kx>=param index.
441
+ // Pan/Tilt are normalized (UI px / frame dim); Zoom/Width/Height = direct mult; Rotate = degrees.
442
+ const SIZING_MAP = {
443
+ '81:81': { channel: 'width', id: 0x10310001 },
444
+ '82:81': { channel: 'height', id: 0x10310002 },
445
+ '83:81': { channel: 'zoom', id: 0x10310003 },
446
+ '84:81': { channel: 'rotate', id: 0x10310004 },
447
+ '85:81': { channel: 'pan', id: 0x10310005, normalized: 'width' },
448
+ '86:81': { channel: 'tilt', id: 0x10310006, normalized: 'height' },
449
+ '81:85': { channel: 'pitch', id: 0x10B10001 },
450
+ '82:85': { channel: 'yaw', id: 0x10B10002 },
451
+ };
452
+ function decodeNodeSizing(buf) {
453
+ if (!Buffer.isBuffer(buf)) return null;
454
+ const out = [];
455
+ for (let i = 0; i + 15 <= buf.length; i++) {
456
+ // 1a 0d 08 <kx> 80 c0 <kg> 01 12 05 0d <f32>
457
+ if (buf[i] !== 0x1a || buf[i + 1] !== 0x0d || buf[i + 2] !== 0x08) continue;
458
+ if (buf[i + 4] !== 0x80 || buf[i + 5] !== 0xc0 || buf[i + 7] !== 0x01) continue;
459
+ if (buf[i + 8] !== 0x12 || buf[i + 9] !== 0x05 || buf[i + 10] !== 0x0d) continue;
460
+ const kx = buf[i + 3], kg = buf[i + 6];
461
+ const info = SIZING_MAP[`${kx.toString(16)}:${kg.toString(16)}`];
462
+ if (!info) continue;
463
+ out.push({ ...info, value: buf.readFloatLE(i + 11) });
464
+ }
465
+ return out.length ? out : null;
466
+ }
467
+
468
+ // Curve spline point: a sub-message `0d<f32 x> 15<f32 y>` — F1(fixed32)=x, F2(fixed32)=y.
469
+ // COORDINATE SPACE DIFFERS BY CURVE TYPE (RE'd 2026-06-22):
470
+ // - Custom Y/R/G/B (0x86000506–509): ~0–1024 (10-bit), tangent handles beyond [0,1024].
471
+ // - HSL curves (0x86000400–405): NORMALIZED — x∈[-1,1] (hue axis, periodic → wrap handles beyond ±1),
472
+ // y∈[0,1] with 0.5=neutral. (Hue-vs-Hue: y = 0.5 − HueRotate°/360.)
473
+ // The decoder is space-agnostic — it just reads the raw f32 x/y; the consumer interprets per curve.
474
+ function parseSplinePoint(raw) {
475
+ if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) return null;
476
+ const buf = Buffer.from(raw);
477
+ let p = 0; let x, y;
478
+ while (p < buf.length) {
479
+ const tag = buf[p++]; const fnum = tag >>> 3; const wt = tag & 7;
480
+ if (wt === 5) { const f = buf.readFloatLE(p); p += 4; if (fnum === 1) x = f; else if (fnum === 2) y = f; }
481
+ else if (wt === 2) { let len = 0, s = 0, b; do { b = buf[p++]; len |= (b & 0x7f) << s; s += 7; } while (b & 0x80); p += len; }
482
+ else if (wt === 0) { while (buf[p] & 0x80) p++; p++; }
483
+ else if (wt === 1) { p += 8; } else break;
484
+ }
485
+ return (x !== undefined || y !== undefined) ? { x: x ?? 0, y: y ?? 0 } : null;
486
+ }
487
+ // Spline data params (custom curves 0x86000506–509, HSL curves 0x86000400–405) store points in F8.F1[].
488
+ const SPLINE_POINT_IDS = new Set([
489
+ 0x86000506, 0x86000507, 0x86000508, 0x86000509, // custom Y/R/G/B
490
+ 0x86000400, 0x86000401, 0x86000402, 0x86000403, 0x86000404, 0x86000405, // HSL curves
491
+ ]);
492
+ function decodeSplinePoints(splineMsg) {
493
+ const msg = ensureParsed(splineMsg);
494
+ if (!msg) return null;
495
+ const f8 = ensureParsed(msg.F8);
496
+ if (!f8 || f8.F1 === undefined) return null;
497
+ const pts = Array.isArray(f8.F1) ? f8.F1 : [f8.F1];
498
+ return pts.map(parseSplinePoint).filter(Boolean);
499
+ }
500
+
501
+ // Structural containers found in the completeness sweep (RE'd 2026-06-22 off the
502
+ // gradient-window fixture, which itself is a live BARS capture):
503
+ // 0x0830006f (qualifier corrector, ct2): value envelope = { F2: <varint> } — an
504
+ // internal qualifier mode flag (registry MODE_FLAG; observed 4). Lift the varint.
505
+ // 0x88f0000d (gradient corrector, ct65554): value envelope = { F10: { F1..F9 } } — a
506
+ // row-major 3×3 matrix (identity by default). Lift the 9 cells into an array.
507
+ const QUALIFIER_MODE_FLAG_ID = 137363567; // 0x0830006f
508
+ const GRADIENT_MATRIX_ID = 2297430029; // 0x88f0000d
509
+ // The gradient window (0x88f0000d), polygon shape (0x88d00014) and LINEAR-window softness mask
510
+ // (0x8870001e, ct3) all carry the same structural F10 3×3 matrix container (identity default).
511
+ // RE'd 2026-06-22 (gradient + polygon + linear-softness fixtures).
512
+ const LINEAR_SOFT_MATRIX_ID = 2289041438; // 0x8870001e
513
+ const STRUCT_MATRIX_NAMES = {
514
+ [GRADIENT_MATRIX_ID]: { name: 'gradientWindow.matrix', key: 'gradientMatrix' },
515
+ 2295332884: { name: 'polygonShape.matrix', key: 'polygonMatrix' }, // 0x88d00014
516
+ [LINEAR_SOFT_MATRIX_ID]: { name: 'window.softMatrix', key: 'softMatrix' }, // 0x8870001e (ct3)
517
+ };
518
+ const STRUCT_MATRIX_IDS = new Set(Object.keys(STRUCT_MATRIX_NAMES).map(Number));
519
+ function decodeStructMatrix(env) {
520
+ const m = ensureParsed(env);
521
+ if (!m) return null;
522
+ const f10 = ensureParsed(m.F10);
523
+ if (!f10) return null;
524
+ const out = [];
525
+ for (let i = 1; i <= 9; i++) {
526
+ const v = f10[`F${i}`];
527
+ out.push(typeof v === 'number' ? v : 0);
528
+ }
529
+ return out;
530
+ }
531
+
532
+ // Polygon shape vertices — RE'd 2026-06-22 on DRX_CALIB (drew a 4-vertex polygon window). The shape
533
+ // lives under CORRECTOR TYPE 6 (NOT the registry's claimed 0x08B0/ct5; that POLYGON_WINDOW block is
534
+ // UNVALIDATED). Vertices are F9.F1[] point sub-messages — the SAME `0d<f32 x>15<f32 y>` codec as curve
535
+ // splines but under F9 (splines use F8) — in FRAME-PIXELS from center. IDs 0x08D00006/07/08 each carry
536
+ // the vertex ring (shape + inner/outer soft edges). These IDs are currently mislabeled "lumMix.*" in
537
+ // the registry (a deeper rename is deferred — see ledger). The vertices are surfaced additively here.
538
+ const POLYGON_VERTEX_IDS = new Set([0x08d00006, 0x08d00007, 0x08d00008]);
539
+ const LINEAR_SOFT_BBOX_ID = 141557780; // 0x08700014 — linear-window softness-mask 4-corner ring (ct3)
540
+ function decodePolygonVertices(env) {
541
+ const m = ensureParsed(env);
542
+ if (!m) return null;
543
+ const f9 = ensureParsed(m.F9);
544
+ if (!f9 || f9.F1 === undefined) return null;
545
+ const pts = Array.isArray(f9.F1) ? f9.F1 : [f9.F1];
546
+ return pts.map(parseSplinePoint).filter(Boolean);
547
+ }
548
+
549
+ // Color Warper (chroma warp) pin list — RE'd LIVE 2026-06-22 on DRX_CALIB (Resolve 21, harness §16a).
550
+ // The registry's old mesh-vertex model (0x86000121 triplets) is WRONG for R21; the real shape is a pin
551
+ // list at 0x86000138: value envelope = { F27: { F1: [ <pin>, … ] } }. Each pin sub-message:
552
+ // F1=id, F2/F3=source chroma XY, F4/F5=dest chroma XY, F6=chromaRange, F7=exposure (skip-if-0),
553
+ // F8=tonalLow, F9=tonalHigh, F10=tonalPivot. Floats are identity-scale vs the UI Pin controls.
554
+ const COLOR_WARPER_PINS_ID = 0x86000138; // 2248147256
555
+ function parseColorWarperPin(raw) {
556
+ const m = ensureParsed(raw);
557
+ if (!m) return null;
558
+ const num = (v) => (typeof v === 'number' ? v : undefined);
559
+ const pin = {
560
+ id: num(m.F1),
561
+ srcX: num(m.F2), srcY: num(m.F3),
562
+ dstX: num(m.F4), dstY: num(m.F5),
563
+ chromaRange: num(m.F6),
564
+ exposure: num(m.F7) ?? 0,
565
+ tonalLow: num(m.F8), tonalHigh: num(m.F9), tonalPivot: num(m.F10),
566
+ };
567
+ // A real pin always carries the chroma source/dest coords.
568
+ return (pin.srcX !== undefined && pin.dstX !== undefined) ? pin : null;
569
+ }
570
+ function decodeColorWarperPins(env) {
571
+ const m = ensureParsed(env);
572
+ if (!m) return null;
573
+ const f27 = ensureParsed(m.F27);
574
+ if (!f27 || f27.F1 === undefined) return null;
575
+ const pins = Array.isArray(f27.F1) ? f27.F1 : [f27.F1];
576
+ return pins.map(parseColorWarperPin).filter(Boolean);
577
+ }
578
+
579
+ // Keyframe tracks — RE'd + validated LIVE 2026-06-22 (§16a OPEN ITEM 2, 3-keyframe sweep). When a corrector
580
+ // is keyframed its F6 is a REPEATED field: one block per keyframe. Each block = { F1: timeUnits, F2: {F3:[params]} }
581
+ // where timeUnits = frame × 2 (half-frame/field units; F1 is ABSENT on block 0 = frame 0). Validated:
582
+ // frames 0/60/119 → F1 0/120/238, matte param 0xc30001d → 0.40/1.10/1.70. Builds paramId → [{frame,value}].
583
+ function buildKeyframeTracks(f6Blocks) {
584
+ const tracks = {};
585
+ for (const raw of f6Blocks) {
586
+ const b = ensureParsed(raw);
587
+ if (!b) continue;
588
+ const frame = typeof b.F1 === 'number' ? b.F1 / 2 : 0;
589
+ const cont = ensureParsed(b.F2);
590
+ if (!cont || cont.F3 === undefined) continue;
591
+ const params = Array.isArray(cont.F3) ? cont.F3 : [cont.F3];
592
+ for (const p of params) {
593
+ const pp = ensureParsed(p);
594
+ if (!pp || pp.F1 === undefined) continue;
595
+ let v = pp.F2;
596
+ if (v && typeof v === 'object' && v.F1 !== undefined) v = v.F1;
597
+ if (typeof v !== 'number') continue;
598
+ (tracks[pp.F1] || (tracks[pp.F1] = [])).push({ frame, value: v });
599
+ }
600
+ }
601
+ for (const k of Object.keys(tracks)) tracks[k].sort((a, b) => a.frame - b.frame);
602
+ return tracks;
603
+ }
604
+
605
+ /**
606
+ * Extract node data from parsed protobuf
607
+ * @param {Buffer} [rawBody] decompressed body — enables the node-Sizing body-scan (single-node graphs).
608
+ */
609
+ function extractNodes(parsed, rawBody) {
610
+ const nodes = [];
611
+
612
+ if (!parsed.F1 || !parsed.F1.F7) return nodes;
613
+
614
+ const nodeFields = Array.isArray(parsed.F1.F7) ? parsed.F1.F7 : [parsed.F1.F7];
615
+
616
+ for (const nodeData of nodeFields) {
617
+ if (!nodeData._fields) continue;
618
+
619
+ const node = {
620
+ id: nodeData.F1,
621
+ index: nodeData.F2,
622
+ xPos: nodeData.F4,
623
+ yPos: nodeData.F5,
624
+ label: extractStringField(nodeData, 6),
625
+ // Node color — RE'd LIVE 2026-06-22 (node-convention/provenance spec). Stored in node field F15 as a
626
+ // plain string "ClipColor<Name>" (e.g. ClipColorBlue); absent = no color. Surface the bare color name.
627
+ color: (() => { const s = extractStringField(nodeData, 15); return s ? s.replace(/^ClipColor/, '') : null; })(),
628
+ enabled: nodeData.F7 === 1,
629
+ keyframed: false, // set true when a corrector carries a repeated F6 (keyframe relocation, §16a OPEN ITEM 2)
630
+ correctors: [],
631
+ // Structured parameters for easy merging
632
+ params: {
633
+ lift: { r: 0, g: 0, b: 0, master: 0 },
634
+ gamma: { r: 0, g: 0, b: 0, master: 0 },
635
+ gain: { r: 1, g: 1, b: 1, master: 1 },
636
+ offset: { r: 0, g: 0, b: 0 },
637
+ saturation: 50, // Resolve Primaries: 0-100, unity=50
638
+ contrast: 1.0,
639
+ pivot: 0.435,
640
+ },
641
+ };
642
+
643
+ // OFX/ResolveFX tool list (node field F10) — plugin params are SELF-DESCRIBING on
644
+ // the wire (name string + float64/string value; enums are label strings), so no
645
+ // per-plugin registry is needed to read them. Verified against real project grades
646
+ // 2026-07-03 (filmgrain/CST/acestransform; 5,977 instances decoded by name). Values
647
+ // are surfaced RAW — float scaling vs the UI is NOT panel-confirmed yet, so treat
648
+ // per valueFidelity until a capture-sweep lands.
649
+ if (nodeData.F10) {
650
+ try {
651
+ // The generic parser recursively converts nested messages to {_fields} objects;
652
+ // extract-ofx-params wants the raw entry BYTES — recover them from rawValue.
653
+ let toolList = nodeData.F10;
654
+ if (toolList && toolList._fields) {
655
+ toolList = toolList._fields
656
+ .filter((f) => f.fieldNum === 1 && f.wireType === 2)
657
+ .map((f) => (Buffer.isBuffer(f.value) ? f.value : f.rawValue))
658
+ .filter(Buffer.isBuffer);
659
+ }
660
+ const ofxTools = require('./extract-ofx-params').extractOFXTools(toolList);
661
+ if (ofxTools.length) node.ofxTools = ofxTools;
662
+ } catch { /* malformed tool list — leave node.ofxTools unset */ }
663
+ }
664
+
665
+ // Extract correctors from Field 9
666
+ if (nodeData.F9 && nodeData.F9._fields) {
667
+ const correctorFields = nodeData.F9.F1 ? (Array.isArray(nodeData.F9.F1) ? nodeData.F9.F1 : [nodeData.F9.F1]) : [];
668
+
669
+ for (const corrector of correctorFields) {
670
+ if (!corrector._fields) continue;
671
+
672
+ const correctorType = corrector.F1;
673
+ const correctorInfo = {
674
+ type: correctorType,
675
+ typeName: CORRECTOR_TYPES[correctorType] || `Type${correctorType}`,
676
+ enabled: corrector.F3 === 1,
677
+ parameters: [],
678
+ };
679
+
680
+ // Keyframed grades relocate each corrector's values: F6 becomes a REPEATED field (a static base
681
+ // block + a keyframe-track block, the latter carrying an extra F1 marker). RE'd live 2026-06-22
682
+ // (§16a OPEN ITEM 2). Without this the static path (corrector.F6.F2) was undefined → 0 params for
683
+ // a keyframed grade. Read params from the base block (the one carrying F2.F3) and flag the node.
684
+ let f6 = corrector.F6;
685
+ if (Array.isArray(f6)) {
686
+ node.keyframed = true;
687
+ // Full keyframe-track decode: paramId → [{frame, value}] across all keyframe blocks.
688
+ const tracks = buildKeyframeTracks(f6);
689
+ const named = {};
690
+ for (const [pid, pts] of Object.entries(tracks)) {
691
+ if (pts.length < 2) continue; // a single point isn't an animation
692
+ const info = PARAM_IDS[pid];
693
+ named[info ? info.name : `param_${pid}`] = pts;
694
+ }
695
+ if (Object.keys(named).length) {
696
+ (node.keyframes || (node.keyframes = [])).push({ correctorType, tracks: named });
697
+ }
698
+ // Static-snapshot base block (the first block / frame 0) for the normal param path.
699
+ f6 = f6.find((b) => b && b.F2 && b.F2.F3 !== undefined) || f6[0];
700
+ }
701
+
702
+ // Extract parameters from Field 6 -> Field 2 -> Field 3[]
703
+ if (f6 && f6.F2) {
704
+ const paramContainer = f6.F2;
705
+ const paramFields = paramContainer.F3 ? (Array.isArray(paramContainer.F3) ? paramContainer.F3 : [paramContainer.F3]) : [];
706
+
707
+ for (const param of paramFields) {
708
+ if (!param._fields) continue;
709
+
710
+ const paramId = param.F1;
711
+ let paramValue = param.F2;
712
+
713
+ // Tier-2: ColorSlice per-vector grid (0x86000606) — decode the F24
714
+ // sub-messages into named per-vector params instead of an opaque NaN.
715
+ if (paramId === COLORSLICE_VECTOR_DATA_ID) {
716
+ const grid = decodeColorSliceGrid(param.F2);
717
+ if (grid && grid.length) {
718
+ node.params.colorSlice = grid;
719
+ for (const vec of grid) {
720
+ correctorInfo.parameters.push({ id: paramId, value: vec.enabled ? 1 : 0, name: `colorSlice.${vec.vector}.enabled`, vector: vec.vector, field: 'enabled' });
721
+ correctorInfo.parameters.push({ id: paramId, value: vec.sat, name: `colorSlice.${vec.vector}.sat`, vector: vec.vector, field: 'sat' });
722
+ correctorInfo.parameters.push({ id: paramId, value: vec.hue, name: `colorSlice.${vec.vector}.hue`, vector: vec.vector, field: 'hue' });
723
+ }
724
+ continue;
725
+ }
726
+ }
727
+
728
+ // Tier-2: ColorSlice per-vector "Center" values — a packed float32 array
729
+ // in GRID_DATA_2 (0x86000607), read from the raw F2 bytes (see §16a, C).
730
+ if (paramId === COLORSLICE_GRID2_ID) {
731
+ const f2 = param._fields && param._fields.find((f) => f.fieldNum === 2);
732
+ const centers = f2 ? decodeColorSliceCenters(f2.rawValue) : null;
733
+ if (centers && centers.length) {
734
+ if (!Array.isArray(node.params.colorSlice)) {
735
+ node.params.colorSlice = centers.map((_, i) => ({ vector: COLORSLICE_VECTORS[i] || `vec${i}` }));
736
+ }
737
+ centers.forEach((c, i) => {
738
+ if (node.params.colorSlice[i]) node.params.colorSlice[i].center = c;
739
+ correctorInfo.parameters.push({ id: paramId, value: c, name: `colorSlice.${COLORSLICE_VECTORS[i] || 'vec' + i}.center`, vector: COLORSLICE_VECTORS[i], field: 'center' });
740
+ });
741
+ continue;
742
+ }
743
+ }
744
+
745
+ // Tier-2: HDR zone adjustments (0x86000305) — lift the nested per-zone
746
+ // exposure/saturation/color-balance out of the opaque blob into named params.
747
+ if (paramId === HDR_ZONE_ADJUSTMENTS_ID) {
748
+ const zones = decodeHdrZones(param.F2);
749
+ if (zones && zones.length) {
750
+ node.params.hdrZones = zones;
751
+ for (const z of zones) {
752
+ const key = z.name.toLowerCase();
753
+ correctorInfo.parameters.push({ id: paramId, value: z.exposure, name: `hdrZone.${key}.exposure`, zone: z.name, field: 'exposure' });
754
+ correctorInfo.parameters.push({ id: paramId, value: z.saturation, name: `hdrZone.${key}.saturation`, zone: z.name, field: 'saturation' });
755
+ if (z.colorBalanceX) correctorInfo.parameters.push({ id: paramId, value: z.colorBalanceX, name: `hdrZone.${key}.colorBalanceX`, zone: z.name, field: 'colorBalanceX' });
756
+ if (z.colorBalanceY) correctorInfo.parameters.push({ id: paramId, value: z.colorBalanceY, name: `hdrZone.${key}.colorBalanceY`, zone: z.name, field: 'colorBalanceY' });
757
+ }
758
+ continue;
759
+ }
760
+ }
761
+
762
+ // Tier-2: 3D-qualifier selection volume (0x0830002A) — lift the packed sample
763
+ // point cloud (see decodeQualifier3dVolume) ADDITIVELY onto node.params.
764
+ if (paramId === QUALIFIER_3D_VOLUME_ID && !node.params.qualifier3d) {
765
+ const vol = decodeQualifier3dVolume(param.F2);
766
+ if (vol) {
767
+ node.params.qualifier3d = vol;
768
+ correctorInfo.parameters.push({ id: paramId, value: vol.count, name: 'qualifier.volume3dSampleCount' });
769
+ }
770
+ }
771
+
772
+ // Blob RE: curve spline points (custom + HSL curves) — lift F8.F1[] point
773
+ // sub-messages into {x,y} arrays (coords in ~0–1024 space) on node.params.curvePoints.
774
+ // ADDITIVE: don't touch the param's own value (the extractCustomCurves/HSL secondary
775
+ // decoders still read param.value) — just expose the decoded points alongside.
776
+ if (SPLINE_POINT_IDS.has(paramId)) {
777
+ const pts = decodeSplinePoints(param.F2);
778
+ if (pts && pts.length) {
779
+ const info = PARAM_IDS[paramId];
780
+ const key = info ? info.name : `spline_${paramId}`;
781
+ if (!node.params.curvePoints) node.params.curvePoints = {};
782
+ node.params.curvePoints[key] = pts;
783
+ }
784
+ }
785
+
786
+ // Tier-2: Color Warper chroma-warp pin list (0x86000138) — lift F2.F27.F1[] pins into
787
+ // named colorWarper.pin<N>.<field> params + node.params.colorWarper (RE'd live, §16a).
788
+ if (paramId === COLOR_WARPER_PINS_ID) {
789
+ const pins = decodeColorWarperPins(param.F2);
790
+ if (pins && pins.length) {
791
+ node.params.colorWarper = pins;
792
+ pins.forEach((pin, pi) => {
793
+ for (const [field, val] of Object.entries(pin)) {
794
+ if (val !== undefined) {
795
+ correctorInfo.parameters.push({ id: paramId, value: val, name: `colorWarper.pin${pi}.${field}`, pin: pi, field });
796
+ }
797
+ }
798
+ });
799
+ continue;
800
+ }
801
+ }
802
+
803
+ // Structural container: 3×3 matrix (gradient window 0x88f0000d ct65554, polygon shape
804
+ // 0x88d00014 ct6) — lift F10's 9 cells into an array instead of an opaque unknown_ object.
805
+ if (STRUCT_MATRIX_IDS.has(paramId)) {
806
+ const mtx = decodeStructMatrix(param.F2);
807
+ if (mtx) {
808
+ const meta = STRUCT_MATRIX_NAMES[paramId];
809
+ node.params[meta.key] = mtx;
810
+ correctorInfo.parameters.push({ id: paramId, value: mtx, name: meta.name });
811
+ continue;
812
+ }
813
+ }
814
+ // Polygon shape vertices (additive): lift the F9.F1[] point ring into node.params.polygonVertices.
815
+ if (POLYGON_VERTEX_IDS.has(paramId) && !node.params.polygonVertices) {
816
+ const verts = decodePolygonVertices(param.F2);
817
+ if (verts && verts.length) node.params.polygonVertices = verts;
818
+ }
819
+ // Linear-window softness mask bbox (0x08700014, ct3): same F9.F1[] point ring (the 4 corners).
820
+ if (paramId === LINEAR_SOFT_BBOX_ID && !node.params.softVertices) {
821
+ const verts = decodePolygonVertices(param.F2);
822
+ if (verts && verts.length) node.params.softVertices = verts;
823
+ }
824
+ // Structural container: qualifier internal mode flag (0x0830006f, ct2) —
825
+ // lift the varint out of the { F2 } envelope.
826
+ if (paramId === QUALIFIER_MODE_FLAG_ID && correctorType === 2) {
827
+ const mf = param.F2 && typeof param.F2.F2 === 'number' ? param.F2.F2 : null;
828
+ correctorInfo.parameters.push({ id: paramId, value: mf, name: 'qualifier.modeFlag' });
829
+ continue;
830
+ }
831
+
832
+ if (paramValue && paramValue.F1 !== undefined) {
833
+ paramValue = paramValue.F1;
834
+ }
835
+
836
+ const paramInfo = PARAM_IDS[paramId];
837
+ // ct-aware override: gradient-window params collide with sat-vs-sat, and polygon-window
838
+ // geometry (ct6) is mis-registered under LUM_MIX (ct5) — relabel by corrector type.
839
+ const ctName =
840
+ (correctorType === GRADIENT_CORRECTOR_TYPE && GRADIENT_NAME_BY_ID[paramId]) ||
841
+ (correctorType === POLYGON_CORRECTOR_TYPE && POLYGON_NAME_BY_ID[paramId]) ||
842
+ null;
843
+ correctorInfo.parameters.push({
844
+ id: paramId,
845
+ value: paramValue,
846
+ name: ctName || (paramInfo ? paramInfo.name : `unknown_${paramId}`),
847
+ });
848
+
849
+ // Map to structured params
850
+ if (paramInfo) {
851
+ if (paramInfo.control === 'lift' && paramInfo.channel) {
852
+ node.params.lift[paramInfo.channel] = paramValue;
853
+ } else if (paramInfo.control === 'gamma' && paramInfo.channel) {
854
+ node.params.gamma[paramInfo.channel] = paramValue;
855
+ } else if (paramInfo.control === 'gain' && paramInfo.channel) {
856
+ node.params.gain[paramInfo.channel] = paramValue;
857
+ } else if (paramInfo.control === 'offset' && paramInfo.channel) {
858
+ node.params.offset[paramInfo.channel] = paramValue;
859
+ } else if (paramInfo.control === 'saturation') {
860
+ node.params.saturation = paramValue;
861
+ } else if (paramInfo.control === 'contrast') {
862
+ node.params.contrast = paramValue;
863
+ } else if (paramInfo.control === 'pivot') {
864
+ node.params.pivot = paramValue;
865
+ }
866
+ }
867
+ }
868
+ }
869
+
870
+ node.correctors.push(correctorInfo);
871
+ }
872
+ }
873
+
874
+ nodes.push(node);
875
+ }
876
+
877
+ // Node "Input Sizing" lives outside the corrector list; scan the raw body for its signature.
878
+ // The scan can't attribute entries to a specific node, so only attach for single-node graphs.
879
+ if (rawBody && nodes.length === 1) {
880
+ const sizing = decodeNodeSizing(rawBody);
881
+ if (sizing && sizing.length) {
882
+ const node = nodes[0];
883
+ node.params.sizing = {};
884
+ for (const s of sizing) {
885
+ node.params.sizing[s.channel] = s.value;
886
+ node.correctors[0]?.parameters.push({ id: s.id, value: s.value, name: `sizing.${s.channel}`, channel: s.channel, normalized: s.normalized || null });
887
+ }
888
+ }
889
+ }
890
+
891
+ return nodes;
892
+ }
893
+
894
+ /**
895
+ * Extract connections from parsed protobuf
896
+ */
897
+ function extractConnections(parsed) {
898
+ const connections = [];
899
+
900
+ if (!parsed.F1 || !parsed.F1.F8) return connections;
901
+
902
+ const connFields = Array.isArray(parsed.F1.F8) ? parsed.F1.F8 : [parsed.F1.F8];
903
+
904
+ for (const conn of connFields) {
905
+ if (!conn._fields) continue;
906
+
907
+ connections.push({
908
+ from: conn.F1,
909
+ to: conn.F3,
910
+ sourcePort: conn.F5,
911
+ targetPort: conn.F6,
912
+ index: conn.F7,
913
+ });
914
+ }
915
+
916
+ return connections;
917
+ }
918
+
919
+ /**
920
+ * Extract metadata from parsed protobuf
921
+ */
922
+ function extractMetadata(parsed) {
923
+ const metadata = {
924
+ version: null,
925
+ width: null,
926
+ height: null,
927
+ timestamp: null,
928
+ };
929
+
930
+ if (parsed.F1) {
931
+ metadata.version = parsed.F1.F1;
932
+ if (parsed.F1.F3) {
933
+ metadata.width = parsed.F1.F3.F1;
934
+ metadata.height = parsed.F1.F3.F2;
935
+ }
936
+ metadata.timestamp = parsed.F1.F12;
937
+ }
938
+
939
+ return metadata;
940
+ }
941
+
942
+ /**
943
+ * Parse a DRX file and extract its structure
944
+ *
945
+ * @param {string} filePath - Path to the DRX file
946
+ * @returns {Promise<Object>} - Parsed DRX structure with nodes, connections, metadata
947
+ */
948
+ async function parseDRX(filePath) {
949
+ const xml = fs.readFileSync(filePath, 'utf-8');
950
+ return parseDRXContent(xml);
951
+ }
952
+
953
+ /**
954
+ * Parse DRX XML content (without reading from file)
955
+ *
956
+ * @param {string} xml - DRX XML content
957
+ * @returns {Promise<Object>} - Parsed DRX structure
958
+ */
959
+ async function parseDRXContent(xml) {
960
+ // P4A: Check LRU cache by SHA-256 of input XML
961
+ const cacheKey = crypto.createHash('sha256').update(xml).digest('hex');
962
+ const cached = drxCacheGet(cacheKey);
963
+ if (cached) {
964
+ return cached;
965
+ }
966
+
967
+ // Extract XML metadata
968
+ const labelMatch = xml.match(/<Label>([^<]*)<\/Label>/);
969
+ const widthMatch = xml.match(/<Width>(\d+)<\/Width>/);
970
+ const heightMatch = xml.match(/<Height>(\d+)<\/Height>/);
971
+ const srcHintMatch = xml.match(/<SrcHint>([^<]*)<\/SrcHint>/);
972
+ const recTCMatch = xml.match(/<RecTC>([^<]*)<\/RecTC>/);
973
+ const srcTCMatch = xml.match(/<SrcTC>([^<]*)<\/SrcTC>/);
974
+
975
+ // Extract pTrackVer section for preservation during merge
976
+ const pTrackVerMatch = xml.match(/<pTrackVer>([\s\S]*?)<\/pTrackVer>/);
977
+ const pTrackVerXml = pTrackVerMatch ? pTrackVerMatch[0] : null;
978
+
979
+ // Extract body
980
+ const bodyMatch = xml.match(/<Body>([^<]+)<\/Body>/);
981
+ if (!bodyMatch) {
982
+ throw new Error('No Body found in DRX content');
983
+ }
984
+
985
+ const hexBody = bodyMatch[1];
986
+ const buf = Buffer.from(hexBody, 'hex');
987
+
988
+ // Verify magic byte. 0x81 = zstd-compressed body; 0x80 = STORED (uncompressed
989
+ // protobuf follows directly) — found in real project exports 2026-07-03 (~10% of
990
+ // grades in large projects; presumably Resolve skips compression for small bodies).
991
+ if (buf[0] !== 0x81 && buf[0] !== 0x80) {
992
+ throw new Error(`Invalid DRX magic byte: 0x${buf[0].toString(16)}`);
993
+ }
994
+
995
+ // Decompress (or use the stored bytes directly for 0x80)
996
+ const decompressed = buf[0] === 0x80
997
+ ? buf.slice(1)
998
+ : Buffer.from((await getZstdCodec()).decompress(buf.slice(1)));
999
+
1000
+ // Parse protobuf
1001
+ const parser = new ProtobufParser(decompressed);
1002
+ const parsed = parser.parse();
1003
+
1004
+ // Extract structure
1005
+ const nodes = extractNodes(parsed, decompressed);
1006
+ const connections = extractConnections(parsed);
1007
+ const metadata = extractMetadata(parsed);
1008
+
1009
+ const result = {
1010
+ // XML metadata
1011
+ label: labelMatch ? labelMatch[1] : '',
1012
+ width: widthMatch ? parseInt(widthMatch[1], 10) : metadata.width || 1920,
1013
+ height: heightMatch ? parseInt(heightMatch[1], 10) : metadata.height || 1080,
1014
+ sourceTimeline: srcHintMatch ? srcHintMatch[1] : 'Timeline 1',
1015
+ recordTC: recTCMatch ? recTCMatch[1] : '01:00:00:00',
1016
+ sourceTC: srcTCMatch ? srcTCMatch[1] : '00:00:00:00',
1017
+
1018
+ // Preserved XML sections for merge
1019
+ pTrackVerXml,
1020
+
1021
+ // Protobuf data
1022
+ nodes,
1023
+ connections,
1024
+ metadata,
1025
+
1026
+ // Raw parsed data (for debugging)
1027
+ _raw: parsed,
1028
+ };
1029
+
1030
+ // P4A: Store in LRU cache
1031
+ drxCacheSet(cacheKey, result);
1032
+ return result;
1033
+ }
1034
+
1035
+ /**
1036
+ * Parse DRX from a hex-encoded body string (for direct API use)
1037
+ *
1038
+ * @param {string} hexBody - Hex-encoded DRX body
1039
+ * @returns {Promise<Object>} - Parsed nodes and connections
1040
+ */
1041
+ async function parseDRXBody(hexBody) {
1042
+ const buf = Buffer.from(hexBody, 'hex');
1043
+
1044
+ // 0x81 = zstd-compressed; 0x80 = STORED/uncompressed (see parseDRXContent).
1045
+ if (buf[0] !== 0x81 && buf[0] !== 0x80) {
1046
+ throw new Error(`Invalid DRX magic byte: 0x${buf[0].toString(16)}`);
1047
+ }
1048
+
1049
+ const decompressed = buf[0] === 0x80
1050
+ ? buf.slice(1)
1051
+ : Buffer.from((await getZstdCodec()).decompress(buf.slice(1)));
1052
+
1053
+ const parser = new ProtobufParser(decompressed);
1054
+ const parsed = parser.parse();
1055
+
1056
+ return {
1057
+ nodes: extractNodes(parsed, decompressed),
1058
+ connections: extractConnections(parsed),
1059
+ metadata: extractMetadata(parsed),
1060
+ };
1061
+ }
1062
+
1063
+ /**
1064
+ * Default parameter values for comparison
1065
+ * Uses shared library for consistency
1066
+ */
1067
+ const DEFAULT_PARAMS = {
1068
+ lift: drxParams.PARAMETER_RANGES.lift
1069
+ ? { r: drxParams.getDefault('lift', 'r'), g: drxParams.getDefault('lift', 'g'), b: drxParams.getDefault('lift', 'b'), master: drxParams.getDefault('lift', 'master') }
1070
+ : { r: 0, g: 0, b: 0, master: 0 },
1071
+ gamma: drxParams.PARAMETER_RANGES.gamma
1072
+ ? { r: drxParams.getDefault('gamma', 'r'), g: drxParams.getDefault('gamma', 'g'), b: drxParams.getDefault('gamma', 'b'), master: drxParams.getDefault('gamma', 'master') }
1073
+ : { r: 0, g: 0, b: 0, master: 0 },
1074
+ gain: drxParams.PARAMETER_RANGES.gain
1075
+ ? { r: drxParams.getDefault('gain', 'r'), g: drxParams.getDefault('gain', 'g'), b: drxParams.getDefault('gain', 'b'), master: drxParams.getDefault('gain', 'master') }
1076
+ : { r: 1, g: 1, b: 1, master: 1 },
1077
+ offset: drxParams.PARAMETER_RANGES.offset
1078
+ ? { r: drxParams.getDefault('offset', 'r'), g: drxParams.getDefault('offset', 'g'), b: drxParams.getDefault('offset', 'b') }
1079
+ : { r: 0, g: 0, b: 0 },
1080
+ saturation: drxParams.getDefault('saturation', 'master') ?? 50, // Resolve Primaries: 0-100, unity=50
1081
+ contrast: drxParams.getDefault('contrast', 'master') ?? 1.0,
1082
+ pivot: drxParams.getDefault('pivotFine', 'master') ?? 0.435,
1083
+ temperature: drxParams.getDefault('temperature', 'master') ?? 0,
1084
+ tint: drxParams.getDefault('tint', 'master') ?? 0,
1085
+ midtoneDetail: drxParams.getDefault('midtoneDetail', 'master') ?? 0,
1086
+ };
1087
+
1088
+ /**
1089
+ * Tolerance for floating point comparison
1090
+ */
1091
+ const PARAM_TOLERANCE = 0.001;
1092
+
1093
+ /**
1094
+ * Check if a node has any non-default parameter values
1095
+ * @param {Object} node - Parsed node object with params
1096
+ * @returns {boolean} - True if node has actual adjustments
1097
+ */
1098
+ function nodeHasAdjustments(node) {
1099
+ if (!node || !node.params) return false;
1100
+
1101
+ const p = node.params;
1102
+ const d = DEFAULT_PARAMS;
1103
+
1104
+ // Check lift
1105
+ if (Math.abs(p.lift.r - d.lift.r) > PARAM_TOLERANCE ||
1106
+ Math.abs(p.lift.g - d.lift.g) > PARAM_TOLERANCE ||
1107
+ Math.abs(p.lift.b - d.lift.b) > PARAM_TOLERANCE ||
1108
+ Math.abs(p.lift.master - d.lift.master) > PARAM_TOLERANCE) {
1109
+ return true;
1110
+ }
1111
+
1112
+ // Check gamma
1113
+ if (Math.abs(p.gamma.r - d.gamma.r) > PARAM_TOLERANCE ||
1114
+ Math.abs(p.gamma.g - d.gamma.g) > PARAM_TOLERANCE ||
1115
+ Math.abs(p.gamma.b - d.gamma.b) > PARAM_TOLERANCE ||
1116
+ Math.abs(p.gamma.master - d.gamma.master) > PARAM_TOLERANCE) {
1117
+ return true;
1118
+ }
1119
+
1120
+ // Check gain
1121
+ if (Math.abs(p.gain.r - d.gain.r) > PARAM_TOLERANCE ||
1122
+ Math.abs(p.gain.g - d.gain.g) > PARAM_TOLERANCE ||
1123
+ Math.abs(p.gain.b - d.gain.b) > PARAM_TOLERANCE ||
1124
+ Math.abs(p.gain.master - d.gain.master) > PARAM_TOLERANCE) {
1125
+ return true;
1126
+ }
1127
+
1128
+ // Check offset
1129
+ if (Math.abs(p.offset.r - d.offset.r) > PARAM_TOLERANCE ||
1130
+ Math.abs(p.offset.g - d.offset.g) > PARAM_TOLERANCE ||
1131
+ Math.abs(p.offset.b - d.offset.b) > PARAM_TOLERANCE) {
1132
+ return true;
1133
+ }
1134
+
1135
+ // Check saturation, contrast, pivot
1136
+ if (Math.abs(p.saturation - d.saturation) > PARAM_TOLERANCE ||
1137
+ Math.abs(p.contrast - d.contrast) > PARAM_TOLERANCE ||
1138
+ Math.abs(p.pivot - d.pivot) > PARAM_TOLERANCE) {
1139
+ return true;
1140
+ }
1141
+
1142
+ // Check for HDR zone adjustments (FIXED 2026-01-17)
1143
+ // HDR zones are detected by:
1144
+ // 1. Node label starting with "HDR"
1145
+ // 2. Correctors containing HDR zone param ID (0x86000305)
1146
+ if (node.label && node.label.startsWith('HDR')) {
1147
+ return true;
1148
+ }
1149
+
1150
+ // Check correctors for HDR zone/global data
1151
+ if (node.correctors && node.correctors.length > 0) {
1152
+ const HDR_PARAM_IDS = [
1153
+ 2248147715, // 0x86000303 - Black Offset
1154
+ 2248147717, // 0x86000305 - Zone Adjustments
1155
+ 2248147718, // 0x86000306 - Zone Definitions (range/falloff)
1156
+ 2248147721, // 0x86000309 - Zone Metadata
1157
+ ];
1158
+ for (const corrector of node.correctors) {
1159
+ if (corrector.parameters) {
1160
+ for (const param of corrector.parameters) {
1161
+ if (HDR_PARAM_IDS.includes(param.id)) {
1162
+ return true;
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ // Check for any significant corrector data (curves, power windows, qualifiers, etc.)
1170
+ // If a node has correctors with substantial parameter data, it likely has a grade
1171
+ if (node.correctors && node.correctors.length > 0) {
1172
+ for (const corrector of node.correctors) {
1173
+ // If corrector has many parameters, it's likely meaningful grade data
1174
+ if (corrector.parameters && corrector.parameters.length > 20) {
1175
+ return true;
1176
+ }
1177
+ // Check for curve data (typically has specific structure)
1178
+ if (corrector.curves && Object.keys(corrector.curves).length > 0) {
1179
+ return true;
1180
+ }
1181
+ // Check for power window/qualifier data
1182
+ if (corrector.windows && corrector.windows.length > 0) {
1183
+ return true;
1184
+ }
1185
+ if (corrector.qualifiers && Object.keys(corrector.qualifiers).length > 0) {
1186
+ return true;
1187
+ }
1188
+ }
1189
+ }
1190
+
1191
+ // Check raw node data for any non-default content
1192
+ // This catches grades we can't specifically parse but have data
1193
+ if (node.rawData && node.rawData.length > 500) {
1194
+ // A default node typically has minimal raw data
1195
+ // Significant raw data usually indicates grade adjustments
1196
+ return true;
1197
+ }
1198
+
1199
+ return false;
1200
+ }
1201
+
1202
+ /**
1203
+ * Check if a parsed DRX has any actual color adjustments
1204
+ * @param {Object} parsedDRX - Parsed DRX object from parseDRX/parseDRXContent
1205
+ * @returns {boolean} - True if any node has non-default adjustments
1206
+ */
1207
+ function hasActualGrade(parsedDRX) {
1208
+ if (!parsedDRX || !parsedDRX.nodes || parsedDRX.nodes.length === 0) {
1209
+ return false;
1210
+ }
1211
+
1212
+ return parsedDRX.nodes.some(node => nodeHasAdjustments(node));
1213
+ }
1214
+
1215
+ /**
1216
+ * Format a numeric value for display
1217
+ * @param {number} val - Value to format
1218
+ * @param {number} defaultVal - Default value (for sign display)
1219
+ * @returns {string} - Formatted string with sign
1220
+ */
1221
+ function formatValue(val, defaultVal = 0) {
1222
+ if (val === undefined || val === null) return '0';
1223
+ const diff = val - defaultVal;
1224
+ if (Math.abs(diff) < 0.001) return '0';
1225
+ const sign = diff > 0 ? '+' : '';
1226
+ return `${sign}${val.toFixed(3)}`;
1227
+ }
1228
+
1229
+ /**
1230
+ * Extract comprehensive tool breakdown from grade params
1231
+ * Returns structured data for UI display with formatted strings
1232
+ *
1233
+ * @param {Object} params - Grade parameters (can be from DRX node or high-level params)
1234
+ * @returns {Object} - Tool breakdown with raw values, formatted strings, and active tools list
1235
+ */
1236
+ function extractToolBreakdown(params) {
1237
+ if (!params) {
1238
+ return {
1239
+ lift: { r: 0, g: 0, b: 0, master: 0 },
1240
+ gamma: { r: 0, g: 0, b: 0, master: 0 },
1241
+ gain: { r: 1, g: 1, b: 1, master: 1 },
1242
+ offset: { r: 0, g: 0, b: 0 },
1243
+ saturation: 50, // Resolve Primaries: 0-100, unity=50
1244
+ contrast: 1.0,
1245
+ pivot: 0.435,
1246
+ temperature: 0,
1247
+ tint: 0,
1248
+ formatted: {},
1249
+ activeTools: [],
1250
+ summary: '',
1251
+ };
1252
+ }
1253
+
1254
+ const d = DEFAULT_PARAMS;
1255
+
1256
+ // Extract values with defaults
1257
+ const breakdown = {
1258
+ lift: {
1259
+ r: params.lift?.r ?? 0,
1260
+ g: params.lift?.g ?? 0,
1261
+ b: params.lift?.b ?? 0,
1262
+ master: params.lift?.master ?? 0,
1263
+ },
1264
+ gamma: {
1265
+ r: params.gamma?.r ?? 0,
1266
+ g: params.gamma?.g ?? 0,
1267
+ b: params.gamma?.b ?? 0,
1268
+ master: params.gamma?.master ?? 0,
1269
+ },
1270
+ gain: {
1271
+ r: params.gain?.r ?? 1,
1272
+ g: params.gain?.g ?? 1,
1273
+ b: params.gain?.b ?? 1,
1274
+ master: params.gain?.master ?? 1,
1275
+ },
1276
+ offset: {
1277
+ r: params.offset?.r ?? 0,
1278
+ g: params.offset?.g ?? 0,
1279
+ b: params.offset?.b ?? 0,
1280
+ },
1281
+ saturation: params.saturation ?? 50, // Resolve Primaries: unity=50
1282
+ contrast: params.contrast ?? 1.0,
1283
+ pivot: params.pivot ?? 0.435,
1284
+ temperature: params.temperature ?? 0,
1285
+ tint: params.tint ?? 0,
1286
+ formatted: {},
1287
+ activeTools: [],
1288
+ };
1289
+
1290
+ // Helper to format wheel controls (R, G, B, Master)
1291
+ const formatWheel = (wheel, name, defaultVal = 0) => {
1292
+ const parts = [];
1293
+ if (Math.abs(wheel.r - defaultVal) > PARAM_TOLERANCE) parts.push(`R:${formatValue(wheel.r, defaultVal)}`);
1294
+ if (Math.abs(wheel.g - defaultVal) > PARAM_TOLERANCE) parts.push(`G:${formatValue(wheel.g, defaultVal)}`);
1295
+ if (Math.abs(wheel.b - defaultVal) > PARAM_TOLERANCE) parts.push(`B:${formatValue(wheel.b, defaultVal)}`);
1296
+ if (wheel.master !== undefined && Math.abs(wheel.master - defaultVal) > PARAM_TOLERANCE) {
1297
+ parts.push(`M:${formatValue(wheel.master, defaultVal)}`);
1298
+ }
1299
+
1300
+ if (parts.length > 0) {
1301
+ breakdown.formatted[name] = `${name}: ${parts.join(' ')}`;
1302
+ breakdown.activeTools.push(name);
1303
+ return true;
1304
+ }
1305
+ return false;
1306
+ };
1307
+
1308
+ // Format each wheel control
1309
+ formatWheel(breakdown.lift, 'Lift', 0);
1310
+ formatWheel(breakdown.gamma, 'Gamma', 0);
1311
+ formatWheel(breakdown.gain, 'Gain', 1);
1312
+ formatWheel(breakdown.offset, 'Offset', 0);
1313
+
1314
+ // Format scalar controls
1315
+ if (Math.abs(breakdown.saturation - d.saturation) > PARAM_TOLERANCE) {
1316
+ breakdown.formatted.Saturation = `Sat: ${formatValue(breakdown.saturation, 50)}`;
1317
+ breakdown.activeTools.push('Saturation');
1318
+ }
1319
+
1320
+ if (Math.abs(breakdown.contrast - d.contrast) > PARAM_TOLERANCE) {
1321
+ breakdown.formatted.Contrast = `Con: ${formatValue(breakdown.contrast, 1)}`;
1322
+ breakdown.activeTools.push('Contrast');
1323
+ }
1324
+
1325
+ if (Math.abs(breakdown.pivot - d.pivot) > 0.01) {
1326
+ breakdown.formatted.Pivot = `Pivot: ${breakdown.pivot.toFixed(3)}`;
1327
+ breakdown.activeTools.push('Pivot');
1328
+ }
1329
+
1330
+ if (Math.abs(breakdown.temperature) > PARAM_TOLERANCE) {
1331
+ const sign = breakdown.temperature > 0 ? '+' : '';
1332
+ breakdown.formatted.Temperature = `Temp: ${sign}${breakdown.temperature.toFixed(2)}`;
1333
+ breakdown.activeTools.push('Temperature');
1334
+ }
1335
+
1336
+ if (Math.abs(breakdown.tint) > PARAM_TOLERANCE) {
1337
+ const sign = breakdown.tint > 0 ? '+' : '';
1338
+ breakdown.formatted.Tint = `Tint: ${sign}${breakdown.tint.toFixed(2)}`;
1339
+ breakdown.activeTools.push('Tint');
1340
+ }
1341
+
1342
+ // Generate summary string
1343
+ breakdown.summary = Object.values(breakdown.formatted).join(', ');
1344
+
1345
+ return breakdown;
1346
+ }
1347
+
1348
+ /**
1349
+ * Extract tool breakdown from a parsed DRX file
1350
+ * Merges params from all nodes
1351
+ *
1352
+ * @param {Object} parsedDRX - Parsed DRX object from parseDRX/parseDRXContent
1353
+ * @returns {Object} - Tool breakdown with merged params from all nodes
1354
+ */
1355
+ function extractToolBreakdownFromDRX(parsedDRX) {
1356
+ if (!parsedDRX || !parsedDRX.nodes || parsedDRX.nodes.length === 0) {
1357
+ return extractToolBreakdown(null);
1358
+ }
1359
+
1360
+ // Merge params from all nodes
1361
+ const mergedParams = {
1362
+ lift: { r: 0, g: 0, b: 0, master: 0 },
1363
+ gamma: { r: 0, g: 0, b: 0, master: 0 },
1364
+ gain: { r: 1, g: 1, b: 1, master: 1 },
1365
+ offset: { r: 0, g: 0, b: 0 },
1366
+ saturation: 50, // Resolve Primaries: 0-100, unity=50
1367
+ contrast: 1.0,
1368
+ pivot: 0.435,
1369
+ temperature: 0,
1370
+ tint: 0,
1371
+ };
1372
+
1373
+ for (const node of parsedDRX.nodes) {
1374
+ if (!node.params) continue;
1375
+
1376
+ // Merge wheel controls (additive for lift/gamma/offset, multiplicative concept for gain)
1377
+ for (const control of ['lift', 'gamma', 'offset']) {
1378
+ if (node.params[control]) {
1379
+ mergedParams[control].r += node.params[control].r || 0;
1380
+ mergedParams[control].g += node.params[control].g || 0;
1381
+ mergedParams[control].b += node.params[control].b || 0;
1382
+ if (node.params[control].master !== undefined) {
1383
+ mergedParams[control].master += node.params[control].master || 0;
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ // Gain is multiplicative
1389
+ if (node.params.gain) {
1390
+ mergedParams.gain.r *= node.params.gain.r ?? 1;
1391
+ mergedParams.gain.g *= node.params.gain.g ?? 1;
1392
+ mergedParams.gain.b *= node.params.gain.b ?? 1;
1393
+ if (node.params.gain.master !== undefined) {
1394
+ mergedParams.gain.master *= node.params.gain.master ?? 1;
1395
+ }
1396
+ }
1397
+
1398
+ // Saturation and contrast are multiplicative
1399
+ if (node.params.saturation !== undefined) {
1400
+ mergedParams.saturation *= node.params.saturation;
1401
+ }
1402
+ if (node.params.contrast !== undefined) {
1403
+ mergedParams.contrast *= node.params.contrast;
1404
+ }
1405
+
1406
+ // Pivot uses last value
1407
+ if (node.params.pivot !== undefined) {
1408
+ mergedParams.pivot = node.params.pivot;
1409
+ }
1410
+
1411
+ // Temperature and tint are additive
1412
+ if (node.params.temperature !== undefined) {
1413
+ mergedParams.temperature += node.params.temperature;
1414
+ }
1415
+ if (node.params.tint !== undefined) {
1416
+ mergedParams.tint += node.params.tint;
1417
+ }
1418
+ }
1419
+
1420
+ return extractToolBreakdown(mergedParams);
1421
+ }
1422
+
1423
+ /**
1424
+ * Tolerance for sync drift detection (slightly higher than display tolerance)
1425
+ * Using 0.01 to avoid false positives from floating point precision issues
1426
+ */
1427
+ const SYNC_DRIFT_TOLERANCE = 0.01;
1428
+
1429
+ /**
1430
+ * Compare two sets of grade parameters to detect sync drift
1431
+ * Returns whether the parameters match within tolerance
1432
+ *
1433
+ * @param {Object} paramsA - First parameter set (from stored version's cumulativeState)
1434
+ * @param {Object} paramsB - Second parameter set (from parsed DRX breakdown)
1435
+ * @param {Object} options - Comparison options
1436
+ * @param {number} options.tolerance - Override default tolerance (default: 0.01)
1437
+ * @returns {Object} - { matches: boolean, differences: Array, summary: string }
1438
+ */
1439
+ function compareGradeParameters(paramsA, paramsB, options = {}) {
1440
+ const tolerance = options.tolerance ?? SYNC_DRIFT_TOLERANCE;
1441
+ const differences = [];
1442
+
1443
+ // Helper to compare single values
1444
+ const compareValue = (valA, valB, paramPath, defaultVal = 0) => {
1445
+ const a = valA ?? defaultVal;
1446
+ const b = valB ?? defaultVal;
1447
+ const diff = Math.abs(a - b);
1448
+ if (diff > tolerance) {
1449
+ differences.push({
1450
+ param: paramPath,
1451
+ stored: a,
1452
+ current: b,
1453
+ diff: diff,
1454
+ });
1455
+ }
1456
+ };
1457
+
1458
+ // Handle case where one or both are null/undefined
1459
+ if (!paramsA && !paramsB) {
1460
+ return { matches: true, differences: [], summary: 'Both neutral' };
1461
+ }
1462
+ if (!paramsA) {
1463
+ paramsA = DEFAULT_PARAMS;
1464
+ }
1465
+ if (!paramsB) {
1466
+ paramsB = DEFAULT_PARAMS;
1467
+ }
1468
+
1469
+ // Compare wheel controls (lift, gamma, gain, offset)
1470
+ const wheels = ['lift', 'gamma', 'gain', 'offset'];
1471
+ const channels = ['r', 'g', 'b', 'master'];
1472
+
1473
+ for (const wheel of wheels) {
1474
+ const wheelA = paramsA[wheel] || {};
1475
+ const wheelB = paramsB[wheel] || {};
1476
+
1477
+ for (const ch of channels) {
1478
+ // Skip master for offset (it doesn't have master in some representations)
1479
+ if (wheel === 'offset' && ch === 'master') continue;
1480
+
1481
+ // Default values differ: gain defaults to 1, others to 0
1482
+ const defaultVal = wheel === 'gain' ? 1 : 0;
1483
+ const valA = wheelA[ch];
1484
+ const valB = wheelB[ch];
1485
+
1486
+ compareValue(valA, valB, `${wheel}.${ch}`, defaultVal);
1487
+ }
1488
+ }
1489
+
1490
+ // Compare scalar controls
1491
+ const scalars = [
1492
+ { name: 'saturation', default: 50 }, // Resolve Primaries: 0-100, unity=50
1493
+ { name: 'contrast', default: 1 },
1494
+ { name: 'pivot', default: 0.435 },
1495
+ { name: 'temperature', default: 0 },
1496
+ { name: 'tint', default: 0 },
1497
+ ];
1498
+
1499
+ for (const scalar of scalars) {
1500
+ const valA = paramsA[scalar.name];
1501
+ const valB = paramsB[scalar.name];
1502
+ compareValue(valA, valB, scalar.name, scalar.default);
1503
+ }
1504
+
1505
+ // Generate summary
1506
+ let summary = '';
1507
+ if (differences.length === 0) {
1508
+ summary = 'Parameters match';
1509
+ } else if (differences.length <= 3) {
1510
+ summary = differences.map(d => `${d.param}: ${d.stored.toFixed(3)} → ${d.current.toFixed(3)}`).join(', ');
1511
+ } else {
1512
+ summary = `${differences.length} parameters differ`;
1513
+ }
1514
+
1515
+ return {
1516
+ matches: differences.length === 0,
1517
+ differences,
1518
+ summary,
1519
+ };
1520
+ }
1521
+
1522
+ /**
1523
+ * Normalize semantic params (temperature, tint, contrast) to actual wheel values
1524
+ * This converts high-level params to the actual lift/gamma/gain values they produce
1525
+ *
1526
+ * Uses the same formulas as drx-generator.js parseAdjustmentsToNodes:
1527
+ * - temperature: gain.r += t*0.15, gain.b -= t*0.15, lift.r += t*0.08, lift.b -= t*0.08
1528
+ * - tint: gain.g -= t*0.15, lift.g -= t*0.08
1529
+ * - contrast: implemented via lift/gain (not contrast corrector)
1530
+ *
1531
+ * @param {Object} params - Semantic params (may include temperature, tint, etc.)
1532
+ * @returns {Object} - Normalized params with wheel values
1533
+ */
1534
+ function normalizeSemanticParams(params) {
1535
+ if (!params) return params;
1536
+
1537
+ const result = {
1538
+ lift: { r: 0, g: 0, b: 0, master: 0 },
1539
+ gamma: { r: 0, g: 0, b: 0, master: 0 },
1540
+ gain: { r: 1, g: 1, b: 1, master: 1 },
1541
+ offset: { r: 0, g: 0, b: 0 },
1542
+ saturation: 50, // Resolve Primaries: 0-100, unity=50
1543
+ contrast: 1,
1544
+ pivot: params.pivot ?? 0.435,
1545
+ };
1546
+
1547
+ // Apply temperature to wheel values
1548
+ if (params.temperature && Math.abs(params.temperature) > 0.001) {
1549
+ const t = params.temperature;
1550
+ result.gain.r = 1 + (t * 0.15);
1551
+ result.gain.b = 1 - (t * 0.15);
1552
+ result.lift.r = t * 0.08;
1553
+ result.lift.b = -(t * 0.08);
1554
+ }
1555
+
1556
+ // Apply tint to wheel values
1557
+ if (params.tint && Math.abs(params.tint) > 0.001) {
1558
+ const t = params.tint;
1559
+ result.gain.g -= t * 0.15;
1560
+ result.lift.g -= t * 0.08;
1561
+ }
1562
+
1563
+ // Apply shadowLift to wheel values (matches drx-generator.js)
1564
+ if (params.shadowLift && Math.abs(params.shadowLift) > 0.001) {
1565
+ const s = params.shadowLift;
1566
+ result.lift.r += s * 0.25;
1567
+ result.lift.g += s * 0.25;
1568
+ result.lift.b += s * 0.25;
1569
+ result.lift.master += s * 0.15;
1570
+ console.log('[drx-parser] ShadowLift applied:', s, '→ lift.r:', result.lift.r);
1571
+ }
1572
+
1573
+ // Apply highlightCompression to wheel values (matches drx-generator.js)
1574
+ if (params.highlightCompression && Math.abs(params.highlightCompression) > 0.001) {
1575
+ const h = params.highlightCompression;
1576
+ result.gain.r -= h * 0.1;
1577
+ result.gain.g -= h * 0.1;
1578
+ result.gain.b -= h * 0.1;
1579
+ console.log('[drx-parser] HighlightCompression applied:', h, '→ gain.r:', result.gain.r);
1580
+ }
1581
+
1582
+ // Apply semantic contrast to wheel values (matches drx-generator.js parseAdjustmentsToNodes)
1583
+ // IMPORTANT: drx-generator.js parseAdjustmentsToNodes implements contrast via lift/gain,
1584
+ // NOT the contrast scalar! The contrast scalar stays at 1.0 (neutral).
1585
+ // See drx-generator.js line 1987: "We implement contrast using Lift/Gain instead of the
1586
+ // Contrast Corrector (Type 2) because Type 2 was causing HSL qualifier issues."
1587
+ if (params.contrast && Math.abs(params.contrast) > 0.01) {
1588
+ const c = params.contrast;
1589
+ const contrastAmount = c * 0.15;
1590
+ // Lower shadows for more contrast (additive to existing lift)
1591
+ result.lift.r += -contrastAmount;
1592
+ result.lift.g += -contrastAmount;
1593
+ result.lift.b += -contrastAmount;
1594
+ result.lift.master += -contrastAmount * 0.5;
1595
+ // Raise highlights for more contrast (additive to existing gain which defaults to 1.0)
1596
+ result.gain.r += contrastAmount;
1597
+ result.gain.g += contrastAmount;
1598
+ result.gain.b += contrastAmount;
1599
+ result.gain.master += contrastAmount * 0.5;
1600
+ console.log('[drx-parser] Contrast (via lift/gain) applied:', c, '→ lift.r:', result.lift.r, 'gain.r:', result.gain.r);
1601
+ }
1602
+
1603
+ // Apply saturation - ALWAYS convert semantic (-1 to 1) to Resolve Primaries (0 to 100)
1604
+ // Semantic model: -1 = desaturated, 0 = neutral, 1 = oversaturated
1605
+ // Resolve Primaries: 0 = desaturated, 50 = neutral (unity), 100 = oversaturated
1606
+ // Formula matches drx-generator.js: params.saturation = 50 + (adjustments.saturation * 50)
1607
+ // NOTE: cumulativeState always stores semantic values from applyTweak() which clamps to -1..1
1608
+ if (params.saturation !== undefined) {
1609
+ // Always convert from semantic -1..1 to Resolve 0..100
1610
+ // semantic 0 -> actual 50, semantic -1 -> actual 0, semantic 1 -> actual 100
1611
+ result.saturation = 50 + (params.saturation * 50);
1612
+ console.log('[drx-parser] Saturation converted: semantic', params.saturation, '→ actual', result.saturation);
1613
+ } else {
1614
+ console.log('[drx-parser] Saturation undefined, using default:', result.saturation);
1615
+ }
1616
+
1617
+ // DO NOT convert contrast to the contrast scalar!
1618
+ // The drx-generator.js parseAdjustmentsToNodes function implements contrast via lift/gain
1619
+ // (see above), so the contrast scalar stays at 1.0 (neutral) in the generated DRX.
1620
+ // We already applied the contrast adjustment to lift/gain above, so leave scalar at default.
1621
+ // result.contrast stays at 1 (the default set at initialization)
1622
+ if (params.contrast !== undefined) {
1623
+ console.log('[drx-parser] Contrast handled via lift/gain, scalar stays at:', result.contrast);
1624
+ }
1625
+
1626
+ // Copy any explicit wheel values from params (they override computed values)
1627
+ for (const wheel of ['lift', 'gamma', 'gain', 'offset']) {
1628
+ if (params[wheel]) {
1629
+ for (const ch of ['r', 'g', 'b', 'master']) {
1630
+ if (params[wheel][ch] !== undefined) {
1631
+ // For temperature/tint derived values, add to existing
1632
+ // For explicit values, use directly
1633
+ if ((wheel === 'gain' || wheel === 'lift') && (params.temperature || params.tint)) {
1634
+ // Values already applied from temperature/tint
1635
+ } else {
1636
+ result[wheel][ch] = params[wheel][ch];
1637
+ }
1638
+ }
1639
+ }
1640
+ }
1641
+ }
1642
+
1643
+ return result;
1644
+ }
1645
+
1646
+ /**
1647
+ * Compare a stored version's cumulativeState with current Resolve grade
1648
+ * Convenience wrapper that parses the DRX and compares parameters
1649
+ *
1650
+ * @param {Object} storedParams - The cumulativeState from stored version
1651
+ * @param {string} drxContent - DRX content exported from Resolve
1652
+ * @returns {Promise<Object>} - { matches, differences, summary, resolveParams }
1653
+ */
1654
+ async function compareSyncState(storedParams, drxContent) {
1655
+ try {
1656
+ // Debug: Log incoming stored params
1657
+ console.log('[drx-parser] compareSyncState input storedParams:', JSON.stringify({
1658
+ saturation: storedParams?.saturation,
1659
+ contrast: storedParams?.contrast,
1660
+ temperature: storedParams?.temperature,
1661
+ shadowLift: storedParams?.shadowLift,
1662
+ highlightCompression: storedParams?.highlightCompression,
1663
+ tint: storedParams?.tint,
1664
+ }));
1665
+
1666
+ // Parse the DRX to get current parameters
1667
+ const parsedDrx = await parseDRXContent(drxContent);
1668
+ const breakdown = extractToolBreakdownFromDRX(parsedDrx);
1669
+
1670
+ // Extract just the parameter values for comparison
1671
+ const resolveParams = {
1672
+ lift: breakdown.lift,
1673
+ gamma: breakdown.gamma,
1674
+ gain: breakdown.gain,
1675
+ offset: breakdown.offset,
1676
+ saturation: breakdown.saturation,
1677
+ contrast: breakdown.contrast,
1678
+ pivot: breakdown.pivot,
1679
+ temperature: breakdown.temperature || 0,
1680
+ tint: breakdown.tint || 0,
1681
+ };
1682
+
1683
+ // Debug: Log DRX params
1684
+ console.log('[drx-parser] compareSyncState DRX resolveParams:', JSON.stringify({
1685
+ saturation: resolveParams.saturation,
1686
+ contrast: resolveParams.contrast,
1687
+ }));
1688
+
1689
+ // Normalize stored params to convert semantic values (temperature, tint)
1690
+ // to actual wheel values for accurate comparison
1691
+ const normalizedStoredParams = normalizeSemanticParams(storedParams);
1692
+
1693
+ // Debug: Log normalized params
1694
+ console.log('[drx-parser] compareSyncState normalized storedParams:', JSON.stringify({
1695
+ saturation: normalizedStoredParams?.saturation,
1696
+ contrast: normalizedStoredParams?.contrast,
1697
+ }));
1698
+
1699
+ const result = compareGradeParameters(normalizedStoredParams, resolveParams);
1700
+
1701
+ return {
1702
+ ...result,
1703
+ resolveParams,
1704
+ nodeCount: parsedDrx.nodes?.length || 0,
1705
+ };
1706
+ } catch (error) {
1707
+ return {
1708
+ matches: false,
1709
+ differences: [],
1710
+ summary: `Parse error: ${error.message}`,
1711
+ error: error.message,
1712
+ };
1713
+ }
1714
+ }
1715
+
1716
+ /**
1717
+ * Compare two DRX contents directly (DRX-to-DRX comparison)
1718
+ * This is more accurate than comparing cumulativeState to DRX
1719
+ * because it avoids normalization/conversion mismatches.
1720
+ *
1721
+ * @param {string} storedDrxContent - The stored version's DRX content
1722
+ * @param {string} currentDrxContent - The current DRX from Resolve
1723
+ * @param {Object} options - Comparison options
1724
+ * @param {number} options.tolerance - Override default tolerance (default: 0.02 for DRX-to-DRX)
1725
+ * @returns {Promise<Object>} - { matches, differences, summary, storedParams, currentParams }
1726
+ */
1727
+ async function compareDRXContent(storedDrxContent, currentDrxContent, options = {}) {
1728
+ // Use slightly higher tolerance for DRX-to-DRX since minor floating point
1729
+ // differences can occur during export/import cycles
1730
+ const tolerance = options.tolerance ?? 0.02;
1731
+
1732
+ try {
1733
+ // Parse both DRX files
1734
+ const storedParsed = await parseDRXContent(storedDrxContent);
1735
+ const currentParsed = await parseDRXContent(currentDrxContent);
1736
+
1737
+ // Extract tool breakdowns (actual wheel values)
1738
+ const storedBreakdown = extractToolBreakdownFromDRX(storedParsed);
1739
+ const currentBreakdown = extractToolBreakdownFromDRX(currentParsed);
1740
+
1741
+ // Build param objects for comparison
1742
+ const storedParams = {
1743
+ lift: storedBreakdown.lift,
1744
+ gamma: storedBreakdown.gamma,
1745
+ gain: storedBreakdown.gain,
1746
+ offset: storedBreakdown.offset,
1747
+ saturation: storedBreakdown.saturation,
1748
+ contrast: storedBreakdown.contrast,
1749
+ pivot: storedBreakdown.pivot,
1750
+ };
1751
+
1752
+ const currentParams = {
1753
+ lift: currentBreakdown.lift,
1754
+ gamma: currentBreakdown.gamma,
1755
+ gain: currentBreakdown.gain,
1756
+ offset: currentBreakdown.offset,
1757
+ saturation: currentBreakdown.saturation,
1758
+ contrast: currentBreakdown.contrast,
1759
+ pivot: currentBreakdown.pivot,
1760
+ };
1761
+
1762
+ // Compare parameters with specified tolerance
1763
+ const result = compareGradeParameters(storedParams, currentParams, { tolerance });
1764
+
1765
+ return {
1766
+ ...result,
1767
+ storedParams,
1768
+ currentParams,
1769
+ storedNodeCount: storedParsed.nodes?.length || 0,
1770
+ currentNodeCount: currentParsed.nodes?.length || 0,
1771
+ };
1772
+ } catch (error) {
1773
+ console.error('[drx-parser] compareDRXContent error:', error.message);
1774
+ return {
1775
+ matches: false,
1776
+ differences: [],
1777
+ summary: `Parse error: ${error.message}`,
1778
+ error: error.message,
1779
+ };
1780
+ }
1781
+ }
1782
+
1783
+ module.exports = {
1784
+ parseDRX,
1785
+ parseDRXContent,
1786
+ parseDRXBody,
1787
+ extractNodes,
1788
+ extractConnections,
1789
+ extractMetadata,
1790
+ ProtobufParser,
1791
+ PARAM_IDS,
1792
+ CORRECTOR_TYPES,
1793
+ DEFAULT_PARAMS,
1794
+ nodeHasAdjustments,
1795
+ hasActualGrade,
1796
+ // Tool breakdown utilities
1797
+ extractToolBreakdown,
1798
+ extractToolBreakdownFromDRX,
1799
+ formatValue,
1800
+ // Sync drift detection utilities
1801
+ SYNC_DRIFT_TOLERANCE,
1802
+ normalizeSemanticParams,
1803
+ compareGradeParameters,
1804
+ compareSyncState,
1805
+ compareDRXContent,
1806
+ // Re-export shared library for convenience
1807
+ drxParams,
1808
+ };