@mevdragon/vidfarm-devcli 0.19.0 → 0.20.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.
Files changed (44) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +3 -3
  2. package/.agents/skills/music/SKILL.md +416 -0
  3. package/.agents/skills/music/references/api_reference.md +519 -0
  4. package/.agents/skills/music/references/installation.md +65 -0
  5. package/.agents/skills/text-to-speech/SKILL.md +226 -0
  6. package/.agents/skills/text-to-speech/references/installation.md +90 -0
  7. package/.agents/skills/text-to-speech/references/streaming.md +307 -0
  8. package/.agents/skills/text-to-speech/references/voice-settings.md +115 -0
  9. package/.agents/skills/vidfarm-media/SKILL.md +25 -11
  10. package/.agents/skills/vidfarm-media/references/tts.md +41 -3
  11. package/SKILL.director.md +31 -13
  12. package/SKILL.platform.md +2 -2
  13. package/demo/dist/app.css +1 -1
  14. package/demo/dist/app.js +76 -74
  15. package/dist/src/account-pages-legacy.js +1 -1
  16. package/dist/src/app.js +308 -111
  17. package/dist/src/cli.js +162 -5
  18. package/dist/src/config.js +13 -0
  19. package/dist/src/devcli/clips.js +7 -2
  20. package/dist/src/devcli/composition-edit.js +5 -2
  21. package/dist/src/domain.js +3 -0
  22. package/dist/src/editor-chat.js +8 -1
  23. package/dist/src/primitive-context.js +45 -2
  24. package/dist/src/primitive-registry.js +140 -18
  25. package/dist/src/reskin/chat-page.js +1 -1
  26. package/dist/src/reskin/inpaint-clipper-page.js +446 -205
  27. package/dist/src/reskin/inpaint-page.js +1 -1
  28. package/dist/src/reskin/inpaint-video-page.js +1 -1
  29. package/dist/src/reskin/library-page.js +7 -1
  30. package/dist/src/reskin/portfolio-page.js +687 -0
  31. package/dist/src/reskin/settings-page.js +4 -4
  32. package/dist/src/reskin/theme.js +1 -0
  33. package/dist/src/services/billing.js +5 -0
  34. package/dist/src/services/clip-curation/ffmpeg.js +48 -0
  35. package/dist/src/services/clip-curation/hunt.js +2 -0
  36. package/dist/src/services/clip-curation/index.js +1 -1
  37. package/dist/src/services/clip-curation/scan.js +29 -16
  38. package/dist/src/services/elevenlabs.js +222 -0
  39. package/dist/src/services/hyperframes.js +252 -83
  40. package/dist/src/services/providers.js +216 -2
  41. package/dist/src/services/serverless-jobs.js +3 -1
  42. package/dist/src/services/swipe-customize.js +5 -2
  43. package/dist/src/template-editor-pages.js +1 -1
  44. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
7
7
  import sharp from "sharp";
8
8
  import { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError, SpeechVoiceMismatchError, conciseProviderDetails, parseRetryAfterSeconds, rateLimitOrQuotaError } from "./provider-errors.js";
9
9
  import { SUPPORTED_STT_MODELS, SUPPORTED_TTS_MODELS, defaultSpeechModelFor, generateSpeechWithKey, inferSpeechContentType, speechVoiceFamilyFor, speechVoiceMismatch, transcribeSpeechWithKey } from "./speech.js";
10
+ import { DEFAULT_ELEVENLABS_STT_MODEL, DEFAULT_ELEVENLABS_TTS_MODEL, elevenLabsListVoices, elevenLabsMusicCompose, elevenLabsSpeechToText, elevenLabsTextToSpeech, validateElevenLabsKey } from "./elevenlabs.js";
10
11
  export { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError } from "./provider-errors.js";
11
12
  const SUPPORTED_TEXT_MODELS = {
12
13
  openai: new Set(["gpt-5.4"]),
@@ -70,6 +71,8 @@ const OPENAI_IMAGE_SIZES = new Set([
70
71
  "auto"
71
72
  ]);
72
73
  const GEMINI_IMAGE_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]);
