@mevdragon/vidfarm-devcli 0.11.0 → 0.12.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,123 @@
1
+ // Turns a transcript into animated-caption cues. Shared by the devcli
2
+ // `captions` command group, the /editor chat agent's set_captions action, and
3
+ // any server surface that wants captions from STT output. Pure — no network,
4
+ // no filesystem — so it is safe to import from the devcli.
5
+ //
6
+ // A "cue" is one caption layer on the timeline: a short run of words shown
7
+ // together (CapCut-style pages of ~3-5 words), with per-word windows relative
8
+ // to the cue start driving the animation. Word timings come from the provider
9
+ // when available (openai whisper-1) and are char-weight estimated inside each
10
+ // segment otherwise (gemini estimates, hand-typed text) — see
11
+ // estimateCaptionWords.
12
+ import { estimateCaptionWords } from "../hyperframes/composition.js";
13
+ const CUE_TAIL_SEC = 0.12;
14
+ const MIN_WORD_SPAN_SEC = 0.05;
15
+ /** Flattens transcript segments into absolute-timed words, estimating windows
16
+ * for segments the provider left word-less (or entirely time-less — those
17
+ * distribute across fallbackDurationSec). */
18
+ export function transcriptWordTimeline(segments, fallbackDurationSec) {
19
+ const words = [];
20
+ const timeless = segments.filter((segment) => typeof segment.start_sec !== "number");
21
+ for (const segment of segments) {
22
+ if (segment.words?.length) {
23
+ words.push(...segment.words.map((word) => ({ ...word, text: word.text.trim() })).filter((word) => word.text));
24
+ continue;
25
+ }
26
+ let base;
27
+ let span;
28
+ if (typeof segment.start_sec === "number") {
29
+ base = segment.start_sec;
30
+ span = typeof segment.end_sec === "number" && segment.end_sec > segment.start_sec
31
+ ? segment.end_sec - segment.start_sec
32
+ : 0;
33
+ }
34
+ else {
35
+ // No timings at all (plain-text providers): spread the blob across the
36
+ // caller-provided window so captions still land somewhere sensible.
37
+ base = 0;
38
+ span = Number.isFinite(fallbackDurationSec) && fallbackDurationSec > 0 && timeless.length
39
+ ? fallbackDurationSec / timeless.length
40
+ : 0;
41
+ if (timeless.length > 1) {
42
+ base = (span || 0) * timeless.indexOf(segment);
43
+ }
44
+ }
45
+ const estimated = estimateCaptionWords(segment.text, span);
46
+ words.push(...estimated.map((word) => ({
47
+ text: word.text,
48
+ start_sec: base + word.start,
49
+ end_sec: base + word.end
50
+ })));
51
+ }
52
+ return words
53
+ .filter((word) => Number.isFinite(word.start_sec) && Number.isFinite(word.end_sec) && word.end_sec > word.start_sec)
54
+ .sort((a, b) => a.start_sec - b.start_sec);
55
+ }
56
+ export function buildCaptionCues(segments, options) {
57
+ const words = transcriptWordTimeline(segments, options?.fallbackDurationSec);
58
+ return cuesFromWordTimeline(words, options);
59
+ }
60
+ export function cuesFromWordTimeline(words, options) {
61
+ const maxWords = Math.max(1, Math.round(options?.maxWordsPerCue ?? 4));
62
+ const maxDuration = Math.max(0.4, options?.maxCueDurationSec ?? 2.6);
63
+ const gapBreak = Math.max(0.15, options?.gapBreakSec ?? 0.6);
64
+ const offset = Number.isFinite(options?.offsetSec) ? options?.offsetSec : 0;
65
+ const cues = [];
66
+ let bucket = [];
67
+ const flush = () => {
68
+ if (!bucket.length)
69
+ return;
70
+ const start = bucket[0].start_sec;
71
+ const end = bucket[bucket.length - 1].end_sec + CUE_TAIL_SEC;
72
+ cues.push({
73
+ text: bucket.map((word) => word.text).join(" "),
74
+ start: round3(start + offset),
75
+ duration: round3(Math.max(MIN_WORD_SPAN_SEC * bucket.length, end - start)),
76
+ words: bucket.map((word) => ({
77
+ text: word.text,
78
+ start: round3(Math.max(0, word.start_sec - start)),
79
+ end: round3(Math.max(word.start_sec - start + MIN_WORD_SPAN_SEC, word.end_sec - start))
80
+ }))
81
+ });
82
+ bucket = [];
83
+ };
84
+ for (const word of words) {
85
+ if (bucket.length) {
86
+ const cueStart = bucket[0].start_sec;
87
+ const previous = bucket[bucket.length - 1];
88
+ const silence = word.start_sec - previous.end_sec;
89
+ const wouldRun = word.end_sec - cueStart;
90
+ const sentenceBreak = /[.!?…]["')\]]?$/.test(previous.text);
91
+ if (bucket.length >= maxWords || silence > gapBreak || wouldRun > maxDuration || sentenceBreak) {
92
+ flush();
93
+ }
94
+ }
95
+ bucket.push(word);
96
+ }
97
+ flush();
98
+ // Clamp each cue against the next so consecutive caption layers never
99
+ // overlap on the shared track (one-clip-per-(track,time) invariant).
100
+ for (let index = 0; index < cues.length - 1; index += 1) {
101
+ const cue = cues[index];
102
+ const next = cues[index + 1];
103
+ const maxEnd = next.start - 0.02;
104
+ if (cue.start + cue.duration > maxEnd) {
105
+ cue.duration = round3(Math.max(MIN_WORD_SPAN_SEC, maxEnd - cue.start));
106
+ }
107
+ }
108
+ return cues.filter((cue) => cue.duration > 0);
109
+ }
110
+ /** Cues straight from plain text spread across a window — the no-transcript
111
+ * path (AI-authored captions, manual cue text). */
112
+ export function cuesFromText(text, startSec, durationSec, options) {
113
+ const words = estimateCaptionWords(text, durationSec).map((word) => ({
114
+ text: word.text,
115
+ start_sec: startSec + word.start,
116
+ end_sec: startSec + word.end
117
+ }));
118
+ return cuesFromWordTimeline(words, options);
119
+ }
120
+ function round3(value) {
121
+ return Number(value.toFixed(3));
122
+ }
123
+ //# sourceMappingURL=captions.js.map
@@ -13,7 +13,7 @@ import ffprobeStatic from "ffprobe-static";
13
13
  import { parseHTML } from "linkedom";
14
14
  import { config } from "../config.js";
15
15
  import { devErrorFields, devLog } from "../lib/dev-log.js";
16
- import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
16
+ import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_ADJACENCY_EPSILON, TRANSITION_CSS, TRANSITION_PRESETS, TRANSITION_STYLE_MARKER, buildHyperframeCompositionHtml, clampTransitionDuration } from "../hyperframes/composition.js";
17
17
  import { applyMarkupUsd } from "./billing-pricing.js";
