@mevdragon/vidfarm-devcli 0.11.0 → 0.13.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.
@@ -5,8 +5,8 @@ import { createId } from "../lib/ids.js";
5
5
  import { addSeconds } from "../lib/time.js";
6
6
  import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
7
7
  import sharp from "sharp";
8
- import { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError, conciseProviderDetails, parseRetryAfterSeconds, rateLimitOrQuotaError } from "./provider-errors.js";
9
- import { SUPPORTED_STT_MODELS, SUPPORTED_TTS_MODELS, defaultSpeechModelFor, generateSpeechWithKey, inferSpeechContentType, transcribeSpeechWithKey } from "./speech.js";
8
+ import { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError, SpeechVoiceMismatchError, conciseProviderDetails, parseRetryAfterSeconds, rateLimitOrQuotaError } from "./provider-errors.js";
9
+ import { SUPPORTED_STT_MODELS, SUPPORTED_TTS_MODELS, defaultSpeechModelFor, generateSpeechWithKey, inferSpeechContentType, speechVoiceFamilyFor, speechVoiceMismatch, transcribeSpeechWithKey } from "./speech.js";
10
10
  export { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError } from "./provider-errors.js";
11
11
  const SUPPORTED_TEXT_MODELS = {
12
12
  openai: new Set(["gpt-5.4"]),
@@ -542,7 +542,9 @@ export class ProviderService {
542
542
  }
543
543
  }
544
544
  async generateSpeech(input) {
545
- const attempts = await providerAttempts({
545
+ // Provider fallback must stay voice-aware: a Gemini preset routed to an
546
+ // OpenAI key (or the reverse) would 400 opaquely at the provider.
547
+ const attempts = (await providerAttempts({
546
548
  providerKeyService: this.serverlessProviderKeys,
547
549
  customerId: input.customerId,
548
550
  capability: "tts",
@@ -550,7 +552,11 @@ export class ProviderService {
550
552
  requestedModel: input.model,
551
553
  supportedModels: SUPPORTED_TTS_MODELS,
552
554
  defaultModelForProvider: (provider) => defaultSpeechModelFor("tts", provider)
553
- });
555
+ })).filter((attempt) => !speechVoiceMismatch({ provider: attempt.provider, model: attempt.model, voice: input.voice }));
556
+ if (!attempts.length) {
557
+ throw new SpeechVoiceMismatchError(speechVoiceMismatch({ provider: input.provider, model: input.model, voice: input.voice }) ??
558
+ `Voice "${input.voice}" needs a ${speechVoiceFamilyFor(input.provider, input.model) === "gemini" ? "Gemini-family (gemini, or openrouter with a Gemini TTS model)" : "OpenAI-family (openai)"} provider key, but none is configured. Save a compatible key or pick a different voice.`);
559
+ }
554
560
  const retryDeadlineMs = providerLeaseRetryDeadlineMs();
555
561
  for (;;) {
556
562
  let lastError = null;
@@ -1549,6 +1555,7 @@ export class ProviderService {
1549
1555
  prompt: input.prompt,
1550
1556
  language: input.language,
1551
1557
  diarize: input.diarize,
1558
+ wordTimestamps: input.wordTimestamps,
1552
1559
  apiKey: lease.secret
1553
1560
  })));
1554
1561
  await this.serverlessProviderKeys.recordProviderKeyUsage({
@@ -151,6 +151,14 @@ class ServerlessRecordsServiceImpl {
151
151
  });
152
152
  }
153
153
  async getLatestApiKeyForCustomer(customerId) {
154
+ const keys = await this.listActiveApiKeysForCustomer(customerId, 1);
155
+ return keys[0] ?? null;
156
+ }
157
+ // Newest-first active API key records. Callers that hand a key to a client
158
+ // (boot configs, Settings) should pick one the CURRENT server can validate —
159
+ // after an API_KEY_SALT rotation the shared table holds keys hashed under
160
+ // different salts (deployed stage vs local dev boxes).
161
+ async listActiveApiKeysForCustomer(customerId, limit = 25) {
154
162
  const response = await dynamodb.send(new QueryCommand({
155
163
  TableName: requireMainTableName(),
156
164
  IndexName: "gsi1",
@@ -164,9 +172,9 @@ class ServerlessRecordsServiceImpl {
164
172
  ":status": "active"
165
173
  },
166
174
  ScanIndexForward: false,
167
- Limit: 1
175
+ Limit: limit
168
176
  }));
169
- return response.Items?.[0] ?? null;
177
+ return response.Items ?? [];
170
178
  }
