@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
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { buildMicroBlocks, buildLLMChunks } from '../src/services/chunkBuilder/index.js';
|
|
3
|
-
import type { TranscriptLine, MicroBlock } from '../src/types/index.js';
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Helpers
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
function makeLine(start: number, duration: number, text: string): TranscriptLine {
|
|
10
|
-
return { start, duration, text };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function makeBlock(start: number, end: number, text: string): MicroBlock {
|
|
14
|
-
return { start, end, text };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// buildMicroBlocks
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
describe('buildMicroBlocks', () => {
|
|
22
|
-
it('returns empty array for empty input', () => {
|
|
23
|
-
expect(buildMicroBlocks([], 15)).toEqual([]);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('groups a single line into one block', () => {
|
|
27
|
-
const lines = [makeLine(0, 5, 'hello world')];
|
|
28
|
-
const blocks = buildMicroBlocks(lines, 15);
|
|
29
|
-
expect(blocks).toHaveLength(1);
|
|
30
|
-
expect(blocks[0].start).toBe(0);
|
|
31
|
-
expect(blocks[0].text).toBe('hello world');
|
|
32
|
-
expect(blocks[0].end).toBe(5); // 0 + 5
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('groups lines within the window into one block', () => {
|
|
36
|
-
const lines = [
|
|
37
|
-
makeLine(0, 3, 'line one'),
|
|
38
|
-
makeLine(5, 3, 'line two'),
|
|
39
|
-
makeLine(10, 3, 'line three'),
|
|
40
|
-
];
|
|
41
|
-
const blocks = buildMicroBlocks(lines, 15);
|
|
42
|
-
expect(blocks).toHaveLength(1);
|
|
43
|
-
expect(blocks[0].text).toBe('line one line two line three');
|
|
44
|
-
expect(blocks[0].start).toBe(0);
|
|
45
|
-
expect(blocks[0].end).toBe(13); // 10 + 3
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('starts a new block when a line falls outside the window', () => {
|
|
49
|
-
const lines = [
|
|
50
|
-
makeLine(0, 5, 'block one a'),
|
|
51
|
-
makeLine(5, 5, 'block one b'),
|
|
52
|
-
makeLine(15, 5, 'block two a'), // exactly at boundary
|
|
53
|
-
makeLine(20, 5, 'block two b'),
|
|
54
|
-
];
|
|
55
|
-
const blocks = buildMicroBlocks(lines, 15);
|
|
56
|
-
expect(blocks).toHaveLength(2);
|
|
57
|
-
expect(blocks[0].text).toBe('block one a block one b');
|
|
58
|
-
expect(blocks[1].text).toBe('block two a block two b');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('creates multiple blocks for long transcripts', () => {
|
|
62
|
-
// 6 lines, each 10s apart → should create 4 blocks with a 15s window
|
|
63
|
-
const lines = Array.from({ length: 6 }, (_, i) => makeLine(i * 10, 5, `line ${i}`));
|
|
64
|
-
const blocks = buildMicroBlocks(lines, 15);
|
|
65
|
-
// line 0 (0s) and line 1 (10s) → block 1 [0–20)
|
|
66
|
-
// line 2 (20s) and line 3 (30s) → block 2 [20–40)
|
|
67
|
-
// line 4 (40s) and line 5 (50s) → block 3 [40–55]
|
|
68
|
-
expect(blocks).toHaveLength(3);
|
|
69
|
-
expect(blocks[0].start).toBe(0);
|
|
70
|
-
expect(blocks[1].start).toBe(20);
|
|
71
|
-
expect(blocks[2].start).toBe(40);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('preserves start time of the first line in each block', () => {
|
|
75
|
-
const lines = [makeLine(3, 5, 'first'), makeLine(25, 5, 'second')];
|
|
76
|
-
const blocks = buildMicroBlocks(lines, 15);
|
|
77
|
-
expect(blocks[0].start).toBe(3);
|
|
78
|
-
expect(blocks[1].start).toBe(25);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
// buildLLMChunks
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
|
|
86
|
-
describe('buildLLMChunks', () => {
|
|
87
|
-
it('returns empty array for empty input', () => {
|
|
88
|
-
expect(buildLLMChunks([], 120, 20)).toEqual([]);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('returns a single chunk when all blocks fit within one window', () => {
|
|
92
|
-
const blocks = [
|
|
93
|
-
makeBlock(0, 30, 'first'),
|
|
94
|
-
makeBlock(30, 60, 'second'),
|
|
95
|
-
makeBlock(60, 90, 'third'),
|
|
96
|
-
];
|
|
97
|
-
const chunks = buildLLMChunks(blocks, 120, 20);
|
|
98
|
-
expect(chunks).toHaveLength(1);
|
|
99
|
-
expect(chunks[0].start).toBe(0);
|
|
100
|
-
expect(chunks[0].text).toBe('first second third');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('produces overlapping chunks', () => {
|
|
104
|
-
// 12 blocks of 10s each → 120s total
|
|
105
|
-
// chunkLen=60, overlap=20 → step=40
|
|
106
|
-
// chunk 1: blocks starting 0–59s → [0,10,20,30,40,50]
|
|
107
|
-
// chunk 2: blocks starting 40–99s → [40,50,60,70,80,90]
|
|
108
|
-
// chunk 3: blocks starting 80–139s (capped at 120) → [80,90,100,110]
|
|
109
|
-
const blocks = Array.from({ length: 12 }, (_, i) => makeBlock(i * 10, i * 10 + 10, `b${i}`));
|
|
110
|
-
const chunks = buildLLMChunks(blocks, 60, 20);
|
|
111
|
-
expect(chunks).toHaveLength(3);
|
|
112
|
-
|
|
113
|
-
// Chunk 1: blocks 0–50
|
|
114
|
-
expect(chunks[0].start).toBe(0);
|
|
115
|
-
expect(chunks[0].text).toContain('b0');
|
|
116
|
-
expect(chunks[0].text).toContain('b5');
|
|
117
|
-
|
|
118
|
-
// Chunk 2 starts at 40 (step = 40 from 0)
|
|
119
|
-
expect(chunks[1].start).toBe(40);
|
|
120
|
-
expect(chunks[1].text).toContain('b4');
|
|
121
|
-
expect(chunks[1].text).toContain('b9');
|
|
122
|
-
|
|
123
|
-
// Overlap: b4 and b5 appear in both chunk 1 and chunk 2
|
|
124
|
-
expect(chunks[0].text).toContain('b4');
|
|
125
|
-
expect(chunks[1].text).toContain('b4');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('does not produce empty chunks', () => {
|
|
129
|
-
const blocks = [makeBlock(0, 10, 'only')];
|
|
130
|
-
const chunks = buildLLMChunks(blocks, 120, 20);
|
|
131
|
-
expect(chunks).toHaveLength(1);
|
|
132
|
-
expect(chunks[0].text).toBe('only');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('chunk start is the start of the first included block', () => {
|
|
136
|
-
const blocks = [makeBlock(5, 15, 'a'), makeBlock(15, 25, 'b')];
|
|
137
|
-
const chunks = buildLLMChunks(blocks, 120, 20);
|
|
138
|
-
expect(chunks[0].start).toBe(5);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('chunk end is the end of the last included block', () => {
|
|
142
|
-
const blocks = [makeBlock(0, 60, 'a'), makeBlock(60, 90, 'b')];
|
|
143
|
-
const chunks = buildLLMChunks(blocks, 120, 20);
|
|
144
|
-
expect(chunks[0].end).toBe(90);
|
|
145
|
-
});
|
|
146
|
-
});
|
package/tests/chunker.test.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { buildWindows } from '../src/utils/chunker.js';
|
|
3
|
-
|
|
4
|
-
describe('buildWindows', () => {
|
|
5
|
-
it('returns empty array when totalDuration is 0', () => {
|
|
6
|
-
expect(buildWindows(0, 30)).toEqual([]);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it('returns empty array when totalDuration is negative', () => {
|
|
10
|
-
expect(buildWindows(-10, 30)).toEqual([]);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('returns empty array when windowSec is 0', () => {
|
|
14
|
-
expect(buildWindows(60, 0)).toEqual([]);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('returns empty array when windowSec is negative', () => {
|
|
18
|
-
expect(buildWindows(60, -5)).toEqual([]);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('produces non-overlapping equal windows with no overlap', () => {
|
|
22
|
-
const windows = buildWindows(60, 20);
|
|
23
|
-
expect(windows).toEqual([
|
|
24
|
-
{ start: 0, end: 20 },
|
|
25
|
-
{ start: 20, end: 40 },
|
|
26
|
-
{ start: 40, end: 60 },
|
|
27
|
-
]);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('clips the last window end to totalDuration', () => {
|
|
31
|
-
const windows = buildWindows(70, 30);
|
|
32
|
-
expect(windows).toEqual([
|
|
33
|
-
{ start: 0, end: 30 },
|
|
34
|
-
{ start: 30, end: 60 },
|
|
35
|
-
{ start: 60, end: 70 },
|
|
36
|
-
]);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('handles exact-fit duration (no remainder)', () => {
|
|
40
|
-
const windows = buildWindows(90, 30);
|
|
41
|
-
expect(windows).toHaveLength(3);
|
|
42
|
-
expect(windows[2]).toEqual({ start: 60, end: 90 });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('produces overlapping windows when overlapSec > 0', () => {
|
|
46
|
-
const windows = buildWindows(60, 30, 10);
|
|
47
|
-
// step = 30 - 10 = 20
|
|
48
|
-
expect(windows).toEqual([
|
|
49
|
-
{ start: 0, end: 30 },
|
|
50
|
-
{ start: 20, end: 50 },
|
|
51
|
-
{ start: 40, end: 60 },
|
|
52
|
-
]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('first window starts at 0', () => {
|
|
56
|
-
const windows = buildWindows(100, 25);
|
|
57
|
-
expect(windows[0].start).toBe(0);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('last window end does not exceed totalDuration', () => {
|
|
61
|
-
for (const [dur, win, ov] of [
|
|
62
|
-
[100, 30, 0],
|
|
63
|
-
[100, 30, 10],
|
|
64
|
-
[77, 20, 5],
|
|
65
|
-
] as [number, number, number][]) {
|
|
66
|
-
const ws = buildWindows(dur, win, ov);
|
|
67
|
-
for (const w of ws) {
|
|
68
|
-
expect(w.end).toBeLessThanOrEqual(dur);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('all windows have positive width', () => {
|
|
74
|
-
const windows = buildWindows(55, 20, 5);
|
|
75
|
-
for (const w of windows) {
|
|
76
|
-
expect(w.end).toBeGreaterThan(w.start);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('treats negative overlapSec as 0', () => {
|
|
81
|
-
const withNeg = buildWindows(60, 20, -10);
|
|
82
|
-
const withZero = buildWindows(60, 20, 0);
|
|
83
|
-
expect(withNeg).toEqual(withZero);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('treats overlapSec >= windowSec as 0 (avoids infinite loop)', () => {
|
|
87
|
-
const windows = buildWindows(60, 20, 20);
|
|
88
|
-
expect(windows).toEqual(buildWindows(60, 20, 0));
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('handles a single-window case (duration <= windowSec)', () => {
|
|
92
|
-
const windows = buildWindows(15, 30);
|
|
93
|
-
expect(windows).toEqual([{ start: 0, end: 15 }]);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { EventDetector } from '../src/services/eventDetector/index.js';
|
|
3
|
-
import type { AudioAnalyzer } from '../src/services/audioAnalyzers/index.js';
|
|
4
|
-
import type { AudioEvent } from '../src/types/index.js';
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Helpers
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
function makeAnalyzer(source: AudioEvent['source'], result: AudioEvent[] | Error): AudioAnalyzer {
|
|
11
|
-
return {
|
|
12
|
-
source,
|
|
13
|
-
detect: vi
|
|
14
|
-
.fn()
|
|
15
|
-
.mockImplementation(() =>
|
|
16
|
-
result instanceof Error ? Promise.reject(result) : Promise.resolve(result),
|
|
17
|
-
),
|
|
18
|
-
} as unknown as AudioAnalyzer;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const EVENT_A: AudioEvent = { time: 10, event: 'gunshot', confidence: 0.9, source: 'gemini' };
|
|
22
|
-
const EVENT_B: AudioEvent = { time: 20, event: 'explosion', confidence: 0.8, source: 'whisper' };
|
|
23
|
-
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
// Tests
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
describe('EventDetector', () => {
|
|
29
|
-
describe('constructor', () => {
|
|
30
|
-
it('throws when chain is empty', () => {
|
|
31
|
-
expect(() => new EventDetector([])).toThrow(
|
|
32
|
-
'EventDetector requires at least one AudioAnalyzer',
|
|
33
|
-
);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe('detect — single analyzer', () => {
|
|
38
|
-
it('returns events from the sole analyzer on success', async () => {
|
|
39
|
-
const analyzer = makeAnalyzer('gemini', [EVENT_A]);
|
|
40
|
-
const detector = new EventDetector([analyzer]);
|
|
41
|
-
const events = await detector.detect('audio.wav', 'general', 0, 120);
|
|
42
|
-
expect(events).toEqual([EVENT_A]);
|
|
43
|
-
expect(analyzer.detect).toHaveBeenCalledOnce();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('re-throws when the sole analyzer fails', async () => {
|
|
47
|
-
const err = new Error('gemini unavailable');
|
|
48
|
-
const analyzer = makeAnalyzer('gemini', err);
|
|
49
|
-
const detector = new EventDetector([analyzer]);
|
|
50
|
-
await expect(detector.detect('audio.wav', 'general', 0, 120)).rejects.toThrow(
|
|
51
|
-
'gemini unavailable',
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe('detect — fallback chain', () => {
|
|
57
|
-
it('returns first-analyzer result without trying the second', async () => {
|
|
58
|
-
const first = makeAnalyzer('gemini', [EVENT_A]);
|
|
59
|
-
const second = makeAnalyzer('whisper', [EVENT_B]);
|
|
60
|
-
const detector = new EventDetector([first, second]);
|
|
61
|
-
const events = await detector.detect('audio.wav', 'general', 0, 120);
|
|
62
|
-
expect(events).toEqual([EVENT_A]);
|
|
63
|
-
expect(second.detect).not.toHaveBeenCalled();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('falls back to second analyzer when first throws', async () => {
|
|
67
|
-
const first = makeAnalyzer('gemini', new Error('timeout'));
|
|
68
|
-
const second = makeAnalyzer('whisper', [EVENT_B]);
|
|
69
|
-
const detector = new EventDetector([first, second]);
|
|
70
|
-
const events = await detector.detect('audio.wav', 'general', 0, 120);
|
|
71
|
-
expect(events).toEqual([EVENT_B]);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('re-throws the last error when the entire chain is exhausted', async () => {
|
|
75
|
-
const err1 = new Error('gemini down');
|
|
76
|
-
const err2 = new Error('whisper crashed');
|
|
77
|
-
const first = makeAnalyzer('gemini', err1);
|
|
78
|
-
const second = makeAnalyzer('whisper', err2);
|
|
79
|
-
const detector = new EventDetector([first, second]);
|
|
80
|
-
await expect(detector.detect('audio.wav', 'general', 0, 120)).rejects.toThrow(
|
|
81
|
-
'whisper crashed',
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('skips failing analyzers and returns results from a later one in a three-item chain', async () => {
|
|
86
|
-
const first = makeAnalyzer('gemini', new Error('fail'));
|
|
87
|
-
const second = makeAnalyzer('whisper', new Error('fail'));
|
|
88
|
-
const third = makeAnalyzer('yamnet', [EVENT_A, EVENT_B]);
|
|
89
|
-
const detector = new EventDetector([first, second, third]);
|
|
90
|
-
const events = await detector.detect('audio.wav', 'fps', 0, 120);
|
|
91
|
-
expect(events).toHaveLength(2);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe('detect — argument forwarding', () => {
|
|
96
|
-
it('passes all arguments through to the analyzer', async () => {
|
|
97
|
-
const analyzer = makeAnalyzer('gemini', []);
|
|
98
|
-
const detector = new EventDetector([analyzer]);
|
|
99
|
-
await detector.detect('/tmp/slice.wav', 'valorant', 60, 120);
|
|
100
|
-
expect(analyzer.detect).toHaveBeenCalledWith('/tmp/slice.wav', 'valorant', 60, 120);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
});
|
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { LLMAnalyzer } from '../src/services/llmAnalyzer/LLMAnalyzer.js';
|
|
3
|
-
import type { TranscriptDetector } from '../src/services/transcriptDetector/index.js';
|
|
4
|
-
import type { Cache } from '../src/utils/cache.js';
|
|
5
|
-
import type {
|
|
6
|
-
TranscriptLine,
|
|
7
|
-
MicroBlock,
|
|
8
|
-
LLMChunk,
|
|
9
|
-
ChunkEvaluation,
|
|
10
|
-
RankedSegment,
|
|
11
|
-
} from '../src/types/index.js';
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Module mocks — must be declared before any imports that touch these modules
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
vi.mock('../src/services/llmAnalyzer/index.js', () => ({
|
|
18
|
-
analyzeChunks: vi.fn(),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
vi.mock('../src/services/clipRefiner/index.js', () => ({
|
|
22
|
-
refineSegments: vi.fn(),
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
|
-
// Import the mocked free functions so we can configure them per test
|
|
26
|
-
import { analyzeChunks } from '../src/services/llmAnalyzer/index.js';
|
|
27
|
-
import { refineSegments } from '../src/services/clipRefiner/index.js';
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Fixtures
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
const LINES: TranscriptLine[] = [
|
|
34
|
-
{ text: 'Hello world', start: 0, duration: 2 },
|
|
35
|
-
{ text: 'Second line', start: 2, duration: 2 },
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
const MICRO_BLOCKS: MicroBlock[] = [{ text: 'Hello world Second line', start: 0, end: 15 }];
|
|
39
|
-
|
|
40
|
-
const CHUNKS: LLMChunk[] = [{ text: 'Hello world Second line', start: 0, end: 120 }];
|
|
41
|
-
|
|
42
|
-
const SUCCESS_EVAL: ChunkEvaluation = {
|
|
43
|
-
status: 'success',
|
|
44
|
-
chunk_index: 0,
|
|
45
|
-
chunk_start: 0,
|
|
46
|
-
chunk_end: 120,
|
|
47
|
-
interesting: true,
|
|
48
|
-
score: 8,
|
|
49
|
-
reason: 'great moment',
|
|
50
|
-
clip_start: 10,
|
|
51
|
-
clip_end: 60,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const RANKED_SEGMENT: RankedSegment = {
|
|
55
|
-
start: 10,
|
|
56
|
-
end: 60,
|
|
57
|
-
score: 8,
|
|
58
|
-
rank: 1,
|
|
59
|
-
reason: 'great moment',
|
|
60
|
-
source: 'transcript',
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
// Stub builders
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
function makeTranscriptDetector(
|
|
68
|
-
result: { lines: TranscriptLine[]; microBlocks: MicroBlock[]; chunks: LLMChunk[] } | Error,
|
|
69
|
-
): TranscriptDetector {
|
|
70
|
-
return {
|
|
71
|
-
detect: vi
|
|
72
|
-
.fn()
|
|
73
|
-
.mockImplementation(() =>
|
|
74
|
-
result instanceof Error ? Promise.reject(result) : Promise.resolve(result),
|
|
75
|
-
),
|
|
76
|
-
} as unknown as TranscriptDetector;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function makeCache(): Cache {
|
|
80
|
-
return {} as unknown as Cache;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Tests
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
describe('LLMAnalyzer', () => {
|
|
88
|
-
beforeEach(() => {
|
|
89
|
-
vi.clearAllMocks();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// ── analyze() ─────────────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
describe('analyze()', () => {
|
|
95
|
-
it('returns lines, microBlocks, chunks, and chunkEvals on success', async () => {
|
|
96
|
-
const detector = makeTranscriptDetector({
|
|
97
|
-
lines: LINES,
|
|
98
|
-
microBlocks: MICRO_BLOCKS,
|
|
99
|
-
chunks: CHUNKS,
|
|
100
|
-
});
|
|
101
|
-
vi.mocked(analyzeChunks).mockResolvedValue([SUCCESS_EVAL]);
|
|
102
|
-
|
|
103
|
-
const analyzer = new LLMAnalyzer(detector, makeCache());
|
|
104
|
-
const result = await analyzer.analyze({
|
|
105
|
-
videoId: 'abc123',
|
|
106
|
-
audioPath: null,
|
|
107
|
-
audioEvents: [],
|
|
108
|
-
maxParallel: 3,
|
|
109
|
-
noCache: true,
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(result.lines).toEqual(LINES);
|
|
113
|
-
expect(result.microBlocks).toEqual(MICRO_BLOCKS);
|
|
114
|
-
expect(result.chunks).toEqual(CHUNKS);
|
|
115
|
-
expect(result.chunkEvals).toEqual([SUCCESS_EVAL]);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('passes videoId, audioPath, and cache to transcriptDetector.detect', async () => {
|
|
119
|
-
const detector = makeTranscriptDetector({
|
|
120
|
-
lines: LINES,
|
|
121
|
-
microBlocks: MICRO_BLOCKS,
|
|
122
|
-
chunks: CHUNKS,
|
|
123
|
-
});
|
|
124
|
-
const cache = makeCache();
|
|
125
|
-
vi.mocked(analyzeChunks).mockResolvedValue([SUCCESS_EVAL]);
|
|
126
|
-
|
|
127
|
-
const analyzer = new LLMAnalyzer(detector, cache);
|
|
128
|
-
await analyzer.analyze({
|
|
129
|
-
videoId: 'vid-xyz',
|
|
130
|
-
audioPath: '/tmp/audio.wav',
|
|
131
|
-
audioEvents: [],
|
|
132
|
-
maxParallel: 2,
|
|
133
|
-
noCache: false,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
expect(detector.detect).toHaveBeenCalledWith('vid-xyz', '/tmp/audio.wav', cache);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('forwards audioEvents and noCache to analyzeChunks', async () => {
|
|
140
|
-
const audioEvents = [
|
|
141
|
-
{ time: 5, event: 'explosion', confidence: 0.9, source: 'gemini' as const },
|
|
142
|
-
];
|
|
143
|
-
const detector = makeTranscriptDetector({
|
|
144
|
-
lines: LINES,
|
|
145
|
-
microBlocks: MICRO_BLOCKS,
|
|
146
|
-
chunks: CHUNKS,
|
|
147
|
-
});
|
|
148
|
-
vi.mocked(analyzeChunks).mockResolvedValue([SUCCESS_EVAL]);
|
|
149
|
-
|
|
150
|
-
const analyzer = new LLMAnalyzer(detector, makeCache());
|
|
151
|
-
await analyzer.analyze({
|
|
152
|
-
videoId: 'abc123',
|
|
153
|
-
audioPath: null,
|
|
154
|
-
audioEvents,
|
|
155
|
-
maxParallel: 1,
|
|
156
|
-
noCache: true,
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
expect(analyzeChunks).toHaveBeenCalledWith(
|
|
160
|
-
CHUNKS,
|
|
161
|
-
LINES,
|
|
162
|
-
audioEvents,
|
|
163
|
-
1, // maxParallel
|
|
164
|
-
true, // noCache
|
|
165
|
-
);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('limits chunks to maxChunks when provided', async () => {
|
|
169
|
-
const manyChunks: LLMChunk[] = Array.from({ length: 5 }, (_, i) => ({
|
|
170
|
-
text: `chunk ${i}`,
|
|
171
|
-
start: i * 120,
|
|
172
|
-
end: (i + 1) * 120,
|
|
173
|
-
}));
|
|
174
|
-
const detector = makeTranscriptDetector({
|
|
175
|
-
lines: LINES,
|
|
176
|
-
microBlocks: MICRO_BLOCKS,
|
|
177
|
-
chunks: manyChunks,
|
|
178
|
-
});
|
|
179
|
-
vi.mocked(analyzeChunks).mockResolvedValue([SUCCESS_EVAL]);
|
|
180
|
-
|
|
181
|
-
const analyzer = new LLMAnalyzer(detector, makeCache());
|
|
182
|
-
await analyzer.analyze({
|
|
183
|
-
videoId: 'abc123',
|
|
184
|
-
audioPath: null,
|
|
185
|
-
audioEvents: [],
|
|
186
|
-
maxChunks: 2,
|
|
187
|
-
maxParallel: 3,
|
|
188
|
-
noCache: true,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const calledWith = vi.mocked(analyzeChunks).mock.calls[0][0] as LLMChunk[];
|
|
192
|
-
expect(calledWith).toHaveLength(2);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('throws when all chunks fail (zero succeeded)', async () => {
|
|
196
|
-
const failedEval: ChunkEvaluation = {
|
|
197
|
-
status: 'failed',
|
|
198
|
-
chunk_index: 0,
|
|
199
|
-
chunk_start: 0,
|
|
200
|
-
chunk_end: 120,
|
|
201
|
-
error: 'LLM error',
|
|
202
|
-
};
|
|
203
|
-
const detector = makeTranscriptDetector({
|
|
204
|
-
lines: LINES,
|
|
205
|
-
microBlocks: MICRO_BLOCKS,
|
|
206
|
-
chunks: CHUNKS,
|
|
207
|
-
});
|
|
208
|
-
vi.mocked(analyzeChunks).mockResolvedValue([failedEval]);
|
|
209
|
-
|
|
210
|
-
const analyzer = new LLMAnalyzer(detector, makeCache());
|
|
211
|
-
|
|
212
|
-
await expect(
|
|
213
|
-
analyzer.analyze({
|
|
214
|
-
videoId: 'abc123',
|
|
215
|
-
audioPath: null,
|
|
216
|
-
audioEvents: [],
|
|
217
|
-
maxParallel: 1,
|
|
218
|
-
noCache: true,
|
|
219
|
-
}),
|
|
220
|
-
).rejects.toThrow('All chunks failed LLM analysis');
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('propagates errors thrown by transcriptDetector.detect', async () => {
|
|
224
|
-
const detector = makeTranscriptDetector(new Error('no transcript available'));
|
|
225
|
-
|
|
226
|
-
const analyzer = new LLMAnalyzer(detector, makeCache());
|
|
227
|
-
|
|
228
|
-
await expect(
|
|
229
|
-
analyzer.analyze({
|
|
230
|
-
videoId: 'abc123',
|
|
231
|
-
audioPath: null,
|
|
232
|
-
audioEvents: [],
|
|
233
|
-
maxParallel: 1,
|
|
234
|
-
noCache: true,
|
|
235
|
-
}),
|
|
236
|
-
).rejects.toThrow('no transcript available');
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// ── refine() ──────────────────────────────────────────────────────────────
|
|
241
|
-
|
|
242
|
-
describe('refine()', () => {
|
|
243
|
-
it('delegates to refineSegments and returns its result', async () => {
|
|
244
|
-
const refined: RankedSegment = { ...RANKED_SEGMENT, start: 12, end: 58 };
|
|
245
|
-
vi.mocked(refineSegments).mockResolvedValue([refined]);
|
|
246
|
-
|
|
247
|
-
const analyzer = new LLMAnalyzer(
|
|
248
|
-
makeTranscriptDetector({ lines: LINES, microBlocks: MICRO_BLOCKS, chunks: CHUNKS }),
|
|
249
|
-
makeCache(),
|
|
250
|
-
);
|
|
251
|
-
const result = await analyzer.refine([RANKED_SEGMENT], MICRO_BLOCKS, {
|
|
252
|
-
maxParallel: 2,
|
|
253
|
-
noCache: false,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
expect(result).toEqual([refined]);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('passes segments, microBlocks, maxParallel and noCache to refineSegments', async () => {
|
|
260
|
-
vi.mocked(refineSegments).mockResolvedValue([RANKED_SEGMENT]);
|
|
261
|
-
|
|
262
|
-
const analyzer = new LLMAnalyzer(
|
|
263
|
-
makeTranscriptDetector({ lines: LINES, microBlocks: MICRO_BLOCKS, chunks: CHUNKS }),
|
|
264
|
-
makeCache(),
|
|
265
|
-
);
|
|
266
|
-
await analyzer.refine([RANKED_SEGMENT], MICRO_BLOCKS, { maxParallel: 4, noCache: true });
|
|
267
|
-
|
|
268
|
-
expect(refineSegments).toHaveBeenCalledWith([RANKED_SEGMENT], MICRO_BLOCKS, 4, true);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('returns an empty array when passed no segments', async () => {
|
|
272
|
-
vi.mocked(refineSegments).mockResolvedValue([]);
|
|
273
|
-
|
|
274
|
-
const analyzer = new LLMAnalyzer(
|
|
275
|
-
makeTranscriptDetector({ lines: LINES, microBlocks: MICRO_BLOCKS, chunks: CHUNKS }),
|
|
276
|
-
makeCache(),
|
|
277
|
-
);
|
|
278
|
-
const result = await analyzer.refine([], MICRO_BLOCKS, { maxParallel: 1, noCache: true });
|
|
279
|
-
|
|
280
|
-
expect(result).toEqual([]);
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
});
|