@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.
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +100 -0
- package/LICENSE +15 -0
- package/commitlint.config.js +25 -0
- package/package.json +3 -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/cli.ts
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { config } from './config/index.js';
|
|
2
|
-
import { log } from './utils/logger.js';
|
|
3
|
-
import type { CliArgs } from './types/index.js';
|
|
4
|
-
|
|
5
|
-
export type { CliArgs };
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Argument parser
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
export function parseArgs(argv: string[]): CliArgs {
|
|
12
|
-
const args = argv.slice(2);
|
|
13
|
-
const result: CliArgs = {
|
|
14
|
-
url: undefined,
|
|
15
|
-
clip: false,
|
|
16
|
-
downloadSections: undefined,
|
|
17
|
-
videoPath: undefined,
|
|
18
|
-
threshold: undefined,
|
|
19
|
-
topN: undefined,
|
|
20
|
-
maxDuration: undefined,
|
|
21
|
-
maxChunks: undefined,
|
|
22
|
-
maxParallel: undefined,
|
|
23
|
-
outputJson: undefined,
|
|
24
|
-
noCache: false,
|
|
25
|
-
noAudio: false,
|
|
26
|
-
gameProfile: undefined,
|
|
27
|
-
help: false,
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
for (let i = 0; i < args.length; i++) {
|
|
31
|
-
const arg = args[i];
|
|
32
|
-
|
|
33
|
-
if (arg === '--help' || arg === '-h') {
|
|
34
|
-
result.help = true;
|
|
35
|
-
} else if (arg === '--clip') {
|
|
36
|
-
result.clip = true;
|
|
37
|
-
} else if (arg === '--download-sections') {
|
|
38
|
-
const val = args[++i];
|
|
39
|
-
if (!val) {
|
|
40
|
-
log.error(`--download-sections requires a value: 'all' or a number (1, 2, 3, ...)`);
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (val === 'all') {
|
|
45
|
-
result.downloadSections = 'all';
|
|
46
|
-
} else if (val === 'segments') {
|
|
47
|
-
log.warn(
|
|
48
|
-
`--download-sections segments is deprecated. Use a number like --download-sections 5 to download top 5 segments, or --download-sections all for full video.`,
|
|
49
|
-
);
|
|
50
|
-
result.downloadSections = 'all';
|
|
51
|
-
} else {
|
|
52
|
-
const num = Number(val);
|
|
53
|
-
if (isNaN(num) || !Number.isInteger(num) || num < 1) {
|
|
54
|
-
log.error(`--download-sections requires 'all' or a positive integer (1, 2, 3, ...)`);
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
result.downloadSections = num;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
result.clip = true;
|
|
61
|
-
} else if (arg === '--video-path') {
|
|
62
|
-
const val = args[++i];
|
|
63
|
-
if (!val) {
|
|
64
|
-
log.error(`--video-path requires a directory path`);
|
|
65
|
-
process.exit(1);
|
|
66
|
-
}
|
|
67
|
-
result.videoPath = val;
|
|
68
|
-
} else if (arg === '--local-video') {
|
|
69
|
-
const val = args[++i];
|
|
70
|
-
if (!val) {
|
|
71
|
-
log.error(`--local-video requires a file path`);
|
|
72
|
-
process.exit(1);
|
|
73
|
-
}
|
|
74
|
-
result.localVideo = val;
|
|
75
|
-
result.clip = true;
|
|
76
|
-
} else if (arg === '--no-cache') {
|
|
77
|
-
result.noCache = true;
|
|
78
|
-
} else if (arg === '--threshold') {
|
|
79
|
-
const val = Number(args[++i]);
|
|
80
|
-
if (isNaN(val)) {
|
|
81
|
-
log.error(`--threshold requires a numeric value`);
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
result.threshold = val;
|
|
85
|
-
} else if (arg === '--top-n') {
|
|
86
|
-
const val = Number(args[++i]);
|
|
87
|
-
if (isNaN(val)) {
|
|
88
|
-
log.error(`--top-n requires a numeric value`);
|
|
89
|
-
process.exit(1);
|
|
90
|
-
}
|
|
91
|
-
result.topN = val;
|
|
92
|
-
} else if (arg === '--max-duration') {
|
|
93
|
-
const val = Number(args[++i]);
|
|
94
|
-
if (isNaN(val)) {
|
|
95
|
-
log.error(`--max-duration requires a numeric value`);
|
|
96
|
-
process.exit(1);
|
|
97
|
-
}
|
|
98
|
-
result.maxDuration = val;
|
|
99
|
-
} else if (arg === '--max-chunks') {
|
|
100
|
-
const val = Number(args[++i]);
|
|
101
|
-
if (isNaN(val) || !Number.isInteger(val) || val < 1) {
|
|
102
|
-
log.error(`--max-chunks requires a positive integer`);
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
105
|
-
result.maxChunks = val;
|
|
106
|
-
} else if (arg === '--max-parallel') {
|
|
107
|
-
const val = Number(args[++i]);
|
|
108
|
-
if (isNaN(val) || !Number.isInteger(val) || val < 1) {
|
|
109
|
-
log.error(`--max-parallel requires a positive integer`);
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
result.maxParallel = val;
|
|
113
|
-
} else if (arg === '--no-audio') {
|
|
114
|
-
result.noAudio = true;
|
|
115
|
-
} else if (arg === '--game-profile') {
|
|
116
|
-
const val = args[++i];
|
|
117
|
-
if (!val) {
|
|
118
|
-
log.error(`--game-profile requires a value (valorant, fps, boss_fight, general)`);
|
|
119
|
-
process.exit(1);
|
|
120
|
-
}
|
|
121
|
-
result.gameProfile = val;
|
|
122
|
-
} else if (arg === '--output-json') {
|
|
123
|
-
result.outputJson = args[++i];
|
|
124
|
-
if (!result.outputJson) {
|
|
125
|
-
log.error(`--output-json requires a file path`);
|
|
126
|
-
process.exit(1);
|
|
127
|
-
}
|
|
128
|
-
} else if (!arg.startsWith('--')) {
|
|
129
|
-
result.url = arg;
|
|
130
|
-
} else {
|
|
131
|
-
log.error(`Unknown flag: ${arg}`);
|
|
132
|
-
printUsage();
|
|
133
|
-
process.exit(1);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return result;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
// Usage text
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
export function printUsage(): void {
|
|
145
|
-
console.log(
|
|
146
|
-
`
|
|
147
|
-
Usage: npm run start -- <youtube-url> [options]
|
|
148
|
-
npx tsx src/index.ts <youtube-url> [options]
|
|
149
|
-
|
|
150
|
-
Note: when invoking via npm run, use -- to pass flags to the script:
|
|
151
|
-
npm run start -- <url> --max-chunks 3
|
|
152
|
-
|
|
153
|
-
Arguments:
|
|
154
|
-
<youtube-url> YouTube video URL (required)
|
|
155
|
-
|
|
156
|
-
Options:
|
|
157
|
-
--clip Download video and generate mp4 clips for each segment
|
|
158
|
-
--download-sections <mode> yt-dlp download mode: 'all' (full video) or N (top N segments only, e.g. 1, 2, 3...) (default: ${config.DOWNLOAD_SECTIONS_MODE})
|
|
159
|
-
--local-video <path> Path to local video file (skips yt-dlp download, requires --clip)
|
|
160
|
-
--video-path <path> Custom output directory for downloaded videos and clips (overrides DOWNLOAD_DIR/OUTPUT_DIR)
|
|
161
|
-
--threshold <n> Minimum score to keep a segment (default: ${config.SCORE_THRESHOLD})
|
|
162
|
-
--top-n <n> Maximum number of segments to return (default: ${config.TOP_N_SEGMENTS})
|
|
163
|
-
--max-duration <s> Abort if video is longer than <s> seconds
|
|
164
|
-
--max-chunks <n> Limit the number of transcript chunks sent to the LLM (useful for testing/cost control)
|
|
165
|
-
--max-parallel <n> Max number of LLM calls to run in parallel (default: LLM_CONCURRENCY env, or 3)
|
|
166
|
-
--output-json <path> Write output JSON to file instead of stdout
|
|
167
|
-
--no-cache Bypass all caches and force a fresh run (transcript + chunk LLM results)
|
|
168
|
-
--no-audio Disable audio event detection (transcript-only mode)
|
|
169
|
-
--game-profile <type> Game profile: valorant, fps, boss_fight, general (default: ${config.GAME_PROFILE})
|
|
170
|
-
--help, -h Show this help message
|
|
171
|
-
|
|
172
|
-
Examples:
|
|
173
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ
|
|
174
|
-
npm run start -- https://youtu.be/dQw4w9WgXcQ --clip
|
|
175
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --download-sections all
|
|
176
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --download-sections 3
|
|
177
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --download-sections 5 --video-path ./my-clips
|
|
178
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --local-video ./downloads/dQw4w9WgXcQ.mp4
|
|
179
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --local-video /path/to/video.mp4 --top-n 5
|
|
180
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --threshold 8 --top-n 5
|
|
181
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --output-json results.json
|
|
182
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --max-chunks 3
|
|
183
|
-
npm run start -- https://youtube.com/watch?v=dQw4w9WgXcQ --max-parallel 5
|
|
184
|
-
`.trim(),
|
|
185
|
-
);
|
|
186
|
-
}
|
package/src/config/env.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import 'dotenv/config';
|
|
2
|
-
import { ConfigSchema } from '../types/config.js';
|
|
3
|
-
|
|
4
|
-
function loadConfig() {
|
|
5
|
-
const result = ConfigSchema.safeParse(process.env);
|
|
6
|
-
|
|
7
|
-
if (!result.success) {
|
|
8
|
-
const issues = result.error.issues
|
|
9
|
-
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
|
10
|
-
.join('\n');
|
|
11
|
-
console.error(`[error] Invalid configuration:\n${issues}`);
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
return result.data;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const config = loadConfig();
|
package/src/config/index.ts
DELETED
package/src/index.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { log } from './utils/logger.js';
|
|
2
|
-
import { formatConfig } from './utils/redactConfig.js';
|
|
3
|
-
import { config } from './config/index.js';
|
|
4
|
-
import { parseArgs, printUsage } from './cli.js';
|
|
5
|
-
import { runPipeline } from './pipeline/runner.js';
|
|
6
|
-
|
|
7
|
-
const args = parseArgs(process.argv);
|
|
8
|
-
|
|
9
|
-
if (args.help) {
|
|
10
|
-
printUsage();
|
|
11
|
-
process.exit(0);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
if (!args.url) {
|
|
15
|
-
log.error('No YouTube URL provided.');
|
|
16
|
-
printUsage();
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (args.localVideo && !args.clip) {
|
|
21
|
-
log.error('--local-video requires --clip flag');
|
|
22
|
-
printUsage();
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (args.localVideo && args.downloadSections) {
|
|
27
|
-
log.warn(
|
|
28
|
-
'--download-sections is ignored when using --local-video (clipping all segments from --top-n)',
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
log.info(
|
|
33
|
-
`Starting video-clipper (model: ${config.LLM_MODEL})` +
|
|
34
|
-
(args.clip ? ' [--clip enabled]' : '') +
|
|
35
|
-
(args.localVideo ? ` [--local-video: ${args.localVideo}]` : '') +
|
|
36
|
-
(args.downloadSections !== undefined && args.downloadSections !== 'all'
|
|
37
|
-
? ` [--download-sections: ${args.downloadSections}]`
|
|
38
|
-
: '') +
|
|
39
|
-
(args.videoPath ? ` [--video-path: ${args.videoPath}]` : ''),
|
|
40
|
-
);
|
|
41
|
-
log.info(`Config: ${formatConfig(config)}`);
|
|
42
|
-
|
|
43
|
-
runPipeline(args).catch((err) => {
|
|
44
|
-
log.error(err instanceof Error ? err.message : String(err));
|
|
45
|
-
process.exit(1);
|
|
46
|
-
});
|
package/src/pipeline/runner.ts
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import { config } from '../config/index.js';
|
|
3
|
-
import { Cache } from '../utils/cache.js';
|
|
4
|
-
import { log } from '../utils/logger.js';
|
|
5
|
-
import { dumpAnalysis, dumpTranscript } from '../utils/dumper.js';
|
|
6
|
-
import { resolveVideo } from './stages/videoResolver.js';
|
|
7
|
-
import { processAudio } from './stages/audioProcessor.js';
|
|
8
|
-
import { analyzeSegments, refineRankedSegments } from './stages/segmentAnalyzer.js';
|
|
9
|
-
import { selectSegments } from './stages/segmentSelector.js';
|
|
10
|
-
import { exportClips } from './stages/clipExporter.js';
|
|
11
|
-
import { downloadAudio } from '../services/audioDownloader/index.js';
|
|
12
|
-
import type { CliArgs, PipelineResult } from '../types/index.js';
|
|
13
|
-
|
|
14
|
-
async function outputResult(
|
|
15
|
-
result: PipelineResult,
|
|
16
|
-
outputJsonPath: string | undefined,
|
|
17
|
-
): Promise<void> {
|
|
18
|
-
const json = JSON.stringify(result, null, 2);
|
|
19
|
-
if (outputJsonPath) {
|
|
20
|
-
await fs.writeFile(outputJsonPath, json, 'utf-8');
|
|
21
|
-
log.info(`Output written to ${outputJsonPath}`);
|
|
22
|
-
} else {
|
|
23
|
-
console.log('\n' + json);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Runs the full video-clipper pipeline for the given CLI arguments.
|
|
29
|
-
*
|
|
30
|
-
* Stage ordering:
|
|
31
|
-
* 1. resolveVideo — parse URL, extract video ID + metadata
|
|
32
|
-
* 2. downloadAudio — download WAV so Whisper/Gemini transcript providers can use it
|
|
33
|
-
* 3. processAudio — detect audio events per window (reuses downloaded WAV)
|
|
34
|
-
* 4a. analyzeSegments — fetch transcript + LLM pass 1 (informed by audio events)
|
|
35
|
-
* 5. selectSegments — merge signals, rank, threshold filter
|
|
36
|
-
* 4b. refineRankedSegments — LLM pass 2 to tighten clip boundaries
|
|
37
|
-
* 6. exportClips — download video + run ffmpeg (only if --clip)
|
|
38
|
-
*
|
|
39
|
-
* downloadAudio runs before analyzeSegments so that `audioPath` is available
|
|
40
|
-
* for Whisper/Gemini transcript providers. processAudio reuses the same WAV.
|
|
41
|
-
*
|
|
42
|
-
* Hard errors (invalid URL, transcript failure, all LLM chunks failed) are
|
|
43
|
-
* thrown so the caller can catch, log, and exit(1). Soft failures (audio
|
|
44
|
-
* detection, individual clip failures) are logged as warnings and the pipeline
|
|
45
|
-
* continues.
|
|
46
|
-
*/
|
|
47
|
-
export async function runPipeline(args: CliArgs): Promise<void> {
|
|
48
|
-
const threshold = args.threshold ?? config.SCORE_THRESHOLD;
|
|
49
|
-
const topN = args.topN ?? config.TOP_N_SEGMENTS;
|
|
50
|
-
const gameProfile = args.gameProfile ?? config.GAME_PROFILE;
|
|
51
|
-
const maxParallel = args.maxParallel ?? config.LLM_CONCURRENCY;
|
|
52
|
-
|
|
53
|
-
const cache = new Cache(config.CACHE_DIR, args.noCache);
|
|
54
|
-
|
|
55
|
-
const { videoId, metadata } = await resolveVideo(args.url as string, args.maxDuration);
|
|
56
|
-
|
|
57
|
-
/** Downloaded before transcript so Whisper/Gemini transcript providers can
|
|
58
|
-
* use the WAV. Returns null when audio detection is disabled.
|
|
59
|
-
*/
|
|
60
|
-
let audioPath: string | null = null;
|
|
61
|
-
const audioEnabled = config.AUDIO_DETECTION_ENABLED && !args.noAudio;
|
|
62
|
-
if (audioEnabled) {
|
|
63
|
-
try {
|
|
64
|
-
audioPath = await downloadAudio(videoId, `${config.OUTPUT_DIR}/audio`);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
-
log.warn(`Audio download failed — continuing without audio: ${message}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const audioEvents = await processAudio(videoId, metadata.duration, cache, {
|
|
72
|
-
noAudio: args.noAudio,
|
|
73
|
-
gameProfile,
|
|
74
|
-
maxParallel,
|
|
75
|
-
audioPath,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const { lines, microBlocks, chunkEvals } = await analyzeSegments(
|
|
79
|
-
videoId,
|
|
80
|
-
audioPath,
|
|
81
|
-
audioEvents,
|
|
82
|
-
cache,
|
|
83
|
-
{
|
|
84
|
-
maxChunks: args.maxChunks,
|
|
85
|
-
maxParallel,
|
|
86
|
-
noCache: args.noCache,
|
|
87
|
-
},
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
if (config.DUMP_OUTPUTS) {
|
|
91
|
-
await dumpTranscript(videoId, lines);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const rankedSegments = selectSegments(chunkEvals, audioEvents, { threshold, topN });
|
|
95
|
-
|
|
96
|
-
const partialResult: PipelineResult = {
|
|
97
|
-
video_id: videoId,
|
|
98
|
-
title: metadata.title,
|
|
99
|
-
duration: metadata.duration,
|
|
100
|
-
chunk_evaluations: chunkEvals,
|
|
101
|
-
segments: rankedSegments,
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
if (rankedSegments.length === 0) {
|
|
105
|
-
await outputResult(partialResult, args.outputJson);
|
|
106
|
-
if (config.DUMP_OUTPUTS) await dumpAnalysis(videoId, partialResult);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const refinedSegments = await refineRankedSegments(rankedSegments, microBlocks, cache, {
|
|
111
|
-
maxParallel,
|
|
112
|
-
noCache: args.noCache,
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const result: PipelineResult = {
|
|
116
|
-
video_id: videoId,
|
|
117
|
-
title: metadata.title,
|
|
118
|
-
duration: metadata.duration,
|
|
119
|
-
chunk_evaluations: chunkEvals,
|
|
120
|
-
segments: refinedSegments,
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
await outputResult(result, args.outputJson);
|
|
124
|
-
if (config.DUMP_OUTPUTS) await dumpAnalysis(videoId, result);
|
|
125
|
-
|
|
126
|
-
log.info('Done.');
|
|
127
|
-
|
|
128
|
-
if (!args.clip) {
|
|
129
|
-
log.info('Tip: run with --clip to download the video and generate mp4 clips.');
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const clipPaths = await exportClips(videoId, refinedSegments, {
|
|
134
|
-
localVideo: args.localVideo,
|
|
135
|
-
downloadSections: args.downloadSections,
|
|
136
|
-
videoPath: args.videoPath,
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
if (clipPaths.length === 0) {
|
|
140
|
-
log.warn('No clips were generated successfully.');
|
|
141
|
-
} else {
|
|
142
|
-
log.info(`Done — ${clipPaths.length} clip${clipPaths.length !== 1 ? 's' : ''} saved:`);
|
|
143
|
-
for (const p of clipPaths) {
|
|
144
|
-
log.info(` ${p}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import pLimit from 'p-limit';
|
|
3
|
-
import { downloadAudio } from '../../services/audioDownloader/index.js';
|
|
4
|
-
import { createAnalyzerChain } from '../../services/audioAnalyzers/index.js';
|
|
5
|
-
import { EventDetector } from '../../services/eventDetector/index.js';
|
|
6
|
-
import { sliceAudio } from '../../utils/sliceAudio.js';
|
|
7
|
-
import { buildWindows } from '../../utils/chunker.js';
|
|
8
|
-
import { log } from '../../utils/logger.js';
|
|
9
|
-
import { config } from '../../config/index.js';
|
|
10
|
-
import type { Cache } from '../../utils/cache.js';
|
|
11
|
-
import type { AudioEvent, AudioProcessorOpts } from '../../types/index.js';
|
|
12
|
-
|
|
13
|
-
export type { AudioProcessorOpts };
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Stage 3 — Audio Processor
|
|
17
|
-
*
|
|
18
|
-
* Downloads audio-only WAV, slices it into chunks using the generic
|
|
19
|
-
* `buildWindows` utility, runs event detection on each slice via an
|
|
20
|
-
* EventDetector (constructed from the ordered provider chain in config),
|
|
21
|
-
* and persists the results to cache.
|
|
22
|
-
*
|
|
23
|
-
* The provider chain is built once per run from `config.AUDIO_PROVIDER`
|
|
24
|
-
* (e.g. "gemini,whisper") via `createAnalyzerChain`. The EventDetector
|
|
25
|
-
* walks the chain in order, falling back to the next analyzer on failure.
|
|
26
|
-
*
|
|
27
|
-
* Returns an empty array immediately when audio detection is disabled via
|
|
28
|
-
* `--no-audio` or the `AUDIO_DETECTION_ENABLED` config flag.
|
|
29
|
-
*/
|
|
30
|
-
export async function processAudio(
|
|
31
|
-
videoId: string,
|
|
32
|
-
duration: number,
|
|
33
|
-
cache: Cache,
|
|
34
|
-
opts: AudioProcessorOpts,
|
|
35
|
-
): Promise<AudioEvent[]> {
|
|
36
|
-
const audioEnabled = config.AUDIO_DETECTION_ENABLED && !opts.noAudio;
|
|
37
|
-
if (!audioEnabled) return [];
|
|
38
|
-
|
|
39
|
-
const cached = await cache.readAudioEvents(videoId, opts.gameProfile, config.AUDIO_PROVIDER);
|
|
40
|
-
if (cached) {
|
|
41
|
-
log.info(`[cache hit] Audio events loaded from cache (${cached.length} events)`);
|
|
42
|
-
return cached;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const audioPath =
|
|
47
|
-
opts.audioPath ?? (await downloadAudio(videoId, `${config.OUTPUT_DIR}/audio`));
|
|
48
|
-
|
|
49
|
-
const chain = createAnalyzerChain(config.AUDIO_PROVIDER);
|
|
50
|
-
const detector = new EventDetector(chain);
|
|
51
|
-
|
|
52
|
-
const providerNames = chain.map((a) => a.source).join(' → ');
|
|
53
|
-
log.info(
|
|
54
|
-
`Detecting audio events (chain: ${providerNames}, profile: ${opts.gameProfile}, max ${opts.maxParallel} parallel)...`,
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const windows = buildWindows(duration, config.CHUNK_LENGTH_SEC, config.CHUNK_OVERLAP_SEC);
|
|
58
|
-
const limit = pLimit(opts.maxParallel);
|
|
59
|
-
|
|
60
|
-
const results = await Promise.allSettled(
|
|
61
|
-
windows.map((window) =>
|
|
62
|
-
limit(async () => {
|
|
63
|
-
log.info(` Processing audio chunk ${window.start}s - ${window.end}s...`);
|
|
64
|
-
|
|
65
|
-
const cachedChunk = await cache.readAudioChunk(
|
|
66
|
-
videoId,
|
|
67
|
-
opts.gameProfile,
|
|
68
|
-
config.AUDIO_PROVIDER,
|
|
69
|
-
window.start,
|
|
70
|
-
window.end,
|
|
71
|
-
);
|
|
72
|
-
if (cachedChunk) {
|
|
73
|
-
log.info(
|
|
74
|
-
` [cache hit] Audio chunk ${window.start}s - ${window.end}s (${cachedChunk.length} events)`,
|
|
75
|
-
);
|
|
76
|
-
return cachedChunk;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const slicePath = await sliceAudio(
|
|
80
|
-
audioPath,
|
|
81
|
-
window.start,
|
|
82
|
-
window.end - window.start,
|
|
83
|
-
config.OUTPUT_DIR,
|
|
84
|
-
);
|
|
85
|
-
const events = await detector.detect(
|
|
86
|
-
slicePath,
|
|
87
|
-
opts.gameProfile,
|
|
88
|
-
window.start,
|
|
89
|
-
window.end - window.start,
|
|
90
|
-
);
|
|
91
|
-
await fs.unlink(slicePath);
|
|
92
|
-
|
|
93
|
-
await cache.writeAudioChunk(
|
|
94
|
-
videoId,
|
|
95
|
-
opts.gameProfile,
|
|
96
|
-
config.AUDIO_PROVIDER,
|
|
97
|
-
window.start,
|
|
98
|
-
window.end,
|
|
99
|
-
events,
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
return events;
|
|
103
|
-
}),
|
|
104
|
-
),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const audioEvents: AudioEvent[] = results
|
|
108
|
-
.flatMap((r, i) => {
|
|
109
|
-
if (r.status === 'fulfilled') return r.value;
|
|
110
|
-
const w = windows[i]!;
|
|
111
|
-
log.warn(
|
|
112
|
-
` Audio event detection failed for chunk ${w.start}s - ${w.end}s: ${String(r.reason)}`,
|
|
113
|
-
);
|
|
114
|
-
return [];
|
|
115
|
-
})
|
|
116
|
-
.sort((a, b) => a.time - b.time);
|
|
117
|
-
|
|
118
|
-
log.info(`Audio event detection complete: ${audioEvents.length} events found`);
|
|
119
|
-
|
|
120
|
-
await cache.writeAudioEvents(videoId, opts.gameProfile, config.AUDIO_PROVIDER, audioEvents);
|
|
121
|
-
return audioEvents;
|
|
122
|
-
} catch (err) {
|
|
123
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
-
log.warn(`Audio event detection disabled due to error: ${message}`);
|
|
125
|
-
return [];
|
|
126
|
-
}
|
|
127
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { downloadVideo } from '../../services/videoDownloader/index.js';
|
|
2
|
-
import { generateClips, organizeClips } from '../../services/clipGenerator/index.js';
|
|
3
|
-
import { log } from '../../utils/logger.js';
|
|
4
|
-
import { config } from '../../config/index.js';
|
|
5
|
-
import type { RankedSegment, ClipExporterOpts } from '../../types/index.js';
|
|
6
|
-
|
|
7
|
-
export type { ClipExporterOpts };
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Stage 6 — Clip Exporter
|
|
11
|
-
*
|
|
12
|
-
* Handles all three clip-generation modes:
|
|
13
|
-
* 1. Local video — user supplied --local-video; run ffmpeg directly
|
|
14
|
-
* 2. Segments -- --download-sections N; download top-N clips via yt-dlp
|
|
15
|
-
* --download-sections, then copy to outputs/
|
|
16
|
-
* 3. Full video — download full video with yt-dlp, then cut clips with ffmpeg
|
|
17
|
-
*
|
|
18
|
-
* @returns Array of absolute paths to the generated clip files.
|
|
19
|
-
*/
|
|
20
|
-
export async function exportClips(
|
|
21
|
-
videoId: string,
|
|
22
|
-
segments: RankedSegment[],
|
|
23
|
-
opts: ClipExporterOpts,
|
|
24
|
-
): Promise<string[]> {
|
|
25
|
-
if (opts.localVideo) {
|
|
26
|
-
log.info(`Using local video: ${opts.localVideo}`);
|
|
27
|
-
return generateClips(
|
|
28
|
-
opts.localVideo,
|
|
29
|
-
segments,
|
|
30
|
-
videoId,
|
|
31
|
-
opts.videoPath,
|
|
32
|
-
config.CLIP_CONCURRENCY,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const downloadSections = opts.downloadSections ?? config.DOWNLOAD_SECTIONS_MODE;
|
|
37
|
-
|
|
38
|
-
if (typeof downloadSections === 'number') {
|
|
39
|
-
const segmentsToDownload = segments.slice(0, downloadSections);
|
|
40
|
-
|
|
41
|
-
if (segmentsToDownload.length < downloadSections) {
|
|
42
|
-
log.warn(
|
|
43
|
-
`Requested ${downloadSections} segments, but only ${segmentsToDownload.length} are available above threshold.`,
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
log.info(`Downloading ${segmentsToDownload.length} segments via yt-dlp --download-sections...`);
|
|
48
|
-
const downloadResult = await downloadVideo(
|
|
49
|
-
videoId,
|
|
50
|
-
'segments',
|
|
51
|
-
segmentsToDownload,
|
|
52
|
-
opts.videoPath,
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
if (downloadResult.mode !== 'segments') {
|
|
56
|
-
throw new Error('Expected segments download result but got full-video result.');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return organizeClips(downloadResult.paths, videoId, opts.videoPath, config.CLIP_CONCURRENCY);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
log.info('Downloading full video via yt-dlp...');
|
|
63
|
-
const downloadResult = await downloadVideo(videoId, 'all', [], opts.videoPath);
|
|
64
|
-
|
|
65
|
-
if (downloadResult.mode !== 'all') {
|
|
66
|
-
throw new Error('Expected full-video download result but got segments result.');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return generateClips(
|
|
70
|
-
downloadResult.path,
|
|
71
|
-
segments,
|
|
72
|
-
videoId,
|
|
73
|
-
opts.videoPath,
|
|
74
|
-
config.CLIP_CONCURRENCY,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { LLMAnalyzer } from '../../services/llmAnalyzer/LLMAnalyzer.js';
|
|
2
|
-
import { TranscriptDetector } from '../../services/transcriptDetector/index.js';
|
|
3
|
-
import { createTranscriptChain } from '../../services/transcriptAnalyzers/index.js';
|
|
4
|
-
import { refineSegments } from '../../services/clipRefiner/index.js';
|
|
5
|
-
import { log } from '../../utils/logger.js';
|
|
6
|
-
import { config } from '../../config/index.js';
|
|
7
|
-
import type { Cache } from '../../utils/cache.js';
|
|
8
|
-
import type {
|
|
9
|
-
AudioEvent,
|
|
10
|
-
MicroBlock,
|
|
11
|
-
RankedSegment,
|
|
12
|
-
SegmentAnalyzerOpts,
|
|
13
|
-
SegmentAnalyzerResult,
|
|
14
|
-
} from '../../types/index.js';
|
|
15
|
-
|
|
16
|
-
export type { SegmentAnalyzerOpts, SegmentAnalyzerResult };
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Stage 4a — Segment Analyzer (LLM pass 1)
|
|
20
|
-
*
|
|
21
|
-
* Builds a TranscriptDetector from config.TRANSCRIPT_PROVIDER and an
|
|
22
|
-
* LLMAnalyzer that owns it. Fetches the transcript (cache-first) and runs
|
|
23
|
-
* LLM chunk analysis informed by pre-computed audio events.
|
|
24
|
-
*
|
|
25
|
-
* Returns raw ChunkEvaluation results plus transcript data (lines, microBlocks,
|
|
26
|
-
* chunks) so the runner has everything it needs for ranking.
|
|
27
|
-
*
|
|
28
|
-
* NOTE: `processTranscript` no longer needs to run as a separate stage before
|
|
29
|
-
* this function — `LLMAnalyzer.analyze()` handles transcript fetching internally.
|
|
30
|
-
*/
|
|
31
|
-
export async function analyzeSegments(
|
|
32
|
-
videoId: string,
|
|
33
|
-
audioPath: string | null,
|
|
34
|
-
audioEvents: AudioEvent[],
|
|
35
|
-
cache: Cache,
|
|
36
|
-
opts: SegmentAnalyzerOpts,
|
|
37
|
-
): Promise<SegmentAnalyzerResult> {
|
|
38
|
-
log.info('Fetching transcript and analyzing segments...');
|
|
39
|
-
|
|
40
|
-
const chain = createTranscriptChain(config.TRANSCRIPT_PROVIDER);
|
|
41
|
-
const transcriptDetector = new TranscriptDetector(chain);
|
|
42
|
-
const analyzer = new LLMAnalyzer(transcriptDetector, cache);
|
|
43
|
-
|
|
44
|
-
const { lines, microBlocks, chunks, chunkEvals } = await analyzer.analyze({
|
|
45
|
-
videoId,
|
|
46
|
-
audioPath,
|
|
47
|
-
audioEvents,
|
|
48
|
-
maxChunks: opts.maxChunks,
|
|
49
|
-
maxParallel: opts.maxParallel,
|
|
50
|
-
noCache: opts.noCache,
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
return { lines, microBlocks, chunks, chunkEvals };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Stage 4b — Segment Refiner (LLM pass 2)
|
|
58
|
-
*
|
|
59
|
-
* Calls refineSegments() directly — no TranscriptDetector needed here since
|
|
60
|
-
* refinement only tightens clip boundaries and never touches the transcript.
|
|
61
|
-
* Separated from `analyzeSegments` because ranking (stage 5) must happen
|
|
62
|
-
* between the two passes.
|
|
63
|
-
*/
|
|
64
|
-
export async function refineRankedSegments(
|
|
65
|
-
rankedSegments: RankedSegment[],
|
|
66
|
-
microBlocks: MicroBlock[],
|
|
67
|
-
_cache: Cache,
|
|
68
|
-
opts: Pick<SegmentAnalyzerOpts, 'maxParallel' | 'noCache'>,
|
|
69
|
-
): Promise<RankedSegment[]> {
|
|
70
|
-
log.info('Refining clip boundaries...');
|
|
71
|
-
return refineSegments(rankedSegments, microBlocks, opts.maxParallel, opts.noCache);
|
|
72
|
-
}
|