redmine-context 1.0.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 (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Extração da faixa de áudio de VÍDEO + pipeline vídeo→áudio→transcrição
3
+ * (M4-07, #63, ADR-002) + keyframe representativo (M4-08, #64).
4
+ *
5
+ * KEYFRAME (M4-08, #64): {@link extractVideoKeyframe} grava 1 frame JPEG no DIR DO
6
+ * ANEXO (ao lado do `original`, ADR-004), reusando o MESMO subprocesso seguro
7
+ * (sem shell, `-protocol_whitelist file`, env sanitizado, watchdog) — o keyframe é
8
+ * um EXTRA: sua falha (ffmpeg ausente/erro/timeout) NUNCA afeta a transcrição
9
+ * (degradação graciosa). O bundle apenas REFERENCIA o keyframe pelo cache path (via
10
+ * {@link ExtractionArtifact}); o binário jamais é embutido (o MCP é read-only/texto).
11
+ *
12
+ * REUSO (não reimplementação): a extração de áudio de um container de vídeo é a
13
+ * MESMA operação ffmpeg da #60 — o ffmpeg já lida com mp4/mkv/webm e `-vn`
14
+ * descarta a faixa de vídeo, deixando só o áudio → WAV 16 kHz mono. Portanto este
15
+ * módulo NÃO duplica a invocação do subprocesso (sem shell, `-protocol_whitelist
16
+ * file`, env sanitizado, watchdog SIGTERM→SIGKILL, remoção do WAV parcial): ele
17
+ * DELEGA a {@link convertAudioToWav} e apenas:
18
+ *
19
+ * 1. {@link convertVideoToWav} — fino wrapper que roteia a entrada de vídeo para a
20
+ * conversão da #60 e DETECTA o caso NOVO do vídeo: container SEM faixa de áudio.
21
+ * O ffmpeg, ao descartar o vídeo com `-vn` sobre um arquivo mudo, falha com
22
+ * "does not contain any stream" / "matches no streams"; remapeamos essa falha
23
+ * genérica (`erro-conversao`) para um motivo CLARO, `video-sem-audio`, sem crash.
24
+ *
25
+ * 2. {@link extractVideoTranscript} — orquestra o pipeline em UM job: extrai o
26
+ * áudio (ffmpeg) → passa o WAV ao extrator whisper da #61. A orquestração é
27
+ * EXPLÍCITA do chamador porque o `magic.ts` ainda não detecta MIMEs de
28
+ * áudio/vídeo (então o `dispatchExtraction` por magic bytes não roteia vídeo
29
+ * hoje) — invocamos os módulos diretamente. Degradação graciosa em toda etapa:
30
+ * conversão falha → `failed` com motivo (transcritor nem é chamado); whisper
31
+ * falho (ou que lance) NÃO derruba a extração de áudio já feita, vira `failed`
32
+ * gracioso; o WAV intermediário é sempre limpo (best-effort).
33
+ *
34
+ * CONTRATO: reusa {@link ExtractionResult}/{@link ExtractionStatus} do core e os
35
+ * resultados de {@link convertAudioToWav}/whisper — sem status paralelo.
36
+ * `exactOptionalPropertyTypes` respeitado (nenhuma chave opcional recebe `undefined`).
37
+ */
38
+ import type { Logger } from '../client/index.js';
39
+ import type { ExtractionResult } from '../contract.js';
40
+ import { type ConvertAudioToWavOptions, type FfmpegRunner, type WavConversionResult } from './audio.js';
41
+ import type { Extractor } from './dispatcher.js';
42
+ import { type DurationProbeResult, type ProbeVideoDurationOptions } from './duration.js';
43
+ export { parseFfprobeDuration, probeVideoDuration, type DurationProbeOk, type DurationProbeResult, type DurationProbeUnavailable, type FfprobeInvocation, type FfprobeRunner, type ProbeVideoDurationOptions, } from './duration.js';
44
+ /**
45
+ * MIMEs de contêiner de vídeo cuja faixa de áudio este pipeline sabe extrair. Não
46
+ * são roteados pelo `dispatchExtraction` (o `magic.ts` ainda não detecta vídeo,
47
+ * ver #63) — servem ao chamador que orquestra a extração explicitamente e a futura
48
+ * detecção de assinatura.
49
+ */
50
+ export declare const VIDEO_MIMES: readonly string[];
51
+ /**
52
+ * Converte a faixa de áudio de um VÍDEO para WAV 16 kHz mono, REUSANDO a conversão
53
+ * ffmpeg da #60 ({@link convertAudioToWav}) — mesma invocação segura (sem shell,
54
+ * `-protocol_whitelist file`, `-vn`, `-ar 16000 -ac 1 -c:a pcm_s16le`, env
55
+ * sanitizado, watchdog). A única diferença é semântica: um vídeo SEM faixa de
56
+ * áudio faz o ffmpeg falhar; essa falha é remapeada de `erro-conversao` para o
57
+ * motivo claro `video-sem-audio`. NUNCA lança (degradação graciosa, ADR-002).
58
+ *
59
+ * @param inputPath - Caminho absoluto do vídeo de entrada já baixado no cache.
60
+ * @param options - Deps injetáveis + timeouts + logger (a mesma superfície da #60).
61
+ * @returns `done` com `wavPath` no sucesso; `failed` com motivo claro na falha
62
+ * (`video-sem-audio` quando não há áudio; senão o motivo original da #60).
63
+ * @example
64
+ * const wav = await convertVideoToWav('/cache/att/screencast.mp4');
65
+ * if (wav.status === 'done') transcribe(wav.wavPath);
66
+ */
67
+ export declare function convertVideoToWav(inputPath: string, options?: ConvertAudioToWavOptions): Promise<WavConversionResult>;
68
+ /** Sucesso da extração do keyframe: caminho da imagem gravada no dir do anexo. */
69
+ export interface KeyframeSuccess {
70
+ readonly status: 'done';
71
+ /** Caminho absoluto do keyframe (`.../attachments/<id>-<digest8>/keyframe.jpg`). */
72
+ readonly keyframePath: string;
73
+ }
74
+ /** Falha graciosa da extração do keyframe (ADR-002) — nunca lança, sempre traz motivo. */
75
+ export interface KeyframeFailure {
76
+ readonly status: 'failed';
77
+ /** Motivo canônico (`ffmpeg-nao-instalado` | `erro-keyframe` | `timeout`). */
78
+ readonly reason: string;
79
+ /** Mensagem de erro subjacente, quando houver. */
80
+ readonly error?: string;
81
+ /** Dica de remediação (ex.: como instalar o ffmpeg). */
82
+ readonly hint?: string;
83
+ }
84
+ /** Resultado tipado da extração do keyframe. */
85
+ export type KeyframeResult = KeyframeSuccess | KeyframeFailure;
86
+ /** Opções (todas injetáveis para testes herméticos) de {@link extractVideoKeyframe}. */
87
+ export interface ExtractKeyframeOptions {
88
+ /** Localizador do `ffmpeg`; default {@link findFfmpeg}. */
89
+ readonly findFfmpegBinary?: () => string | undefined;
90
+ /** Executor do subprocesso; default: watchdog real com `execFile`. */
91
+ readonly run?: FfmpegRunner;
92
+ /**
93
+ * Caminho de saída do keyframe; default: `keyframe.jpg` no MESMO dir do anexo de
94
+ * entrada (`dirname(inputPath)`), coexistindo com o `original` do anexo (ADR-004).
95
+ */
96
+ readonly outputPath?: string;
97
+ /** Criador de diretório (recursivo); default: `fs/promises.mkdir`. */
98
+ readonly mkdir?: (dir: string) => Promise<void>;
99
+ /** Remove o `.jpg` parcial na falha (best-effort); default: `fs/promises.rm` com `force`. */
100
+ readonly rm?: (path: string) => Promise<void>;
101
+ /** Timeout antes do `SIGTERM` (ms); default {@link DEFAULT_KEYFRAME_TIMEOUT_MS}. */
102
+ readonly timeoutMs?: number;
103
+ /** Graça `SIGTERM` → `SIGKILL` (ms); default {@link KEYFRAME_KILL_GRACE_MS}. */
104
+ readonly killGraceMs?: number;
105
+ /**
106
+ * Sinal de CANCELAMENTO (#69/#73) repassado ao `runWithWatchdog` do keyframe, que
107
+ * MATA o ffmpeg ao abortar. Opcional/aditivo (respeita `exactOptionalPropertyTypes`).
108
+ */
109
+ readonly signal?: AbortSignal;
110
+ /** Logger para o aviso de binário ausente; sem default de lib (ADR-003). */
111
+ readonly logger?: Logger;
112
+ }
113
+ /**
114
+ * Extrai 1 keyframe representativo de um vídeo como JPEG no DIR DO ANEXO (ao lado do
115
+ * `original`, ADR-004), REUSANDO o subprocesso seguro compartilhado (sem shell,
116
+ * `-protocol_whitelist file`, env sanitizado, watchdog `SIGTERM`→`SIGKILL`). NUNCA
117
+ * lança (degradação graciosa, ADR-002): binário ausente, exit != 0 ou timeout viram
118
+ * `{ status: 'failed', reason }`. O keyframe é um EXTRA — sua falha jamais derruba o
119
+ * pipeline de transcrição (ver {@link extractVideoTranscript}).
120
+ *
121
+ * @param inputPath - Caminho absoluto do vídeo já baixado no cache (`.../original.mp4`).
122
+ * @param options - Deps injetáveis + timeouts + logger — ver {@link ExtractKeyframeOptions}.
123
+ * @returns `done` com `keyframePath` no sucesso; `failed` com motivo claro na falha.
124
+ * @example
125
+ * const kf = await extractVideoKeyframe('/cache/att/12-ab/original.mp4');
126
+ * if (kf.status === 'done') referenceInBundle(kf.keyframePath);
127
+ */
128
+ export declare function extractVideoKeyframe(inputPath: string, options?: ExtractKeyframeOptions): Promise<KeyframeResult>;
129
+ /** Opções de orquestração do pipeline {@link extractVideoTranscript}. */
130
+ export interface ExtractVideoTranscriptOptions {
131
+ /**
132
+ * Extrator de transcrição (whisper.cpp da #61) — só o {@link Extractor.extract}
133
+ * é necessário. Injetado para desacoplamento e testes herméticos.
134
+ */
135
+ readonly transcriber: Pick<Extractor, 'extract'>;
136
+ /**
137
+ * Conversão vídeo → WAV; default: {@link convertVideoToWav} (com o `logger`
138
+ * repassado). Injetável para testes sem ffmpeg real.
139
+ */
140
+ readonly convert?: (inputPath: string) => Promise<WavConversionResult>;
141
+ /** Opções repassadas à conversão default (binário, timeouts, tempDir). */
142
+ readonly convertOptions?: ConvertAudioToWavOptions;
143
+ /** Remove o WAV intermediário após a transcrição (best-effort); default `fs.rm` com `force`. */
144
+ readonly rm?: (path: string) => Promise<void>;
145
+ /** MIME REAL do vídeo (para metadados do resultado); default {@link DEFAULT_TRANSCRIBE_MIME}. */
146
+ readonly mime?: string;
147
+ /**
148
+ * Extração do keyframe (EXTRA do vídeo); default: {@link extractVideoKeyframe} com
149
+ * o `logger` repassado. Injetável para testes herméticos. Nunca afeta a transcrição.
150
+ */
151
+ readonly extractKeyframe?: (inputPath: string) => Promise<KeyframeResult>;
152
+ /** Opções repassadas à extração default do keyframe (binário, timeouts, outputPath). */
153
+ readonly keyframeOptions?: ExtractKeyframeOptions;
154
+ /**
155
+ * Limite de duração do vídeo em SEGUNDOS (M4-09, #65): acima dele a transcrição é
156
+ * PULADA (`skipped`) com aviso; o keyframe segue sendo extraído. Default: 20 min
157
+ * ({@link DEFAULT_MAX_DURATION_SECONDS}).
158
+ */
159
+ readonly maxDurationSeconds?: number;
160
+ /**
161
+ * Sonda de duração (ffprobe); default: {@link probeVideoDuration} (com o `logger`
162
+ * repassado). Injetável para testes herméticos, sem ffprobe real.
163
+ */
164
+ readonly probeDuration?: (inputPath: string) => Promise<DurationProbeResult>;
165
+ /** Opções repassadas à sonda default de duração (binário, timeouts). */
166
+ readonly probeOptions?: ProbeVideoDurationOptions;
167
+ /**
168
+ * Sinal de CANCELAMENTO (#69/#73) fiado às ETAPAS de subprocesso do pipeline —
169
+ * conversão (ffmpeg), transcrição (whisper) e keyframe (ffmpeg) — para que o abort
170
+ * MATE o subprocesso em curso (`SIGTERM`→`SIGKILL`). Opcional/aditivo (respeita
171
+ * `exactOptionalPropertyTypes`). Repassado apenas às deps DEFAULT; deps injetadas
172
+ * nos testes controlam seu próprio ciclo de vida.
173
+ */
174
+ readonly signal?: AbortSignal;
175
+ /** Logger para avisos das etapas; sem default de lib (ADR-003). */
176
+ readonly logger?: Logger;
177
+ }
178
+ /**
179
+ * Orquestra o pipeline vídeo→áudio→transcrição em UM job (M4-07, #63): extrai o
180
+ * áudio do vídeo ({@link convertVideoToWav}) e, no sucesso, passa o WAV ao extrator
181
+ * whisper (#61). Como o `magic.ts` ainda não roteia vídeo, a orquestração é
182
+ * explícita aqui. NUNCA lança: conversão falha (inclusive `video-sem-audio`)
183
+ * curto-circuita com `failed` + motivo; whisper falho (ou que lance) vira `failed`
184
+ * gracioso — a extração do áudio já feita não é derrubada. O WAV intermediário é
185
+ * sempre limpo (best-effort), em qualquer desfecho.
186
+ *
187
+ * @param inputPath - Caminho absoluto do vídeo já baixado no cache.
188
+ * @param options - Transcritor + deps injetáveis + mime/logger — ver {@link ExtractVideoTranscriptOptions}.
189
+ * @returns O {@link ExtractionResult} da transcrição no sucesso; `failed` com motivo
190
+ * claro em qualquer falha de etapa.
191
+ * @example
192
+ * const extractor = await createWhisperExtractor();
193
+ * const result = await extractVideoTranscript('/cache/att/clip.mp4', {
194
+ * mime: 'video/mp4',
195
+ * transcriber: extractor,
196
+ * });
197
+ */
198
+ export declare function extractVideoTranscript(inputPath: string, options: ExtractVideoTranscriptOptions): Promise<ExtractionResult>;
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Extração da faixa de áudio de VÍDEO + pipeline vídeo→áudio→transcrição
3
+ * (M4-07, #63, ADR-002) + keyframe representativo (M4-08, #64).
4
+ *
5
+ * KEYFRAME (M4-08, #64): {@link extractVideoKeyframe} grava 1 frame JPEG no DIR DO
6
+ * ANEXO (ao lado do `original`, ADR-004), reusando o MESMO subprocesso seguro
7
+ * (sem shell, `-protocol_whitelist file`, env sanitizado, watchdog) — o keyframe é
8
+ * um EXTRA: sua falha (ffmpeg ausente/erro/timeout) NUNCA afeta a transcrição
9
+ * (degradação graciosa). O bundle apenas REFERENCIA o keyframe pelo cache path (via
10
+ * {@link ExtractionArtifact}); o binário jamais é embutido (o MCP é read-only/texto).
11
+ *
12
+ * REUSO (não reimplementação): a extração de áudio de um container de vídeo é a
13
+ * MESMA operação ffmpeg da #60 — o ffmpeg já lida com mp4/mkv/webm e `-vn`
14
+ * descarta a faixa de vídeo, deixando só o áudio → WAV 16 kHz mono. Portanto este
15
+ * módulo NÃO duplica a invocação do subprocesso (sem shell, `-protocol_whitelist
16
+ * file`, env sanitizado, watchdog SIGTERM→SIGKILL, remoção do WAV parcial): ele
17
+ * DELEGA a {@link convertAudioToWav} e apenas:
18
+ *
19
+ * 1. {@link convertVideoToWav} — fino wrapper que roteia a entrada de vídeo para a
20
+ * conversão da #60 e DETECTA o caso NOVO do vídeo: container SEM faixa de áudio.
21
+ * O ffmpeg, ao descartar o vídeo com `-vn` sobre um arquivo mudo, falha com
22
+ * "does not contain any stream" / "matches no streams"; remapeamos essa falha
23
+ * genérica (`erro-conversao`) para um motivo CLARO, `video-sem-audio`, sem crash.
24
+ *
25
+ * 2. {@link extractVideoTranscript} — orquestra o pipeline em UM job: extrai o
26
+ * áudio (ffmpeg) → passa o WAV ao extrator whisper da #61. A orquestração é
27
+ * EXPLÍCITA do chamador porque o `magic.ts` ainda não detecta MIMEs de
28
+ * áudio/vídeo (então o `dispatchExtraction` por magic bytes não roteia vídeo
29
+ * hoje) — invocamos os módulos diretamente. Degradação graciosa em toda etapa:
30
+ * conversão falha → `failed` com motivo (transcritor nem é chamado); whisper
31
+ * falho (ou que lance) NÃO derruba a extração de áudio já feita, vira `failed`
32
+ * gracioso; o WAV intermediário é sempre limpo (best-effort).
33
+ *
34
+ * CONTRATO: reusa {@link ExtractionResult}/{@link ExtractionStatus} do core e os
35
+ * resultados de {@link convertAudioToWav}/whisper — sem status paralelo.
36
+ * `exactOptionalPropertyTypes` respeitado (nenhuma chave opcional recebe `undefined`).
37
+ */
38
+ import { mkdir as fsMkdir, rm as fsRm } from 'node:fs/promises';
39
+ import { dirname, join } from 'node:path';
40
+ import { convertAudioToWav, } from './audio.js';
41
+ import { probeVideoDuration, } from './duration.js';
42
+ import { findFfmpeg } from './ffmpeg.js';
43
+ import { runWithWatchdog, sanitizedEnv } from './subprocess.js';
44
+ // Reason: a sonda de duração vive em `duration.js` (Rule #24, módulo único-propósito),
45
+ // mas é reexportada aqui para preservar a superfície pública do pipeline de vídeo —
46
+ // chamadores e testes continuam importando tudo de `video.js`.
47
+ export { parseFfprobeDuration, probeVideoDuration, } from './duration.js';
48
+ /** Identificador do pipeline (entra em metadados de resultados de falha da conversão). */
49
+ const PIPELINE_EXTRACTOR_ID = 'video-transcribe';
50
+ /** Nome-base fixo do keyframe gravado no dir do anexo (ADR-004: 1 por id+digest). */
51
+ const KEYFRAME_BASENAME = 'keyframe.jpg';
52
+ /** MIME do keyframe extraído (JPEG) — vira o `mime` do artefato no bundle. */
53
+ const KEYFRAME_MIME = 'image/jpeg';
54
+ /** Tipo do artefato de keyframe no {@link ExtractionArtifact} (referência no bundle). */
55
+ const KEYFRAME_ARTIFACT_KIND = 'keyframe';
56
+ /** Timeout default da extração do keyframe (ms) — 1 frame é rápido. */
57
+ const DEFAULT_KEYFRAME_TIMEOUT_MS = 30_000;
58
+ /** Graça entre `SIGTERM` e `SIGKILL` do keyframe (ms). */
59
+ const KEYFRAME_KILL_GRACE_MS = 2_000;
60
+ /** Teto do stdout/stderr do ffmpeg do keyframe (2 MiB) — conciso em `-loglevel error`. */
61
+ const KEYFRAME_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
62
+ /** MIME default passado ao whisper quando o mime real do vídeo não é informado. */
63
+ const DEFAULT_TRANSCRIBE_MIME = 'audio/wav';
64
+ /**
65
+ * Limite default de duração de vídeo em SEGUNDOS (20 min, ADR-002): acima disso a
66
+ * transcrição é PULADA com aviso (o keyframe segue sendo extraído). Configurável
67
+ * via {@link ExtractVideoTranscriptOptions.maxDurationSeconds}.
68
+ */
69
+ const DEFAULT_MAX_DURATION_SECONDS = 1200;
70
+ /** Motivo canônico do skip por exceder o limite de duração (entra em `metadata.reason`). */
71
+ const DURATION_LIMIT_REASON = 'video-excede-limite-duracao';
72
+ /**
73
+ * MIMEs de contêiner de vídeo cuja faixa de áudio este pipeline sabe extrair. Não
74
+ * são roteados pelo `dispatchExtraction` (o `magic.ts` ainda não detecta vídeo,
75
+ * ver #63) — servem ao chamador que orquestra a extração explicitamente e a futura
76
+ * detecção de assinatura.
77
+ */
78
+ export const VIDEO_MIMES = [
79
+ 'video/mp4',
80
+ 'video/quicktime',
81
+ 'video/x-matroska',
82
+ 'video/webm',
83
+ 'video/x-msvideo',
84
+ 'video/mpeg',
85
+ ];
86
+ /**
87
+ * Assinatura textual do ffmpeg quando o container não tem faixa de áudio para
88
+ * extrair (após `-vn` descartar o vídeo): "Output file does not contain any
89
+ * stream" ou "Stream map '0:a' matches no streams". Robusto a variações de versão.
90
+ */
91
+ const NO_AUDIO_STREAM_PATTERN = /does not contain any stream|matches no streams/i;
92
+ /**
93
+ * `true` se a mensagem de erro do ffmpeg indica ausência de faixa de áudio no
94
+ * vídeo (o caso NOVO desta issue), e não um erro de conversão genérico.
95
+ *
96
+ * @param error - Mensagem de erro subjacente reportada pela conversão.
97
+ * @returns `true` quando o padrão de "sem stream de áudio" casa.
98
+ */
99
+ function isNoAudioStream(error) {
100
+ return error !== undefined && NO_AUDIO_STREAM_PATTERN.test(error);
101
+ }
102
+ /**
103
+ * Converte a faixa de áudio de um VÍDEO para WAV 16 kHz mono, REUSANDO a conversão
104
+ * ffmpeg da #60 ({@link convertAudioToWav}) — mesma invocação segura (sem shell,
105
+ * `-protocol_whitelist file`, `-vn`, `-ar 16000 -ac 1 -c:a pcm_s16le`, env
106
+ * sanitizado, watchdog). A única diferença é semântica: um vídeo SEM faixa de
107
+ * áudio faz o ffmpeg falhar; essa falha é remapeada de `erro-conversao` para o
108
+ * motivo claro `video-sem-audio`. NUNCA lança (degradação graciosa, ADR-002).
109
+ *
110
+ * @param inputPath - Caminho absoluto do vídeo de entrada já baixado no cache.
111
+ * @param options - Deps injetáveis + timeouts + logger (a mesma superfície da #60).
112
+ * @returns `done` com `wavPath` no sucesso; `failed` com motivo claro na falha
113
+ * (`video-sem-audio` quando não há áudio; senão o motivo original da #60).
114
+ * @example
115
+ * const wav = await convertVideoToWav('/cache/att/screencast.mp4');
116
+ * if (wav.status === 'done') transcribe(wav.wavPath);
117
+ */
118
+ export async function convertVideoToWav(inputPath, options = {}) {
119
+ const result = await convertAudioToWav(inputPath, options);
120
+ if (result.status === 'failed' && result.reason === 'erro-conversao' && isNoAudioStream(result.error)) {
121
+ const failure = {
122
+ status: 'failed',
123
+ reason: 'video-sem-audio',
124
+ hint: 'o vídeo não possui faixa de áudio; não há o que transcrever',
125
+ ...(result.error !== undefined ? { error: result.error } : {}),
126
+ };
127
+ return failure;
128
+ }
129
+ return result;
130
+ }
131
+ /** Erro interno: o watchdog matou o ffmpeg do keyframe por estourar o timeout. */
132
+ class KeyframeTimeoutError extends Error {
133
+ timeoutMs;
134
+ constructor(timeoutMs) {
135
+ super(`ffmpeg (keyframe) excedeu o timeout de ${timeoutMs}ms e foi encerrado`);
136
+ this.timeoutMs = timeoutMs;
137
+ this.name = 'FfmpegTimeoutError';
138
+ }
139
+ }
140
+ /**
141
+ * Monta os argumentos do ffmpeg para extrair 1 frame representativo de `inputPath`
142
+ * como JPEG em `outputPath`. `-protocol_whitelist file` (opção de INPUT) precede o
143
+ * `-i` (ADR-002); `-frames:v 1` limita a saída a um único frame e `-q:v 2` fixa alta
144
+ * qualidade JPEG.
145
+ *
146
+ * Reason: extraímos o PRIMEIRO frame decodável (sem `-ss`) porque um seek a um ponto
147
+ * arbitrário falharia em vídeos muito curtos; 1 frame no início é suficiente como
148
+ * referência visual (MVP, ADR-002), sem sondar a duração (over-engineering).
149
+ *
150
+ * @param inputPath - Caminho absoluto do vídeo de entrada.
151
+ * @param outputPath - Caminho absoluto do keyframe JPEG de saída.
152
+ * @returns Lista de argumentos, na ordem exigida pelo ffmpeg.
153
+ */
154
+ function buildKeyframeArgs(inputPath, outputPath) {
155
+ return [
156
+ '-nostdin', // não consome stdin (execução headless)
157
+ '-loglevel',
158
+ 'error', // silencioso, exceto erros
159
+ '-protocol_whitelist',
160
+ 'file', // ADR-002: só o arquivo local dado, nada remoto
161
+ '-i',
162
+ inputPath,
163
+ '-frames:v',
164
+ '1', // exatamente 1 frame
165
+ '-q:v',
166
+ '2', // alta qualidade JPEG
167
+ '-y', // sobrescreve o destino (nome fixo por anexo)
168
+ outputPath,
169
+ ];
170
+ }
171
+ /**
172
+ * Executor default do keyframe: delega ao watchdog compartilhado
173
+ * ({@link runWithWatchdog}) — ffmpeg SEM shell, env sanitizado e escalonamento
174
+ * `SIGTERM` → graça → `SIGKILL`. O estouro de timeout é sinalizado com
175
+ * {@link KeyframeTimeoutError} (nome `FfmpegTimeoutError`) para classificar a falha.
176
+ *
177
+ * @param invocation - Ver {@link FfmpegInvocation}.
178
+ * @returns Promessa resolvida no sucesso do ffmpeg.
179
+ */
180
+ function defaultKeyframeRun(invocation) {
181
+ const { bin, args, env, timeoutMs, killGraceMs, signal } = invocation;
182
+ return runWithWatchdog({
183
+ bin,
184
+ args,
185
+ env,
186
+ timeoutMs,
187
+ killGraceMs,
188
+ maxBuffer: KEYFRAME_MAX_BUFFER_BYTES,
189
+ ...(signal !== undefined ? { signal } : {}),
190
+ }, { makeTimeoutError: (ms) => new KeyframeTimeoutError(ms) }).then(() => undefined);
191
+ }
192
+ /** `true` se o erro sinaliza estouro de timeout do watchdog (por nome, robusto a DI). */
193
+ function isTimeoutError(error) {
194
+ return error instanceof Error && error.name === 'FfmpegTimeoutError';
195
+ }
196
+ /**
197
+ * Extrai 1 keyframe representativo de um vídeo como JPEG no DIR DO ANEXO (ao lado do
198
+ * `original`, ADR-004), REUSANDO o subprocesso seguro compartilhado (sem shell,
199
+ * `-protocol_whitelist file`, env sanitizado, watchdog `SIGTERM`→`SIGKILL`). NUNCA
200
+ * lança (degradação graciosa, ADR-002): binário ausente, exit != 0 ou timeout viram
201
+ * `{ status: 'failed', reason }`. O keyframe é um EXTRA — sua falha jamais derruba o
202
+ * pipeline de transcrição (ver {@link extractVideoTranscript}).
203
+ *
204
+ * @param inputPath - Caminho absoluto do vídeo já baixado no cache (`.../original.mp4`).
205
+ * @param options - Deps injetáveis + timeouts + logger — ver {@link ExtractKeyframeOptions}.
206
+ * @returns `done` com `keyframePath` no sucesso; `failed` com motivo claro na falha.
207
+ * @example
208
+ * const kf = await extractVideoKeyframe('/cache/att/12-ab/original.mp4');
209
+ * if (kf.status === 'done') referenceInBundle(kf.keyframePath);
210
+ */
211
+ export async function extractVideoKeyframe(inputPath, options = {}) {
212
+ const findBinary = options.findFfmpegBinary ?? (() => findFfmpeg()?.path);
213
+ const bin = findBinary();
214
+ if (bin === undefined) {
215
+ options.logger?.warn('ffmpeg: binário não encontrado; keyframe do vídeo não será extraído ' +
216
+ '(a transcrição segue normalmente) — instale o ffmpeg (ver doctor)');
217
+ return {
218
+ status: 'failed',
219
+ reason: 'ffmpeg-nao-instalado',
220
+ hint: 'instale o ffmpeg; o keyframe é opcional e não bloqueia a transcrição',
221
+ };
222
+ }
223
+ const run = options.run ?? defaultKeyframeRun;
224
+ const outputPath = options.outputPath ?? join(dirname(inputPath), KEYFRAME_BASENAME);
225
+ const mkdir = options.mkdir ?? ((dir) => fsMkdir(dir, { recursive: true }).then(() => undefined));
226
+ const rm = options.rm ?? ((path) => fsRm(path, { force: true }));
227
+ const timeoutMs = options.timeoutMs ?? DEFAULT_KEYFRAME_TIMEOUT_MS;
228
+ const killGraceMs = options.killGraceMs ?? KEYFRAME_KILL_GRACE_MS;
229
+ const signal = options.signal;
230
+ const args = buildKeyframeArgs(inputPath, outputPath);
231
+ try {
232
+ await mkdir(dirname(outputPath));
233
+ await run({
234
+ bin,
235
+ args,
236
+ env: sanitizedEnv(),
237
+ timeoutMs,
238
+ killGraceMs,
239
+ ...(signal !== undefined ? { signal } : {}),
240
+ });
241
+ return { status: 'done', keyframePath: outputPath };
242
+ }
243
+ catch (error) {
244
+ // O ffmpeg com `-y` pode ter criado um JPEG parcial; descarta-o (best-effort).
245
+ await rm(outputPath).catch(() => undefined);
246
+ const message = error instanceof Error ? error.message : String(error);
247
+ return {
248
+ status: 'failed',
249
+ reason: isTimeoutError(error) ? 'timeout' : 'erro-keyframe',
250
+ error: message,
251
+ };
252
+ }
253
+ }
254
+ /**
255
+ * Anexa o resultado do keyframe a um {@link ExtractionResult} da transcrição SEM
256
+ * jamais alterar seu `status`/`text` (o keyframe é um EXTRA, ADR-002). No sucesso,
257
+ * adiciona um {@link ExtractionArtifact} `keyframe` (cache path + mime) que o bundle
258
+ * REFERENCIA — o binário nunca é embutido. Na falha, registra `keyframeReason` em
259
+ * `metadata` como sinal diagnóstico, preservando o resultado da transcrição intacto.
260
+ *
261
+ * @param result - Resultado da transcrição (ou da falha de conversão) a enriquecer.
262
+ * @param keyframe - Desfecho da extração do keyframe.
263
+ * @returns Um novo {@link ExtractionResult} com o keyframe referenciado/anotado.
264
+ */
265
+ function attachKeyframe(result, keyframe) {
266
+ if (keyframe.status === 'done') {
267
+ const artifact = {
268
+ kind: KEYFRAME_ARTIFACT_KIND,
269
+ path: keyframe.keyframePath,
270
+ mime: KEYFRAME_MIME,
271
+ };
272
+ return { ...result, artifacts: [...(result.artifacts ?? []), artifact] };
273
+ }
274
+ return {
275
+ ...result,
276
+ metadata: { ...(result.metadata ?? {}), keyframeReason: keyframe.reason },
277
+ };
278
+ }
279
+ /**
280
+ * Monta o {@link ExtractionResult} `skipped` de uma transcrição PULADA por exceder o
281
+ * limite de duração (ADR-002). Registra o motivo canônico + a duração medida e o
282
+ * limite aplicado nos metadados, para o bundle explicar por que não há transcrição.
283
+ *
284
+ * @param mime - MIME real do vídeo.
285
+ * @param durationSeconds - Duração medida pelo ffprobe (segundos).
286
+ * @param limitSeconds - Limite configurado (segundos).
287
+ * @returns Resultado `skipped` com metadados diagnósticos.
288
+ */
289
+ function durationLimitSkip(mime, durationSeconds, limitSeconds) {
290
+ return {
291
+ status: 'skipped',
292
+ mime,
293
+ metadata: {
294
+ extractorId: PIPELINE_EXTRACTOR_ID,
295
+ reason: DURATION_LIMIT_REASON,
296
+ durationSeconds,
297
+ limitSeconds,
298
+ },
299
+ };
300
+ }
301
+ /**
302
+ * Monta um {@link ExtractionResult} `failed` a partir de uma falha de conversão,
303
+ * sem injetar chaves `undefined` (respeita `exactOptionalPropertyTypes`).
304
+ *
305
+ * @param mime - MIME real do vídeo.
306
+ * @param failure - Falha devolvida pela conversão vídeo → WAV.
307
+ * @returns Resultado `failed` com `reason`/`error`/`hint` preservados.
308
+ */
309
+ function conversionFailure(mime, failure) {
310
+ const metadata = {
311
+ extractorId: PIPELINE_EXTRACTOR_ID,
312
+ reason: failure.reason,
313
+ };
314
+ if (failure.error !== undefined)
315
+ metadata.error = failure.error;
316
+ if (failure.hint !== undefined)
317
+ metadata.hint = failure.hint;
318
+ return { status: 'failed', mime, metadata };
319
+ }
320
+ /**
321
+ * Orquestra o pipeline vídeo→áudio→transcrição em UM job (M4-07, #63): extrai o
322
+ * áudio do vídeo ({@link convertVideoToWav}) e, no sucesso, passa o WAV ao extrator
323
+ * whisper (#61). Como o `magic.ts` ainda não roteia vídeo, a orquestração é
324
+ * explícita aqui. NUNCA lança: conversão falha (inclusive `video-sem-audio`)
325
+ * curto-circuita com `failed` + motivo; whisper falho (ou que lance) vira `failed`
326
+ * gracioso — a extração do áudio já feita não é derrubada. O WAV intermediário é
327
+ * sempre limpo (best-effort), em qualquer desfecho.
328
+ *
329
+ * @param inputPath - Caminho absoluto do vídeo já baixado no cache.
330
+ * @param options - Transcritor + deps injetáveis + mime/logger — ver {@link ExtractVideoTranscriptOptions}.
331
+ * @returns O {@link ExtractionResult} da transcrição no sucesso; `failed` com motivo
332
+ * claro em qualquer falha de etapa.
333
+ * @example
334
+ * const extractor = await createWhisperExtractor();
335
+ * const result = await extractVideoTranscript('/cache/att/clip.mp4', {
336
+ * mime: 'video/mp4',
337
+ * transcriber: extractor,
338
+ * });
339
+ */
340
+ export async function extractVideoTranscript(inputPath, options) {
341
+ const mime = options.mime ?? DEFAULT_TRANSCRIBE_MIME;
342
+ const signal = options.signal;
343
+ const convert = options.convert ??
344
+ ((path) => convertVideoToWav(path, {
345
+ ...(options.convertOptions ?? {}),
346
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
347
+ ...(signal !== undefined ? { signal } : {}),
348
+ }));
349
+ // O keyframe é um EXTRA independente da transcrição (ADR-002): é extraído mesmo
350
+ // quando o vídeo não tem áudio (um frame ainda é uma referência visual útil) e
351
+ // JAMAIS derruba o pipeline — `extractVideoKeyframe` é gracioso e o `.catch`
352
+ // blinda contra um extrator injetado hostil que lance.
353
+ const extractKeyframe = options.extractKeyframe ??
354
+ ((path) => extractVideoKeyframe(path, {
355
+ ...(options.keyframeOptions ?? {}),
356
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
357
+ ...(signal !== undefined ? { signal } : {}),
358
+ }));
359
+ const keyframe = await extractKeyframe(inputPath).catch((error) => ({
360
+ status: 'failed',
361
+ reason: 'erro-keyframe',
362
+ error: error instanceof Error ? error.message : String(error),
363
+ }));
364
+ // LIMITE DE DURAÇÃO (M4-09, #65, ADR-002): mede a duração via ffprobe ANTES de
365
+ // gastar CPU convertendo/transcrevendo. Acima do limite → PULA a transcrição com
366
+ // aviso + metadados (o keyframe já extraído acima é preservado — AC explícito).
367
+ // Duração INDISPONÍVEL (ffprobe ausente/erro) ou sonda que lance NÃO bloqueia: o
368
+ // pipeline prossegue (a medição é um guard, não um pré-requisito).
369
+ const maxDurationSeconds = options.maxDurationSeconds ?? DEFAULT_MAX_DURATION_SECONDS;
370
+ const probeDuration = options.probeDuration ??
371
+ ((path) => probeVideoDuration(path, {
372
+ ...(options.probeOptions ?? {}),
373
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
374
+ }));
375
+ const duration = await probeDuration(inputPath).catch((error) => ({
376
+ status: 'unavailable',
377
+ reason: 'erro-ffprobe',
378
+ error: error instanceof Error ? error.message : String(error),
379
+ }));
380
+ if (duration.status === 'ok' && duration.seconds > maxDurationSeconds) {
381
+ options.logger?.warn(`vídeo excede o limite de duração (${duration.seconds}s > ${maxDurationSeconds}s); ` +
382
+ 'transcrição pulada (o keyframe é preservado)');
383
+ return attachKeyframe(durationLimitSkip(mime, duration.seconds, maxDurationSeconds), keyframe);
384
+ }
385
+ const conversion = await convert(inputPath);
386
+ if (conversion.status === 'failed') {
387
+ return attachKeyframe(conversionFailure(mime, conversion), keyframe);
388
+ }
389
+ const rm = options.rm ?? ((path) => fsRm(path, { force: true }));
390
+ const wavPath = conversion.wavPath;
391
+ const extractOptions = {
392
+ mime,
393
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
394
+ ...(signal !== undefined ? { signal } : {}),
395
+ };
396
+ try {
397
+ const transcript = await options.transcriber.extract(wavPath, extractOptions);
398
+ return attachKeyframe(transcript, keyframe);
399
+ }
400
+ catch (error) {
401
+ // Reason: o extrator whisper é gracioso por contrato, mas um transcritor
402
+ // hostil/quebrado não pode derrubar o bundle — degrada para failed (ADR-002).
403
+ return attachKeyframe({
404
+ status: 'failed',
405
+ mime,
406
+ metadata: {
407
+ extractorId: PIPELINE_EXTRACTOR_ID,
408
+ reason: 'erro-transcricao',
409
+ error: error instanceof Error ? error.message : String(error),
410
+ },
411
+ }, keyframe);
412
+ }
413
+ finally {
414
+ // O WAV é um artefato intermediário do cache temp; descarta em qualquer
415
+ // desfecho para não acumular lixo (convenção de `audio.ts`/`download.ts`).
416
+ await rm(wavPath).catch(() => undefined);
417
+ }
418
+ }