@1agh/maude 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +1451 -324
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +568 -24
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +258 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +656 -12
- package/apps/studio/canvas-lib.tsx +637 -10
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +2385 -699
- package/apps/studio/client/export-center.jsx +35 -1
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +18 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +432 -0
- package/apps/studio/client/styles/3-shell-maude.css +376 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +42 -8
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +5572 -21
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/exporters/jobs.ts +47 -5
- package/apps/studio/exporters/video.ts +24 -4
- package/apps/studio/footage/schema.test.ts +287 -0
- package/apps/studio/footage/schema.ts +662 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +199 -21
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +1039 -22
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +20 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +18 -2
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +154 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +49 -21
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +40 -1
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +149 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +67 -3
- package/cli/lib/gitignore-block.mjs +2 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +13 -11
- package/plugins/design/dependencies.json +18 -0
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file footage-store.ts — Footage-analysis + EDL sidecar persistence
|
|
3
|
+
* (feature-footage-analysis-director, Task 2).
|
|
4
|
+
* @scope apps/studio/footage-store.ts
|
|
5
|
+
* @purpose Read / write the two VERSIONED sidecars behind the footage
|
|
6
|
+
* "director" pipeline (DDR-115 taxonomy):
|
|
7
|
+
* • `assets/<sha8>.footage.json` — a `FootageAnalysis` (per clip)
|
|
8
|
+
* • `<designRoot>/<slug>.edl.json` — an `Edl` (per cut)
|
|
9
|
+
* Mirrors `photo-store.ts`'s `createX(ctx)` factory + `Bun.write`/
|
|
10
|
+
* `Bun.file` shape. Server-side only — imports the DEPENDENCY-FREE
|
|
11
|
+
* `footage/schema.ts`.
|
|
12
|
+
*
|
|
13
|
+
* @security UNLIKE `/_api/photo-edit`, the `/_api/footage` route is
|
|
14
|
+
* MAIN-ORIGIN ONLY (privileged — NOT in either canvas allowlist).
|
|
15
|
+
* It is written by the `footage-analyst` / `footage-director` agents
|
|
16
|
+
* over loopback, never by the untrusted canvas iframe (DDR-054), so
|
|
17
|
+
* a `GET → 405` assertion from the canvas origin guards it
|
|
18
|
+
* (canvas-origin-gate.test.ts). The write path is still derived from
|
|
19
|
+
* caller-supplied params, so the DDR-088 cap stack is preserved:
|
|
20
|
+
* 1. strict sha8 / slug extraction (hex or kebab, bounded) —
|
|
21
|
+
* rejects `..`, `/`, `%2f`, absolute paths by construction;
|
|
22
|
+
* 2. explicit containment assert (poisoned-designRoot backstop);
|
|
23
|
+
* 3. structural `validateFootageAnalysis` / `validateEdl`;
|
|
24
|
+
* 4. body/file size cap (256 KB — generous for analysis JSON).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
import type { Context } from './context.ts';
|
|
30
|
+
import {
|
|
31
|
+
EDL_VERSION,
|
|
32
|
+
type Edl,
|
|
33
|
+
FOOTAGE_ANALYSIS_VERSION,
|
|
34
|
+
type FootageAnalysis,
|
|
35
|
+
validateEdl,
|
|
36
|
+
validateFootageAnalysis,
|
|
37
|
+
} from './footage/schema.ts';
|
|
38
|
+
|
|
39
|
+
/** Max bytes for either sidecar (read + write). Analysis JSON is small; slack. */
|
|
40
|
+
export const FOOTAGE_MAX_BYTES = 256 * 1024;
|
|
41
|
+
|
|
42
|
+
const SHA_RE = /^[0-9a-f]{8,64}$/;
|
|
43
|
+
// A cut slug: lowercase kebab, bounded. Matches the `slug.sh` normalization
|
|
44
|
+
// shape — no path segment, no dot, so `..`/`/`/absolute can never survive.
|
|
45
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract + validate the content-address sha8 from an `asset` param, in any of
|
|
49
|
+
* the forms a caller might hold (`assets/<sha8>.<ext>`, `/assets/<sha8>.png`,
|
|
50
|
+
* `<sha8>.png`, bare `<sha8>`). The hex regex is the traversal defense.
|
|
51
|
+
* (Copied from photo-store.assetSha8 — same contract, kept local so the two
|
|
52
|
+
* stores stay independent.)
|
|
53
|
+
*/
|
|
54
|
+
export function assetSha8(param: string | null | undefined): string | null {
|
|
55
|
+
if (typeof param !== 'string') return null;
|
|
56
|
+
let s = param.trim();
|
|
57
|
+
if (!s) return null;
|
|
58
|
+
s = s.replace(/^\/+/, '');
|
|
59
|
+
s = s.replace(/^assets\//, '');
|
|
60
|
+
s = s.replace(/\.[A-Za-z0-9]+$/, '');
|
|
61
|
+
return SHA_RE.test(s) ? s : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Validate a cut slug (already-normalized kebab), or null. */
|
|
65
|
+
export function edlSlug(param: string | null | undefined): string | null {
|
|
66
|
+
if (typeof param !== 'string') return null;
|
|
67
|
+
const s = param.trim();
|
|
68
|
+
return SLUG_RE.test(s) ? s : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FootageStore {
|
|
72
|
+
analysisPathForSha8(sha8: string): string;
|
|
73
|
+
edlPathForSlug(slug: string): string;
|
|
74
|
+
getAnalysis(assetParam: string | null | undefined): Promise<FootageAnalysis | null>;
|
|
75
|
+
saveAnalysis(
|
|
76
|
+
assetParam: string | null | undefined,
|
|
77
|
+
analysis: unknown
|
|
78
|
+
): Promise<
|
|
79
|
+
| { ok: true; path: string; analysis: FootageAnalysis }
|
|
80
|
+
| { ok: false; status: number; error: string }
|
|
81
|
+
>;
|
|
82
|
+
getEdl(slugParam: string | null | undefined): Promise<Edl | null>;
|
|
83
|
+
saveEdl(
|
|
84
|
+
slugParam: string | null | undefined,
|
|
85
|
+
edl: unknown
|
|
86
|
+
): Promise<{ ok: true; path: string; edl: Edl } | { ok: false; status: number; error: string }>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createFootageStore(ctx: Context): FootageStore {
|
|
90
|
+
const designRoot = ctx.paths.designRoot;
|
|
91
|
+
const assetsDir = path.join(designRoot, 'assets');
|
|
92
|
+
|
|
93
|
+
function analysisPathForSha8(sha8: string): string {
|
|
94
|
+
if (!SHA_RE.test(sha8)) throw new Error(`invalid sha8: ${sha8}`);
|
|
95
|
+
const abs = path.join(assetsDir, `${sha8}.footage.json`);
|
|
96
|
+
const rel = path.relative(assetsDir, abs);
|
|
97
|
+
if (rel !== `${sha8}.footage.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
98
|
+
throw new Error(`footage path escaped assets/: ${abs}`);
|
|
99
|
+
}
|
|
100
|
+
return abs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function edlPathForSlug(slug: string): string {
|
|
104
|
+
if (!SLUG_RE.test(slug)) throw new Error(`invalid slug: ${slug}`);
|
|
105
|
+
const abs = path.join(designRoot, `${slug}.edl.json`);
|
|
106
|
+
const rel = path.relative(designRoot, abs);
|
|
107
|
+
if (rel !== `${slug}.edl.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
108
|
+
throw new Error(`edl path escaped designRoot: ${abs}`);
|
|
109
|
+
}
|
|
110
|
+
return abs;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readSidecar<T>(
|
|
114
|
+
abs: string,
|
|
115
|
+
validate: (v: unknown) => { ok: boolean }
|
|
116
|
+
): Promise<T | null> {
|
|
117
|
+
try {
|
|
118
|
+
const file = Bun.file(abs);
|
|
119
|
+
if (!(await file.exists())) return null;
|
|
120
|
+
if (file.size > FOOTAGE_MAX_BYTES) return null;
|
|
121
|
+
const parsed = JSON.parse(await file.text());
|
|
122
|
+
return validate(parsed).ok ? (parsed as T) : null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function getAnalysis(
|
|
129
|
+
assetParam: string | null | undefined
|
|
130
|
+
): Promise<FootageAnalysis | null> {
|
|
131
|
+
const sha8 = assetSha8(assetParam);
|
|
132
|
+
if (!sha8) return null;
|
|
133
|
+
return readSidecar<FootageAnalysis>(analysisPathForSha8(sha8), validateFootageAnalysis);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function saveAnalysis(
|
|
137
|
+
assetParam: string | null | undefined,
|
|
138
|
+
analysis: unknown
|
|
139
|
+
): Promise<
|
|
140
|
+
| { ok: true; path: string; analysis: FootageAnalysis }
|
|
141
|
+
| { ok: false; status: number; error: string }
|
|
142
|
+
> {
|
|
143
|
+
const sha8 = assetSha8(assetParam);
|
|
144
|
+
if (!sha8) return { ok: false, status: 400, error: 'invalid or missing asset (expected sha8)' };
|
|
145
|
+
|
|
146
|
+
let serialized: string;
|
|
147
|
+
try {
|
|
148
|
+
serialized = JSON.stringify(analysis ?? {});
|
|
149
|
+
} catch {
|
|
150
|
+
return { ok: false, status: 400, error: 'body is not serializable JSON' };
|
|
151
|
+
}
|
|
152
|
+
if (Buffer.byteLength(serialized, 'utf8') > FOOTAGE_MAX_BYTES) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
status: 413,
|
|
156
|
+
error: `footage analysis exceeds ${FOOTAGE_MAX_BYTES} bytes`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const check = validateFootageAnalysis(analysis);
|
|
161
|
+
if (!check.ok) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
status: 400,
|
|
165
|
+
error: `invalid FootageAnalysis: ${check.errors.join('; ')}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Stamp version only; the sidecar filename (<sha8>.footage.json) is the
|
|
170
|
+
// authoritative source key. `asset` is already validated as a well-formed
|
|
171
|
+
// relative path — keep the client's value verbatim.
|
|
172
|
+
const toWrite: FootageAnalysis = {
|
|
173
|
+
...(analysis as FootageAnalysis),
|
|
174
|
+
version: FOOTAGE_ANALYSIS_VERSION,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let abs: string;
|
|
178
|
+
try {
|
|
179
|
+
abs = analysisPathForSha8(sha8);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
await Bun.write(abs, JSON.stringify(toWrite, null, 2));
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, path: path.posix.join('assets', `${sha8}.footage.json`), analysis: toWrite };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function getEdl(slugParam: string | null | undefined): Promise<Edl | null> {
|
|
192
|
+
const slug = edlSlug(slugParam);
|
|
193
|
+
if (!slug) return null;
|
|
194
|
+
return readSidecar<Edl>(edlPathForSlug(slug), validateEdl);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function saveEdl(
|
|
198
|
+
slugParam: string | null | undefined,
|
|
199
|
+
edl: unknown
|
|
200
|
+
): Promise<{ ok: true; path: string; edl: Edl } | { ok: false; status: number; error: string }> {
|
|
201
|
+
const slug = edlSlug(slugParam);
|
|
202
|
+
if (!slug) return { ok: false, status: 400, error: 'invalid or missing slug (expected kebab)' };
|
|
203
|
+
|
|
204
|
+
let serialized: string;
|
|
205
|
+
try {
|
|
206
|
+
serialized = JSON.stringify(edl ?? {});
|
|
207
|
+
} catch {
|
|
208
|
+
return { ok: false, status: 400, error: 'body is not serializable JSON' };
|
|
209
|
+
}
|
|
210
|
+
if (Buffer.byteLength(serialized, 'utf8') > FOOTAGE_MAX_BYTES) {
|
|
211
|
+
return { ok: false, status: 413, error: `edl exceeds ${FOOTAGE_MAX_BYTES} bytes` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const check = validateEdl(edl);
|
|
215
|
+
if (!check.ok) {
|
|
216
|
+
return { ok: false, status: 400, error: `invalid Edl: ${check.errors.join('; ')}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const toWrite: Edl = { ...(edl as Edl), version: EDL_VERSION };
|
|
220
|
+
let abs: string;
|
|
221
|
+
try {
|
|
222
|
+
abs = edlPathForSlug(slug);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
await Bun.write(abs, JSON.stringify(toWrite, null, 2));
|
|
228
|
+
} catch (e) {
|
|
229
|
+
return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, path: `${slug}.edl.json`, edl: toWrite };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { analysisPathForSha8, edlPathForSlug, getAnalysis, saveAnalysis, getEdl, saveEdl };
|
|
235
|
+
}
|
package/apps/studio/fs-watch.ts
CHANGED
|
@@ -34,6 +34,16 @@ export function createFsWatch(ctx: Context): FsWatch {
|
|
|
34
34
|
|
|
35
35
|
function start() {
|
|
36
36
|
if (watcher) return;
|
|
37
|
+
// Capture/CI opt-out (MAUDE_NO_WATCH=1): a headless render server has no
|
|
38
|
+
// interactive editor to hot-reload, so it never needs the watcher. It's a
|
|
39
|
+
// DEFENSIVE measure for long video exports — an HMR hard-reload broadcast
|
|
40
|
+
// fired mid-capture (from a concurrent write to designRoot, e.g. a peer edit
|
|
41
|
+
// or a runtime-state save NOT in the skip-list below) destroys the Playwright
|
|
42
|
+
// page's execution context. (NB: in the 2026-07-10 cinematic-cut dogfood the
|
|
43
|
+
// ACTUAL cause of the "Execution context was destroyed" crashes turned out to
|
|
44
|
+
// be renderer compositing pressure — too many full-frame mix-blend-mode /
|
|
45
|
+
// filter layers per frame — not the watcher; this guard is belt-and-braces.)
|
|
46
|
+
if (process.env.MAUDE_NO_WATCH === '1') return;
|
|
37
47
|
try {
|
|
38
48
|
watcher = watch(ctx.paths.designRoot, { recursive: true }, (_event, filename) => {
|
|
39
49
|
if (!filename) return;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// generation/adapters/elevenlabs.ts — ElevenLabs audio stack via the REST API
|
|
2
|
+
// (DDR-164, Phase 2). Direct BYOK: the user's own ElevenLabs key →
|
|
3
|
+
// api.elevenlabs.io directly, no aggregator, no SDK (plain `fetch`). ONE key
|
|
4
|
+
// covers the whole audio stack:
|
|
5
|
+
//
|
|
6
|
+
// • Music — POST /v1/music (prompt → mp3)
|
|
7
|
+
// • SFX — POST /v1/sound-generation (prompt → mp3)
|
|
8
|
+
// • TTS — POST /v1/text-to-speech/{voice} (text + voice_id → mp3)
|
|
9
|
+
// • Scribe — POST /v1/speech-to-text (audio → word-timestamps → SRT)
|
|
10
|
+
//
|
|
11
|
+
// The audio verbs return binary mp3 in ONE round trip → an already-`done` Job
|
|
12
|
+
// (callers never branch on sync/async), localized into assets/<sha8>.mp3.
|
|
13
|
+
// Scribe returns word-level timings that captions.ts turns into an SRT the EDL
|
|
14
|
+
// caption track consumes.
|
|
15
|
+
//
|
|
16
|
+
// Security mirrors the Gemini adapter (F1/F2): the key is sent ONLY in the
|
|
17
|
+
// `xi-api-key` header (never a query string → no redirect/log leak); the outbound
|
|
18
|
+
// base is https + host-allowlisted before the key-bearing request; and every
|
|
19
|
+
// response body is byte-capped before it is buffered (RAM-DoS guard).
|
|
20
|
+
|
|
21
|
+
import { wordsToSrt } from '../captions.ts';
|
|
22
|
+
import type {
|
|
23
|
+
AdapterContext,
|
|
24
|
+
GenAsset,
|
|
25
|
+
GenRequest,
|
|
26
|
+
GenResult,
|
|
27
|
+
HistoryAudioItem,
|
|
28
|
+
Job,
|
|
29
|
+
ModelDescriptor,
|
|
30
|
+
ProviderAdapter,
|
|
31
|
+
ProviderDescriptor,
|
|
32
|
+
} from '../types.ts';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_BASE = 'https://api.elevenlabs.io';
|
|
35
|
+
const API_BASE = process.env.MAUDE_ELEVENLABS_API_BASE ?? DEFAULT_BASE;
|
|
36
|
+
const ALLOWED_HOST = 'api.elevenlabs.io';
|
|
37
|
+
const ALLOW_CUSTOM_BASE = /^(1|true|on|yes)$/i.test(process.env.MAUDE_GEN_ALLOW_CUSTOM_BASE ?? '');
|
|
38
|
+
|
|
39
|
+
// Hard ceiling on a provider response before it is buffered (F1 — the same guard
|
|
40
|
+
// the Gemini adapter added). Audio is heavier than a JSON image, so the cap is
|
|
41
|
+
// higher; still bounded against a wedged/hostile upstream.
|
|
42
|
+
const MAX_RESPONSE_BYTES = Math.max(
|
|
43
|
+
1024 * 1024,
|
|
44
|
+
Number(process.env.MAUDE_ELEVENLABS_MAX_RESPONSE_BYTES) || 128 * 1024 * 1024
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/** Validate the outbound base ONCE (https + fixed host allowlist), F1/F2. */
|
|
48
|
+
function assertSafeBase(base: string): void {
|
|
49
|
+
let u: URL;
|
|
50
|
+
try {
|
|
51
|
+
u = new URL(base);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error('ElevenLabs API base is not a valid URL');
|
|
54
|
+
}
|
|
55
|
+
if (u.protocol !== 'https:') throw new Error('ElevenLabs API base must be https');
|
|
56
|
+
if (u.hostname !== ALLOWED_HOST && !ALLOW_CUSTOM_BASE) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`ElevenLabs API base host ${u.hostname} is not allowlisted (set MAUDE_GEN_ALLOW_CUSTOM_BASE=1 to override)`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read a Response body into bytes with a hard cap (F1). */
|
|
64
|
+
async function readBytesCapped(res: Response, maxBytes: number): Promise<Uint8Array> {
|
|
65
|
+
const declared = Number(res.headers.get('content-length'));
|
|
66
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
67
|
+
throw new Error(`ElevenLabs response too large (${declared} > ${maxBytes} bytes)`);
|
|
68
|
+
}
|
|
69
|
+
const body = res.body;
|
|
70
|
+
if (!body) return new Uint8Array(await res.arrayBuffer());
|
|
71
|
+
const reader = body.getReader();
|
|
72
|
+
const chunks: Uint8Array[] = [];
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done) break;
|
|
77
|
+
if (value) {
|
|
78
|
+
total += value.byteLength;
|
|
79
|
+
if (total > maxBytes) {
|
|
80
|
+
await reader.cancel().catch(() => {});
|
|
81
|
+
throw new Error(`ElevenLabs response exceeded ${maxBytes} bytes`);
|
|
82
|
+
}
|
|
83
|
+
chunks.push(value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read a capped body as text (for the JSON STT response + error messages). */
|
|
90
|
+
async function readTextCapped(res: Response, maxBytes: number): Promise<string> {
|
|
91
|
+
return new TextDecoder().decode(await readBytesCapped(res, maxBytes));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const ELEVENLABS_DESCRIPTOR: ProviderDescriptor = {
|
|
95
|
+
id: 'elevenlabs',
|
|
96
|
+
label: 'ElevenLabs (audio)',
|
|
97
|
+
kind: 'cloud',
|
|
98
|
+
auth: 'api-key',
|
|
99
|
+
keychainService: 'com.maude.app.elevenlabs',
|
|
100
|
+
modalities: ['audio', 'transcription'],
|
|
101
|
+
keyUrl: 'https://elevenlabs.io/app/settings/api-keys',
|
|
102
|
+
notes:
|
|
103
|
+
'Music, sound effects, text-to-speech, and Scribe speech-to-text — one key for the whole stack, billing your own ElevenLabs account. Music carries commercial-rights tiers (check your plan) and rejects prompts naming artists, songs, or lyrics. Voice cloning requires the voice owner’s consent.',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const ELEVENLABS_MODELS: ModelDescriptor[] = [
|
|
107
|
+
{ id: 'eleven_music', label: 'Music', modality: 'audio', costNote: 'per second', sync: true },
|
|
108
|
+
{ id: 'eleven_text_to_sound_v2', label: 'Sound effects', modality: 'audio', sync: true },
|
|
109
|
+
{
|
|
110
|
+
id: 'eleven_v3',
|
|
111
|
+
label: 'Text to speech (v3)',
|
|
112
|
+
modality: 'audio',
|
|
113
|
+
costNote: 'per character',
|
|
114
|
+
sync: true,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'eleven_multilingual_v2',
|
|
118
|
+
label: 'Text to speech (multilingual v2)',
|
|
119
|
+
modality: 'audio',
|
|
120
|
+
sync: true,
|
|
121
|
+
},
|
|
122
|
+
{ id: 'scribe_v1', label: 'Scribe (speech-to-text)', modality: 'transcription', sync: true },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/** Which audio verb a request wants — from params.audioKind, else inferred. */
|
|
126
|
+
type AudioKind = 'music' | 'sfx' | 'tts';
|
|
127
|
+
function resolveAudioKind(req: GenRequest): AudioKind {
|
|
128
|
+
const explicit = req.params?.audioKind;
|
|
129
|
+
if (explicit === 'music' || explicit === 'sfx' || explicit === 'tts') return explicit;
|
|
130
|
+
// Infer: a voice_id means speech; otherwise default to music (the flagship).
|
|
131
|
+
if (req.params?.voice_id) return 'tts';
|
|
132
|
+
return 'music';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const num = (v: unknown): number | undefined =>
|
|
136
|
+
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
137
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
|
138
|
+
|
|
139
|
+
interface ScribeWord {
|
|
140
|
+
text?: string;
|
|
141
|
+
start?: number;
|
|
142
|
+
end?: number;
|
|
143
|
+
type?: string;
|
|
144
|
+
}
|
|
145
|
+
interface ScribeResponse {
|
|
146
|
+
text?: string;
|
|
147
|
+
words?: ScribeWord[];
|
|
148
|
+
detail?: unknown;
|
|
149
|
+
}
|
|
150
|
+
interface ErrorResponse {
|
|
151
|
+
detail?: { message?: string } | string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Task 2.5 — ElevenLabs History (GET /v1/history). The user's re-downloadable
|
|
155
|
+
// past generations; matching + reuse spends NO credit (already paid).
|
|
156
|
+
interface HistoryItemRaw {
|
|
157
|
+
history_item_id?: string;
|
|
158
|
+
text?: string;
|
|
159
|
+
voice_id?: string;
|
|
160
|
+
date_unix?: number;
|
|
161
|
+
}
|
|
162
|
+
interface HistoryResponseRaw {
|
|
163
|
+
history?: HistoryItemRaw[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Pure History-response → HistoryAudioItem[] (exported for unit test). */
|
|
167
|
+
export function parseHistory(json: HistoryResponseRaw): HistoryAudioItem[] {
|
|
168
|
+
const items = Array.isArray(json?.history) ? json.history : [];
|
|
169
|
+
const out: HistoryAudioItem[] = [];
|
|
170
|
+
for (const it of items) {
|
|
171
|
+
const id = typeof it.history_item_id === 'string' ? it.history_item_id : '';
|
|
172
|
+
if (!id) continue;
|
|
173
|
+
out.push({
|
|
174
|
+
id,
|
|
175
|
+
text: typeof it.text === 'string' ? it.text : '',
|
|
176
|
+
voiceId: typeof it.voice_id === 'string' ? it.voice_id : undefined,
|
|
177
|
+
at:
|
|
178
|
+
typeof it.date_unix === 'number' && Number.isFinite(it.date_unix)
|
|
179
|
+
? new Date(it.date_unix * 1000).toISOString()
|
|
180
|
+
: undefined,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A sync Job wrapper — already resolved (mirrors the Gemini adapter). */
|
|
187
|
+
function doneJob(id: string, result: Promise<GenResult>): Job {
|
|
188
|
+
let settled: 'done' | 'failed' = 'done';
|
|
189
|
+
const guarded = result.catch((err) => {
|
|
190
|
+
settled = 'failed';
|
|
191
|
+
throw err;
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
status: () => settled,
|
|
196
|
+
async *events() {
|
|
197
|
+
try {
|
|
198
|
+
await guarded;
|
|
199
|
+
yield { status: 'done' as const };
|
|
200
|
+
} catch (err) {
|
|
201
|
+
yield {
|
|
202
|
+
status: 'failed' as const,
|
|
203
|
+
message: err instanceof Error ? err.message : String(err),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
result: () => guarded,
|
|
208
|
+
cancel: () => {},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function createElevenLabsAdapter(ctx: AdapterContext): ProviderAdapter {
|
|
213
|
+
function requireKey(): string {
|
|
214
|
+
if (!ctx.apiKey) throw new Error('no ElevenLabs key configured — add one in Settings');
|
|
215
|
+
return ctx.apiKey;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** POST JSON, expect a binary audio body → an audio GenAsset. */
|
|
219
|
+
async function postForAudio(path: string, body: unknown): Promise<GenAsset> {
|
|
220
|
+
assertSafeBase(API_BASE);
|
|
221
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'content-type': 'application/json', 'xi-api-key': requireKey() },
|
|
224
|
+
body: JSON.stringify(body),
|
|
225
|
+
signal: ctx.signal ?? AbortSignal.timeout(180_000),
|
|
226
|
+
});
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
// Error bodies are JSON; the key is never echoed in them.
|
|
229
|
+
let msg = `HTTP ${res.status}`;
|
|
230
|
+
try {
|
|
231
|
+
const j = JSON.parse(await readTextCapped(res, 64 * 1024)) as ErrorResponse;
|
|
232
|
+
const d = j.detail;
|
|
233
|
+
msg = (typeof d === 'string' ? d : d?.message) ?? msg;
|
|
234
|
+
} catch {
|
|
235
|
+
/* keep the status message */
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`ElevenLabs error: ${msg}`);
|
|
238
|
+
}
|
|
239
|
+
const bytes = await readBytesCapped(res, MAX_RESPONSE_BYTES);
|
|
240
|
+
if (bytes.byteLength === 0) throw new Error('ElevenLabs returned an empty audio body');
|
|
241
|
+
return { kind: 'audio', mime: res.headers.get('content-type') || 'audio/mpeg', bytes };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function runAudio(req: GenRequest): Promise<GenResult> {
|
|
245
|
+
const started = Date.now();
|
|
246
|
+
const kind = resolveAudioKind(req);
|
|
247
|
+
const prompt = req.prompt ?? '';
|
|
248
|
+
let asset: GenAsset;
|
|
249
|
+
if (kind === 'tts') {
|
|
250
|
+
const voiceId = str(req.params?.voice_id);
|
|
251
|
+
if (!voiceId) throw new Error('text-to-speech requires a params.voice_id');
|
|
252
|
+
const modelId = str(req.params?.model_id) ?? str(req.model) ?? 'eleven_v3';
|
|
253
|
+
asset = await postForAudio(`/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
|
|
254
|
+
text: prompt,
|
|
255
|
+
model_id: modelId,
|
|
256
|
+
});
|
|
257
|
+
} else if (kind === 'sfx') {
|
|
258
|
+
const duration = num(req.params?.durationSeconds);
|
|
259
|
+
asset = await postForAudio('/v1/sound-generation', {
|
|
260
|
+
text: prompt,
|
|
261
|
+
...(duration ? { duration_seconds: duration } : {}),
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
// music
|
|
265
|
+
const ms = num(req.params?.durationSeconds);
|
|
266
|
+
asset = await postForAudio('/v1/music', {
|
|
267
|
+
prompt,
|
|
268
|
+
...(ms ? { music_length_ms: Math.round(ms * 1000) } : {}),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return { assets: [asset], usage: { ms: Date.now() - started } };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function runTranscription(req: GenRequest): Promise<GenResult> {
|
|
275
|
+
const started = Date.now();
|
|
276
|
+
if (!req.sourceAsset) throw new Error('transcription requires a sourceAsset (an audio/video)');
|
|
277
|
+
if (!ctx.readSourceAsset) throw new Error('transcription requires source-asset access');
|
|
278
|
+
const src = await ctx.readSourceAsset(req.sourceAsset);
|
|
279
|
+
if (!src) throw new Error(`source asset not found or unreadable: ${req.sourceAsset}`);
|
|
280
|
+
|
|
281
|
+
assertSafeBase(API_BASE);
|
|
282
|
+
// Multipart: the audio file + model_id. Blob keeps the bytes out of a query.
|
|
283
|
+
// Copy to a plain ArrayBuffer (a valid BlobPart; also normalizes byteOffset).
|
|
284
|
+
const buf = src.bytes.buffer.slice(
|
|
285
|
+
src.bytes.byteOffset,
|
|
286
|
+
src.bytes.byteOffset + src.bytes.byteLength
|
|
287
|
+
) as ArrayBuffer;
|
|
288
|
+
const form = new FormData();
|
|
289
|
+
form.append('file', new Blob([buf], { type: src.mime }), 'audio');
|
|
290
|
+
form.append('model_id', str(req.model) ?? 'scribe_v1');
|
|
291
|
+
const res = await fetch(`${API_BASE}/v1/speech-to-text`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: { 'xi-api-key': requireKey() }, // FormData sets its own content-type boundary
|
|
294
|
+
body: form,
|
|
295
|
+
signal: ctx.signal ?? AbortSignal.timeout(300_000),
|
|
296
|
+
});
|
|
297
|
+
const text = await readTextCapped(res, MAX_RESPONSE_BYTES);
|
|
298
|
+
if (!res.ok) {
|
|
299
|
+
let msg = `HTTP ${res.status}`;
|
|
300
|
+
try {
|
|
301
|
+
const j = JSON.parse(text) as ErrorResponse;
|
|
302
|
+
const d = j.detail;
|
|
303
|
+
msg = (typeof d === 'string' ? d : d?.message) ?? msg;
|
|
304
|
+
} catch {
|
|
305
|
+
/* keep status */
|
|
306
|
+
}
|
|
307
|
+
throw new Error(`ElevenLabs Scribe error: ${msg}`);
|
|
308
|
+
}
|
|
309
|
+
const json = JSON.parse(text) as ScribeResponse;
|
|
310
|
+
// Scribe returns word-level entries with `type` ('word' | 'spacing'); keep words.
|
|
311
|
+
const words = (json.words ?? [])
|
|
312
|
+
.filter((w) => (w.type ?? 'word') === 'word' && typeof w.text === 'string')
|
|
313
|
+
.map((w) => ({ text: String(w.text), start: Number(w.start) || 0, end: Number(w.end) || 0 }));
|
|
314
|
+
const srt = wordsToSrt(words);
|
|
315
|
+
const asset: GenAsset = { kind: 'transcription', mime: 'application/x-subrip', text: srt };
|
|
316
|
+
return { assets: [asset], usage: { ms: Date.now() - started }, raw: { text: json.text } };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Task 2.5 — the user's re-usable audio history. Re-downloading spends NO
|
|
320
|
+
// credit (already paid). Rate-limited LIST → the route caches the listing;
|
|
321
|
+
// the audio GET localizes through the host's saveAsset (magic-byte sniff).
|
|
322
|
+
async function listHistory(): Promise<HistoryAudioItem[]> {
|
|
323
|
+
assertSafeBase(API_BASE);
|
|
324
|
+
const res = await fetch(`${API_BASE}/v1/history?page_size=100`, {
|
|
325
|
+
headers: { 'xi-api-key': requireKey() },
|
|
326
|
+
signal: ctx.signal ?? AbortSignal.timeout(30_000),
|
|
327
|
+
});
|
|
328
|
+
const text = await readTextCapped(res, 8 * 1024 * 1024);
|
|
329
|
+
if (!res.ok) {
|
|
330
|
+
let msg = `HTTP ${res.status}`;
|
|
331
|
+
try {
|
|
332
|
+
const j = JSON.parse(text) as ErrorResponse;
|
|
333
|
+
const d = j.detail;
|
|
334
|
+
msg = (typeof d === 'string' ? d : d?.message) ?? msg;
|
|
335
|
+
} catch {
|
|
336
|
+
/* keep status */
|
|
337
|
+
}
|
|
338
|
+
throw new Error(`ElevenLabs history error: ${msg}`);
|
|
339
|
+
}
|
|
340
|
+
return parseHistory(JSON.parse(text) as HistoryResponseRaw);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function fetchHistoryAudio(id: string): Promise<GenAsset> {
|
|
344
|
+
// The id is provider-scoped and used ONLY in a fixed, non-interpolated path
|
|
345
|
+
// segment (encodeURIComponent) — no query, no key leak.
|
|
346
|
+
assertSafeBase(API_BASE);
|
|
347
|
+
const res = await fetch(`${API_BASE}/v1/history/${encodeURIComponent(id)}/audio`, {
|
|
348
|
+
headers: { 'xi-api-key': requireKey() },
|
|
349
|
+
signal: ctx.signal ?? AbortSignal.timeout(120_000),
|
|
350
|
+
});
|
|
351
|
+
if (!res.ok) throw new Error(`ElevenLabs history audio error: HTTP ${res.status}`);
|
|
352
|
+
const bytes = await readBytesCapped(res, MAX_RESPONSE_BYTES);
|
|
353
|
+
if (bytes.byteLength === 0) throw new Error('ElevenLabs history returned an empty audio body');
|
|
354
|
+
return { kind: 'audio', mime: res.headers.get('content-type') || 'audio/mpeg', bytes };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
descriptor: ELEVENLABS_DESCRIPTOR,
|
|
359
|
+
async listModels() {
|
|
360
|
+
return ELEVENLABS_MODELS;
|
|
361
|
+
},
|
|
362
|
+
async submit(req: GenRequest): Promise<Job> {
|
|
363
|
+
const id = `gen_${crypto.randomUUID()}`;
|
|
364
|
+
const run = req.modality === 'transcription' ? runTranscription(req) : runAudio(req);
|
|
365
|
+
return doneJob(id, run);
|
|
366
|
+
},
|
|
367
|
+
listHistory,
|
|
368
|
+
fetchHistoryAudio,
|
|
369
|
+
};
|
|
370
|
+
}
|