@hraness/slopcamera 3.2.4
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/LICENSE +21 -0
- package/NOTICE.md +60 -0
- package/PRIVACY.md +46 -0
- package/README.md +311 -0
- package/SECURITY.md +21 -0
- package/apps/desktop/README.md +966 -0
- package/apps/desktop/analysis/protocol.ts +223 -0
- package/apps/desktop/application/capability-binding.ts +594 -0
- package/apps/desktop/application/context.ts +97 -0
- package/apps/desktop/application/creative-iteration.ts +1385 -0
- package/apps/desktop/application/default-registry.ts +146 -0
- package/apps/desktop/application/directing-contract.ts +218 -0
- package/apps/desktop/application/directing-plan.ts +178 -0
- package/apps/desktop/application/errors.ts +18 -0
- package/apps/desktop/application/gateway-port.ts +867 -0
- package/apps/desktop/application/html-overlay-browser-runtime.ts +687 -0
- package/apps/desktop/application/html-overlay-integrity.ts +337 -0
- package/apps/desktop/application/html-overlay-renderer.ts +45 -0
- package/apps/desktop/application/index.ts +13 -0
- package/apps/desktop/application/operation-completion-checkpoint.ts +333 -0
- package/apps/desktop/application/operation-effects.ts +94 -0
- package/apps/desktop/application/operation.ts +224 -0
- package/apps/desktop/application/operations/analysis/faces.ts +244 -0
- package/apps/desktop/application/operations/analysis/music.ts +248 -0
- package/apps/desktop/application/operations/analysis/project-auto-zooms.ts +302 -0
- package/apps/desktop/application/operations/analysis/project-inactivity.ts +381 -0
- package/apps/desktop/application/operations/analysis/scenes.ts +272 -0
- package/apps/desktop/application/operations/analysis/shared.ts +202 -0
- package/apps/desktop/application/operations/derive/edit-batch.ts +967 -0
- package/apps/desktop/application/operations/derive/follow-faces.ts +454 -0
- package/apps/desktop/application/operations/edit/bind-candidate-revision.ts +91 -0
- package/apps/desktop/application/operations/edit/create-candidate-revision.ts +377 -0
- package/apps/desktop/application/operations/edit/create-revision.ts +255 -0
- package/apps/desktop/application/operations/edit/freeze-revision.ts +182 -0
- package/apps/desktop/application/operations/gateway/image.ts +41 -0
- package/apps/desktop/application/operations/gateway/index.ts +17 -0
- package/apps/desktop/application/operations/gateway/shared.ts +122 -0
- package/apps/desktop/application/operations/gateway/speech.ts +41 -0
- package/apps/desktop/application/operations/gateway/transcription.ts +42 -0
- package/apps/desktop/application/operations/gateway/video.ts +41 -0
- package/apps/desktop/application/operations/index.ts +31 -0
- package/apps/desktop/application/operations/iteration/create-candidate.ts +240 -0
- package/apps/desktop/application/operations/iteration/create-matrix.ts +117 -0
- package/apps/desktop/application/operations/iteration/select.ts +155 -0
- package/apps/desktop/application/operations/iteration/shared.ts +250 -0
- package/apps/desktop/application/operations/media/audio-effects.ts +376 -0
- package/apps/desktop/application/operations/media/binding.ts +79 -0
- package/apps/desktop/application/operations/media/capabilities.ts +112 -0
- package/apps/desktop/application/operations/media/color-grade.ts +343 -0
- package/apps/desktop/application/operations/media/html-overlay.ts +802 -0
- package/apps/desktop/application/operations/media/index.ts +8 -0
- package/apps/desktop/application/operations/media/ingest.ts +303 -0
- package/apps/desktop/application/operations/media/overlay.ts +729 -0
- package/apps/desktop/application/operations/media/shared.ts +845 -0
- package/apps/desktop/application/operations/media/transform-platform.ts +120 -0
- package/apps/desktop/application/operations/project/commit-edits.ts +2054 -0
- package/apps/desktop/application/operations/project/promote-selection.ts +495 -0
- package/apps/desktop/application/operations/project/snapshot.ts +95 -0
- package/apps/desktop/application/operations/recording/index.ts +5 -0
- package/apps/desktop/application/operations/recording/pause.ts +34 -0
- package/apps/desktop/application/operations/recording/resume.ts +34 -0
- package/apps/desktop/application/operations/recording/shared.ts +199 -0
- package/apps/desktop/application/operations/recording/start.ts +169 -0
- package/apps/desktop/application/operations/recording/stop.ts +34 -0
- package/apps/desktop/application/operations/render/bind-candidate-output.ts +324 -0
- package/apps/desktop/application/operations/render/materialize-selection.ts +322 -0
- package/apps/desktop/application/operations/render/project-caption-plan.ts +287 -0
- package/apps/desktop/application/operations/render/project-plan.ts +939 -0
- package/apps/desktop/application/operations/render/project-spatial-receipt.ts +70 -0
- package/apps/desktop/application/operations/render/project.ts +1922 -0
- package/apps/desktop/application/operations/shared.ts +67 -0
- package/apps/desktop/application/operations/slopcamera-portable.ts +1188 -0
- package/apps/desktop/application/operations/slopcamera-visuals.ts +784 -0
- package/apps/desktop/application/operations/spatial-project.ts +209 -0
- package/apps/desktop/application/operations/spatial-render.ts +415 -0
- package/apps/desktop/application/operations/spatial-scene.ts +97 -0
- package/apps/desktop/application/operations/studio.ts +143 -0
- package/apps/desktop/application/output-publication-lease.ts +163 -0
- package/apps/desktop/application/project-publication-lease.ts +233 -0
- package/apps/desktop/application/project-store.ts +196 -0
- package/apps/desktop/application/receipts.ts +533 -0
- package/apps/desktop/application/registry.ts +182 -0
- package/apps/desktop/application/spatial-assets.ts +675 -0
- package/apps/desktop/application/spatial-compositor-cadence.ts +23 -0
- package/apps/desktop/application/spatial-project-authority.ts +35 -0
- package/apps/desktop/application/spatial-project-lease.ts +126 -0
- package/apps/desktop/application/spatial-project-render-preparation.ts +225 -0
- package/apps/desktop/application/spatial-project-store.ts +286 -0
- package/apps/desktop/application/spatial-render-projection.ts +201 -0
- package/apps/desktop/application/spatial-render.ts +677 -0
- package/apps/desktop/application/spatial-spz.ts +44 -0
- package/apps/desktop/application/spatial-world-import.ts +135 -0
- package/apps/desktop/application/spatial-world-provenance.ts +46 -0
- package/apps/desktop/application/studio-port.ts +58 -0
- package/apps/desktop/application/verified-receipt-reconciliation.ts +1574 -0
- package/apps/desktop/capture/protocol.ts +689 -0
- package/apps/desktop/cli/analyzer.ts +406 -0
- package/apps/desktop/cli/args.ts +3181 -0
- package/apps/desktop/cli/asset-ingest.ts +747 -0
- package/apps/desktop/cli/atomic-render-effects.ts +210 -0
- package/apps/desktop/cli/atomic-render-platform.ts +205 -0
- package/apps/desktop/cli/atomic-render.ts +50 -0
- package/apps/desktop/cli/audio-analysis.ts +252 -0
- package/apps/desktop/cli/bounded-map.ts +39 -0
- package/apps/desktop/cli/bundle-service.ts +256 -0
- package/apps/desktop/cli/capabilities.ts +212 -0
- package/apps/desktop/cli/capture-bundle.ts +1117 -0
- package/apps/desktop/cli/command-host-resources.ts +251 -0
- package/apps/desktop/cli/commands.ts +6836 -0
- package/apps/desktop/cli/directing-blob.ts +291 -0
- package/apps/desktop/cli/directing-media.ts +517 -0
- package/apps/desktop/cli/directing-quote.ts +89 -0
- package/apps/desktop/cli/directing-service.ts +288 -0
- package/apps/desktop/cli/emoji-assets.ts +538 -0
- package/apps/desktop/cli/errors.ts +29 -0
- package/apps/desktop/cli/face-analysis-service.ts +940 -0
- package/apps/desktop/cli/filter-script.ts +76 -0
- package/apps/desktop/cli/gateway-application-port.ts +1142 -0
- package/apps/desktop/cli/gateway-credential.ts +81 -0
- package/apps/desktop/cli/gateway-media-artifacts.ts +723 -0
- package/apps/desktop/cli/gateway-media-catalog.ts +922 -0
- package/apps/desktop/cli/gateway-media-service.ts +3106 -0
- package/apps/desktop/cli/gateway-media-signature.ts +122 -0
- package/apps/desktop/cli/gateway-provider-options.ts +562 -0
- package/apps/desktop/cli/gateway-scene-provider.ts +391 -0
- package/apps/desktop/cli/help.ts +501 -0
- package/apps/desktop/cli/html-overlay-gpu.ts +97 -0
- package/apps/desktop/cli/html-overlay-renderer.ts +2478 -0
- package/apps/desktop/cli/io.ts +290 -0
- package/apps/desktop/cli/main.ts +123 -0
- package/apps/desktop/cli/media-effects-service.ts +885 -0
- package/apps/desktop/cli/media-ingest-model.ts +364 -0
- package/apps/desktop/cli/media-ingest-platform.ts +227 -0
- package/apps/desktop/cli/media-ingest-program.ts +200 -0
- package/apps/desktop/cli/media-ingest.ts +30 -0
- package/apps/desktop/cli/music-analysis-service.ts +434 -0
- package/apps/desktop/cli/mutation-lock.ts +530 -0
- package/apps/desktop/cli/native-assets.d.ts +12 -0
- package/apps/desktop/cli/native-media-runtime.macos.ts +7 -0
- package/apps/desktop/cli/overlay-playback.ts +177 -0
- package/apps/desktop/cli/paths.ts +265 -0
- package/apps/desktop/cli/portable-surface.ts +242 -0
- package/apps/desktop/cli/project-camera-receipt.ts +112 -0
- package/apps/desktop/cli/project-face-camera.ts +394 -0
- package/apps/desktop/cli/project-inactivity-service.ts +552 -0
- package/apps/desktop/cli/project-media-integrity.ts +121 -0
- package/apps/desktop/cli/project-renderer.ts +1526 -0
- package/apps/desktop/cli/project-service.ts +519 -0
- package/apps/desktop/cli/project-state-transaction.ts +520 -0
- package/apps/desktop/cli/query-limits.ts +1 -0
- package/apps/desktop/cli/recording-controller.ts +1567 -0
- package/apps/desktop/cli/recording-daemon.ts +931 -0
- package/apps/desktop/cli/recording-ref.ts +56 -0
- package/apps/desktop/cli/renamed-environment.ts +6 -0
- package/apps/desktop/cli/renderer.ts +1439 -0
- package/apps/desktop/cli/run-cli-test-helper.ts +60 -0
- package/apps/desktop/cli/scene-analysis-service.ts +879 -0
- package/apps/desktop/cli/spatial-compositor-verifier.ts +213 -0
- package/apps/desktop/cli/spatial-project-service.ts +90 -0
- package/apps/desktop/cli/spatial-scene-service.ts +105 -0
- package/apps/desktop/cli/spatial-world-service.ts +39 -0
- package/apps/desktop/cli/speech-analysis-service.ts +682 -0
- package/apps/desktop/cli/studio-assemble.ts +58 -0
- package/apps/desktop/cli/studio-command.ts +60 -0
- package/apps/desktop/cli/studio-custody.ts +60 -0
- package/apps/desktop/cli/studio-encode.ts +216 -0
- package/apps/desktop/cli/studio-exr.ts +52 -0
- package/apps/desktop/cli/studio-files.ts +179 -0
- package/apps/desktop/cli/studio-output-validation.ts +162 -0
- package/apps/desktop/cli/studio-process.ts +145 -0
- package/apps/desktop/cli/studio-runtime.ts +77 -0
- package/apps/desktop/cli/studio-scaffold.ts +70 -0
- package/apps/desktop/cli/studio-service.ts +301 -0
- package/apps/desktop/cli/studio-spatial-asset.ts +248 -0
- package/apps/desktop/cli/studio-template-names.ts +3 -0
- package/apps/desktop/cli/studio-workflow.ts +19 -0
- package/apps/desktop/cli/time.ts +25 -0
- package/apps/desktop/cli/workflow-code.ts +584 -0
- package/apps/desktop/cli/workflow-runs.ts +576 -0
- package/apps/desktop/code/advanced.ts +139 -0
- package/apps/desktop/code/application-node-planner.ts +1288 -0
- package/apps/desktop/code/compiler.ts +329 -0
- package/apps/desktop/code/contracts.ts +13 -0
- package/apps/desktop/code/creative-recipes.ts +94 -0
- package/apps/desktop/code/define-workflow.ts +115 -0
- package/apps/desktop/code/editing.ts +284 -0
- package/apps/desktop/code/file-candidate-provenance.ts +420 -0
- package/apps/desktop/code/file-candidate.ts +69 -0
- package/apps/desktop/code/graph-builder.ts +10 -0
- package/apps/desktop/code/host-resource-policy.ts +55 -0
- package/apps/desktop/code/plan-contracts.ts +159 -0
- package/apps/desktop/code/planning.ts +361 -0
- package/apps/desktop/code/public.ts +134 -0
- package/apps/desktop/code/reference-projection.ts +62 -0
- package/apps/desktop/code/run-contracts.ts +466 -0
- package/apps/desktop/code/run-store.ts +2478 -0
- package/apps/desktop/code/runtime-identity.ts +328 -0
- package/apps/desktop/code/scheduler.ts +3067 -0
- package/apps/desktop/code/semantic-builder.ts +1436 -0
- package/apps/desktop/code/source-bundle.ts +1641 -0
- package/apps/desktop/code/source-typecheck.ts +211 -0
- package/apps/desktop/code/testing.ts +79 -0
- package/apps/desktop/code/worker-client.ts +1675 -0
- package/apps/desktop/code/worker-effects.ts +141 -0
- package/apps/desktop/code/worker-entry.ts +435 -0
- package/apps/desktop/code/worker-lease-guardian.ts +179 -0
- package/apps/desktop/code/worker-process-identity.ts +484 -0
- package/apps/desktop/code/worker-protocol.ts +211 -0
- package/apps/desktop/code/workflow-effects.ts +132 -0
- package/apps/desktop/contracts/analysis.ts +949 -0
- package/apps/desktop/contracts/edit.ts +576 -0
- package/apps/desktop/contracts/index.ts +9 -0
- package/apps/desktop/contracts/media-effects.ts +139 -0
- package/apps/desktop/contracts/project-render.ts +644 -0
- package/apps/desktop/contracts/project.ts +740 -0
- package/apps/desktop/contracts/recording.ts +1434 -0
- package/apps/desktop/contracts/render.ts +334 -0
- package/apps/desktop/contracts/runtime.ts +155 -0
- package/apps/desktop/contracts/spatial-compositor.ts +58 -0
- package/apps/desktop/contracts/spatial-project.ts +159 -0
- package/apps/desktop/contracts/spatial-world.ts +48 -0
- package/apps/desktop/contracts/video-effects.ts +120 -0
- package/apps/desktop/core/alignment-apply.ts +400 -0
- package/apps/desktop/core/audio-alignment.ts +466 -0
- package/apps/desktop/core/auto-zoom.ts +209 -0
- package/apps/desktop/core/canonical-json.ts +14 -0
- package/apps/desktop/core/emoji.ts +210 -0
- package/apps/desktop/core/events.ts +409 -0
- package/apps/desktop/core/face-framing.ts +533 -0
- package/apps/desktop/core/face-tracks.ts +326 -0
- package/apps/desktop/core/inactivity.ts +137 -0
- package/apps/desktop/core/index.ts +30 -0
- package/apps/desktop/core/intervals.ts +123 -0
- package/apps/desktop/core/montage.ts +236 -0
- package/apps/desktop/core/music-analysis.ts +518 -0
- package/apps/desktop/core/plan.ts +244 -0
- package/apps/desktop/core/project-auto-zoom.ts +75 -0
- package/apps/desktop/core/project-camera-spatial.ts +676 -0
- package/apps/desktop/core/project-camera.ts +403 -0
- package/apps/desktop/core/project-captions.ts +888 -0
- package/apps/desktop/core/project-inactivity.ts +125 -0
- package/apps/desktop/core/project-metadata.ts +927 -0
- package/apps/desktop/core/project-plan.ts +375 -0
- package/apps/desktop/core/project-render-plan.ts +303 -0
- package/apps/desktop/core/project-time.ts +469 -0
- package/apps/desktop/core/render-plan.ts +442 -0
- package/apps/desktop/core/scene-cache.ts +174 -0
- package/apps/desktop/core/scene-sampling.ts +448 -0
- package/apps/desktop/core/spatial-compositor.ts +22 -0
- package/apps/desktop/core/spatial-durability.ts +99 -0
- package/apps/desktop/core/spatial-project.ts +169 -0
- package/apps/desktop/core/spatial-shot-clock.ts +32 -0
- package/apps/desktop/core/speech-analysis.ts +333 -0
- package/apps/desktop/core/storage.ts +970 -0
- package/apps/desktop/core/time-map.ts +91 -0
- package/apps/desktop/core/time.ts +61 -0
- package/apps/desktop/core/video-effects.ts +660 -0
- package/apps/desktop/dist/cli/NebulaSans-Bold-26se8aek.otf +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Book-5ax05zvn.woff2 +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Book-8cenzchw.otf +0 -0
- package/apps/desktop/dist/cli/main.js +5086 -0
- package/apps/desktop/html-overlay/catalog.ts +171 -0
- package/apps/desktop/html-overlay/contracts.ts +372 -0
- package/apps/desktop/html-overlay/execution-profile.ts +77 -0
- package/apps/desktop/html-overlay/index.ts +98 -0
- package/apps/desktop/html-overlay/libraries.ts +263 -0
- package/apps/desktop/html-overlay/random.ts +49 -0
- package/apps/desktop/html-overlay/runtime.ts +813 -0
- package/apps/desktop/html-overlay/scaffolds.ts +792 -0
- package/apps/desktop/html-overlay/spark-runtime.ts +88 -0
- package/apps/desktop/html-overlay/spatial-splat-runtime.ts +60 -0
- package/apps/desktop/html-overlay/spatial.ts +720 -0
- package/apps/desktop/studio/assets/README.md +39 -0
- package/apps/desktop/studio/assets/contracts.ts +129 -0
- package/apps/desktop/studio/assets/network.ts +64 -0
- package/apps/desktop/studio/assets/poly-haven.ts +148 -0
- package/apps/desktop/studio/drivers/blender_driver.py +466 -0
- package/apps/desktop/studio/drivers/cadquery_driver.py +176 -0
- package/apps/desktop/studio/education/driver.py +308 -0
- package/apps/desktop/studio/education/lesson.py +180 -0
- package/apps/desktop/studio/education/toolkit.py +249 -0
- package/apps/desktop/workflows/chaptered-demo.ts +48 -0
- package/apps/desktop/workflows/creative-iteration.ts +107 -0
- package/apps/desktop/workflows/creative-selection.ts +92 -0
- package/apps/desktop/workflows/fragments.ts +486 -0
- package/apps/desktop/workflows/index.ts +93 -0
- package/apps/desktop/workflows/polished-screen-demo.ts +128 -0
- package/apps/desktop/workflows/scene-direction.ts +12 -0
- package/apps/desktop/workflows/social-variants.ts +75 -0
- package/apps/desktop/workflows/talking-head-cleanup.ts +53 -0
- package/dist/NebulaSans-Bold-26se8aek.otf +0 -0
- package/dist/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
- package/dist/NebulaSans-Book-5ax05zvn.woff2 +0 -0
- package/dist/NebulaSans-Book-8cenzchw.otf +0 -0
- package/dist/cli.js +479 -0
- package/dist/code/advanced.js +270 -0
- package/dist/code/index.js +2240 -0
- package/dist/generate.js +24 -0
- package/dist/host-resources.js +26 -0
- package/dist/index-42zsesc1.js +2257 -0
- package/dist/index-7308egqr.js +48 -0
- package/dist/index-8txs6fkn.js +712 -0
- package/dist/index-mcy8z0br.js +192 -0
- package/dist/index-p63wavx0.js +2029 -0
- package/dist/index-r7gdhmsp.js +424 -0
- package/dist/index-sh6xbav6.js +1219 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index-z7239b4h.js +2363 -0
- package/dist/index-zfeg9hmw.js +2013 -0
- package/dist/index.js +241 -0
- package/dist/operations.js +29 -0
- package/dist/skill-install-dh0ntqba.js +10 -0
- package/dist/vectorize/worker.js +142 -0
- package/dist/workflow.js +16 -0
- package/docs/README.md +38 -0
- package/docs/studio.md +215 -0
- package/examples/studio/blender/README.md +26 -0
- package/examples/studio/blender/character.py +152 -0
- package/examples/studio/blender/cloth.py +101 -0
- package/examples/studio/blender/color_chart.py +29 -0
- package/examples/studio/blender/fluid.py +78 -0
- package/examples/studio/blender/import_model.py +74 -0
- package/examples/studio/blender/product.py +53 -0
- package/examples/studio/blender/shaded_street.py +481 -0
- package/examples/studio/blender/studio_scene.py +147 -0
- package/examples/studio/cadquery/README.md +13 -0
- package/examples/studio/cadquery/bracket.py +36 -0
- package/examples/studio/cadquery/import_step.py +12 -0
- package/examples/studio/education/README.md +46 -0
- package/examples/studio/education/lesson.json +99 -0
- package/examples/studio/education/scene.py +7 -0
- package/examples/studio/hybrid-scene.ts +96 -0
- package/examples/studio/native-workflow.ts +14 -0
- package/examples/studio/vgpu/README.md +64 -0
- package/examples/studio/vgpu/heat-field.mjs +290 -0
- package/package.json +304 -0
- package/packages/scene/src/index.ts +1 -0
- package/packages/scene/src/scene.ts +215 -0
- package/schema/diagram.schema.json +334 -0
- package/skills/slopcamera/SKILL.md +45 -0
- package/skills/slopcamera/agents/openai.yaml +4 -0
- package/skills/slopcamera/references/customization.md +77 -0
- package/skills/slopcamera/references/diagrams.md +153 -0
- package/skills/slopcamera/references/directed-scenes.md +42 -0
- package/skills/slopcamera/references/directing-video.md +18 -0
- package/skills/slopcamera/references/educational-video.md +26 -0
- package/skills/slopcamera/references/gateway-media.md +134 -0
- package/skills/slopcamera/references/install.md +62 -0
- package/skills/slopcamera/references/native-studio.md +31 -0
- package/skills/slopcamera/references/reference-led-3d.md +140 -0
- package/skills/slopcamera/references/rubber-stamp-examples/poster-example-1.jpg +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/poster-example-2.jpg +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-1.png +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-2.png +0 -0
- package/skills/slopcamera/references/rubber-stamp-field-notes.md +144 -0
- package/skills/slopcamera/references/vectorization.md +29 -0
- package/skills/slopcamera/references/video-projects.md +187 -0
- package/skills/slopcamera/references/visual-communication.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +45 -0
- package/skills/slopcamera/scripts/compose-rubber-stamp-field-note.ts +229 -0
- package/src/artifacts.ts +106 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +21 -0
- package/src/assets.d.ts +9 -0
- package/src/cli.ts +601 -0
- package/src/cloud-errors.ts +26 -0
- package/src/code/advanced.ts +10 -0
- package/src/code/boundary.ts +21 -0
- package/src/code/canonical-json.ts +129 -0
- package/src/code/compiler.ts +953 -0
- package/src/code/contracts.ts +576 -0
- package/src/code/define-workflow.ts +334 -0
- package/src/code/errors.ts +41 -0
- package/src/code/graph-builder.ts +945 -0
- package/src/code/index.ts +121 -0
- package/src/code/json-snapshot.ts +899 -0
- package/src/code/json-utf8.ts +88 -0
- package/src/code/portable-builder.ts +136 -0
- package/src/code/projection.ts +378 -0
- package/src/code/public-operations.ts +450 -0
- package/src/code/runtime.ts +580 -0
- package/src/code/sha256.ts +36 -0
- package/src/config.ts +180 -0
- package/src/desktop.ts +253 -0
- package/src/fs.ts +10 -0
- package/src/generate.ts +704 -0
- package/src/host-resource-posix.ts +161 -0
- package/src/host-resources.ts +1749 -0
- package/src/icons.ts +58 -0
- package/src/index.ts +200 -0
- package/src/label-layout.ts +130 -0
- package/src/layout.ts +413 -0
- package/src/lint.ts +127 -0
- package/src/mcp/boundary.ts +354 -0
- package/src/mcp/index.ts +33 -0
- package/src/mcp/server.ts +272 -0
- package/src/mcp/tools.ts +963 -0
- package/src/mcp/types.ts +71 -0
- package/src/operations.ts +775 -0
- package/src/parse.ts +861 -0
- package/src/process-environment.ts +17 -0
- package/src/render.ts +437 -0
- package/src/skill-install.ts +49 -0
- package/src/spatial-scene/authoring.ts +27 -0
- package/src/spatial-scene/camera-track.ts +83 -0
- package/src/spatial-scene/contracts.ts +277 -0
- package/src/spatial-scene/evaluate.ts +145 -0
- package/src/spatial-scene/gltf.ts +558 -0
- package/src/spatial-scene/identity.ts +260 -0
- package/src/spatial-scene/index.ts +10 -0
- package/src/spatial-scene/inspect.ts +68 -0
- package/src/spatial-scene/math.ts +280 -0
- package/src/spatial-scene/patch.ts +131 -0
- package/src/spatial-scene/time.ts +53 -0
- package/src/studio/contracts.ts +129 -0
- package/src/studio/index.ts +2 -0
- package/src/studio/plan.ts +156 -0
- package/src/studio/shared.ts +41 -0
- package/src/theme.ts +68 -0
- package/src/tldr.ts +474 -0
- package/src/types.ts +212 -0
- package/src/vectorize/archive.ts +101 -0
- package/src/vectorize/command.ts +797 -0
- package/src/vectorize/index.ts +27 -0
- package/src/vectorize/limits.ts +61 -0
- package/src/vectorize/metrics.ts +351 -0
- package/src/vectorize/pixels.ts +323 -0
- package/src/vectorize/supervisor.ts +339 -0
- package/src/vectorize/svg.ts +188 -0
- package/src/vectorize/tool.ts +542 -0
- package/src/vectorize/types.ts +113 -0
- package/src/vectorize/vectorize.ts +634 -0
- package/src/vectorize/worker-protocol.ts +40 -0
- package/src/vectorize/worker.ts +209 -0
- package/src/version.ts +1 -0
- package/src/workflow.ts +409 -0
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { link, lstat, open, realpath, rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import {
|
|
8
|
+
SCENE_AI_SDK_VERSION,
|
|
9
|
+
SCENE_PROMPT_SHA256,
|
|
10
|
+
SCENE_PROMPT_VERSION,
|
|
11
|
+
SCENE_UPLOAD_POLICY,
|
|
12
|
+
SceneProviderError,
|
|
13
|
+
type SceneDescriptionProvider,
|
|
14
|
+
type SceneProviderResult,
|
|
15
|
+
} from "@hraness/slopcamera/scene";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
AnalysisSubjectSchema,
|
|
19
|
+
RepositoryRelativePathSchema,
|
|
20
|
+
SceneBatchSchema,
|
|
21
|
+
SceneDescriptionSchema,
|
|
22
|
+
SceneIdSchema,
|
|
23
|
+
SceneAnalysisV1Schema,
|
|
24
|
+
Sha256Schema,
|
|
25
|
+
type AnalysisSubject,
|
|
26
|
+
type ProjectAssetV1,
|
|
27
|
+
type SceneAnalysisV1,
|
|
28
|
+
type VideoProjectV1,
|
|
29
|
+
} from "../contracts";
|
|
30
|
+
import {
|
|
31
|
+
canonicalJson,
|
|
32
|
+
canonicalJsonSha256,
|
|
33
|
+
createNodeBundleFileSystem,
|
|
34
|
+
deduplicateFrameFingerprints,
|
|
35
|
+
differenceHash64,
|
|
36
|
+
planSceneBatches,
|
|
37
|
+
planSceneSampling,
|
|
38
|
+
reusableSceneBatchCacheEntry,
|
|
39
|
+
sceneBatchCacheKey,
|
|
40
|
+
transitionSceneBatch,
|
|
41
|
+
type PlannedSceneSample,
|
|
42
|
+
type SceneBoundaryCandidate,
|
|
43
|
+
type SceneSamplingPlan,
|
|
44
|
+
} from "../core";
|
|
45
|
+
import { CliError } from "./errors";
|
|
46
|
+
import { mapBounded } from "./bounded-map";
|
|
47
|
+
import type { ProcessRunner } from "./io";
|
|
48
|
+
import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
|
|
49
|
+
import { resolveVerifiedProjectMedia } from "./project-media-integrity";
|
|
50
|
+
|
|
51
|
+
type VideoStream = Extract<ProjectAssetV1["streams"][number], { readonly kind: "video" }>;
|
|
52
|
+
type VideoSegment = VideoStream["segments"][number];
|
|
53
|
+
|
|
54
|
+
export const MAXIMUM_SCENE_SAMPLES = 20_000;
|
|
55
|
+
const MAXIMUM_FRAME_BYTES = 2_000_000;
|
|
56
|
+
const MAXIMUM_DETECTION_OUTPUT_BYTES = 8_000_000;
|
|
57
|
+
const MAXIMUM_SCENE_CACHE_BYTES = 256_000;
|
|
58
|
+
const INITIAL_FRAME_LOOKBACK_US = 250_000;
|
|
59
|
+
const MAXIMUM_FRAME_DISCOVERY_ATTEMPTS = 64;
|
|
60
|
+
export const SCENE_EXTRACTION_CONCURRENCY = 4;
|
|
61
|
+
|
|
62
|
+
const CachedSceneProviderResultSchema = z.strictObject({
|
|
63
|
+
descriptions: z.array(z.strictObject({
|
|
64
|
+
description: SceneDescriptionSchema,
|
|
65
|
+
sceneId: SceneIdSchema,
|
|
66
|
+
})).min(1).max(4),
|
|
67
|
+
resolvedModel: z.string().min(1).max(256).nullable(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const SceneBatchCacheFileSchema = z.strictObject({
|
|
71
|
+
entry: z.strictObject({
|
|
72
|
+
batchKey: Sha256Schema,
|
|
73
|
+
payloadSha256: Sha256Schema,
|
|
74
|
+
schemaVersion: z.literal(1),
|
|
75
|
+
state: z.literal("complete"),
|
|
76
|
+
}),
|
|
77
|
+
result: CachedSceneProviderResultSchema,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
type CachedSceneProviderResult = Pick<SceneProviderResult, "descriptions" | "resolvedModel">;
|
|
81
|
+
|
|
82
|
+
export interface ResolvedVideoAnalysisSubject {
|
|
83
|
+
readonly asset: ProjectAssetV1;
|
|
84
|
+
readonly stream: VideoStream;
|
|
85
|
+
readonly subject: AnalysisSubject;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseSubjectReference(reference: string): { readonly assetId: string; readonly streamId: string } {
|
|
89
|
+
const separator = reference.indexOf(":");
|
|
90
|
+
if (separator <= 0 || separator === reference.length - 1 || reference.indexOf(":", separator + 1) !== -1) {
|
|
91
|
+
throw new CliError("usage", `Media stream must use <asset-id>:<stream-id>: ${reference}`);
|
|
92
|
+
}
|
|
93
|
+
return { assetId: reference.slice(0, separator), streamId: reference.slice(separator + 1) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function resolveVideoAnalysisSubject(
|
|
97
|
+
project: VideoProjectV1,
|
|
98
|
+
reference: string,
|
|
99
|
+
): ResolvedVideoAnalysisSubject {
|
|
100
|
+
const selected = parseSubjectReference(reference);
|
|
101
|
+
const asset = project.assets.find(candidate => candidate.assetId === selected.assetId);
|
|
102
|
+
if (asset === undefined) throw new CliError("not-found", `Unknown project asset: ${selected.assetId}`);
|
|
103
|
+
const stream = asset.streams.find(candidate => candidate.streamId === selected.streamId);
|
|
104
|
+
if (stream?.kind !== "video") {
|
|
105
|
+
throw new CliError("not-found", `Unknown video stream on ${selected.assetId}: ${selected.streamId}`);
|
|
106
|
+
}
|
|
107
|
+
const subject = AnalysisSubjectSchema.parse({
|
|
108
|
+
assetId: asset.assetId,
|
|
109
|
+
integritySha256: canonicalJsonSha256({
|
|
110
|
+
assetDurationUs: asset.durationUs,
|
|
111
|
+
stream,
|
|
112
|
+
version: "slopcamera-video-analysis-subject-v1",
|
|
113
|
+
}),
|
|
114
|
+
streamId: stream.streamId,
|
|
115
|
+
});
|
|
116
|
+
return { asset, stream, subject };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
120
|
+
const fromRoot = relative(root, candidate);
|
|
121
|
+
return fromRoot === "" || (!fromRoot.startsWith("..") && !isAbsolute(fromRoot));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function segmentIntegrityKey(segment: VideoSegment): string {
|
|
125
|
+
return `${segment.path}:${segment.bytes}:${segment.sha256}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function verifiedSegmentPath(
|
|
129
|
+
repositoryRoot: string,
|
|
130
|
+
segment: VideoSegment,
|
|
131
|
+
verifiedMedia: ReadonlyMap<string, string> | undefined,
|
|
132
|
+
): Promise<string> {
|
|
133
|
+
const cached = verifiedMedia?.get(segmentIntegrityKey(segment));
|
|
134
|
+
if (cached !== undefined) return cached;
|
|
135
|
+
return await resolveVerifiedProjectMedia({
|
|
136
|
+
expected: { bytes: segment.bytes, sha256: segment.sha256 },
|
|
137
|
+
label: `Scene segment ${segment.path}:${segment.streamIndex}`,
|
|
138
|
+
path: segment.path,
|
|
139
|
+
repositoryRoot,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function seconds(microseconds: number): string {
|
|
144
|
+
return (microseconds / 1_000_000).toFixed(6).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Parses the first decoded frame timestamp emitted by this service's showinfo filter. */
|
|
148
|
+
export function parseExtractedFramePtsTimeUs(output: string): number | null {
|
|
149
|
+
let hasMicrosecondTimeBase = false;
|
|
150
|
+
let selectedPts: bigint | null = null;
|
|
151
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
152
|
+
if (!line.includes("Parsed_showinfo_")) continue;
|
|
153
|
+
if (/\bconfig in time_base:\s*1\/1000000\b/u.test(line)) hasMicrosecondTimeBase = true;
|
|
154
|
+
if (!/\bn:\s*0\b/u.test(line)) continue;
|
|
155
|
+
const match = /\bpts:\s*(?<pts>-?[0-9]+)\b/u.exec(line);
|
|
156
|
+
if (match?.groups?.pts !== undefined) selectedPts = BigInt(match.groups.pts);
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
!hasMicrosecondTimeBase
|
|
160
|
+
|| selectedPts === null
|
|
161
|
+
|| selectedPts < BigInt(Number.MIN_SAFE_INTEGER)
|
|
162
|
+
|| selectedPts > BigInt(Number.MAX_SAFE_INTEGER)
|
|
163
|
+
) return null;
|
|
164
|
+
return Number(selectedPts);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseFrameCrcPtsTimesUs(output: string): readonly number[] {
|
|
168
|
+
const timestamps: number[] = [];
|
|
169
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
170
|
+
const match = /^\s*[0-9]+,\s*-?[0-9]+,\s*(?<pts>-?[0-9]+),/u.exec(line);
|
|
171
|
+
if (match?.groups?.pts === undefined) continue;
|
|
172
|
+
const pts = BigInt(match.groups.pts);
|
|
173
|
+
if (
|
|
174
|
+
pts < BigInt(Number.MIN_SAFE_INTEGER)
|
|
175
|
+
|| pts > BigInt(Number.MAX_SAFE_INTEGER)
|
|
176
|
+
) continue;
|
|
177
|
+
timestamps.push(Number(pts));
|
|
178
|
+
}
|
|
179
|
+
return timestamps;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function frameSeekTimes(segmentStartUs: number, requestedTimeUs: number): readonly number[] {
|
|
183
|
+
const distanceUs = requestedTimeUs - segmentStartUs;
|
|
184
|
+
const seekTimes = [requestedTimeUs];
|
|
185
|
+
if (distanceUs === 0) return seekTimes;
|
|
186
|
+
|
|
187
|
+
let lookbackUs = Math.min(INITIAL_FRAME_LOOKBACK_US, distanceUs);
|
|
188
|
+
while (seekTimes.length < MAXIMUM_FRAME_DISCOVERY_ATTEMPTS) {
|
|
189
|
+
const seekTimeUs = requestedTimeUs - lookbackUs;
|
|
190
|
+
seekTimes.push(seekTimeUs);
|
|
191
|
+
if (seekTimeUs === segmentStartUs) return seekTimes;
|
|
192
|
+
lookbackUs += Math.min(lookbackUs, distanceUs - lookbackUs);
|
|
193
|
+
}
|
|
194
|
+
throw new CliError("internal", "Scene frame discovery could not reach the segment start within its attempt bound.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function discoverFramePtsTimeUs(options: {
|
|
198
|
+
readonly ffmpeg: string;
|
|
199
|
+
readonly requestedFileTimeUs: number;
|
|
200
|
+
readonly runner: ProcessRunner;
|
|
201
|
+
readonly selectionFileEndUs: number;
|
|
202
|
+
readonly selectionFileStartUs: number;
|
|
203
|
+
readonly source: string;
|
|
204
|
+
readonly streamIndex: number;
|
|
205
|
+
}): Promise<number> {
|
|
206
|
+
const searchEndUs = Math.min(
|
|
207
|
+
options.selectionFileEndUs,
|
|
208
|
+
options.requestedFileTimeUs + 1,
|
|
209
|
+
);
|
|
210
|
+
for (
|
|
211
|
+
const seekTimeUs of frameSeekTimes(
|
|
212
|
+
options.selectionFileStartUs,
|
|
213
|
+
options.requestedFileTimeUs,
|
|
214
|
+
)
|
|
215
|
+
) {
|
|
216
|
+
const discovery = await options.runner.run([
|
|
217
|
+
options.ffmpeg,
|
|
218
|
+
"-hide_banner", "-loglevel", "error", "-nostdin",
|
|
219
|
+
"-copyts", "-start_at_zero", "-seek_timestamp", "1",
|
|
220
|
+
"-ss", seconds(seekTimeUs),
|
|
221
|
+
"-t", seconds(searchEndUs - seekTimeUs),
|
|
222
|
+
"-i", options.source,
|
|
223
|
+
"-map", `0:${options.streamIndex}`,
|
|
224
|
+
"-vf", [
|
|
225
|
+
`trim=start=${seconds(seekTimeUs)}:end=${seconds(searchEndUs)},`,
|
|
226
|
+
"settb=expr=1/1000000,",
|
|
227
|
+
"scale=2:2:flags=neighbor,format=gray",
|
|
228
|
+
].join(""),
|
|
229
|
+
"-an",
|
|
230
|
+
"-fps_mode", "passthrough",
|
|
231
|
+
"-enc_time_base", "1:1000000",
|
|
232
|
+
"-f", "framecrc", "-",
|
|
233
|
+
], { maxOutputBytes: 1_000_000 });
|
|
234
|
+
if (discovery.exitCode !== 0) {
|
|
235
|
+
throw new CliError(
|
|
236
|
+
"subprocess",
|
|
237
|
+
`FFmpeg frame timestamp discovery failed: ${
|
|
238
|
+
discovery.stderr.trim().slice(-4_000) || `exit ${discovery.exitCode}`
|
|
239
|
+
}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const closestPredecessor = parseFrameCrcPtsTimesUs(discovery.stdout)
|
|
243
|
+
.filter(timestamp =>
|
|
244
|
+
timestamp >= options.selectionFileStartUs
|
|
245
|
+
&& timestamp <= options.requestedFileTimeUs
|
|
246
|
+
&& timestamp < options.selectionFileEndUs)
|
|
247
|
+
.reduce<number | null>(
|
|
248
|
+
(closest, timestamp) => closest === null || timestamp > closest ? timestamp : closest,
|
|
249
|
+
null,
|
|
250
|
+
);
|
|
251
|
+
if (closestPredecessor !== null) return closestPredecessor;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A scene can begin between two frames. Only when it has no in-range
|
|
255
|
+
// predecessor may its first following frame satisfy the sample.
|
|
256
|
+
const successor = await options.runner.run([
|
|
257
|
+
options.ffmpeg,
|
|
258
|
+
"-hide_banner", "-loglevel", "error", "-nostdin",
|
|
259
|
+
"-copyts", "-start_at_zero", "-seek_timestamp", "1",
|
|
260
|
+
"-ss", seconds(options.requestedFileTimeUs),
|
|
261
|
+
"-t", seconds(options.selectionFileEndUs - options.requestedFileTimeUs),
|
|
262
|
+
"-i", options.source,
|
|
263
|
+
"-map", `0:${options.streamIndex}`,
|
|
264
|
+
"-vf", [
|
|
265
|
+
`trim=start=${seconds(options.requestedFileTimeUs)}:end=${
|
|
266
|
+
seconds(options.selectionFileEndUs)
|
|
267
|
+
},`,
|
|
268
|
+
"settb=expr=1/1000000,",
|
|
269
|
+
"scale=2:2:flags=neighbor,format=gray",
|
|
270
|
+
].join(""),
|
|
271
|
+
"-frames:v", "1",
|
|
272
|
+
"-an",
|
|
273
|
+
"-fps_mode", "passthrough",
|
|
274
|
+
"-enc_time_base", "1:1000000",
|
|
275
|
+
"-f", "framecrc", "-",
|
|
276
|
+
], { maxOutputBytes: 1_000_000 });
|
|
277
|
+
if (successor.exitCode !== 0) {
|
|
278
|
+
throw new CliError(
|
|
279
|
+
"subprocess",
|
|
280
|
+
`FFmpeg frame timestamp discovery failed: ${
|
|
281
|
+
successor.stderr.trim().slice(-4_000) || `exit ${successor.exitCode}`
|
|
282
|
+
}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const firstSuccessor = parseFrameCrcPtsTimesUs(successor.stdout)
|
|
286
|
+
.find(timestamp =>
|
|
287
|
+
timestamp >= options.requestedFileTimeUs
|
|
288
|
+
&& timestamp < options.selectionFileEndUs);
|
|
289
|
+
if (firstSuccessor !== undefined) return firstSuccessor;
|
|
290
|
+
throw new CliError(
|
|
291
|
+
"invalid-data",
|
|
292
|
+
`No decodable video frame exists in the selected scene interval.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Parses only FFmpeg showinfo timestamps and never treats log text as structured commands. */
|
|
297
|
+
export function parseSceneDetectionLog(
|
|
298
|
+
output: string,
|
|
299
|
+
segment: VideoSegment,
|
|
300
|
+
confidence: number,
|
|
301
|
+
): readonly SceneBoundaryCandidate[] {
|
|
302
|
+
const boundaries = new Map<number, SceneBoundaryCandidate>();
|
|
303
|
+
for (const match of output.matchAll(/\bpts_time:(?<seconds>[0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?)/giu)) {
|
|
304
|
+
const secondsValue = Number(match.groups?.seconds);
|
|
305
|
+
const fileTimeUs = Math.round(secondsValue * 1_000_000);
|
|
306
|
+
if (!Number.isSafeInteger(fileTimeUs)) continue;
|
|
307
|
+
if (fileTimeUs <= segment.fileRange.startUs || fileTimeUs >= segment.fileRange.endUs) continue;
|
|
308
|
+
const assetTimeUs = segment.assetRange.startUs + fileTimeUs - segment.fileRange.startUs;
|
|
309
|
+
if (assetTimeUs <= segment.assetRange.startUs || assetTimeUs >= segment.assetRange.endUs) continue;
|
|
310
|
+
boundaries.set(assetTimeUs, { confidence, kind: "visual", timeUs: assetTimeUs });
|
|
311
|
+
}
|
|
312
|
+
return [...boundaries.values()].sort((left, right) => left.timeUs - right.timeUs);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function detectVisualSceneBoundaries(options: {
|
|
316
|
+
readonly ffmpeg: string;
|
|
317
|
+
readonly repositoryRoot: string;
|
|
318
|
+
readonly runner: ProcessRunner;
|
|
319
|
+
readonly stream: VideoStream;
|
|
320
|
+
readonly threshold: number;
|
|
321
|
+
readonly verifiedMedia?: ReadonlyMap<string, string>;
|
|
322
|
+
}): Promise<readonly SceneBoundaryCandidate[]> {
|
|
323
|
+
if (!Number.isFinite(options.threshold) || options.threshold <= 0 || options.threshold > 1) {
|
|
324
|
+
throw new CliError("usage", "Scene threshold must be greater than zero and at most one.");
|
|
325
|
+
}
|
|
326
|
+
const boundaries: SceneBoundaryCandidate[] = [];
|
|
327
|
+
for (const segment of options.stream.segments) {
|
|
328
|
+
const source = await verifiedSegmentPath(options.repositoryRoot, segment, options.verifiedMedia);
|
|
329
|
+
const result = await options.runner.run([
|
|
330
|
+
options.ffmpeg,
|
|
331
|
+
"-hide_banner", "-nostdin",
|
|
332
|
+
"-copyts", "-start_at_zero",
|
|
333
|
+
"-i", source,
|
|
334
|
+
"-map", `0:${segment.streamIndex}`,
|
|
335
|
+
"-vf", `settb=expr=1/1000000,select='gt(scene,${options.threshold})',showinfo`,
|
|
336
|
+
"-an", "-vsync", "0", "-f", "null", "-",
|
|
337
|
+
], { maxOutputBytes: MAXIMUM_DETECTION_OUTPUT_BYTES });
|
|
338
|
+
if (result.exitCode !== 0) {
|
|
339
|
+
throw new CliError(
|
|
340
|
+
"subprocess",
|
|
341
|
+
`FFmpeg scene detection failed: ${result.stderr.trim().slice(-4_000) || `exit ${result.exitCode}`}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
boundaries.push(...parseSceneDetectionLog(result.stderr, segment, options.threshold));
|
|
345
|
+
}
|
|
346
|
+
return [...new Map(boundaries.map(boundary => [boundary.timeUs, boundary])).values()]
|
|
347
|
+
.sort((left, right) => left.timeUs - right.timeUs);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function segmentAt(stream: VideoStream, assetTimeUs: number): VideoSegment {
|
|
351
|
+
const segment = stream.segments.find(candidate =>
|
|
352
|
+
assetTimeUs >= candidate.assetRange.startUs && assetTimeUs < candidate.assetRange.endUs);
|
|
353
|
+
if (segment === undefined) {
|
|
354
|
+
throw new CliError("invalid-data", `No video media exists at requested asset time ${assetTimeUs}.`);
|
|
355
|
+
}
|
|
356
|
+
return segment;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sha256Bytes(value: Uint8Array): string {
|
|
360
|
+
return createHash("sha256").update(value).digest("hex");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function checkedRead(path: string, maximumBytes: number, expectedBytes?: number): Promise<Uint8Array> {
|
|
364
|
+
const details = await lstat(path);
|
|
365
|
+
if (
|
|
366
|
+
details.isSymbolicLink()
|
|
367
|
+
|| !details.isFile()
|
|
368
|
+
|| details.nlink !== 1
|
|
369
|
+
|| details.size < 1
|
|
370
|
+
|| details.size > maximumBytes
|
|
371
|
+
) {
|
|
372
|
+
throw new CliError("invalid-data", `Derived scene frame has an invalid byte size: ${details.size}.`);
|
|
373
|
+
}
|
|
374
|
+
if (expectedBytes !== undefined && details.size !== expectedBytes) {
|
|
375
|
+
throw new CliError("invalid-data", `Derived frame fingerprint must contain ${expectedBytes} bytes.`);
|
|
376
|
+
}
|
|
377
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
378
|
+
try {
|
|
379
|
+
const before = await handle.stat();
|
|
380
|
+
if (
|
|
381
|
+
!before.isFile()
|
|
382
|
+
|| before.dev !== details.dev
|
|
383
|
+
|| before.ino !== details.ino
|
|
384
|
+
|| before.nlink !== 1
|
|
385
|
+
|| before.size !== details.size
|
|
386
|
+
) {
|
|
387
|
+
throw new CliError("conflict", "Derived scene frame changed before validation.");
|
|
388
|
+
}
|
|
389
|
+
const bytes = await handle.readFile();
|
|
390
|
+
const after = await handle.stat();
|
|
391
|
+
if (
|
|
392
|
+
bytes.byteLength !== before.size
|
|
393
|
+
|| after.dev !== before.dev
|
|
394
|
+
|| after.ino !== before.ino
|
|
395
|
+
|| after.nlink !== 1
|
|
396
|
+
|| after.size !== before.size
|
|
397
|
+
|| after.mtimeMs !== before.mtimeMs
|
|
398
|
+
|| after.ctimeMs !== before.ctimeMs
|
|
399
|
+
) {
|
|
400
|
+
throw new CliError("conflict", "Derived scene frame changed during validation.");
|
|
401
|
+
}
|
|
402
|
+
return new Uint8Array(bytes);
|
|
403
|
+
} finally {
|
|
404
|
+
await handle.close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isFileSystemError(error: unknown, code: string): boolean {
|
|
409
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function awaitSingleLink(path: string): Promise<void> {
|
|
413
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
414
|
+
if ((await lstat(path)).nlink === 1) return;
|
|
415
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function cacheRelativePath(batchKey: string): string {
|
|
420
|
+
return RepositoryRelativePathSchema.parse(`analysis/scene-cache/${Sha256Schema.parse(batchKey)}.json`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function ensureSceneCacheDirectory(projectDirectory: string): Promise<void> {
|
|
424
|
+
await ensurePhysicalPrivateDirectoryWithin(projectDirectory, "analysis/scene-cache");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function loadCachedSceneBatch(
|
|
428
|
+
projectDirectory: string,
|
|
429
|
+
batchKey: string,
|
|
430
|
+
expectedSceneIds: readonly string[],
|
|
431
|
+
): Promise<CachedSceneProviderResult | null> {
|
|
432
|
+
await ensureSceneCacheDirectory(projectDirectory);
|
|
433
|
+
const relativePath = cacheRelativePath(batchKey);
|
|
434
|
+
const absolutePath = join(projectDirectory, relativePath);
|
|
435
|
+
try {
|
|
436
|
+
const details = await lstat(absolutePath);
|
|
437
|
+
if (
|
|
438
|
+
details.isSymbolicLink()
|
|
439
|
+
|| !details.isFile()
|
|
440
|
+
|| details.size < 1
|
|
441
|
+
|| details.size > MAXIMUM_SCENE_CACHE_BYTES
|
|
442
|
+
) return null;
|
|
443
|
+
const input: unknown = JSON.parse(await createNodeBundleFileSystem(projectDirectory).readText(relativePath));
|
|
444
|
+
const parsed = SceneBatchCacheFileSchema.safeParse(input);
|
|
445
|
+
if (!parsed.success) return null;
|
|
446
|
+
if (reusableSceneBatchCacheEntry(batchKey, parsed.data.entry) === null) return null;
|
|
447
|
+
if (canonicalJsonSha256(parsed.data.result) !== parsed.data.entry.payloadSha256) return null;
|
|
448
|
+
if (
|
|
449
|
+
parsed.data.result.descriptions.length !== expectedSceneIds.length
|
|
450
|
+
|| parsed.data.result.descriptions.some((description, index) => description.sceneId !== expectedSceneIds[index])
|
|
451
|
+
) return null;
|
|
452
|
+
return parsed.data.result;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (isFileSystemError(error, "ENOENT") || error instanceof SyntaxError) return null;
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function saveCachedSceneBatch(
|
|
460
|
+
projectDirectory: string,
|
|
461
|
+
batchKey: string,
|
|
462
|
+
result: CachedSceneProviderResult,
|
|
463
|
+
): Promise<void> {
|
|
464
|
+
await ensureSceneCacheDirectory(projectDirectory);
|
|
465
|
+
const parsedResult = CachedSceneProviderResultSchema.parse(result);
|
|
466
|
+
const cache = SceneBatchCacheFileSchema.parse({
|
|
467
|
+
entry: {
|
|
468
|
+
batchKey,
|
|
469
|
+
payloadSha256: canonicalJsonSha256(parsedResult),
|
|
470
|
+
schemaVersion: 1,
|
|
471
|
+
state: "complete",
|
|
472
|
+
},
|
|
473
|
+
result: parsedResult,
|
|
474
|
+
});
|
|
475
|
+
await createNodeBundleFileSystem(projectDirectory).writeTextAtomic(
|
|
476
|
+
cacheRelativePath(batchKey),
|
|
477
|
+
`${canonicalJson(cache)}\n`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
interface MaterializedFrame {
|
|
482
|
+
readonly bytes: Uint8Array;
|
|
483
|
+
readonly record: SceneAnalysisV1["samples"][number];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function extractFrame(options: {
|
|
487
|
+
readonly ffmpeg: string;
|
|
488
|
+
readonly frameDirectory: string;
|
|
489
|
+
readonly repositoryRoot: string;
|
|
490
|
+
readonly runner: ProcessRunner;
|
|
491
|
+
readonly sample: PlannedSceneSample;
|
|
492
|
+
readonly sceneRange: VideoSegment["assetRange"];
|
|
493
|
+
readonly stream: VideoStream;
|
|
494
|
+
readonly verifiedMedia: ReadonlyMap<string, string>;
|
|
495
|
+
}): Promise<MaterializedFrame> {
|
|
496
|
+
const segment = segmentAt(options.stream, options.sample.requestedAssetTimeUs);
|
|
497
|
+
const source = await verifiedSegmentPath(options.repositoryRoot, segment, options.verifiedMedia);
|
|
498
|
+
const selectionAssetStartUs = Math.max(segment.assetRange.startUs, options.sceneRange.startUs);
|
|
499
|
+
const selectionAssetEndUs = Math.min(segment.assetRange.endUs, options.sceneRange.endUs);
|
|
500
|
+
const selectionFileStartUs = segment.fileRange.startUs
|
|
501
|
+
+ selectionAssetStartUs
|
|
502
|
+
- segment.assetRange.startUs;
|
|
503
|
+
const selectionFileEndUs = segment.fileRange.startUs
|
|
504
|
+
+ selectionAssetEndUs
|
|
505
|
+
- segment.assetRange.startUs;
|
|
506
|
+
const requestedFileTimeUs = segment.fileRange.startUs
|
|
507
|
+
+ options.sample.requestedAssetTimeUs
|
|
508
|
+
- segment.assetRange.startUs;
|
|
509
|
+
const temporaryStem = join(options.frameDirectory, `.extract-${randomUUID()}`);
|
|
510
|
+
const temporaryJpeg = `${temporaryStem}.jpg`;
|
|
511
|
+
const temporaryGray = `${temporaryStem}.gray`;
|
|
512
|
+
try {
|
|
513
|
+
const selectedFileTimeUs = await discoverFramePtsTimeUs({
|
|
514
|
+
ffmpeg: options.ffmpeg,
|
|
515
|
+
requestedFileTimeUs,
|
|
516
|
+
runner: options.runner,
|
|
517
|
+
selectionFileEndUs,
|
|
518
|
+
selectionFileStartUs,
|
|
519
|
+
source,
|
|
520
|
+
streamIndex: segment.streamIndex,
|
|
521
|
+
});
|
|
522
|
+
const extraction = await options.runner.run([
|
|
523
|
+
options.ffmpeg,
|
|
524
|
+
"-hide_banner", "-loglevel", "info", "-nostdin", "-n",
|
|
525
|
+
"-copyts", "-start_at_zero", "-seek_timestamp", "1",
|
|
526
|
+
"-ss", seconds(selectedFileTimeUs),
|
|
527
|
+
"-t", seconds(selectionFileEndUs - selectedFileTimeUs),
|
|
528
|
+
"-i", source,
|
|
529
|
+
"-filter_complex", [
|
|
530
|
+
`[0:${segment.streamIndex}]`,
|
|
531
|
+
`trim=start=${seconds(selectedFileTimeUs)}:end=${seconds(selectionFileEndUs)},`,
|
|
532
|
+
"settb=expr=1/1000000,showinfo,split=2[slopcamera_scene_jpeg_source][slopcamera_scene_gray_source];",
|
|
533
|
+
"[slopcamera_scene_jpeg_source]",
|
|
534
|
+
"scale=w='min(960,iw)':h=-2:force_original_aspect_ratio=decrease,",
|
|
535
|
+
"format=yuvj420p[slopcamera_scene_jpeg];",
|
|
536
|
+
"[slopcamera_scene_gray_source]",
|
|
537
|
+
"scale=9:8:flags=area,format=gray[slopcamera_scene_gray]",
|
|
538
|
+
].join(""),
|
|
539
|
+
"-map", "[slopcamera_scene_jpeg]",
|
|
540
|
+
"-frames:v", "1",
|
|
541
|
+
"-pix_fmt", "yuvj420p",
|
|
542
|
+
"-q:v", "6", "-f", "image2", temporaryJpeg,
|
|
543
|
+
"-map", "[slopcamera_scene_gray]",
|
|
544
|
+
"-frames:v", "1",
|
|
545
|
+
"-pix_fmt", "gray", "-f", "rawvideo", temporaryGray,
|
|
546
|
+
], { maxOutputBytes: 1_000_000 });
|
|
547
|
+
if (extraction.exitCode !== 0) {
|
|
548
|
+
throw new CliError(
|
|
549
|
+
"subprocess",
|
|
550
|
+
`FFmpeg frame extraction failed: ${
|
|
551
|
+
extraction.stderr.trim().slice(-4_000) || `exit ${extraction.exitCode}`
|
|
552
|
+
}`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const decodedFileTimeUs = parseExtractedFramePtsTimeUs(extraction.stderr);
|
|
556
|
+
if (decodedFileTimeUs !== selectedFileTimeUs) {
|
|
557
|
+
throw new CliError(
|
|
558
|
+
"invalid-data",
|
|
559
|
+
"FFmpeg did not extract the exact discovered scene-frame timestamp.",
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const [bytes, grayscale] = await Promise.all([
|
|
563
|
+
checkedRead(temporaryJpeg, MAXIMUM_FRAME_BYTES),
|
|
564
|
+
checkedRead(temporaryGray, 72, 72),
|
|
565
|
+
]);
|
|
566
|
+
const sha256 = sha256Bytes(bytes);
|
|
567
|
+
const finalPath = join(options.frameDirectory, `${sha256}.jpg`);
|
|
568
|
+
try {
|
|
569
|
+
await link(temporaryJpeg, finalPath);
|
|
570
|
+
await rm(temporaryJpeg);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (!isFileSystemError(error, "EEXIST")) throw error;
|
|
573
|
+
await awaitSingleLink(finalPath);
|
|
574
|
+
}
|
|
575
|
+
const committed = await checkedRead(finalPath, MAXIMUM_FRAME_BYTES, bytes.byteLength);
|
|
576
|
+
if (sha256Bytes(committed) !== sha256) {
|
|
577
|
+
throw new CliError("conflict", `Content-addressed scene frame does not match ${sha256}.`);
|
|
578
|
+
}
|
|
579
|
+
const physicalRepositoryRoot = await realpath(options.repositoryRoot);
|
|
580
|
+
const repositoryPath = relative(physicalRepositoryRoot, finalPath);
|
|
581
|
+
if (!isWithin(physicalRepositoryRoot, finalPath) || isAbsolute(repositoryPath)) {
|
|
582
|
+
throw new CliError("unsafe-path", "Derived scene frame escaped the repository.");
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
bytes,
|
|
586
|
+
record: {
|
|
587
|
+
actualAssetTimeUs: segment.assetRange.startUs
|
|
588
|
+
+ selectedFileTimeUs
|
|
589
|
+
- segment.fileRange.startUs,
|
|
590
|
+
bytes: bytes.byteLength,
|
|
591
|
+
path: RepositoryRelativePathSchema.parse(repositoryPath),
|
|
592
|
+
perceptualHash: differenceHash64(grayscale),
|
|
593
|
+
reasons: options.sample.reasons,
|
|
594
|
+
requestedAssetTimeUs: options.sample.requestedAssetTimeUs,
|
|
595
|
+
sampleId: options.sample.sampleId as SceneAnalysisV1["samples"][number]["sampleId"],
|
|
596
|
+
sha256,
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
} finally {
|
|
600
|
+
await Promise.all([
|
|
601
|
+
rm(temporaryJpeg, { force: true }),
|
|
602
|
+
rm(temporaryGray, { force: true }),
|
|
603
|
+
]);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export interface AnalyzeProjectScenesOptions {
|
|
608
|
+
readonly acknowledgedAt: string;
|
|
609
|
+
readonly analysisId: string;
|
|
610
|
+
readonly createdAt: string;
|
|
611
|
+
readonly execute: boolean;
|
|
612
|
+
readonly ffmpeg: string;
|
|
613
|
+
readonly maximumSceneDurationUs: number;
|
|
614
|
+
readonly model: string;
|
|
615
|
+
readonly project: VideoProjectV1;
|
|
616
|
+
readonly projectDirectory: string;
|
|
617
|
+
readonly provider?: SceneDescriptionProvider;
|
|
618
|
+
readonly repositoryRoot: string;
|
|
619
|
+
readonly runner: ProcessRunner;
|
|
620
|
+
readonly sceneThreshold: number;
|
|
621
|
+
readonly source: string;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export interface PlanProjectScenesOptions {
|
|
625
|
+
readonly ffmpeg: string;
|
|
626
|
+
readonly maximumSceneDurationUs: number;
|
|
627
|
+
readonly project: VideoProjectV1;
|
|
628
|
+
readonly repositoryRoot: string;
|
|
629
|
+
readonly runner: ProcessRunner;
|
|
630
|
+
readonly sceneThreshold: number;
|
|
631
|
+
readonly source: string;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export interface PlanProjectScenesResult {
|
|
635
|
+
readonly plan: SceneSamplingPlan;
|
|
636
|
+
readonly subject: AnalysisSubject;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
interface PreparedProjectScenePlan extends PlanProjectScenesResult {
|
|
640
|
+
readonly resolved: ResolvedVideoAnalysisSubject;
|
|
641
|
+
readonly verifiedMedia: ReadonlyMap<string, string>;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function prepareProjectScenePlan(
|
|
645
|
+
options: PlanProjectScenesOptions,
|
|
646
|
+
): Promise<PreparedProjectScenePlan> {
|
|
647
|
+
const resolved = resolveVideoAnalysisSubject(options.project, options.source);
|
|
648
|
+
const uniqueSegments = [...new Map(
|
|
649
|
+
resolved.stream.segments.map(segment => [segmentIntegrityKey(segment), segment]),
|
|
650
|
+
).entries()];
|
|
651
|
+
const verifiedMedia = new Map(await Promise.all(uniqueSegments.map(async ([key, segment]) => [
|
|
652
|
+
key,
|
|
653
|
+
await verifiedSegmentPath(options.repositoryRoot, segment, undefined),
|
|
654
|
+
] as const)));
|
|
655
|
+
const boundaries = await detectVisualSceneBoundaries({
|
|
656
|
+
ffmpeg: options.ffmpeg,
|
|
657
|
+
repositoryRoot: options.repositoryRoot,
|
|
658
|
+
runner: options.runner,
|
|
659
|
+
stream: resolved.stream,
|
|
660
|
+
threshold: options.sceneThreshold,
|
|
661
|
+
verifiedMedia,
|
|
662
|
+
});
|
|
663
|
+
const plan = planSceneSampling({
|
|
664
|
+
boundaries,
|
|
665
|
+
inputDigest: resolved.subject.integritySha256,
|
|
666
|
+
maximumSceneDurationUs: options.maximumSceneDurationUs,
|
|
667
|
+
ranges: resolved.stream.segments.map(segment => segment.assetRange),
|
|
668
|
+
});
|
|
669
|
+
if (plan.samples.length > MAXIMUM_SCENE_SAMPLES) {
|
|
670
|
+
throw new CliError("invalid-data", `Scene sampling exceeds its ${MAXIMUM_SCENE_SAMPLES}-frame bound.`);
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
plan,
|
|
674
|
+
resolved,
|
|
675
|
+
subject: resolved.subject,
|
|
676
|
+
verifiedMedia,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function planProjectScenes(
|
|
681
|
+
options: PlanProjectScenesOptions,
|
|
682
|
+
): Promise<PlanProjectScenesResult> {
|
|
683
|
+
const prepared = await prepareProjectScenePlan(options);
|
|
684
|
+
return {
|
|
685
|
+
plan: prepared.plan,
|
|
686
|
+
subject: prepared.subject,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export type AnalyzeProjectScenesResult =
|
|
691
|
+
| {
|
|
692
|
+
readonly analysis: SceneAnalysisV1;
|
|
693
|
+
readonly kind: "complete";
|
|
694
|
+
readonly plan: SceneSamplingPlan;
|
|
695
|
+
}
|
|
696
|
+
| {
|
|
697
|
+
readonly kind: "planned";
|
|
698
|
+
readonly plan: SceneSamplingPlan;
|
|
699
|
+
readonly subject: AnalysisSubject;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
export async function analyzeProjectScenes(
|
|
703
|
+
options: AnalyzeProjectScenesOptions,
|
|
704
|
+
): Promise<AnalyzeProjectScenesResult> {
|
|
705
|
+
if (!/^google\/gemini-[a-z0-9][a-z0-9._-]{0,239}$/u.test(options.model)) {
|
|
706
|
+
throw new CliError("usage", "Scene model must be a bounded google/gemini-* AI Gateway model ID.");
|
|
707
|
+
}
|
|
708
|
+
const {
|
|
709
|
+
plan,
|
|
710
|
+
resolved,
|
|
711
|
+
verifiedMedia,
|
|
712
|
+
} = await prepareProjectScenePlan(options);
|
|
713
|
+
if (!options.execute) return { kind: "planned", plan, subject: resolved.subject };
|
|
714
|
+
if (options.provider === undefined) throw new CliError("internal", "Scene execution requires a configured description provider.");
|
|
715
|
+
|
|
716
|
+
const frameDirectory = await ensurePhysicalPrivateDirectoryWithin(
|
|
717
|
+
options.projectDirectory,
|
|
718
|
+
`analysis/scene-frames/${plan.planDigest}`,
|
|
719
|
+
);
|
|
720
|
+
const sceneById = new Map(plan.scenes.map(scene => [scene.sceneId, scene]));
|
|
721
|
+
const materialized = await mapBounded(
|
|
722
|
+
plan.samples,
|
|
723
|
+
SCENE_EXTRACTION_CONCURRENCY,
|
|
724
|
+
async (sample) => {
|
|
725
|
+
const ownerScene = sceneById.get(sample.sceneId);
|
|
726
|
+
if (ownerScene === undefined) throw new CliError("internal", `Missing owner scene: ${sample.sceneId}`);
|
|
727
|
+
return await extractFrame({
|
|
728
|
+
ffmpeg: options.ffmpeg,
|
|
729
|
+
frameDirectory,
|
|
730
|
+
repositoryRoot: options.repositoryRoot,
|
|
731
|
+
runner: options.runner,
|
|
732
|
+
sample,
|
|
733
|
+
sceneRange: ownerScene.range,
|
|
734
|
+
stream: resolved.stream,
|
|
735
|
+
verifiedMedia,
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
);
|
|
739
|
+
const frameById = new Map(materialized.map(frame => [frame.record.sampleId, frame]));
|
|
740
|
+
const batchPlans = planSceneBatches(plan.planDigest, plan.scenes.map(scene => ({
|
|
741
|
+
frames: deduplicateFrameFingerprints(scene.sampleIds.map((sampleId) => {
|
|
742
|
+
const frame = frameById.get(sampleId as SceneAnalysisV1["samples"][number]["sampleId"]);
|
|
743
|
+
if (frame === undefined) throw new CliError("internal", `Missing extracted sample: ${sampleId}`);
|
|
744
|
+
return {
|
|
745
|
+
actualAssetTimeUs: frame.record.actualAssetTimeUs,
|
|
746
|
+
bytes: frame.record.bytes,
|
|
747
|
+
perceptualHash: frame.record.perceptualHash,
|
|
748
|
+
sampleId,
|
|
749
|
+
sha256: frame.record.sha256,
|
|
750
|
+
};
|
|
751
|
+
})).filter(item => item.canonicalSampleId === item.sampleId).map((item) => {
|
|
752
|
+
const frame = frameById.get(item.sampleId as SceneAnalysisV1["samples"][number]["sampleId"]);
|
|
753
|
+
if (frame === undefined) throw new CliError("internal", `Missing deduplicated sample: ${item.sampleId}`);
|
|
754
|
+
return {
|
|
755
|
+
actualAssetTimeUs: frame.record.actualAssetTimeUs,
|
|
756
|
+
bytes: frame.record.bytes,
|
|
757
|
+
sampleId: frame.record.sampleId,
|
|
758
|
+
sha256: frame.record.sha256,
|
|
759
|
+
};
|
|
760
|
+
}),
|
|
761
|
+
rangeStartUs: scene.range.startUs,
|
|
762
|
+
sceneId: scene.sceneId,
|
|
763
|
+
})));
|
|
764
|
+
|
|
765
|
+
const batches: SceneAnalysisV1["batches"][number][] = [];
|
|
766
|
+
const descriptionBySceneId = new Map<string, SceneAnalysisV1["scenes"][number]["description"]>();
|
|
767
|
+
const resolvedModels = new Set<string>();
|
|
768
|
+
const usage = { inputTokens: 0, outputTokens: 0, uploadedBytes: 0, uploadedImages: 0 };
|
|
769
|
+
for (const batchPlan of batchPlans) {
|
|
770
|
+
const batchKey = sceneBatchCacheKey({
|
|
771
|
+
inputDigest: resolved.subject.integritySha256,
|
|
772
|
+
model: {
|
|
773
|
+
aiSdkVersion: SCENE_AI_SDK_VERSION,
|
|
774
|
+
gateway: "vercel-ai-gateway",
|
|
775
|
+
promptSha256: SCENE_PROMPT_SHA256,
|
|
776
|
+
promptVersion: SCENE_PROMPT_VERSION,
|
|
777
|
+
requestedModel: options.model,
|
|
778
|
+
samplingVersion: plan.samplingVersion,
|
|
779
|
+
},
|
|
780
|
+
samples: batchPlan.frames.map(frame => ({
|
|
781
|
+
actualAssetTimeUs: frame.actualAssetTimeUs,
|
|
782
|
+
sampleId: frame.sampleId,
|
|
783
|
+
sha256: frame.sha256,
|
|
784
|
+
})),
|
|
785
|
+
sceneIds: batchPlan.batch.sceneIds,
|
|
786
|
+
});
|
|
787
|
+
let batch = transitionSceneBatch({ ...batchPlan.batch, batchKey }, { kind: "dispatch" });
|
|
788
|
+
try {
|
|
789
|
+
const cached = await loadCachedSceneBatch(options.projectDirectory, batch.batchKey, batch.sceneIds);
|
|
790
|
+
let result: CachedSceneProviderResult;
|
|
791
|
+
if (cached !== null) {
|
|
792
|
+
result = cached;
|
|
793
|
+
} else {
|
|
794
|
+
const live = await options.provider.describe({
|
|
795
|
+
batchKey: batch.batchKey,
|
|
796
|
+
cloudUpload: { acknowledgedAt: options.acknowledgedAt, policy: SCENE_UPLOAD_POLICY },
|
|
797
|
+
model: options.model,
|
|
798
|
+
prompt: { sha256: SCENE_PROMPT_SHA256, version: SCENE_PROMPT_VERSION },
|
|
799
|
+
scenes: batch.sceneIds.map(sceneId => ({
|
|
800
|
+
frames: batchPlan.frames.filter(frame => frame.sceneId === sceneId).map((frame) => {
|
|
801
|
+
const materializedFrame = frameById.get(frame.sampleId as SceneAnalysisV1["samples"][number]["sampleId"]);
|
|
802
|
+
if (materializedFrame === undefined) throw new CliError("internal", `Missing batch sample: ${frame.sampleId}`);
|
|
803
|
+
return {
|
|
804
|
+
actualAssetTimeUs: frame.actualAssetTimeUs,
|
|
805
|
+
bytes: materializedFrame.bytes,
|
|
806
|
+
mediaType: "image/jpeg" as const,
|
|
807
|
+
sampleId: frame.sampleId,
|
|
808
|
+
};
|
|
809
|
+
}),
|
|
810
|
+
sceneId,
|
|
811
|
+
})),
|
|
812
|
+
});
|
|
813
|
+
result = CachedSceneProviderResultSchema.parse({
|
|
814
|
+
descriptions: live.descriptions,
|
|
815
|
+
resolvedModel: live.resolvedModel,
|
|
816
|
+
});
|
|
817
|
+
usage.inputTokens += live.usage.inputTokens;
|
|
818
|
+
usage.outputTokens += live.usage.outputTokens;
|
|
819
|
+
usage.uploadedBytes += live.usage.uploadedBytes;
|
|
820
|
+
usage.uploadedImages += live.usage.uploadedImages;
|
|
821
|
+
try {
|
|
822
|
+
await saveCachedSceneBatch(options.projectDirectory, batch.batchKey, result);
|
|
823
|
+
} catch {
|
|
824
|
+
// Persisting a successful response is best-effort. The in-memory
|
|
825
|
+
// descriptions and provider usage remain authoritative for this run.
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
batch = transitionSceneBatch(batch, { kind: "complete" });
|
|
829
|
+
for (const described of result.descriptions) descriptionBySceneId.set(described.sceneId, described.description);
|
|
830
|
+
if (result.resolvedModel !== null) resolvedModels.add(result.resolvedModel);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
const failure = error instanceof SceneProviderError
|
|
833
|
+
? error
|
|
834
|
+
: new SceneProviderError("gateway-unavailable");
|
|
835
|
+
batch = transitionSceneBatch(batch, {
|
|
836
|
+
errorCode: failure.code,
|
|
837
|
+
kind: "fail",
|
|
838
|
+
outcome: failure.outcome,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
batches.push(SceneBatchSchema.parse(batch));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const scenes = plan.scenes.flatMap((scene) => {
|
|
845
|
+
const description = descriptionBySceneId.get(scene.sceneId);
|
|
846
|
+
return description === undefined ? [] : [{
|
|
847
|
+
boundaryConfidence: scene.boundaryConfidence,
|
|
848
|
+
description,
|
|
849
|
+
range: scene.range,
|
|
850
|
+
sampleIds: scene.sampleIds,
|
|
851
|
+
sceneId: scene.sceneId,
|
|
852
|
+
}];
|
|
853
|
+
});
|
|
854
|
+
const resolvedModel = resolvedModels.size === 1 ? [...resolvedModels][0]! : null;
|
|
855
|
+
const analysis = SceneAnalysisV1Schema.parse({
|
|
856
|
+
analysisId: options.analysisId,
|
|
857
|
+
batches,
|
|
858
|
+
cloudUpload: { acknowledgedAt: options.acknowledgedAt, policy: SCENE_UPLOAD_POLICY },
|
|
859
|
+
createdAt: options.createdAt,
|
|
860
|
+
durationUs: resolved.asset.durationUs,
|
|
861
|
+
inputDigest: resolved.subject.integritySha256,
|
|
862
|
+
kind: "slopcamera.scene-analysis",
|
|
863
|
+
model: {
|
|
864
|
+
aiSdkVersion: SCENE_AI_SDK_VERSION,
|
|
865
|
+
gateway: "vercel-ai-gateway",
|
|
866
|
+
promptSha256: SCENE_PROMPT_SHA256,
|
|
867
|
+
promptVersion: SCENE_PROMPT_VERSION,
|
|
868
|
+
requestedModel: options.model,
|
|
869
|
+
resolvedModel,
|
|
870
|
+
samplingVersion: plan.samplingVersion,
|
|
871
|
+
},
|
|
872
|
+
samples: materialized.map(frame => frame.record),
|
|
873
|
+
scenes,
|
|
874
|
+
schemaVersion: 1,
|
|
875
|
+
subjects: [resolved.subject],
|
|
876
|
+
usage,
|
|
877
|
+
});
|
|
878
|
+
return { analysis, kind: "complete", plan };
|
|
879
|
+
}
|