@thunderkiller/video-clipper 1.2.0 → 1.3.1

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 (91) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +15 -0
  3. package/package.json +1 -1
  4. package/.github/workflows/ci.yml +0 -42
  5. package/.github/workflows/release.yml +0 -76
  6. package/.husky/pre-commit +0 -3
  7. package/.prettierignore +0 -6
  8. package/.prettierrc +0 -7
  9. package/.releaserc.json +0 -21
  10. package/AGENTS.md +0 -122
  11. package/docs/free-models.md +0 -78
  12. package/docs/plan.md +0 -442
  13. package/docs/refactorPhases.md +0 -105
  14. package/docs/yt-downloader.md +0 -440
  15. package/requirements.txt +0 -5
  16. package/scripts/detect_events.py +0 -81
  17. package/scripts/detect_events_whisper.py +0 -101
  18. package/scripts/transcribe_whisper.py +0 -70
  19. package/src/cli.ts +0 -186
  20. package/src/config/env.ts +0 -18
  21. package/src/config/index.ts +0 -2
  22. package/src/index.ts +0 -46
  23. package/src/pipeline/runner.ts +0 -147
  24. package/src/pipeline/stages/audioProcessor.ts +0 -127
  25. package/src/pipeline/stages/clipExporter.ts +0 -76
  26. package/src/pipeline/stages/segmentAnalyzer.ts +0 -72
  27. package/src/pipeline/stages/segmentSelector.ts +0 -39
  28. package/src/pipeline/stages/videoResolver.ts +0 -44
  29. package/src/services/audioAnalyzers/base.ts +0 -32
  30. package/src/services/audioAnalyzers/factory.ts +0 -69
  31. package/src/services/audioAnalyzers/gemini.ts +0 -136
  32. package/src/services/audioAnalyzers/index.ts +0 -6
  33. package/src/services/audioAnalyzers/whisper.ts +0 -80
  34. package/src/services/audioAnalyzers/yamnet.ts +0 -54
  35. package/src/services/audioDownloader/index.ts +0 -102
  36. package/src/services/chunkBuilder/index.ts +0 -82
  37. package/src/services/clipGenerator/index.ts +0 -210
  38. package/src/services/clipRefiner/index.ts +0 -141
  39. package/src/services/eventDetector/index.ts +0 -68
  40. package/src/services/llmAnalyzer/LLMAnalyzer.ts +0 -98
  41. package/src/services/llmAnalyzer/index.ts +0 -231
  42. package/src/services/metadataExtractor/index.ts +0 -83
  43. package/src/services/segmentRanker/index.ts +0 -88
  44. package/src/services/signalMerger/index.ts +0 -53
  45. package/src/services/transcriptAnalyzers/base.ts +0 -26
  46. package/src/services/transcriptAnalyzers/factory.ts +0 -66
  47. package/src/services/transcriptAnalyzers/gemini.ts +0 -24
  48. package/src/services/transcriptAnalyzers/index.ts +0 -6
  49. package/src/services/transcriptAnalyzers/whisper.ts +0 -68
  50. package/src/services/transcriptAnalyzers/ytdlp.ts +0 -19
  51. package/src/services/transcriptDetector/index.ts +0 -122
  52. package/src/services/transcriptFetcher/index.ts +0 -147
  53. package/src/services/urlParser/index.ts +0 -52
  54. package/src/services/videoDownloader/index.ts +0 -268
  55. package/src/types/analyzer.ts +0 -23
  56. package/src/types/audio.ts +0 -19
  57. package/src/types/cache.ts +0 -8
  58. package/src/types/cli.ts +0 -22
  59. package/src/types/config.ts +0 -151
  60. package/src/types/downloader.ts +0 -15
  61. package/src/types/factory.ts +0 -3
  62. package/src/types/index.ts +0 -40
  63. package/src/types/pipeline.ts +0 -60
  64. package/src/types/segment.ts +0 -43
  65. package/src/types/transcript.ts +0 -22
  66. package/src/types/video.ts +0 -18
  67. package/src/utils/cache.ts +0 -224
  68. package/src/utils/chunker.ts +0 -60
  69. package/src/utils/dumper.ts +0 -41
  70. package/src/utils/format.ts +0 -10
  71. package/src/utils/logger.ts +0 -17
  72. package/src/utils/modelFactory.ts +0 -71
  73. package/src/utils/redactConfig.ts +0 -23
  74. package/src/utils/sliceAudio.ts +0 -35
  75. package/test-trigger.txt +0 -1
  76. package/tests/analyzerFactory.test.ts +0 -146
  77. package/tests/audioEventDetector.test.ts +0 -69
  78. package/tests/cache.test.ts +0 -203
  79. package/tests/chunkBuilder.test.ts +0 -146
  80. package/tests/chunker.test.ts +0 -95
  81. package/tests/eventDetector.test.ts +0 -103
  82. package/tests/llmAnalyzer.test.ts +0 -283
  83. package/tests/segmentRanker.test.ts +0 -133
  84. package/tests/setup.ts +0 -48
  85. package/tests/signalMerger.test.ts +0 -197
  86. package/tests/transcriptDetector.test.ts +0 -150
  87. package/tests/transcriptFetcher.test.ts +0 -179
  88. package/tests/urlParser.test.ts +0 -70
  89. package/tsconfig.json +0 -16
  90. package/tsconfig.test.json +0 -8
  91. package/vitest.config.ts +0 -8
