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.
- package/AGENTS.md +20 -0
- package/CHANGELOG.md +197 -2
- package/LICENSE +1 -1
- package/README.md +73 -1
- package/bin/davinci-resolve-advanced-mcp.mjs +20 -0
- package/docs/README.md +19 -0
- package/docs/SKILL.md +62 -1
- package/docs/contributing.md +1 -1
- package/docs/guides/control-panel.md +9 -0
- package/docs/kernels/README.md +21 -0
- package/docs/kernels/audio-fairlight-kernel.md +22 -0
- package/docs/kernels/color-grade-kernel.md +51 -0
- package/docs/kernels/fusion-composition-kernel.md +17 -0
- package/docs/kernels/media-pool-ingest-kernel.md +20 -0
- package/docs/kernels/render-deliver-kernel.md +32 -0
- package/docs/kernels/timeline-conform-interchange-kernel.md +39 -0
- package/docs/kernels/timeline-edit-kernel.md +17 -0
- package/docs/process/release-process.md +29 -7
- package/docs/reference/api-coverage.md +1 -1
- package/docs/reference/api-limitations.md +38 -1
- package/install.py +27 -4
- package/package.json +23 -2
- package/resolve-advanced/README.md +194 -0
- package/resolve-advanced/package.json +50 -0
- package/resolve-advanced/server/black-balance.mjs +92 -0
- package/resolve-advanced/server/capabilities.mjs +86 -0
- package/resolve-advanced/server/cdl-io.mjs +76 -0
- package/resolve-advanced/server/contrast-normalize.mjs +180 -0
- package/resolve-advanced/server/db-patch.mjs +91 -0
- package/resolve-advanced/server/deliverable-entities.mjs +86 -0
- package/resolve-advanced/server/deliverable-qc.mjs +270 -0
- package/resolve-advanced/server/editorial.mjs +370 -0
- package/resolve-advanced/server/exposure-level.mjs +129 -0
- package/resolve-advanced/server/extract-frames.mjs +196 -0
- package/resolve-advanced/server/ffprobe-media.mjs +88 -0
- package/resolve-advanced/server/gamut-legal.mjs +112 -0
- package/resolve-advanced/server/grade-body-patch.mjs +246 -0
- package/resolve-advanced/server/grade-transfer.mjs +66 -0
- package/resolve-advanced/server/group-grade-read.mjs +137 -0
- package/resolve-advanced/server/index.mjs +105 -0
- package/resolve-advanced/server/lib.mjs +84 -0
- package/resolve-advanced/server/libs.mjs +65 -0
- package/resolve-advanced/server/lineage-db.mjs +479 -0
- package/resolve-advanced/server/lut-apply.mjs +61 -0
- package/resolve-advanced/server/match-to-reference.mjs +173 -0
- package/resolve-advanced/server/media-inventory.mjs +169 -0
- package/resolve-advanced/server/media-ops.mjs +243 -0
- package/resolve-advanced/server/node-meta-db.mjs +62 -0
- package/resolve-advanced/server/node-provenance.mjs +102 -0
- package/resolve-advanced/server/offline-ref-db.mjs +420 -0
- package/resolve-advanced/server/offline-ref.mjs +179 -0
- package/resolve-advanced/server/project-db.mjs +233 -0
- package/resolve-advanced/server/provenance-audit.mjs +210 -0
- package/resolve-advanced/server/qc-frame.mjs +224 -0
- package/resolve-advanced/server/qc-sampler.mjs +67 -0
- package/resolve-advanced/server/readback.mjs +77 -0
- package/resolve-advanced/server/render-manifest.mjs +93 -0
- package/resolve-advanced/server/reverse-clip-db.mjs +270 -0
- package/resolve-advanced/server/runner-apply-contract.mjs +64 -0
- package/resolve-advanced/server/runner.mjs +244 -0
- package/resolve-advanced/server/saturation-match.mjs +108 -0
- package/resolve-advanced/server/scope-read.mjs +257 -0
- package/resolve-advanced/server/season-look.mjs +96 -0
- package/resolve-advanced/server/shot-intent.mjs +82 -0
- package/resolve-advanced/server/shot-match.mjs +147 -0
- package/resolve-advanced/server/skin-match.mjs +205 -0
- package/resolve-advanced/server/spec-compile.mjs +217 -0
- package/resolve-advanced/server/tone-curve-transfer.mjs +184 -0
- package/resolve-advanced/server/tool-catalog.mjs +265 -0
- package/resolve-advanced/server/tools/audio.mjs +54 -0
- package/resolve-advanced/server/tools/audio_plan.mjs +58 -0
- package/resolve-advanced/server/tools/capabilities.mjs +16 -0
- package/resolve-advanced/server/tools/color_trace.mjs +165 -0
- package/resolve-advanced/server/tools/conform.mjs +291 -0
- package/resolve-advanced/server/tools/deliverable.mjs +105 -0
- package/resolve-advanced/server/tools/drp.mjs +232 -0
- package/resolve-advanced/server/tools/drt.mjs +200 -0
- package/resolve-advanced/server/tools/drx.mjs +943 -0
- package/resolve-advanced/server/tools/editorial.mjs +69 -0
- package/resolve-advanced/server/tools/fairlight.mjs +108 -0
- package/resolve-advanced/server/tools/fusion.mjs +64 -0
- package/resolve-advanced/server/tools/media.mjs +102 -0
- package/resolve-advanced/server/tools/offline_ref.mjs +123 -0
- package/resolve-advanced/server/tools/pipeline.mjs +137 -0
- package/resolve-advanced/server/tools/project_db.mjs +200 -0
- package/resolve-advanced/server/tools/project_read.mjs +212 -0
- package/resolve-advanced/server/tools/provenance.mjs +73 -0
- package/resolve-advanced/server/verify-grade.mjs +113 -0
- package/resolve-advanced/server/white-balance-match.mjs +122 -0
- package/resolve-advanced/vendor/audio/format-converter.js +330 -0
- package/resolve-advanced/vendor/audio/split.js +173 -0
- package/resolve-advanced/vendor/audio/trim.js +188 -0
- package/resolve-advanced/vendor/audio-fairlight/index.js +391 -0
- package/resolve-advanced/vendor/conform-qc/adapters/frame-sampler.js +56 -0
- package/resolve-advanced/vendor/conform-qc/adapters/local-ffmpeg-sampler.js +65 -0
- package/resolve-advanced/vendor/conform-qc/adapters/rendernode-ffmpeg-sampler.js +50 -0
- package/resolve-advanced/vendor/conform-qc/adapters/resolve/driver.py +176 -0
- package/resolve-advanced/vendor/conform-qc/adapters/resolve-driver.js +61 -0
- package/resolve-advanced/vendor/conform-qc/adapters/resolve-headless-driver.js +86 -0
- package/resolve-advanced/vendor/conform-qc/adapters/vision-validator.js +75 -0
- package/resolve-advanced/vendor/conform-qc/cli.js +63 -0
- package/resolve-advanced/vendor/conform-qc/compare/advisory.js +106 -0
- package/resolve-advanced/vendor/conform-qc/compare/decode.js +36 -0
- package/resolve-advanced/vendor/conform-qc/compare/index.js +61 -0
- package/resolve-advanced/vendor/conform-qc/compare/locate.js +70 -0
- package/resolve-advanced/vendor/conform-qc/compare/metrics.js +409 -0
- package/resolve-advanced/vendor/conform-qc/docs/runbook.md +72 -0
- package/resolve-advanced/vendor/conform-qc/index.js +49 -0
- package/resolve-advanced/vendor/conform-qc/knowledge/index.js +110 -0
- package/resolve-advanced/vendor/conform-qc/ops/conform-core.js +137 -0
- package/resolve-advanced/vendor/conform-qc/ops/conform-diff.js +109 -0
- package/resolve-advanced/vendor/conform-qc/ops/insert.js +108 -0
- package/resolve-advanced/vendor/conform-qc/ops/local-conform.js +61 -0
- package/resolve-advanced/vendor/conform-qc/ops/patch.js +80 -0
- package/resolve-advanced/vendor/conform-qc/ops/trigger.js +32 -0
- package/resolve-advanced/vendor/conform-qc/ops/verify.js +105 -0
- package/resolve-advanced/vendor/conform-qc/ops/workflow.js +30 -0
- package/resolve-advanced/vendor/conform-qc/oracle/emulate.js +155 -0
- package/resolve-advanced/vendor/conform-qc/oracle/index.js +38 -0
- package/resolve-advanced/vendor/conform-qc/oracle/resolve.js +196 -0
- package/resolve-advanced/vendor/conform-qc/package.json +16 -0
- package/resolve-advanced/vendor/conform-qc/packaging/emit-aaf.js +30 -0
- package/resolve-advanced/vendor/conform-qc/packaging/emit-fcp7.js +64 -0
- package/resolve-advanced/vendor/conform-qc/packaging/index.js +169 -0
- package/resolve-advanced/vendor/conform-qc/packaging/media-ops.js +56 -0
- package/resolve-advanced/vendor/conform-qc/packaging/otio.js +50 -0
- package/resolve-advanced/vendor/conform-qc/packaging/relink.js +50 -0
- package/resolve-advanced/vendor/conform-qc/packaging/surgical-relink.js +195 -0
- package/resolve-advanced/vendor/conform-qc/parse/index.js +54 -0
- package/resolve-advanced/vendor/conform-qc/parse/merge-dto.js +27 -0
- package/resolve-advanced/vendor/conform-qc/parse/xmeml-geometry.js +256 -0
- package/resolve-advanced/vendor/conform-qc/reference/burnin-ocr.js +100 -0
- package/resolve-advanced/vendor/conform-qc/reference/sidecar.js +37 -0
- package/resolve-advanced/vendor/conform-qc/repair/analysis.js +62 -0
- package/resolve-advanced/vendor/conform-qc/repair/index.js +78 -0
- package/resolve-advanced/vendor/conform-qc/repair/media-analysis.js +139 -0
- package/resolve-advanced/vendor/conform-qc/repair/media-index.js +135 -0
- package/resolve-advanced/vendor/conform-qc/repair/strategies.js +250 -0
- package/resolve-advanced/vendor/conform-qc/report/index.js +133 -0
- package/resolve-advanced/vendor/conform-qc/roles/index.js +96 -0
- package/resolve-advanced/vendor/conform-qc/synthetic/generate.js +233 -0
- package/resolve-advanced/vendor/conform-qc/test/advisory.test.js +85 -0
- package/resolve-advanced/vendor/conform-qc/test/cli.test.js +39 -0
- package/resolve-advanced/vendor/conform-qc/test/compare.test.js +152 -0
- package/resolve-advanced/vendor/conform-qc/test/conform-diff.test.js +70 -0
- package/resolve-advanced/vendor/conform-qc/test/emulate.test.js +51 -0
- package/resolve-advanced/vendor/conform-qc/test/frame-sampler.test.js +30 -0
- package/resolve-advanced/vendor/conform-qc/test/geometry.test.js +112 -0
- package/resolve-advanced/vendor/conform-qc/test/knowledge.test.js +56 -0
- package/resolve-advanced/vendor/conform-qc/test/lifecycle.test.js +110 -0
- package/resolve-advanced/vendor/conform-qc/test/local-ffmpeg-sampler.test.js +66 -0
- package/resolve-advanced/vendor/conform-qc/test/locate.test.js +63 -0
- package/resolve-advanced/vendor/conform-qc/test/media-analysis.test.js +63 -0
- package/resolve-advanced/vendor/conform-qc/test/media-index-normalized.test.js +65 -0
- package/resolve-advanced/vendor/conform-qc/test/media-index-origin.test.js +53 -0
- package/resolve-advanced/vendor/conform-qc/test/metrics-fast.test.js +91 -0
- package/resolve-advanced/vendor/conform-qc/test/oracle-reverse.test.js +86 -0
- package/resolve-advanced/vendor/conform-qc/test/oracle.test.js +136 -0
- package/resolve-advanced/vendor/conform-qc/test/p0-acceptance.test.js +60 -0
- package/resolve-advanced/vendor/conform-qc/test/p1-acceptance.test.js +75 -0
- package/resolve-advanced/vendor/conform-qc/test/p45-patch-acceptance.test.js +102 -0
- package/resolve-advanced/vendor/conform-qc/test/packaging.test.js +133 -0
- package/resolve-advanced/vendor/conform-qc/test/parse-stubs.test.js +28 -0
- package/resolve-advanced/vendor/conform-qc/test/repair-strategies.test.js +128 -0
- package/resolve-advanced/vendor/conform-qc/test/repair.test.js +84 -0
- package/resolve-advanced/vendor/conform-qc/test/report.test.js +52 -0
- package/resolve-advanced/vendor/conform-qc/test/resolve-readback.test.js +71 -0
- package/resolve-advanced/vendor/conform-qc/test/roles.test.js +107 -0
- package/resolve-advanced/vendor/conform-qc/test/runbook.test.js +30 -0
- package/resolve-advanced/vendor/conform-qc/test/scale-residual.test.js +40 -0
- package/resolve-advanced/vendor/conform-qc/test/sidecar.test.js +51 -0
- package/resolve-advanced/vendor/conform-qc/test/smoke.test.js +87 -0
- package/resolve-advanced/vendor/conform-qc/test/surgical-relink.test.js +73 -0
- package/resolve-advanced/vendor/conform-qc/test/synthetic.test.js +98 -0
- package/resolve-advanced/vendor/conform-qc/test/verify.test.js +93 -0
- package/resolve-advanced/vendor/conform-qc/test/vision-validator.test.js +43 -0
- package/resolve-advanced/vendor/conform-qc/test/workflow-driver.test.js +46 -0
- package/resolve-advanced/vendor/conform-qc/toolset/index.js +53 -0
- package/resolve-advanced/vendor/drp-format/README.md +116 -0
- package/resolve-advanced/vendor/drp-format/__tests__/_resolve-verify.js +39 -0
- package/resolve-advanced/vendor/drp-format/__tests__/add-media-clip.test.js +30 -0
- package/resolve-advanced/vendor/drp-format/__tests__/assemble-timeline.test.js +68 -0
- package/resolve-advanced/vendor/drp-format/__tests__/author-project.test.js +57 -0
- package/resolve-advanced/vendor/drp-format/__tests__/composition-text.test.js +98 -0
- package/resolve-advanced/vendor/drp-format/__tests__/diff.test.js +272 -0
- package/resolve-advanced/vendor/drp-format/__tests__/effect-filters-compressed.test.js +143 -0
- package/resolve-advanced/vendor/drp-format/__tests__/fixtures/p0-3-session30-empty-effectfilters.drp +0 -0
- package/resolve-advanced/vendor/drp-format/__tests__/fixtures/retimed-timemap-50pct.hex +1 -0
- package/resolve-advanced/vendor/drp-format/__tests__/fixtures/retimed-timemap-dynamic.hex +1 -0
- package/resolve-advanced/vendor/drp-format/__tests__/inject-grades.test.js +238 -0
- package/resolve-advanced/vendor/drp-format/__tests__/keyed-dict.test.js +85 -0
- package/resolve-advanced/vendor/drp-format/__tests__/lut-refs.test.js +119 -0
- package/resolve-advanced/vendor/drp-format/__tests__/media-blobs.test.js +25 -0
- package/resolve-advanced/vendor/drp-format/__tests__/media-timemap.test.js +119 -0
- package/resolve-advanced/vendor/drp-format/__tests__/place-fusion-title.test.js +52 -0
- package/resolve-advanced/vendor/drp-format/__tests__/place-generator.test.js +40 -0
- package/resolve-advanced/vendor/drp-format/__tests__/place-transition.test.js +48 -0
- package/resolve-advanced/vendor/drp-format/__tests__/protobuf-wire.test.js +64 -0
- package/resolve-advanced/vendor/drp-format/__tests__/relink-media.test.js +106 -0
- package/resolve-advanced/vendor/drp-format/__tests__/splice-clips.test.js +256 -0
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +65 -0
- package/resolve-advanced/vendor/drp-format/author-project.js +118 -0
- package/resolve-advanced/vendor/drp-format/composition-text.js +213 -0
- package/resolve-advanced/vendor/drp-format/curves-encoder.js +763 -0
- package/resolve-advanced/vendor/drp-format/diff.js +310 -0
- package/resolve-advanced/vendor/drp-format/drp-packager.js +201 -0
- package/resolve-advanced/vendor/drp-format/drp-validator.js +809 -0
- package/resolve-advanced/vendor/drp-format/effect-encoder.js +640 -0
- package/resolve-advanced/vendor/drp-format/extract-lut-refs.js +119 -0
- package/resolve-advanced/vendor/drp-format/grade-encoder.example.js +133 -0
- package/resolve-advanced/vendor/drp-format/grade-encoder.js +401 -0
- package/resolve-advanced/vendor/drp-format/grade-encoder.test.js +171 -0
- package/resolve-advanced/vendor/drp-format/grade-encoder.verify.js +249 -0
- package/resolve-advanced/vendor/drp-format/grade-node-extractor.js +495 -0
- package/resolve-advanced/vendor/drp-format/grade-parameter-decoder.js +774 -0
- package/resolve-advanced/vendor/drp-format/index.js +174 -0
- package/resolve-advanced/vendor/drp-format/inject-grades.js +219 -0
- package/resolve-advanced/vendor/drp-format/keyed-dict.js +227 -0
- package/resolve-advanced/vendor/drp-format/marker-encoder.js +532 -0
- package/resolve-advanced/vendor/drp-format/media-blobs.js +53 -0
- package/resolve-advanced/vendor/drp-format/media-timemap.js +164 -0
- package/resolve-advanced/vendor/drp-format/mp-folder-builder.js +293 -0
- package/resolve-advanced/vendor/drp-format/node-tree-encoder.js +1246 -0
- package/resolve-advanced/vendor/drp-format/package.json +18 -0
- package/resolve-advanced/vendor/drp-format/place-fusion-title.js +131 -0
- package/resolve-advanced/vendor/drp-format/place-generator.js +77 -0
- package/resolve-advanced/vendor/drp-format/place-transition.js +91 -0
- package/resolve-advanced/vendor/drp-format/power-window-encoder.js +776 -0
- package/resolve-advanced/vendor/drp-format/protobuf-wire.js +111 -0
- package/resolve-advanced/vendor/drp-format/qualifier-encoder.js +706 -0
- package/resolve-advanced/vendor/drp-format/relink-media.js +218 -0
- package/resolve-advanced/vendor/drp-format/rich-title-encoder.js +447 -0
- package/resolve-advanced/vendor/drp-format/seq-container-builder.js +572 -0
- package/resolve-advanced/vendor/drp-format/seq-surgery.js +155 -0
- package/resolve-advanced/vendor/drp-format/splice-clips.js +445 -0
- package/resolve-advanced/vendor/drp-format/templates/empty-project.drp +0 -0
- package/resolve-advanced/vendor/drp-format/templates/fusion-title.xml +56 -0
- package/resolve-advanced/vendor/drp-format/templates/generator-solid-color.xml +21 -0
- package/resolve-advanced/vendor/drp-format/templates/media-clip-h264.drp +0 -0
- package/resolve-advanced/vendor/drp-format/templates/transition-cross-dissolve.xml +22 -0
- package/resolve-advanced/vendor/drp-format/tool-registry.descriptor.js +80 -0
- package/resolve-advanced/vendor/drp-format/utils/safe-archive.js +54 -0
- package/resolve-advanced/vendor/drp-format/xml-builder.js +759 -0
- package/resolve-advanced/vendor/drt-format/__tests__/_resolve-verify.js +21 -0
- package/resolve-advanced/vendor/drt-format/__tests__/build.test.js +113 -0
- package/resolve-advanced/vendor/drt-format/__tests__/multi-format.test.js +93 -0
- package/resolve-advanced/vendor/drt-format/__tests__/parse-real-resolve21.test.js +51 -0
- package/resolve-advanced/vendor/drt-format/__tests__/parse.test.js +134 -0
- package/resolve-advanced/vendor/drt-format/__tests__/resolve-versions.test.js +120 -0
- package/resolve-advanced/vendor/drt-format/__tests__/schema-fingerprint.test.js +62 -0
- package/resolve-advanced/vendor/drt-format/__tests__/smoke.test.js +27 -0
- package/resolve-advanced/vendor/drt-format/__tests__/validate.test.js +109 -0
- package/resolve-advanced/vendor/drt-format/capabilities.js +69 -0
- package/resolve-advanced/vendor/drt-format/capability-domains.js +56 -0
- package/resolve-advanced/vendor/drt-format/drt-builder.js +42 -0
- package/resolve-advanced/vendor/drt-format/drt-parser.js +164 -0
- package/resolve-advanced/vendor/drt-format/drt-validator.js +98 -0
- package/resolve-advanced/vendor/drt-format/index.js +45 -0
- package/resolve-advanced/vendor/drt-format/package.json +17 -0
- package/resolve-advanced/vendor/drt-format/resolve-versions.js +153 -0
- package/resolve-advanced/vendor/drt-format/schema-fingerprint.js +74 -0
- package/resolve-advanced/vendor/drt-format/templates/drt-project-shell.xml +124 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/curves-roundtrip.test.js +148 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-face-refinement.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-filmgrain.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-glow.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-1-lens-flare.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-3-beauty-25.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p2-3-beauty-75.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p5-1-node-no-lut.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/fixtures/p5-1-node-with-lut.drx +58 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/hsl-curves-roundtrip.test.js +117 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/lut-refs-roundtrip.test.js +112 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/matte-roundtrip.test.js +167 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/ofx-roundtrip.test.js +215 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/ofx-slugs-discovery.test.js +149 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/power-window-roundtrip.test.js +230 -0
- package/resolve-advanced/vendor/drx-codec/__tests__/qualifier-roundtrip.test.js +226 -0
- package/resolve-advanced/vendor/drx-codec/cdl-exporter.js +470 -0
- package/resolve-advanced/vendor/drx-codec/color-nlp-parser.js +833 -0
- package/resolve-advanced/vendor/drx-codec/data/color-nlp-action-mappings.json +2232 -0
- package/resolve-advanced/vendor/drx-codec/drx-generator.js +4123 -0
- package/resolve-advanced/vendor/drx-codec/drx-merger.js +320 -0
- package/resolve-advanced/vendor/drx-codec/drx-parser.js +1808 -0
- package/resolve-advanced/vendor/drx-codec/extract-custom-curves.js +215 -0
- package/resolve-advanced/vendor/drx-codec/extract-hsl-curves.js +180 -0
- package/resolve-advanced/vendor/drx-codec/extract-lut-refs.js +158 -0
- package/resolve-advanced/vendor/drx-codec/extract-matte-finesse.js +83 -0
- package/resolve-advanced/vendor/drx-codec/extract-ofx-params.js +226 -0
- package/resolve-advanced/vendor/drx-codec/extract-power-window.js +184 -0
- package/resolve-advanced/vendor/drx-codec/extract-qualifier.js +99 -0
- package/resolve-advanced/vendor/drx-codec/index.js +109 -0
- package/resolve-advanced/vendor/drx-codec/node-layout.js +249 -0
- package/resolve-advanced/vendor/drx-codec/resolved-drx-generator.js +393 -0
- package/resolve-advanced/vendor/drx-codec/tool-resolver.js +974 -0
- package/resolve-advanced/vendor/drx-codec/training-examples.js +548 -0
- package/resolve-advanced/vendor/drx-codec/vocabulary-intents.js +539 -0
- package/resolve-advanced/vendor/drx-parameters/CALIBRATION-STATUS.md +319 -0
- package/resolve-advanced/vendor/drx-parameters/DRX-VALUE-SCALING.md +251 -0
- package/resolve-advanced/vendor/drx-parameters/calibrated-ranges.json +17737 -0
- package/resolve-advanced/vendor/drx-parameters/corrector-types.js +120 -0
- package/resolve-advanced/vendor/drx-parameters/index.js +193 -0
- package/resolve-advanced/vendor/drx-parameters/parameter-codec.js +451 -0
- package/resolve-advanced/vendor/drx-parameters/parameter-ids.js +1292 -0
- package/resolve-advanced/vendor/drx-parameters/parameter-ranges.js +570 -0
- package/resolve-advanced/vendor/drx-parameters/parameter-validator.js +392 -0
- package/resolve-advanced/vendor/drx-parameters/resolvefx-registry.json +14973 -0
- package/resolve-advanced/vendor/drx-parameters/tool-registry.descriptor.js +38 -0
- package/resolve-advanced/vendor/drx-parameters/tools/drx-analyzer.js +371 -0
- package/resolve-advanced/vendor/fairlight/index.js +833 -0
- package/resolve-advanced/vendor/fusion-codec/composition-generator.js +401 -0
- package/resolve-advanced/vendor/fusion-codec/templates/blur-region.js +103 -0
- package/resolve-advanced/vendor/fusion-codec/templates/color-correct.js +87 -0
- package/resolve-advanced/vendor/fusion-codec/templates/film-grain.js +106 -0
- package/resolve-advanced/vendor/fusion-codec/templates/lower-third.js +241 -0
- package/resolve-advanced/vendor/fusion-codec/templates/picture-in-picture.js +188 -0
- package/resolve-advanced/vendor/fusion-codec/templates/text-overlay.js +161 -0
- package/resolve-advanced/vendor/fusion-codec/templates/title-card.js +184 -0
- package/resolve-advanced/vendor/fusion-codec/templates/vignette.js +107 -0
- package/resolve-advanced/vendor/fusion-codec/templates/watermark.js +101 -0
- package/scripts/agent-rules/README.md +68 -0
- package/src/analysis_dashboard.py +233 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +479 -29
- package/src/utils/api_truth.py +117 -0
- package/src/utils/cut_ir.py +1 -1
- package/src/utils/failure_tracker.py +0 -2
- package/src/utils/timeline_xml.py +405 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster D — deliverable QC / compliance. The single biggest reject-preventer for a
|
|
3
|
+
* two-deliverable-per-episode show (producer + online both ranked it #1).
|
|
4
|
+
*
|
|
5
|
+
* All MEASURE-only, report-only (gate: review — NEVER auto-`pass`-clear a deliverable; the
|
|
6
|
+
* producer/online sign off). Silent-lie discipline extends to media here: assert bytes read,
|
|
7
|
+
* skip-not-fake on unreadable, and a per-field pass/fail (an empty/green report is the lie).
|
|
8
|
+
*
|
|
9
|
+
* deliverable_qc — ffprobe a rendered file vs its spec → pass/fail PER FIELD
|
|
10
|
+
* loudness_qc — ffmpeg ebur128 integrated LUFS + true-peak dBTP + LRA vs target
|
|
11
|
+
* reframe_blanking_check — letterbox/pillarbox + active-picture bounds + illegal edge pixels
|
|
12
|
+
* conform_completeness — all clips online, handles present, duration == reference (frame-exact)
|
|
13
|
+
* re_delivery_diff — old vs new render: frame-count Δ, duration Δ, spec drift, sampled changes
|
|
14
|
+
*
|
|
15
|
+
* Deps: ffmpeg/ffprobe (peer) for the file tools; sharp for reframe_blanking on a PNG.
|
|
16
|
+
* The comparison cores are PURE (probe → check) so they unit-test without media. No Resolve, no LLM.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { probeMedia } from './ffprobe-media.mjs';
|
|
21
|
+
import { requireFfmpeg } from './capabilities.mjs';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
|
|
25
|
+
// ── deliverable_qc ─────────────────────────────────────────────────────
|
|
26
|
+
const eq = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
|
|
27
|
+
const near = (a, b, tol) => Math.abs(Number(a) - Number(b)) <= tol;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compare a probed media object to a deliverable spec → per-field pass/fail. PURE.
|
|
31
|
+
* @param {object} probe result of probeMedia()
|
|
32
|
+
* @param {object} spec { video:{...}, audio:{...}, container, durationSeconds, durationTolSeconds, filenameRegex }
|
|
33
|
+
* @param {{filename?:string, fpsTol?:number}} [opts]
|
|
34
|
+
*/
|
|
35
|
+
export function checkDeliverable(probe, spec = {}, opts = {}) {
|
|
36
|
+
const fields = [];
|
|
37
|
+
const add = (field, expected, actual, pass, note) => fields.push({ field, expected, actual, pass, ...(note ? { note } : {}) });
|
|
38
|
+
const fpsTol = opts.fpsTol ?? 0.01;
|
|
39
|
+
|
|
40
|
+
if (spec.video) {
|
|
41
|
+
const v = probe.video || {};
|
|
42
|
+
const s = spec.video;
|
|
43
|
+
if (s.codec != null) add('video.codec', s.codec, v.codec, eq(s.codec, v.codec));
|
|
44
|
+
if (s.profile != null) add('video.profile', s.profile, v.profile, eq(s.profile, v.profile));
|
|
45
|
+
if (s.width != null) add('video.width', s.width, v.width, Number(s.width) === Number(v.width));
|
|
46
|
+
if (s.height != null) add('video.height', s.height, v.height, Number(s.height) === Number(v.height));
|
|
47
|
+
if (s.fps != null) add('video.fps', s.fps, v.fps, near(s.fps, v.fps, fpsTol));
|
|
48
|
+
if (s.scan != null) add('video.scan', s.scan, v.scan, eq(s.scan, v.scan));
|
|
49
|
+
if (s.colorPrimaries != null) add('video.colorPrimaries', s.colorPrimaries, v.colorPrimaries, eq(s.colorPrimaries, v.colorPrimaries));
|
|
50
|
+
if (s.colorTransfer != null) add('video.colorTransfer', s.colorTransfer, v.colorTransfer, eq(s.colorTransfer, v.colorTransfer));
|
|
51
|
+
if (s.colorMatrix != null) add('video.colorMatrix', s.colorMatrix, v.colorMatrix, eq(s.colorMatrix, v.colorMatrix));
|
|
52
|
+
if (s.bitDepth != null) add('video.bitDepth', s.bitDepth, v.bitDepth, Number(s.bitDepth) === Number(v.bitDepth));
|
|
53
|
+
if (s.pixFmt != null) add('video.pixFmt', s.pixFmt, v.pixFmt, eq(s.pixFmt, v.pixFmt));
|
|
54
|
+
}
|
|
55
|
+
if (spec.audio) {
|
|
56
|
+
const a0 = (probe.audio && probe.audio[0]) || {};
|
|
57
|
+
const totalCh = (probe.audio || []).reduce((n, a) => n + (a.channels || 0), 0);
|
|
58
|
+
const s = spec.audio;
|
|
59
|
+
if (s.streams != null) add('audio.streams', s.streams, (probe.audio || []).length, Number(s.streams) === (probe.audio || []).length);
|
|
60
|
+
if (s.channels != null) add('audio.channels', s.channels, totalCh, Number(s.channels) === totalCh, 'total across streams');
|
|
61
|
+
if (s.channelLayout != null) add('audio.channelLayout', s.channelLayout, a0.channelLayout, eq(s.channelLayout, a0.channelLayout));
|
|
62
|
+
if (s.sampleRate != null) add('audio.sampleRate', s.sampleRate, a0.sampleRate, Number(s.sampleRate) === Number(a0.sampleRate));
|
|
63
|
+
if (s.codec != null) add('audio.codec', s.codec, a0.codec, eq(s.codec, a0.codec));
|
|
64
|
+
if (s.bitDepth != null) add('audio.bitDepth', s.bitDepth, a0.bitDepth, Number(s.bitDepth) === Number(a0.bitDepth));
|
|
65
|
+
}
|
|
66
|
+
if (spec.container != null) add('container', spec.container, probe.format.container, eq(spec.container, probe.format.container));
|
|
67
|
+
if (spec.durationSeconds != null) {
|
|
68
|
+
const tol = spec.durationTolSeconds ?? 0.5;
|
|
69
|
+
add('duration', `${spec.durationSeconds}s ±${tol}`, probe.format.duration, near(spec.durationSeconds, probe.format.duration, tol));
|
|
70
|
+
}
|
|
71
|
+
if (spec.filenameRegex != null && opts.filename != null) {
|
|
72
|
+
let re;
|
|
73
|
+
try {
|
|
74
|
+
re = new RegExp(spec.filenameRegex);
|
|
75
|
+
} catch {
|
|
76
|
+
re = null;
|
|
77
|
+
}
|
|
78
|
+
add('filename', spec.filenameRegex, opts.filename, re ? re.test(opts.filename) : false, re ? undefined : 'invalid regex');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const failed = fields.filter((f) => !f.pass);
|
|
82
|
+
return { pass: failed.length === 0, fieldCount: fields.length, failedCount: failed.length, fields, failed: failed.map((f) => f.field) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** deliverable_qc: probe a file + check it against a spec. */
|
|
86
|
+
export function deliverableQc(file, spec = {}, opts = {}) {
|
|
87
|
+
const probe = probeMedia(file, opts);
|
|
88
|
+
if (!probe) throw new Error(`deliverable_qc: could not probe '${file}' (unreadable/not media — skip-not-fake)`);
|
|
89
|
+
const filename = opts.filename ?? file.split('/').pop();
|
|
90
|
+
const result = checkDeliverable(probe, spec, { filename, fpsTol: opts.fpsTol });
|
|
91
|
+
return { file, probe, ...result, gate: 'review', note: 'measurement only — the producer/online signs off; never auto-pass-clear' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── loudness_qc ────────────────────────────────────────────────────────
|
|
95
|
+
/** Parse ffmpeg ebur128 stderr Summary block → { integratedLufs, truePeakDbtp, lra, threshold }. */
|
|
96
|
+
export function parseEbur128(stderr) {
|
|
97
|
+
const grab = (label) => {
|
|
98
|
+
const re = new RegExp(`${label}:\\s*(-?\\d+(?:\\.\\d+)?)`);
|
|
99
|
+
const m = re.exec(stderr);
|
|
100
|
+
return m ? Number(m[1]) : null;
|
|
101
|
+
};
|
|
102
|
+
// The Summary block prints "I:", "LRA:", "Peak:" under headers; match the trailing values.
|
|
103
|
+
const integratedLufs = grab('I') ?? grab('Integrated loudness\\s*[\\s\\S]*?I');
|
|
104
|
+
const lra = grab('LRA') ?? grab('Loudness range\\s*[\\s\\S]*?LRA');
|
|
105
|
+
const truePeakDbtp = grab('Peak');
|
|
106
|
+
return { integratedLufs, lra, truePeakDbtp };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* loudness_qc: measure integrated LUFS + true-peak + LRA and check vs a per-deliverable target.
|
|
111
|
+
* @param {string} file
|
|
112
|
+
* @param {{integrated?:number, integratedTol?:number, truePeakMax?:number, lraMax?:number, ffmpeg?:string}} target
|
|
113
|
+
*/
|
|
114
|
+
export function loudnessQc(file, target = {}) {
|
|
115
|
+
requireFfmpeg();
|
|
116
|
+
const ffmpeg = target.ffmpeg || 'ffmpeg';
|
|
117
|
+
const r = spawnSync(ffmpeg, ['-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], {
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
timeout: 120000,
|
|
120
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
121
|
+
});
|
|
122
|
+
const stderr = r.stderr || '';
|
|
123
|
+
const measured = parseEbur128(stderr);
|
|
124
|
+
if (measured.integratedLufs == null) throw new Error(`loudness_qc: could not measure loudness on '${file}' (no audio? ffmpeg ebur128 produced no summary)`);
|
|
125
|
+
const checks = [];
|
|
126
|
+
const add = (field, expected, actual, pass) => checks.push({ field, expected, actual, pass });
|
|
127
|
+
if (target.integrated != null) {
|
|
128
|
+
const tol = target.integratedTol ?? 1.0;
|
|
129
|
+
add('integratedLufs', `${target.integrated} ±${tol}`, measured.integratedLufs, Math.abs(measured.integratedLufs - target.integrated) <= tol);
|
|
130
|
+
}
|
|
131
|
+
if (target.truePeakMax != null)
|
|
132
|
+
add('truePeakDbtp', `≤ ${target.truePeakMax}`, measured.truePeakDbtp, measured.truePeakDbtp != null && measured.truePeakDbtp <= target.truePeakMax);
|
|
133
|
+
if (target.lraMax != null) add('lra', `≤ ${target.lraMax}`, measured.lra, measured.lra != null && measured.lra <= target.lraMax);
|
|
134
|
+
const failed = checks.filter((c) => !c.pass);
|
|
135
|
+
return { file, measured, pass: failed.length === 0, checks, failed: failed.map((c) => c.field), gate: 'review' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── reframe_blanking_check ─────────────────────────────────────────────
|
|
139
|
+
function loadSharp() {
|
|
140
|
+
try {
|
|
141
|
+
return require('sharp');
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error("reframe_blanking_check needs the optional dep 'sharp'. Install: npm i sharp");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* reframe_blanking_check: detect letterbox/pillarbox bars + active-picture bounds + illegal
|
|
149
|
+
* edge pixels on a display-referred PNG (an extracted frame). Deterministic.
|
|
150
|
+
* @param {string} pngPath
|
|
151
|
+
* @param {{blackThreshold?:number, barFraction?:number, maxSide?:number}} opts
|
|
152
|
+
*/
|
|
153
|
+
export async function reframeBlankingCheck(pngPath, opts = {}) {
|
|
154
|
+
const sharp = loadSharp();
|
|
155
|
+
const maxSide = opts.maxSide ?? 480;
|
|
156
|
+
const blk = opts.blackThreshold ?? 16; // ≤ this (per channel) counts as blanking
|
|
157
|
+
const barFrac = opts.barFraction ?? 0.98; // a row/col is a bar if ≥98% of its pixels are black
|
|
158
|
+
let data, ch, W, H;
|
|
159
|
+
try {
|
|
160
|
+
const out = await sharp(pngPath).resize(maxSide, maxSide, { fit: 'inside', kernel: 'nearest' }).raw().toBuffer({ resolveWithObject: true });
|
|
161
|
+
data = out.data;
|
|
162
|
+
ch = out.info.channels;
|
|
163
|
+
W = out.info.width;
|
|
164
|
+
H = out.info.height;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const isBlack = (i) => data[i] <= blk && data[i + 1] <= blk && data[i + 2] <= blk;
|
|
169
|
+
const rowBlack = (y) => {
|
|
170
|
+
let n = 0;
|
|
171
|
+
for (let x = 0; x < W; x++) if (isBlack((y * W + x) * ch)) n++;
|
|
172
|
+
return n / W >= barFrac;
|
|
173
|
+
};
|
|
174
|
+
const colBlack = (x) => {
|
|
175
|
+
let n = 0;
|
|
176
|
+
for (let y = 0; y < H; y++) if (isBlack((y * W + x) * ch)) n++;
|
|
177
|
+
return n / H >= barFrac;
|
|
178
|
+
};
|
|
179
|
+
let top = 0,
|
|
180
|
+
bottom = 0,
|
|
181
|
+
left = 0,
|
|
182
|
+
right = 0;
|
|
183
|
+
while (top < H && rowBlack(top)) top++;
|
|
184
|
+
while (bottom < H - top && rowBlack(H - 1 - bottom)) bottom++;
|
|
185
|
+
while (left < W && colBlack(left)) left++;
|
|
186
|
+
while (right < W - left && colBlack(W - 1 - right)) right++;
|
|
187
|
+
const activeW = Math.max(0, W - left - right);
|
|
188
|
+
const activeH = Math.max(0, H - top - bottom);
|
|
189
|
+
const activeAspect = activeH ? +(activeW / activeH).toFixed(4) : 0;
|
|
190
|
+
// Illegal edge pixels: a frame edge that is neither fully blanked nor part of the active
|
|
191
|
+
// picture bound cleanly (a 1px bright seam in the blanking) — report the brightest edge px.
|
|
192
|
+
let edgeMax = 0;
|
|
193
|
+
const scan = (i) => {
|
|
194
|
+
edgeMax = Math.max(edgeMax, data[i], data[i + 1], data[i + 2]);
|
|
195
|
+
};
|
|
196
|
+
for (let x = 0; x < W; x++) {
|
|
197
|
+
scan((0 * W + x) * ch);
|
|
198
|
+
scan(((H - 1) * W + x) * ch);
|
|
199
|
+
}
|
|
200
|
+
for (let y = 0; y < H; y++) {
|
|
201
|
+
scan((y * W + 0) * ch);
|
|
202
|
+
scan((y * W + (W - 1)) * ch);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
frameSize: { w: W, h: H },
|
|
206
|
+
bars: { top: +(top / H).toFixed(4), bottom: +(bottom / H).toFixed(4), left: +(left / W).toFixed(4), right: +(right / W).toFixed(4) },
|
|
207
|
+
activeRect: { x: left, y: top, w: activeW, h: activeH },
|
|
208
|
+
activeAspect,
|
|
209
|
+
letterboxed: top + bottom > 0,
|
|
210
|
+
pillarboxed: left + right > 0,
|
|
211
|
+
edgeMaxLuma: edgeMax,
|
|
212
|
+
illegalEdge: edgeMax > blk && top === 0 && bottom === 0 && left === 0 && right === 0 ? false : edgeMax > blk && (top || bottom || left || right) > 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── conform_completeness ───────────────────────────────────────────────
|
|
217
|
+
/**
|
|
218
|
+
* conform_completeness: assert a conformed timeline is deliver-ready. PURE over a supplied
|
|
219
|
+
* structure (the live server reports clip online-state/handles; Node judges). Frame-exact.
|
|
220
|
+
* @param {{clips:Array<{id, online?:boolean, handleIn?:number, handleOut?:number}>, timelineFrames?:number}} timeline
|
|
221
|
+
* @param {{referenceFrames?:number, minHandle?:number}} opts
|
|
222
|
+
*/
|
|
223
|
+
export function conformCompleteness(timeline, opts = {}) {
|
|
224
|
+
const clips = timeline.clips || [];
|
|
225
|
+
const minHandle = opts.minHandle ?? 0;
|
|
226
|
+
const offline = clips.filter((c) => c.online === false).map((c) => c.id);
|
|
227
|
+
const shortHandles = clips.filter((c) => minHandle > 0 && ((c.handleIn ?? Infinity) < minHandle || (c.handleOut ?? Infinity) < minHandle)).map((c) => c.id);
|
|
228
|
+
const checks = [];
|
|
229
|
+
checks.push({ field: 'all_online', pass: offline.length === 0, offline });
|
|
230
|
+
if (minHandle > 0) checks.push({ field: 'handles', pass: shortHandles.length === 0, minHandle, shortHandles });
|
|
231
|
+
if (opts.referenceFrames != null)
|
|
232
|
+
checks.push({
|
|
233
|
+
field: 'duration_frame_exact',
|
|
234
|
+
pass: Number(timeline.timelineFrames) === Number(opts.referenceFrames),
|
|
235
|
+
expected: opts.referenceFrames,
|
|
236
|
+
actual: timeline.timelineFrames ?? null,
|
|
237
|
+
});
|
|
238
|
+
const failed = checks.filter((c) => !c.pass);
|
|
239
|
+
return { pass: failed.length === 0, clipCount: clips.length, checks, failed: failed.map((c) => c.field), gate: 'review' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── re_delivery_diff ───────────────────────────────────────────────────
|
|
243
|
+
/**
|
|
244
|
+
* re_delivery_diff: compare an OLD render to a NEW render → frame-count Δ, duration Δ, spec
|
|
245
|
+
* drift (codec/raster/fps/color/audio), and sampled-frame change hints. PURE core over two probes.
|
|
246
|
+
*/
|
|
247
|
+
export function compareRenders(oldP, newP) {
|
|
248
|
+
const drift = [];
|
|
249
|
+
const cmp = (field, a, b) => {
|
|
250
|
+
if (String(a) !== String(b)) drift.push({ field, old: a, new: b });
|
|
251
|
+
};
|
|
252
|
+
const ov = oldP.video || {},
|
|
253
|
+
nv = newP.video || {};
|
|
254
|
+
for (const k of ['codec', 'profile', 'width', 'height', 'fps', 'scan', 'colorPrimaries', 'colorTransfer', 'colorMatrix', 'bitDepth', 'pixFmt'])
|
|
255
|
+
cmp(`video.${k}`, ov[k], nv[k]);
|
|
256
|
+
cmp('container', oldP.format.container, newP.format.container);
|
|
257
|
+
const oa = (oldP.audio || [])[0] || {},
|
|
258
|
+
na = (newP.audio || [])[0] || {};
|
|
259
|
+
for (const k of ['codec', 'channels', 'channelLayout', 'sampleRate']) cmp(`audio.${k}`, oa[k], na[k]);
|
|
260
|
+
const frameDelta = (nv.frameCount || 0) - (ov.frameCount || 0);
|
|
261
|
+
const durationDelta = +(newP.format.duration - oldP.format.duration).toFixed(4);
|
|
262
|
+
return { frameDelta, durationDelta, specDrift: drift, specDriftCount: drift.length, sameLength: frameDelta === 0 };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function reDeliveryDiff(oldFile, newFile, opts = {}) {
|
|
266
|
+
const oldP = probeMedia(oldFile, opts);
|
|
267
|
+
const newP = probeMedia(newFile, opts);
|
|
268
|
+
if (!oldP || !newP) throw new Error('re_delivery_diff: could not probe one of the renders (skip-not-fake)');
|
|
269
|
+
return { old: oldFile, new: newFile, ...compareRenders(oldP, newP), gate: 'review' };
|
|
270
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster E — editorial integrity. The turnover interchange (XML/EDL/OTIO) → a normalized
|
|
3
|
+
* edit-event list → a structured CHANGELIST (what moved/retimed/replaced/appeared/vanished) and
|
|
4
|
+
* a per-event CONFORM MANIFEST assert BEFORE grading. This is the north star made real: the
|
|
5
|
+
* changelist drives the frame-QC worklist.
|
|
6
|
+
*
|
|
7
|
+
* TIMING silent-lie discipline (cross-craft review): flattened retime · dropped J/L-cut audio ·
|
|
8
|
+
* framerate/pulldown slip · reverse dropped · transition-handle starvation — THROW/FLAG,
|
|
9
|
+
* skip-not-fake. A conform that silently flattens a speed ramp or drops split-track audio is the
|
|
10
|
+
* timing analogue of a faked grade.
|
|
11
|
+
*
|
|
12
|
+
* Interchange breadth AT INGEST: EDL (CMX3600) + OTIO (JSON) parse natively here; XMEML via a
|
|
13
|
+
* light clipitem parse. AAF is binary — honest refuse (convert to OTIO/EDL upstream).
|
|
14
|
+
*
|
|
15
|
+
* PURE + deterministic. No Resolve, no LLM.
|
|
16
|
+
*/
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
// ── timecode ───────────────────────────────────────────────────────────
|
|
22
|
+
const TC_RE = /^(\d{2}):(\d{2}):(\d{2})[:;](\d{2,3})$/;
|
|
23
|
+
export function tcToFrames(tc, fps) {
|
|
24
|
+
const m = TC_RE.exec(String(tc).trim());
|
|
25
|
+
if (!m || !fps) return null;
|
|
26
|
+
const [, h, mm, s, f] = m.map(Number);
|
|
27
|
+
return Math.round((h * 3600 + mm * 60 + s) * fps) + f;
|
|
28
|
+
}
|
|
29
|
+
const isTc = (t) => TC_RE.test(t);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalized edit event. All positions in FRAMES.
|
|
33
|
+
* { index, track, source, srcIn, srcOut, recIn, recOut, speed(%), reverse, transition, fps }
|
|
34
|
+
*/
|
|
35
|
+
function evt(o) {
|
|
36
|
+
return {
|
|
37
|
+
index: o.index ?? null,
|
|
38
|
+
track: o.track || 'V',
|
|
39
|
+
source: o.source || 'UNKNOWN',
|
|
40
|
+
srcIn: o.srcIn ?? null,
|
|
41
|
+
srcOut: o.srcOut ?? null,
|
|
42
|
+
recIn: o.recIn ?? null,
|
|
43
|
+
recOut: o.recOut ?? null,
|
|
44
|
+
speed: o.speed ?? 100,
|
|
45
|
+
reverse: o.reverse ?? false,
|
|
46
|
+
transition: o.transition || null,
|
|
47
|
+
fps: o.fps ?? null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── EDL (CMX3600) ──────────────────────────────────────────────────────
|
|
52
|
+
/** Parse a CMX3600 EDL text into normalized events. */
|
|
53
|
+
export function parseEDL(text, opts = {}) {
|
|
54
|
+
const fps = opts.fps ?? 24;
|
|
55
|
+
const events = [];
|
|
56
|
+
let pendingSpeed = null; // from an M2 motion line preceding/following the event
|
|
57
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
58
|
+
const line = raw.trim();
|
|
59
|
+
if (!line) continue;
|
|
60
|
+
// Motion (speed) line: M2 <reel> <fps-signed> <srcTC>
|
|
61
|
+
const m2 = /^M2\s+(\S+)\s+(-?\d+(?:\.\d+)?)/.exec(line);
|
|
62
|
+
if (m2) {
|
|
63
|
+
const playFps = Number(m2[2]);
|
|
64
|
+
pendingSpeed = { reel: m2[1], speedPct: fps ? (playFps / fps) * 100 : 100, reverse: playFps < 0 };
|
|
65
|
+
// Attach to the last event with this reel.
|
|
66
|
+
const target = [...events].reverse().find((e) => e.source === m2[1]);
|
|
67
|
+
if (target) {
|
|
68
|
+
target.speed = +Math.abs(pendingSpeed.speedPct).toFixed(2);
|
|
69
|
+
target.reverse = pendingSpeed.reverse;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const tokens = line.split(/\s+/);
|
|
74
|
+
if (!/^\d+$/.test(tokens[0])) continue; // not an event line
|
|
75
|
+
const tcs = tokens.filter(isTc);
|
|
76
|
+
if (tcs.length < 4) continue;
|
|
77
|
+
const [srcIn, srcOut, recIn, recOut] = tcs.slice(-4);
|
|
78
|
+
const head = tokens.slice(0, tokens.indexOf(tcs[tcs.length - 4]));
|
|
79
|
+
const [eventNum, reel, channel, transition] = head;
|
|
80
|
+
const dur = transition && transition !== 'C' ? Number(head[4]) : 0;
|
|
81
|
+
const track = /A/i.test(channel || '') && !/V/i.test(channel || '') ? 'A' : 'V';
|
|
82
|
+
events.push(
|
|
83
|
+
evt({
|
|
84
|
+
index: Number(eventNum),
|
|
85
|
+
track,
|
|
86
|
+
source: reel,
|
|
87
|
+
srcIn: tcToFrames(srcIn, fps),
|
|
88
|
+
srcOut: tcToFrames(srcOut, fps),
|
|
89
|
+
recIn: tcToFrames(recIn, fps),
|
|
90
|
+
recOut: tcToFrames(recOut, fps),
|
|
91
|
+
transition: transition && transition !== 'C' ? { type: transition, duration: dur || 0 } : null,
|
|
92
|
+
fps,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return events;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── OTIO (JSON) ────────────────────────────────────────────────────────
|
|
100
|
+
/** Parse an OTIO timeline JSON (object or string) into normalized events. */
|
|
101
|
+
export function parseOTIO(otio, opts = {}) {
|
|
102
|
+
const doc = typeof otio === 'string' ? JSON.parse(otio) : otio;
|
|
103
|
+
const tracks = (doc.tracks && doc.tracks.children) || [];
|
|
104
|
+
const events = [];
|
|
105
|
+
let idx = 1;
|
|
106
|
+
for (const track of tracks) {
|
|
107
|
+
const kind = track.kind === 'Audio' ? 'A' : 'V';
|
|
108
|
+
let rec = 0;
|
|
109
|
+
for (const child of track.children || []) {
|
|
110
|
+
const schema = child.OTIO_SCHEMA || '';
|
|
111
|
+
const dur = (child.source_range && child.source_range.duration && child.source_range.duration.value) || 0;
|
|
112
|
+
const rate = (child.source_range && child.source_range.duration && child.source_range.duration.rate) || opts.fps || 24;
|
|
113
|
+
if (schema.startsWith('Gap')) {
|
|
114
|
+
rec += dur;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (schema.startsWith('Clip')) {
|
|
118
|
+
const startVal = (child.source_range && child.source_range.start_time && child.source_range.start_time.value) || 0;
|
|
119
|
+
// Retime via a LinearTimeWarp effect (time_scalar).
|
|
120
|
+
let speed = 100,
|
|
121
|
+
reverse = false;
|
|
122
|
+
for (const eff of child.effects || []) {
|
|
123
|
+
if (eff.time_scalar != null) {
|
|
124
|
+
speed = +(eff.time_scalar * 100).toFixed(2);
|
|
125
|
+
reverse = eff.time_scalar < 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const src = (child.media_reference && (child.media_reference.target_url || child.media_reference.name)) || child.name || 'UNKNOWN';
|
|
129
|
+
events.push(
|
|
130
|
+
evt({
|
|
131
|
+
index: idx++,
|
|
132
|
+
track: kind,
|
|
133
|
+
source: src,
|
|
134
|
+
srcIn: startVal,
|
|
135
|
+
srcOut: startVal + dur,
|
|
136
|
+
recIn: rec,
|
|
137
|
+
recOut: rec + dur,
|
|
138
|
+
speed: Math.abs(speed),
|
|
139
|
+
reverse,
|
|
140
|
+
fps: rate,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
rec += dur;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return events;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── XMEML (FCP7 XML) — light clipitem parse ────────────────────────────
|
|
151
|
+
export function parseXMEMLEvents(xml, opts = {}) {
|
|
152
|
+
const { XMLParser } = require('fast-xml-parser');
|
|
153
|
+
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
|
|
154
|
+
const doc = parser.parse(xml);
|
|
155
|
+
const events = [];
|
|
156
|
+
let idx = 1;
|
|
157
|
+
const seqRate = opts.fps || 24;
|
|
158
|
+
const walk = (node, track) => {
|
|
159
|
+
if (!node) return;
|
|
160
|
+
const items = Array.isArray(node) ? node : [node];
|
|
161
|
+
for (const it of items) {
|
|
162
|
+
const name = it.name || (it.file && it.file.name) || 'UNKNOWN';
|
|
163
|
+
const start = Number(it.start);
|
|
164
|
+
const end = Number(it.end);
|
|
165
|
+
const inF = Number(it.in);
|
|
166
|
+
const outF = Number(it.out);
|
|
167
|
+
let speed = 100,
|
|
168
|
+
reverse = false;
|
|
169
|
+
// speed via a timeremap/motion filter (best-effort)
|
|
170
|
+
const filters = it.filter ? (Array.isArray(it.filter) ? it.filter : [it.filter]) : [];
|
|
171
|
+
for (const fl of filters) {
|
|
172
|
+
const params = fl.effect && fl.effect.parameter ? (Array.isArray(fl.effect.parameter) ? fl.effect.parameter : [fl.effect.parameter]) : [];
|
|
173
|
+
for (const pm of params) {
|
|
174
|
+
if (/speed/i.test(pm.name || '') && pm.value != null) {
|
|
175
|
+
speed = Math.abs(Number(pm.value));
|
|
176
|
+
reverse = Number(pm.value) < 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (Number.isFinite(start) && Number.isFinite(end)) {
|
|
181
|
+
events.push(evt({ index: idx++, track, source: name, srcIn: inF, srcOut: outF, recIn: start, recOut: end, speed, reverse, fps: seqRate }));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const seq = doc.xmeml && doc.xmeml.sequence;
|
|
186
|
+
const media = seq && seq.media;
|
|
187
|
+
if (media) {
|
|
188
|
+
const vtracks = media.video && media.video.track ? (Array.isArray(media.video.track) ? media.video.track : [media.video.track]) : [];
|
|
189
|
+
for (const t of vtracks) if (t.clipitem) walk(t.clipitem, 'V');
|
|
190
|
+
const atracks = media.audio && media.audio.track ? (Array.isArray(media.audio.track) ? media.audio.track : [media.audio.track]) : [];
|
|
191
|
+
for (const t of atracks) if (t.clipitem) walk(t.clipitem, 'A');
|
|
192
|
+
}
|
|
193
|
+
return events;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Dispatch by format. AAF is an honest refuse (binary — convert to OTIO/EDL upstream). */
|
|
197
|
+
export function parseInterchange(format, content, opts = {}) {
|
|
198
|
+
switch (String(format).toLowerCase()) {
|
|
199
|
+
case 'edl':
|
|
200
|
+
return parseEDL(content, opts);
|
|
201
|
+
case 'otio':
|
|
202
|
+
return parseOTIO(content, opts);
|
|
203
|
+
case 'xml':
|
|
204
|
+
case 'xmeml':
|
|
205
|
+
case 'fcp7':
|
|
206
|
+
return parseXMEMLEvents(content, opts);
|
|
207
|
+
case 'aaf':
|
|
208
|
+
throw new Error('parse_interchange: AAF is binary — not parseable offline in pure JS. Convert to OTIO or EDL upstream (e.g. via OpenTimelineIO).');
|
|
209
|
+
default:
|
|
210
|
+
throw new Error(`parse_interchange: unknown format '${format}' (edl|otio|xml|aaf)`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── turnover_changelist ────────────────────────────────────────────────
|
|
215
|
+
const sig = (e) => `${e.track}:${e.source}`;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Diff two normalized event lists → per-event {kind: moved|retimed|replaced|new|gone|unchanged}.
|
|
219
|
+
* @param {Array} oldEvents
|
|
220
|
+
* @param {Array} newEvents
|
|
221
|
+
* @param {{recTolerance?:number}} [opts]
|
|
222
|
+
*/
|
|
223
|
+
export function diffChangelist(oldEvents, newEvents, opts = {}) {
|
|
224
|
+
const recTol = opts.recTolerance ?? 1;
|
|
225
|
+
const oldPool = oldEvents.map((e) => ({ e, used: false }));
|
|
226
|
+
const changes = [];
|
|
227
|
+
|
|
228
|
+
for (const ne of newEvents) {
|
|
229
|
+
// Prefer a same-source match; among those, the closest record position.
|
|
230
|
+
const candidates = oldPool.filter((o) => !o.used && sig(o.e) === sig(ne));
|
|
231
|
+
let match = null;
|
|
232
|
+
if (candidates.length) {
|
|
233
|
+
candidates.sort((a, b) => Math.abs((a.e.recIn ?? 0) - (ne.recIn ?? 0)) - Math.abs((b.e.recIn ?? 0) - (ne.recIn ?? 0)));
|
|
234
|
+
match = candidates[0];
|
|
235
|
+
}
|
|
236
|
+
if (!match) {
|
|
237
|
+
changes.push({ kind: 'new', source: ne.source, track: ne.track, newRecIn: ne.recIn });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
match.used = true;
|
|
241
|
+
const oe = match.e;
|
|
242
|
+
const deltas = {};
|
|
243
|
+
let kind = 'unchanged';
|
|
244
|
+
if (Math.abs((oe.speed ?? 100) - (ne.speed ?? 100)) > 0.01 || oe.reverse !== ne.reverse) {
|
|
245
|
+
kind = 'retimed';
|
|
246
|
+
deltas.speed = { old: oe.speed, new: ne.speed };
|
|
247
|
+
if (oe.reverse !== ne.reverse) deltas.reverse = { old: oe.reverse, new: ne.reverse };
|
|
248
|
+
} else if (Math.abs((oe.recIn ?? 0) - (ne.recIn ?? 0)) > recTol) {
|
|
249
|
+
kind = 'moved';
|
|
250
|
+
deltas.recIn = { old: oe.recIn, new: ne.recIn };
|
|
251
|
+
} else if ((oe.srcIn ?? null) !== (ne.srcIn ?? null) || (oe.srcOut ?? null) !== (ne.srcOut ?? null)) {
|
|
252
|
+
kind = 'trimmed';
|
|
253
|
+
deltas.src = { old: [oe.srcIn, oe.srcOut], new: [ne.srcIn, ne.srcOut] };
|
|
254
|
+
}
|
|
255
|
+
if (kind !== 'unchanged') changes.push({ kind, source: ne.source, track: ne.track, oldRecIn: oe.recIn, newRecIn: ne.recIn, deltas });
|
|
256
|
+
}
|
|
257
|
+
// Unconsumed old events → gone (unless a 'new' at the same rec position → replaced).
|
|
258
|
+
for (const o of oldPool.filter((x) => !x.used)) {
|
|
259
|
+
const replacement = changes.find((c) => c.kind === 'new' && c.track === o.e.track && Math.abs((c.newRecIn ?? 0) - (o.e.recIn ?? 0)) <= recTol);
|
|
260
|
+
if (replacement) {
|
|
261
|
+
replacement.kind = 'replaced';
|
|
262
|
+
replacement.oldSource = o.e.source;
|
|
263
|
+
replacement.oldRecIn = o.e.recIn;
|
|
264
|
+
} else {
|
|
265
|
+
changes.push({ kind: 'gone', source: o.e.source, track: o.e.track, oldRecIn: o.e.recIn });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const counts = {};
|
|
269
|
+
for (const c of changes) counts[c.kind] = (counts[c.kind] || 0) + 1;
|
|
270
|
+
changes.sort((a, b) => (a.newRecIn ?? a.oldRecIn ?? 0) - (b.newRecIn ?? b.oldRecIn ?? 0));
|
|
271
|
+
return { changes, counts, changedCount: changes.length, gate: 'review' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── TIMING silent-lie guards ───────────────────────────────────────────
|
|
275
|
+
/**
|
|
276
|
+
* Detect timing lies between an old (locked-cut) and new (conformed) event list.
|
|
277
|
+
* @returns {{flags:Array<{kind, source, detail}>}}
|
|
278
|
+
*/
|
|
279
|
+
export function timingGuards(oldEvents, newEvents) {
|
|
280
|
+
const flags = [];
|
|
281
|
+
const newBySig = new Map();
|
|
282
|
+
for (const e of newEvents) {
|
|
283
|
+
if (!newBySig.has(sig(e))) newBySig.set(sig(e), []);
|
|
284
|
+
newBySig.get(sig(e)).push(e);
|
|
285
|
+
}
|
|
286
|
+
for (const oe of oldEvents) {
|
|
287
|
+
const matches = newBySig.get(sig(oe)) || [];
|
|
288
|
+
const ne = matches[0];
|
|
289
|
+
if (!ne) {
|
|
290
|
+
// A dropped audio event where its video sibling survives → dropped J/L-cut audio.
|
|
291
|
+
if (oe.track === 'A' && newEvents.some((x) => x.track === 'V' && x.source === oe.source))
|
|
292
|
+
flags.push({ kind: 'dropped_split_audio', source: oe.source, detail: 'audio event gone but video sibling present (J/L-cut lost)' });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// Flattened retime: a speed ramp/change flattened to 100%.
|
|
296
|
+
if ((oe.speed ?? 100) !== 100 && (ne.speed ?? 100) === 100)
|
|
297
|
+
flags.push({ kind: 'flattened_retime', source: oe.source, detail: `speed ${oe.speed}% → 100% (retime lost)` });
|
|
298
|
+
// Reverse dropped.
|
|
299
|
+
if (oe.reverse && !ne.reverse) flags.push({ kind: 'reverse_dropped', source: oe.source, detail: 'reversed clip conformed forward' });
|
|
300
|
+
// Framerate/pulldown slip.
|
|
301
|
+
if (oe.fps && ne.fps && oe.fps !== ne.fps) flags.push({ kind: 'framerate_slip', source: oe.source, detail: `fps ${oe.fps} → ${ne.fps}` });
|
|
302
|
+
}
|
|
303
|
+
return { flags, flagged: flags.length > 0 };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── conform_manifest ───────────────────────────────────────────────────
|
|
307
|
+
/**
|
|
308
|
+
* Per-event conform assert before grading. PURE over events + a resolution map.
|
|
309
|
+
* @param {Array} events normalized events
|
|
310
|
+
* @param {Object} resolution source → { online?, path?, handleIn?, handleOut?, tcBase?, reverse?, speed? }
|
|
311
|
+
* @param {{minHandle?:number, expectTcBase?:string}} [opts]
|
|
312
|
+
*/
|
|
313
|
+
export function conformManifest(events, resolution = {}, opts = {}) {
|
|
314
|
+
const minHandle = opts.minHandle ?? 0;
|
|
315
|
+
const rows = [];
|
|
316
|
+
for (const e of events) {
|
|
317
|
+
const res = resolution[e.source] || {};
|
|
318
|
+
const checks = [];
|
|
319
|
+
const add = (name, pass, detail) => checks.push({ name, pass, ...(detail ? { detail } : {}) });
|
|
320
|
+
add('source_resolved', res.online !== false && !!(res.path || res.online), res.online === false ? 'offline' : res.path ? undefined : 'no resolved path');
|
|
321
|
+
// Handles — and transition-handle starvation (a dissolve needs handle ≥ half its duration each side).
|
|
322
|
+
const needHandle = Math.max(minHandle, e.transition ? Math.ceil((e.transition.duration || 0) / 2) : 0);
|
|
323
|
+
if (needHandle > 0) {
|
|
324
|
+
const ok = (res.handleIn ?? 0) >= needHandle && (res.handleOut ?? 0) >= needHandle;
|
|
325
|
+
add(
|
|
326
|
+
'handles',
|
|
327
|
+
ok,
|
|
328
|
+
ok ? undefined : `need ≥${needHandle} (transition ${e.transition ? e.transition.duration : 0}); have ${res.handleIn ?? 0}/${res.handleOut ?? 0}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
// Retime preserved (if the event carries a non-100 speed, the resolution must carry it too).
|
|
332
|
+
if ((e.speed ?? 100) !== 100)
|
|
333
|
+
add('retime_preserved', res.speed == null || Math.abs(res.speed - e.speed) < 0.5, `event ${e.speed}% vs resolved ${res.speed ?? 'n/a'}`);
|
|
334
|
+
// Reverse preserved.
|
|
335
|
+
if (e.reverse) add('reverse_preserved', res.reverse !== false, res.reverse === false ? 'resolved forward' : undefined);
|
|
336
|
+
// TC-base matched.
|
|
337
|
+
if (opts.expectTcBase != null && res.tcBase != null)
|
|
338
|
+
add('tc_base', String(res.tcBase) === String(opts.expectTcBase), `${res.tcBase} vs ${opts.expectTcBase}`);
|
|
339
|
+
const pass = checks.every((c) => c.pass);
|
|
340
|
+
rows.push({ index: e.index, source: e.source, track: e.track, pass, checks });
|
|
341
|
+
}
|
|
342
|
+
const failed = rows.filter((r) => !r.pass);
|
|
343
|
+
return { pass: failed.length === 0, eventCount: rows.length, failedCount: failed.length, rows, failed: failed.map((r) => r.source), gate: 'review' };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── marker_roundtrip ───────────────────────────────────────────────────
|
|
347
|
+
/**
|
|
348
|
+
* Round-trip markers/notes with provenance tags. Normalizes a marker set, stamps provenance,
|
|
349
|
+
* and asserts the set survives encode→decode non-empty (skip-not-fake).
|
|
350
|
+
* @param {Array<{frame:number, name?:string, note?:string, color?:string, source?:string}>} markers
|
|
351
|
+
* @param {{provenanceTag?:string}} [opts]
|
|
352
|
+
*/
|
|
353
|
+
export function markerRoundtrip(markers, opts = {}) {
|
|
354
|
+
const tag = opts.provenanceTag || 'AUTO:marker_roundtrip';
|
|
355
|
+
const normalized = markers.map((m, i) => ({
|
|
356
|
+
frame: Number(m.frame),
|
|
357
|
+
name: m.name || `Marker ${i + 1}`,
|
|
358
|
+
note: m.note || '',
|
|
359
|
+
color: m.color || 'Blue',
|
|
360
|
+
provenance: m.source ? `${tag} ← ${m.source}` : tag,
|
|
361
|
+
}));
|
|
362
|
+
// Encode → decode (JSON is the interchange; a real EDL/marker export mirrors this shape).
|
|
363
|
+
const encoded = JSON.stringify(normalized);
|
|
364
|
+
const decoded = JSON.parse(encoded);
|
|
365
|
+
if (markers.length && decoded.length !== markers.length)
|
|
366
|
+
throw new Error(`marker_roundtrip: ${markers.length} in, ${decoded.length} out — round-trip dropped markers`);
|
|
367
|
+
const provenanceOk = decoded.every((m) => typeof m.provenance === 'string' && m.provenance.length);
|
|
368
|
+
if (markers.length && !provenanceOk) throw new Error('marker_roundtrip: a marker lost its provenance tag');
|
|
369
|
+
return { count: decoded.length, markers: decoded, provenanceOk, roundTrip: 'ok' };
|
|
370
|
+
}
|