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