@larkup/tool-video-intelligence 0.2.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 (54) hide show
  1. package/.env.example +150 -0
  2. package/LICENSE +176 -0
  3. package/README.md +281 -0
  4. package/compose.gpu.yaml +14 -0
  5. package/compose.yaml +71 -0
  6. package/dist/agent.d.ts +131 -0
  7. package/dist/agent.js +2087 -0
  8. package/dist/brief.d.ts +2 -0
  9. package/dist/brief.js +37 -0
  10. package/dist/client.d.ts +46 -0
  11. package/dist/client.js +139 -0
  12. package/dist/contracts.d.ts +331 -0
  13. package/dist/contracts.js +1 -0
  14. package/dist/index.d.ts +87 -0
  15. package/dist/index.js +391 -0
  16. package/dist/runtime.d.ts +96 -0
  17. package/dist/runtime.js +592 -0
  18. package/dist/ui.d.ts +82 -0
  19. package/dist/ui.js +87 -0
  20. package/package.json +84 -0
  21. package/runtime/Dockerfile +119 -0
  22. package/runtime/app/__init__.py +3 -0
  23. package/runtime/app/__main__.py +19 -0
  24. package/runtime/app/api/__init__.py +0 -0
  25. package/runtime/app/api/deps.py +69 -0
  26. package/runtime/app/api/v1.py +166 -0
  27. package/runtime/app/config.py +78 -0
  28. package/runtime/app/db/__init__.py +0 -0
  29. package/runtime/app/db/schemas.py +162 -0
  30. package/runtime/app/db/store.py +466 -0
  31. package/runtime/app/main.py +27 -0
  32. package/runtime/app/model_configuration.py +157 -0
  33. package/runtime/app/services/__init__.py +0 -0
  34. package/runtime/app/services/brain.py +2221 -0
  35. package/runtime/app/services/embedding.py +473 -0
  36. package/runtime/app/services/jobs.py +237 -0
  37. package/runtime/app/services/motion.py +66 -0
  38. package/runtime/app/services/pipeline.py +1911 -0
  39. package/runtime/app/services/scene.py +161 -0
  40. package/runtime/app/services/storage.py +44 -0
  41. package/runtime/app/services/transcription.py +667 -0
  42. package/runtime/app/services/vision.py +1441 -0
  43. package/runtime/app/utils/__init__.py +0 -0
  44. package/runtime/app/utils/timing.py +99 -0
  45. package/runtime/app/worker.py +20 -0
  46. package/runtime/pyproject.toml +56 -0
  47. package/runtime/requirements-cpu.txt +15 -0
  48. package/runtime/requirements-smoke.txt +7 -0
  49. package/runtime/requirements.txt +14 -0
  50. package/runtime/uv.lock +3637 -0
  51. package/scripts/grant-cloud-credits.sh +43 -0
  52. package/scripts/runtime.mjs +156 -0
  53. package/scripts/validate-indexing.mjs +168 -0
  54. package/tool.manifest.json +617 -0