74
+ // elevenlabs is intentionally absent — it validates against its own /v1/user
75
+ // endpoint (see validateProviderKey), not a chat-completion probe.
73
76
  const PROVIDER_KEY_VALIDATION_MODELS = {
74
77
  openai: "gpt-4.1-mini",
75
78
  gemini: "gemini-3.1-flash-lite-preview",
@@ -334,6 +337,20 @@ export class ProviderService {
334
337
  return [...new Set(providers)];
335
338
  }
336
339
  async validateProviderKey(input) {
340
+ // ElevenLabs is audio-only: validate against its own account endpoint, not a
341
+ // chat-completion probe.
342
+ if (input.provider === "elevenlabs") {
343
+ if (config.mockProviders) {
344
+ return { provider: input.provider, model: "elevenlabs", mock: true };
345
+ }
346
+ try {
347
+ await validateElevenLabsKey(input.secret);
348
+ return { provider: input.provider, model: "elevenlabs", mock: false };
349
+ }
350
+ catch (error) {
351
+ throw new Error(toProviderValidationMessage(input.provider, error));
352
+ }
353
+ }
337
354
  const model = PROVIDER_KEY_VALIDATION_MODELS[input.provider];
338
355
  if (!model) {
339
356
  throw new Error(`Provider ${input.provider} does not support validation.`);
@@ -541,7 +558,123 @@ export class ProviderService {
541
558
  await this.releaseLease(lease);
542
559
  }
543
560
  }
561
+ // --- Audio (music / TTS / STT): platform-key + wallet vs BYOK routing ---
562
+ async resolveCustomerElevenLabsKey(customerId) {
563
+ const now = new Date().toISOString();
564
+ const secret = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(customerId))
565
+ .filter((entry) => String(entry.provider) === "elevenlabs")
566
+ .filter((entry) => String(entry.status) === "active")
567
+ .filter((entry) => !entry.cooldown_until || String(entry.cooldown_until) <= now)
568
+ .map((entry) => readStoredProviderSecret(String(entry.secret ?? "")))
569
+ .find((value) => value.trim().length > 0);
570
+ return secret ?? null;
571
+ }
572
+ async customerHasByokAudioKey(customerId) {
573
+ const providers = await this.listAvailableProvidersAsync(customerId);
574
+ return providers.some((provider) => provider === "openai" || provider === "gemini" || provider === "openrouter");
575
+ }
576
+ /**
577
+ * Decide how an audio call is authorized. Default (`use_wallet_credits` unset
578
+ * or true): the platform ElevenLabs key, billed to the wallet. Explicit
579
+ * `false`: the customer's own ElevenLabs key, else a BYOK openai/gemini/
580
+ * openrouter key (TTS/STT only), else fall back to the platform key + wallet.
581
+ */
582
+ async resolveAudioAuthPlan(input) {
583
+ // `undefined` = legacy BYOK-only. Internal callers (captions, voice-matched
584
+ // regenerate-speech) never pass the flag, so they keep their exact behavior
585
+ // and never silently draw on the platform key or the wallet. Only surfaces
586
+ // that explicitly opt in (the music/tts/stt primitives) reach ElevenLabs.
587
+ if (input.useWalletCredits === undefined) {
588
+ return { mode: "byok" };
589
+ }
590
+ const platformKey = config.ELEVENLABS_API_KEY?.trim() || null;
591
+ if (input.useWalletCredits) {
592
+ if (platformKey) {
593
+ return { mode: "elevenlabs", apiKey: platformKey, walletBilled: true, scope: "platform" };
594
+ }
595
+ // Operator hasn't wired a platform key — honor the intent with the user's
596
+ // own ElevenLabs key (no charge) or a BYOK audio key before failing.
597
+ const userKey = await this.resolveCustomerElevenLabsKey(input.customerId);
598
+ if (userKey) {
599
+ return { mode: "elevenlabs", apiKey: userKey, walletBilled: false, scope: "user" };
600
+ }
601
+ if (input.byokFallback && (await this.customerHasByokAudioKey(input.customerId))) {
602
+ return { mode: "byok" };
603
+ }
604
+ throw new ProviderKeyUnavailableError("use_wallet_credits is on but no platform ElevenLabs key is configured, and you have no usable audio key. Ask the operator to set ELEVENLABS_API_KEY or save your own key.");
605
+ }
606
+ // use_wallet_credits === false: prefer the user's OWN key, then a BYOK audio
607
+ // key (TTS/STT), and only then fall back to the platform key + wallet.
608
+ const userKey = await this.resolveCustomerElevenLabsKey(input.customerId);
609
+ if (userKey) {
610
+ return { mode: "elevenlabs", apiKey: userKey, walletBilled: false, scope: "user" };
611
+ }
612
+ if (input.byokFallback && (await this.customerHasByokAudioKey(input.customerId))) {
613
+ return { mode: "byok" };
614
+ }
615
+ if (platformKey) {
616
+ return { mode: "elevenlabs", apiKey: platformKey, walletBilled: true, scope: "platform" };
617
+ }
618
+ throw new ProviderKeyUnavailableError(input.capability === "music"
619
+ ? "Music needs ElevenLabs: save an ElevenLabs key in Settings, or ask the operator to configure the platform ELEVENLABS_API_KEY (use_wallet_credits)."
620
+ : `No audio provider available for ${input.capability}. Enable use_wallet_credits (platform ElevenLabs key), save your own ElevenLabs key, or add an OpenAI/Gemini/OpenRouter key.`);
621
+ }
622
+ /** Voices available to whichever ElevenLabs account will serve this customer. */
623
+ async listElevenLabsVoices(input) {
624
+ const plan = await this.resolveAudioAuthPlan({
625
+ customerId: input.customerId,
626
+ useWalletCredits: input.useWalletCredits,
627
+ byokFallback: false,
628
+ capability: "tts"
629
+ });
630
+ if (plan.mode !== "elevenlabs") {
631
+ throw new ProviderKeyUnavailableError("No ElevenLabs account is available to list voices.");
632
+ }
633
+ return { scope: plan.scope, voices: await elevenLabsListVoices(plan.apiKey) };
634
+ }
544
635
  async generateSpeech(input) {
636
+ const plan = await this.resolveAudioAuthPlan({
637
+ customerId: input.customerId,
638
+ useWalletCredits: input.useWalletCredits,
639
+ byokFallback: true,
640
+ capability: "tts"
641
+ });
642
+ if (plan.mode === "elevenlabs") {
643
+ const model = /^eleven/i.test(input.model || "") ? input.model : DEFAULT_ELEVENLABS_TTS_MODEL;
644
+ const generated = await elevenLabsTextToSpeech({
645
+ apiKey: plan.apiKey,
646
+ text: input.text,
647
+ voice: input.voice,
648
+ model,
649
+ responseFormat: input.responseFormat
650
+ });
651
+ const billing = plan.walletBilled
652
+ ? {
653
+ costUsd: Number(((generated.characterCount / 1000) * config.ELEVENLABS_TTS_USD_PER_1K_CHARS).toFixed(4)),
654
+ costCenterSlug: "elevenlabs_tts",
655
+ metadata: {
656
+ operation: "tts",
657
+ provider: "elevenlabs",
658
+ model,
659
+ character_count: generated.characterCount,
660
+ wallet_billed: true,
661
+ scope: plan.scope
662
+ }
663
+ }
664
+ : null;
665
+ return {
666
+ bytes: generated.bytes,
667
+ contentType: generated.contentType,
668
+ usage: generated.usage,
669
+ effectiveProvider: "elevenlabs",
670
+ effectiveModel: model,
671
+ billing
672
+ };
673
+ }
674
+ const byok = await this.generateSpeechByok(input);
675
+ return { ...byok.result, effectiveProvider: byok.provider, effectiveModel: byok.model, billing: null };
676
+ }
677
+ async generateSpeechByok(input) {
545
678
  // Provider fallback must stay voice-aware: a Gemini preset routed to an
546
679
  // OpenAI key (or the reverse) would 400 opaquely at the provider.
547
680
  const attempts = (await providerAttempts({
@@ -562,11 +695,12 @@ export class ProviderService {
562
695
  let lastError = null;
563
696
  for (const attempt of attempts) {
564
697
  try {
565
- return await this.generateSpeechWithProvider({
698
+ const result = await this.generateSpeechWithProvider({
566
699
  ...input,
567
700
  provider: attempt.provider,
568
701
  model: attempt.model
569
702
  });
703
+ return { result, provider: attempt.provider, model: attempt.model };
570
704
  }
571
705
  catch (error) {
572
706
  lastError = error;
@@ -581,6 +715,42 @@ export class ProviderService {
581
715
  throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI speech provider key became available in time.");
582
716
  }
583
717
  }
718
+ /** ElevenLabs music generation. Platform key + wallet by default; the
719
+ * customer's own ElevenLabs key when use_wallet_credits is false. */
720
+ async generateMusic(input) {
721
+ const plan = await this.resolveAudioAuthPlan({
722
+ customerId: input.customerId,
723
+ useWalletCredits: input.useWalletCredits,
724
+ byokFallback: false,
725
+ capability: "music"
726
+ });
727
+ if (plan.mode !== "elevenlabs") {
728
+ throw new ProviderKeyUnavailableError("Music generation requires an ElevenLabs key.");
729
+ }
730
+ const generated = await elevenLabsMusicCompose({
731
+ apiKey: plan.apiKey,
732
+ prompt: input.prompt,
733
+ musicLengthMs: input.musicLengthMs,
734
+ compositionPlan: input.compositionPlan,
735
+ model: input.model,
736
+ outputFormat: input.outputFormat
737
+ });
738
+ const minutes = Math.max(0, generated.musicLengthMs) / 60000;
739
+ const billing = plan.walletBilled
740
+ ? {
741
+ costUsd: Number((minutes * config.ELEVENLABS_MUSIC_USD_PER_MIN).toFixed(4)),
742
+ costCenterSlug: "elevenlabs_music",
743
+ metadata: {
744
+ operation: "music",
745
+ provider: "elevenlabs",
746
+ music_length_ms: generated.musicLengthMs,
747
+ wallet_billed: true,
748
+ scope: plan.scope
749
+ }
750
+ }
751
+ : null;
752
+ return { ...generated, scope: plan.scope, billing };
753
+ }
584
754
  async generateSpeechWithProvider(input) {
585
755
  assertSupportedSpeechModel("tts", input.provider, input.model);
586
756
  const lease = await this.leaseCustomerKey({
@@ -1475,6 +1645,45 @@ export class ProviderService {
1475
1645
  }
1476
1646
  }
1477
1647
  async transcribeSpeech(input) {
1648
+ const plan = await this.resolveAudioAuthPlan({
1649
+ customerId: input.customerId,
1650
+ useWalletCredits: input.useWalletCredits,
1651
+ byokFallback: true,
1652
+ capability: "stt"
1653
+ });
1654
+ if (plan.mode === "elevenlabs") {
1655
+ const model = /^scribe/i.test(input.model || "") ? input.model : DEFAULT_ELEVENLABS_STT_MODEL;
1656
+ const transcription = await elevenLabsSpeechToText({
1657
+ apiKey: plan.apiKey,
1658
+ audio: input.audio,
1659
+ contentType: input.contentType,
1660
+ model,
1661
+ diarize: input.diarize,
1662
+ languageCode: input.language
1663
+ });
1664
+ const minutes = Math.max(0, transcription.audioDurationSec ?? 0) / 60;
1665
+ const billing = plan.walletBilled
1666
+ ? {
1667
+ costUsd: Number((minutes * config.ELEVENLABS_STT_USD_PER_MIN).toFixed(4)),
1668
+ costCenterSlug: "elevenlabs_stt",
1669
+ metadata: {
1670
+ operation: "stt",
1671
+ provider: "elevenlabs",
1672
+ model,
1673
+ audio_duration_sec: transcription.audioDurationSec,
1674
+ wallet_billed: true,
1675
+ scope: plan.scope
1676
+ }
1677
+ }
1678
+ : null;
1679
+ const { audioDurationSec, ...rest } = transcription;
1680
+ void audioDurationSec;
1681
+ return { ...rest, effectiveProvider: "elevenlabs", effectiveModel: model, billing };
1682
+ }
1683
+ const byok = await this.transcribeSpeechByok(input);
1684
+ return { ...byok.result, effectiveProvider: byok.provider, effectiveModel: byok.model, billing: null };
1685
+ }
1686
+ async transcribeSpeechByok(input) {
1478
1687
  const attempts = await providerAttempts({
1479
1688
  providerKeyService: this.serverlessProviderKeys,
1480
1689
  customerId: input.customerId,
@@ -1489,11 +1698,12 @@ export class ProviderService {
1489
1698
  let lastError = null;
1490
1699
  for (const attempt of attempts) {
1491
1700
  try {
1492
- return await this.transcribeSpeechWithProvider({
1701
+ const result = await this.transcribeSpeechWithProvider({
1493
1702
  ...input,
1494
1703
  provider: attempt.provider,
1495
1704
  model: attempt.model
1496
1705
  });
1706
+ return { result, provider: attempt.provider, model: attempt.model };
1497
1707
  }
1498
1708
  catch (error) {
1499
1709
  lastError = error;
@@ -2041,6 +2251,10 @@ function formatProviderLabel(provider) {
2041
2251
  return "OpenRouter";
2042
2252
  case "perplexity":
2043
2253
  return "Perplexity";
2254
+ case "elevenlabs":
2255
+ return "ElevenLabs";
2256
+ default:
2257
+ return provider;
2044
2258
  }
2045
2259
  }
2046
2260
  function readStringField(value, field) {
@@ -17,7 +17,7 @@ const dynamodb = config.RECORDS_DRIVER === "local"
17
17
  }
18
18
  });
19
19
  const sfn = new SFNClient({});
20
- const ACTIVE_ROOT_JOB_STATUSES = ["queued", "running", "waiting_for_provider", "waiting_for_child", "waiting_for_human"];
20
+ const ACTIVE_ROOT_JOB_STATUSES = ["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"];
21
21
  const ACTIVE_JOB_STATUSES = new Set(ACTIVE_ROOT_JOB_STATUSES);
22
22
  export class ServerlessJobsService {
23
23
  async createRootJob(input) {
@@ -282,6 +282,7 @@ export class ServerlessJobsService {
282
282
  "#status = :queuedStatus",
283
283
  "#status = :runningStatus",
284
284
  "#status = :waitingForProviderStatus",
285
+ "#status = :waitingForRenderStatus",
285
286
  "#status = :waitingForChildStatus",
286
287
  "#status = :waitingForHumanStatus"
287
288
  ].join(" OR ")
@@ -293,6 +294,7 @@ export class ServerlessJobsService {
293
294
  expressionAttributeValues[":queuedStatus"] = "queued";
294
295
  expressionAttributeValues[":runningStatus"] = "running";
295
296
  expressionAttributeValues[":waitingForProviderStatus"] = "waiting_for_provider";
297
+ expressionAttributeValues[":waitingForRenderStatus"] = "waiting_for_render";
296
298
  expressionAttributeValues[":waitingForChildStatus"] = "waiting_for_child";
297
299
  expressionAttributeValues[":waitingForHumanStatus"] = "waiting_for_human";
298
300
  }
@@ -66,6 +66,7 @@ export async function reconcileSwipeRows(customerId, rows) {
66
66
  }
67
67
  return out;
68
68
  }
69
+ // Text generation only — the audio-only elevenlabs provider never appears here.
69
70
  const TEXT_MODEL_DEFAULTS = {
70
71
  openai: "gpt-5.4-mini",
71
72
  gemini: "gemini-2.5-flash",
@@ -246,7 +247,9 @@ function parseJsonLoose(text) {
246
247
  }
247
248
  async function generateRecaption(input) {
248
249
  const available = await providers.listAvailableProvidersAsync(input.customerId);
249
- const provider = TEXT_PROVIDER_PRIORITY.find((p) => available.includes(p)) ?? available[0];
250
+ // Text generation only never fall back to a non-text provider (e.g. the
251
+ // audio-only elevenlabs key), which has no text model.
252
+ const provider = TEXT_PROVIDER_PRIORITY.find((p) => available.includes(p));
250
253
  if (!provider)
251
254
  throw new NoProviderKeyError();
252
255
  const generated = await providers.generateText({
@@ -254,7 +257,7 @@ async function generateRecaption(input) {
254
257
  jobId: input.jobId,
255
258
  workerId: "swipe-customize",
256
259
  provider,
257
- model: TEXT_MODEL_DEFAULTS[provider],
260
+ model: TEXT_MODEL_DEFAULTS[provider] ?? "gpt-5.4-mini",
258
261
  prompt: input.prompt,
259
262
  temperature: 0.7,
260
263
  responseFormat: "json"
@@ -1925,7 +1925,7 @@ export function renderCustomInpaintPage(input) {
1925
1925
  }
1926
1926
 
1927
1927
  function isPendingJobStatus(status) {
1928
- return ["queued", "running", "waiting_for_provider", "waiting_for_child", "waiting_for_human"].includes(String(status || ""));
1928
+ return ["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"].includes(String(status || ""));
1929
1929
  }
1930
1930
 
1931
1931
  function pendingHistoryItemKey(jobId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {