@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,1439 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
RenderInvocationSchema,
|
|
6
|
+
type OverlayOperation,
|
|
7
|
+
type RecordingManifestV1,
|
|
8
|
+
type RenderInvocation,
|
|
9
|
+
type RenderPlanV1,
|
|
10
|
+
} from "../contracts";
|
|
11
|
+
import { canonicalJsonSha256 } from "../core";
|
|
12
|
+
import { probeVisualMediaSummary } from "./analyzer";
|
|
13
|
+
import { inspectPngIntrinsicSize, inspectSvgIntrinsicSize } from "./asset-ingest";
|
|
14
|
+
import { CliError } from "./errors";
|
|
15
|
+
import { MAXIMUM_FILTER_GRAPH_BYTES, materializeFilterScript } from "./filter-script";
|
|
16
|
+
import type { ProcessRunner } from "./io";
|
|
17
|
+
import {
|
|
18
|
+
assertAudioLoopBufferWithinLimit,
|
|
19
|
+
assertVideoLoopBufferWithinLimit,
|
|
20
|
+
resolveAudibleSourceRange,
|
|
21
|
+
} from "./overlay-playback";
|
|
22
|
+
import { ensurePrivateDirectory, resolveSafePath } from "./paths";
|
|
23
|
+
import {
|
|
24
|
+
fingerprintPhysicalProjectMedia,
|
|
25
|
+
resolveVerifiedProjectMedia,
|
|
26
|
+
} from "./project-media-integrity";
|
|
27
|
+
|
|
28
|
+
const MAX_DERIVED_OVERLAY_BYTES = 512 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
function seconds(microseconds: number): string {
|
|
31
|
+
return (microseconds / 1_000_000).toFixed(6).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function decimal(value: number): string {
|
|
35
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(8).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function inputSpecifier(index: number, streamIndex: number): string {
|
|
39
|
+
return `${index}:${streamIndex}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function animatedStreamSpecifier(
|
|
43
|
+
inputIndex: number,
|
|
44
|
+
streamType: "a" | "v",
|
|
45
|
+
absoluteStreamIndex: number | null | undefined,
|
|
46
|
+
): string {
|
|
47
|
+
return absoluteStreamIndex === null || absoluteStreamIndex === undefined
|
|
48
|
+
? `${inputIndex}:${streamType}:0`
|
|
49
|
+
: inputSpecifier(inputIndex, absoluteStreamIndex);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function atempo(rate: number): string {
|
|
53
|
+
const filters: string[] = [];
|
|
54
|
+
let remaining = rate;
|
|
55
|
+
while (remaining > 100) {
|
|
56
|
+
filters.push("atempo=100");
|
|
57
|
+
remaining /= 100;
|
|
58
|
+
}
|
|
59
|
+
while (remaining < 0.5) {
|
|
60
|
+
filters.push("atempo=0.5");
|
|
61
|
+
remaining /= 0.5;
|
|
62
|
+
}
|
|
63
|
+
filters.push(`atempo=${decimal(remaining)}`);
|
|
64
|
+
return filters.join(",");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sourceSpeed(plan: RenderPlanV1, startUs: number, endUs: number): number {
|
|
68
|
+
const mapping = plan.timeMap.find(({ source }) => source.startUs <= startUs && source.endUs >= endUs);
|
|
69
|
+
if (mapping === undefined) throw new CliError("invalid-data", `Render source range ${startUs}..${endUs} has no time map.`);
|
|
70
|
+
return mapping.speed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function localTimes(
|
|
74
|
+
manifest: RecordingManifestV1,
|
|
75
|
+
segment: RenderPlanV1["sourceSegments"][number],
|
|
76
|
+
): readonly [number, number] {
|
|
77
|
+
const source = manifestSourceSegment(manifest, segment);
|
|
78
|
+
return [
|
|
79
|
+
source.fileRange.startUs + segment.source.startUs - source.startUs,
|
|
80
|
+
source.fileRange.startUs + segment.source.endUs - source.startUs,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function manifestSourceSegment(
|
|
85
|
+
manifest: RecordingManifestV1,
|
|
86
|
+
segment: RenderPlanV1["sourceSegments"][number],
|
|
87
|
+
): RecordingManifestV1["tracks"][number]["segments"][number] {
|
|
88
|
+
const track = manifest.tracks.find(({ trackId }) => trackId === segment.trackId);
|
|
89
|
+
const source = track?.segments.find((candidate) =>
|
|
90
|
+
candidate.path === segment.path
|
|
91
|
+
&& candidate.streamIndex === segment.streamIndex
|
|
92
|
+
&& candidate.startUs <= segment.source.startUs
|
|
93
|
+
&& candidate.endUs >= segment.source.endUs
|
|
94
|
+
);
|
|
95
|
+
if (source === undefined) {
|
|
96
|
+
throw new CliError("invalid-data", `Render segment ${segment.path}:${segment.streamIndex} is absent from the manifest.`);
|
|
97
|
+
}
|
|
98
|
+
return source;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type RenderEasing = RenderPlanV1["cameraKeyframes"][number]["easing"];
|
|
102
|
+
|
|
103
|
+
export interface MetadataEffectRenderPlan {
|
|
104
|
+
readonly cameraKeyframes: RenderPlanV1["cameraKeyframes"];
|
|
105
|
+
readonly effects: RenderPlanV1["effects"];
|
|
106
|
+
readonly output: RenderPlanV1["output"];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function easedProgress(easing: RenderEasing, progress: string): string {
|
|
110
|
+
switch (easing.kind) {
|
|
111
|
+
case "linear": return progress;
|
|
112
|
+
case "ease-in": return `pow(${progress},2)`;
|
|
113
|
+
case "ease-out": return `1-pow(1-(${progress}),2)`;
|
|
114
|
+
case "ease-in-out": return `if(lt(${progress},0.5),2*pow(${progress},2),1-pow(-2*(${progress})+2,2)/2)`;
|
|
115
|
+
case "spring": return `min(1,max(0,1-exp(-6*(${progress}))*cos(8*(${progress}))))`;
|
|
116
|
+
case "cubic-bezier": {
|
|
117
|
+
const inverse = `(1-(${progress}))`;
|
|
118
|
+
return `3*pow(${inverse},2)*(${progress})*${decimal(easing.y1)}+3*(${inverse})*pow(${progress},2)*${decimal(easing.y2)}+pow(${progress},3)`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function interpolation(
|
|
124
|
+
keyframes: readonly {
|
|
125
|
+
readonly easing: RenderEasing;
|
|
126
|
+
readonly outputTimeUs: number;
|
|
127
|
+
readonly viewport: Readonly<Record<"height" | "width" | "x" | "y", number>>;
|
|
128
|
+
}[],
|
|
129
|
+
field: "height" | "width" | "x" | "y",
|
|
130
|
+
fallback: number,
|
|
131
|
+
timeExpression = "t",
|
|
132
|
+
): string {
|
|
133
|
+
let expression = decimal(fallback);
|
|
134
|
+
for (let index = keyframes.length - 1; index > 0; index -= 1) {
|
|
135
|
+
const left = keyframes[index - 1]!;
|
|
136
|
+
const right = keyframes[index]!;
|
|
137
|
+
if (right.outputTimeUs <= left.outputTimeUs) continue;
|
|
138
|
+
const start = seconds(left.outputTimeUs);
|
|
139
|
+
const end = seconds(right.outputTimeUs);
|
|
140
|
+
const leftValue = left.viewport[field];
|
|
141
|
+
const delta = right.viewport[field] - leftValue;
|
|
142
|
+
const progress = `((${timeExpression})-${start})/(${end}-${start})`;
|
|
143
|
+
const value = delta === 0
|
|
144
|
+
? decimal(leftValue)
|
|
145
|
+
: `${decimal(leftValue)}+${decimal(delta)}*(${easedProgress(right.easing, progress)})`;
|
|
146
|
+
expression = `if(between(${timeExpression},${start},${end}),${value},${expression})`;
|
|
147
|
+
}
|
|
148
|
+
return expression;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function groupedZoomInterpolation(
|
|
152
|
+
keyframes: RenderPlanV1["cameraKeyframes"],
|
|
153
|
+
field: "height" | "width" | "x" | "y",
|
|
154
|
+
fallback: number,
|
|
155
|
+
timeExpression: string,
|
|
156
|
+
): string {
|
|
157
|
+
const byZoom = new Map<string, RenderPlanV1["cameraKeyframes"][number][]>();
|
|
158
|
+
for (const keyframe of keyframes) {
|
|
159
|
+
const group = byZoom.get(keyframe.zoomId) ?? [];
|
|
160
|
+
group.push(keyframe);
|
|
161
|
+
byZoom.set(keyframe.zoomId, group);
|
|
162
|
+
}
|
|
163
|
+
const groups = [...byZoom.entries()].map(([zoomId, group]) => ({
|
|
164
|
+
group: group.sort((left, right) => left.outputTimeUs - right.outputTimeUs),
|
|
165
|
+
zoomId,
|
|
166
|
+
})).sort((left, right) => (
|
|
167
|
+
left.group[0]!.outputTimeUs - right.group[0]!.outputTimeUs
|
|
168
|
+
|| left.zoomId.localeCompare(right.zoomId)
|
|
169
|
+
));
|
|
170
|
+
|
|
171
|
+
let expression = decimal(fallback);
|
|
172
|
+
for (const { group } of groups) {
|
|
173
|
+
const startUs = group[0]!.outputTimeUs;
|
|
174
|
+
const endUs = group.at(-1)!.outputTimeUs;
|
|
175
|
+
const local = interpolation(group, field, group.at(-1)!.viewport[field], timeExpression);
|
|
176
|
+
// Zoom ranges are half-open. Grouping by zoom ID prevents interpolation
|
|
177
|
+
// from bridging the unzoomed gap between two independent operations.
|
|
178
|
+
const active = `gte(${timeExpression},${seconds(startUs)})*lt(${timeExpression},${seconds(endUs)})`;
|
|
179
|
+
expression = `if(${active},${local},${expression})`;
|
|
180
|
+
}
|
|
181
|
+
return expression;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface ZoomGeometryExpressions {
|
|
185
|
+
readonly height: string;
|
|
186
|
+
readonly width: string;
|
|
187
|
+
readonly x: string;
|
|
188
|
+
readonly y: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function zoomGeometryExpressions(plan: MetadataEffectRenderPlan, timeExpression = "t"): ZoomGeometryExpressions | null {
|
|
192
|
+
if (plan.cameraKeyframes.length === 0) return null;
|
|
193
|
+
const keyframes = [...plan.cameraKeyframes].sort((left, right) => left.outputTimeUs - right.outputTimeUs);
|
|
194
|
+
return {
|
|
195
|
+
height: groupedZoomInterpolation(keyframes, "height", plan.output.pixelHeight, timeExpression),
|
|
196
|
+
width: groupedZoomInterpolation(keyframes, "width", plan.output.pixelWidth, timeExpression),
|
|
197
|
+
x: groupedZoomInterpolation(keyframes, "x", 0, timeExpression),
|
|
198
|
+
y: groupedZoomInterpolation(keyframes, "y", 0, timeExpression),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function zoomFilter(plan: MetadataEffectRenderPlan): string | null {
|
|
203
|
+
// crop's width/height are configuration-time expressions in FFmpeg, so it
|
|
204
|
+
// cannot execute an animated viewport. zoompan evaluates zoom and pan for
|
|
205
|
+
// every input frame; d=1 preserves the existing constant-frame-rate timeline.
|
|
206
|
+
const geometry = zoomGeometryExpressions(plan, `on/${decimal(plan.output.frameRate)}`);
|
|
207
|
+
if (geometry === null) return null;
|
|
208
|
+
return `zoompan=z='${plan.output.pixelWidth}/(${geometry.width})':x='${geometry.x}':y='${geometry.y}':d=1:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:fps=${decimal(plan.output.frameRate)}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function zoomedSpatialCoordinate(
|
|
212
|
+
plan: MetadataEffectRenderPlan,
|
|
213
|
+
axis: "x" | "y",
|
|
214
|
+
unzoomed: string,
|
|
215
|
+
): string {
|
|
216
|
+
const geometry = zoomGeometryExpressions(plan);
|
|
217
|
+
if (geometry === null) return unzoomed;
|
|
218
|
+
const offset = axis === "x" ? geometry.x : geometry.y;
|
|
219
|
+
const viewportSize = axis === "x" ? geometry.width : geometry.height;
|
|
220
|
+
const outputSize = axis === "x" ? plan.output.pixelWidth : plan.output.pixelHeight;
|
|
221
|
+
return `((${unzoomed})-(${offset}))*${outputSize}/(${viewportSize})`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function overlayAnimationDuration(animation: OverlayOperation["entrance"]): number {
|
|
225
|
+
return animation.kind === "none" ? 0 : animation.durationUs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function withOverlayAnimationDuration<Animation extends OverlayOperation["entrance"]>(
|
|
229
|
+
animation: Animation,
|
|
230
|
+
durationUs: number,
|
|
231
|
+
): Animation {
|
|
232
|
+
if (animation.kind === "none") return animation;
|
|
233
|
+
return { ...animation, durationUs };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function withEffectiveOverlayAnimations(
|
|
237
|
+
overlay: OverlayOperation,
|
|
238
|
+
visibleDurationUs: number,
|
|
239
|
+
): OverlayOperation {
|
|
240
|
+
const entranceUs = overlayAnimationDuration(overlay.entrance);
|
|
241
|
+
const exitUs = overlayAnimationDuration(overlay.exit);
|
|
242
|
+
const totalUs = entranceUs + exitUs;
|
|
243
|
+
if (totalUs === 0 || totalUs <= visibleDurationUs) return overlay;
|
|
244
|
+
const effectiveEntranceUs = Math.floor(visibleDurationUs * entranceUs / totalUs);
|
|
245
|
+
return {
|
|
246
|
+
...overlay,
|
|
247
|
+
entrance: withOverlayAnimationDuration(overlay.entrance, effectiveEntranceUs),
|
|
248
|
+
exit: withOverlayAnimationDuration(overlay.exit, visibleDurationUs - effectiveEntranceUs),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function animationScale(
|
|
253
|
+
overlay: OverlayOperation,
|
|
254
|
+
outputStartUs: number,
|
|
255
|
+
outputEndUs: number,
|
|
256
|
+
): string {
|
|
257
|
+
let expression = decimal(overlay.scale);
|
|
258
|
+
if (overlay.entrance.kind === "scale" && overlay.entrance.durationUs > 0) {
|
|
259
|
+
const start = seconds(outputStartUs);
|
|
260
|
+
const end = seconds(outputStartUs + overlay.entrance.durationUs);
|
|
261
|
+
const from = overlay.entrance.fromScale * overlay.scale;
|
|
262
|
+
const progress = `(t-${start})/(${end}-${start})`;
|
|
263
|
+
expression = `if(between(t,${start},${end}),${decimal(from)}+(${decimal(overlay.scale - from)})*(${easedProgress(overlay.entrance.easing, progress)}),${expression})`;
|
|
264
|
+
}
|
|
265
|
+
if (overlay.exit.kind === "scale" && overlay.exit.durationUs > 0) {
|
|
266
|
+
const start = seconds(outputEndUs - overlay.exit.durationUs);
|
|
267
|
+
const end = seconds(outputEndUs);
|
|
268
|
+
const to = overlay.exit.fromScale * overlay.scale;
|
|
269
|
+
const progress = `(t-${start})/(${end}-${start})`;
|
|
270
|
+
expression = `if(between(t,${start},${end}),${decimal(overlay.scale)}+(${decimal(to - overlay.scale)})*(${easedProgress(overlay.exit.easing, progress)}),${expression})`;
|
|
271
|
+
}
|
|
272
|
+
const motionScale = overlayMotionExpression(overlay, "scaleMultiplier", outputStartUs, outputEndUs, 1);
|
|
273
|
+
return motionScale === "1" ? expression : `(${expression})*(${motionScale})`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function fadeFilters(overlay: OverlayOperation, startUs: number, endUs: number): readonly string[] {
|
|
277
|
+
const opacity = overlayMotionExpression(overlay, "opacityMultiplier", startUs, endUs, 1, "T");
|
|
278
|
+
const mask = overlay.mask ?? { kind: "none" as const };
|
|
279
|
+
const filters = ["format=rgba"];
|
|
280
|
+
if (mask.kind === "none" && opacity === "1") {
|
|
281
|
+
filters.push(`colorchannelmixer=aa=${decimal(overlay.opacity)}`);
|
|
282
|
+
} else {
|
|
283
|
+
const radius = mask.kind === "rounded-rectangle"
|
|
284
|
+
? `min(${decimal(mask.radiusPx)},min(W/2,H/2))`
|
|
285
|
+
: "0";
|
|
286
|
+
const maskExpression = mask.kind === "rounded-rectangle"
|
|
287
|
+
? `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)`
|
|
288
|
+
: "1";
|
|
289
|
+
filters.push(
|
|
290
|
+
`geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='alpha(X,Y)*${decimal(overlay.opacity)}*(${opacity})*(${maskExpression})'`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (overlay.entrance.kind === "fade" && overlay.entrance.durationUs > 0) {
|
|
294
|
+
filters.push(`fade=t=in:st=${seconds(startUs)}:d=${seconds(overlay.entrance.durationUs)}:alpha=1`);
|
|
295
|
+
}
|
|
296
|
+
if (overlay.exit.kind === "fade" && overlay.exit.durationUs > 0) {
|
|
297
|
+
filters.push(`fade=t=out:st=${seconds(endUs - overlay.exit.durationUs)}:d=${seconds(overlay.exit.durationUs)}:alpha=1`);
|
|
298
|
+
}
|
|
299
|
+
return filters;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type OverlayMotionField = "opacityMultiplier" | "positionX" | "positionY" | "rotationOffsetDegrees" | "scaleMultiplier";
|
|
303
|
+
|
|
304
|
+
function overlayMotionValue(
|
|
305
|
+
keyframe: Extract<NonNullable<OverlayOperation["motion"]>, { readonly kind: "keyframes" }>["keyframes"][number],
|
|
306
|
+
field: OverlayMotionField,
|
|
307
|
+
): number {
|
|
308
|
+
if (field === "positionX") return keyframe.positionOffset.x;
|
|
309
|
+
if (field === "positionY") return keyframe.positionOffset.y;
|
|
310
|
+
return keyframe[field];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function overlayMotionExpression(
|
|
314
|
+
overlay: OverlayOperation,
|
|
315
|
+
field: OverlayMotionField,
|
|
316
|
+
startUs: number,
|
|
317
|
+
endUs: number,
|
|
318
|
+
fallback: number,
|
|
319
|
+
timeExpression = "t",
|
|
320
|
+
): string {
|
|
321
|
+
const motion = overlay.motion ?? { kind: "none" as const };
|
|
322
|
+
if (motion.kind === "none" || endUs <= startUs) return decimal(fallback);
|
|
323
|
+
const keyframes = motion.keyframes;
|
|
324
|
+
let expression = decimal(overlayMotionValue(keyframes.at(-1)!, field));
|
|
325
|
+
for (let index = keyframes.length - 1; index > 0; index -= 1) {
|
|
326
|
+
const left = keyframes[index - 1]!;
|
|
327
|
+
const right = keyframes[index]!;
|
|
328
|
+
const leftTimeUs = startUs + Math.round((endUs - startUs) * left.offset);
|
|
329
|
+
const rightTimeUs = startUs + Math.round((endUs - startUs) * right.offset);
|
|
330
|
+
if (rightTimeUs <= leftTimeUs) continue;
|
|
331
|
+
const leftValue = overlayMotionValue(left, field);
|
|
332
|
+
const rightValue = overlayMotionValue(right, field);
|
|
333
|
+
const progress = `(${timeExpression}-${seconds(leftTimeUs)})/(${seconds(rightTimeUs)}-${seconds(leftTimeUs)})`;
|
|
334
|
+
const value = leftValue === rightValue
|
|
335
|
+
? decimal(leftValue)
|
|
336
|
+
: `${decimal(leftValue)}+${decimal(rightValue - leftValue)}*(${easedProgress(left.easing, progress)})`;
|
|
337
|
+
expression = `if(between(${timeExpression},${seconds(leftTimeUs)},${seconds(rightTimeUs)}),${value},${expression})`;
|
|
338
|
+
}
|
|
339
|
+
return expression;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function overlayGeometryFilters(
|
|
343
|
+
overlay: OverlayOperation,
|
|
344
|
+
outputStartUs: number,
|
|
345
|
+
outputEndUs: number,
|
|
346
|
+
): readonly string[] {
|
|
347
|
+
const filters: string[] = [];
|
|
348
|
+
const crop = overlay.crop ?? { kind: "none" as const };
|
|
349
|
+
if (crop.kind === "normalized-insets") {
|
|
350
|
+
filters.push(
|
|
351
|
+
`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)}'`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
filters.push("format=rgba");
|
|
355
|
+
if (overlay.size.kind === "pixels") {
|
|
356
|
+
const width = decimal(overlay.size.width);
|
|
357
|
+
const height = decimal(overlay.size.height);
|
|
358
|
+
const fit = overlay.fit ?? "fill";
|
|
359
|
+
if (fit === "fill") {
|
|
360
|
+
filters.push(`scale=${width}:${height}`);
|
|
361
|
+
} else if (fit === "contain") {
|
|
362
|
+
filters.push(
|
|
363
|
+
`scale=${width}:${height}:force_original_aspect_ratio=decrease`,
|
|
364
|
+
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=0x00000000`,
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
filters.push(
|
|
368
|
+
`scale=${width}:${height}:force_original_aspect_ratio=increase`,
|
|
369
|
+
`crop=${width}:${height}`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const scale = animationScale(overlay, outputStartUs, outputEndUs);
|
|
374
|
+
filters.push(`scale=w='iw*(${scale})':h='ih*(${scale})':eval=frame`);
|
|
375
|
+
filters.push(...fadeFilters(overlay, outputStartUs, outputEndUs));
|
|
376
|
+
const rotationOffset = overlayMotionExpression(overlay, "rotationOffsetDegrees", outputStartUs, outputEndUs, 0);
|
|
377
|
+
const rotation = rotationOffset === "0"
|
|
378
|
+
? decimal(overlay.rotationDegrees)
|
|
379
|
+
: `${decimal(overlay.rotationDegrees)}+(${rotationOffset})`;
|
|
380
|
+
if (rotation !== "0") {
|
|
381
|
+
filters.push(`rotate='(${rotation})*PI/180':c=none:ow=rotw(iw):oh=roth(ih)`);
|
|
382
|
+
}
|
|
383
|
+
return filters;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function anchorExpression(anchor: OverlayOperation["anchor"], axis: "x" | "y"): string {
|
|
387
|
+
if (axis === "x") {
|
|
388
|
+
if (anchor.endsWith("left") || anchor === "left") return "0";
|
|
389
|
+
if (anchor.endsWith("right") || anchor === "right") return "W-w";
|
|
390
|
+
return "(W-w)/2";
|
|
391
|
+
}
|
|
392
|
+
if (anchor.startsWith("top") || anchor === "top") return "0";
|
|
393
|
+
if (anchor.startsWith("bottom") || anchor === "bottom") return "H-h";
|
|
394
|
+
return "(H-h)/2";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function slideOffset(
|
|
398
|
+
overlay: OverlayOperation,
|
|
399
|
+
axis: "x" | "y",
|
|
400
|
+
startUs: number,
|
|
401
|
+
endUs: number,
|
|
402
|
+
): string {
|
|
403
|
+
const components: string[] = [];
|
|
404
|
+
for (const [animation, animationStartUs, entering] of [
|
|
405
|
+
[overlay.entrance, startUs, true],
|
|
406
|
+
[overlay.exit, endUs - (overlay.exit.kind === "none" ? 0 : overlay.exit.durationUs), false],
|
|
407
|
+
] as const) {
|
|
408
|
+
if (animation.kind !== "slide" || animation.durationUs === 0) continue;
|
|
409
|
+
const onAxis = axis === "x"
|
|
410
|
+
? animation.direction === "left" || animation.direction === "right"
|
|
411
|
+
: animation.direction === "up" || animation.direction === "down";
|
|
412
|
+
if (!onAxis) continue;
|
|
413
|
+
const sign = animation.direction === "left" || animation.direction === "up" ? -1 : 1;
|
|
414
|
+
const start = seconds(animationStartUs);
|
|
415
|
+
const finish = seconds(animationStartUs + animation.durationUs);
|
|
416
|
+
const startDistance = entering ? sign * animation.distancePx : 0;
|
|
417
|
+
const delta = entering ? -sign * animation.distancePx : sign * animation.distancePx;
|
|
418
|
+
const progress = `(t-${start})/(${finish}-${start})`;
|
|
419
|
+
components.push(
|
|
420
|
+
`if(between(t,${start},${finish}),${decimal(startDistance)}+${decimal(delta)}*(${easedProgress(animation.easing, progress)}),0)`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return components.length === 0 ? "0" : components.join("+");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function overlayPositionExpression(
|
|
427
|
+
overlay: OverlayOperation,
|
|
428
|
+
axis: "x" | "y",
|
|
429
|
+
startUs: number,
|
|
430
|
+
endUs: number,
|
|
431
|
+
): string {
|
|
432
|
+
const field = axis === "x" ? "positionX" : "positionY";
|
|
433
|
+
return `${anchorExpression(overlay.anchor, axis)}+${decimal(overlay.position[axis])}+${overlayMotionExpression(overlay, field, startUs, endUs, 0)}+${slideOffset(overlay, axis, startUs, endUs)}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const MAX_METADATA_FILTERS = 10_000;
|
|
437
|
+
const MAX_LOOP_VIDEO_FRAMES = 1_000_000;
|
|
438
|
+
const MAX_LOOP_AUDIO_SAMPLES = 10_000_000;
|
|
439
|
+
const MAX_METADATA_SPRITE_DIMENSION = 16_384;
|
|
440
|
+
const MAX_METADATA_SPRITE_AREA = 134_217_728;
|
|
441
|
+
|
|
442
|
+
function escapeDrawtext(value: string): string {
|
|
443
|
+
return [...value]
|
|
444
|
+
.map((character) => {
|
|
445
|
+
const point = character.codePointAt(0) ?? 0;
|
|
446
|
+
return point < 32 || point === 127 ? " " : character;
|
|
447
|
+
})
|
|
448
|
+
.join("")
|
|
449
|
+
.replaceAll("\\", "\\\\")
|
|
450
|
+
.replaceAll("'", "\\'")
|
|
451
|
+
.replaceAll(":", "\\:")
|
|
452
|
+
.replaceAll(",", "\\,")
|
|
453
|
+
.replaceAll("%", "\\%")
|
|
454
|
+
.replaceAll("[", "\\[")
|
|
455
|
+
.replaceAll("]", "\\]");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function animatedVideoFilters(
|
|
459
|
+
source: Extract<OverlayOperation["source"], { readonly kind: "gif" | "video" }>,
|
|
460
|
+
intrinsicSize: OverlayOperation["intrinsicSize"],
|
|
461
|
+
outputDurationUs: number,
|
|
462
|
+
outputStartUs: number,
|
|
463
|
+
frameRate: number,
|
|
464
|
+
): { readonly filters: readonly string[]; readonly visibleDurationUs: number } {
|
|
465
|
+
const playback = source.playback;
|
|
466
|
+
const availableSourceUs = playback.sourceOutUs - playback.sourceInUs;
|
|
467
|
+
const requiredSourceUs = Math.ceil(outputDurationUs * playback.playbackRate);
|
|
468
|
+
if (!Number.isSafeInteger(requiredSourceUs) || requiredSourceUs <= 0) {
|
|
469
|
+
throw new CliError("unsupported-plan", `Overlay ${source.asset.path} has an unsupported playback duration.`);
|
|
470
|
+
}
|
|
471
|
+
const sourceDurationUs = Math.min(availableSourceUs, requiredSourceUs);
|
|
472
|
+
const streamStartUs = playback.streamStartUs ?? 0;
|
|
473
|
+
const effectiveSourceOutUs = streamStartUs + playback.sourceInUs + sourceDurationUs;
|
|
474
|
+
const filters = [
|
|
475
|
+
`trim=start=${seconds(streamStartUs + playback.sourceInUs)}:end=${seconds(effectiveSourceOutUs)}`,
|
|
476
|
+
"setpts=PTS-STARTPTS",
|
|
477
|
+
`fps=fps=${decimal(frameRate)}`,
|
|
478
|
+
];
|
|
479
|
+
let visibleDurationUs = outputDurationUs;
|
|
480
|
+
if (playback.endBehavior === "loop") {
|
|
481
|
+
if (availableSourceUs < requiredSourceUs) {
|
|
482
|
+
const frames = Math.max(1, Math.ceil(sourceDurationUs * frameRate / 1_000_000));
|
|
483
|
+
if (frames > MAX_LOOP_VIDEO_FRAMES) {
|
|
484
|
+
throw new CliError(
|
|
485
|
+
"unsupported-plan",
|
|
486
|
+
`Overlay ${source.asset.path} needs ${frames} buffered loop frames; maximum is ${MAX_LOOP_VIDEO_FRAMES}.`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
assertVideoLoopBufferWithinLimit({
|
|
490
|
+
frameCount: frames,
|
|
491
|
+
label: `Overlay ${source.asset.path}`,
|
|
492
|
+
pixelHeight: intrinsicSize.height,
|
|
493
|
+
pixelWidth: intrinsicSize.width,
|
|
494
|
+
});
|
|
495
|
+
filters.push("format=rgba");
|
|
496
|
+
filters.push(`loop=loop=-1:size=${frames}:start=0`);
|
|
497
|
+
}
|
|
498
|
+
filters.push(`trim=duration=${seconds(requiredSourceUs)}`);
|
|
499
|
+
} else if (playback.endBehavior === "freeze-end") {
|
|
500
|
+
// Pad by the full requested window before trimming. This remains correct
|
|
501
|
+
// even when a decoder ends a frame early relative to container duration.
|
|
502
|
+
filters.push(`tpad=stop_mode=clone:stop_duration=${seconds(requiredSourceUs)}`);
|
|
503
|
+
filters.push(`trim=duration=${seconds(requiredSourceUs)}`);
|
|
504
|
+
} else {
|
|
505
|
+
visibleDurationUs = Math.min(outputDurationUs, sourceDurationUs / playback.playbackRate);
|
|
506
|
+
filters.push(`trim=duration=${seconds(Math.min(sourceDurationUs, requiredSourceUs))}`);
|
|
507
|
+
}
|
|
508
|
+
filters.push(`setpts=(PTS-STARTPTS)/${decimal(playback.playbackRate)}+${seconds(outputStartUs)}/TB`);
|
|
509
|
+
return { filters, visibleDurationUs };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function animatedAudioFilters(
|
|
513
|
+
source: Extract<OverlayOperation["source"], { readonly kind: "video" }>,
|
|
514
|
+
outputDurationUs: number,
|
|
515
|
+
outputStartUs: number,
|
|
516
|
+
): {
|
|
517
|
+
readonly audibleEndUs: number;
|
|
518
|
+
readonly audibleStartUs: number;
|
|
519
|
+
readonly filters: readonly string[];
|
|
520
|
+
readonly loopDuck: {
|
|
521
|
+
readonly audioDelayUs: number;
|
|
522
|
+
readonly audioEndUs: number;
|
|
523
|
+
readonly sourceWindowUs: number;
|
|
524
|
+
} | null;
|
|
525
|
+
} | null {
|
|
526
|
+
const playback = source.playback;
|
|
527
|
+
const availableSourceUs = playback.sourceOutUs - playback.sourceInUs;
|
|
528
|
+
const requiredSourceUs = Math.ceil(outputDurationUs * playback.playbackRate);
|
|
529
|
+
if (!Number.isSafeInteger(requiredSourceUs) || requiredSourceUs <= 0) {
|
|
530
|
+
throw new CliError("unsupported-plan", `Overlay ${source.asset.path} has an unsupported playback duration.`);
|
|
531
|
+
}
|
|
532
|
+
const sourceDurationUs = Math.min(availableSourceUs, requiredSourceUs);
|
|
533
|
+
const streamStartUs = playback.streamStartUs ?? 0;
|
|
534
|
+
const selectedSourceStartUs = streamStartUs + playback.sourceInUs;
|
|
535
|
+
const audioDelayUs = Math.max(
|
|
536
|
+
0,
|
|
537
|
+
(playback.audioStartUs ?? streamStartUs) - selectedSourceStartUs,
|
|
538
|
+
);
|
|
539
|
+
const audioEndUs = Math.min(
|
|
540
|
+
availableSourceUs,
|
|
541
|
+
Math.max(
|
|
542
|
+
0,
|
|
543
|
+
(playback.audioEndUs ?? selectedSourceStartUs + availableSourceUs) - selectedSourceStartUs,
|
|
544
|
+
),
|
|
545
|
+
);
|
|
546
|
+
const audibleSourceRange = resolveAudibleSourceRange({
|
|
547
|
+
audioDelayUs,
|
|
548
|
+
audioEndUs,
|
|
549
|
+
endBehavior: playback.endBehavior,
|
|
550
|
+
requestedDurationUs: requiredSourceUs,
|
|
551
|
+
requestedStartUs: 0,
|
|
552
|
+
sourceWindowUs: availableSourceUs,
|
|
553
|
+
});
|
|
554
|
+
if (audibleSourceRange === null) return null;
|
|
555
|
+
const audibleStartUs = outputStartUs + Math.ceil(audibleSourceRange.startUs / playback.playbackRate);
|
|
556
|
+
const audibleEndUs = Math.min(
|
|
557
|
+
outputStartUs + outputDurationUs,
|
|
558
|
+
outputStartUs + Math.floor(audibleSourceRange.endUs / playback.playbackRate),
|
|
559
|
+
);
|
|
560
|
+
if (audibleEndUs <= audibleStartUs) return null;
|
|
561
|
+
const effectiveSourceOutUs = selectedSourceStartUs + sourceDurationUs;
|
|
562
|
+
const filters = [
|
|
563
|
+
`atrim=start=${seconds(selectedSourceStartUs)}:end=${seconds(effectiveSourceOutUs)}`,
|
|
564
|
+
"aresample=48000",
|
|
565
|
+
"asetpts=PTS-STARTPTS",
|
|
566
|
+
...(audioDelayUs === 0 ? [] : [`adelay=${decimal(audioDelayUs / 1_000)}:all=1`]),
|
|
567
|
+
];
|
|
568
|
+
if (playback.endBehavior === "loop") {
|
|
569
|
+
if (availableSourceUs < requiredSourceUs) {
|
|
570
|
+
const samples = Math.max(1, Math.ceil(sourceDurationUs * 48_000 / 1_000_000));
|
|
571
|
+
if (samples > MAX_LOOP_AUDIO_SAMPLES) {
|
|
572
|
+
throw new CliError(
|
|
573
|
+
"unsupported-plan",
|
|
574
|
+
`Overlay ${source.asset.path} needs ${samples} buffered loop samples; maximum is ${MAX_LOOP_AUDIO_SAMPLES}.`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
assertAudioLoopBufferWithinLimit({ label: `Overlay ${source.asset.path}`, sampleCount: samples });
|
|
578
|
+
if (audioEndUs < availableSourceUs) {
|
|
579
|
+
filters.push(`apad=pad_dur=${seconds(availableSourceUs - audioEndUs)}`);
|
|
580
|
+
}
|
|
581
|
+
filters.push(`atrim=duration=${seconds(availableSourceUs)}`);
|
|
582
|
+
filters.push("aformat=sample_fmts=flt:channel_layouts=stereo");
|
|
583
|
+
filters.push(`aloop=loop=-1:size=${samples}`);
|
|
584
|
+
}
|
|
585
|
+
filters.push(`atrim=duration=${seconds(requiredSourceUs)}`);
|
|
586
|
+
} else {
|
|
587
|
+
filters.push(`atrim=duration=${seconds(Math.min(sourceDurationUs, requiredSourceUs))}`);
|
|
588
|
+
}
|
|
589
|
+
filters.push(atempo(playback.playbackRate));
|
|
590
|
+
filters.push(`asetpts=PTS-STARTPTS+${seconds(outputStartUs)}/TB`);
|
|
591
|
+
return {
|
|
592
|
+
audibleEndUs,
|
|
593
|
+
audibleStartUs,
|
|
594
|
+
filters,
|
|
595
|
+
loopDuck: playback.endBehavior === "loop"
|
|
596
|
+
&& availableSourceUs < requiredSourceUs
|
|
597
|
+
&& (audioDelayUs > 0 || audioEndUs < availableSourceUs)
|
|
598
|
+
? { audioDelayUs, audioEndUs, sourceWindowUs: availableSourceUs }
|
|
599
|
+
: null,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function cursorCoordinate(
|
|
604
|
+
samples: RenderPlanV1["effects"]["cursorSamples"],
|
|
605
|
+
axis: "x" | "y",
|
|
606
|
+
hidden: number,
|
|
607
|
+
): string {
|
|
608
|
+
if (samples.length === 0) return decimal(hidden);
|
|
609
|
+
let expression = decimal(samples.at(-1)!.position[axis]);
|
|
610
|
+
for (let index = samples.length - 1; index > 0; index -= 1) {
|
|
611
|
+
const left = samples[index - 1]!;
|
|
612
|
+
const right = samples[index]!;
|
|
613
|
+
if (right.outputTimeUs <= left.outputTimeUs) continue;
|
|
614
|
+
const start = seconds(left.outputTimeUs);
|
|
615
|
+
const end = seconds(right.outputTimeUs);
|
|
616
|
+
const delta = right.position[axis] - left.position[axis];
|
|
617
|
+
const value = delta === 0
|
|
618
|
+
? decimal(left.position[axis])
|
|
619
|
+
: `${decimal(left.position[axis])}+${decimal(delta)}*(t-${start})/(${end}-${start})`;
|
|
620
|
+
expression = `if(between(t,${start},${end}),${value},${expression})`;
|
|
621
|
+
}
|
|
622
|
+
return `if(lt(t,${seconds(samples[0]!.outputTimeUs)}),${decimal(hidden)},${expression})`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function cursorVisibility(
|
|
626
|
+
samples: RenderPlanV1["effects"]["cursorSamples"],
|
|
627
|
+
durationUs: number,
|
|
628
|
+
): string {
|
|
629
|
+
const intervals: Array<{ readonly endUs: number; readonly startUs: number }> = [];
|
|
630
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
631
|
+
const sample = samples[index]!;
|
|
632
|
+
if (!sample.visible) continue;
|
|
633
|
+
const endUs = samples[index + 1]?.outputTimeUs ?? durationUs;
|
|
634
|
+
if (endUs <= sample.outputTimeUs) continue;
|
|
635
|
+
const prior = intervals.at(-1);
|
|
636
|
+
if (prior !== undefined && prior.endUs === sample.outputTimeUs) {
|
|
637
|
+
intervals[intervals.length - 1] = { endUs, startUs: prior.startUs };
|
|
638
|
+
} else {
|
|
639
|
+
intervals.push({ endUs, startUs: sample.outputTimeUs });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return intervals.length === 0
|
|
643
|
+
? "0"
|
|
644
|
+
: intervals.map(({ startUs, endUs }) => `between(t,${seconds(startUs)},${seconds(endUs)})`).join("+");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
type EnabledCursorEffect = Extract<RenderPlanV1["effects"]["cursor"], { readonly enabled: true }>;
|
|
648
|
+
type EnabledClickEffect = Extract<RenderPlanV1["effects"]["clicks"], { readonly enabled: true }>;
|
|
649
|
+
|
|
650
|
+
export interface MetadataSpriteSource {
|
|
651
|
+
readonly filters: readonly string[];
|
|
652
|
+
readonly height: number;
|
|
653
|
+
readonly width: number;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export interface MetadataCursorSpriteSource extends MetadataSpriteSource {
|
|
657
|
+
readonly hotspotX: number;
|
|
658
|
+
readonly hotspotY: number;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function nestedExpression(operator: "max" | "min", values: readonly string[]): string {
|
|
662
|
+
const first = values[0];
|
|
663
|
+
if (first === undefined) throw new Error(`Cannot build an empty ${operator} expression.`);
|
|
664
|
+
return values.slice(1).reduce((expression, value) => `${operator}(${expression},${value})`, first);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function coverageFromMargin(margin: string): string {
|
|
668
|
+
return `clip((${margin})+0.5,0,1)`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function circleMask(radius: number, x = "X", y = "Y"): string {
|
|
672
|
+
const distance = `hypot((${x})-(W-1)/2,(${y})-(H-1)/2)`;
|
|
673
|
+
return coverageFromMargin(`${decimal(radius)}-${distance}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function ringMask(outerRadius: number, innerRadius: number, x = "X", y = "Y"): string {
|
|
677
|
+
const outer = circleMask(outerRadius, x, y);
|
|
678
|
+
if (innerRadius <= 0) return outer;
|
|
679
|
+
const distance = `hypot((${x})-(W-1)/2,(${y})-(H-1)/2)`;
|
|
680
|
+
return nestedExpression("min", [
|
|
681
|
+
outer,
|
|
682
|
+
coverageFromMargin(`${distance}-${decimal(innerRadius)}`),
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function arrowMask(
|
|
687
|
+
designScale: number,
|
|
688
|
+
expansion: number,
|
|
689
|
+
x = "X",
|
|
690
|
+
y = "Y",
|
|
691
|
+
): string {
|
|
692
|
+
const left = 3 * designScale;
|
|
693
|
+
const right = 25 * designScale;
|
|
694
|
+
const upperOrigin = 2 * designScale;
|
|
695
|
+
const lowerOrigin = 30 * designScale;
|
|
696
|
+
const upperSlope = 17 / 22;
|
|
697
|
+
const lowerSlope = 10 / 22;
|
|
698
|
+
const upper = `${decimal(upperOrigin)}+${decimal(upperSlope)}*((${x})-${decimal(left)})`;
|
|
699
|
+
const lower = `${decimal(lowerOrigin)}-${decimal(lowerSlope)}*((${x})-${decimal(left)})`;
|
|
700
|
+
const triangleMargin = nestedExpression("min", [
|
|
701
|
+
`(${x})-${decimal(left)}`,
|
|
702
|
+
`${decimal(right)}-(${x})`,
|
|
703
|
+
`(${y})-(${upper})`,
|
|
704
|
+
`(${lower})-(${y})`,
|
|
705
|
+
]);
|
|
706
|
+
|
|
707
|
+
const stemStartX = 11 * designScale;
|
|
708
|
+
const stemStartY = 21 * designScale;
|
|
709
|
+
const stemLength = Math.sqrt(245) * designScale;
|
|
710
|
+
const directionX = 7 / Math.sqrt(245);
|
|
711
|
+
const directionY = 14 / Math.sqrt(245);
|
|
712
|
+
const relativeX = `((${x})-${decimal(stemStartX)})`;
|
|
713
|
+
const relativeY = `((${y})-${decimal(stemStartY)})`;
|
|
714
|
+
const projection = `${decimal(directionX)}*${relativeX}+${decimal(directionY)}*${relativeY}`;
|
|
715
|
+
const perpendicular = `${decimal(directionY)}*${relativeX}-${decimal(directionX)}*${relativeY}`;
|
|
716
|
+
const stemMargin = nestedExpression("min", [
|
|
717
|
+
projection,
|
|
718
|
+
`${decimal(stemLength)}-(${projection})`,
|
|
719
|
+
`${decimal(3 * designScale)}-abs(${perpendicular})`,
|
|
720
|
+
]);
|
|
721
|
+
const expandedTriangle = coverageFromMargin(`(${triangleMargin})+${decimal(expansion)}`);
|
|
722
|
+
const expandedStem = coverageFromMargin(`(${stemMargin})+${decimal(expansion)}`);
|
|
723
|
+
return nestedExpression("max", [expandedTriangle, expandedStem]);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function grayscaleGeq(innerMask: string, outerMask: string, shadowMask: string): string {
|
|
727
|
+
const luminance = `255*(${innerMask})`;
|
|
728
|
+
const alpha = `255*${nestedExpression("max", [outerMask, `0.3*(${shadowMask})`])}`;
|
|
729
|
+
return `geq=r='${luminance}':g='${luminance}':b='${luminance}':a='${alpha}'`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function assertMetadataSpriteWithinLimit(width: number, height: number, label: string): void {
|
|
733
|
+
if (
|
|
734
|
+
width > MAX_METADATA_SPRITE_DIMENSION
|
|
735
|
+
|| height > MAX_METADATA_SPRITE_DIMENSION
|
|
736
|
+
|| width * height > MAX_METADATA_SPRITE_AREA
|
|
737
|
+
) {
|
|
738
|
+
throw new CliError(
|
|
739
|
+
"unsupported-plan",
|
|
740
|
+
`${label} requires a ${width}x${height} procedural sprite, exceeding the ${MAX_METADATA_SPRITE_DIMENSION}-pixel or 128-megapixel render safety limit.`,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function metadataCadence(frameRate: number, exactFrameRate?: Readonly<{ numerator: number; denominator: number }>): string {
|
|
746
|
+
if (exactFrameRate === undefined) return decimal(frameRate);
|
|
747
|
+
const { numerator, denominator } = exactFrameRate;
|
|
748
|
+
if (!Number.isSafeInteger(numerator) || !Number.isSafeInteger(denominator) || numerator <= 0 || denominator <= 0
|
|
749
|
+
|| numerator > 1_000_000 || denominator > 1_000_000 || numerator / denominator !== frameRate) throw new CliError("invalid-data", "Metadata sprite cadence does not match its exact output rate.");
|
|
750
|
+
return `${numerator}/${denominator}`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function buildMetadataCursorSprite(
|
|
754
|
+
cursor: Pick<EnabledCursorEffect, "scale" | "style">,
|
|
755
|
+
frameRate: number,
|
|
756
|
+
durationUs: number,
|
|
757
|
+
exactFrameRate?: Readonly<{ numerator: number; denominator: number }>,
|
|
758
|
+
): MetadataCursorSpriteSource {
|
|
759
|
+
const emittedRate = metadataCadence(frameRate, exactFrameRate);
|
|
760
|
+
if (cursor.style === "captured") {
|
|
761
|
+
const height = Math.max(8, Math.min(256, Math.round(40 * cursor.scale)));
|
|
762
|
+
const designScale = height / 40;
|
|
763
|
+
const width = Math.max(8, Math.ceil(32 * designScale));
|
|
764
|
+
const outline = Math.max(0.65, 0.9 * designScale);
|
|
765
|
+
const shadowOffset = Math.max(1, 1.5 * designScale);
|
|
766
|
+
const inner = arrowMask(designScale, -outline);
|
|
767
|
+
const outer = arrowMask(designScale, outline);
|
|
768
|
+
const shadow = arrowMask(
|
|
769
|
+
designScale,
|
|
770
|
+
outline,
|
|
771
|
+
`(X-${decimal(shadowOffset)})`,
|
|
772
|
+
`(Y-${decimal(shadowOffset)})`,
|
|
773
|
+
);
|
|
774
|
+
return {
|
|
775
|
+
filters: [
|
|
776
|
+
`color=c=black@0.0:s=${width}x${height}:r=${emittedRate}:d=${seconds(durationUs)}`,
|
|
777
|
+
"format=rgba",
|
|
778
|
+
grayscaleGeq(inner, outer, shadow),
|
|
779
|
+
],
|
|
780
|
+
height,
|
|
781
|
+
hotspotX: 3 * designScale,
|
|
782
|
+
hotspotY: 2 * designScale,
|
|
783
|
+
width,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const size = Math.max(8, Math.min(256, Math.round(20 * cursor.scale)));
|
|
788
|
+
const outerRadius = Math.max(1, (size - 1) / 2 - Math.max(1, size * 0.05));
|
|
789
|
+
const shadowOffset = Math.max(1, size * 0.075);
|
|
790
|
+
const shadowX = `(X-${decimal(shadowOffset)})`;
|
|
791
|
+
const shadowY = `(Y-${decimal(shadowOffset)})`;
|
|
792
|
+
let inner: string;
|
|
793
|
+
let outer: string;
|
|
794
|
+
let shadow: string;
|
|
795
|
+
if (cursor.style === "ring") {
|
|
796
|
+
const ringWidth = Math.max(2, size * 0.22);
|
|
797
|
+
const innerRadius = Math.max(0, outerRadius - ringWidth);
|
|
798
|
+
outer = ringMask(outerRadius, innerRadius);
|
|
799
|
+
inner = ringMask(
|
|
800
|
+
Math.max(0, outerRadius - Math.max(0.75, size * 0.055)),
|
|
801
|
+
innerRadius + Math.max(0.75, size * 0.055),
|
|
802
|
+
);
|
|
803
|
+
shadow = ringMask(outerRadius, innerRadius, shadowX, shadowY);
|
|
804
|
+
} else {
|
|
805
|
+
outer = circleMask(outerRadius);
|
|
806
|
+
inner = circleMask(Math.max(0, outerRadius - Math.max(1, size * 0.09)));
|
|
807
|
+
shadow = circleMask(outerRadius, shadowX, shadowY);
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
filters: [
|
|
811
|
+
`color=c=black@0.0:s=${size}x${size}:r=${emittedRate}:d=${seconds(durationUs)}`,
|
|
812
|
+
"format=rgba",
|
|
813
|
+
grayscaleGeq(inner, outer, shadow),
|
|
814
|
+
],
|
|
815
|
+
height: size,
|
|
816
|
+
hotspotX: (size - 1) / 2,
|
|
817
|
+
hotspotY: (size - 1) / 2,
|
|
818
|
+
width: size,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function rgbaChannels(value: string): {
|
|
823
|
+
readonly alpha: number;
|
|
824
|
+
readonly blue: number;
|
|
825
|
+
readonly green: number;
|
|
826
|
+
readonly red: number;
|
|
827
|
+
} {
|
|
828
|
+
if (!/^#[a-fA-F0-9]{6}(?:[a-fA-F0-9]{2})?$/u.test(value)) {
|
|
829
|
+
throw new CliError("invalid-data", `Invalid metadata effect color ${JSON.stringify(value)}.`);
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
alpha: value.length === 9 ? Number.parseInt(value.slice(7, 9), 16) : 255,
|
|
833
|
+
blue: Number.parseInt(value.slice(5, 7), 16),
|
|
834
|
+
green: Number.parseInt(value.slice(3, 5), 16),
|
|
835
|
+
red: Number.parseInt(value.slice(1, 3), 16),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function buildMetadataClickSprite(
|
|
840
|
+
click: Pick<EnabledClickEffect, "color" | "radiusPx" | "style">,
|
|
841
|
+
frameRate: number,
|
|
842
|
+
durationUs: number,
|
|
843
|
+
exactFrameRate?: Readonly<{ numerator: number; denominator: number }>,
|
|
844
|
+
): MetadataSpriteSource {
|
|
845
|
+
const emittedRate = metadataCadence(frameRate, exactFrameRate);
|
|
846
|
+
const diameter = Math.max(5, Math.ceil(click.radiusPx * 2) + 4);
|
|
847
|
+
assertMetadataSpriteWithinLimit(diameter, diameter, "Click highlight");
|
|
848
|
+
const radius = Math.min(click.radiusPx, (diameter - 1) / 2);
|
|
849
|
+
const thickness = Math.max(2, radius / 8);
|
|
850
|
+
const mask = click.style === "fill"
|
|
851
|
+
? circleMask(radius)
|
|
852
|
+
: ringMask(radius, Math.max(0, radius - thickness));
|
|
853
|
+
const color = rgbaChannels(click.color);
|
|
854
|
+
const filters = [
|
|
855
|
+
`color=c=black@0.0:s=${diameter}x${diameter}:r=${emittedRate}:d=${seconds(durationUs)}`,
|
|
856
|
+
"format=rgba",
|
|
857
|
+
`geq=r='${color.red}':g='${color.green}':b='${color.blue}':a='${color.alpha}*(${mask})'`,
|
|
858
|
+
];
|
|
859
|
+
if (click.style === "pulse") {
|
|
860
|
+
const progress = `t/${seconds(durationUs)}`;
|
|
861
|
+
filters.push(
|
|
862
|
+
`scale=w='max(2,iw*(0.5+0.5*${progress}))':h='max(2,ih*(0.5+0.5*${progress}))':flags=bicubic:eval=frame`,
|
|
863
|
+
);
|
|
864
|
+
filters.push(
|
|
865
|
+
`fade=t=out:st=${seconds(Math.floor(durationUs / 2))}:d=${seconds(Math.ceil(durationUs / 2))}:alpha=1`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
return { filters, height: diameter, width: diameter };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function keystrokeLabel(cue: RenderPlanV1["effects"]["keystrokeCues"][number]): string {
|
|
872
|
+
if (cue.activity.kind === "printable") return "•";
|
|
873
|
+
if (cue.activity.kind === "shortcut") return [...cue.activity.modifiers, cue.activity.keyCode].join("+");
|
|
874
|
+
return [...cue.activity.modifiers, cue.activity.control].join("+");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function metadataFilterCount(plan: MetadataEffectRenderPlan): number {
|
|
878
|
+
// Cursor samples are encoded into three expressions on one overlay node.
|
|
879
|
+
// Charge the node here; its serialized expression has a separate byte bound.
|
|
880
|
+
return (plan.effects.cursor.enabled && plan.effects.cursorSamples.length > 0 ? 1 : 0)
|
|
881
|
+
+ (plan.effects.clicks.enabled ? plan.effects.clickCues.filter(({ phase }) => phase === "down").length : 0)
|
|
882
|
+
+ (plan.effects.keystrokes.enabled
|
|
883
|
+
? plan.effects.keystrokeCues.filter(({ phase }) => phase === "down").length
|
|
884
|
+
: 0)
|
|
885
|
+
+ (plan.effects.typedText.enabled
|
|
886
|
+
? plan.effects.typingSpans.reduce((count, span) => count + (span.secure ? 0 : span.updates.length), 0)
|
|
887
|
+
: 0);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export function applyMetadataEffects(
|
|
891
|
+
plan: MetadataEffectRenderPlan,
|
|
892
|
+
filters: string[],
|
|
893
|
+
initialVideo: string,
|
|
894
|
+
initialSerial: number,
|
|
895
|
+
exactFrameRate?: Readonly<{ numerator: number; denominator: number }>,
|
|
896
|
+
): { readonly currentVideo: string; readonly serial: number } {
|
|
897
|
+
const filterCount = metadataFilterCount(plan);
|
|
898
|
+
if (filterCount > MAX_METADATA_FILTERS) {
|
|
899
|
+
throw new CliError(
|
|
900
|
+
"unsupported-plan",
|
|
901
|
+
`Resolved metadata effects require ${filterCount} filter operations; maximum is ${MAX_METADATA_FILTERS}.`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
let currentVideo = initialVideo;
|
|
905
|
+
let serial = initialSerial;
|
|
906
|
+
if (plan.effects.cursor.enabled && plan.effects.cursorSamples.length > 0) {
|
|
907
|
+
const samples = [...plan.effects.cursorSamples].sort((left, right) => left.outputTimeUs - right.outputTimeUs);
|
|
908
|
+
const cursorSprite = buildMetadataCursorSprite(
|
|
909
|
+
plan.effects.cursor,
|
|
910
|
+
plan.output.frameRate,
|
|
911
|
+
plan.output.durationUs,
|
|
912
|
+
exactFrameRate,
|
|
913
|
+
);
|
|
914
|
+
const sourceLabel = `cursor_source_${serial++}`;
|
|
915
|
+
filters.push(`${cursorSprite.filters.join(",")}[${sourceLabel}]`);
|
|
916
|
+
const nextVideo = `video_cursor_${serial++}`;
|
|
917
|
+
const cursorX = cursorCoordinate(samples, "x", -10_000);
|
|
918
|
+
const cursorY = cursorCoordinate(samples, "y", -10_000);
|
|
919
|
+
const visibility = cursorVisibility(samples, plan.output.durationUs);
|
|
920
|
+
const cursorExpressionBytes = Buffer.byteLength(`${cursorX}\0${cursorY}\0${visibility}`);
|
|
921
|
+
if (cursorExpressionBytes > MAXIMUM_FILTER_GRAPH_BYTES) {
|
|
922
|
+
throw new CliError(
|
|
923
|
+
"unsupported-plan",
|
|
924
|
+
`Resolved cursor expressions require ${cursorExpressionBytes} UTF-8 bytes; maximum is ${MAXIMUM_FILTER_GRAPH_BYTES}.`,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
const x = `${zoomedSpatialCoordinate(plan, "x", cursorX)}-${decimal(cursorSprite.hotspotX)}`;
|
|
928
|
+
const y = `${zoomedSpatialCoordinate(plan, "y", cursorY)}-${decimal(cursorSprite.hotspotY)}`;
|
|
929
|
+
filters.push(
|
|
930
|
+
`[${currentVideo}][${sourceLabel}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${visibility}'[${nextVideo}]`,
|
|
931
|
+
);
|
|
932
|
+
currentVideo = nextVideo;
|
|
933
|
+
}
|
|
934
|
+
if (plan.effects.clicks.enabled) {
|
|
935
|
+
for (const cue of plan.effects.clickCues.filter(({ phase }) => phase === "down")) {
|
|
936
|
+
const durationUs = Math.min(plan.effects.clicks.durationUs, plan.output.durationUs - cue.outputTimeUs);
|
|
937
|
+
if (durationUs <= 0) continue;
|
|
938
|
+
const clickSprite = buildMetadataClickSprite(
|
|
939
|
+
plan.effects.clicks,
|
|
940
|
+
plan.output.frameRate,
|
|
941
|
+
durationUs,
|
|
942
|
+
exactFrameRate,
|
|
943
|
+
);
|
|
944
|
+
const sourceLabel = `click_source_${serial++}`;
|
|
945
|
+
const clickFilters = [
|
|
946
|
+
...clickSprite.filters,
|
|
947
|
+
"settb=expr=1/1000000",
|
|
948
|
+
`setpts=PTS-STARTPTS+${seconds(cue.outputTimeUs)}/TB`,
|
|
949
|
+
];
|
|
950
|
+
filters.push(`${clickFilters.join(",")}[${sourceLabel}]`);
|
|
951
|
+
const nextVideo = `video_click_${serial++}`;
|
|
952
|
+
const start = seconds(cue.outputTimeUs);
|
|
953
|
+
const end = seconds(cue.outputTimeUs + durationUs);
|
|
954
|
+
const x = zoomedSpatialCoordinate(plan, "x", decimal(cue.position.x));
|
|
955
|
+
const y = zoomedSpatialCoordinate(plan, "y", decimal(cue.position.y));
|
|
956
|
+
filters.push(
|
|
957
|
+
`[${currentVideo}][${sourceLabel}]overlay=x='${x}-w/2':y='${y}-h/2':eof_action=pass:repeatlast=0:enable='between(t,${start},${end})'[${nextVideo}]`,
|
|
958
|
+
);
|
|
959
|
+
currentVideo = nextVideo;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (plan.effects.keystrokes.enabled) {
|
|
963
|
+
const keystrokes = plan.effects.keystrokes;
|
|
964
|
+
const x = keystrokes.position === "bottom-left"
|
|
965
|
+
? "40"
|
|
966
|
+
: keystrokes.position === "bottom-center"
|
|
967
|
+
? "(w-tw)/2"
|
|
968
|
+
: "w-tw-40";
|
|
969
|
+
const cues = plan.effects.keystrokeCues.filter(({ phase }) => phase === "down")
|
|
970
|
+
.sort((left, right) => left.outputTimeUs - right.outputTimeUs);
|
|
971
|
+
for (let index = 0; index < cues.length; index += 1) {
|
|
972
|
+
const cue = cues[index]!;
|
|
973
|
+
const naturalEndUs = cue.outputTimeUs + keystrokes.holdUs;
|
|
974
|
+
const endUs = Math.min(plan.output.durationUs, naturalEndUs, cues[index + 1]?.outputTimeUs ?? naturalEndUs);
|
|
975
|
+
if (endUs <= cue.outputTimeUs) continue;
|
|
976
|
+
const label = cues.slice(0, index + 1)
|
|
977
|
+
.filter((candidate) => candidate.outputTimeUs + keystrokes.holdUs > cue.outputTimeUs)
|
|
978
|
+
.slice(-keystrokes.maxKeys)
|
|
979
|
+
.map(keystrokeLabel)
|
|
980
|
+
.join(" ");
|
|
981
|
+
const nextVideo = `video_key_${serial++}`;
|
|
982
|
+
filters.push(
|
|
983
|
+
`[${currentVideo}]drawtext=text='${escapeDrawtext(label)}':fontcolor=white:fontsize=32:box=1:boxcolor=black@0.7:boxborderw=14:x='${x}':y='h-th-40':enable='between(t,${seconds(cue.outputTimeUs)},${seconds(endUs)})'[${nextVideo}]`,
|
|
984
|
+
);
|
|
985
|
+
currentVideo = nextVideo;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (plan.effects.typedText.enabled) {
|
|
989
|
+
for (const span of plan.effects.typingSpans) {
|
|
990
|
+
if (span.secure) continue;
|
|
991
|
+
for (let index = 0; index < span.updates.length; index += 1) {
|
|
992
|
+
const update = span.updates[index]!;
|
|
993
|
+
const endUs = Math.min(span.endOutputUs, span.updates[index + 1]?.outputTimeUs ?? span.endOutputUs);
|
|
994
|
+
if (endUs <= update.outputTimeUs) continue;
|
|
995
|
+
const text = [...update.text].slice(-plan.effects.typedText.maxCharacters).join("");
|
|
996
|
+
if (text === "") continue;
|
|
997
|
+
const x = plan.effects.typedText.placement === "caption"
|
|
998
|
+
? "(w-tw)/2"
|
|
999
|
+
: zoomedSpatialCoordinate(plan, "x", decimal(update.bounds.x + 8));
|
|
1000
|
+
const y = plan.effects.typedText.placement === "caption"
|
|
1001
|
+
? "h-th-80"
|
|
1002
|
+
: zoomedSpatialCoordinate(plan, "y", decimal(update.bounds.y + 4));
|
|
1003
|
+
const nextVideo = `video_typing_${serial++}`;
|
|
1004
|
+
filters.push(
|
|
1005
|
+
`[${currentVideo}]drawtext=text='${escapeDrawtext(text)}':fontcolor=white:fontsize=28:box=1:boxcolor=black@0.72:boxborderw=10:x='${x}':y='${y}':enable='between(t,${seconds(update.outputTimeUs)},${seconds(endUs)})'[${nextVideo}]`,
|
|
1006
|
+
);
|
|
1007
|
+
currentVideo = nextVideo;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return { currentVideo, serial };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export interface PreparedOverlaySource {
|
|
1015
|
+
readonly audioStreamIndex: number | null;
|
|
1016
|
+
readonly overlayId: string;
|
|
1017
|
+
readonly path: string;
|
|
1018
|
+
readonly videoStreamIndex: number | null;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
export interface RenderBuildOptions {
|
|
1022
|
+
readonly bundleRoot: string;
|
|
1023
|
+
readonly ffmpeg: string;
|
|
1024
|
+
readonly outputPath: string;
|
|
1025
|
+
readonly overlaySources: readonly PreparedOverlaySource[];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export interface BuiltRenderInvocation {
|
|
1029
|
+
readonly argv: readonly [string, ...string[]];
|
|
1030
|
+
readonly invocation: RenderInvocation;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
export async function buildFfmpegInvocation(
|
|
1034
|
+
manifest: RecordingManifestV1,
|
|
1035
|
+
plan: RenderPlanV1,
|
|
1036
|
+
options: RenderBuildOptions,
|
|
1037
|
+
): Promise<BuiltRenderInvocation> {
|
|
1038
|
+
if (plan.output.durationUs <= 0) throw new CliError("unsupported-plan", "A zero-duration render plan cannot be encoded.");
|
|
1039
|
+
const orderedSegments = [...plan.sourceSegments].sort((left, right) =>
|
|
1040
|
+
left.output.startUs - right.output.startUs
|
|
1041
|
+
|| left.kind.localeCompare(right.kind)
|
|
1042
|
+
|| left.trackId.localeCompare(right.trackId)
|
|
1043
|
+
);
|
|
1044
|
+
const orderedOverlays = [...plan.overlays].sort((left, right) =>
|
|
1045
|
+
left.operation.zIndex - right.operation.zIndex
|
|
1046
|
+
|| left.output.startUs - right.output.startUs
|
|
1047
|
+
|| left.operation.overlayId.localeCompare(right.operation.overlayId)
|
|
1048
|
+
);
|
|
1049
|
+
const inputArguments: string[] = [];
|
|
1050
|
+
const inputIndex = new Map<RenderPlanV1["sourceSegments"][number], number>();
|
|
1051
|
+
const inputByPath = new Map<string, { readonly bytes: number; readonly index: number; readonly sha256: string }>();
|
|
1052
|
+
for (const segment of orderedSegments) {
|
|
1053
|
+
const source = manifestSourceSegment(manifest, segment);
|
|
1054
|
+
if (
|
|
1055
|
+
source.integrity.state !== "verified"
|
|
1056
|
+
|| source.integrity.bytes !== segment.bytes
|
|
1057
|
+
|| source.integrity.sha256 !== segment.sha256
|
|
1058
|
+
) {
|
|
1059
|
+
throw new CliError("invalid-data", `Render segment integrity is stale for ${segment.path}:${segment.streamIndex}.`);
|
|
1060
|
+
}
|
|
1061
|
+
const prior = inputByPath.get(segment.path);
|
|
1062
|
+
if (prior !== undefined) {
|
|
1063
|
+
if (prior.bytes !== segment.bytes || prior.sha256 !== segment.sha256) {
|
|
1064
|
+
throw new CliError("invalid-data", `Render segments disagree about whole-file integrity for ${segment.path}.`);
|
|
1065
|
+
}
|
|
1066
|
+
inputIndex.set(segment, prior.index);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const absolute = await resolveVerifiedProjectMedia({
|
|
1070
|
+
expected: segment,
|
|
1071
|
+
label: `Recording media ${segment.path}`,
|
|
1072
|
+
path: segment.path,
|
|
1073
|
+
repositoryRoot: options.bundleRoot,
|
|
1074
|
+
});
|
|
1075
|
+
const index = inputByPath.size;
|
|
1076
|
+
inputByPath.set(segment.path, { bytes: segment.bytes, index, sha256: segment.sha256 });
|
|
1077
|
+
inputIndex.set(segment, index);
|
|
1078
|
+
inputArguments.push("-i", absolute);
|
|
1079
|
+
}
|
|
1080
|
+
const overlayInputIndex = new Map<string, number>();
|
|
1081
|
+
const preparedOverlaySource = new Map<string, PreparedOverlaySource>();
|
|
1082
|
+
for (const overlay of orderedOverlays) {
|
|
1083
|
+
if (overlayInputIndex.has(overlay.operation.overlayId)) continue;
|
|
1084
|
+
const prepared = options.overlaySources.find(({ overlayId }) => overlayId === overlay.operation.overlayId);
|
|
1085
|
+
if (prepared === undefined) throw new CliError("invalid-data", `Overlay ${overlay.operation.overlayId} has no prepared source.`);
|
|
1086
|
+
const index = inputByPath.size + overlayInputIndex.size;
|
|
1087
|
+
overlayInputIndex.set(overlay.operation.overlayId, index);
|
|
1088
|
+
preparedOverlaySource.set(overlay.operation.overlayId, prepared);
|
|
1089
|
+
const source = overlay.operation.source;
|
|
1090
|
+
if (source.kind === "image" || source.kind === "svg" || source.kind === "emoji") {
|
|
1091
|
+
inputArguments.push("-loop", "1", "-i", prepared.path);
|
|
1092
|
+
} else {
|
|
1093
|
+
inputArguments.push("-i", prepared.path);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const filters: string[] = [];
|
|
1098
|
+
const videoLayers: Array<{
|
|
1099
|
+
readonly endUs: number;
|
|
1100
|
+
readonly label: string;
|
|
1101
|
+
readonly startUs: number;
|
|
1102
|
+
}> = [];
|
|
1103
|
+
const audioLabels: string[] = [];
|
|
1104
|
+
let serial = 0;
|
|
1105
|
+
for (const segment of orderedSegments) {
|
|
1106
|
+
const index = inputIndex.get(segment)!;
|
|
1107
|
+
const [localStartUs, localEndUs] = localTimes(manifest, segment);
|
|
1108
|
+
const speed = sourceSpeed(plan, segment.source.startUs, segment.source.endUs);
|
|
1109
|
+
if (segment.kind === "display-video") {
|
|
1110
|
+
const label = `v${serial++}`;
|
|
1111
|
+
filters.push(
|
|
1112
|
+
`[${inputSpecifier(index, segment.streamIndex)}]trim=start=${seconds(localStartUs)}:end=${seconds(localEndUs)},setpts=(PTS-STARTPTS)/${decimal(speed)}+${seconds(segment.output.startUs)}/TB,scale=${plan.output.pixelWidth}:${plan.output.pixelHeight}[${label}]`,
|
|
1113
|
+
);
|
|
1114
|
+
videoLayers.push({ endUs: segment.output.endUs, label, startUs: segment.output.startUs });
|
|
1115
|
+
} else if (segment.kind === "system-audio" || segment.kind === "microphone-audio") {
|
|
1116
|
+
const label = `a${serial++}`;
|
|
1117
|
+
filters.push(
|
|
1118
|
+
`[${inputSpecifier(index, segment.streamIndex)}]atrim=start=${seconds(localStartUs)}:end=${seconds(localEndUs)},asetpts=PTS-STARTPTS,${atempo(speed)},asetpts=PTS-STARTPTS+${seconds(segment.output.startUs)}/TB[${label}]`,
|
|
1119
|
+
);
|
|
1120
|
+
audioLabels.push(label);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (videoLayers.length === 0) throw new CliError("unsupported-plan", "Selected display has no executable video segments.");
|
|
1124
|
+
filters.push(
|
|
1125
|
+
`color=c=black:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${decimal(plan.output.frameRate)}:d=${seconds(plan.output.durationUs)},format=yuv420p[video_timeline]`,
|
|
1126
|
+
);
|
|
1127
|
+
let joinedVideo = "video_timeline";
|
|
1128
|
+
for (const layer of videoLayers) {
|
|
1129
|
+
const next = `video_segment_${serial++}`;
|
|
1130
|
+
filters.push(
|
|
1131
|
+
`[${joinedVideo}][${layer.label}]overlay=x=0:y=0:eof_action=pass:repeatlast=0:enable='between(t,${seconds(layer.startUs)},${seconds(layer.endUs)})'[${next}]`,
|
|
1132
|
+
);
|
|
1133
|
+
joinedVideo = next;
|
|
1134
|
+
}
|
|
1135
|
+
const zoom = zoomFilter(plan);
|
|
1136
|
+
let currentVideo = joinedVideo;
|
|
1137
|
+
if (zoom !== null) {
|
|
1138
|
+
filters.push(`[${currentVideo}]${zoom}[video_zoomed]`);
|
|
1139
|
+
currentVideo = "video_zoomed";
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
({ currentVideo, serial } = applyMetadataEffects(plan, filters, currentVideo, serial));
|
|
1143
|
+
|
|
1144
|
+
for (const resolved of orderedOverlays) {
|
|
1145
|
+
const overlay = resolved.operation;
|
|
1146
|
+
const index = overlayInputIndex.get(overlay.overlayId)!;
|
|
1147
|
+
const startUs = resolved.output.startUs;
|
|
1148
|
+
const endUs = resolved.output.endUs;
|
|
1149
|
+
const source = overlay.source;
|
|
1150
|
+
const prepared = preparedOverlaySource.get(overlay.overlayId)!;
|
|
1151
|
+
const overlayLabel = `overlay_${serial++}`;
|
|
1152
|
+
const chain: string[] = [];
|
|
1153
|
+
let visibleEndUs = endUs;
|
|
1154
|
+
if (source.kind === "gif" || source.kind === "video") {
|
|
1155
|
+
const animated = animatedVideoFilters(
|
|
1156
|
+
source,
|
|
1157
|
+
overlay.intrinsicSize,
|
|
1158
|
+
endUs - startUs,
|
|
1159
|
+
startUs,
|
|
1160
|
+
plan.output.frameRate,
|
|
1161
|
+
);
|
|
1162
|
+
chain.push(...animated.filters);
|
|
1163
|
+
visibleEndUs = startUs + animated.visibleDurationUs;
|
|
1164
|
+
} else {
|
|
1165
|
+
chain.push(`trim=duration=${seconds(endUs - startUs)}`);
|
|
1166
|
+
chain.push(`setpts=PTS-STARTPTS+${seconds(startUs)}/TB`);
|
|
1167
|
+
}
|
|
1168
|
+
const effectiveOverlay = withEffectiveOverlayAnimations(overlay, visibleEndUs - startUs);
|
|
1169
|
+
chain.push(...overlayGeometryFilters(effectiveOverlay, startUs, visibleEndUs));
|
|
1170
|
+
const videoStreamIndex = source.kind === "gif" || source.kind === "video"
|
|
1171
|
+
? prepared.videoStreamIndex ?? source.playback.videoStreamIndex
|
|
1172
|
+
: null;
|
|
1173
|
+
filters.push(`[${animatedStreamSpecifier(index, "v", videoStreamIndex)}]${chain.join(",")}[${overlayLabel}]`);
|
|
1174
|
+
const nextVideo = `video_${serial++}`;
|
|
1175
|
+
const x = overlayPositionExpression(effectiveOverlay, "x", startUs, visibleEndUs);
|
|
1176
|
+
const y = overlayPositionExpression(effectiveOverlay, "y", startUs, visibleEndUs);
|
|
1177
|
+
const enable = `between(t,${seconds(startUs)},${seconds(visibleEndUs)})`;
|
|
1178
|
+
const blendMode = overlay.blendMode ?? "normal";
|
|
1179
|
+
if (blendMode === "normal") {
|
|
1180
|
+
filters.push(
|
|
1181
|
+
`[${currentVideo}][${overlayLabel}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${nextVideo}]`,
|
|
1182
|
+
);
|
|
1183
|
+
} else {
|
|
1184
|
+
const transparent = `overlay_blend_canvas_${serial++}`;
|
|
1185
|
+
const positioned = `overlay_blend_positioned_${serial++}`;
|
|
1186
|
+
const layerColor = `overlay_blend_color_${serial++}`;
|
|
1187
|
+
const layerAlphaSource = `overlay_blend_alpha_source_${serial++}`;
|
|
1188
|
+
const layerMask = `overlay_blend_mask_${serial++}`;
|
|
1189
|
+
const baseBlend = `overlay_blend_base_${serial++}`;
|
|
1190
|
+
const baseMerge = `overlay_blend_merge_${serial++}`;
|
|
1191
|
+
const blended = `overlay_blend_result_${serial++}`;
|
|
1192
|
+
filters.push(
|
|
1193
|
+
`color=c=black@0:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${decimal(plan.output.frameRate)}:d=${seconds(plan.output.durationUs)},format=rgba[${transparent}]`,
|
|
1194
|
+
`[${transparent}][${overlayLabel}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${positioned}]`,
|
|
1195
|
+
`[${positioned}]format=rgba,split=2[${layerColor}][${layerAlphaSource}]`,
|
|
1196
|
+
`[${layerAlphaSource}]alphaextract[${layerMask}]`,
|
|
1197
|
+
`[${currentVideo}]split=2[${baseBlend}][${baseMerge}]`,
|
|
1198
|
+
`[${baseBlend}][${layerColor}]blend=all_mode=${blendMode}[${blended}]`,
|
|
1199
|
+
`[${baseMerge}][${blended}][${layerMask}]maskedmerge[${nextVideo}]`,
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
currentVideo = nextVideo;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
let currentPrimaryAudio: string | undefined;
|
|
1206
|
+
if (audioLabels.length > 0) {
|
|
1207
|
+
filters.push(`anullsrc=r=48000:cl=stereo:d=${seconds(plan.output.durationUs)}[audio_timeline]`);
|
|
1208
|
+
currentPrimaryAudio = "audio_primary";
|
|
1209
|
+
filters.push(
|
|
1210
|
+
`[audio_timeline]${audioLabels.map((label) => `[${label}]`).join("")}amix=inputs=${audioLabels.length + 1}:duration=longest:dropout_transition=0:normalize=0,atrim=duration=${seconds(plan.output.durationUs)}[${currentPrimaryAudio}]`,
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
const overlayAudioLabels: string[] = [];
|
|
1214
|
+
const primaryDuckEnvelopes: { readonly condition: string; readonly target: number }[] = [];
|
|
1215
|
+
for (const resolved of orderedOverlays) {
|
|
1216
|
+
const source = resolved.operation.source;
|
|
1217
|
+
if (source.kind !== "video" || source.audioPolicy.kind === "mute") continue;
|
|
1218
|
+
const index = overlayInputIndex.get(resolved.operation.overlayId)!;
|
|
1219
|
+
const overlayAudio = `overlay_audio_${serial++}`;
|
|
1220
|
+
const volume = source.audioPolicy.volume;
|
|
1221
|
+
const animatedAudio = animatedAudioFilters(
|
|
1222
|
+
source,
|
|
1223
|
+
resolved.output.endUs - resolved.output.startUs,
|
|
1224
|
+
resolved.output.startUs,
|
|
1225
|
+
);
|
|
1226
|
+
if (animatedAudio === null) continue;
|
|
1227
|
+
const prepared = preparedOverlaySource.get(resolved.operation.overlayId)!;
|
|
1228
|
+
filters.push(
|
|
1229
|
+
`[${animatedStreamSpecifier(index, "a", prepared.audioStreamIndex ?? source.playback.audioStreamIndex)}]${animatedAudio.filters.join(",")},volume=${decimal(volume)}[${overlayAudio}]`,
|
|
1230
|
+
);
|
|
1231
|
+
if (source.audioPolicy.kind === "duck-primary" && currentPrimaryAudio !== undefined) {
|
|
1232
|
+
const duckCondition = animatedAudio.loopDuck === null
|
|
1233
|
+
? `between(t,${seconds(animatedAudio.audibleStartUs)},${seconds(animatedAudio.audibleEndUs)})`
|
|
1234
|
+
: [
|
|
1235
|
+
`between(t,${seconds(resolved.output.startUs)},${seconds(resolved.output.endUs)})`,
|
|
1236
|
+
`gte(mod((t-${seconds(resolved.output.startUs)})*${decimal(source.playback.playbackRate)},${seconds(animatedAudio.loopDuck.sourceWindowUs)}),${seconds(animatedAudio.loopDuck.audioDelayUs)})`,
|
|
1237
|
+
`lt(mod((t-${seconds(resolved.output.startUs)})*${decimal(source.playback.playbackRate)},${seconds(animatedAudio.loopDuck.sourceWindowUs)}),${seconds(animatedAudio.loopDuck.audioEndUs)})`,
|
|
1238
|
+
].join("*");
|
|
1239
|
+
primaryDuckEnvelopes.push({ condition: duckCondition, target: source.audioPolicy.duckPrimaryTo });
|
|
1240
|
+
}
|
|
1241
|
+
overlayAudioLabels.push(overlayAudio);
|
|
1242
|
+
}
|
|
1243
|
+
if (currentPrimaryAudio !== undefined && primaryDuckEnvelopes.length > 0) {
|
|
1244
|
+
const ducked = `audio_ducked_${serial++}`;
|
|
1245
|
+
const volume = primaryDuckEnvelopes
|
|
1246
|
+
.map(envelope => `if(${envelope.condition},${decimal(envelope.target)},1)`)
|
|
1247
|
+
.reduce((left, right) => `min(${left},${right})`);
|
|
1248
|
+
filters.push(`[${currentPrimaryAudio}]volume='${volume}':eval=frame[${ducked}]`);
|
|
1249
|
+
currentPrimaryAudio = ducked;
|
|
1250
|
+
}
|
|
1251
|
+
const audioMixLabels = [
|
|
1252
|
+
...(currentPrimaryAudio === undefined ? [] : [currentPrimaryAudio]),
|
|
1253
|
+
...overlayAudioLabels,
|
|
1254
|
+
];
|
|
1255
|
+
let currentAudio = audioMixLabels[0];
|
|
1256
|
+
if (audioMixLabels.length > 1) {
|
|
1257
|
+
const mixed = `audio_mix_${serial++}`;
|
|
1258
|
+
filters.push(
|
|
1259
|
+
`${audioMixLabels.map(label => `[${label}]`).join("")}amix=inputs=${audioMixLabels.length}:duration=longest:dropout_transition=0:normalize=0[${mixed}]`,
|
|
1260
|
+
);
|
|
1261
|
+
currentAudio = mixed;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const outputRelative = options.outputPath.startsWith(`${options.bundleRoot}/`)
|
|
1265
|
+
? options.outputPath.slice(options.bundleRoot.length + 1)
|
|
1266
|
+
: (() => { throw new CliError("unsafe-path", "Render output must remain inside its recording bundle."); })();
|
|
1267
|
+
if (!outputRelative.startsWith("renders/")) {
|
|
1268
|
+
throw new CliError("unsafe-path", "Render output must remain under renders/ so source media stays immutable.");
|
|
1269
|
+
}
|
|
1270
|
+
const filterGraph = await materializeFilterScript({
|
|
1271
|
+
graph: filters.join(";"),
|
|
1272
|
+
relativeDirectory: "derived/filter-graphs",
|
|
1273
|
+
root: options.bundleRoot,
|
|
1274
|
+
});
|
|
1275
|
+
const arguments_: string[] = [
|
|
1276
|
+
"-hide_banner", "-nostdin", "-y",
|
|
1277
|
+
...inputArguments,
|
|
1278
|
+
"-filter_complex_script", filterGraph.path,
|
|
1279
|
+
"-map", `[${currentVideo}]`,
|
|
1280
|
+
...(currentAudio === undefined ? [] : ["-map", `[${currentAudio}]`]),
|
|
1281
|
+
"-c:v", "libx264",
|
|
1282
|
+
"-pix_fmt", "yuv420p",
|
|
1283
|
+
...(currentAudio === undefined ? [] : ["-c:a", "aac"]),
|
|
1284
|
+
"-t", seconds(plan.output.durationUs),
|
|
1285
|
+
"-movflags", "+faststart",
|
|
1286
|
+
options.outputPath,
|
|
1287
|
+
];
|
|
1288
|
+
const invocation = RenderInvocationSchema.parse({
|
|
1289
|
+
arguments: arguments_,
|
|
1290
|
+
executable: "ffmpeg",
|
|
1291
|
+
filterGraph: {
|
|
1292
|
+
bytes: filterGraph.bytes,
|
|
1293
|
+
path: filterGraph.repositoryPath,
|
|
1294
|
+
sha256: filterGraph.sha256,
|
|
1295
|
+
},
|
|
1296
|
+
outputPath: outputRelative,
|
|
1297
|
+
renderPlanSha256: canonicalJsonSha256(plan),
|
|
1298
|
+
});
|
|
1299
|
+
return { argv: [options.ffmpeg, ...arguments_], invocation };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
export interface SvgPreparationOptions {
|
|
1303
|
+
readonly bundleRoot: string;
|
|
1304
|
+
readonly dryRun: boolean;
|
|
1305
|
+
readonly ffprobe?: string;
|
|
1306
|
+
readonly rsvgConvert: string | undefined;
|
|
1307
|
+
readonly runner: ProcessRunner;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
export interface OverlayPreparation {
|
|
1311
|
+
readonly sources: readonly PreparedOverlaySource[];
|
|
1312
|
+
readonly steps: readonly { readonly argv: readonly string[]; readonly output: string }[];
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
export async function prepareOverlaySources(
|
|
1316
|
+
plan: RenderPlanV1,
|
|
1317
|
+
options: SvgPreparationOptions,
|
|
1318
|
+
): Promise<OverlayPreparation> {
|
|
1319
|
+
const sources: PreparedOverlaySource[] = [];
|
|
1320
|
+
const steps: { readonly argv: readonly string[]; readonly output: string }[] = [];
|
|
1321
|
+
for (const resolved of plan.overlays) {
|
|
1322
|
+
const operation = resolved.operation;
|
|
1323
|
+
if (sources.some(({ overlayId }) => overlayId === operation.overlayId)) continue;
|
|
1324
|
+
const input = await resolveVerifiedProjectMedia({
|
|
1325
|
+
expected: operation.source.asset,
|
|
1326
|
+
label: `Overlay ${operation.overlayId}`,
|
|
1327
|
+
path: operation.source.asset.path,
|
|
1328
|
+
repositoryRoot: options.bundleRoot,
|
|
1329
|
+
});
|
|
1330
|
+
if (operation.source.asset.mediaType !== "image/svg+xml") {
|
|
1331
|
+
if (options.ffprobe === undefined) {
|
|
1332
|
+
throw new CliError("unavailable", `Overlay ${operation.overlayId} requires FFprobe dimension verification.`);
|
|
1333
|
+
}
|
|
1334
|
+
const probed = await probeVisualMediaSummary(options.ffprobe, options.runner, input);
|
|
1335
|
+
if (
|
|
1336
|
+
probed.pixelWidth !== operation.intrinsicSize.width
|
|
1337
|
+
|| probed.pixelHeight !== operation.intrinsicSize.height
|
|
1338
|
+
) {
|
|
1339
|
+
throw new CliError("invalid-data", `Overlay ${operation.overlayId} intrinsic dimensions do not match its media.`);
|
|
1340
|
+
}
|
|
1341
|
+
let audioStreamIndex: number | null = null;
|
|
1342
|
+
let videoStreamIndex: number | null = null;
|
|
1343
|
+
if (operation.source.kind === "gif" || operation.source.kind === "video") {
|
|
1344
|
+
const playback = operation.source.playback;
|
|
1345
|
+
if (
|
|
1346
|
+
playback.videoStreamIndex !== null
|
|
1347
|
+
&& playback.videoStreamIndex !== undefined
|
|
1348
|
+
&& playback.videoStreamIndex !== probed.videoStreamIndex
|
|
1349
|
+
) {
|
|
1350
|
+
throw new CliError("invalid-data", `Overlay ${operation.overlayId} selected video stream does not match its media.`);
|
|
1351
|
+
}
|
|
1352
|
+
if (
|
|
1353
|
+
playback.audioStreamIndex !== null
|
|
1354
|
+
&& playback.audioStreamIndex !== undefined
|
|
1355
|
+
&& playback.audioStreamIndex !== probed.audioStreamIndex
|
|
1356
|
+
) {
|
|
1357
|
+
throw new CliError("invalid-data", `Overlay ${operation.overlayId} selected audio stream does not match its media.`);
|
|
1358
|
+
}
|
|
1359
|
+
if (
|
|
1360
|
+
operation.source.kind === "video"
|
|
1361
|
+
&& operation.source.audioPolicy.kind !== "mute"
|
|
1362
|
+
&& probed.audioStreamIndex === null
|
|
1363
|
+
) {
|
|
1364
|
+
throw new CliError("invalid-data", `Overlay ${operation.overlayId} requires an audio stream that is absent from its media.`);
|
|
1365
|
+
}
|
|
1366
|
+
audioStreamIndex = probed.audioStreamIndex;
|
|
1367
|
+
videoStreamIndex = probed.videoStreamIndex;
|
|
1368
|
+
}
|
|
1369
|
+
sources.push({ audioStreamIndex, overlayId: operation.overlayId, path: input, videoStreamIndex });
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const svgSize = await inspectSvgIntrinsicSize(input);
|
|
1373
|
+
if (
|
|
1374
|
+
svgSize.width !== operation.intrinsicSize.width
|
|
1375
|
+
|| svgSize.height !== operation.intrinsicSize.height
|
|
1376
|
+
) {
|
|
1377
|
+
throw new CliError("invalid-data", `Overlay ${operation.overlayId} intrinsic dimensions do not match its SVG.`);
|
|
1378
|
+
}
|
|
1379
|
+
if (options.rsvgConvert === undefined) {
|
|
1380
|
+
throw new CliError(
|
|
1381
|
+
"unavailable",
|
|
1382
|
+
`SVG overlay ${operation.overlayId} requires rsvg-convert; the resolved render plan was preserved.`,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
const derivedRoot = await resolveSafePath(options.bundleRoot, "derived/svg");
|
|
1386
|
+
await ensurePrivateDirectory(derivedRoot);
|
|
1387
|
+
const output = join(derivedRoot, `${operation.source.asset.sha256}.png`);
|
|
1388
|
+
if (options.dryRun) {
|
|
1389
|
+
const argv = [
|
|
1390
|
+
options.rsvgConvert,
|
|
1391
|
+
"--keep-aspect-ratio",
|
|
1392
|
+
"--width", String(svgSize.width),
|
|
1393
|
+
"--height", String(svgSize.height),
|
|
1394
|
+
"-o", output,
|
|
1395
|
+
input,
|
|
1396
|
+
] as const;
|
|
1397
|
+
steps.push({ argv, output });
|
|
1398
|
+
} else {
|
|
1399
|
+
const temporary = join(
|
|
1400
|
+
derivedRoot,
|
|
1401
|
+
`.${operation.source.asset.sha256}.${randomUUID()}.tmp.png`,
|
|
1402
|
+
);
|
|
1403
|
+
const argv = [
|
|
1404
|
+
options.rsvgConvert,
|
|
1405
|
+
"--keep-aspect-ratio",
|
|
1406
|
+
"--width", String(svgSize.width),
|
|
1407
|
+
"--height", String(svgSize.height),
|
|
1408
|
+
"-o", temporary,
|
|
1409
|
+
input,
|
|
1410
|
+
] as const;
|
|
1411
|
+
steps.push({ argv, output });
|
|
1412
|
+
try {
|
|
1413
|
+
try {
|
|
1414
|
+
const existing = await lstat(output);
|
|
1415
|
+
if (existing.isSymbolicLink() || !existing.isFile()) {
|
|
1416
|
+
throw new CliError("unsafe-path", `SVG derivative must be a physical regular file: ${output}`);
|
|
1417
|
+
}
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
1420
|
+
}
|
|
1421
|
+
const result = await options.runner.run(argv);
|
|
1422
|
+
if (result.exitCode !== 0) {
|
|
1423
|
+
throw new CliError("subprocess", `rsvg-convert failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
1424
|
+
}
|
|
1425
|
+
await fingerprintPhysicalProjectMedia(temporary, MAX_DERIVED_OVERLAY_BYTES);
|
|
1426
|
+
const derivativeSize = await inspectPngIntrinsicSize(temporary);
|
|
1427
|
+
if (derivativeSize.width !== svgSize.width || derivativeSize.height !== svgSize.height) {
|
|
1428
|
+
throw new CliError("invalid-data", `SVG derivative dimensions do not match overlay ${operation.overlayId}.`);
|
|
1429
|
+
}
|
|
1430
|
+
await chmod(temporary, 0o600);
|
|
1431
|
+
await rename(temporary, output);
|
|
1432
|
+
} finally {
|
|
1433
|
+
await rm(temporary, { force: true });
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
sources.push({ audioStreamIndex: null, overlayId: operation.overlayId, path: output, videoStreamIndex: null });
|
|
1437
|
+
}
|
|
1438
|
+
return { sources, steps };
|
|
1439
|
+
}
|