@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +15 -0
- package/package.json +1 -1
- package/.github/workflows/ci.yml +0 -42
- package/.github/workflows/release.yml +0 -76
- package/.husky/pre-commit +0 -3
- package/.prettierignore +0 -6
- package/.prettierrc +0 -7
- package/.releaserc.json +0 -21
- package/AGENTS.md +0 -122
- package/docs/free-models.md +0 -78
- package/docs/plan.md +0 -442
- package/docs/refactorPhases.md +0 -105
- package/docs/yt-downloader.md +0 -440
- package/requirements.txt +0 -5
- package/scripts/detect_events.py +0 -81
- package/scripts/detect_events_whisper.py +0 -101
- package/scripts/transcribe_whisper.py +0 -70
- package/src/cli.ts +0 -186
- package/src/config/env.ts +0 -18
- package/src/config/index.ts +0 -2
- package/src/index.ts +0 -46
- package/src/pipeline/runner.ts +0 -147
- package/src/pipeline/stages/audioProcessor.ts +0 -127
- package/src/pipeline/stages/clipExporter.ts +0 -76
- package/src/pipeline/stages/segmentAnalyzer.ts +0 -72
- package/src/pipeline/stages/segmentSelector.ts +0 -39
- package/src/pipeline/stages/videoResolver.ts +0 -44
- package/src/services/audioAnalyzers/base.ts +0 -32
- package/src/services/audioAnalyzers/factory.ts +0 -69
- package/src/services/audioAnalyzers/gemini.ts +0 -136
- package/src/services/audioAnalyzers/index.ts +0 -6
- package/src/services/audioAnalyzers/whisper.ts +0 -80
- package/src/services/audioAnalyzers/yamnet.ts +0 -54
- package/src/services/audioDownloader/index.ts +0 -102
- package/src/services/chunkBuilder/index.ts +0 -82
- package/src/services/clipGenerator/index.ts +0 -210
- package/src/services/clipRefiner/index.ts +0 -141
- package/src/services/eventDetector/index.ts +0 -68
- package/src/services/llmAnalyzer/LLMAnalyzer.ts +0 -98
- package/src/services/llmAnalyzer/index.ts +0 -231
- package/src/services/metadataExtractor/index.ts +0 -83
- package/src/services/segmentRanker/index.ts +0 -88
- package/src/services/signalMerger/index.ts +0 -53
- package/src/services/transcriptAnalyzers/base.ts +0 -26
- package/src/services/transcriptAnalyzers/factory.ts +0 -66
- package/src/services/transcriptAnalyzers/gemini.ts +0 -24
- package/src/services/transcriptAnalyzers/index.ts +0 -6
- package/src/services/transcriptAnalyzers/whisper.ts +0 -68
- package/src/services/transcriptAnalyzers/ytdlp.ts +0 -19
- package/src/services/transcriptDetector/index.ts +0 -122
- package/src/services/transcriptFetcher/index.ts +0 -147
- package/src/services/urlParser/index.ts +0 -52
- package/src/services/videoDownloader/index.ts +0 -268
- package/src/types/analyzer.ts +0 -23
- package/src/types/audio.ts +0 -19
- package/src/types/cache.ts +0 -8
- package/src/types/cli.ts +0 -22
- package/src/types/config.ts +0 -151
- package/src/types/downloader.ts +0 -15
- package/src/types/factory.ts +0 -3
- package/src/types/index.ts +0 -40
- package/src/types/pipeline.ts +0 -60
- package/src/types/segment.ts +0 -43
- package/src/types/transcript.ts +0 -22
- package/src/types/video.ts +0 -18
- package/src/utils/cache.ts +0 -224
- package/src/utils/chunker.ts +0 -60
- package/src/utils/dumper.ts +0 -41
- package/src/utils/format.ts +0 -10
- package/src/utils/logger.ts +0 -17
- package/src/utils/modelFactory.ts +0 -71
- package/src/utils/redactConfig.ts +0 -23
- package/src/utils/sliceAudio.ts +0 -35
- package/test-trigger.txt +0 -1
- package/tests/analyzerFactory.test.ts +0 -146
- package/tests/audioEventDetector.test.ts +0 -69
- package/tests/cache.test.ts +0 -203
- package/tests/chunkBuilder.test.ts +0 -146
- package/tests/chunker.test.ts +0 -95
- package/tests/eventDetector.test.ts +0 -103
- package/tests/llmAnalyzer.test.ts +0 -283
- package/tests/segmentRanker.test.ts +0 -133
- package/tests/setup.ts +0 -48
- package/tests/signalMerger.test.ts +0 -197
- package/tests/transcriptDetector.test.ts +0 -150
- package/tests/transcriptFetcher.test.ts +0 -179
- package/tests/urlParser.test.ts +0 -70
- package/tsconfig.json +0 -16
- package/tsconfig.test.json +0 -8
- package/vitest.config.ts +0 -8
package/src/utils/logger.ts
DELETED
|
@@ -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
|
-
}
|
package/src/utils/sliceAudio.ts
DELETED
|
@@ -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
|
-
});
|
package/tests/cache.test.ts
DELETED
|
@@ -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
|
-
}
|