@@ -0,0 +1,667 @@
1
+ """Speech-to-text, behind a provider so a deploy target can pick the cheapest
2
+ or fastest option available to it without the pipeline caring which one runs.
3
+
4
+ `whisper` decodes locally (faster-whisper); hosted providers are preferred by
5
+ managed workers and may fall back to Whisper when they return no usable speech.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ import threading
13
+ import mimetypes
14
+ import tempfile
15
+ from abc import ABC, abstractmethod
16
+ from collections import Counter
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+ import requests
21
+
22
+ DEEPGRAM_URL = "https://api.deepgram.com/v1/listen"
23
+ OPENAI_TRANSCRIPT_URL = "https://api.openai.com/v1/audio/transcriptions"
24
+ GROQ_TRANSCRIPT_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
25
+ ELEVENLABS_TRANSCRIPT_URL = "https://api.elevenlabs.io/v1/speech-to-text"
26
+ TranscriptionProgress = Callable[[int, int], None]
27
+
28
+
29
+ class TranscriptionProvider(ABC):
30
+ @abstractmethod
31
+ def transcribe(
32
+ self, path: Path, language_hint: str | None, hints: list[str] | None = None
33
+ ) -> tuple[list[dict[str, Any]], str | None]:
34
+ """Returns (segments, detected_language). Each segment has startMs/endMs/text/words."""
35
+
36
+
37
+ class EmptyTranscriptionError(RuntimeError):
38
+ """A provider completed successfully but returned no usable speech evidence."""
39
+
40
+
41
+ def _materialize_hosted_audio_chunks(
42
+ path: Path, duration_secs: float, chunk_secs: int, destination: Path
43
+ ) -> list[tuple[float, Path]]:
44
+ """Decode a long source once into small speech-provider uploads."""
45
+ if duration_secs <= chunk_secs:
46
+ return [(0.0, path)]
47
+ pattern = destination / "speech-%05d.mp3"
48
+ subprocess.run(
49
+ [
50
+ "ffmpeg",
51
+ "-nostdin",
52
+ "-hide_banner",
53
+ "-loglevel",
54
+ "error",
55
+ "-i",
56
+ str(path),
57
+ "-map",
58
+ "0:a:0",
59
+ "-vn",
60
+ "-ac",
61
+ "1",
62
+ "-ar",
63
+ "16000",
64
+ "-c:a",
65
+ "libmp3lame",
66
+ "-b:a",
67
+ "48k",
68
+ "-f",
69
+ "segment",
70
+ "-segment_time",
71
+ str(chunk_secs),
72
+ "-reset_timestamps",
73
+ "1",
74
+ str(pattern),
75
+ ],
76
+ check=True,
77
+ capture_output=True,
78
+ )
79
+ chunks = sorted(destination.glob("speech-*.mp3"))
80
+ if not chunks:
81
+ raise RuntimeError("ffmpeg produced no audio chunks")
82
+ return [(index * float(chunk_secs), chunk) for index, chunk in enumerate(chunks)]
83
+
84
+
85
+ def _rebase_segments(
86
+ segments: list[dict[str, Any]], offset_secs: float
87
+ ) -> list[dict[str, Any]]:
88
+ offset_ms = round(offset_secs * 1_000)
89
+ rebased: list[dict[str, Any]] = []
90
+ for segment in segments:
91
+ copy = {**segment}
92
+ for key in ("startMs", "endMs"):
93
+ if isinstance(copy.get(key), (int, float)):
94
+ copy[key] = round(float(copy[key])) + offset_ms
95
+ copy["words"] = [
96
+ {
97
+ **word,
98
+ **{
99
+ key: round(float(word[key])) + offset_ms
100
+ for key in ("startMs", "endMs")
101
+ if isinstance(word.get(key), (int, float))
102
+ },
103
+ }
104
+ for word in segment.get("words") or []
105
+ if isinstance(word, dict)
106
+ ]
107
+ rebased.append(copy)
108
+ return rebased
109
+
110
+
111
+ def _timestamped_segment(
112
+ item: dict[str, Any], words: list[dict[str, Any]] | None = None
113
+ ) -> dict[str, Any]:
114
+ return {
115
+ "startMs": round(float(item.get("start", 0)) * 1_000),
116
+ "endMs": round(float(item.get("end", 0)) * 1_000),
117
+ "text": str(item.get("transcript") or item.get("text") or "").strip(),
118
+ "words": [
119
+ {
120
+ "startMs": round(float(word.get("start", 0)) * 1_000),
121
+ "endMs": round(float(word.get("end", 0)) * 1_000),
122
+ "text": str(
123
+ word.get("punctuated_word") or word.get("word") or ""
124
+ ).strip(),
125
+ "confidence": round(float(word.get("confidence", 0)), 4),
126
+ }
127
+ for word in (words if words is not None else item.get("words") or [])
128
+ if str(word.get("punctuated_word") or word.get("word") or "").strip()
129
+ ],
130
+ }
131
+
132
+
133
+ def _deepgram_segments(results: dict[str, Any]) -> list[dict[str, Any]]:
134
+ utterances = [
135
+ _timestamped_segment(utterance)
136
+ for utterance in results.get("utterances") or []
137
+ if str(utterance.get("transcript") or "").strip()
138
+ ]
139
+ if utterances:
140
+ return utterances
141
+
142
+ channels = results.get("channels") or []
143
+ alternatives = channels[0].get("alternatives") or [] if channels else []
144
+ alternative = alternatives[0] if alternatives else {}
145
+ paragraphs = (alternative.get("paragraphs") or {}).get("paragraphs") or []
146
+ sentences = [
147
+ sentence
148
+ for paragraph in paragraphs
149
+ for sentence in paragraph.get("sentences") or []
150
+ if str(sentence.get("text") or "").strip()
151
+ ]
152
+ if sentences:
153
+ words = alternative.get("words") or []
154
+ return [
155
+ _timestamped_segment(
156
+ sentence,
157
+ [
158
+ word
159
+ for word in words
160
+ if float(sentence.get("start", 0))
161
+ <= float(word.get("start", 0))
162
+ <= float(sentence.get("end", 0))
163
+ ],
164
+ )
165
+ for sentence in sentences
166
+ ]
167
+
168
+ words = [
169
+ word
170
+ for word in alternative.get("words") or []
171
+ if str(word.get("punctuated_word") or word.get("word") or "").strip()
172
+ ]
173
+ if not words:
174
+ return []
175
+
176
+ # Some Deepgram responses omit both utterances and paragraph metadata.
177
+ # Build short evidence windows from word timestamps instead of collapsing
178
+ # a long recording into one unusable segment.
179
+ groups: list[list[dict[str, Any]]] = []
180
+ current: list[dict[str, Any]] = []
181
+ for word in words:
182
+ previous_end = float(current[-1].get("end", 0)) if current else 0
183
+ group_start = float(current[0].get("start", 0)) if current else 0
184
+ word_start = float(word.get("start", 0))
185
+ if current and (
186
+ word_start - previous_end > 1.25 or word_start - group_start >= 15
187
+ ):
188
+ groups.append(current)
189
+ current = []
190
+ current.append(word)
191
+ punctuated = str(word.get("punctuated_word") or "")
192
+ if punctuated.endswith((".", "?", "!", "؟")):
193
+ groups.append(current)
194
+ current = []
195
+ if current:
196
+ groups.append(current)
197
+ return [
198
+ _timestamped_segment(
199
+ {
200
+ "start": group[0].get("start", 0),
201
+ "end": group[-1].get("end", group[0].get("start", 0)),
202
+ "text": " ".join(
203
+ str(word.get("punctuated_word") or word.get("word") or "").strip()
204
+ for word in group
205
+ ),
206
+ },
207
+ group,
208
+ )
209
+ for group in groups
210
+ ]
211
+
212
+
213
+ class WhisperProvider(TranscriptionProvider):
214
+ # Below this share of the source carrying speech, voice detection is
215
+ # assumed to have discarded speech rather than found silence. Continuous
216
+ # background noise -- a crowd, traffic, music, a busy room -- is what
217
+ # triggers it, and the speech it swallows is exactly the commentary or
218
+ # narration an answer later depends on. Measured on a noisy source: voice
219
+ # detection kept 2 segments where a second pass without it recovered 66.
220
+ MINIMUM_SPEECH_COVERAGE = 0.1
221
+
222
+ def __init__(self, device: str) -> None:
223
+ self.device = device
224
+ self._lock = threading.Lock()
225
+ self._model: Any = None
226
+
227
+ def _decode(
228
+ self,
229
+ path: Path,
230
+ language_hint: str | None,
231
+ hints: list[str] | None,
232
+ use_voice_detection: bool,
233
+ ) -> tuple[list[dict[str, Any]], Any]:
234
+ segments, info = self._model.transcribe(
235
+ str(path),
236
+ language=language_hint,
237
+ vad_filter=use_voice_detection,
238
+ word_timestamps=True,
239
+ beam_size=5,
240
+ initial_prompt=", ".join(
241
+ hint.strip() for hint in (hints or []) if hint.strip()
242
+ )[:900]
243
+ or None,
244
+ )
245
+ return [
246
+ {
247
+ "startMs": round(segment.start * 1_000),
248
+ "endMs": round(segment.end * 1_000),
249
+ "text": segment.text.strip(),
250
+ "words": [
251
+ {
252
+ "startMs": round((word.start or segment.start) * 1_000),
253
+ "endMs": round((word.end or segment.end) * 1_000),
254
+ "text": word.word.strip(),
255
+ "confidence": round(float(word.probability), 4),
256
+ }
257
+ for word in (segment.words or [])
258
+ ],
259
+ }
260
+ for segment in segments
261
+ if segment.text.strip()
262
+ ], info
263
+
264
+ def transcribe(
265
+ self, path: Path, language_hint: str | None, hints: list[str] | None = None
266
+ ) -> tuple[list[dict[str, Any]], str | None]:
267
+ with self._lock:
268
+ if self._model is None:
269
+ from faster_whisper import WhisperModel
270
+
271
+ model_name = os.getenv("LARKUP_VIDEO_WHISPER_MODEL", "small")
272
+ compute_type = "float16" if self.device == "cuda" else "int8"
273
+ self._model = WhisperModel(
274
+ model_name, device=self.device, compute_type=compute_type
275
+ )
276
+ result, info = self._decode(path, language_hint, hints, True)
277
+ duration = float(getattr(info, "duration", 0) or 0)
278
+ speech_secs = sum(
279
+ (item["endMs"] - item["startMs"]) / 1_000 for item in result
280
+ )
281
+ if duration > 0 and speech_secs / duration < self.MINIMUM_SPEECH_COVERAGE:
282
+ # Keep whichever pass heard more. Voice detection is worth
283
+ # having when it works -- it is faster and suppresses
284
+ # hallucinated text over silence -- so it is only overridden
285
+ # when a second pass demonstrably recovers more speech.
286
+ retried, retried_info = self._decode(path, language_hint, hints, False)
287
+ if len(retried) > len(result):
288
+ return retried, getattr(retried_info, "language", None)
289
+ return result, getattr(info, "language", None)
290
+
291
+
292
+ class DeepgramProvider(TranscriptionProvider):
293
+ def transcribe(
294
+ self, path: Path, language_hint: str | None, hints: list[str] | None = None
295
+ ) -> tuple[list[dict[str, Any]], str | None]:
296
+ api_key = os.getenv("DEEPGRAM_API_KEY")
297
+ if not api_key:
298
+ raise RuntimeError("DEEPGRAM_API_KEY is not configured")
299
+ automatic_language = not language_hint or language_hint == "auto"
300
+ model = (
301
+ os.getenv(
302
+ (
303
+ "LARKUP_VIDEO_DEEPGRAM_AUTO_MODEL"
304
+ if automatic_language
305
+ else "LARKUP_VIDEO_DEEPGRAM_MODEL"
306
+ ),
307
+ "nova-3",
308
+ ).strip()
309
+ or "nova-3"
310
+ )
311
+ params: list[tuple[str, str]] = [
312
+ ("model", model),
313
+ ("smart_format", "true"),
314
+ ("punctuate", "true"),
315
+ ("utterances", "true"),
316
+ ]
317
+ if not automatic_language:
318
+ params.append(("language", language_hint))
319
+ else:
320
+ # Nova-3's multilingual route recognizes code-switching and RTL
321
+ # languages in one pass. Generic language detection can select a
322
+ # single wrong model from noisy music, crowds, or commentary.
323
+ params.append(("language", "multi"))
324
+ for hint in (hints or [])[:100] if model.startswith("nova-3") else []:
325
+ normalized = hint.strip()
326
+ if normalized:
327
+ # Nova-3 keyterms improve transcription of people,
328
+ # organizations, products, and other proper nouns without
329
+ # changing the meaning of the audio or relying on a scenario.
330
+ params.append(("keyterm", normalized[:200]))
331
+ media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
332
+ timeout_secs = max(
333
+ 30,
334
+ min(
335
+ 900,
336
+ int(
337
+ os.getenv(
338
+ "LARKUP_VIDEO_TRANSCRIPTION_REQUEST_TIMEOUT_SECONDS", "60"
339
+ )
340
+ ),
341
+ ),
342
+ )
343
+ with path.open("rb") as source:
344
+ response = requests.post(
345
+ DEEPGRAM_URL,
346
+ params=params,
347
+ # Deepgram's pre-recorded endpoint uses the media Content-Type
348
+ # to decode local containers accurately; omitting it makes
349
+ # video speech recognition materially less reliable.
350
+ headers={
351
+ "Authorization": f"Token {api_key}",
352
+ "Content-Type": media_type,
353
+ },
354
+ data=source,
355
+ timeout=timeout_secs,
356
+ )
357
+ if not response.ok:
358
+ raise RuntimeError(
359
+ f"Deepgram request failed {response.status_code}: {response.text[:500]}"
360
+ )
361
+ body = response.json()
362
+ results = body.get("results") or {}
363
+ channels = results.get("channels") or [{}]
364
+ detected_language = channels[0].get("detected_language") if channels else None
365
+ segments = _deepgram_segments(results)
366
+ return segments, detected_language or (
367
+ language_hint if language_hint != "auto" else None
368
+ )
369
+
370
+
371
+ class OpenAICompatibleProvider(TranscriptionProvider):
372
+ """Timestamped transcription through OpenAI or Groq's compatible API."""
373
+
374
+ def __init__(self, provider: str) -> None:
375
+ self.provider = provider
376
+
377
+ def transcribe(
378
+ self, path: Path, language_hint: str | None, hints: list[str] | None = None
379
+ ) -> tuple[list[dict[str, Any]], str | None]:
380
+ is_groq = self.provider == "groq"
381
+ api_key = os.getenv("GROQ_API_KEY" if is_groq else "OPENAI_API_KEY")
382
+ if not api_key:
383
+ raise RuntimeError(f"{self.provider.upper()}_API_KEY is not configured")
384
+ model = os.getenv(
385
+ (
386
+ "LARKUP_VIDEO_GROQ_TRANSCRIPTION_MODEL"
387
+ if is_groq
388
+ else "LARKUP_VIDEO_OPENAI_TRANSCRIPTION_MODEL"
389
+ ),
390
+ "whisper-large-v3-turbo" if is_groq else "whisper-1",
391
+ )
392
+ data: list[tuple[str, str]] = [
393
+ ("model", model),
394
+ ("response_format", "verbose_json"),
395
+ ("timestamp_granularities[]", "segment"),
396
+ ("timestamp_granularities[]", "word"),
397
+ ]
398
+ if language_hint and language_hint != "auto":
399
+ data.append(("language", language_hint))
400
+ prompt = ", ".join(hint.strip() for hint in (hints or []) if hint.strip())[:900]
401
+ if prompt:
402
+ data.append(("prompt", prompt))
403
+ media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
404
+ with path.open("rb") as source:
405
+ response = requests.post(
406
+ GROQ_TRANSCRIPT_URL if is_groq else OPENAI_TRANSCRIPT_URL,
407
+ headers={"Authorization": f"Bearer {api_key}"},
408
+ data=data,
409
+ files={"file": (path.name, source, media_type)},
410
+ timeout=900,
411
+ )
412
+ if not response.ok:
413
+ raise RuntimeError(
414
+ f"{self.provider} transcription failed {response.status_code}: {response.text[:500]}"
415
+ )
416
+ body = response.json()
417
+ segments = [
418
+ {
419
+ "startMs": round(float(segment.get("start", 0)) * 1_000),
420
+ "endMs": round(float(segment.get("end", 0)) * 1_000),
421
+ "text": str(segment.get("text") or "").strip(),
422
+ "words": [
423
+ {
424
+ "startMs": round(float(word.get("start", 0)) * 1_000),
425
+ "endMs": round(float(word.get("end", 0)) * 1_000),
426
+ "text": str(word.get("word") or word.get("text") or "").strip(),
427
+ "confidence": 0.8,
428
+ }
429
+ for word in body.get("words") or []
430
+ if float(segment.get("start", 0))
431
+ <= float(word.get("start", 0))
432
+ <= float(segment.get("end", 0))
433
+ ],
434
+ }
435
+ for segment in body.get("segments") or []
436
+ if str(segment.get("text") or "").strip()
437
+ ]
438
+ return segments, body.get("language") or (
439
+ language_hint if language_hint != "auto" else None
440
+ )
441
+
442
+
443
+ class ElevenLabsProvider(TranscriptionProvider):
444
+ def transcribe(
445
+ self, path: Path, language_hint: str | None, hints: list[str] | None = None
446
+ ) -> tuple[list[dict[str, Any]], str | None]:
447
+ api_key = os.getenv("ELEVENLABS_API_KEY")
448
+ if not api_key:
449
+ raise RuntimeError("ELEVENLABS_API_KEY is not configured")
450
+ media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
451
+ data = {
452
+ "model_id": os.getenv(
453
+ "LARKUP_VIDEO_ELEVENLABS_TRANSCRIPTION_MODEL", "scribe_v2"
454
+ )
455
+ }
456
+ if language_hint and language_hint != "auto":
457
+ data["language_code"] = language_hint
458
+ with path.open("rb") as source:
459
+ response = requests.post(
460
+ ELEVENLABS_TRANSCRIPT_URL,
461
+ headers={"xi-api-key": api_key},
462
+ data=data,
463
+ files={"file": (path.name, source, media_type)},
464
+ timeout=900,
465
+ )
466
+ if not response.ok:
467
+ raise RuntimeError(
468
+ f"ElevenLabs transcription failed {response.status_code}: {response.text[:500]}"
469
+ )
470
+ body = response.json()
471
+ words = [
472
+ word
473
+ for word in body.get("words") or []
474
+ if str(word.get("text") or "").strip()
475
+ ]
476
+ segments = [
477
+ {
478
+ "startMs": round(float(word.get("start", 0)) * 1_000),
479
+ "endMs": round(float(word.get("end", 0)) * 1_000),
480
+ "text": str(word.get("text") or "").strip(),
481
+ "words": [
482
+ {
483
+ "startMs": round(float(word.get("start", 0)) * 1_000),
484
+ "endMs": round(float(word.get("end", 0)) * 1_000),
485
+ "text": str(word.get("text") or "").strip(),
486
+ "confidence": float(word.get("logprob", 0.8)),
487
+ }
488
+ ],
489
+ }
490
+ for word in words
491
+ if str(word.get("type") or "word") == "word"
492
+ ]
493
+ return segments, body.get("language_code") or (
494
+ language_hint if language_hint != "auto" else None
495
+ )
496
+
497
+
498
+ class TranscriptionService:
499
+ def __init__(self, device: str) -> None:
500
+ self._factories: dict[str, Any] = {
501
+ "whisper": lambda: WhisperProvider(device),
502
+ "deepgram": DeepgramProvider,
503
+ "openai": lambda: OpenAICompatibleProvider("openai"),
504
+ "groq": lambda: OpenAICompatibleProvider("groq"),
505
+ "elevenlabs": ElevenLabsProvider,
506
+ }
507
+ self._providers: dict[str, TranscriptionProvider] = {}
508
+ self.last_diagnostics: dict[str, Any] = {
509
+ "provider": None,
510
+ "fallbackProvider": None,
511
+ "fallbackUsed": False,
512
+ "chunkCount": 0,
513
+ "chunkErrors": 0,
514
+ }
515
+
516
+ def _get_provider(self, name: str) -> TranscriptionProvider:
517
+ if name not in self._providers:
518
+ factory = self._factories.get(name)
519
+ if factory is None:
520
+ raise ValueError(f"unknown transcription provider: {name!r}")
521
+ self._providers[name] = factory()
522
+ return self._providers[name]
523
+
524
+ def transcribe(
525
+ self,
526
+ path: Path,
527
+ language_hint: str | None,
528
+ hints: list[str] | None = None,
529
+ source_duration_secs: float | None = None,
530
+ progress: TranscriptionProgress | None = None,
531
+ ) -> tuple[list[dict[str, Any]], str | None]:
532
+ primary_name = os.getenv(
533
+ "LARKUP_VIDEO_TRANSCRIPTION_PROVIDER", "whisper"
534
+ ).strip()
535
+ fallback_name = os.getenv(
536
+ "LARKUP_VIDEO_TRANSCRIPTION_FALLBACK",
537
+ "whisper" if primary_name != "whisper" else "",
538
+ ).strip()
539
+ self.last_diagnostics = {
540
+ "provider": primary_name,
541
+ "fallbackProvider": None,
542
+ "fallbackUsed": False,
543
+ "chunkCount": 0,
544
+ "completedChunks": 0,
545
+ "chunkErrors": 0,
546
+ }
547
+ primary_error: BaseException | None = None
548
+ try:
549
+ segments, language = self._transcribe_primary(
550
+ primary_name,
551
+ path,
552
+ language_hint,
553
+ hints,
554
+ source_duration_secs,
555
+ progress,
556
+ )
557
+ if segments:
558
+ return segments, language
559
+ primary_error = EmptyTranscriptionError(
560
+ f"{primary_name} returned no usable speech segments"
561
+ )
562
+ except BaseException as error:
563
+ primary_error = error
564
+
565
+ if fallback_name and fallback_name != primary_name:
566
+ try:
567
+ self.last_diagnostics["fallbackProvider"] = fallback_name
568
+ self.last_diagnostics["fallbackUsed"] = True
569
+ if progress:
570
+ progress(0, 1)
571
+ segments, language = self._get_provider(fallback_name).transcribe(
572
+ path, language_hint, hints
573
+ )
574
+ if segments:
575
+ self.last_diagnostics["completedChunks"] = 1
576
+ if progress:
577
+ progress(1, 1)
578
+ return segments, language
579
+ raise EmptyTranscriptionError(
580
+ f"{fallback_name} returned no usable speech segments"
581
+ )
582
+ except BaseException as fallback_error:
583
+ raise RuntimeError(
584
+ f"primary transcription failed ({type(primary_error).__name__}: "
585
+ f"{primary_error}); fallback failed ({type(fallback_error).__name__}: "
586
+ f"{fallback_error})"
587
+ ) from fallback_error
588
+
589
+ assert primary_error is not None
590
+ raise primary_error
591
+
592
+ def _transcribe_primary(
593
+ self,
594
+ provider_name: str,
595
+ path: Path,
596
+ language_hint: str | None,
597
+ hints: list[str] | None,
598
+ source_duration_secs: float | None,
599
+ progress: TranscriptionProgress | None,
600
+ ) -> tuple[list[dict[str, Any]], str | None]:
601
+ provider = self._get_provider(provider_name)
602
+ chunk_secs = max(
603
+ 30,
604
+ min(900, int(os.getenv("LARKUP_VIDEO_TRANSCRIPTION_CHUNK_SECONDS", "180"))),
605
+ )
606
+ duration_secs = max(0.0, float(source_duration_secs or 0))
607
+ if provider_name == "whisper" or duration_secs <= chunk_secs:
608
+ self.last_diagnostics["chunkCount"] = 1
609
+ if progress:
610
+ progress(0, 1)
611
+ result = provider.transcribe(path, language_hint, hints)
612
+ self.last_diagnostics["completedChunks"] = 1
613
+ if progress:
614
+ progress(1, 1)
615
+ return result
616
+
617
+ concurrency = max(
618
+ 1,
619
+ min(8, int(os.getenv("LARKUP_VIDEO_TRANSCRIPTION_CONCURRENCY", "3"))),
620
+ )
621
+ with tempfile.TemporaryDirectory(prefix="larkup-speech-chunks-") as temporary:
622
+ chunks = _materialize_hosted_audio_chunks(
623
+ path, duration_secs, chunk_secs, Path(temporary)
624
+ )
625
+ self.last_diagnostics["chunkCount"] = len(chunks)
626
+ if progress:
627
+ progress(0, len(chunks))
628
+ completed: list[tuple[float, list[dict[str, Any]], str | None]] = []
629
+ errors: list[BaseException] = []
630
+ processed_chunks = 0
631
+ with ThreadPoolExecutor(max_workers=min(concurrency, len(chunks))) as pool:
632
+ futures = {
633
+ pool.submit(
634
+ provider.transcribe, chunk, language_hint, hints
635
+ ): offset
636
+ for offset, chunk in chunks
637
+ }
638
+ for future in as_completed(futures):
639
+ try:
640
+ segments, language = future.result()
641
+ completed.append((futures[future], segments, language))
642
+ except BaseException as error:
643
+ errors.append(error)
644
+ finally:
645
+ processed_chunks += 1
646
+ self.last_diagnostics["completedChunks"] = processed_chunks
647
+ if progress:
648
+ progress(processed_chunks, len(chunks))
649
+ self.last_diagnostics["chunkErrors"] = len(errors)
650
+ minimum_successes = max(1, round(len(chunks) * 0.8))
651
+ if len(completed) < minimum_successes:
652
+ detail = errors[0] if errors else "no chunk completed"
653
+ raise RuntimeError(
654
+ f"hosted transcription completed {len(completed)}/{len(chunks)} chunks: {detail}"
655
+ )
656
+ merged = [
657
+ segment
658
+ for offset, segments, _language in sorted(completed)
659
+ for segment in _rebase_segments(segments, offset)
660
+ ]
661
+ languages = Counter(
662
+ language for _offset, _segments, language in completed if language
663
+ )
664
+ detected = languages.most_common(1)[0][0] if languages else None
665
+ return merged, detected or (
666
+ language_hint if language_hint != "auto" else None
667
+ )