171
179
  async hasCustomerCreditGrant(customerId, grantType) {
172
180
  return Boolean(await this.getById("credit_grant", `${customerId}:${grantType}`));
@@ -390,6 +398,7 @@ class ServerlessRecordsServiceImpl {
390
398
  templateId: null,
391
399
  trendTagline: input.trendTagline ?? null,
392
400
  visibility: input.visibility ?? "public",
401
+ origin: input.origin ?? "inspiration",
393
402
  notes: input.notes ?? null,
394
403
  errorMessage: null,
395
404
  createdAt: timestamp,
@@ -446,6 +455,7 @@ class ServerlessRecordsServiceImpl {
446
455
  viralDna: input.viralDna ?? null,
447
456
  defaultForkId: input.defaultForkId ?? null,
448
457
  visibility: input.visibility ?? "public",
458
+ origin: input.origin ?? "inspiration",
449
459
  notes: input.notes ?? null,
450
460
  createdAt: timestamp,
451
461
  updatedAt: timestamp
@@ -9,7 +9,7 @@
9
9
  // is an audio-native LLM, so we ask for speaker-attributed JSON segments in one
10
10
  // call. OpenAI/OpenRouter transcription endpoints return plain text only and
11
11
  // degrade to a single unlabeled segment.
12
- import { ProviderAuthError, rateLimitOrQuotaError } from "./provider-errors.js";
12
+ import { ProviderAuthError, SpeechVoiceMismatchError, rateLimitOrQuotaError } from "./provider-errors.js";
13
13
  export const SUPPORTED_TTS_MODELS = {
14
14
  openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
15
15
  gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
@@ -18,25 +18,109 @@ export const SUPPORTED_TTS_MODELS = {
18
18
  export const SUPPORTED_STT_MODELS = {
19
19
  openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
20
20
  gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
21
- openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
21
+ openai: new Set(["gpt-4o-mini-transcribe-2025-12-15", "whisper-1"])
22
22
  };
23
+ // The only STT model that returns real word-level timestamps
24
+ // (verbose_json + timestamp_granularities). Word-timestamp requests on the
25
+ // openai path are routed here regardless of the configured chat-grade model.
26
+ export const OPENAI_WORD_TIMESTAMP_STT_MODEL = "whisper-1";
23
27
  export function defaultSpeechModelFor(kind, provider) {
24
28
  return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
25
29
  }
30
+ // Known TTS voice presets, in the exact casing each provider accepts (OpenAI is
31
+ // lowercase; Gemini prebuilt voices are case-sensitive TitleCase). These lists
32
+ // exist to guard, not to gate: a voice that clearly belongs to the OTHER family
33
+ // is rejected with an actionable message (the provider would 400 opaquely), a
34
+ // known voice has its casing normalized, and an UNKNOWN voice passes through
35
+ // untouched so new provider presets keep working without a code change.
36
+ export const OPENAI_TTS_VOICES = [
37
+ "alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse"
38
+ ];
39
+ export const GEMINI_TTS_VOICES = [
40
+ "Achernar", "Achird", "Algenib", "Algieba", "Alnilam", "Aoede", "Autonoe", "Callirrhoe", "Charon", "Despina",
41
+ "Enceladus", "Erinome", "Fenrir", "Gacrux", "Iapetus", "Kore", "Laomedeia", "Leda", "Orus", "Puck",
42
+ "Pulcherrima", "Rasalgethi", "Sadachbia", "Sadaltager", "Schedar", "Sulafat", "Umbriel", "Vindemiatrix", "Zephyr", "Zubenelgenubi"
43
+ ];
44
+ const OPENAI_VOICES_BY_KEY = new Map(OPENAI_TTS_VOICES.map((voice) => [voice.toLowerCase(), voice]));
45
+ const GEMINI_VOICES_BY_KEY = new Map(GEMINI_TTS_VOICES.map((voice) => [voice.toLowerCase(), voice]));
46
+ /** Which voice family a provider/model pair expects; openrouter follows its (typically Gemini) TTS model. */
47
+ export function speechVoiceFamilyFor(provider, model) {
48
+ if (provider === "gemini") {
49
+ return "gemini";
50
+ }
51
+ if (provider === "openrouter") {
52
+ const resolvedModel = model?.trim() || defaultSpeechModelFor("tts", provider) || "";
53
+ return /gemini/i.test(resolvedModel) ? "gemini" : "openai";
54
+ }
55
+ return "openai";
56
+ }
57
+ /** Which provider a bare voice preset implies (null when the name is not a known preset). */
58
+ export function inferSpeechProviderForVoice(voice) {
59
+ const key = voice?.trim().toLowerCase();
60
+ if (!key) {
61
+ return null;
62
+ }
63
+ if (OPENAI_VOICES_BY_KEY.has(key)) {
64
+ return "openai";
65
+ }
66
+ if (GEMINI_VOICES_BY_KEY.has(key)) {
67
+ return "gemini";
68
+ }
69
+ return null;
70
+ }
71
+ /** Actionable mismatch message when the voice belongs to the other provider family, else null (usable). */
72
+ export function speechVoiceMismatch(input) {
73
+ const key = input.voice?.trim().toLowerCase();
74
+ if (!key) {
75
+ return null;
76
+ }
77
+ const family = speechVoiceFamilyFor(input.provider, input.model);
78
+ if ((family === "gemini" ? GEMINI_VOICES_BY_KEY : OPENAI_VOICES_BY_KEY).has(key)) {
79
+ return null;
80
+ }
81
+ const foreign = (family === "gemini" ? OPENAI_VOICES_BY_KEY : GEMINI_VOICES_BY_KEY).get(key);
82
+ if (!foreign) {
83
+ return null;
84
+ }
85
+ return family === "gemini"
86
+ ? `Voice "${foreign}" is an OpenAI preset, but provider ${input.provider} expects a Gemini voice (e.g. Kore, Puck, Charon). Pick a Gemini voice or set provider to "openai".`
87
+ : `Voice "${foreign}" is a Gemini preset, but provider ${input.provider} expects an OpenAI voice (e.g. alloy, ash, coral). Pick an OpenAI voice or set provider to "gemini".`;
88
+ }
89
+ /** Canonicalize a requested voice for the target provider/model (casing fixed, cross-family presets rejected). */
90
+ export function resolveSpeechVoice(input) {
91
+ const raw = input.voice?.trim();
92
+ if (!raw) {
93
+ return undefined;
94
+ }
95
+ const mismatch = speechVoiceMismatch(input);
96
+ if (mismatch) {
97
+ throw new SpeechVoiceMismatchError(mismatch);
98
+ }
99
+ const family = speechVoiceFamilyFor(input.provider, input.model);
100
+ return (family === "gemini" ? GEMINI_VOICES_BY_KEY : OPENAI_VOICES_BY_KEY).get(raw.toLowerCase()) ?? raw;
101
+ }
26
102
  /** Generate speech audio with a raw provider key. Voice style rides on `instructions`. */
27
103
  export async function generateSpeechWithKey(input) {
104
+ const voice = resolveSpeechVoice(input);
28
105
  return input.provider === "gemini"
29
- ? callGeminiSpeechGeneration(input)
106
+ ? callGeminiSpeechGeneration({ ...input, voice })
30
107
  : callOpenAICompatibleSpeechGeneration({
31
108
  ...input,
109
+ voice,
32
110
  provider: input.provider === "openrouter" ? "openrouter" : "openai"
33
111
  });
34
112
  }
35
- /** Transcribe speech audio with a raw provider key; diarizes on the Gemini path. */
113
+ /** Transcribe speech audio with a raw provider key; diarizes on the Gemini path.
114
+ * wordTimestamps requests word-level timings — honored only on the openai path
115
+ * (routed to whisper-1 verbose_json); other providers return their usual
116
+ * segment-level output and callers estimate word windows from text. */
36
117
  export async function transcribeSpeechWithKey(input) {
37
118
  if (input.provider === "gemini") {
38
119
  return callGeminiSpeechTranscription(input);
39
120
  }
121
+ if (input.provider === "openai" && input.wordTimestamps) {
122
+ return callOpenAIVerboseSpeechTranscription(input);
123
+ }
40
124
  const plain = input.provider === "openrouter"
41
125
  ? await callOpenRouterSpeechTranscription(input)
42
126
  : await callOpenAISpeechTranscription(input);
@@ -201,6 +285,84 @@ async function callOpenAISpeechTranscription(input) {
201
285
  }
202
286
  };
203
287
  }
288
+ // whisper-1 verbose_json: the one provider path with real (non-estimated) word
289
+ // timestamps. Returns timed segments with their word windows attached, ready
290
+ // for animated-caption cue building.
291
+ async function callOpenAIVerboseSpeechTranscription(input) {
292
+ const contentType = input.contentType ?? "audio/mpeg";
293
+ const model = /^whisper/i.test(input.model) ? input.model : OPENAI_WORD_TIMESTAMP_STT_MODEL;
294
+ const form = new FormData();
295
+ form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
296
+ form.set("model", model);
297
+ form.set("response_format", "verbose_json");
298
+ form.append("timestamp_granularities[]", "word");
299
+ form.append("timestamp_granularities[]", "segment");
300
+ if (input.prompt) {
301
+ form.set("prompt", input.prompt);
302
+ }
303
+ if (input.language) {
304
+ form.set("language", input.language);
305
+ }
306
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
307
+ method: "POST",
308
+ headers: {
309
+ Authorization: `Bearer ${input.apiKey}`
310
+ },
311
+ body: form
312
+ });
313
+ if (response.status === 401 || response.status === 403) {
314
+ throw new ProviderAuthError("openai authentication failed");
315
+ }
316
+ if (response.status === 429) {
317
+ throw await rateLimitOrQuotaError("openai", response);
318
+ }
319
+ if (!response.ok) {
320
+ const details = await response.text();
321
+ throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
322
+ }
323
+ const data = await response.json();
324
+ const words = Array.isArray(data.words)
325
+ ? data.words
326
+ .map((entry) => ({
327
+ text: String(entry.word ?? "").trim(),
328
+ start_sec: Number(entry.start),
329
+ end_sec: Number(entry.end)
330
+ }))
331
+ .filter((word) => word.text && Number.isFinite(word.start_sec) && Number.isFinite(word.end_sec) && word.end_sec > word.start_sec)
332
+ : [];
333
+ const rawSegments = Array.isArray(data.segments) ? data.segments : [];
334
+ const segments = [];
335
+ for (const entry of rawSegments) {
336
+ const start = Number(entry.start);
337
+ const end = Number(entry.end);
338
+ const text = String(entry.text ?? "").trim();
339
+ if (!text || !Number.isFinite(start) || !Number.isFinite(end))
340
+ continue;
341
+ const segmentWords = words.filter((word) => word.start_sec >= start - 0.05 && word.start_sec < end + 0.05);
342
+ segments.push({
343
+ speaker: "speaker_1",
344
+ start_sec: start,
345
+ end_sec: end,
346
+ text,
347
+ words: segmentWords.length ? segmentWords : undefined
348
+ });
349
+ }
350
+ const text = String(data.text ?? "").trim();
351
+ if (!segments.length && text) {
352
+ segments.push({ speaker: "speaker_1", start_sec: null, end_sec: null, text, words: words.length ? words : undefined });
353
+ }
354
+ return {
355
+ text,
356
+ language: typeof data.language === "string" && data.language.trim() ? data.language.trim() : input.language?.trim() || null,
357
+ segments,
358
+ diarization: "none",
359
+ usage: {
360
+ inputTokens: Number(data.usage?.input_tokens ?? 0),
361
+ outputTokens: Number(data.usage?.output_tokens ?? 0),
362
+ costUsd: Number(data.usage?.cost ?? 0)
363
+ }
364
+ };
365
+ }
204
366
  async function callOpenRouterSpeechTranscription(input) {
205
367
  const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
206
368
  method: "POST",
@@ -162,6 +162,18 @@ export class StorageService {
162
162
  async getReadUrl(key) {
163
163
  return this.createReadUrl(key);
164
164
  }
165
+ // Absolute on-disk path for a key — local driver only (null on S3), for
166
+ // callers that hand the file to a local process (ffmpeg) without copying.
167
+ getLocalPath(key) {
168
+ if (this.s3 && config.AWS_S3_BUCKET)
169
+ return null;
170
+ try {
171
+ return this.resolveLocalPath(key);
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
165
177
  async createWriteUrl(key, input) {
166
178
  if (!this.s3 || !config.AWS_S3_BUCKET) {
167
179
  throw new Error("Presigned uploads require S3 storage.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.11.0",
3
+ "version": "0.13.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": {