@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,1526 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference -- packed source types must include Bun asset modules
|
|
2
|
+
/// <reference path="../../../src/assets.d.ts" />
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import {
|
|
7
|
+
chmod,
|
|
8
|
+
link,
|
|
9
|
+
lstat,
|
|
10
|
+
open,
|
|
11
|
+
readFile,
|
|
12
|
+
realpath,
|
|
13
|
+
rename,
|
|
14
|
+
rm,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import nebulaSansBoldAsset from "../../../src/assets/fonts/nebula-sans/NebulaSans-Bold.otf" with { type: "file" };
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
ProjectRenderInvocationSchema,
|
|
26
|
+
resolveProjectRenderEncoderRecipe,
|
|
27
|
+
type OverlayOperation,
|
|
28
|
+
type ProjectRenderEncoderRecipe,
|
|
29
|
+
type ProjectRenderInvocation,
|
|
30
|
+
type ProjectRenderPlanV1,
|
|
31
|
+
type ProjectRenderTier,
|
|
32
|
+
type ResolvedProjectOverlay,
|
|
33
|
+
type ResolvedProjectVideoSlice,
|
|
34
|
+
} from "../contracts";
|
|
35
|
+
import {
|
|
36
|
+
assertProjectCameraSpatialLayerGeometry,
|
|
37
|
+
buildProjectCameraSpatialIndex,
|
|
38
|
+
canonicalJson,
|
|
39
|
+
canonicalJsonSha256,
|
|
40
|
+
evaluateProjectCameraSpatialViewport,
|
|
41
|
+
projectCameraSegmentsOverlapping,
|
|
42
|
+
projectCameraSpatialLayer,
|
|
43
|
+
type ProjectCameraSpatialAlgebra,
|
|
44
|
+
type ProjectCameraSpatialIndex,
|
|
45
|
+
} from "../core";
|
|
46
|
+
import { probeVisualMediaSummary } from "./analyzer";
|
|
47
|
+
import { inspectPngIntrinsicSize, inspectSvgIntrinsicSize } from "./asset-ingest";
|
|
48
|
+
import { CliError } from "./errors";
|
|
49
|
+
import { materializeFilterScript } from "./filter-script";
|
|
50
|
+
import type { ProcessRunner } from "./io";
|
|
51
|
+
import {
|
|
52
|
+
assertAudioLoopBufferWithinLimit,
|
|
53
|
+
assertVideoLoopBufferWithinLimit,
|
|
54
|
+
resolveAudibleSourceRange,
|
|
55
|
+
} from "./overlay-playback";
|
|
56
|
+
import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
|
|
57
|
+
import {
|
|
58
|
+
fingerprintPhysicalProjectMedia,
|
|
59
|
+
resolvePhysicalProjectMedia,
|
|
60
|
+
resolveVerifiedProjectMedia,
|
|
61
|
+
verifyPhysicalProjectMedia,
|
|
62
|
+
type ExpectedProjectMediaIntegrity,
|
|
63
|
+
} from "./project-media-integrity";
|
|
64
|
+
import { applyMetadataEffects } from "./renderer";
|
|
65
|
+
import type { SpatialCompositorCadenceBindingV1 } from "../contracts/spatial-compositor";
|
|
66
|
+
import { assertSpatialCompositorCadence } from "../core/spatial-compositor";
|
|
67
|
+
import { spatialFrameCount } from "../../../src/spatial-scene/time";
|
|
68
|
+
|
|
69
|
+
const MAXIMUM_SVG_CACHE_MANIFEST_BYTES = 64 * 1_024;
|
|
70
|
+
const MAXIMUM_SVG_DERIVATIVE_BYTES = 512 * 1_024 * 1_024;
|
|
71
|
+
const SVG_RASTER_RECIPE_VERSION = "slopcamera-rsvg-convert-v1";
|
|
72
|
+
const CAPTION_SVG_RASTER_RECIPE_VERSION = "slopcamera-caption-resvg-v1";
|
|
73
|
+
const CAPTION_RESVG_RENDERER_VERSION = "2.6.2";
|
|
74
|
+
const CAPTION_FONT = Object.freeze({
|
|
75
|
+
bytes: 145_348,
|
|
76
|
+
family: "Nebula Sans",
|
|
77
|
+
sha256: "91617d3e2281e8213f64f6bf359f387022d3149b35000b38365c32130a25bfa8",
|
|
78
|
+
weight: 700,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const SvgRasterSourceSchema = z.strictObject({
|
|
82
|
+
bytes: z.number().int().safe().positive(),
|
|
83
|
+
mediaType: z.literal("image/svg+xml"),
|
|
84
|
+
pixelHeight: z.number().int().safe().positive().max(16_384),
|
|
85
|
+
pixelWidth: z.number().int().safe().positive().max(16_384),
|
|
86
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const SvgRasterRecipeSchema = z.discriminatedUnion("renderer", [
|
|
90
|
+
z.strictObject({
|
|
91
|
+
arguments: z.tuple([
|
|
92
|
+
z.literal("--keep-aspect-ratio"),
|
|
93
|
+
z.literal("--width"),
|
|
94
|
+
z.string().regex(/^[1-9][0-9]{0,4}$/u),
|
|
95
|
+
z.literal("--height"),
|
|
96
|
+
z.string().regex(/^[1-9][0-9]{0,4}$/u),
|
|
97
|
+
]),
|
|
98
|
+
renderer: z.literal("rsvg-convert"),
|
|
99
|
+
rendererVersion: z.string().trim().min(1).max(300),
|
|
100
|
+
source: SvgRasterSourceSchema,
|
|
101
|
+
version: z.literal(SVG_RASTER_RECIPE_VERSION),
|
|
102
|
+
}),
|
|
103
|
+
z.strictObject({
|
|
104
|
+
arguments: z.strictObject({
|
|
105
|
+
defaultFontFamily: z.literal(CAPTION_FONT.family),
|
|
106
|
+
fontBytes: z.literal(CAPTION_FONT.bytes),
|
|
107
|
+
fontSha256: z.literal(CAPTION_FONT.sha256),
|
|
108
|
+
fontWeight: z.literal(CAPTION_FONT.weight),
|
|
109
|
+
loadSystemFonts: z.literal(false),
|
|
110
|
+
}),
|
|
111
|
+
renderer: z.literal("@resvg/resvg-js"),
|
|
112
|
+
rendererVersion: z.literal(CAPTION_RESVG_RENDERER_VERSION),
|
|
113
|
+
source: SvgRasterSourceSchema,
|
|
114
|
+
version: z.literal(CAPTION_SVG_RASTER_RECIPE_VERSION),
|
|
115
|
+
}),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const SvgCacheManifestSchema = z.strictObject({
|
|
119
|
+
derivative: z.strictObject({
|
|
120
|
+
bytes: z.number().int().safe().positive().max(MAXIMUM_SVG_DERIVATIVE_BYTES),
|
|
121
|
+
path: z.string().regex(/^[a-f0-9]{64}\.png$/u),
|
|
122
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
123
|
+
}),
|
|
124
|
+
kind: z.union([
|
|
125
|
+
z.literal("slopcamera.svg-raster-cache"),
|
|
126
|
+
z.literal("studio.svg-raster-cache"),
|
|
127
|
+
]),
|
|
128
|
+
recipe: SvgRasterRecipeSchema,
|
|
129
|
+
recipeSha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
130
|
+
schemaVersion: z.literal(2),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function seconds(microseconds: number): string {
|
|
134
|
+
return (microseconds / 1_000_000).toFixed(6).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function decimal(value: number): string {
|
|
138
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(10).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function audioPlacementFilters(startUs: number): string[] {
|
|
142
|
+
// amix consumes each input from its first sample; shifting timestamps alone
|
|
143
|
+
// does not place that input in the program. Insert silence at the output rate.
|
|
144
|
+
const samples = (BigInt(startUs) * 48_000n + 500_000n) / 1_000_000n;
|
|
145
|
+
return ["asetpts=PTS-STARTPTS", ...(samples === 0n ? [] : [`adelay=${samples}S:all=1`])];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function atempo(rate: number): string {
|
|
149
|
+
if (!Number.isFinite(rate) || rate <= 0) throw new CliError("unsupported-plan", "Audio tempo rate must be positive.");
|
|
150
|
+
const filters: string[] = [];
|
|
151
|
+
let remaining = rate;
|
|
152
|
+
while (remaining > 100) {
|
|
153
|
+
filters.push("atempo=100");
|
|
154
|
+
remaining /= 100;
|
|
155
|
+
}
|
|
156
|
+
while (remaining < 0.5) {
|
|
157
|
+
filters.push("atempo=0.5");
|
|
158
|
+
remaining /= 0.5;
|
|
159
|
+
}
|
|
160
|
+
filters.push(`atempo=${decimal(remaining)}`);
|
|
161
|
+
return filters.join(",");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
165
|
+
const pathFromRoot = relative(root, candidate);
|
|
166
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function resolveProjectOverlayMedia(
|
|
170
|
+
projectDirectory: string,
|
|
171
|
+
repositoryRoot: string,
|
|
172
|
+
path: string,
|
|
173
|
+
): Promise<string> {
|
|
174
|
+
const projectRoot = await realpath(projectDirectory);
|
|
175
|
+
try {
|
|
176
|
+
return await resolvePhysicalProjectMedia(projectRoot, path);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof CliError) throw error;
|
|
179
|
+
if (!isFileSystemError(error, "ENOENT")) throw error;
|
|
180
|
+
}
|
|
181
|
+
// Backward compatibility for plans that intentionally stored a repository-relative overlay path.
|
|
182
|
+
return await resolvePhysicalProjectMedia(repositoryRoot, path);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isFileSystemError(error: unknown, code: string): boolean {
|
|
186
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function readBoundedPhysicalText(path: string, maximumBytes: number): Promise<string> {
|
|
190
|
+
const lexical = await lstat(path);
|
|
191
|
+
if (lexical.isSymbolicLink() || !lexical.isFile() || lexical.size <= 0 || lexical.size > maximumBytes) {
|
|
192
|
+
throw new CliError("invalid-data", `Cache manifest must be a bounded physical file: ${path}`);
|
|
193
|
+
}
|
|
194
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
195
|
+
try {
|
|
196
|
+
const before = await handle.stat();
|
|
197
|
+
if (!before.isFile() || before.dev !== lexical.dev || before.ino !== lexical.ino || before.size !== lexical.size) {
|
|
198
|
+
throw new CliError("conflict", `Cache manifest changed before validation: ${path}`);
|
|
199
|
+
}
|
|
200
|
+
const bytes = await handle.readFile();
|
|
201
|
+
const after = await handle.stat();
|
|
202
|
+
if (
|
|
203
|
+
bytes.byteLength !== before.size
|
|
204
|
+
|| after.dev !== before.dev
|
|
205
|
+
|| after.ino !== before.ino
|
|
206
|
+
|| after.size !== before.size
|
|
207
|
+
|| after.mtimeMs !== before.mtimeMs
|
|
208
|
+
|| after.ctimeMs !== before.ctimeMs
|
|
209
|
+
) {
|
|
210
|
+
throw new CliError("conflict", `Cache manifest changed during validation: ${path}`);
|
|
211
|
+
}
|
|
212
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
213
|
+
} finally {
|
|
214
|
+
await handle.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function physicalOverlayCacheDirectory(
|
|
219
|
+
projectDirectory: string,
|
|
220
|
+
workspaceDirectory: string | undefined,
|
|
221
|
+
): Promise<string> {
|
|
222
|
+
return await ensurePhysicalPrivateDirectoryWithin(
|
|
223
|
+
workspaceDirectory ?? projectDirectory,
|
|
224
|
+
workspaceDirectory === undefined ? "renders/.overlay-cache" : "overlay-cache",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function svgRasterRecipe(overlay: OverlayOperation, rendererVersion: string) {
|
|
229
|
+
const asset = overlay.source.asset;
|
|
230
|
+
return SvgCacheManifestSchema.shape.recipe.parse({
|
|
231
|
+
arguments: [
|
|
232
|
+
"--keep-aspect-ratio",
|
|
233
|
+
"--width", String(overlay.intrinsicSize.width),
|
|
234
|
+
"--height", String(overlay.intrinsicSize.height),
|
|
235
|
+
],
|
|
236
|
+
renderer: "rsvg-convert",
|
|
237
|
+
rendererVersion,
|
|
238
|
+
source: {
|
|
239
|
+
bytes: asset.bytes,
|
|
240
|
+
mediaType: "image/svg+xml",
|
|
241
|
+
pixelHeight: overlay.intrinsicSize.height,
|
|
242
|
+
pixelWidth: overlay.intrinsicSize.width,
|
|
243
|
+
sha256: asset.sha256,
|
|
244
|
+
},
|
|
245
|
+
version: SVG_RASTER_RECIPE_VERSION,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isOwnedCaptionSprite(overlay: OverlayOperation): boolean {
|
|
250
|
+
const asset = overlay.source.asset;
|
|
251
|
+
const provenance = asset.provenance;
|
|
252
|
+
return overlay.source.kind === "svg"
|
|
253
|
+
&& asset.path === `renders/caption-assets/${asset.sha256}.svg`
|
|
254
|
+
&& provenance.kind === "generated"
|
|
255
|
+
&& provenance.generator === "slopcamera-social-caption-sprite"
|
|
256
|
+
&& provenance.generatorVersion === "1"
|
|
257
|
+
&& provenance.command.length === 3
|
|
258
|
+
&& provenance.command[0] === "slopcamera"
|
|
259
|
+
&& provenance.command[1] === "caption"
|
|
260
|
+
&& provenance.command[2] === "social-block-v1";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function captionSvgRasterRecipe(overlay: OverlayOperation) {
|
|
264
|
+
const asset = overlay.source.asset;
|
|
265
|
+
return SvgCacheManifestSchema.shape.recipe.parse({
|
|
266
|
+
arguments: {
|
|
267
|
+
defaultFontFamily: CAPTION_FONT.family,
|
|
268
|
+
fontBytes: CAPTION_FONT.bytes,
|
|
269
|
+
fontSha256: CAPTION_FONT.sha256,
|
|
270
|
+
fontWeight: CAPTION_FONT.weight,
|
|
271
|
+
loadSystemFonts: false,
|
|
272
|
+
},
|
|
273
|
+
renderer: "@resvg/resvg-js",
|
|
274
|
+
rendererVersion: CAPTION_RESVG_RENDERER_VERSION,
|
|
275
|
+
source: {
|
|
276
|
+
bytes: asset.bytes,
|
|
277
|
+
mediaType: "image/svg+xml",
|
|
278
|
+
pixelHeight: overlay.intrinsicSize.height,
|
|
279
|
+
pixelWidth: overlay.intrinsicSize.width,
|
|
280
|
+
sha256: asset.sha256,
|
|
281
|
+
},
|
|
282
|
+
version: CAPTION_SVG_RASTER_RECIPE_VERSION,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function bundledCaptionFontPath(): string {
|
|
287
|
+
return isAbsolute(nebulaSansBoldAsset)
|
|
288
|
+
? nebulaSansBoldAsset
|
|
289
|
+
: fileURLToPath(new URL(nebulaSansBoldAsset, import.meta.url));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function reusableSvgDerivative(
|
|
293
|
+
cacheDirectory: string,
|
|
294
|
+
recipeSha256: string,
|
|
295
|
+
expectedSize: OverlayOperation["intrinsicSize"],
|
|
296
|
+
): Promise<string | null> {
|
|
297
|
+
const manifestPath = join(cacheDirectory, `${recipeSha256}.json`);
|
|
298
|
+
try {
|
|
299
|
+
const parsed = SvgCacheManifestSchema.safeParse(
|
|
300
|
+
JSON.parse(await readBoundedPhysicalText(manifestPath, MAXIMUM_SVG_CACHE_MANIFEST_BYTES)) as unknown,
|
|
301
|
+
);
|
|
302
|
+
if (
|
|
303
|
+
!parsed.success
|
|
304
|
+
|| parsed.data.recipeSha256 !== recipeSha256
|
|
305
|
+
|| canonicalJsonSha256(parsed.data.recipe) !== recipeSha256
|
|
306
|
+
) return null;
|
|
307
|
+
const derivative = join(cacheDirectory, parsed.data.derivative.path);
|
|
308
|
+
try {
|
|
309
|
+
const verified = await verifyPhysicalProjectMedia(derivative, parsed.data.derivative, "SVG cache derivative");
|
|
310
|
+
const size = await inspectPngIntrinsicSize(verified);
|
|
311
|
+
return size.width === expectedSize.width && size.height === expectedSize.height ? verified : null;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error instanceof CliError && (error.code === "invalid-data" || error.code === "conflict")) return null;
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (isFileSystemError(error, "ENOENT") || error instanceof SyntaxError) return null;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function createSvgDerivative(
|
|
323
|
+
input: string,
|
|
324
|
+
cacheDirectory: string,
|
|
325
|
+
recipe: z.infer<typeof SvgCacheManifestSchema>["recipe"],
|
|
326
|
+
recipeSha256: string,
|
|
327
|
+
options: ProjectRenderBuildOptions,
|
|
328
|
+
): Promise<string> {
|
|
329
|
+
if (
|
|
330
|
+
recipe.renderer === "rsvg-convert"
|
|
331
|
+
&& (options.rsvgConvert === undefined || options.runner === undefined)
|
|
332
|
+
) {
|
|
333
|
+
throw new CliError("unavailable", "SVG overlays require rsvg-convert.");
|
|
334
|
+
}
|
|
335
|
+
const temporaryOutput = join(cacheDirectory, `.raster-${randomUUID()}.png`);
|
|
336
|
+
const temporaryManifest = join(cacheDirectory, `.manifest-${randomUUID()}.json`);
|
|
337
|
+
try {
|
|
338
|
+
if (recipe.renderer === "rsvg-convert") {
|
|
339
|
+
const result = await options.runner!.run([
|
|
340
|
+
options.rsvgConvert!,
|
|
341
|
+
...recipe.arguments,
|
|
342
|
+
"-o", temporaryOutput,
|
|
343
|
+
input,
|
|
344
|
+
], { maxOutputBytes: 1_000_000 });
|
|
345
|
+
if (result.exitCode !== 0) {
|
|
346
|
+
throw new CliError("subprocess", `rsvg-convert failed: ${result.stderr.trim().slice(-4_000) || `exit ${result.exitCode}`}`);
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
const fontPath = await verifyPhysicalProjectMedia(
|
|
350
|
+
bundledCaptionFontPath(),
|
|
351
|
+
CAPTION_FONT,
|
|
352
|
+
"Bundled Nebula Sans Bold caption font",
|
|
353
|
+
);
|
|
354
|
+
try {
|
|
355
|
+
const rendered = new Resvg(await readFile(input), {
|
|
356
|
+
font: {
|
|
357
|
+
defaultFontFamily: recipe.arguments.defaultFontFamily,
|
|
358
|
+
fontFiles: [fontPath],
|
|
359
|
+
loadSystemFonts: recipe.arguments.loadSystemFonts,
|
|
360
|
+
},
|
|
361
|
+
}).render().asPng();
|
|
362
|
+
if (rendered.byteLength <= 0 || rendered.byteLength > MAXIMUM_SVG_DERIVATIVE_BYTES) {
|
|
363
|
+
throw new CliError("invalid-data", "Caption SVG derivative exceeds its byte bound.");
|
|
364
|
+
}
|
|
365
|
+
await writeFile(temporaryOutput, rendered, { flag: "wx", mode: 0o600 });
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (error instanceof CliError) throw error;
|
|
368
|
+
throw new CliError("subprocess", "Resvg could not rasterize the generated caption sprite.", {
|
|
369
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const generated = await fingerprintPhysicalProjectMedia(temporaryOutput, MAXIMUM_SVG_DERIVATIVE_BYTES);
|
|
374
|
+
const generatedSize = await inspectPngIntrinsicSize(temporaryOutput);
|
|
375
|
+
if (
|
|
376
|
+
generatedSize.width !== recipe.source.pixelWidth
|
|
377
|
+
|| generatedSize.height !== recipe.source.pixelHeight
|
|
378
|
+
) {
|
|
379
|
+
throw new CliError("invalid-data", "SVG derivative dimensions do not match its bounded raster recipe.");
|
|
380
|
+
}
|
|
381
|
+
const derivativeName = `${generated.sha256}.png`;
|
|
382
|
+
const derivative = join(cacheDirectory, derivativeName);
|
|
383
|
+
await chmod(temporaryOutput, 0o600);
|
|
384
|
+
try {
|
|
385
|
+
await link(temporaryOutput, derivative);
|
|
386
|
+
await rm(temporaryOutput);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (!isFileSystemError(error, "EEXIST")) throw error;
|
|
389
|
+
try {
|
|
390
|
+
await verifyPhysicalProjectMedia(derivative, generated, "SVG cache derivative");
|
|
391
|
+
await rm(temporaryOutput);
|
|
392
|
+
} catch (verificationError) {
|
|
393
|
+
const existing = await lstat(derivative);
|
|
394
|
+
if (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1) {
|
|
395
|
+
throw new CliError("unsafe-path", `SVG cache derivative is unsafe: ${derivative}`);
|
|
396
|
+
}
|
|
397
|
+
if (!(verificationError instanceof CliError)) throw verificationError;
|
|
398
|
+
await rename(temporaryOutput, derivative);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const manifest = SvgCacheManifestSchema.parse({
|
|
402
|
+
derivative: { bytes: generated.bytes, path: derivativeName, sha256: generated.sha256 },
|
|
403
|
+
kind: "slopcamera.svg-raster-cache",
|
|
404
|
+
recipe,
|
|
405
|
+
recipeSha256,
|
|
406
|
+
schemaVersion: 2,
|
|
407
|
+
});
|
|
408
|
+
await writeFile(temporaryManifest, `${canonicalJson(manifest)}\n`, { flag: "wx", mode: 0o600 });
|
|
409
|
+
await rename(temporaryManifest, join(cacheDirectory, `${recipeSha256}.json`));
|
|
410
|
+
return await verifyPhysicalProjectMedia(derivative, manifest.derivative, "SVG cache derivative");
|
|
411
|
+
} finally {
|
|
412
|
+
await Promise.all([
|
|
413
|
+
rm(temporaryOutput, { force: true }),
|
|
414
|
+
rm(temporaryManifest, { force: true }),
|
|
415
|
+
]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
interface PreparedProjectOverlayMedia {
|
|
420
|
+
readonly audioStreamIndex: number | null;
|
|
421
|
+
readonly path: string;
|
|
422
|
+
readonly sourcePath: string;
|
|
423
|
+
readonly videoStreamIndex: number | null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function prepareProjectOverlayMedia(
|
|
427
|
+
overlay: OverlayOperation,
|
|
428
|
+
options: ProjectRenderBuildOptions,
|
|
429
|
+
): Promise<PreparedProjectOverlayMedia> {
|
|
430
|
+
const input = await resolveProjectOverlayMedia(
|
|
431
|
+
options.projectDirectory,
|
|
432
|
+
options.repositoryRoot,
|
|
433
|
+
overlay.source.asset.path,
|
|
434
|
+
);
|
|
435
|
+
await verifyPhysicalProjectMedia(input, overlay.source.asset, `Overlay ${overlay.overlayId}`);
|
|
436
|
+
if (overlay.source.asset.mediaType !== "image/svg+xml") {
|
|
437
|
+
if (options.ffprobe === undefined || options.runner === undefined) {
|
|
438
|
+
throw new CliError("unavailable", `Overlay ${overlay.overlayId} requires FFprobe dimension verification.`);
|
|
439
|
+
}
|
|
440
|
+
const probed = await probeVisualMediaSummary(options.ffprobe, options.runner, input);
|
|
441
|
+
if (
|
|
442
|
+
probed.pixelWidth !== overlay.intrinsicSize.width
|
|
443
|
+
|| probed.pixelHeight !== overlay.intrinsicSize.height
|
|
444
|
+
) {
|
|
445
|
+
throw new CliError("invalid-data", `Overlay ${overlay.overlayId} intrinsic dimensions do not match its media.`);
|
|
446
|
+
}
|
|
447
|
+
if (overlay.source.kind === "gif" || overlay.source.kind === "video") {
|
|
448
|
+
const playback = overlay.source.playback;
|
|
449
|
+
if (
|
|
450
|
+
playback.videoStreamIndex !== null
|
|
451
|
+
&& playback.videoStreamIndex !== undefined
|
|
452
|
+
&& playback.videoStreamIndex !== probed.videoStreamIndex
|
|
453
|
+
) {
|
|
454
|
+
throw new CliError("invalid-data", `Overlay ${overlay.overlayId} selected video stream does not match its media.`);
|
|
455
|
+
}
|
|
456
|
+
if (
|
|
457
|
+
playback.audioStreamIndex !== null
|
|
458
|
+
&& playback.audioStreamIndex !== undefined
|
|
459
|
+
&& playback.audioStreamIndex !== probed.audioStreamIndex
|
|
460
|
+
) {
|
|
461
|
+
throw new CliError("invalid-data", `Overlay ${overlay.overlayId} selected audio stream does not match its media.`);
|
|
462
|
+
}
|
|
463
|
+
if (
|
|
464
|
+
overlay.source.kind === "video"
|
|
465
|
+
&& overlay.source.audioPolicy.kind !== "mute"
|
|
466
|
+
&& probed.audioStreamIndex === null
|
|
467
|
+
) {
|
|
468
|
+
throw new CliError("invalid-data", `Overlay ${overlay.overlayId} requires an audio stream that is absent from its media.`);
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
audioStreamIndex: probed.audioStreamIndex,
|
|
472
|
+
path: input,
|
|
473
|
+
sourcePath: input,
|
|
474
|
+
videoStreamIndex: probed.videoStreamIndex,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
audioStreamIndex: null,
|
|
479
|
+
path: input,
|
|
480
|
+
sourcePath: input,
|
|
481
|
+
videoStreamIndex: null,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const svgSize = await inspectSvgIntrinsicSize(input);
|
|
485
|
+
if (svgSize.width !== overlay.intrinsicSize.width || svgSize.height !== overlay.intrinsicSize.height) {
|
|
486
|
+
throw new CliError("invalid-data", `Overlay ${overlay.overlayId} intrinsic dimensions do not match its SVG.`);
|
|
487
|
+
}
|
|
488
|
+
const captionSprite = isOwnedCaptionSprite(overlay);
|
|
489
|
+
if (!captionSprite && (
|
|
490
|
+
options.rsvgConvert === undefined
|
|
491
|
+
|| options.rsvgConvertVersion === undefined
|
|
492
|
+
|| options.runner === undefined
|
|
493
|
+
)) {
|
|
494
|
+
throw new CliError("unavailable", `SVG overlay ${overlay.overlayId} requires rsvg-convert with a probed version.`);
|
|
495
|
+
}
|
|
496
|
+
const cacheDirectory = await physicalOverlayCacheDirectory(
|
|
497
|
+
options.projectDirectory,
|
|
498
|
+
options.workspaceDirectory,
|
|
499
|
+
);
|
|
500
|
+
const recipe = captionSprite
|
|
501
|
+
? captionSvgRasterRecipe(overlay)
|
|
502
|
+
: svgRasterRecipe(overlay, options.rsvgConvertVersion!);
|
|
503
|
+
const recipeSha256 = canonicalJsonSha256(recipe);
|
|
504
|
+
const cached = await reusableSvgDerivative(cacheDirectory, recipeSha256, overlay.intrinsicSize);
|
|
505
|
+
if (cached !== null) {
|
|
506
|
+
return {
|
|
507
|
+
audioStreamIndex: null,
|
|
508
|
+
path: cached,
|
|
509
|
+
sourcePath: input,
|
|
510
|
+
videoStreamIndex: null,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (options.dryRun) {
|
|
514
|
+
return {
|
|
515
|
+
audioStreamIndex: null,
|
|
516
|
+
path: join(cacheDirectory, `pending-${recipeSha256}.png`),
|
|
517
|
+
sourcePath: input,
|
|
518
|
+
videoStreamIndex: null,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
audioStreamIndex: null,
|
|
523
|
+
path: await createSvgDerivative(input, cacheDirectory, recipe, recipeSha256, options),
|
|
524
|
+
sourcePath: input,
|
|
525
|
+
videoStreamIndex: null,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function inputSpecifier(index: number, streamIndex: number): string {
|
|
530
|
+
return `${index}:${streamIndex}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function animatedStreamSpecifier(
|
|
534
|
+
inputIndex: number,
|
|
535
|
+
streamType: "a" | "v",
|
|
536
|
+
absoluteStreamIndex: number | null | undefined,
|
|
537
|
+
): string {
|
|
538
|
+
return absoluteStreamIndex === null || absoluteStreamIndex === undefined
|
|
539
|
+
? `${inputIndex}:${streamType}:0`
|
|
540
|
+
: inputSpecifier(inputIndex, absoluteStreamIndex);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function layoutPixels(
|
|
544
|
+
presentation: Extract<ResolvedProjectVideoSlice["presentation"], { readonly enabled: true }>,
|
|
545
|
+
outputWidth: number,
|
|
546
|
+
outputHeight: number,
|
|
547
|
+
): { readonly height: number; readonly width: number; readonly x: number; readonly y: number } {
|
|
548
|
+
if (presentation.layout.kind === "output-pixels") {
|
|
549
|
+
return {
|
|
550
|
+
height: Math.max(1, Math.round(presentation.layout.height)),
|
|
551
|
+
width: Math.max(1, Math.round(presentation.layout.width)),
|
|
552
|
+
x: Math.round(presentation.layout.x),
|
|
553
|
+
y: Math.round(presentation.layout.y),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
height: Math.max(1, Math.round(presentation.layout.height * outputHeight)),
|
|
558
|
+
width: Math.max(1, Math.round(presentation.layout.width * outputWidth)),
|
|
559
|
+
x: Math.round(presentation.layout.x * outputWidth),
|
|
560
|
+
y: Math.round(presentation.layout.y * outputHeight),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function videoTransform(
|
|
565
|
+
slice: ResolvedProjectVideoSlice,
|
|
566
|
+
outputWidth: number,
|
|
567
|
+
outputHeight: number,
|
|
568
|
+
): { readonly filters: readonly string[]; readonly x: number; readonly y: number } {
|
|
569
|
+
if (!slice.presentation.enabled) throw new CliError("invalid-data", "Disabled video slice reached the renderer.");
|
|
570
|
+
const presentation = slice.presentation;
|
|
571
|
+
const filters: string[] = [];
|
|
572
|
+
if (presentation.crop.kind === "normalized-insets") {
|
|
573
|
+
const crop = presentation.crop;
|
|
574
|
+
filters.push(
|
|
575
|
+
`crop=w='iw*${decimal(1 - crop.left - crop.right)}':h='ih*${decimal(1 - crop.top - crop.bottom)}':x='iw*${decimal(crop.left)}':y='ih*${decimal(crop.top)}'`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const layout = layoutPixels(presentation, outputWidth, outputHeight);
|
|
579
|
+
if (presentation.fit === "fill") {
|
|
580
|
+
filters.push(`scale=${layout.width}:${layout.height}`);
|
|
581
|
+
} else if (presentation.fit === "contain") {
|
|
582
|
+
filters.push(
|
|
583
|
+
`scale=${layout.width}:${layout.height}:force_original_aspect_ratio=decrease`,
|
|
584
|
+
`pad=${layout.width}:${layout.height}:(ow-iw)/2:(oh-ih)/2:color=0x00000000`,
|
|
585
|
+
);
|
|
586
|
+
} else {
|
|
587
|
+
filters.push(
|
|
588
|
+
`scale=${layout.width}:${layout.height}:force_original_aspect_ratio=increase`,
|
|
589
|
+
`crop=${layout.width}:${layout.height}`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
filters.push("format=rgba");
|
|
593
|
+
if (presentation.opacity !== 1) filters.push(`colorchannelmixer=aa=${decimal(presentation.opacity)}`);
|
|
594
|
+
return { filters, x: layout.x, y: layout.y };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function overlayAnimationDuration(animation: OverlayOperation["entrance"]): number {
|
|
598
|
+
return animation.kind === "none" ? 0 : animation.durationUs;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function effectiveAnimationBounds(
|
|
602
|
+
overlay: OverlayOperation,
|
|
603
|
+
visibleDurationUs: number,
|
|
604
|
+
): { readonly entranceUs: number; readonly exitUs: number } {
|
|
605
|
+
const entranceUs = overlayAnimationDuration(overlay.entrance);
|
|
606
|
+
const exitUs = overlayAnimationDuration(overlay.exit);
|
|
607
|
+
if (entranceUs + exitUs <= visibleDurationUs || entranceUs + exitUs === 0) return { entranceUs, exitUs };
|
|
608
|
+
const first = Math.floor(visibleDurationUs * entranceUs / (entranceUs + exitUs));
|
|
609
|
+
return { entranceUs: first, exitUs: visibleDurationUs - first };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
type OverlayEasing = Extract<OverlayOperation["entrance"], { readonly easing: unknown }>["easing"];
|
|
613
|
+
|
|
614
|
+
function eased(easing: OverlayEasing, progress: string): string {
|
|
615
|
+
if (easing.kind === "linear") return progress;
|
|
616
|
+
if (easing.kind === "ease-in") return `pow(${progress},2)`;
|
|
617
|
+
if (easing.kind === "ease-out") return `1-pow(1-(${progress}),2)`;
|
|
618
|
+
if (easing.kind === "ease-in-out") return `if(lt(${progress},0.5),2*pow(${progress},2),1-pow(-2*(${progress})+2,2)/2)`;
|
|
619
|
+
if (easing.kind === "spring") return `min(1,max(0,1-exp(-6*(${progress}))*cos(8*(${progress}))))`;
|
|
620
|
+
if (easing.kind === "cubic-bezier") {
|
|
621
|
+
const bounded = `min(1,max(0,${progress}))`;
|
|
622
|
+
const parameter = `root(${cubicBezierCoordinateExpression("ld(0)", easing.x1, easing.x2)}-(${bounded}),1)`;
|
|
623
|
+
return cubicBezierCoordinateExpression(parameter, easing.y1, easing.y2);
|
|
624
|
+
}
|
|
625
|
+
return progress;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function cubicBezierCoordinateExpression(parameter: string, first: number, second: number): string {
|
|
629
|
+
const inverse = `(1-(${parameter}))`;
|
|
630
|
+
return `3*pow(${inverse},2)*(${parameter})*${decimal(first)}+3*(${inverse})*pow(${parameter},2)*${decimal(second)}+pow(${parameter},3)`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const CAMERA_EXPRESSION_ALGEBRA = {
|
|
634
|
+
add: (left: string, right: string) => `((${left})+(${right}))`,
|
|
635
|
+
constant: decimal,
|
|
636
|
+
divide: (left: string, right: string) => `((${left})/(${right}))`,
|
|
637
|
+
easing: (easing: OverlayEasing, progress: string) => (
|
|
638
|
+
eased(easing, progress)
|
|
639
|
+
),
|
|
640
|
+
exponential: (value: string) => `exp(${value})`,
|
|
641
|
+
maximum: (left: string, right: string) => `max(${left},${right})`,
|
|
642
|
+
minimum: (left: string, right: string) => `min(${left},${right})`,
|
|
643
|
+
multiply: (left: string, right: string) => `((${left})*(${right}))`,
|
|
644
|
+
selectRange: (
|
|
645
|
+
clock: string,
|
|
646
|
+
range: { readonly endUs: number; readonly startUs: number },
|
|
647
|
+
end: "inclusive" | "exclusive",
|
|
648
|
+
active: string,
|
|
649
|
+
fallback: string,
|
|
650
|
+
) => `if(gte(${clock},${decimal(range.startUs)})*${
|
|
651
|
+
end === "inclusive" ? "lte" : "lt"
|
|
652
|
+
}(${clock},${decimal(range.endUs)}),${active},${fallback})`,
|
|
653
|
+
subtract: (left: string, right: string) => `((${left})-(${right}))`,
|
|
654
|
+
} satisfies ProjectCameraSpatialAlgebra<string>;
|
|
655
|
+
|
|
656
|
+
function projectCameraFilters(
|
|
657
|
+
plan: ProjectRenderPlanV1,
|
|
658
|
+
slice: ResolvedProjectVideoSlice,
|
|
659
|
+
cameraIndex: ProjectCameraSpatialIndex,
|
|
660
|
+
rationalRate?: string,
|
|
661
|
+
): readonly string[] {
|
|
662
|
+
const layerCamera = projectCameraSpatialLayer(
|
|
663
|
+
cameraIndex,
|
|
664
|
+
slice.placementId,
|
|
665
|
+
slice.streamId,
|
|
666
|
+
);
|
|
667
|
+
if (layerCamera === null) return [];
|
|
668
|
+
const segments = projectCameraSegmentsOverlapping(
|
|
669
|
+
layerCamera.segments,
|
|
670
|
+
slice.outputRange,
|
|
671
|
+
);
|
|
672
|
+
if (layerCamera.keyframeGroups.length === 0 && segments.length === 0) {
|
|
673
|
+
return [];
|
|
674
|
+
}
|
|
675
|
+
const layout = layoutPixels(slice.presentation as Extract<ResolvedProjectVideoSlice["presentation"], { readonly enabled: true }>, plan.output.pixelWidth, plan.output.pixelHeight);
|
|
676
|
+
const activeCamera = {
|
|
677
|
+
keyframeGroups: layerCamera.keyframeGroups,
|
|
678
|
+
segments,
|
|
679
|
+
};
|
|
680
|
+
try {
|
|
681
|
+
assertProjectCameraSpatialLayerGeometry(
|
|
682
|
+
activeCamera,
|
|
683
|
+
layout.width,
|
|
684
|
+
layout.height,
|
|
685
|
+
`${slice.placementId}:${slice.streamId}`,
|
|
686
|
+
);
|
|
687
|
+
} catch {
|
|
688
|
+
throw new CliError("invalid-data", `Camera geometry no longer matches layer ${slice.placementId}:${slice.streamId}.`);
|
|
689
|
+
}
|
|
690
|
+
const globalTimeUs = `(${decimal(slice.outputRange.startUs)}+on*1000000/${
|
|
691
|
+
rationalRate === undefined ? decimal(plan.output.frameRate) : `(${rationalRate})`
|
|
692
|
+
})`;
|
|
693
|
+
const viewport = evaluateProjectCameraSpatialViewport(
|
|
694
|
+
activeCamera,
|
|
695
|
+
{
|
|
696
|
+
outputTimeUs: globalTimeUs,
|
|
697
|
+
pixelHeight: layout.height,
|
|
698
|
+
pixelWidth: layout.width,
|
|
699
|
+
},
|
|
700
|
+
CAMERA_EXPRESSION_ALGEBRA,
|
|
701
|
+
);
|
|
702
|
+
return [
|
|
703
|
+
// zoompan's d=1 clock is frame-count based. Normalize the already
|
|
704
|
+
// speed-adjusted slice to the output cadence first so it cannot restore
|
|
705
|
+
// source cadence or discard the project's speed map.
|
|
706
|
+
`fps=${rationalRate ?? decimal(plan.output.frameRate)}`,
|
|
707
|
+
`zoompan=z='${layout.width}/(${viewport.width})':x='${viewport.x}':y='${viewport.y}':d=1:s=${layout.width}x${layout.height}:fps=${rationalRate ?? decimal(plan.output.frameRate)}`,
|
|
708
|
+
"settb=AVTB",
|
|
709
|
+
`setpts=PTS-STARTPTS+${seconds(slice.outputRange.startUs)}/TB`,
|
|
710
|
+
"format=rgba",
|
|
711
|
+
];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function scaleExpression(
|
|
715
|
+
overlay: OverlayOperation,
|
|
716
|
+
fullStartUs: number,
|
|
717
|
+
fullEndUs: number,
|
|
718
|
+
entranceUs: number,
|
|
719
|
+
exitUs: number,
|
|
720
|
+
): string {
|
|
721
|
+
let expression = decimal(overlay.scale);
|
|
722
|
+
if (overlay.entrance.kind === "scale" && entranceUs > 0) {
|
|
723
|
+
const start = seconds(fullStartUs);
|
|
724
|
+
const end = seconds(fullStartUs + entranceUs);
|
|
725
|
+
const from = overlay.entrance.fromScale * overlay.scale;
|
|
726
|
+
const progress = `(t-${start})/(${end}-${start})`;
|
|
727
|
+
expression = `if(between(t,${start},${end}),${decimal(from)}+${decimal(overlay.scale - from)}*(${eased(overlay.entrance.easing, progress)}),${expression})`;
|
|
728
|
+
}
|
|
729
|
+
if (overlay.exit.kind === "scale" && exitUs > 0) {
|
|
730
|
+
const start = seconds(fullEndUs - exitUs);
|
|
731
|
+
const end = seconds(fullEndUs);
|
|
732
|
+
const to = overlay.exit.fromScale * overlay.scale;
|
|
733
|
+
const progress = `(t-${start})/(${end}-${start})`;
|
|
734
|
+
expression = `if(between(t,${start},${end}),${decimal(overlay.scale)}+${decimal(to - overlay.scale)}*(${eased(overlay.exit.easing, progress)}),${expression})`;
|
|
735
|
+
}
|
|
736
|
+
const motionScale = overlayMotionExpression(overlay, "scaleMultiplier", fullStartUs, fullEndUs, 1);
|
|
737
|
+
return motionScale === "1" ? expression : `(${expression})*(${motionScale})`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
type OverlayMotionField = "opacityMultiplier" | "positionX" | "positionY" | "rotationOffsetDegrees" | "scaleMultiplier";
|
|
741
|
+
|
|
742
|
+
function overlayMotionValue(
|
|
743
|
+
keyframe: Extract<NonNullable<OverlayOperation["motion"]>, { readonly kind: "keyframes" }>["keyframes"][number],
|
|
744
|
+
field: OverlayMotionField,
|
|
745
|
+
): number {
|
|
746
|
+
if (field === "positionX") return keyframe.positionOffset.x;
|
|
747
|
+
if (field === "positionY") return keyframe.positionOffset.y;
|
|
748
|
+
return keyframe[field];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function overlayMotionExpression(
|
|
752
|
+
overlay: OverlayOperation,
|
|
753
|
+
field: OverlayMotionField,
|
|
754
|
+
startUs: number,
|
|
755
|
+
endUs: number,
|
|
756
|
+
fallback: number,
|
|
757
|
+
timeExpression = "t",
|
|
758
|
+
): string {
|
|
759
|
+
const motion = overlay.motion ?? { kind: "none" as const };
|
|
760
|
+
if (motion.kind === "none" || endUs <= startUs) return decimal(fallback);
|
|
761
|
+
const keyframes = motion.keyframes;
|
|
762
|
+
let expression = decimal(overlayMotionValue(keyframes.at(-1)!, field));
|
|
763
|
+
for (let index = keyframes.length - 1; index > 0; index -= 1) {
|
|
764
|
+
const left = keyframes[index - 1]!;
|
|
765
|
+
const right = keyframes[index]!;
|
|
766
|
+
const leftTimeUs = startUs + Math.round((endUs - startUs) * left.offset);
|
|
767
|
+
const rightTimeUs = startUs + Math.round((endUs - startUs) * right.offset);
|
|
768
|
+
if (rightTimeUs <= leftTimeUs) continue;
|
|
769
|
+
const leftValue = overlayMotionValue(left, field);
|
|
770
|
+
const rightValue = overlayMotionValue(right, field);
|
|
771
|
+
const progress = `(${timeExpression}-${seconds(leftTimeUs)})/(${seconds(rightTimeUs)}-${seconds(leftTimeUs)})`;
|
|
772
|
+
const value = leftValue === rightValue
|
|
773
|
+
? decimal(leftValue)
|
|
774
|
+
: `${decimal(leftValue)}+${decimal(rightValue - leftValue)}*(${eased(left.easing, progress)})`;
|
|
775
|
+
expression = `if(between(${timeExpression},${seconds(leftTimeUs)},${seconds(rightTimeUs)}),${value},${expression})`;
|
|
776
|
+
}
|
|
777
|
+
return expression;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function overlayGeometryFilters(
|
|
781
|
+
overlay: OverlayOperation,
|
|
782
|
+
fullStartUs: number,
|
|
783
|
+
fullEndUs: number,
|
|
784
|
+
entranceUs: number,
|
|
785
|
+
exitUs: number,
|
|
786
|
+
): readonly string[] {
|
|
787
|
+
const filters: string[] = [];
|
|
788
|
+
const crop = overlay.crop ?? { kind: "none" as const };
|
|
789
|
+
if (crop.kind === "normalized-insets") {
|
|
790
|
+
filters.push(
|
|
791
|
+
`crop=w='iw*${decimal(1 - crop.left - crop.right)}':h='ih*${decimal(1 - crop.top - crop.bottom)}':x='iw*${decimal(crop.left)}':y='ih*${decimal(crop.top)}'`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
filters.push("format=rgba");
|
|
795
|
+
if (overlay.size.kind === "pixels") {
|
|
796
|
+
const width = decimal(overlay.size.width);
|
|
797
|
+
const height = decimal(overlay.size.height);
|
|
798
|
+
const fit = overlay.fit ?? "fill";
|
|
799
|
+
if (fit === "fill") {
|
|
800
|
+
filters.push(`scale=${width}:${height}`);
|
|
801
|
+
} else if (fit === "contain") {
|
|
802
|
+
filters.push(
|
|
803
|
+
`scale=${width}:${height}:force_original_aspect_ratio=decrease`,
|
|
804
|
+
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=0x00000000`,
|
|
805
|
+
);
|
|
806
|
+
} else {
|
|
807
|
+
filters.push(
|
|
808
|
+
`scale=${width}:${height}:force_original_aspect_ratio=increase`,
|
|
809
|
+
`crop=${width}:${height}`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const scale = scaleExpression(overlay, fullStartUs, fullEndUs, entranceUs, exitUs);
|
|
814
|
+
filters.push(`scale=w='iw*(${scale})':h='ih*(${scale})':eval=frame`);
|
|
815
|
+
const opacity = overlayMotionExpression(overlay, "opacityMultiplier", fullStartUs, fullEndUs, 1, "T");
|
|
816
|
+
const mask = overlay.mask ?? { kind: "none" as const };
|
|
817
|
+
if (mask.kind === "none" && opacity === "1") {
|
|
818
|
+
filters.push(`colorchannelmixer=aa=${decimal(overlay.opacity)}`);
|
|
819
|
+
} else {
|
|
820
|
+
const radius = mask.kind === "rounded-rectangle"
|
|
821
|
+
? `min(${decimal(mask.radiusPx)},min(W/2,H/2))`
|
|
822
|
+
: "0";
|
|
823
|
+
const maskExpression = mask.kind === "rounded-rectangle"
|
|
824
|
+
? `gt(between(X,${radius},W-${radius})+between(Y,${radius},H-${radius})+lte(pow(X-min(max(X,${radius}),W-${radius}),2)+pow(Y-min(max(Y,${radius}),H-${radius}),2),pow(${radius},2)),0)`
|
|
825
|
+
: "1";
|
|
826
|
+
filters.push(
|
|
827
|
+
`geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='alpha(X,Y)*${decimal(overlay.opacity)}*(${opacity})*(${maskExpression})'`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
if (overlay.entrance.kind === "fade" && entranceUs > 0) {
|
|
831
|
+
filters.push(`fade=t=in:st=${seconds(fullStartUs)}:d=${seconds(entranceUs)}:alpha=1`);
|
|
832
|
+
}
|
|
833
|
+
if (overlay.exit.kind === "fade" && exitUs > 0) {
|
|
834
|
+
filters.push(`fade=t=out:st=${seconds(fullEndUs - exitUs)}:d=${seconds(exitUs)}:alpha=1`);
|
|
835
|
+
}
|
|
836
|
+
const rotationOffset = overlayMotionExpression(overlay, "rotationOffsetDegrees", fullStartUs, fullEndUs, 0);
|
|
837
|
+
const rotation = rotationOffset === "0"
|
|
838
|
+
? decimal(overlay.rotationDegrees)
|
|
839
|
+
: `${decimal(overlay.rotationDegrees)}+(${rotationOffset})`;
|
|
840
|
+
if (rotation !== "0") {
|
|
841
|
+
filters.push(`rotate='(${rotation})*PI/180':c=none:ow=rotw(iw):oh=roth(ih)`);
|
|
842
|
+
}
|
|
843
|
+
return filters;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function anchorPosition(anchor: OverlayOperation["anchor"], axis: "x" | "y"): string {
|
|
847
|
+
if (axis === "x") {
|
|
848
|
+
if (anchor.endsWith("left") || anchor === "left") return "0";
|
|
849
|
+
if (anchor.endsWith("right") || anchor === "right") return "W-w";
|
|
850
|
+
return "(W-w)/2";
|
|
851
|
+
}
|
|
852
|
+
if (anchor.startsWith("top") || anchor === "top") return "0";
|
|
853
|
+
if (anchor.startsWith("bottom") || anchor === "bottom") return "H-h";
|
|
854
|
+
return "(H-h)/2";
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function slideExpression(
|
|
858
|
+
overlay: OverlayOperation,
|
|
859
|
+
axis: "x" | "y",
|
|
860
|
+
fullStartUs: number,
|
|
861
|
+
fullEndUs: number,
|
|
862
|
+
entranceUs: number,
|
|
863
|
+
exitUs: number,
|
|
864
|
+
): string {
|
|
865
|
+
const expressions: string[] = [];
|
|
866
|
+
for (const [animation, startUs, durationUs, entering] of [
|
|
867
|
+
[overlay.entrance, fullStartUs, entranceUs, true],
|
|
868
|
+
[overlay.exit, fullEndUs - exitUs, exitUs, false],
|
|
869
|
+
] as const) {
|
|
870
|
+
if (animation.kind !== "slide" || durationUs === 0) continue;
|
|
871
|
+
const onAxis = axis === "x"
|
|
872
|
+
? animation.direction === "left" || animation.direction === "right"
|
|
873
|
+
: animation.direction === "up" || animation.direction === "down";
|
|
874
|
+
if (!onAxis) continue;
|
|
875
|
+
const sign = animation.direction === "left" || animation.direction === "up" ? -1 : 1;
|
|
876
|
+
const start = seconds(startUs);
|
|
877
|
+
const end = seconds(startUs + durationUs);
|
|
878
|
+
const from = entering ? sign * animation.distancePx : 0;
|
|
879
|
+
const delta = entering ? -sign * animation.distancePx : sign * animation.distancePx;
|
|
880
|
+
const progress = `(t-${start})/(${end}-${start})`;
|
|
881
|
+
expressions.push(`if(between(t,${start},${end}),${decimal(from)}+${decimal(delta)}*(${eased(animation.easing, progress)}),0)`);
|
|
882
|
+
}
|
|
883
|
+
return expressions.length === 0 ? "0" : expressions.join("+");
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function overlayPositionExpression(
|
|
887
|
+
overlay: OverlayOperation,
|
|
888
|
+
axis: "x" | "y",
|
|
889
|
+
fullStartUs: number,
|
|
890
|
+
fullEndUs: number,
|
|
891
|
+
entranceUs: number,
|
|
892
|
+
exitUs: number,
|
|
893
|
+
): string {
|
|
894
|
+
const field = axis === "x" ? "positionX" : "positionY";
|
|
895
|
+
return `${anchorPosition(overlay.anchor, axis)}+${decimal(overlay.position[axis])}+${overlayMotionExpression(overlay, field, fullStartUs, fullEndUs, 0)}+${slideExpression(overlay, axis, fullStartUs, fullEndUs, entranceUs, exitUs)}`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function overlayVideoChain(
|
|
899
|
+
resolved: ResolvedProjectOverlay,
|
|
900
|
+
fullStartUs: number,
|
|
901
|
+
fullEndUs: number,
|
|
902
|
+
frameRate: number,
|
|
903
|
+
spatialCadence?: SpatialCompositorCadenceBindingV1,
|
|
904
|
+
): { readonly filters: readonly string[]; readonly visibleEndUs: number } | null {
|
|
905
|
+
const overlay = resolved.operation;
|
|
906
|
+
const sliceDurationUs = resolved.outputRange.endUs - resolved.outputRange.startUs;
|
|
907
|
+
const filters: string[] = [];
|
|
908
|
+
let visibleDurationUs = sliceDurationUs;
|
|
909
|
+
if (overlay.source.kind === "gif" || overlay.source.kind === "video") {
|
|
910
|
+
const playback = overlay.source.playback;
|
|
911
|
+
const streamStartUs = playback.streamStartUs ?? 0;
|
|
912
|
+
const sourceWindowUs = playback.sourceOutUs - playback.sourceInUs;
|
|
913
|
+
const offsetSourceUs = Math.round(resolved.playbackOffsetUs * playback.playbackRate);
|
|
914
|
+
const requiredSourceUs = Math.ceil(sliceDurationUs * playback.playbackRate);
|
|
915
|
+
const requestedEndSourceUs = offsetSourceUs + requiredSourceUs;
|
|
916
|
+
if (
|
|
917
|
+
!Number.isSafeInteger(offsetSourceUs)
|
|
918
|
+
|| !Number.isSafeInteger(requiredSourceUs)
|
|
919
|
+
|| !Number.isSafeInteger(requestedEndSourceUs)
|
|
920
|
+
|| offsetSourceUs < 0
|
|
921
|
+
|| requiredSourceUs <= 0
|
|
922
|
+
) {
|
|
923
|
+
throw new CliError("unsupported-plan", `Overlay ${overlay.overlayId} has an unsupported playback duration.`);
|
|
924
|
+
}
|
|
925
|
+
const needsLoop = playback.endBehavior === "loop" && requestedEndSourceUs > sourceWindowUs;
|
|
926
|
+
const decodedSourceUs = Math.min(sourceWindowUs, requestedEndSourceUs);
|
|
927
|
+
const effectiveSourceOutUs = streamStartUs + playback.sourceInUs + decodedSourceUs;
|
|
928
|
+
if (!Number.isSafeInteger(effectiveSourceOutUs)) {
|
|
929
|
+
throw new CliError("unsupported-plan", `Overlay ${overlay.overlayId} has an unsupported source window.`);
|
|
930
|
+
}
|
|
931
|
+
filters.push(
|
|
932
|
+
`trim=start=${seconds(streamStartUs + playback.sourceInUs)}:end=${seconds(effectiveSourceOutUs)}`,
|
|
933
|
+
"setpts=PTS-STARTPTS",
|
|
934
|
+
`fps=${spatialCadence === undefined ? decimal(frameRate) : `${spatialCadence.cadence.frameRate.numerator}/${spatialCadence.cadence.frameRate.denominator}`}`,
|
|
935
|
+
);
|
|
936
|
+
if (needsLoop) {
|
|
937
|
+
const frameCount = spatialCadence === undefined
|
|
938
|
+
? Math.max(1, Math.ceil(sourceWindowUs * frameRate / 1_000_000))
|
|
939
|
+
: spatialFrameCount(sourceWindowUs, spatialCadence.cadence.frameRate);
|
|
940
|
+
if (frameCount > 1_000_000) throw new CliError("unsupported-plan", "Animated overlay loop exceeds the frame buffer bound.");
|
|
941
|
+
assertVideoLoopBufferWithinLimit({
|
|
942
|
+
frameCount,
|
|
943
|
+
label: `Overlay ${overlay.overlayId}`,
|
|
944
|
+
pixelHeight: overlay.intrinsicSize.height,
|
|
945
|
+
pixelWidth: overlay.intrinsicSize.width,
|
|
946
|
+
});
|
|
947
|
+
filters.push("format=rgba");
|
|
948
|
+
filters.push(`loop=loop=-1:size=${frameCount}:start=0`);
|
|
949
|
+
} else if (playback.endBehavior === "freeze-end") {
|
|
950
|
+
filters.push(`tpad=stop_mode=clone:stop_duration=${seconds(offsetSourceUs + requiredSourceUs)}`);
|
|
951
|
+
} else {
|
|
952
|
+
if (offsetSourceUs >= sourceWindowUs) return null;
|
|
953
|
+
visibleDurationUs = Math.min(sliceDurationUs, (sourceWindowUs - offsetSourceUs) / playback.playbackRate);
|
|
954
|
+
}
|
|
955
|
+
filters.push(
|
|
956
|
+
`trim=start=${seconds(offsetSourceUs)}:duration=${seconds(Math.min(requiredSourceUs, playback.endBehavior === "hide" ? sourceWindowUs - offsetSourceUs : requiredSourceUs))}`,
|
|
957
|
+
`setpts=(PTS-STARTPTS)/${decimal(playback.playbackRate)}+${seconds(resolved.outputRange.startUs)}/TB`,
|
|
958
|
+
);
|
|
959
|
+
} else {
|
|
960
|
+
filters.push(
|
|
961
|
+
`trim=duration=${seconds(sliceDurationUs)}`,
|
|
962
|
+
`setpts=PTS-STARTPTS+${seconds(resolved.outputRange.startUs)}/TB`,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
const { entranceUs, exitUs } = effectiveAnimationBounds(overlay, fullEndUs - fullStartUs);
|
|
966
|
+
filters.push(...overlayGeometryFilters(overlay, fullStartUs, fullEndUs, entranceUs, exitUs));
|
|
967
|
+
return { filters, visibleEndUs: resolved.outputRange.startUs + Math.round(visibleDurationUs) };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function overlayAudioChain(resolved: ResolvedProjectOverlay): {
|
|
971
|
+
readonly audibleEndUs: number;
|
|
972
|
+
readonly audibleStartUs: number;
|
|
973
|
+
readonly filters: readonly string[];
|
|
974
|
+
readonly loopDuck: {
|
|
975
|
+
readonly audioDelayUs: number;
|
|
976
|
+
readonly audioEndUs: number;
|
|
977
|
+
readonly sourceOffsetUs: number;
|
|
978
|
+
readonly sourceWindowUs: number;
|
|
979
|
+
} | null;
|
|
980
|
+
} | null {
|
|
981
|
+
const source = resolved.operation.source;
|
|
982
|
+
if (source.kind !== "video" || source.audioPolicy.kind === "mute") return null;
|
|
983
|
+
const playback = source.playback;
|
|
984
|
+
const streamStartUs = playback.streamStartUs ?? 0;
|
|
985
|
+
const audioDelayUs = Math.max(0, (playback.audioStartUs ?? streamStartUs) - (streamStartUs + playback.sourceInUs));
|
|
986
|
+
const sliceDurationUs = resolved.outputRange.endUs - resolved.outputRange.startUs;
|
|
987
|
+
const sourceWindowUs = playback.sourceOutUs - playback.sourceInUs;
|
|
988
|
+
const selectedSourceStartUs = streamStartUs + playback.sourceInUs;
|
|
989
|
+
const audioEndUs = Math.min(
|
|
990
|
+
sourceWindowUs,
|
|
991
|
+
Math.max(
|
|
992
|
+
0,
|
|
993
|
+
(playback.audioEndUs ?? selectedSourceStartUs + sourceWindowUs) - selectedSourceStartUs,
|
|
994
|
+
),
|
|
995
|
+
);
|
|
996
|
+
const offsetSourceUs = Math.round(resolved.playbackOffsetUs * playback.playbackRate);
|
|
997
|
+
if (playback.endBehavior === "hide" && offsetSourceUs >= sourceWindowUs) return null;
|
|
998
|
+
const requiredSourceUs = Math.ceil(sliceDurationUs * playback.playbackRate);
|
|
999
|
+
const requestedEndSourceUs = offsetSourceUs + requiredSourceUs;
|
|
1000
|
+
if (
|
|
1001
|
+
!Number.isSafeInteger(offsetSourceUs)
|
|
1002
|
+
|| !Number.isSafeInteger(requiredSourceUs)
|
|
1003
|
+
|| !Number.isSafeInteger(requestedEndSourceUs)
|
|
1004
|
+
|| offsetSourceUs < 0
|
|
1005
|
+
|| requiredSourceUs <= 0
|
|
1006
|
+
) {
|
|
1007
|
+
throw new CliError("unsupported-plan", `Overlay ${resolved.operation.overlayId} has an unsupported audio duration.`);
|
|
1008
|
+
}
|
|
1009
|
+
const needsLoop = playback.endBehavior === "loop" && requestedEndSourceUs > sourceWindowUs;
|
|
1010
|
+
const audibleSourceRange = resolveAudibleSourceRange({
|
|
1011
|
+
audioDelayUs,
|
|
1012
|
+
audioEndUs,
|
|
1013
|
+
endBehavior: playback.endBehavior,
|
|
1014
|
+
requestedDurationUs: requiredSourceUs,
|
|
1015
|
+
requestedStartUs: offsetSourceUs,
|
|
1016
|
+
sourceWindowUs,
|
|
1017
|
+
});
|
|
1018
|
+
if (audibleSourceRange === null) return null;
|
|
1019
|
+
const audibleStartUs = resolved.outputRange.startUs + Math.ceil(
|
|
1020
|
+
(audibleSourceRange.startUs - offsetSourceUs) / playback.playbackRate,
|
|
1021
|
+
);
|
|
1022
|
+
const audibleEndUs = Math.min(
|
|
1023
|
+
resolved.outputRange.endUs,
|
|
1024
|
+
resolved.outputRange.startUs + Math.floor(
|
|
1025
|
+
(audibleSourceRange.endUs - offsetSourceUs) / playback.playbackRate,
|
|
1026
|
+
),
|
|
1027
|
+
);
|
|
1028
|
+
if (audibleEndUs <= audibleStartUs) return null;
|
|
1029
|
+
const decodedSourceUs = Math.min(sourceWindowUs, requestedEndSourceUs);
|
|
1030
|
+
const effectiveSourceOutUs = streamStartUs + playback.sourceInUs + decodedSourceUs;
|
|
1031
|
+
if (!Number.isSafeInteger(effectiveSourceOutUs)) {
|
|
1032
|
+
throw new CliError("unsupported-plan", `Overlay ${resolved.operation.overlayId} has an unsupported audio source window.`);
|
|
1033
|
+
}
|
|
1034
|
+
const filters = [
|
|
1035
|
+
`atrim=start=${seconds(streamStartUs + playback.sourceInUs)}:end=${seconds(effectiveSourceOutUs)}`,
|
|
1036
|
+
"aresample=48000",
|
|
1037
|
+
"asetpts=PTS-STARTPTS",
|
|
1038
|
+
...(audioDelayUs === 0 ? [] : [`adelay=${decimal(audioDelayUs / 1_000)}:all=1`]),
|
|
1039
|
+
];
|
|
1040
|
+
if (needsLoop) {
|
|
1041
|
+
const samples = Math.max(1, Math.ceil(sourceWindowUs * 48_000 / 1_000_000));
|
|
1042
|
+
if (samples > 10_000_000) throw new CliError("unsupported-plan", "Animated overlay audio loop exceeds its sample buffer bound.");
|
|
1043
|
+
assertAudioLoopBufferWithinLimit({ label: `Overlay ${resolved.operation.overlayId}`, sampleCount: samples });
|
|
1044
|
+
if (audioEndUs < sourceWindowUs) {
|
|
1045
|
+
filters.push(`apad=pad_dur=${seconds(sourceWindowUs - audioEndUs)}`);
|
|
1046
|
+
}
|
|
1047
|
+
filters.push(`atrim=duration=${seconds(sourceWindowUs)}`);
|
|
1048
|
+
filters.push("aformat=sample_fmts=flt:channel_layouts=stereo");
|
|
1049
|
+
filters.push(`aloop=loop=-1:size=${samples}`);
|
|
1050
|
+
}
|
|
1051
|
+
const usableSourceUs = playback.endBehavior === "loop"
|
|
1052
|
+
? requiredSourceUs
|
|
1053
|
+
: Math.max(0, Math.min(requiredSourceUs, sourceWindowUs - offsetSourceUs));
|
|
1054
|
+
if (usableSourceUs === 0) return null;
|
|
1055
|
+
filters.push(
|
|
1056
|
+
`atrim=start=${seconds(offsetSourceUs)}:duration=${seconds(usableSourceUs)}`,
|
|
1057
|
+
atempo(playback.playbackRate),
|
|
1058
|
+
...audioPlacementFilters(resolved.outputRange.startUs),
|
|
1059
|
+
`volume=${decimal(source.audioPolicy.volume)}`,
|
|
1060
|
+
);
|
|
1061
|
+
return {
|
|
1062
|
+
audibleEndUs,
|
|
1063
|
+
audibleStartUs,
|
|
1064
|
+
filters,
|
|
1065
|
+
loopDuck: needsLoop && (audioDelayUs > 0 || audioEndUs < sourceWindowUs)
|
|
1066
|
+
? { audioDelayUs, audioEndUs, sourceOffsetUs: offsetSourceUs, sourceWindowUs }
|
|
1067
|
+
: null,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function overlayVisibleEndUs(overlay: OverlayOperation, startUs: number, endUs: number): number {
|
|
1072
|
+
const source = overlay.source;
|
|
1073
|
+
if ((source.kind !== "gif" && source.kind !== "video") || source.playback.endBehavior !== "hide") return endUs;
|
|
1074
|
+
const sourceWindowUs = source.playback.sourceOutUs - source.playback.sourceInUs;
|
|
1075
|
+
return Math.min(endUs, startUs + Math.round(sourceWindowUs / source.playback.playbackRate));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
type AudioSlice = ProjectRenderPlanV1["audioSlices"][number];
|
|
1079
|
+
|
|
1080
|
+
function audioSlicesAreContinuous(left: AudioSlice, right: AudioSlice): boolean {
|
|
1081
|
+
return left.placementId === right.placementId
|
|
1082
|
+
&& left.streamId === right.streamId
|
|
1083
|
+
&& left.outputRange.endUs === right.outputRange.startUs
|
|
1084
|
+
&& left.projectRange.endUs === right.projectRange.startUs
|
|
1085
|
+
&& left.assetRange.endUs === right.assetRange.startUs
|
|
1086
|
+
&& left.presentation.enabled
|
|
1087
|
+
&& right.presentation.enabled
|
|
1088
|
+
&& left.presentation.gainDb === right.presentation.gainDb
|
|
1089
|
+
&& left.presentation.pan === right.presentation.pan;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function audioFadeBoundaries(
|
|
1093
|
+
slices: readonly AudioSlice[],
|
|
1094
|
+
): ReadonlyMap<AudioSlice, Readonly<{ readonly fadeIn: boolean; readonly fadeOut: boolean }>> {
|
|
1095
|
+
const groups = new Map<string, AudioSlice[]>();
|
|
1096
|
+
for (const slice of slices) {
|
|
1097
|
+
const key = `${slice.placementId}\u0000${slice.streamId}`;
|
|
1098
|
+
const group = groups.get(key) ?? [];
|
|
1099
|
+
group.push(slice);
|
|
1100
|
+
groups.set(key, group);
|
|
1101
|
+
}
|
|
1102
|
+
const boundaries = new Map<AudioSlice, { readonly fadeIn: boolean; readonly fadeOut: boolean }>();
|
|
1103
|
+
for (const group of groups.values()) {
|
|
1104
|
+
group.sort((left, right) => (
|
|
1105
|
+
left.outputRange.startUs - right.outputRange.startUs
|
|
1106
|
+
|| left.projectRange.startUs - right.projectRange.startUs
|
|
1107
|
+
|| left.assetRange.startUs - right.assetRange.startUs
|
|
1108
|
+
));
|
|
1109
|
+
for (const [index, slice] of group.entries()) {
|
|
1110
|
+
const previous = group[index - 1];
|
|
1111
|
+
const next = group[index + 1];
|
|
1112
|
+
boundaries.set(slice, {
|
|
1113
|
+
fadeIn: previous === undefined || !audioSlicesAreContinuous(previous, slice),
|
|
1114
|
+
fadeOut: next === undefined || !audioSlicesAreContinuous(slice, next),
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return boundaries;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export interface ProjectRenderBuildOptions {
|
|
1122
|
+
readonly dryRun?: boolean;
|
|
1123
|
+
readonly ffmpeg: string;
|
|
1124
|
+
readonly ffprobe?: string;
|
|
1125
|
+
readonly outputPath: string;
|
|
1126
|
+
readonly projectDirectory: string;
|
|
1127
|
+
/** Versioned host-owned encoder recipe. Omitted only for v1 compatibility. */
|
|
1128
|
+
readonly renderTier?: ProjectRenderTier;
|
|
1129
|
+
readonly repositoryRoot: string;
|
|
1130
|
+
readonly rsvgConvert?: string;
|
|
1131
|
+
readonly rsvgConvertVersion?: string;
|
|
1132
|
+
readonly runner?: ProcessRunner;
|
|
1133
|
+
/** Host-owned private root for filter graphs and SVG derivatives. */
|
|
1134
|
+
readonly workspaceDirectory?: string;
|
|
1135
|
+
/** V2 projection's versioned exact cadence; V1 numeric planning hashes retain their meaning. */
|
|
1136
|
+
readonly spatialCadence?: SpatialCompositorCadenceBindingV1;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function projectRenderEncoderArguments(
|
|
1140
|
+
recipe: ProjectRenderEncoderRecipe | undefined,
|
|
1141
|
+
): readonly string[] {
|
|
1142
|
+
return recipe === undefined
|
|
1143
|
+
? []
|
|
1144
|
+
: [
|
|
1145
|
+
"-threads:v", String(recipe.video.threads),
|
|
1146
|
+
"-preset", recipe.video.preset,
|
|
1147
|
+
"-crf", String(recipe.video.crf),
|
|
1148
|
+
"-b:a", recipe.audio.bitrate,
|
|
1149
|
+
];
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function projectRenderFilterThreadArguments(
|
|
1153
|
+
recipe: ProjectRenderEncoderRecipe | undefined,
|
|
1154
|
+
): readonly string[] {
|
|
1155
|
+
return recipe === undefined
|
|
1156
|
+
? []
|
|
1157
|
+
: [
|
|
1158
|
+
"-filter_threads", String(recipe.filterThreads),
|
|
1159
|
+
"-filter_complex_threads", String(recipe.filterComplexThreads),
|
|
1160
|
+
];
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function projectRenderDecoderThreadArguments(
|
|
1164
|
+
recipe: ProjectRenderEncoderRecipe | undefined,
|
|
1165
|
+
): readonly string[] {
|
|
1166
|
+
return recipe === undefined
|
|
1167
|
+
? []
|
|
1168
|
+
: ["-threads", String(recipe.decoderThreads)];
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export interface ProjectRenderPinnedInput extends ExpectedProjectMediaIntegrity {
|
|
1172
|
+
readonly label: string;
|
|
1173
|
+
readonly path: string;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export interface BuiltProjectRenderInvocation {
|
|
1177
|
+
readonly argv: readonly [string, ...string[]];
|
|
1178
|
+
readonly invocation: ProjectRenderInvocation;
|
|
1179
|
+
readonly pinnedInputs: readonly ProjectRenderPinnedInput[];
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
export async function reverifyProjectRenderInputs(
|
|
1183
|
+
inputs: readonly ProjectRenderPinnedInput[],
|
|
1184
|
+
): Promise<void> {
|
|
1185
|
+
await Promise.all(inputs.map(async input => {
|
|
1186
|
+
await verifyPhysicalProjectMedia(input.path, input, input.label);
|
|
1187
|
+
}));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
export async function buildProjectFfmpegInvocation(
|
|
1191
|
+
plan: ProjectRenderPlanV1,
|
|
1192
|
+
options: ProjectRenderBuildOptions,
|
|
1193
|
+
): Promise<BuiltProjectRenderInvocation> {
|
|
1194
|
+
if (plan.output.durationUs <= 0) throw new CliError("unsupported-plan", "A zero-duration project cannot render.");
|
|
1195
|
+
const spatialCadence = options.spatialCadence === undefined ? undefined : assertSpatialCompositorCadence(options.spatialCadence, plan);
|
|
1196
|
+
const rationalRate = spatialCadence === undefined ? undefined : `${spatialCadence.cadence.frameRate.numerator}/${spatialCadence.cadence.frameRate.denominator}`;
|
|
1197
|
+
const emittedRate = rationalRate ?? decimal(plan.output.frameRate);
|
|
1198
|
+
const projectRoot = await realpath(options.projectDirectory);
|
|
1199
|
+
const output = resolve(options.outputPath);
|
|
1200
|
+
if (!isWithin(projectRoot, output)) throw new CliError("unsafe-path", "Project render output must remain in its project directory.");
|
|
1201
|
+
const outputRelative = relative(projectRoot, output);
|
|
1202
|
+
if (!outputRelative.startsWith("renders/") || isAbsolute(outputRelative)) {
|
|
1203
|
+
throw new CliError("unsafe-path", "Project render output must remain under renders/.");
|
|
1204
|
+
}
|
|
1205
|
+
const encoderRecipe = options.renderTier === undefined
|
|
1206
|
+
? undefined
|
|
1207
|
+
: resolveProjectRenderEncoderRecipe(options.renderTier);
|
|
1208
|
+
|
|
1209
|
+
const mediaIntegrity = new Map<string, ExpectedProjectMediaIntegrity>();
|
|
1210
|
+
for (const slice of [...plan.videoSlices, ...plan.audioSlices]) {
|
|
1211
|
+
const expected = { bytes: slice.bytes, sha256: slice.sha256 };
|
|
1212
|
+
const prior = mediaIntegrity.get(slice.path);
|
|
1213
|
+
if (prior !== undefined && (prior.bytes !== expected.bytes || prior.sha256 !== expected.sha256)) {
|
|
1214
|
+
throw new CliError("invalid-data", `Render slices disagree about media integrity for ${slice.path}.`);
|
|
1215
|
+
}
|
|
1216
|
+
mediaIntegrity.set(slice.path, expected);
|
|
1217
|
+
}
|
|
1218
|
+
const mediaInputIndex = new Map<string, number>();
|
|
1219
|
+
const inputArguments: string[] = [];
|
|
1220
|
+
const pinnedInputs: ProjectRenderPinnedInput[] = [];
|
|
1221
|
+
for (const [path, expected] of mediaIntegrity) {
|
|
1222
|
+
mediaInputIndex.set(path, mediaInputIndex.size);
|
|
1223
|
+
const spatialOutput = spatialCadence === undefined ? null : /^spatial\/outputs\/([a-f0-9]{64})\.(?:mov|mp4|webm)$/u.exec(path);
|
|
1224
|
+
if (spatialOutput !== null && spatialOutput[1] !== expected.sha256) throw new CliError("invalid-data", "Spatial materialized media filename does not match its exact payload digest.");
|
|
1225
|
+
const physical = await resolveVerifiedProjectMedia({
|
|
1226
|
+
expected,
|
|
1227
|
+
label: `Project media ${path}`,
|
|
1228
|
+
path,
|
|
1229
|
+
repositoryRoot: spatialOutput === null ? options.repositoryRoot : projectRoot,
|
|
1230
|
+
});
|
|
1231
|
+
inputArguments.push(
|
|
1232
|
+
...projectRenderDecoderThreadArguments(encoderRecipe),
|
|
1233
|
+
"-i",
|
|
1234
|
+
physical,
|
|
1235
|
+
);
|
|
1236
|
+
pinnedInputs.push({
|
|
1237
|
+
...expected,
|
|
1238
|
+
label: `Project media ${path}`,
|
|
1239
|
+
path: physical,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const overlayInputIndex = new Map<string, number>();
|
|
1243
|
+
const preparedOverlayMedia = new Map<string, PreparedProjectOverlayMedia>();
|
|
1244
|
+
for (const resolvedOverlay of plan.overlays) {
|
|
1245
|
+
const overlay = resolvedOverlay.operation;
|
|
1246
|
+
if (overlayInputIndex.has(overlay.overlayId)) continue;
|
|
1247
|
+
overlayInputIndex.set(overlay.overlayId, mediaInputIndex.size + overlayInputIndex.size);
|
|
1248
|
+
if (overlay.source.kind === "image" || overlay.source.kind === "svg" || overlay.source.kind === "emoji") {
|
|
1249
|
+
inputArguments.push("-loop", "1");
|
|
1250
|
+
}
|
|
1251
|
+
const prepared = await prepareProjectOverlayMedia(overlay, options);
|
|
1252
|
+
preparedOverlayMedia.set(overlay.overlayId, prepared);
|
|
1253
|
+
inputArguments.push(
|
|
1254
|
+
...projectRenderDecoderThreadArguments(encoderRecipe),
|
|
1255
|
+
"-i",
|
|
1256
|
+
prepared.path,
|
|
1257
|
+
);
|
|
1258
|
+
pinnedInputs.push({
|
|
1259
|
+
bytes: overlay.source.asset.bytes,
|
|
1260
|
+
label: `Overlay ${overlay.overlayId}`,
|
|
1261
|
+
path: prepared.sourcePath,
|
|
1262
|
+
sha256: overlay.source.asset.sha256,
|
|
1263
|
+
});
|
|
1264
|
+
if (prepared.path !== prepared.sourcePath && !options.dryRun) {
|
|
1265
|
+
const derivative = await fingerprintPhysicalProjectMedia(
|
|
1266
|
+
prepared.path,
|
|
1267
|
+
MAXIMUM_SVG_DERIVATIVE_BYTES,
|
|
1268
|
+
);
|
|
1269
|
+
pinnedInputs.push({
|
|
1270
|
+
bytes: derivative.bytes,
|
|
1271
|
+
label: `Prepared overlay ${overlay.overlayId}`,
|
|
1272
|
+
path: derivative.path,
|
|
1273
|
+
sha256: derivative.sha256,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const filters: string[] = [
|
|
1279
|
+
`color=c=${plan.output.background}:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[canvas_0]`,
|
|
1280
|
+
];
|
|
1281
|
+
const cameraIndex = buildProjectCameraSpatialIndex(plan);
|
|
1282
|
+
let currentVideo = "canvas_0";
|
|
1283
|
+
let serial = 0;
|
|
1284
|
+
for (const slice of plan.videoSlices) {
|
|
1285
|
+
if (!slice.presentation.enabled) continue;
|
|
1286
|
+
const input = mediaInputIndex.get(slice.path)!;
|
|
1287
|
+
const label = `project_video_${serial++}`;
|
|
1288
|
+
const inputDurationUs = slice.fileRange.endUs - slice.fileRange.startUs;
|
|
1289
|
+
const outputDurationUs = slice.outputRange.endUs - slice.outputRange.startUs;
|
|
1290
|
+
const transform = videoTransform(slice, plan.output.pixelWidth, plan.output.pixelHeight);
|
|
1291
|
+
const chain = [
|
|
1292
|
+
`trim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)}`,
|
|
1293
|
+
// A source may use a coarse clock such as 1/24. Apply project offsets in
|
|
1294
|
+
// microseconds so a fractional cut cannot truncate away a whole frame.
|
|
1295
|
+
"settb=AVTB",
|
|
1296
|
+
`setpts=(PTS-STARTPTS)*${decimal(outputDurationUs / inputDurationUs)}+${seconds(slice.outputRange.startUs)}/TB`,
|
|
1297
|
+
...transform.filters,
|
|
1298
|
+
...projectCameraFilters(plan, slice, cameraIndex, rationalRate),
|
|
1299
|
+
];
|
|
1300
|
+
filters.push(`[${inputSpecifier(input, slice.streamIndex)}]${chain.join(",")}[${label}]`);
|
|
1301
|
+
const next = `canvas_${serial++}`;
|
|
1302
|
+
// EOF is the last decoded PTS, not the end of that frame's presentation.
|
|
1303
|
+
// Hold its pixels only inside the compiler's declared half-open interval.
|
|
1304
|
+
const enable = `gte(t,${seconds(slice.outputRange.startUs)})*lt(t,${seconds(slice.outputRange.endUs)})`;
|
|
1305
|
+
if (slice.presentation.blendMode === "normal") {
|
|
1306
|
+
filters.push(
|
|
1307
|
+
`[${currentVideo}][${label}]overlay=x=${transform.x}:y=${transform.y}:eof_action=repeat:repeatlast=1:enable='${enable}'[${next}]`,
|
|
1308
|
+
);
|
|
1309
|
+
} else {
|
|
1310
|
+
const transparent = `video_blend_canvas_${serial++}`;
|
|
1311
|
+
const positioned = `video_blend_positioned_${serial++}`;
|
|
1312
|
+
const layerColor = `video_blend_color_${serial++}`;
|
|
1313
|
+
const layerAlphaSource = `video_blend_alpha_source_${serial++}`;
|
|
1314
|
+
const layerMask = `video_blend_mask_${serial++}`;
|
|
1315
|
+
const baseBlend = `video_blend_base_${serial++}`;
|
|
1316
|
+
const baseMerge = `video_blend_merge_${serial++}`;
|
|
1317
|
+
const blended = `video_blend_result_${serial++}`;
|
|
1318
|
+
filters.push(
|
|
1319
|
+
`color=c=black@0:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[${transparent}]`,
|
|
1320
|
+
`[${transparent}][${label}]overlay=x=${transform.x}:y=${transform.y}:eof_action=repeat:repeatlast=1:enable='${enable}'[${positioned}]`,
|
|
1321
|
+
`[${positioned}]format=rgba,split=2[${layerColor}][${layerAlphaSource}]`,
|
|
1322
|
+
`[${layerAlphaSource}]alphaextract[${layerMask}]`,
|
|
1323
|
+
`[${currentVideo}]split=2[${baseBlend}][${baseMerge}]`,
|
|
1324
|
+
`[${baseBlend}][${layerColor}]blend=all_mode=${slice.presentation.blendMode}[${blended}]`,
|
|
1325
|
+
`[${baseMerge}][${blended}][${layerMask}]maskedmerge[${next}]`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
currentVideo = next;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
({ currentVideo, serial } = applyMetadataEffects({
|
|
1332
|
+
cameraKeyframes: [],
|
|
1333
|
+
effects: plan.effects,
|
|
1334
|
+
output: plan.output,
|
|
1335
|
+
}, filters, currentVideo, serial, spatialCadence?.cadence.frameRate));
|
|
1336
|
+
|
|
1337
|
+
const orderedOverlays = [...plan.overlays].sort((left, right) => (
|
|
1338
|
+
left.operation.zIndex - right.operation.zIndex
|
|
1339
|
+
|| left.outputRange.startUs - right.outputRange.startUs
|
|
1340
|
+
|| left.operation.overlayId.localeCompare(right.operation.overlayId)
|
|
1341
|
+
));
|
|
1342
|
+
const overlayBounds = new Map<string, { readonly endUs: number; readonly startUs: number }>();
|
|
1343
|
+
for (const resolvedOverlay of orderedOverlays) {
|
|
1344
|
+
const prior = overlayBounds.get(resolvedOverlay.operation.overlayId);
|
|
1345
|
+
overlayBounds.set(resolvedOverlay.operation.overlayId, {
|
|
1346
|
+
endUs: Math.max(prior?.endUs ?? 0, resolvedOverlay.outputRange.endUs),
|
|
1347
|
+
startUs: Math.min(prior?.startUs ?? Number.MAX_SAFE_INTEGER, resolvedOverlay.outputRange.startUs),
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
for (const resolvedOverlay of orderedOverlays) {
|
|
1351
|
+
const bounds = overlayBounds.get(resolvedOverlay.operation.overlayId)!;
|
|
1352
|
+
const visibleFullEndUs = overlayVisibleEndUs(resolvedOverlay.operation, bounds.startUs, bounds.endUs);
|
|
1353
|
+
const chain = overlayVideoChain(resolvedOverlay, bounds.startUs, visibleFullEndUs, plan.output.frameRate, spatialCadence);
|
|
1354
|
+
if (chain === null) continue;
|
|
1355
|
+
const input = overlayInputIndex.get(resolvedOverlay.operation.overlayId)!;
|
|
1356
|
+
const label = `graphic_${serial++}`;
|
|
1357
|
+
const source = resolvedOverlay.operation.source;
|
|
1358
|
+
const prepared = preparedOverlayMedia.get(resolvedOverlay.operation.overlayId)!;
|
|
1359
|
+
const videoStreamIndex = source.kind === "gif" || source.kind === "video"
|
|
1360
|
+
? prepared.videoStreamIndex ?? source.playback.videoStreamIndex
|
|
1361
|
+
: null;
|
|
1362
|
+
filters.push(`[${animatedStreamSpecifier(input, "v", videoStreamIndex)}]${chain.filters.join(",")}[${label}]`);
|
|
1363
|
+
const overlay = resolvedOverlay.operation;
|
|
1364
|
+
const animations = effectiveAnimationBounds(overlay, visibleFullEndUs - bounds.startUs);
|
|
1365
|
+
const x = overlayPositionExpression(overlay, "x", bounds.startUs, visibleFullEndUs, animations.entranceUs, animations.exitUs);
|
|
1366
|
+
const y = overlayPositionExpression(overlay, "y", bounds.startUs, visibleFullEndUs, animations.entranceUs, animations.exitUs);
|
|
1367
|
+
const next = `canvas_${serial++}`;
|
|
1368
|
+
const enable = `between(t,${seconds(resolvedOverlay.outputRange.startUs)},${seconds(chain.visibleEndUs)})`;
|
|
1369
|
+
const blendMode = overlay.blendMode ?? "normal";
|
|
1370
|
+
if (blendMode === "normal") {
|
|
1371
|
+
filters.push(
|
|
1372
|
+
`[${currentVideo}][${label}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${next}]`,
|
|
1373
|
+
);
|
|
1374
|
+
} else {
|
|
1375
|
+
const transparent = `graphic_blend_canvas_${serial++}`;
|
|
1376
|
+
const positioned = `graphic_blend_positioned_${serial++}`;
|
|
1377
|
+
const layerColor = `graphic_blend_color_${serial++}`;
|
|
1378
|
+
const layerAlphaSource = `graphic_blend_alpha_source_${serial++}`;
|
|
1379
|
+
const layerMask = `graphic_blend_mask_${serial++}`;
|
|
1380
|
+
const baseBlend = `graphic_blend_base_${serial++}`;
|
|
1381
|
+
const baseMerge = `graphic_blend_merge_${serial++}`;
|
|
1382
|
+
const blended = `graphic_blend_result_${serial++}`;
|
|
1383
|
+
filters.push(
|
|
1384
|
+
`color=c=black@0:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[${transparent}]`,
|
|
1385
|
+
`[${transparent}][${label}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${positioned}]`,
|
|
1386
|
+
`[${positioned}]format=rgba,split=2[${layerColor}][${layerAlphaSource}]`,
|
|
1387
|
+
`[${layerAlphaSource}]alphaextract[${layerMask}]`,
|
|
1388
|
+
`[${currentVideo}]split=2[${baseBlend}][${baseMerge}]`,
|
|
1389
|
+
`[${baseBlend}][${layerColor}]blend=all_mode=${blendMode}[${blended}]`,
|
|
1390
|
+
`[${baseMerge}][${blended}][${layerMask}]maskedmerge[${next}]`,
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
currentVideo = next;
|
|
1394
|
+
}
|
|
1395
|
+
filters.push(spatialCadence === undefined
|
|
1396
|
+
? `[${currentVideo}]format=yuv420p[video_out]`
|
|
1397
|
+
: `[${currentVideo}]trim=end_frame=${spatialCadence.cadence.frameCount},settb=expr=${spatialCadence.cadence.frameRate.denominator}/${spatialCadence.cadence.frameRate.numerator},setpts=N,format=yuv420p[video_out]`);
|
|
1398
|
+
|
|
1399
|
+
const audioLabels: string[] = [];
|
|
1400
|
+
const fadeBoundaries = audioFadeBoundaries(plan.audioSlices);
|
|
1401
|
+
for (const slice of plan.audioSlices) {
|
|
1402
|
+
if (!slice.presentation.enabled) continue;
|
|
1403
|
+
const input = mediaInputIndex.get(slice.path)!;
|
|
1404
|
+
const label = `project_audio_${serial++}`;
|
|
1405
|
+
const inputDurationUs = slice.fileRange.endUs - slice.fileRange.startUs;
|
|
1406
|
+
const outputDurationUs = slice.outputRange.endUs - slice.outputRange.startUs;
|
|
1407
|
+
const playbackRate = inputDurationUs / outputDurationUs;
|
|
1408
|
+
const fadeUs = Math.min(5_000, Math.floor(outputDurationUs / 4));
|
|
1409
|
+
const boundary = fadeBoundaries.get(slice) ?? { fadeIn: true, fadeOut: true };
|
|
1410
|
+
const chain = [
|
|
1411
|
+
`atrim=start=${seconds(slice.fileRange.startUs)}:end=${seconds(slice.fileRange.endUs)}`,
|
|
1412
|
+
"asetpts=PTS-STARTPTS",
|
|
1413
|
+
"aresample=48000",
|
|
1414
|
+
atempo(playbackRate),
|
|
1415
|
+
`atrim=duration=${seconds(outputDurationUs)}`,
|
|
1416
|
+
...(fadeUs > 0 && boundary.fadeIn ? [`afade=t=in:st=0:d=${seconds(fadeUs)}`] : []),
|
|
1417
|
+
...(fadeUs > 0 && boundary.fadeOut
|
|
1418
|
+
? [`afade=t=out:st=${seconds(outputDurationUs - fadeUs)}:d=${seconds(fadeUs)}`]
|
|
1419
|
+
: []),
|
|
1420
|
+
`volume=${decimal(slice.presentation.gainDb)}dB`,
|
|
1421
|
+
"aformat=channel_layouts=stereo",
|
|
1422
|
+
...(slice.presentation.pan === 0 ? [] : [`stereotools=balance_out=${decimal(slice.presentation.pan)}`]),
|
|
1423
|
+
...audioPlacementFilters(slice.outputRange.startUs),
|
|
1424
|
+
];
|
|
1425
|
+
filters.push(`[${inputSpecifier(input, slice.streamIndex)}]${chain.join(",")}[${label}]`);
|
|
1426
|
+
audioLabels.push(label);
|
|
1427
|
+
}
|
|
1428
|
+
filters.push(`anullsrc=r=48000:cl=stereo:d=${seconds(plan.output.durationUs)}[silence]`);
|
|
1429
|
+
const primaryAudio = "primary_audio";
|
|
1430
|
+
filters.push(
|
|
1431
|
+
`[silence]${audioLabels.map(label => `[${label}]`).join("")}amix=inputs=${audioLabels.length + 1}:duration=longest:dropout_transition=0:normalize=0,atrim=duration=${seconds(plan.output.durationUs)}[${primaryAudio}]`,
|
|
1432
|
+
);
|
|
1433
|
+
const overlayAudioLabels: string[] = [];
|
|
1434
|
+
const duckEnvelopes: { readonly condition: string; readonly target: number }[] = [];
|
|
1435
|
+
for (const resolvedOverlay of orderedOverlays) {
|
|
1436
|
+
const overlayAudio = overlayAudioChain(resolvedOverlay);
|
|
1437
|
+
if (overlayAudio === null) continue;
|
|
1438
|
+
const input = overlayInputIndex.get(resolvedOverlay.operation.overlayId)!;
|
|
1439
|
+
const label = `graphic_audio_${serial++}`;
|
|
1440
|
+
const source = resolvedOverlay.operation.source;
|
|
1441
|
+
const prepared = preparedOverlayMedia.get(resolvedOverlay.operation.overlayId)!;
|
|
1442
|
+
const audioStreamIndex = source.kind === "video"
|
|
1443
|
+
? prepared.audioStreamIndex ?? source.playback.audioStreamIndex
|
|
1444
|
+
: null;
|
|
1445
|
+
filters.push(`[${animatedStreamSpecifier(input, "a", audioStreamIndex)}]${overlayAudio.filters.join(",")}[${label}]`);
|
|
1446
|
+
if (source.kind === "video" && source.audioPolicy.kind === "duck-primary") {
|
|
1447
|
+
const duckCondition = overlayAudio.loopDuck === null
|
|
1448
|
+
? `between(t,${seconds(overlayAudio.audibleStartUs)},${seconds(overlayAudio.audibleEndUs)})`
|
|
1449
|
+
: [
|
|
1450
|
+
`between(t,${seconds(resolvedOverlay.outputRange.startUs)},${seconds(resolvedOverlay.outputRange.endUs)})`,
|
|
1451
|
+
`gte(mod(${seconds(overlayAudio.loopDuck.sourceOffsetUs)}+(t-${seconds(resolvedOverlay.outputRange.startUs)})*${decimal(source.playback.playbackRate)},${seconds(overlayAudio.loopDuck.sourceWindowUs)}),${seconds(overlayAudio.loopDuck.audioDelayUs)})`,
|
|
1452
|
+
`lt(mod(${seconds(overlayAudio.loopDuck.sourceOffsetUs)}+(t-${seconds(resolvedOverlay.outputRange.startUs)})*${decimal(source.playback.playbackRate)},${seconds(overlayAudio.loopDuck.sourceWindowUs)}),${seconds(overlayAudio.loopDuck.audioEndUs)})`,
|
|
1453
|
+
].join("*");
|
|
1454
|
+
duckEnvelopes.push({ condition: duckCondition, target: source.audioPolicy.duckPrimaryTo });
|
|
1455
|
+
}
|
|
1456
|
+
overlayAudioLabels.push(label);
|
|
1457
|
+
}
|
|
1458
|
+
let currentPrimaryAudio = primaryAudio;
|
|
1459
|
+
if (duckEnvelopes.length > 0) {
|
|
1460
|
+
const ducked = `ducked_${serial++}`;
|
|
1461
|
+
const duckVolume = duckEnvelopes
|
|
1462
|
+
.map(envelope => `if(${envelope.condition},${decimal(envelope.target)},1)`)
|
|
1463
|
+
.reduce((left, right) => `min(${left},${right})`);
|
|
1464
|
+
filters.push(`[${primaryAudio}]volume='${duckVolume}':eval=frame[${ducked}]`);
|
|
1465
|
+
currentPrimaryAudio = ducked;
|
|
1466
|
+
}
|
|
1467
|
+
let currentAudio = currentPrimaryAudio;
|
|
1468
|
+
if (overlayAudioLabels.length > 0) {
|
|
1469
|
+
const mixed = `mixed_${serial++}`;
|
|
1470
|
+
filters.push(
|
|
1471
|
+
`[${currentPrimaryAudio}]${overlayAudioLabels.map(label => `[${label}]`).join("")}amix=inputs=${overlayAudioLabels.length + 1}:duration=longest:dropout_transition=0:normalize=0[${mixed}]`,
|
|
1472
|
+
);
|
|
1473
|
+
currentAudio = mixed;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (spatialCadence !== undefined) {
|
|
1477
|
+
const boundedAudio = `spatial_audio_${serial++}`;
|
|
1478
|
+
filters.push(`[${currentAudio}]atrim=duration=${seconds(plan.output.durationUs)}[${boundedAudio}]`);
|
|
1479
|
+
currentAudio = boundedAudio;
|
|
1480
|
+
}
|
|
1481
|
+
const filterGraph = await materializeFilterScript({
|
|
1482
|
+
graph: filters.join(";"),
|
|
1483
|
+
relativeDirectory: options.workspaceDirectory === undefined
|
|
1484
|
+
? "renders/.filter-graphs"
|
|
1485
|
+
: "filter-graphs",
|
|
1486
|
+
root: options.workspaceDirectory ?? projectRoot,
|
|
1487
|
+
});
|
|
1488
|
+
const arguments_: string[] = [
|
|
1489
|
+
"-hide_banner", "-nostdin", "-y",
|
|
1490
|
+
...projectRenderFilterThreadArguments(encoderRecipe),
|
|
1491
|
+
...inputArguments,
|
|
1492
|
+
"-filter_complex_script", filterGraph.path,
|
|
1493
|
+
"-map", "[video_out]",
|
|
1494
|
+
"-map", `[${currentAudio}]`,
|
|
1495
|
+
"-c:v", encoderRecipe?.video.codec ?? "libx264",
|
|
1496
|
+
...projectRenderEncoderArguments(encoderRecipe),
|
|
1497
|
+
"-pix_fmt", encoderRecipe?.video.pixelFormat ?? "yuv420p",
|
|
1498
|
+
"-c:a", encoderRecipe?.audio.codec ?? "aac",
|
|
1499
|
+
...(spatialCadence === undefined ? [] : [
|
|
1500
|
+
"-r:v", rationalRate!, "-fps_mode:v", "cfr",
|
|
1501
|
+
"-enc_time_base:v", `${spatialCadence.cadence.frameRate.denominator}:${spatialCadence.cadence.frameRate.numerator}`,
|
|
1502
|
+
"-video_track_timescale", String(spatialCadence.cadence.frameRate.numerator),
|
|
1503
|
+
// The default 1-kHz MOV edit-list clock would truncate sub-ms audio tails.
|
|
1504
|
+
"-movie_timescale", "1000000",
|
|
1505
|
+
]),
|
|
1506
|
+
...(spatialCadence === undefined ? ["-t", seconds(plan.output.durationUs)] : []),
|
|
1507
|
+
"-movflags", encoderRecipe?.container.movflags ?? "+faststart",
|
|
1508
|
+
output,
|
|
1509
|
+
];
|
|
1510
|
+
const invocation = ProjectRenderInvocationSchema.parse({
|
|
1511
|
+
arguments: arguments_,
|
|
1512
|
+
executable: "ffmpeg",
|
|
1513
|
+
filterGraph: {
|
|
1514
|
+
bytes: filterGraph.bytes,
|
|
1515
|
+
path: filterGraph.repositoryPath,
|
|
1516
|
+
sha256: filterGraph.sha256,
|
|
1517
|
+
},
|
|
1518
|
+
outputPath: outputRelative,
|
|
1519
|
+
renderPlanSha256: canonicalJsonSha256(plan),
|
|
1520
|
+
});
|
|
1521
|
+
return {
|
|
1522
|
+
argv: [options.ffmpeg, ...arguments_],
|
|
1523
|
+
invocation,
|
|
1524
|
+
pinnedInputs,
|
|
1525
|
+
};
|
|
1526
|
+
}
|