@thunderkiller/video-clipper 1.2.0 → 1.4.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 (93) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CONTRIBUTING.md +100 -0
  3. package/LICENSE +15 -0
  4. package/commitlint.config.js +25 -0
  5. package/package.json +3 -1
  6. package/.github/workflows/ci.yml +0 -42
  7. package/.github/workflows/release.yml +0 -76
  8. package/.husky/pre-commit +0 -3
  9. package/.prettierignore +0 -6
  10. package/.prettierrc +0 -7
  11. package/.releaserc.json +0 -21
  12. package/AGENTS.md +0 -122
  13. package/docs/free-models.md +0 -78
  14. package/docs/plan.md +0 -442
  15. package/docs/refactorPhases.md +0 -105
  16. package/docs/yt-downloader.md +0 -440
  17. package/requirements.txt +0 -5
  18. package/scripts/detect_events.py +0 -81
  19. package/scripts/detect_events_whisper.py +0 -101
  20. package/scripts/transcribe_whisper.py +0 -70
  21. package/src/cli.ts +0 -186
  22. package/src/config/env.ts +0 -18
  23. package/src/config/index.ts +0 -2
  24. package/src/index.ts +0 -46
  25. package/src/pipeline/runner.ts +0 -147
  26. package/src/pipeline/stages/audioProcessor.ts +0 -127
  27. package/src/pipeline/stages/clipExporter.ts +0 -76
  28. package/src/pipeline/stages/segmentAnalyzer.ts +0 -72
  29. package/src/pipeline/stages/segmentSelector.ts +0 -39
  30. package/src/pipeline/stages/videoResolver.ts +0 -44
  31. package/src/services/audioAnalyzers/base.ts +0 -32
  32. package/src/services/audioAnalyzers/factory.ts +0 -69
  33. package/src/services/audioAnalyzers/gemini.ts +0 -136
  34. package/src/services/audioAnalyzers/index.ts +0 -6
  35. package/src/services/audioAnalyzers/whisper.ts +0 -80
  36. package/src/services/audioAnalyzers/yamnet.ts +0 -54
  37. package/src/services/audioDownloader/index.ts +0 -102
  38. package/src/services/chunkBuilder/index.ts +0 -82
  39. package/src/services/clipGenerator/index.ts +0 -210
  40. package/src/services/clipRefiner/index.ts +0 -141
  41. package/src/services/eventDetector/index.ts +0 -68
  42. package/src/services/llmAnalyzer/LLMAnalyzer.ts +0 -98
  43. package/src/services/llmAnalyzer/index.ts +0 -231
  44. package/src/services/metadataExtractor/index.ts +0 -83
  45. package/src/services/segmentRanker/index.ts +0 -88
  46. package/src/services/signalMerger/index.ts +0 -53
  47. package/src/services/transcriptAnalyzers/base.ts +0 -26
  48. package/src/services/transcriptAnalyzers/factory.ts +0 -66
  49. package/src/services/transcriptAnalyzers/gemini.ts +0 -24
  50. package/src/services/transcriptAnalyzers/index.ts +0 -6
  51. package/src/services/transcriptAnalyzers/whisper.ts +0 -68
  52. package/src/services/transcriptAnalyzers/ytdlp.ts +0 -19
  53. package/src/services/transcriptDetector/index.ts +0 -122
  54. package/src/services/transcriptFetcher/index.ts +0 -147
  55. package/src/services/urlParser/index.ts +0 -52
  56. package/src/services/videoDownloader/index.ts +0 -268
  57. package/src/types/analyzer.ts +0 -23
  58. package/src/types/audio.ts +0 -19
  59. package/src/types/cache.ts +0 -8
  60. package/src/types/cli.ts +0 -22
  61. package/src/types/config.ts +0 -151
  62. package/src/types/downloader.ts +0 -15
  63. package/src/types/factory.ts +0 -3
  64. package/src/types/index.ts +0 -40
  65. package/src/types/pipeline.ts +0 -60
  66. package/src/types/segment.ts +0 -43
  67. package/src/types/transcript.ts +0 -22
  68. package/src/types/video.ts +0 -18
  69. package/src/utils/cache.ts +0 -224
  70. package/src/utils/chunker.ts +0 -60
  71. package/src/utils/dumper.ts +0 -41
  72. package/src/utils/format.ts +0 -10
  73. package/src/utils/logger.ts +0 -17
  74. package/src/utils/modelFactory.ts +0 -71
  75. package/src/utils/redactConfig.ts +0 -23
  76. package/src/utils/sliceAudio.ts +0 -35
  77. package/test-trigger.txt +0 -1
  78. package/tests/analyzerFactory.test.ts +0 -146
  79. package/tests/audioEventDetector.test.ts +0 -69
  80. package/tests/cache.test.ts +0 -203
  81. package/tests/chunkBuilder.test.ts +0 -146
  82. package/tests/chunker.test.ts +0 -95
  83. package/tests/eventDetector.test.ts +0 -103
  84. package/tests/llmAnalyzer.test.ts +0 -283
  85. package/tests/segmentRanker.test.ts +0 -133
  86. package/tests/setup.ts +0 -48
  87. package/tests/signalMerger.test.ts +0 -197
  88. package/tests/transcriptDetector.test.ts +0 -150
  89. package/tests/transcriptFetcher.test.ts +0 -179
  90. package/tests/urlParser.test.ts +0 -70
  91. package/tsconfig.json +0 -16
  92. package/tsconfig.test.json +0 -8
  93. 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
- }