18
18
  // The scene-annotation pass reuses the clip library's controlled vocabulary and
19
19
  // embedding-text recipe verbatim so a source scene's clip-match query is
@@ -2296,6 +2296,102 @@ function compositionHasCoverageGap(intervals, total, minGap = 0.2) {
2296
2296
  }
2297
2297
  return cursor < total - minGap;
2298
2298
  }
2299
+ // Scene transitions: the INCOMING clip carries data-transition and its vf-tr-*
2300
+ // animation plays over the clip's first data-transition-duration seconds — the
2301
+ // render engine's CSS adapter scrubs that on its own. What the render engine
2302
+ // canNOT do is keep the OUTGOING clip on screen beneath it (visibility is
2303
+ // derived strictly from data-start/data-duration), so materialize the overlap
2304
+ // here: extend the previous same-track butt-cut clip's data-duration by the
2305
+ // transition length. The granted extension is recorded in data-transition-hold
2306
+ // so the pass is idempotent and self-healing — every run first restores each
2307
+ // clip's nominal duration (duration - hold), then re-grants from the current
2308
+ // set of [data-transition] clips (so removed/retimed transitions shrink their
2309
+ // neighbor back). Stored drafts stay butt-cut; only publish/render output
2310
+ // carries the overlap. The preview runtime applies the same window live.
2311
+ function materializeTransitionOverlaps(document) {
2312
+ const clips = [];
2313
+ for (const el of Array.from(document.querySelectorAll("[data-start]"))) {
2314
+ if (el.hasAttribute("data-composition-id"))
2315
+ continue;
2316
+ const start = Number.parseFloat(el.getAttribute("data-start") || "");
2317
+ const duration = Number.parseFloat(el.getAttribute("data-duration") || "");
2318
+ if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0)
2319
+ continue;
2320
+ const priorHold = Number.parseFloat(el.getAttribute("data-transition-hold") || "");
2321
+ const hold = Number.isFinite(priorHold) && priorHold > 0 ? Math.min(priorHold, duration) : 0;
2322
+ clips.push({
2323
+ el,
2324
+ track: Number.parseInt(el.getAttribute("data-track-index") || "0", 10) || 0,
2325
+ start,
2326
+ nominal: duration - hold,
2327
+ hold: 0
2328
+ });
2329
+ }
2330
+ if (!clips.length)
2331
+ return;
2332
+ for (const clip of clips) {
2333
+ const transitionEl = clip.el;
2334
+ const preset = transitionEl.getAttribute("data-transition");
2335
+ if (!preset)
2336
+ continue;
2337
+ if (!TRANSITION_PRESETS.includes(preset)) {
2338
+ // Unknown preset would render as a permanently-paused animation with no
2339
+ // keyframes — harmless — but drop it so agents get clean state back.
2340
+ transitionEl.removeAttribute("data-transition");
2341
+ transitionEl.removeAttribute("data-transition-duration");
2342
+ continue;
2343
+ }
2344
+ const tagName = transitionEl.tagName?.toLowerCase?.() ?? "";
2345
+ if (tagName === "audio") {
2346
+ transitionEl.removeAttribute("data-transition");
2347
+ transitionEl.removeAttribute("data-transition-duration");
2348
+ continue;
2349
+ }
2350
+ // The animation cannot outlast its own clip.
2351
+ const duration = Math.min(clampTransitionDuration(transitionEl.getAttribute("data-transition-duration")), clip.nominal);
2352
+ const durationValue = Number(duration.toFixed(3));
2353
+ transitionEl.setAttribute("data-transition-duration", String(durationValue));
2354
+ // Keep the inline var (which drives animation-duration) in lockstep, same
2355
+ // as the --vf-kb-dur re-derivation above.
2356
+ const styleValue = transitionEl.getAttribute("style") || "";
2357
+ const durDecl = `--vf-tr-dur:${durationValue}s`;
2358
+ const nextStyle = /(^|;)\s*--vf-tr-dur\s*:[^;]*/i.test(styleValue)
2359
+ ? styleValue.replace(/(^|;)\s*--vf-tr-dur\s*:[^;]*/i, (_match, lead) => `${lead}${durDecl}`)
2360
+ : styleValue.length === 0 || styleValue.endsWith(";")
2361
+ ? `${styleValue}${durDecl}`
2362
+ : `${styleValue};${durDecl}`;
2363
+ if (nextStyle !== styleValue)
2364
+ transitionEl.setAttribute("style", nextStyle);
2365
+ // Grant the hold to the clip this one cuts away from: same track, nominal
2366
+ // end at (within epsilon of) this clip's start.
2367
+ for (const candidate of clips) {
2368
+ if (candidate === clip || candidate.track !== clip.track)
2369
+ continue;
2370
+ if (Math.abs(candidate.start + candidate.nominal - clip.start) > TRANSITION_ADJACENCY_EPSILON)
2371
+ continue;
2372
+ const candidateTag = candidate.el.tagName?.toLowerCase?.() ?? "";
2373
+ if (candidateTag === "audio")
2374
+ continue;
2375
+ candidate.hold = Math.max(candidate.hold, duration);
2376
+ }
2377
+ }
2378
+ for (const clip of clips) {
2379
+ const nextDuration = Number((clip.nominal + clip.hold).toFixed(3));
2380
+ const currentDuration = Number.parseFloat(clip.el.getAttribute("data-duration") || "");
2381
+ if (currentDuration !== nextDuration) {
2382
+ clip.el.setAttribute("data-duration", String(nextDuration));
2383
+ }
2384
+ if (clip.el.hasAttribute("data-end")) {
2385
+ clip.el.setAttribute("data-end", String(Number((clip.start + nextDuration).toFixed(3))));
2386
+ }
2387
+ if (clip.hold > 0) {
2388
+ clip.el.setAttribute("data-transition-hold", String(Number(clip.hold.toFixed(3))));
2389
+ }
2390
+ else {
2391
+ clip.el.removeAttribute("data-transition-hold");
2392
+ }
2393
+ }
2394
+ }
2299
2395
  export function normalizePublishHtml(html) {
2300
2396
  const { document } = parseHTML(html);
2301
2397
  // Coverage gaps: any interval of the timeline with no active canvas-filling
@@ -2412,6 +2508,22 @@ export function normalizePublishHtml(html) {
2412
2508
  styleEl.textContent = KEN_BURNS_CSS;
2413
2509
  (document.head ?? document.documentElement)?.appendChild(styleEl);
2414
2510
  }
2511
+ // Animated captions: word windows are stored relative to the layer start
2512
+ // (inline animation-delay/duration on each [data-cap-word]), so retimes need
2513
+ // no re-derivation — but the shared vf-cap-* keyframes stylesheet must
2514
+ // travel with compositions stored before the builder embedded it, or every
2515
+ // word renders in its resting state.
2516
+ if (document.querySelector("[data-caption-animation]") && !html.includes(CAPTION_STYLE_MARKER)) {
2517
+ const styleEl = document.createElement("style");
2518
+ styleEl.textContent = CAPTION_ANIM_CSS;
2519
+ (document.head ?? document.documentElement)?.appendChild(styleEl);
2520
+ }
2521
+ materializeTransitionOverlaps(document);
2522
+ if (document.querySelector("[data-transition]") && !html.includes(TRANSITION_STYLE_MARKER)) {
2523
+ const styleEl = document.createElement("style");
2524
+ styleEl.textContent = TRANSITION_CSS;
2525
+ (document.head ?? document.documentElement)?.appendChild(styleEl);
2526
+ }
2415
2527
  // Editor sessions occasionally leave inline style attributes with double
2416
2528
  // quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
2417
2529
  // an HTML attribute, those quotes become &quot; entities. The render lambda
@@ -7,6 +7,9 @@ export class ProviderKeyUnavailableError extends Error {
7
7
  }
8
8
  export class ProviderAuthError extends Error {
9
9
  }
10
+ /** A TTS voice preset that belongs to a different provider family — user input error, never retryable. */
11
+ export class SpeechVoiceMismatchError extends Error {
12
+ }
10
13
  export class ProviderQuotaExceededError extends Error {
11
14
  }
12
15
  export class ProviderWaitExceededError extends Error {
@@ -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;
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.11.0",
3
+ "version": "0.12.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": {