@@ -1,17 +0,0 @@
1
- const LEVELS = {
2
- info: '[info]',
3
- warn: '[warn]',
4
- error: '[error]',
5
- } as const;
6
-
7
- export const log = {
8
- info: (msg: string): void => {
9
- console.log(`${LEVELS.info} ${msg}`);
10
- },
11
- warn: (msg: string): void => {
12
- console.warn(`${LEVELS.warn} ${msg}`);
13
- },
14
- error: (msg: string): void => {
15
- console.error(`${LEVELS.error} ${msg}`);
16
- },
17
- };
@@ -1,71 +0,0 @@
1
- import { createOpenAI, openai } from '@ai-sdk/openai';
2
- import { anthropic } from '@ai-sdk/anthropic';
3
- import { google } from '@ai-sdk/google';
4
- import { xai } from '@ai-sdk/xai';
5
- import { mistral } from '@ai-sdk/mistral';
6
- import { groq } from '@ai-sdk/groq';
7
- import type { LanguageModel } from 'ai';
8
- import { config } from '../config/index.js';
9
- import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
10
-
11
- /**
12
- * Returns a Vercel AI SDK LanguageModel instance for the configured provider
13
- * and model. Both `llmAnalyzer` and `clipRefiner` call this instead of
14
- * hard-coding `openai(config.LLM_MODEL)`.
15
- *
16
- * The active provider is controlled by `LLM_PROVIDER` in the environment.
17
- * The model name is controlled by `LLM_MODEL`.
18
- *
19
- * In ai@5, all provider packages ship LanguageModelV3 which natively
20
- * satisfies the LanguageModel interface.
21
- */
22
- export function getModel(): LanguageModel {
23
- const model = config.LLM_MODEL;
24
-
25
- switch (config.LLM_PROVIDER) {
26
- case 'openai':
27
- return openai.languageModel(model);
28
-
29
- case 'anthropic':
30
- return anthropic.languageModel(model);
31
-
32
- case 'google':
33
- return google.languageModel(model);
34
-
35
- case 'xai':
36
- return xai.languageModel(model);
37
-
38
- case 'mistral':
39
- return mistral.languageModel(model);
40
-
41
- case 'groq':
42
- return groq.languageModel(model);
43
-
44
- case 'zai':
45
- return createOpenAICompatible({
46
- name: 'zai',
47
- baseURL: 'https://api.z.ai/api/paas/v4',
48
- apiKey: config.ZAI_API_KEY,
49
- // Zai is OpenAI-compatible and supports json_schema response format.
50
- // Without this flag the SDK falls back to json_object mode and emits
51
- // a "responseFormat not supported" warning on every chunk call.
52
- supportsStructuredOutputs: true,
53
- }).languageModel(model);
54
-
55
- case 'openrouter':
56
- return createOpenAI({
57
- baseURL: 'https://openrouter.ai/api/v1',
58
- apiKey: config.OPENROUTER_API_KEY,
59
- }).languageModel(model);
60
-
61
- case 'custom':
62
- return createOpenAICompatible({
63
- name: 'custom',
64
- baseURL: config.CUSTOM_OPENAI_BASE_URL!,
65
- apiKey: config.CUSTOM_OPENAI_API_KEY,
66
- // Treat as a fully OpenAI-compatible endpoint with structured output
67
- // support so generateObject uses json_schema mode, not json_object fallback.
68
- supportsStructuredOutputs: true,
69
- }).languageModel(model);
70
- }
71
- }
@@ -1,23 +0,0 @@
1
- import type { Config } from '../types/config.js';
2
-
3
- const SENSITIVE_KEYS = new Set([
4
- 'OPENAI_API_KEY',
5
- 'ANTHROPIC_API_KEY',
6
- 'GOOGLE_GENERATIVE_AI_API_KEY',
7
- 'XAI_API_KEY',
8
- 'MISTRAL_API_KEY',
9
- 'GROQ_API_KEY',
10
- 'ZAI_API_KEY',
11
- ]);
12
-
13
- /**
14
- * Formats the resolved config as a single-line key=value string,
15
- * omitting all API key fields and any undefined optional values.
16
- */
17
- export function formatConfig(cfg: Config): string {
18
- return (Object.entries(cfg) as [string, unknown][])
19
- .filter(([k]) => !SENSITIVE_KEYS.has(k))
20
- .filter(([, v]) => v !== undefined)
21
- .map(([k, v]) => `${k}=${String(v)}`)
22
- .join(' ');
23
- }
@@ -1,35 +0,0 @@
1
- import * as path from 'path';
2
- import ffmpeg from 'fluent-ffmpeg';
3
- import { config } from '../config/index.js';
4
-
5
- if (config.FFMPEG_PATH) {
6
- ffmpeg.setFfmpegPath(config.FFMPEG_PATH);
7
- }
8
- if (config.FFPROBE_PATH) {
9
- ffmpeg.setFfprobePath(config.FFPROBE_PATH);
10
- }
11
-
12
- export async function sliceAudio(
13
- inputPath: string,
14
- startSec: number,
15
- durationSec: number,
16
- outputDir: string,
17
- ): Promise<string> {
18
- const filename = path.basename(inputPath, path.extname(inputPath));
19
- const outputPath = path.join(outputDir, `${filename}_slice_${startSec}.wav`);
20
-
21
- await new Promise<void>((resolve, reject) => {
22
- ffmpeg(inputPath)
23
- .setStartTime(startSec)
24
- .setDuration(durationSec)
25
- .audioFrequency(16000)
26
- .audioChannels(1)
27
- .format('wav')
28
- .output(outputPath)
29
- .on('end', () => resolve())
30
- .on('error', (err) => reject(err))
31
- .run();
32
- });
33
-
34
- return outputPath;
35
- }
package/test-trigger.txt DELETED
@@ -1 +0,0 @@
1
- test trigger for release workflow
@@ -1,146 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseProviderChain, createAnalyzerChain } from '../src/services/audioAnalyzers/index.js';
3
- import {
4
- parseTranscriptProviderChain,
5
- createTranscriptChain,
6
- } from '../src/services/transcriptAnalyzers/index.js';
7
- import { GeminiAudioAnalyzer } from '../src/services/audioAnalyzers/gemini.js';
8
- import { WhisperAudioAnalyzer } from '../src/services/audioAnalyzers/whisper.js';
9
- import { YAMNetAudioAnalyzer } from '../src/services/audioAnalyzers/yamnet.js';
10
- import { YtDlpTranscriptAnalyzer } from '../src/services/transcriptAnalyzers/ytdlp.js';
11
- import { WhisperTranscriptAnalyzer } from '../src/services/transcriptAnalyzers/whisper.js';
12
- import { GeminiTranscriptAnalyzer } from '../src/services/transcriptAnalyzers/gemini.js';
13
-
14
- // ---------------------------------------------------------------------------
15
- // Audio analyzer factory
16
- // ---------------------------------------------------------------------------
17
-
18
- describe('parseProviderChain (audio)', () => {
19
- it('parses a single provider', () => {
20
- expect(parseProviderChain('gemini')).toEqual(['gemini']);
21
- });
22
-
23
- it('parses a comma-separated chain', () => {
24
- expect(parseProviderChain('gemini,whisper')).toEqual(['gemini', 'whisper']);
25
- });
26
-
27
- it('parses all three providers', () => {
28
- expect(parseProviderChain('gemini,whisper,yamnet')).toEqual(['gemini', 'whisper', 'yamnet']);
29
- });
30
-
31
- it('trims whitespace around provider names', () => {
32
- expect(parseProviderChain(' gemini , whisper ')).toEqual(['gemini', 'whisper']);
33
- });
34
-
35
- it('maps legacy "both" to ["gemini", "whisper"]', () => {
36
- expect(parseProviderChain('both')).toEqual(['gemini', 'whisper']);
37
- });
38
-
39
- it('throws on an unknown provider name', () => {
40
- expect(() => parseProviderChain('openai')).toThrow('Unknown audio provider "openai"');
41
- });
42
-
43
- it('throws when the string is empty', () => {
44
- expect(() => parseProviderChain('')).toThrow('AUDIO_PROVIDER is empty');
45
- });
46
-
47
- it('throws when a chain contains one unknown name', () => {
48
- expect(() => parseProviderChain('gemini,unknown')).toThrow('Unknown audio provider "unknown"');
49
- });
50
- });
51
-
52
- describe('createAnalyzerChain (audio)', () => {
53
- it('returns a GeminiAudioAnalyzer for "gemini"', () => {
54
- const chain = createAnalyzerChain('gemini');
55
- expect(chain).toHaveLength(1);
56
- expect(chain[0]).toBeInstanceOf(GeminiAudioAnalyzer);
57
- });
58
-
59
- it('returns a WhisperAudioAnalyzer for "whisper"', () => {
60
- const chain = createAnalyzerChain('whisper');
61
- expect(chain[0]).toBeInstanceOf(WhisperAudioAnalyzer);
62
- });
63
-
64
- it('returns a YAMNetAudioAnalyzer for "yamnet"', () => {
65
- const chain = createAnalyzerChain('yamnet');
66
- expect(chain[0]).toBeInstanceOf(YAMNetAudioAnalyzer);
67
- });
68
-
69
- it('returns analyzers in the declared order for a two-item chain', () => {
70
- const chain = createAnalyzerChain('gemini,whisper');
71
- expect(chain).toHaveLength(2);
72
- expect(chain[0]).toBeInstanceOf(GeminiAudioAnalyzer);
73
- expect(chain[1]).toBeInstanceOf(WhisperAudioAnalyzer);
74
- });
75
-
76
- it('maps legacy "both" to [Gemini, Whisper]', () => {
77
- const chain = createAnalyzerChain('both');
78
- expect(chain).toHaveLength(2);
79
- expect(chain[0]).toBeInstanceOf(GeminiAudioAnalyzer);
80
- expect(chain[1]).toBeInstanceOf(WhisperAudioAnalyzer);
81
- });
82
- });
83
-
84
- // ---------------------------------------------------------------------------
85
- // Transcript analyzer factory
86
- // ---------------------------------------------------------------------------
87
-
88
- describe('parseTranscriptProviderChain', () => {
89
- it('parses a single provider', () => {
90
- expect(parseTranscriptProviderChain('ytdlp')).toEqual(['ytdlp']);
91
- });
92
-
93
- it('parses a comma-separated chain', () => {
94
- expect(parseTranscriptProviderChain('ytdlp,whisper')).toEqual(['ytdlp', 'whisper']);
95
- });
96
-
97
- it('trims whitespace around provider names', () => {
98
- expect(parseTranscriptProviderChain(' ytdlp , whisper ')).toEqual(['ytdlp', 'whisper']);
99
- });
100
-
101
- it('throws on an unknown provider name', () => {
102
- expect(() => parseTranscriptProviderChain('openai')).toThrow(
103
- 'Unknown transcript provider "openai"',
104
- );
105
- });
106
-
107
- it('throws when the string is empty', () => {
108
- expect(() => parseTranscriptProviderChain('')).toThrow('TRANSCRIPT_PROVIDER is empty');
109
- });
110
-
111
- it('throws when a chain contains one unknown name', () => {
112
- expect(() => parseTranscriptProviderChain('ytdlp,unknown')).toThrow(
113
- 'Unknown transcript provider "unknown"',
114
- );
115
- });
116
- });
117
-
118
- describe('createTranscriptChain', () => {
119
- it('returns a YtDlpTranscriptAnalyzer for "ytdlp"', () => {
120
- const chain = createTranscriptChain('ytdlp');
121
- expect(chain).toHaveLength(1);
122
- expect(chain[0]).toBeInstanceOf(YtDlpTranscriptAnalyzer);
123
- });
124
-
125
- it('returns a WhisperTranscriptAnalyzer for "whisper"', () => {
126
- const chain = createTranscriptChain('whisper');
127
- expect(chain[0]).toBeInstanceOf(WhisperTranscriptAnalyzer);
128
- });
129
-
130
- it('returns a GeminiTranscriptAnalyzer for "gemini"', () => {
131
- const chain = createTranscriptChain('gemini');
132
- expect(chain[0]).toBeInstanceOf(GeminiTranscriptAnalyzer);
133
- });
134
-
135
- it('returns analyzers in the declared order for a two-item chain', () => {
136
- const chain = createTranscriptChain('ytdlp,whisper');
137
- expect(chain).toHaveLength(2);
138
- expect(chain[0]).toBeInstanceOf(YtDlpTranscriptAnalyzer);
139
- expect(chain[1]).toBeInstanceOf(WhisperTranscriptAnalyzer);
140
- });
141
-
142
- it('GeminiTranscriptAnalyzer throws when detect() is called (stub)', async () => {
143
- const chain = createTranscriptChain('gemini');
144
- await expect(chain[0].detect('abc', null)).rejects.toThrow('not yet implemented');
145
- });
146
- });
@@ -1,69 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { normalizeGeminiTime } from '../src/services/audioAnalyzers/gemini.js';
3
-
4
- const CHUNK = 120; // default chunk duration in seconds
5
-
6
- describe('normalizeGeminiTime', () => {
7
- // --- MM.SS format (frac ≤ 0.59, MM.SS result within chunk) ---
8
-
9
- it('converts 0.41 (MM.SS: 0min 41sec) to 41', () => {
10
- expect(normalizeGeminiTime(0.41, CHUNK)).toBe(41);
11
- });
12
-
13
- it('converts 0.55 (MM.SS: 0min 55sec) to 55', () => {
14
- expect(normalizeGeminiTime(0.55, CHUNK)).toBe(55);
15
- });
16
-
17
- it('converts 1.03 (MM.SS: 1min 3sec) to 63', () => {
18
- expect(normalizeGeminiTime(1.03, CHUNK)).toBe(63);
19
- });
20
-
21
- it('converts 1.40 (MM.SS: 1min 40sec) to 100', () => {
22
- expect(normalizeGeminiTime(1.4, CHUNK)).toBe(100);
23
- });
24
-
25
- it('converts 1.59 (MM.SS: 1min 59sec) to 119', () => {
26
- expect(normalizeGeminiTime(1.59, CHUNK)).toBe(119);
27
- });
28
-
29
- it('converts 0.00 to 0', () => {
30
- expect(normalizeGeminiTime(0.0, CHUNK)).toBe(0);
31
- });
32
-
33
- // --- True decimal seconds (frac > 0.59 → cannot be MM.SS) ---
34
-
35
- it('treats 53.403 as true decimal seconds (frac > 0.59)', () => {
36
- expect(normalizeGeminiTime(53.403, CHUNK)).toBe(53.403);
37
- });
38
-
39
- it('treats 110.273 as true decimal seconds (frac > 0.59)', () => {
40
- expect(normalizeGeminiTime(110.273, CHUNK)).toBe(110.273);
41
- });
42
-
43
- it('treats 12.396 as true decimal seconds (frac > 0.59)', () => {
44
- expect(normalizeGeminiTime(12.396, CHUNK)).toBe(12.396);
45
- });
46
-
47
- it('treats 0.7 as true decimal seconds (frac > 0.59)', () => {
48
- expect(normalizeGeminiTime(0.7, CHUNK)).toBe(0.7);
49
- });
50
-
51
- // --- MM.SS overflows chunk → falls back to decimal seconds ---
52
-
53
- it('falls back to decimal seconds when MM.SS result exceeds chunk duration', () => {
54
- // 2.30 as MM.SS = 2min 30sec = 150s, which exceeds 120s chunk → use 2.30 as-is
55
- expect(normalizeGeminiTime(2.3, CHUNK)).toBe(2.3);
56
- });
57
-
58
- it('falls back to decimal for value already near chunk boundary', () => {
59
- // 2.00 as MM.SS = 120s, which equals chunk duration (not < chunk) → use 2.00 as-is
60
- expect(normalizeGeminiTime(2.0, CHUNK)).toBe(2.0);
61
- });
62
-
63
- // --- Edge: large chunk duration ---
64
-
65
- it('converts MM.SS correctly when chunk is large enough to contain it', () => {
66
- // 12.05 as MM.SS = 12min 5sec = 725s, within a 900s chunk
67
- expect(normalizeGeminiTime(12.05, 900)).toBe(725);
68
- });
69
- });
@@ -1,203 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'fs';
3
- import os from 'os';
4
- import path from 'path';
5
- import { Cache } from '../src/utils/cache.js';
6
- import type { TranscriptLine, LLMChunk, ChunkEvaluation, AudioEvent } from '../src/types/index.js';
7
-
8
- // ---------------------------------------------------------------------------
9
- // Helpers
10
- // ---------------------------------------------------------------------------
11
-
12
- function tmpDir(): string {
13
- return path.join(os.tmpdir(), `cache-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
- }
15
-
16
- const TRANSCRIPT_LINE: TranscriptLine = { text: 'hello world', start: 0, duration: 2 };
17
-
18
- const LLM_CHUNK: LLMChunk = {
19
- start: 0,
20
- end: 120,
21
- text: 'some transcript text',
22
- };
23
-
24
- const CHUNK_EVALUATION: ChunkEvaluation = {
25
- status: 'success',
26
- chunk_index: 0,
27
- chunk_start: 0,
28
- chunk_end: 120,
29
- interesting: true,
30
- score: 8,
31
- reason: 'great moment',
32
- clip_start: 10,
33
- clip_end: 40,
34
- };
35
-
36
- const AUDIO_EVENT: AudioEvent = {
37
- time: 30,
38
- event: 'gunshot',
39
- confidence: 0.9,
40
- source: 'gemini',
41
- };
42
-
43
- // ---------------------------------------------------------------------------
44
- // Tests
45
- // ---------------------------------------------------------------------------
46
-
47
- describe('Cache', () => {
48
- let cacheDir: string;
49
- let cache: Cache;
50
-
51
- beforeEach(async () => {
52
- cacheDir = tmpDir();
53
- await fs.mkdir(cacheDir, { recursive: true });
54
- cache = new Cache(cacheDir);
55
- });
56
-
57
- afterEach(async () => {
58
- await fs.rm(cacheDir, { recursive: true, force: true });
59
- });
60
-
61
- // ── Transcript ────────────────────────────────────────────────────────────
62
-
63
- describe('transcript', () => {
64
- it('returns null on cache miss', async () => {
65
- const result = await cache.readTranscript('abc12345678');
66
- expect(result).toBeNull();
67
- });
68
-
69
- it('round-trips transcript lines', async () => {
70
- const lines: TranscriptLine[] = [TRANSCRIPT_LINE, { text: 'bye', start: 3, duration: 1 }];
71
- await cache.writeTranscript('abc12345678', lines);
72
- const loaded = await cache.readTranscript('abc12345678');
73
- expect(loaded).toEqual(lines);
74
- });
75
-
76
- it('different video IDs have independent entries', async () => {
77
- const lines1: TranscriptLine[] = [TRANSCRIPT_LINE];
78
- const lines2: TranscriptLine[] = [{ text: 'other', start: 5, duration: 1 }];
79
- await cache.writeTranscript('aaaaaaaaaaa', lines1);
80
- await cache.writeTranscript('bbbbbbbbbbb', lines2);
81
-
82
- expect(await cache.readTranscript('aaaaaaaaaaa')).toEqual(lines1);
83
- expect(await cache.readTranscript('bbbbbbbbbbb')).toEqual(lines2);
84
- });
85
- });
86
-
87
- // ── LLM chunk results ─────────────────────────────────────────────────────
88
-
89
- describe('chunk evaluations', () => {
90
- it('returns null on cache miss', async () => {
91
- expect(await cache.readChunk(LLM_CHUNK)).toBeNull();
92
- });
93
-
94
- it('round-trips a successful evaluation', async () => {
95
- await cache.writeChunk(LLM_CHUNK, CHUNK_EVALUATION);
96
- const loaded = await cache.readChunk(LLM_CHUNK);
97
- expect(loaded).toEqual(CHUNK_EVALUATION);
98
- });
99
-
100
- it('does not write failed evaluations', async () => {
101
- const failed: ChunkEvaluation = {
102
- status: 'failed',
103
- chunk_index: 0,
104
- chunk_start: 0,
105
- chunk_end: 120,
106
- error: 'timeout',
107
- };
108
- await cache.writeChunk(LLM_CHUNK, failed);
109
- expect(await cache.readChunk(LLM_CHUNK)).toBeNull();
110
- });
111
-
112
- it('different chunks (different text) are independent', async () => {
113
- const chunk2: LLMChunk = { ...LLM_CHUNK, text: 'different text' };
114
- const eval2: ChunkEvaluation = { ...CHUNK_EVALUATION, score: 5 };
115
-
116
- await cache.writeChunk(LLM_CHUNK, CHUNK_EVALUATION);
117
- await cache.writeChunk(chunk2, eval2);
118
-
119
- expect(await cache.readChunk(LLM_CHUNK)).toEqual(CHUNK_EVALUATION);
120
- expect(await cache.readChunk(chunk2)).toEqual(eval2);
121
- });
122
- });
123
-
124
- // ── Audio events ──────────────────────────────────────────────────────────
125
-
126
- describe('audio events', () => {
127
- it('returns null on cache miss', async () => {
128
- expect(await cache.readAudioEvents('abc12345678', 'fps', 'gemini')).toBeNull();
129
- });
130
-
131
- it('round-trips audio events', async () => {
132
- const events: AudioEvent[] = [AUDIO_EVENT];
133
- await cache.writeAudioEvents('abc12345678', 'fps', 'gemini', events);
134
- const loaded = await cache.readAudioEvents('abc12345678', 'fps', 'gemini');
135
- expect(loaded).toEqual(events);
136
- });
137
-
138
- it('different game profiles are independent', async () => {
139
- const events1: AudioEvent[] = [AUDIO_EVENT];
140
- const events2: AudioEvent[] = [
141
- { time: 60, event: 'explosion', confidence: 0.7, source: 'yamnet' },
142
- ];
143
-
144
- await cache.writeAudioEvents('abc12345678', 'fps', 'gemini', events1);
145
- await cache.writeAudioEvents('abc12345678', 'valorant', 'gemini', events2);
146
-
147
- expect(await cache.readAudioEvents('abc12345678', 'fps', 'gemini')).toEqual(events1);
148
- expect(await cache.readAudioEvents('abc12345678', 'valorant', 'gemini')).toEqual(events2);
149
- });
150
- });
151
-
152
- // ── Disabled mode (--no-cache) ────────────────────────────────────────────
153
-
154
- describe('disabled cache', () => {
155
- let disabledCache: Cache;
156
-
157
- beforeEach(() => {
158
- disabledCache = new Cache(cacheDir, true);
159
- });
160
-
161
- it('always returns null for reads', async () => {
162
- // pre-write with enabled cache to confirm disabled cache ignores it
163
- await cache.writeTranscript('abc12345678', [TRANSCRIPT_LINE]);
164
- expect(await disabledCache.readTranscript('abc12345678')).toBeNull();
165
- });
166
-
167
- it('silently skips writes', async () => {
168
- // No error thrown, file is not written
169
- await disabledCache.writeTranscript('abc12345678', [TRANSCRIPT_LINE]);
170
- expect(await cache.readTranscript('abc12345678')).toBeNull();
171
- });
172
-
173
- it('returns null for chunk reads', async () => {
174
- await cache.writeChunk(LLM_CHUNK, CHUNK_EVALUATION);
175
- expect(await disabledCache.readChunk(LLM_CHUNK)).toBeNull();
176
- });
177
-
178
- it('returns null for audio event reads', async () => {
179
- await cache.writeAudioEvents('abc12345678', 'fps', 'gemini', [AUDIO_EVENT]);
180
- expect(await disabledCache.readAudioEvents('abc12345678', 'fps', 'gemini')).toBeNull();
181
- });
182
- });
183
-
184
- // ── Corrupt cache handling ────────────────────────────────────────────────
185
-
186
- describe('corrupt cache entries', () => {
187
- it('returns null and does not throw for corrupt transcript JSON', async () => {
188
- const corruptPath = path.join(cacheDir, 'transcript');
189
- await fs.mkdir(corruptPath, { recursive: true });
190
- // Write a file that will match the expected hash path
191
- const hash = await getTranscriptHash('abc12345678');
192
- await fs.writeFile(path.join(corruptPath, `${hash}.json`), 'NOT VALID JSON', 'utf-8');
193
-
194
- expect(await cache.readTranscript('abc12345678')).toBeNull();
195
- });
196
- });
197
- });
198
-
199
- // Helper: compute the same sha256 hash the Cache class uses internally
200
- async function getTranscriptHash(videoId: string): Promise<string> {
201
- const { createHash } = await import('node:crypto');
202
- return createHash('sha256').update(videoId).digest('hex');
203
- }