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.
- package/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/bundle/index.d.ts +5 -0
- package/dist/bundle/index.js +5 -0
- package/dist/bundle/json.d.ts +90 -0
- package/dist/bundle/json.js +266 -0
- package/dist/bundle/markdown.d.ts +75 -0
- package/dist/bundle/markdown.js +294 -0
- package/dist/bundle/search-list.d.ts +43 -0
- package/dist/bundle/search-list.js +53 -0
- package/dist/bundle/stable-stringify.d.ts +26 -0
- package/dist/bundle/stable-stringify.js +50 -0
- package/dist/cache/contract.d.ts +157 -0
- package/dist/cache/contract.js +0 -0
- package/dist/cache/disk-index.d.ts +82 -0
- package/dist/cache/disk-index.js +220 -0
- package/dist/cache/disk.d.ts +133 -0
- package/dist/cache/disk.js +313 -0
- package/dist/cache/gc.d.ts +78 -0
- package/dist/cache/gc.js +123 -0
- package/dist/cache/get-or-compute.d.ts +36 -0
- package/dist/cache/get-or-compute.js +52 -0
- package/dist/cache/index.d.ts +9 -0
- package/dist/cache/index.js +8 -0
- package/dist/cache/keys.d.ts +76 -0
- package/dist/cache/keys.js +78 -0
- package/dist/cache/memory.d.ts +48 -0
- package/dist/cache/memory.js +110 -0
- package/dist/cache-first.d.ts +127 -0
- package/dist/cache-first.js +227 -0
- package/dist/client/errors.d.ts +33 -0
- package/dist/client/errors.js +49 -0
- package/dist/client/http.d.ts +110 -0
- package/dist/client/http.js +207 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/issues.d.ts +71 -0
- package/dist/client/issues.js +100 -0
- package/dist/client/search.d.ts +58 -0
- package/dist/client/search.js +81 -0
- package/dist/config/credentials.d.ts +247 -0
- package/dist/config/credentials.js +427 -0
- package/dist/config/doctor.d.ts +123 -0
- package/dist/config/doctor.js +260 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +6 -0
- package/dist/config/keyring.d.ts +96 -0
- package/dist/config/keyring.js +158 -0
- package/dist/config/login.d.ts +97 -0
- package/dist/config/login.js +189 -0
- package/dist/config/settings.d.ts +94 -0
- package/dist/config/settings.js +140 -0
- package/dist/contract.d.ts +173 -0
- package/dist/contract.js +27 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +8 -0
- package/dist/extract/audio-extractor.d.ts +105 -0
- package/dist/extract/audio-extractor.js +156 -0
- package/dist/extract/audio.d.ts +126 -0
- package/dist/extract/audio.js +184 -0
- package/dist/extract/dispatcher.d.ts +132 -0
- package/dist/extract/dispatcher.js +115 -0
- package/dist/extract/download.d.ts +111 -0
- package/dist/extract/download.js +261 -0
- package/dist/extract/duration.d.ts +106 -0
- package/dist/extract/duration.js +148 -0
- package/dist/extract/ffmpeg.d.ts +56 -0
- package/dist/extract/ffmpeg.js +95 -0
- package/dist/extract/gguf.d.ts +137 -0
- package/dist/extract/gguf.js +215 -0
- package/dist/extract/index.d.ts +19 -0
- package/dist/extract/index.js +19 -0
- package/dist/extract/magic.d.ts +80 -0
- package/dist/extract/magic.js +282 -0
- package/dist/extract/ooxml.d.ts +131 -0
- package/dist/extract/ooxml.js +336 -0
- package/dist/extract/pdf.d.ts +147 -0
- package/dist/extract/pdf.js +322 -0
- package/dist/extract/queue.d.ts +167 -0
- package/dist/extract/queue.js +217 -0
- package/dist/extract/subprocess.d.ts +145 -0
- package/dist/extract/subprocess.js +181 -0
- package/dist/extract/tesseract.d.ts +153 -0
- package/dist/extract/tesseract.js +321 -0
- package/dist/extract/video-extractor.d.ts +84 -0
- package/dist/extract/video-extractor.js +89 -0
- package/dist/extract/video.d.ts +198 -0
- package/dist/extract/video.js +418 -0
- package/dist/extract/which.d.ts +63 -0
- package/dist/extract/which.js +72 -0
- package/dist/extract/whisper-extract.d.ts +211 -0
- package/dist/extract/whisper-extract.js +323 -0
- package/dist/extract/whisper.d.ts +50 -0
- package/dist/extract/whisper.js +67 -0
- package/dist/extract/zip.d.ts +50 -0
- package/dist/extract/zip.js +165 -0
- package/dist/extract-issue-attachments.d.ts +82 -0
- package/dist/extract-issue-attachments.js +156 -0
- package/dist/fetch-attachment-text.d.ts +113 -0
- package/dist/fetch-attachment-text.js +155 -0
- package/dist/fetch-issue-bundle.d.ts +81 -0
- package/dist/fetch-issue-bundle.js +93 -0
- package/dist/fetch-issue-search.d.ts +74 -0
- package/dist/fetch-issue-search.js +120 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/normalize/collections.d.ts +52 -0
- package/dist/normalize/collections.js +170 -0
- package/dist/normalize/helpers.d.ts +35 -0
- package/dist/normalize/helpers.js +59 -0
- package/dist/normalize/index.d.ts +2 -0
- package/dist/normalize/index.js +2 -0
- package/dist/normalize/issue.d.ts +34 -0
- package/dist/normalize/issue.js +154 -0
- package/dist/surfaces/cli/commands.d.ts +61 -0
- package/dist/surfaces/cli/commands.js +262 -0
- package/dist/surfaces/cli/main.d.ts +32 -0
- package/dist/surfaces/cli/main.js +210 -0
- package/dist/surfaces/cli/prompts.d.ts +66 -0
- package/dist/surfaces/cli/prompts.js +147 -0
- package/dist/surfaces/cli/tty.d.ts +27 -0
- package/dist/surfaces/cli/tty.js +35 -0
- package/dist/surfaces/cli/types.d.ts +39 -0
- package/dist/surfaces/cli/types.js +7 -0
- package/dist/surfaces/mcp/server.d.ts +171 -0
- package/dist/surfaces/mcp/server.js +427 -0
- package/dist/surfaces/tui/app.d.ts +55 -0
- package/dist/surfaces/tui/app.js +180 -0
- package/dist/surfaces/tui/attachment-status.d.ts +79 -0
- package/dist/surfaces/tui/attachment-status.js +113 -0
- package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
- package/dist/surfaces/tui/components/breadcrumb.js +31 -0
- package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
- package/dist/surfaces/tui/components/gradient-text.js +75 -0
- package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
- package/dist/surfaces/tui/components/scroll-view.js +77 -0
- package/dist/surfaces/tui/components/spinner.d.ts +12 -0
- package/dist/surfaces/tui/components/spinner.js +39 -0
- package/dist/surfaces/tui/components/text-input.d.ts +45 -0
- package/dist/surfaces/tui/components/text-input.js +114 -0
- package/dist/surfaces/tui/format-file-size.d.ts +24 -0
- package/dist/surfaces/tui/format-file-size.js +43 -0
- package/dist/surfaces/tui/glyphs.d.ts +47 -0
- package/dist/surfaces/tui/glyphs.js +84 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
- package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
- package/dist/surfaces/tui/index.d.ts +50 -0
- package/dist/surfaces/tui/index.js +158 -0
- package/dist/surfaces/tui/instance.d.ts +37 -0
- package/dist/surfaces/tui/instance.js +36 -0
- package/dist/surfaces/tui/job-registry.d.ts +113 -0
- package/dist/surfaces/tui/job-registry.js +123 -0
- package/dist/surfaces/tui/job-status.d.ts +45 -0
- package/dist/surfaces/tui/job-status.js +81 -0
- package/dist/surfaces/tui/navigation.d.ts +74 -0
- package/dist/surfaces/tui/navigation.js +87 -0
- package/dist/surfaces/tui/palettes.d.ts +38 -0
- package/dist/surfaces/tui/palettes.js +244 -0
- package/dist/surfaces/tui/screen.d.ts +30 -0
- package/dist/surfaces/tui/screen.js +51 -0
- package/dist/surfaces/tui/screens/about.d.ts +2 -0
- package/dist/surfaces/tui/screens/about.js +35 -0
- package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
- package/dist/surfaces/tui/screens/appearance.js +74 -0
- package/dist/surfaces/tui/screens/config.d.ts +2 -0
- package/dist/surfaces/tui/screens/config.js +82 -0
- package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
- package/dist/surfaces/tui/screens/doctor.js +109 -0
- package/dist/surfaces/tui/screens/export.d.ts +2 -0
- package/dist/surfaces/tui/screens/export.js +168 -0
- package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
- package/dist/surfaces/tui/screens/home-selection.js +80 -0
- package/dist/surfaces/tui/screens/home.d.ts +2 -0
- package/dist/surfaces/tui/screens/home.js +200 -0
- package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
- package/dist/surfaces/tui/screens/issue-detail.js +182 -0
- package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
- package/dist/surfaces/tui/screens/jobs.js +89 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
- package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
- package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
- package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
- package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
- package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
- package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
- package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
- package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
- package/dist/surfaces/tui/screens/welcome.js +95 -0
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +23 -0
- package/dist/surfaces/tui/symbols.d.ts +231 -0
- package/dist/surfaces/tui/symbols.js +14 -0
- package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
- package/dist/surfaces/tui/terminal-colors.js +39 -0
- package/dist/surfaces/tui/theme.d.ts +156 -0
- package/dist/surfaces/tui/theme.js +86 -0
- package/dist/surfaces/tui/truncate.d.ts +31 -0
- package/dist/surfaces/tui/truncate.js +81 -0
- 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
|
+
}
|