@mevdragon/vidfarm-devcli 0.9.0 → 0.11.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.
@@ -0,0 +1,467 @@
1
+ // Speech provider layer (TTS + STT) shared by cloud and devcli. ProviderService
2
+ // wraps these calls with BYOK key leasing/usage metering; the devcli `tts`/`stt`
3
+ // commands call them DIRECTLY with a local env key (GEMINI_API_KEY /
4
+ // OPENAI_API_KEY / OPENROUTER_API_KEY) so speech runs local-first without the
5
+ // REST primitive route (which stays the explicit `--cloud` backup). Keep the
6
+ // request/response shapes here — one implementation, zero cloud/devcli drift.
7
+ //
8
+ // Diarization (multi-narration): only the Gemini path can label speakers — it
9
+ // is an audio-native LLM, so we ask for speaker-attributed JSON segments in one
10
+ // call. OpenAI/OpenRouter transcription endpoints return plain text only and
11
+ // degrade to a single unlabeled segment.
12
+ import { ProviderAuthError, rateLimitOrQuotaError } from "./provider-errors.js";
13
+ export const SUPPORTED_TTS_MODELS = {
14
+ openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
15
+ gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
16
+ openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
17
+ };
18
+ export const SUPPORTED_STT_MODELS = {
19
+ openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
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"])
22
+ };
23
+ export function defaultSpeechModelFor(kind, provider) {
24
+ return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
25
+ }
26
+ /** Generate speech audio with a raw provider key. Voice style rides on `instructions`. */
27
+ export async function generateSpeechWithKey(input) {
28
+ return input.provider === "gemini"
29
+ ? callGeminiSpeechGeneration(input)
30
+ : callOpenAICompatibleSpeechGeneration({
31
+ ...input,
32
+ provider: input.provider === "openrouter" ? "openrouter" : "openai"
33
+ });
34
+ }
35
+ /** Transcribe speech audio with a raw provider key; diarizes on the Gemini path. */
36
+ export async function transcribeSpeechWithKey(input) {
37
+ if (input.provider === "gemini") {
38
+ return callGeminiSpeechTranscription(input);
39
+ }
40
+ const plain = input.provider === "openrouter"
41
+ ? await callOpenRouterSpeechTranscription(input)
42
+ : await callOpenAISpeechTranscription(input);
43
+ return {
44
+ text: plain.text,
45
+ language: input.language?.trim() || null,
46
+ segments: plain.text.trim()
47
+ ? [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: plain.text.trim() }]
48
+ : [],
49
+ diarization: "none",
50
+ usage: plain.usage
51
+ };
52
+ }
53
+ async function callOpenAICompatibleSpeechGeneration(input) {
54
+ const requestedFormat = input.responseFormat ?? "mp3";
55
+ const usePcmBridge = input.provider === "openrouter" &&
56
+ /gemini/i.test(input.model) &&
57
+ requestedFormat !== "mp3";
58
+ const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
59
+ const endpoint = input.provider === "openrouter"
60
+ ? "https://openrouter.ai/api/v1/audio/speech"
61
+ : "https://api.openai.com/v1/audio/speech";
62
+ const response = await fetch(endpoint, {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ Authorization: `Bearer ${input.apiKey}`
67
+ },
68
+ body: JSON.stringify({
69
+ model: input.model,
70
+ input: input.text,
71
+ voice: input.voice ?? "alloy",
72
+ instructions: input.instructions,
73
+ response_format: responseFormat
74
+ })
75
+ });
76
+ if (response.status === 401 || response.status === 403) {
77
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
78
+ }
79
+ if (response.status === 429) {
80
+ throw await rateLimitOrQuotaError(input.provider, response);
81
+ }
82
+ if (!response.ok) {
83
+ const details = await response.text();
84
+ throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
85
+ }
86
+ const rawBytes = Buffer.from(await response.arrayBuffer());
87
+ const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
88
+ const contentType = usePcmBridge
89
+ ? "audio/wav"
90
+ : response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
91
+ return {
92
+ bytes,
93
+ contentType,
94
+ usage: {
95
+ inputTokens: 0,
96
+ outputTokens: 0,
97
+ costUsd: 0
98
+ }
99
+ };
100
+ }
101
+ async function callGeminiSpeechGeneration(input) {
102
+ const prompt = input.instructions?.trim()
103
+ ? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
104
+ : input.text;
105
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
106
+ method: "POST",
107
+ headers: {
108
+ "Content-Type": "application/json"
109
+ },
110
+ body: JSON.stringify({
111
+ contents: [{ parts: [{ text: prompt }] }],
112
+ generationConfig: {
113
+ responseModalities: ["AUDIO"],
114
+ speechConfig: {
115
+ voiceConfig: {
116
+ prebuiltVoiceConfig: {
117
+ voiceName: input.voice ?? "Kore"
118
+ }
119
+ }
120
+ }
121
+ }
122
+ })
123
+ });
124
+ if (response.status === 401 || response.status === 403) {
125
+ throw new ProviderAuthError("gemini authentication failed");
126
+ }
127
+ if (response.status === 429) {
128
+ throw await rateLimitOrQuotaError("gemini", response);
129
+ }
130
+ if (!response.ok) {
131
+ const details = await response.text();
132
+ throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
133
+ }
134
+ const data = await response.json();
135
+ const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
136
+ const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
137
+ if (!encoded) {
138
+ throw new Error("gemini speech generation returned no audio payload");
139
+ }
140
+ const rawBytes = Buffer.from(encoded, "base64");
141
+ const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
142
+ const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
143
+ const looksLikeWav = isWavBuffer(rawBytes);
144
+ const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
145
+ const shouldWrapPcm = Boolean(pcmRateMatch ||
146
+ (!looksLikeWav && !looksLikeMp3));
147
+ const bytes = shouldWrapPcm
148
+ ? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
149
+ : rawBytes;
150
+ const contentType = shouldWrapPcm
151
+ ? "audio/wav"
152
+ : looksLikeWav
153
+ ? "audio/wav"
154
+ : "audio/mpeg";
155
+ return {
156
+ bytes,
157
+ contentType,
158
+ usage: {
159
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
160
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
161
+ costUsd: 0
162
+ }
163
+ };
164
+ }
165
+ async function callOpenAISpeechTranscription(input) {
166
+ const contentType = input.contentType ?? "audio/mpeg";
167
+ const form = new FormData();
168
+ form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
169
+ form.set("model", input.model);
170
+ form.set("response_format", "json");
171
+ if (input.prompt) {
172
+ form.set("prompt", input.prompt);
173
+ }
174
+ if (input.language) {
175
+ form.set("language", input.language);
176
+ }
177
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
178
+ method: "POST",
179
+ headers: {
180
+ Authorization: `Bearer ${input.apiKey}`
181
+ },
182
+ body: form
183
+ });
184
+ if (response.status === 401 || response.status === 403) {
185
+ throw new ProviderAuthError("openai authentication failed");
186
+ }
187
+ if (response.status === 429) {
188
+ throw await rateLimitOrQuotaError("openai", response);
189
+ }
190
+ if (!response.ok) {
191
+ const details = await response.text();
192
+ throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
193
+ }
194
+ const data = await response.json();
195
+ return {
196
+ text: String(data.text ?? ""),
197
+ usage: {
198
+ inputTokens: Number(data.usage?.input_tokens ?? 0),
199
+ outputTokens: Number(data.usage?.output_tokens ?? 0),
200
+ costUsd: Number(data.usage?.cost ?? 0)
201
+ }
202
+ };
203
+ }
204
+ async function callOpenRouterSpeechTranscription(input) {
205
+ const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
206
+ method: "POST",
207
+ headers: {
208
+ "Content-Type": "application/json",
209
+ Authorization: `Bearer ${input.apiKey}`
210
+ },
211
+ body: JSON.stringify({
212
+ model: input.model,
213
+ input_audio: {
214
+ data: Buffer.from(input.audio).toString("base64"),
215
+ format: audioFormatFromContentType(input.contentType)
216
+ },
217
+ ...(input.prompt ? { prompt: input.prompt } : {}),
218
+ ...(input.language ? { language: input.language } : {})
219
+ })
220
+ });
221
+ if (response.status === 401 || response.status === 403) {
222
+ throw new ProviderAuthError("openrouter authentication failed");
223
+ }
224
+ if (response.status === 429) {
225
+ throw await rateLimitOrQuotaError("openrouter", response);
226
+ }
227
+ if (!response.ok) {
228
+ const details = await response.text();
229
+ throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
230
+ }
231
+ const data = await response.json();
232
+ return {
233
+ text: String(data.text ?? ""),
234
+ usage: {
235
+ inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
236
+ outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
237
+ costUsd: Number(data.usage?.cost ?? 0)
238
+ }
239
+ };
240
+ }
241
+ async function callGeminiSpeechTranscription(input) {
242
+ const diarize = input.diarize !== false;
243
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
244
+ method: "POST",
245
+ headers: {
246
+ "Content-Type": "application/json"
247
+ },
248
+ body: JSON.stringify({
249
+ contents: [{
250
+ parts: [
251
+ {
252
+ inline_data: {
253
+ mime_type: input.contentType ?? "audio/mpeg",
254
+ data: Buffer.from(input.audio).toString("base64")
255
+ }
256
+ },
257
+ {
258
+ text: diarize
259
+ ? buildGeminiDiarizedTranscriptionPrompt(input.prompt, input.language)
260
+ : buildGeminiTranscriptionPrompt(input.prompt, input.language)
261
+ }
262
+ ]
263
+ }],
264
+ generationConfig: {
265
+ temperature: 0,
266
+ responseMimeType: diarize ? "application/json" : "text/plain"
267
+ }
268
+ })
269
+ });
270
+ if (response.status === 401 || response.status === 403) {
271
+ throw new ProviderAuthError("gemini authentication failed");
272
+ }
273
+ if (response.status === 429) {
274
+ throw await rateLimitOrQuotaError("gemini", response);
275
+ }
276
+ if (!response.ok) {
277
+ const details = await response.text();
278
+ throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
279
+ }
280
+ const data = await response.json();
281
+ const usage = {
282
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
283
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
284
+ costUsd: 0
285
+ };
286
+ const rawText = extractGeminiText(data);
287
+ if (diarize) {
288
+ const parsed = parseDiarizedTranscription(rawText);
289
+ if (parsed) {
290
+ return { ...parsed, usage };
291
+ }
292
+ // JSON mode misbehaved — degrade to the raw text rather than failing the job.
293
+ }
294
+ return {
295
+ text: rawText.trim(),
296
+ language: input.language?.trim() || null,
297
+ segments: rawText.trim()
298
+ ? [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: rawText.trim() }]
299
+ : [],
300
+ diarization: "none",
301
+ usage
302
+ };
303
+ }
304
+ function buildGeminiTranscriptionPrompt(prompt, language) {
305
+ const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
306
+ return language?.trim()
307
+ ? `${basePrompt}\n\nExpected language: ${language.trim()}.`
308
+ : basePrompt;
309
+ }
310
+ function buildGeminiDiarizedTranscriptionPrompt(prompt, language) {
311
+ return [
312
+ "Transcribe this audio verbatim with speaker diarization.",
313
+ "Return ONLY a JSON object with this exact shape:",
314
+ `{"language": "<BCP-47 code or null>", "segments": [{"speaker": "<label>", "start_sec": <number>, "end_sec": <number>, "text": "<verbatim words>"}]}`,
315
+ "Rules:",
316
+ "- Split segments on speaker turns; keep each segment's text verbatim.",
317
+ "- Label speakers consistently across the whole audio (speaker_1, speaker_2, …), or use a real name/role when the audio makes it unambiguous (e.g. it is spoken aloud).",
318
+ "- If only one voice speaks, return all segments under speaker_1.",
319
+ "- start_sec/end_sec are seconds from the start of the audio; use your best estimate.",
320
+ "- Non-speech audio (music, silence, sfx) is not a segment.",
321
+ ...(language?.trim() ? [`- Expected language: ${language.trim()}.`] : []),
322
+ ...(prompt?.trim() ? [`Context from the caller: ${prompt.trim()}`] : [])
323
+ ].join("\n");
324
+ }
325
+ function parseDiarizedTranscription(raw) {
326
+ const trimmed = (raw ?? "").trim();
327
+ if (!trimmed)
328
+ return null;
329
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
330
+ for (const candidate of [fenced?.[1], trimmed]) {
331
+ if (!candidate)
332
+ continue;
333
+ const start = candidate.indexOf("{");
334
+ const end = candidate.lastIndexOf("}");
335
+ if (start < 0 || end <= start)
336
+ continue;
337
+ let parsed;
338
+ try {
339
+ parsed = JSON.parse(candidate.slice(start, end + 1));
340
+ }
341
+ catch {
342
+ continue;
343
+ }
344
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.segments))
345
+ continue;
346
+ const record = parsed;
347
+ const segments = record.segments
348
+ .filter((entry) => Boolean(entry) && typeof entry === "object")
349
+ .map((entry, index) => ({
350
+ speaker: typeof entry.speaker === "string" && entry.speaker.trim() ? entry.speaker.trim() : `speaker_${index + 1}`,
351
+ start_sec: Number.isFinite(Number(entry.start_sec)) ? Number(entry.start_sec) : null,
352
+ end_sec: Number.isFinite(Number(entry.end_sec)) ? Number(entry.end_sec) : null,
353
+ text: String(entry.text ?? "").trim()
354
+ }))
355
+ .filter((segment) => segment.text);
356
+ if (!segments.length)
357
+ continue;
358
+ const speakers = new Set(segments.map((segment) => segment.speaker));
359
+ return {
360
+ text: segments.map((segment) => segment.text).join(" ").trim(),
361
+ language: typeof record.language === "string" && record.language.trim() ? record.language.trim() : null,
362
+ segments,
363
+ diarization: speakers.size >= 1 ? "diarized" : "none"
364
+ };
365
+ }
366
+ return null;
367
+ }
368
+ function extractGeminiText(data) {
369
+ return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
370
+ }
371
+ function formatSrtTimestamp(totalSeconds) {
372
+ const clamped = Math.max(0, totalSeconds);
373
+ const hours = Math.floor(clamped / 3600);
374
+ const minutes = Math.floor((clamped % 3600) / 60);
375
+ const seconds = Math.floor(clamped % 60);
376
+ const millis = Math.floor((clamped * 1000) % 1000);
377
+ const pad = (value, length = 2) => String(value).padStart(length, "0");
378
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
379
+ }
380
+ /**
381
+ * Simple subtitle rendering of a transcript: one plain-text cue per timed
382
+ * segment, no speaker labels. Returns null when the provider gave no timings
383
+ * (the plain-text transcript still covers that case).
384
+ */
385
+ export function buildSrtFromSegments(segments) {
386
+ const timed = segments.filter((segment) => typeof segment.start_sec === "number" && Number.isFinite(segment.start_sec));
387
+ if (!timed.length) {
388
+ return null;
389
+ }
390
+ const cues = timed.map((segment, index) => {
391
+ const start = segment.start_sec;
392
+ const nextStart = timed[index + 1]?.start_sec;
393
+ const end = typeof segment.end_sec === "number" && segment.end_sec > start
394
+ ? segment.end_sec
395
+ : typeof nextStart === "number" && nextStart > start
396
+ ? nextStart
397
+ : start + 4;
398
+ return `${index + 1}\n${formatSrtTimestamp(start)} --> ${formatSrtTimestamp(end)}\n${segment.text}`;
399
+ });
400
+ return `${cues.join("\n\n")}\n`;
401
+ }
402
+ export function inferSpeechContentType(format) {
403
+ return format === "wav" ? "audio/wav" : "audio/mpeg";
404
+ }
405
+ export function pcm16MonoToWav(pcmBuffer, sampleRate) {
406
+ const header = Buffer.alloc(44);
407
+ header.write("RIFF", 0, 4, "ascii");
408
+ header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
409
+ header.write("WAVE", 8, 4, "ascii");
410
+ header.write("fmt ", 12, 4, "ascii");
411
+ header.writeUInt32LE(16, 16);
412
+ header.writeUInt16LE(1, 20);
413
+ header.writeUInt16LE(1, 22);
414
+ header.writeUInt32LE(sampleRate, 24);
415
+ header.writeUInt32LE(sampleRate * 2, 28);
416
+ header.writeUInt16LE(2, 32);
417
+ header.writeUInt16LE(16, 34);
418
+ header.write("data", 36, 4, "ascii");
419
+ header.writeUInt32LE(pcmBuffer.byteLength, 40);
420
+ return Buffer.concat([header, pcmBuffer]);
421
+ }
422
+ function isWavBuffer(buffer) {
423
+ return buffer.byteLength >= 12
424
+ && buffer.subarray(0, 4).toString("ascii") === "RIFF"
425
+ && buffer.subarray(8, 12).toString("ascii") === "WAVE";
426
+ }
427
+ function isLikelyMp3Buffer(buffer) {
428
+ if (buffer.byteLength < 3) {
429
+ return false;
430
+ }
431
+ if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
432
+ return true;
433
+ }
434
+ return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
435
+ }
436
+ export function audioFormatFromContentType(contentType) {
437
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
438
+ switch (normalized) {
439
+ case "audio/wav":
440
+ case "audio/x-wav":
441
+ return "wav";
442
+ case "audio/mp4":
443
+ case "audio/m4a":
444
+ return "mp4";
445
+ case "audio/webm":
446
+ return "webm";
447
+ case "audio/ogg":
448
+ return "ogg";
449
+ default:
450
+ return "mp3";
451
+ }
452
+ }
453
+ export function audioExtensionFromContentType(contentType) {
454
+ switch (audioFormatFromContentType(contentType)) {
455
+ case "wav":
456
+ return "wav";
457
+ case "mp4":
458
+ return "m4a";
459
+ case "webm":
460
+ return "webm";
461
+ case "ogg":
462
+ return "ogg";
463
+ default:
464
+ return "mp3";
465
+ }
466
+ }
467
+ //# sourceMappingURL=speech.js.map
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
3
+ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, PutObjectTaggingCommand, S3Client } from "@aws-sdk/client-s3";
4
4
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
5
5
  import { config } from "../config.js";
6
6
  function isS3NotFoundError(error) {
@@ -107,6 +107,21 @@ export class StorageService {
107
107
  writeFileSync(filePath, value);
108
108
  return { key, url: this.getPublicUrl(key) };
109
109
  }
110
+ /**
111
+ * Tag an existing S3 object. Used to mark temp-folder uploads with
112
+ * `vidfarm-temp=1` AFTER the client's presigned PUT lands (server-side, so
113
+ * no client header contract), which the bucket's tag-scoped lifecycle rule
114
+ * expires after 30 days. No-op on the local filesystem driver.
115
+ */
116
+ async putObjectTags(key, tags) {
117
+ if (!this.s3 || !config.AWS_S3_BUCKET)
118
+ return;
119
+ await this.s3.send(new PutObjectTaggingCommand({
120
+ Bucket: config.AWS_S3_BUCKET,
121
+ Key: key,
122
+ Tagging: { TagSet: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) }
123
+ }));
124
+ }
110
125
  // Returns a directly-fetchable URL for a storage key when the key is
111
126
  // stored under a prefix the bucket resource policy grants public read to.
112
127
  // Callers get a real S3 URL that browsers/CDNs/GhostCut/etc. can hit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.9.0",
3
+ "version": "0.11.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": {