@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,210 +0,0 @@
|
|
|
1
|
-
import ffmpeg from 'fluent-ffmpeg';
|
|
2
|
-
import { promises as fs } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import pLimit from 'p-limit';
|
|
5
|
-
import { config } from '../../config/index.js';
|
|
6
|
-
import { log } from '../../utils/logger.js';
|
|
7
|
-
import { formatSeconds } from '../../utils/format.js';
|
|
8
|
-
import type { RankedSegment } from '../../types/index.js';
|
|
9
|
-
|
|
10
|
-
if (config.FFMPEG_PATH) {
|
|
11
|
-
ffmpeg.setFfmpegPath(config.FFMPEG_PATH);
|
|
12
|
-
}
|
|
13
|
-
if (config.FFPROBE_PATH) {
|
|
14
|
-
ffmpeg.setFfprobePath(config.FFPROBE_PATH);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Cuts a single clip from a video file using fluent-ffmpeg.
|
|
19
|
-
* Re-encodes with libx264 (video) and aac (audio) for perfect audio/video sync.
|
|
20
|
-
*
|
|
21
|
-
* @returns The output file path on success
|
|
22
|
-
* @throws {Error} if ffmpeg fails
|
|
23
|
-
*/
|
|
24
|
-
function cutClip(
|
|
25
|
-
videoPath: string,
|
|
26
|
-
start: number,
|
|
27
|
-
end: number,
|
|
28
|
-
outputPath: string,
|
|
29
|
-
): Promise<string> {
|
|
30
|
-
const adjustedStart = Math.max(0, start + config.TIMESTAMP_OFFSET_SECONDS);
|
|
31
|
-
const adjustedEnd = Math.max(adjustedStart + 1, end + config.TIMESTAMP_OFFSET_SECONDS);
|
|
32
|
-
const duration = adjustedEnd - adjustedStart;
|
|
33
|
-
|
|
34
|
-
log.info(
|
|
35
|
-
`Cutting clip: start=${adjustedStart.toFixed(2)}s, end=${adjustedEnd.toFixed(2)}s, duration=${duration.toFixed(2)}s`,
|
|
36
|
-
);
|
|
37
|
-
if (config.TIMESTAMP_OFFSET_SECONDS !== 0) {
|
|
38
|
-
log.info(` Timestamp offset applied: ${config.TIMESTAMP_OFFSET_SECONDS}s`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
ffmpeg(videoPath)
|
|
43
|
-
.setStartTime(adjustedStart)
|
|
44
|
-
.setDuration(duration)
|
|
45
|
-
.outputOptions('-c:v', 'libx264')
|
|
46
|
-
.outputOptions('-preset', config.FFMPEG_PRESET)
|
|
47
|
-
.outputOptions('-c:a', 'aac')
|
|
48
|
-
.output(outputPath)
|
|
49
|
-
.on('end', () => resolve(outputPath))
|
|
50
|
-
.on('error', (err: Error) => reject(err))
|
|
51
|
-
.run();
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Copies a pre-downloaded segment file to the output directory.
|
|
57
|
-
* Used when videos are downloaded via --download-sections segments mode.
|
|
58
|
-
*/
|
|
59
|
-
async function copySegment(
|
|
60
|
-
sourcePath: string,
|
|
61
|
-
outputPath: string,
|
|
62
|
-
customPath?: string,
|
|
63
|
-
): Promise<string> {
|
|
64
|
-
const outputDir = customPath || config.OUTPUT_DIR;
|
|
65
|
-
const finalOutputPath = join(outputDir, outputPath.split('/').pop() || '');
|
|
66
|
-
await fs.copyFile(sourcePath, finalOutputPath);
|
|
67
|
-
return finalOutputPath;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Generates video clips for each ranked segment using fluent-ffmpeg.
|
|
72
|
-
*
|
|
73
|
-
* - Auto-creates the output directory if it doesn't exist.
|
|
74
|
-
* - Runs clips with controlled concurrency via p-limit.
|
|
75
|
-
* - Logs a warning per failed clip; never aborts the entire run.
|
|
76
|
-
* - Re-encodes with libx264/aac for accurate audio/video sync.
|
|
77
|
-
*
|
|
78
|
-
* @param videoPath - Local path to the downloaded mp4
|
|
79
|
-
* @param segments - Ranked segments to cut
|
|
80
|
-
* @param videoId - Used to name output files
|
|
81
|
-
* @param customPath - Custom output directory (optional, overrides OUTPUT_DIR)
|
|
82
|
-
* @param concurrency - Maximum number of parallel clip operations (default: 1)
|
|
83
|
-
* @returns Array of successfully written clip file paths
|
|
84
|
-
* @throws {Error} if ffmpeg is not installed
|
|
85
|
-
*/
|
|
86
|
-
export async function generateClips(
|
|
87
|
-
videoPath: string,
|
|
88
|
-
segments: RankedSegment[],
|
|
89
|
-
videoId: string,
|
|
90
|
-
customPath?: string,
|
|
91
|
-
concurrency: number = 1,
|
|
92
|
-
): Promise<string[]> {
|
|
93
|
-
const outputDir = customPath || config.OUTPUT_DIR;
|
|
94
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
95
|
-
|
|
96
|
-
if (segments.length === 0) {
|
|
97
|
-
log.warn('No segments provided to generateClips — nothing to cut.');
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const limit = pLimit(concurrency);
|
|
102
|
-
log.info(
|
|
103
|
-
`Generating ${segments.length} clip${segments.length !== 1 ? 's' : ''} from local video (max ${concurrency} parallel)...`,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
const jobs = segments.map((segment, i) =>
|
|
107
|
-
limit(async () => {
|
|
108
|
-
const startInt = Math.floor(segment.start);
|
|
109
|
-
const endInt = Math.ceil(segment.end);
|
|
110
|
-
const outputPath = join(outputDir, `${videoId}_${startInt}_${endInt}.mp4`);
|
|
111
|
-
|
|
112
|
-
log.info(
|
|
113
|
-
`Cutting clip: ${outputPath} (${formatSeconds(startInt)} – ${formatSeconds(endInt)})`,
|
|
114
|
-
);
|
|
115
|
-
return cutClip(videoPath, segment.start, segment.end, outputPath);
|
|
116
|
-
}),
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
const results = await Promise.allSettled(jobs);
|
|
120
|
-
|
|
121
|
-
const paths: string[] = [];
|
|
122
|
-
for (let i = 0; i < results.length; i++) {
|
|
123
|
-
const result = results[i];
|
|
124
|
-
const segment = segments[i];
|
|
125
|
-
if (result.status === 'fulfilled') {
|
|
126
|
-
log.info(`Clip ready: ${result.value}`);
|
|
127
|
-
paths.push(result.value);
|
|
128
|
-
} else {
|
|
129
|
-
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
130
|
-
log.warn(
|
|
131
|
-
`Failed to cut clip for segment [${formatSeconds(segment.start)} – ${formatSeconds(segment.end)}] (rank ${segment.rank}): ${reason}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return paths;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Organizes pre-downloaded segment files from downloads/ to outputs/.
|
|
141
|
-
* Used when videos are downloaded via --download-sections segments mode.
|
|
142
|
-
*
|
|
143
|
-
* @param sourcePaths - Paths to the pre-downloaded segment files in downloads/
|
|
144
|
-
* @param videoId - Used to verify file naming
|
|
145
|
-
* @param customPath - Custom output directory (optional, overrides OUTPUT_DIR)
|
|
146
|
-
* @param concurrency - Maximum number of parallel copy operations (default: 1)
|
|
147
|
-
* @returns Array of organized clip file paths in outputs/
|
|
148
|
-
*/
|
|
149
|
-
export async function organizeClips(
|
|
150
|
-
sourcePaths: string[],
|
|
151
|
-
videoId: string,
|
|
152
|
-
customPath?: string,
|
|
153
|
-
concurrency: number = 1,
|
|
154
|
-
): Promise<string[]> {
|
|
155
|
-
if (sourcePaths.length === 0) {
|
|
156
|
-
log.warn('No pre-downloaded segments to organize.');
|
|
157
|
-
return [];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const outputDir = customPath || config.OUTPUT_DIR;
|
|
161
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
162
|
-
|
|
163
|
-
const limit = pLimit(concurrency);
|
|
164
|
-
log.info(
|
|
165
|
-
`Organizing ${sourcePaths.length} clip${sourcePaths.length !== 1 ? 's' : ''} (max ${concurrency} parallel)...`,
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
const jobs = sourcePaths.map((sourcePath) =>
|
|
169
|
-
limit(async () => {
|
|
170
|
-
const filename = sourcePath.split('/').pop() || '';
|
|
171
|
-
const outputPath = join(outputDir, filename);
|
|
172
|
-
|
|
173
|
-
log.info(`Organizing clip: ${outputPath}`);
|
|
174
|
-
return copySegment(sourcePath, outputPath, customPath);
|
|
175
|
-
}),
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
const results = await Promise.allSettled(jobs);
|
|
179
|
-
|
|
180
|
-
const paths: string[] = [];
|
|
181
|
-
for (let i = 0; i < results.length; i++) {
|
|
182
|
-
const result = results[i];
|
|
183
|
-
if (result.status === 'fulfilled') {
|
|
184
|
-
log.info(`Clip ready: ${result.value}`);
|
|
185
|
-
paths.push(result.value);
|
|
186
|
-
} else {
|
|
187
|
-
const sourcePath = sourcePaths[i];
|
|
188
|
-
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
189
|
-
log.warn(`Failed to organize clip ${sourcePath}: ${reason}`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return paths;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Probes ffmpeg availability by running `ffmpeg -version`.
|
|
198
|
-
* @throws {Error} with an actionable install message if ffmpeg is not found
|
|
199
|
-
*/
|
|
200
|
-
async function verifyFfmpeg(): Promise<void> {
|
|
201
|
-
await new Promise<void>((resolve, reject) => {
|
|
202
|
-
ffmpeg.getAvailableFormats((err) => {
|
|
203
|
-
if (err) {
|
|
204
|
-
reject(new Error('ffmpeg is required for clip generation. Install it first.'));
|
|
205
|
-
} else {
|
|
206
|
-
resolve();
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { generateObject } from 'ai';
|
|
2
|
-
import pLimit from 'p-limit';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { config } from '../../config/index.js';
|
|
5
|
-
import { log } from '../../utils/logger.js';
|
|
6
|
-
import { getModel } from '../../utils/modelFactory.js';
|
|
7
|
-
import { Cache } from '../../utils/cache.js';
|
|
8
|
-
import type { RankedSegment, MicroBlock } from '../../types/index.js';
|
|
9
|
-
|
|
10
|
-
const CONTEXT_PADDING_SEC = 30;
|
|
11
|
-
|
|
12
|
-
const RefinedBoundariesSchema = z.object({
|
|
13
|
-
clip_start: z.number().describe('Refined clip start time in seconds'),
|
|
14
|
-
clip_end: z.number().describe('Refined clip end time in seconds'),
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Extracts the micro-block text that falls within a context window
|
|
19
|
-
* around the segment, padded by CONTEXT_PADDING_SEC on each side.
|
|
20
|
-
*/
|
|
21
|
-
function buildContextText(
|
|
22
|
-
segment: RankedSegment,
|
|
23
|
-
allBlocks: MicroBlock[],
|
|
24
|
-
): { text: string; windowStart: number; windowEnd: number } {
|
|
25
|
-
const windowStart = Math.max(0, segment.start - CONTEXT_PADDING_SEC);
|
|
26
|
-
const windowEnd = segment.end + CONTEXT_PADDING_SEC;
|
|
27
|
-
|
|
28
|
-
const contextBlocks = allBlocks.filter((b) => b.end > windowStart && b.start < windowEnd);
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
text: contextBlocks.map((b) => b.text).join(' '),
|
|
32
|
-
windowStart,
|
|
33
|
-
windowEnd,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Builds the refinement prompt for a single ranked segment.
|
|
39
|
-
*/
|
|
40
|
-
function buildPrompt(
|
|
41
|
-
segment: RankedSegment,
|
|
42
|
-
contextText: string,
|
|
43
|
-
windowStart: number,
|
|
44
|
-
windowEnd: number,
|
|
45
|
-
): string {
|
|
46
|
-
return `You are a video editor refining clip boundaries.
|
|
47
|
-
|
|
48
|
-
Goal: tighten the clip so it starts just before the interesting moment begins
|
|
49
|
-
and ends just after it concludes, giving it a natural entry and exit point.
|
|
50
|
-
Avoid cutting in the middle of a sentence.
|
|
51
|
-
|
|
52
|
-
Current clip:
|
|
53
|
-
START: ${segment.start}s
|
|
54
|
-
END: ${segment.end}s
|
|
55
|
-
REASON: ${segment.reason}
|
|
56
|
-
|
|
57
|
-
Broader transcript context (${windowStart}s – ${windowEnd}s):
|
|
58
|
-
${contextText}
|
|
59
|
-
|
|
60
|
-
Rules:
|
|
61
|
-
- clip_start must be >= ${windowStart}
|
|
62
|
-
- clip_end must be <= ${windowEnd}
|
|
63
|
-
- clip_start must be less than clip_end
|
|
64
|
-
- Only make small adjustments (seconds, not minutes)`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Refines the boundaries of a single ranked segment via a second LLM pass.
|
|
69
|
-
* Returns the segment with updated start/end if successful.
|
|
70
|
-
* Falls back to the original boundaries on failure.
|
|
71
|
-
*/
|
|
72
|
-
async function refineSegment(
|
|
73
|
-
segment: RankedSegment,
|
|
74
|
-
allBlocks: MicroBlock[],
|
|
75
|
-
noCache: boolean,
|
|
76
|
-
): Promise<RankedSegment> {
|
|
77
|
-
const cache = new Cache(config.CACHE_DIR);
|
|
78
|
-
if (!noCache) {
|
|
79
|
-
const cached = await cache.readSegmentRefinement(segment.start, segment.end, segment.reason);
|
|
80
|
-
if (cached) {
|
|
81
|
-
log.info(`[segment] cache hit (rank=${segment.rank})`);
|
|
82
|
-
return { ...segment, start: cached.refined_start, end: cached.refined_end };
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const { text, windowStart, windowEnd } = buildContextText(segment, allBlocks);
|
|
87
|
-
|
|
88
|
-
const { object } = await generateObject({
|
|
89
|
-
model: getModel(),
|
|
90
|
-
schema: RefinedBoundariesSchema,
|
|
91
|
-
prompt: buildPrompt(segment, text, windowStart, windowEnd),
|
|
92
|
-
maxRetries: config.LLM_MAX_RETRIES,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
/** Clamp to context window to prevent LLM from hallucinating out-of-range values */
|
|
96
|
-
const refinedStart = Math.max(windowStart, Math.min(object.clip_start, object.clip_end - 1));
|
|
97
|
-
const refinedEnd = Math.min(windowEnd, Math.max(object.clip_end, object.clip_start + 1));
|
|
98
|
-
|
|
99
|
-
if (!noCache) {
|
|
100
|
-
await cache.writeSegmentRefinement(segment.start, segment.end, segment.reason, {
|
|
101
|
-
refined_start: refinedStart,
|
|
102
|
-
refined_end: refinedEnd,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return { ...segment, start: refinedStart, end: refinedEnd };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Runs a second LLM pass on all ranked segments in parallel to tighten clip boundaries.
|
|
111
|
-
* Segments that fail refinement retain their original boundaries.
|
|
112
|
-
*
|
|
113
|
-
* @returns RankedSegment[] with refined (or original) start/end values
|
|
114
|
-
*/
|
|
115
|
-
export async function refineSegments(
|
|
116
|
-
segments: RankedSegment[],
|
|
117
|
-
allBlocks: MicroBlock[],
|
|
118
|
-
concurrency: number,
|
|
119
|
-
noCache = false,
|
|
120
|
-
): Promise<RankedSegment[]> {
|
|
121
|
-
log.info(
|
|
122
|
-
`Refining boundaries for ${segments.length} segment${segments.length !== 1 ? 's' : ''} (max ${concurrency} parallel)...`,
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const limit = pLimit(concurrency);
|
|
126
|
-
const results = await Promise.allSettled(
|
|
127
|
-
segments.map((segment) => limit(() => refineSegment(segment, allBlocks, noCache))),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const refined = results.map((result, i) => {
|
|
131
|
-
if (result.status === 'fulfilled') {
|
|
132
|
-
return result.value;
|
|
133
|
-
}
|
|
134
|
-
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
135
|
-
log.warn(`[segment rank=${segments[i].rank}] refinement skipped: ${reason}`);
|
|
136
|
-
return segments[i];
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
log.info(`Refinement complete`);
|
|
140
|
-
return refined;
|
|
141
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { log } from '../../utils/logger.js';
|
|
2
|
-
import type { AudioAnalyzer } from '../audioAnalyzers/index.js';
|
|
3
|
-
import type { AudioEvent } from '../../types/index.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Top-level audio event detector.
|
|
7
|
-
*
|
|
8
|
-
* Holds an ordered chain of AudioAnalyzer instances and walks the chain on each
|
|
9
|
-
* `detect()` call: the first analyzer that succeeds wins. If an analyzer throws,
|
|
10
|
-
* the error is logged and the next analyzer in the chain is tried. If the entire
|
|
11
|
-
* chain is exhausted without success the error from the last analyzer is re-thrown.
|
|
12
|
-
*
|
|
13
|
-
* The chain is built once at startup via `createAnalyzerChain(config.AUDIO_PROVIDER)`
|
|
14
|
-
* and injected here, keeping provider-selection logic out of this class.
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* const chain = createAnalyzerChain('gemini,whisper');
|
|
18
|
-
* const detector = new EventDetector(chain);
|
|
19
|
-
* const events = await detector.detect(audioPath, 'valorant', 0, 120);
|
|
20
|
-
*/
|
|
21
|
-
export class EventDetector {
|
|
22
|
-
constructor(private readonly chain: AudioAnalyzer[]) {
|
|
23
|
-
if (chain.length === 0) {
|
|
24
|
-
throw new Error('EventDetector requires at least one AudioAnalyzer in the chain.');
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Detect audio events by walking the analyzer chain in order.
|
|
30
|
-
* Falls back to the next analyzer whenever one throws.
|
|
31
|
-
*/
|
|
32
|
-
async detect(
|
|
33
|
-
audioPath: string,
|
|
34
|
-
gameProfile: string,
|
|
35
|
-
chunkOffsetSec: number,
|
|
36
|
-
chunkDurationSec: number,
|
|
37
|
-
): Promise<AudioEvent[]> {
|
|
38
|
-
let lastError: unknown;
|
|
39
|
-
|
|
40
|
-
for (let i = 0; i < this.chain.length; i++) {
|
|
41
|
-
const analyzer = this.chain[i];
|
|
42
|
-
const isLast = i === this.chain.length - 1;
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
const events = await analyzer.detect(
|
|
46
|
-
audioPath,
|
|
47
|
-
gameProfile,
|
|
48
|
-
chunkOffsetSec,
|
|
49
|
-
chunkDurationSec,
|
|
50
|
-
);
|
|
51
|
-
log.info(`[audio:${analyzer.source}] detected ${events.length} events`);
|
|
52
|
-
return events;
|
|
53
|
-
} catch (err) {
|
|
54
|
-
lastError = err;
|
|
55
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
56
|
-
|
|
57
|
-
if (!isLast) {
|
|
58
|
-
const nextSource = this.chain[i + 1].source;
|
|
59
|
-
log.warn(`[audio:${analyzer.source}] failed, falling back to ${nextSource}: ${message}`);
|
|
60
|
-
} else {
|
|
61
|
-
log.error(`[audio:${analyzer.source}] failed (no more fallbacks): ${message}`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
throw lastError;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { analyzeChunks } from './index.js';
|
|
2
|
-
import { refineSegments } from '../clipRefiner/index.js';
|
|
3
|
-
import { log } from '../../utils/logger.js';
|
|
4
|
-
import { config } from '../../config/index.js';
|
|
5
|
-
import type { TranscriptDetector } from '../transcriptDetector/index.js';
|
|
6
|
-
import type { Cache } from '../../utils/cache.js';
|
|
7
|
-
import type {
|
|
8
|
-
TranscriptLine,
|
|
9
|
-
MicroBlock,
|
|
10
|
-
LLMChunk,
|
|
11
|
-
AudioEvent,
|
|
12
|
-
ChunkEvaluation,
|
|
13
|
-
RankedSegment,
|
|
14
|
-
LLMAnalyzerResult,
|
|
15
|
-
LLMAnalyzerOpts,
|
|
16
|
-
} from '../../types/index.js';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* LLMAnalyzer — orchestrates transcript fetching + LLM-based segment analysis.
|
|
20
|
-
*
|
|
21
|
-
* Owns a TranscriptDetector (which encapsulates the provider chain) and the
|
|
22
|
-
* Cache. Audio events are provided externally — they are pre-computed by the
|
|
23
|
-
* AudioProcessor stage so that the full per-chunk caching logic stays in
|
|
24
|
-
* audioProcessor.ts and is not duplicated here.
|
|
25
|
-
*
|
|
26
|
-
* LLM pass 1 (`analyze`) — fetches transcript, runs chunk analysis.
|
|
27
|
-
* LLM pass 2 (`refine`) — tightens clip boundaries on ranked segments.
|
|
28
|
-
*
|
|
29
|
-
* The free functions in `llmAnalyzer/index.ts` and `clipRefiner/index.ts` are
|
|
30
|
-
* NOT modified — this class wraps them.
|
|
31
|
-
*
|
|
32
|
-
* @example
|
|
33
|
-
* const analyzer = new LLMAnalyzer(transcriptDetector, cache);
|
|
34
|
-
* const result = await analyzer.analyze({ videoId, audioPath, audioEvents, ... });
|
|
35
|
-
* // ... ranking step ...
|
|
36
|
-
* const refined = await analyzer.refine(rankedSegments, result.microBlocks, opts);
|
|
37
|
-
*/
|
|
38
|
-
export class LLMAnalyzer {
|
|
39
|
-
constructor(
|
|
40
|
-
private readonly transcriptDetector: TranscriptDetector,
|
|
41
|
-
private readonly cache: Cache,
|
|
42
|
-
) {}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* LLM pass 1 — fetch transcript then run chunk analysis.
|
|
46
|
-
*
|
|
47
|
-
* Returns lines, microBlocks, chunks, and chunkEvals so the caller has
|
|
48
|
-
* everything needed for the ranking step.
|
|
49
|
-
*/
|
|
50
|
-
async analyze(opts: LLMAnalyzerOpts): Promise<LLMAnalyzerResult> {
|
|
51
|
-
const { lines, microBlocks, chunks } = await this.transcriptDetector.detect(
|
|
52
|
-
opts.videoId,
|
|
53
|
-
opts.audioPath,
|
|
54
|
-
this.cache,
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const chunkLimit = opts.maxChunks ?? config.MAX_CHUNKS;
|
|
58
|
-
const chunksToAnalyze = chunkLimit !== undefined ? chunks.slice(0, chunkLimit) : chunks;
|
|
59
|
-
|
|
60
|
-
if (chunkLimit !== undefined) {
|
|
61
|
-
log.info(
|
|
62
|
-
`Limiting evaluation to ${chunksToAnalyze.length} of ${chunks.length} chunks (--max-chunks ${chunkLimit})`,
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
log.info(
|
|
67
|
-
`Analyzing chunks with ${config.LLM_MODEL} (${chunksToAnalyze.length} chunks, max ${opts.maxParallel} parallel)...`,
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
const chunkEvals = await analyzeChunks(
|
|
71
|
-
chunksToAnalyze,
|
|
72
|
-
lines,
|
|
73
|
-
opts.audioEvents,
|
|
74
|
-
opts.maxParallel,
|
|
75
|
-
opts.noCache,
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
const succeededCount = chunkEvals.filter((e) => e.status === 'success').length;
|
|
79
|
-
if (succeededCount === 0) {
|
|
80
|
-
throw new Error('All chunks failed LLM analysis. Check your API key and model config.');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return { lines, microBlocks, chunks, chunkEvals };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* LLM pass 2 — tighten clip boundaries on already-ranked segments.
|
|
88
|
-
* Must be called after ranking, since it takes `RankedSegment[]` as input.
|
|
89
|
-
*/
|
|
90
|
-
async refine(
|
|
91
|
-
rankedSegments: RankedSegment[],
|
|
92
|
-
microBlocks: MicroBlock[],
|
|
93
|
-
opts: Pick<LLMAnalyzerOpts, 'maxParallel' | 'noCache'>,
|
|
94
|
-
): Promise<RankedSegment[]> {
|
|
95
|
-
log.info('Refining clip boundaries...');
|
|
96
|
-
return refineSegments(rankedSegments, microBlocks, opts.maxParallel, opts.noCache);
|
|
97
|
-
}
|
|
98
|
-
}
|