@thunderkiller/video-clipper 1.1.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.
Files changed (140) hide show
  1. package/.env.example +130 -0
  2. package/.github/workflows/ci.yml +42 -0
  3. package/.github/workflows/release.yml +72 -0
  4. package/.husky/pre-commit +3 -0
  5. package/.prettierignore +6 -0
  6. package/.prettierrc +7 -0
  7. package/.releaserc.json +21 -0
  8. package/AGENTS.md +122 -0
  9. package/CHANGELOG.md +45 -0
  10. package/README.md +410 -0
  11. package/dist/cli.js +187 -0
  12. package/dist/config/env.js +14 -0
  13. package/dist/config/index.js +1 -0
  14. package/dist/index.js +35 -0
  15. package/dist/pipeline/runner.js +132 -0
  16. package/dist/pipeline/stages/audioProcessor.js +75 -0
  17. package/dist/pipeline/stages/clipExporter.js +44 -0
  18. package/dist/pipeline/stages/segmentAnalyzer.js +46 -0
  19. package/dist/pipeline/stages/segmentSelector.js +23 -0
  20. package/dist/pipeline/stages/videoResolver.js +34 -0
  21. package/dist/services/audioAnalyzers/base.js +13 -0
  22. package/dist/services/audioAnalyzers/factory.js +56 -0
  23. package/dist/services/audioAnalyzers/gemini.js +109 -0
  24. package/dist/services/audioAnalyzers/index.js +5 -0
  25. package/dist/services/audioAnalyzers/whisper.js +62 -0
  26. package/dist/services/audioAnalyzers/yamnet.js +40 -0
  27. package/dist/services/audioDownloader/index.js +81 -0
  28. package/dist/services/chunkBuilder/index.js +71 -0
  29. package/dist/services/clipGenerator/index.js +156 -0
  30. package/dist/services/clipRefiner/index.js +103 -0
  31. package/dist/services/eventDetector/index.js +54 -0
  32. package/dist/services/llmAnalyzer/LLMAnalyzer.js +63 -0
  33. package/dist/services/llmAnalyzer/index.js +173 -0
  34. package/dist/services/metadataExtractor/index.js +66 -0
  35. package/dist/services/segmentRanker/index.js +40 -0
  36. package/dist/services/signalMerger/index.js +36 -0
  37. package/dist/services/transcriptAnalyzers/base.js +13 -0
  38. package/dist/services/transcriptAnalyzers/factory.js +51 -0
  39. package/dist/services/transcriptAnalyzers/gemini.js +19 -0
  40. package/dist/services/transcriptAnalyzers/index.js +5 -0
  41. package/dist/services/transcriptAnalyzers/whisper.js +55 -0
  42. package/dist/services/transcriptAnalyzers/ytdlp.js +16 -0
  43. package/dist/services/transcriptDetector/index.js +102 -0
  44. package/dist/services/transcriptFetcher/index.js +124 -0
  45. package/dist/services/urlParser/index.js +46 -0
  46. package/dist/services/videoDownloader/index.js +212 -0
  47. package/dist/types/audio.js +15 -0
  48. package/dist/types/cli.js +1 -0
  49. package/dist/types/config.js +150 -0
  50. package/dist/types/index.js +5 -0
  51. package/dist/types/pipeline.js +9 -0
  52. package/dist/types/segment.js +36 -0
  53. package/dist/types/transcript.js +16 -0
  54. package/dist/types/video.js +14 -0
  55. package/dist/utils/cache.js +143 -0
  56. package/dist/utils/chunker.js +51 -0
  57. package/dist/utils/dumper.js +36 -0
  58. package/dist/utils/format.js +10 -0
  59. package/dist/utils/logger.js +16 -0
  60. package/dist/utils/modelFactory.js +60 -0
  61. package/dist/utils/redactConfig.js +20 -0
  62. package/dist/utils/sliceAudio.js +26 -0
  63. package/docs/free-models.md +78 -0
  64. package/docs/plan.md +442 -0
  65. package/docs/refactorPhases.md +105 -0
  66. package/docs/yt-downloader.md +440 -0
  67. package/package.json +65 -0
  68. package/requirements.txt +5 -0
  69. package/scripts/detect_events.py +81 -0
  70. package/scripts/detect_events_whisper.py +101 -0
  71. package/scripts/transcribe_whisper.py +70 -0
  72. package/src/cli.ts +186 -0
  73. package/src/config/env.ts +18 -0
  74. package/src/config/index.ts +2 -0
  75. package/src/index.ts +46 -0
  76. package/src/pipeline/runner.ts +155 -0
  77. package/src/pipeline/stages/audioProcessor.ts +129 -0
  78. package/src/pipeline/stages/clipExporter.ts +80 -0
  79. package/src/pipeline/stages/segmentAnalyzer.ts +72 -0
  80. package/src/pipeline/stages/segmentSelector.ts +39 -0
  81. package/src/pipeline/stages/videoResolver.ts +47 -0
  82. package/src/services/audioAnalyzers/base.ts +32 -0
  83. package/src/services/audioAnalyzers/factory.ts +71 -0
  84. package/src/services/audioAnalyzers/gemini.ts +137 -0
  85. package/src/services/audioAnalyzers/index.ts +6 -0
  86. package/src/services/audioAnalyzers/whisper.ts +80 -0
  87. package/src/services/audioAnalyzers/yamnet.ts +54 -0
  88. package/src/services/audioDownloader/index.ts +102 -0
  89. package/src/services/chunkBuilder/index.ts +86 -0
  90. package/src/services/clipGenerator/index.ts +210 -0
  91. package/src/services/clipRefiner/index.ts +141 -0
  92. package/src/services/eventDetector/index.ts +68 -0
  93. package/src/services/llmAnalyzer/LLMAnalyzer.ts +114 -0
  94. package/src/services/llmAnalyzer/index.ts +231 -0
  95. package/src/services/metadataExtractor/index.ts +83 -0
  96. package/src/services/segmentRanker/index.ts +88 -0
  97. package/src/services/signalMerger/index.ts +53 -0
  98. package/src/services/transcriptAnalyzers/base.ts +26 -0
  99. package/src/services/transcriptAnalyzers/factory.ts +67 -0
  100. package/src/services/transcriptAnalyzers/gemini.ts +24 -0
  101. package/src/services/transcriptAnalyzers/index.ts +6 -0
  102. package/src/services/transcriptAnalyzers/whisper.ts +68 -0
  103. package/src/services/transcriptAnalyzers/ytdlp.ts +19 -0
  104. package/src/services/transcriptDetector/index.ts +128 -0
  105. package/src/services/transcriptFetcher/index.ts +151 -0
  106. package/src/services/urlParser/index.ts +53 -0
  107. package/src/services/videoDownloader/index.ts +282 -0
  108. package/src/types/audio.ts +19 -0
  109. package/src/types/cli.ts +22 -0
  110. package/src/types/config.ts +174 -0
  111. package/src/types/index.ts +26 -0
  112. package/src/types/pipeline.ts +93 -0
  113. package/src/types/segment.ts +43 -0
  114. package/src/types/transcript.ts +22 -0
  115. package/src/types/video.ts +18 -0
  116. package/src/utils/cache.ts +223 -0
  117. package/src/utils/chunker.ts +60 -0
  118. package/src/utils/dumper.ts +41 -0
  119. package/src/utils/format.ts +10 -0
  120. package/src/utils/logger.ts +17 -0
  121. package/src/utils/modelFactory.ts +71 -0
  122. package/src/utils/redactConfig.ts +23 -0
  123. package/src/utils/sliceAudio.ts +35 -0
  124. package/test-trigger.txt +1 -0
  125. package/tests/analyzerFactory.test.ts +146 -0
  126. package/tests/audioEventDetector.test.ts +69 -0
  127. package/tests/cache.test.ts +203 -0
  128. package/tests/chunkBuilder.test.ts +146 -0
  129. package/tests/chunker.test.ts +95 -0
  130. package/tests/eventDetector.test.ts +103 -0
  131. package/tests/llmAnalyzer.test.ts +283 -0
  132. package/tests/segmentRanker.test.ts +133 -0
  133. package/tests/setup.ts +48 -0
  134. package/tests/signalMerger.test.ts +197 -0
  135. package/tests/transcriptDetector.test.ts +150 -0
  136. package/tests/transcriptFetcher.test.ts +179 -0
  137. package/tests/urlParser.test.ts +70 -0
  138. package/tsconfig.json +16 -0
  139. package/tsconfig.test.json +8 -0
  140. package/vitest.config.ts +8 -0
@@ -0,0 +1,143 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { z } from 'zod';
5
+ import { log } from './logger.js';
6
+ import { TranscriptLineSchema, ChunkEvaluationSchema, AudioEventSchema } from '../types/index.js';
7
+ // ---------------------------------------------------------------------------
8
+ // Internal cache-key helpers
9
+ // ---------------------------------------------------------------------------
10
+ /**
11
+ * Serializes audio events into a stable string for cache keying.
12
+ * Events are sorted by time so the key is order-independent.
13
+ */
14
+ function audioEventsKey(events) {
15
+ if (events.length === 0)
16
+ return '';
17
+ const sorted = [...events].sort((a, b) => a.time - b.time);
18
+ return JSON.stringify(sorted);
19
+ }
20
+ function hashContent(input) {
21
+ return createHash('sha256').update(input).digest('hex');
22
+ }
23
+ async function readCacheFile(filePath, schema) {
24
+ try {
25
+ const raw = await fs.readFile(filePath, 'utf-8');
26
+ const parsed = schema.safeParse(JSON.parse(raw));
27
+ if (!parsed.success) {
28
+ log.warn(`[cache] Corrupt entry at ${filePath} — ignoring`);
29
+ return null;
30
+ }
31
+ return parsed.data;
32
+ }
33
+ catch {
34
+ // File not found or unreadable — normal cache miss, stay silent
35
+ return null;
36
+ }
37
+ }
38
+ async function writeCacheFile(filePath, data) {
39
+ try {
40
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
41
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
42
+ }
43
+ catch (err) {
44
+ log.warn(`[cache] Failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
45
+ }
46
+ }
47
+ const SegmentRefinementSchema = z.object({
48
+ refined_start: z.number(),
49
+ refined_end: z.number(),
50
+ });
51
+ /**
52
+ * Disk-backed cache for all pipeline stages.
53
+ *
54
+ * Constructed once in runner.ts with the resolved cache directory and passed
55
+ * down to each stage that needs caching. Pass `disabled = true` to bypass all
56
+ * reads and writes (equivalent to --no-cache).
57
+ */
58
+ export class Cache {
59
+ cacheDir;
60
+ disabled;
61
+ constructor(cacheDir, disabled = false) {
62
+ this.cacheDir = cacheDir;
63
+ this.disabled = disabled;
64
+ }
65
+ // ---- Transcript ---------------------------------------------------------
66
+ transcriptPath(videoId) {
67
+ return path.join(this.cacheDir, 'transcript', `${hashContent(videoId)}.json`);
68
+ }
69
+ async readTranscript(videoId) {
70
+ if (this.disabled)
71
+ return null;
72
+ return readCacheFile(this.transcriptPath(videoId), z.array(TranscriptLineSchema));
73
+ }
74
+ async writeTranscript(videoId, lines) {
75
+ if (this.disabled)
76
+ return;
77
+ await writeCacheFile(this.transcriptPath(videoId), lines);
78
+ }
79
+ // ---- LLM chunk results --------------------------------------------------
80
+ chunkPath(chunk, chunkAudioEvents = []) {
81
+ const audioKey = audioEventsKey(chunkAudioEvents);
82
+ return path.join(this.cacheDir, 'chunks', `${hashContent(`${chunk.start}|${chunk.end}|${chunk.text}|${audioKey}`)}.json`);
83
+ }
84
+ async readChunk(chunk, chunkAudioEvents = []) {
85
+ if (this.disabled)
86
+ return null;
87
+ return readCacheFile(this.chunkPath(chunk, chunkAudioEvents), ChunkEvaluationSchema);
88
+ }
89
+ async writeChunk(chunk, evaluation, chunkAudioEvents = []) {
90
+ if (this.disabled)
91
+ return;
92
+ if (evaluation.status !== 'success')
93
+ return;
94
+ await writeCacheFile(this.chunkPath(chunk, chunkAudioEvents), evaluation);
95
+ }
96
+ // ---- Segment refinement -------------------------------------------------
97
+ segmentRefinementPath(start, end, reason) {
98
+ return path.join(this.cacheDir, 'segments', `${hashContent(`${start}|${end}|${reason}`)}.json`);
99
+ }
100
+ async readSegmentRefinement(start, end, reason) {
101
+ if (this.disabled)
102
+ return null;
103
+ return readCacheFile(this.segmentRefinementPath(start, end, reason), SegmentRefinementSchema);
104
+ }
105
+ async writeSegmentRefinement(start, end, reason, refined) {
106
+ if (this.disabled)
107
+ return;
108
+ await writeCacheFile(this.segmentRefinementPath(start, end, reason), refined);
109
+ }
110
+ // ---- Audio events (whole-video) -----------------------------------------
111
+ audioEventPath(videoId, gameProfile, provider) {
112
+ return path.join(this.cacheDir, 'audio', `${hashContent(`${videoId}|${gameProfile}|${provider}`)}.json`);
113
+ }
114
+ async readAudioEvents(videoId, gameProfile, provider) {
115
+ if (this.disabled)
116
+ return null;
117
+ return readCacheFile(this.audioEventPath(videoId, gameProfile, provider), z.array(AudioEventSchema));
118
+ }
119
+ async writeAudioEvents(videoId, gameProfile, provider, events) {
120
+ if (this.disabled)
121
+ return;
122
+ await writeCacheFile(this.audioEventPath(videoId, gameProfile, provider), events);
123
+ }
124
+ // ---- Audio events (per-chunk) -------------------------------------------
125
+ /**
126
+ * Per-chunk audio cache — mirrors the LLM `chunks/` pattern.
127
+ * Key includes videoId, gameProfile, provider, and the exact window bounds
128
+ * so each 120s slice is stored independently.
129
+ */
130
+ audioChunkPath(videoId, gameProfile, provider, windowStart, windowEnd) {
131
+ return path.join(this.cacheDir, 'audio', `${hashContent(`${videoId}|${gameProfile}|${provider}|${windowStart}|${windowEnd}`)}.json`);
132
+ }
133
+ async readAudioChunk(videoId, gameProfile, provider, windowStart, windowEnd) {
134
+ if (this.disabled)
135
+ return null;
136
+ return readCacheFile(this.audioChunkPath(videoId, gameProfile, provider, windowStart, windowEnd), z.array(AudioEventSchema));
137
+ }
138
+ async writeAudioChunk(videoId, gameProfile, provider, windowStart, windowEnd, events) {
139
+ if (this.disabled)
140
+ return;
141
+ await writeCacheFile(this.audioChunkPath(videoId, gameProfile, provider, windowStart, windowEnd), events);
142
+ }
143
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Generic windowed-chunker utility.
3
+ *
4
+ * Used by:
5
+ * - transcriptProcessor — builds LLM analysis windows over micro-blocks
6
+ * - audioProcessor — builds audio slice windows over the video duration
7
+ *
8
+ * The function returns non-overlapping or overlapping half-open intervals
9
+ * [start, end) that together cover the full range [0, totalDuration).
10
+ */
11
+ /**
12
+ * Builds a list of time windows covering `[0, totalDuration)`.
13
+ *
14
+ * @param totalDuration - Total duration of the content in seconds.
15
+ * @param windowSec - Width of each window in seconds. Must be > 0.
16
+ * @param overlapSec - How many seconds consecutive windows share. Must be
17
+ * >= 0 and < windowSec. Defaults to 0.
18
+ * @returns Array of {start, end} windows. Empty when totalDuration <= 0.
19
+ *
20
+ * @example
21
+ * // No overlap — three equal windows
22
+ * buildWindows(60, 20)
23
+ * // → [{start:0,end:20}, {start:20,end:40}, {start:40,end:60}]
24
+ *
25
+ * @example
26
+ * // With overlap — each window starts 10s after the previous
27
+ * buildWindows(60, 30, 10)
28
+ * // → [{start:0,end:30}, {start:20,end:50}, {start:40,end:60}]
29
+ *
30
+ * @example
31
+ * // Remainder — last window is shorter
32
+ * buildWindows(70, 30)
33
+ * // → [{start:0,end:30}, {start:30,end:60}, {start:60,end:70}]
34
+ */
35
+ export function buildWindows(totalDuration, windowSec, overlapSec = 0) {
36
+ if (totalDuration <= 0 || windowSec <= 0)
37
+ return [];
38
+ if (overlapSec < 0)
39
+ overlapSec = 0;
40
+ if (overlapSec >= windowSec)
41
+ overlapSec = 0; // guard against infinite loop
42
+ const step = windowSec - overlapSec;
43
+ const windows = [];
44
+ for (let start = 0; start < totalDuration; start += step) {
45
+ windows.push({
46
+ start,
47
+ end: Math.min(start + windowSec, totalDuration),
48
+ });
49
+ }
50
+ return windows;
51
+ }
@@ -0,0 +1,36 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { config } from '../config/index.js';
4
+ import { log } from './logger.js';
5
+ /**
6
+ * Writes the raw normalized transcript lines to
7
+ * `{OUTPUT_DIR}/transcript/{videoId}.json`.
8
+ */
9
+ export async function dumpTranscript(videoId, lines) {
10
+ try {
11
+ const dir = path.join(config.OUTPUT_DIR, 'transcript');
12
+ await fs.mkdir(dir, { recursive: true });
13
+ const filePath = path.join(dir, `${videoId}.json`);
14
+ await fs.writeFile(filePath, JSON.stringify(lines, null, 2), 'utf-8');
15
+ log.info(`Transcript dumped to ${filePath}`);
16
+ }
17
+ catch (err) {
18
+ log.warn(`Failed to dump transcript for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
19
+ }
20
+ }
21
+ /**
22
+ * Writes the full pipeline result (metadata + ranked segments) to
23
+ * `{OUTPUT_DIR}/analysis/{videoId}.json`.
24
+ */
25
+ export async function dumpAnalysis(videoId, result) {
26
+ try {
27
+ const dir = path.join(config.OUTPUT_DIR, 'analysis');
28
+ await fs.mkdir(dir, { recursive: true });
29
+ const filePath = path.join(dir, `${videoId}.json`);
30
+ await fs.writeFile(filePath, JSON.stringify(result, null, 2), 'utf-8');
31
+ log.info(`Analysis dumped to ${filePath}`);
32
+ }
33
+ catch (err) {
34
+ log.warn(`Failed to dump analysis for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
35
+ }
36
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Converts a duration in seconds to HH:MM:SS string.
3
+ * e.g. 3723 → "01:02:03"
4
+ */
5
+ export function formatSeconds(seconds) {
6
+ const h = Math.floor(seconds / 3600);
7
+ const m = Math.floor((seconds % 3600) / 60);
8
+ const s = Math.floor(seconds % 60);
9
+ return [h, m, s].map((v) => String(v).padStart(2, '0')).join(':');
10
+ }
@@ -0,0 +1,16 @@
1
+ const LEVELS = {
2
+ info: '[info]',
3
+ warn: '[warn]',
4
+ error: '[error]',
5
+ };
6
+ export const log = {
7
+ info: (msg) => {
8
+ console.log(`${LEVELS.info} ${msg}`);
9
+ },
10
+ warn: (msg) => {
11
+ console.warn(`${LEVELS.warn} ${msg}`);
12
+ },
13
+ error: (msg) => {
14
+ console.error(`${LEVELS.error} ${msg}`);
15
+ },
16
+ };
@@ -0,0 +1,60 @@
1
+ import { createOpenAI, openai } from '@ai-sdk/openai';
2
+ import { anthropic } from '@ai-sdk/anthropic';
3
+ import { google } from '@ai-sdk/google';
4
+ import { xai } from '@ai-sdk/xai';
5
+ import { mistral } from '@ai-sdk/mistral';
6
+ import { groq } from '@ai-sdk/groq';
7
+ import { config } from '../config/index.js';
8
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
9
+ /**
10
+ * Returns a Vercel AI SDK LanguageModel instance for the configured provider
11
+ * and model. Both `llmAnalyzer` and `clipRefiner` call this instead of
12
+ * hard-coding `openai(config.LLM_MODEL)`.
13
+ *
14
+ * The active provider is controlled by `LLM_PROVIDER` in the environment.
15
+ * The model name is controlled by `LLM_MODEL`.
16
+ *
17
+ * In ai@5, all provider packages ship LanguageModelV3 which natively
18
+ * satisfies the LanguageModel interface.
19
+ */
20
+ export function getModel() {
21
+ const model = config.LLM_MODEL;
22
+ switch (config.LLM_PROVIDER) {
23
+ case 'openai':
24
+ return openai.languageModel(model);
25
+ case 'anthropic':
26
+ return anthropic.languageModel(model);
27
+ case 'google':
28
+ return google.languageModel(model);
29
+ case 'xai':
30
+ return xai.languageModel(model);
31
+ case 'mistral':
32
+ return mistral.languageModel(model);
33
+ case 'groq':
34
+ return groq.languageModel(model);
35
+ case 'zai':
36
+ return createOpenAICompatible({
37
+ name: 'zai',
38
+ baseURL: 'https://api.z.ai/api/paas/v4',
39
+ apiKey: config.ZAI_API_KEY,
40
+ // Zai is OpenAI-compatible and supports json_schema response format.
41
+ // Without this flag the SDK falls back to json_object mode and emits
42
+ // a "responseFormat not supported" warning on every chunk call.
43
+ supportsStructuredOutputs: true,
44
+ }).languageModel(model);
45
+ case 'openrouter':
46
+ return createOpenAI({
47
+ baseURL: 'https://openrouter.ai/api/v1',
48
+ apiKey: config.OPENROUTER_API_KEY,
49
+ }).languageModel(model);
50
+ case 'custom':
51
+ return createOpenAICompatible({
52
+ name: 'custom',
53
+ baseURL: config.CUSTOM_OPENAI_BASE_URL,
54
+ apiKey: config.CUSTOM_OPENAI_API_KEY,
55
+ // Treat as a fully OpenAI-compatible endpoint with structured output
56
+ // support so generateObject uses json_schema mode, not json_object fallback.
57
+ supportsStructuredOutputs: true,
58
+ }).languageModel(model);
59
+ }
60
+ }
@@ -0,0 +1,20 @@
1
+ const SENSITIVE_KEYS = new Set([
2
+ 'OPENAI_API_KEY',
3
+ 'ANTHROPIC_API_KEY',
4
+ 'GOOGLE_GENERATIVE_AI_API_KEY',
5
+ 'XAI_API_KEY',
6
+ 'MISTRAL_API_KEY',
7
+ 'GROQ_API_KEY',
8
+ 'ZAI_API_KEY',
9
+ ]);
10
+ /**
11
+ * Formats the resolved config as a single-line key=value string,
12
+ * omitting all API key fields and any undefined optional values.
13
+ */
14
+ export function formatConfig(cfg) {
15
+ return Object.entries(cfg)
16
+ .filter(([k]) => !SENSITIVE_KEYS.has(k))
17
+ .filter(([, v]) => v !== undefined)
18
+ .map(([k, v]) => `${k}=${String(v)}`)
19
+ .join(' ');
20
+ }
@@ -0,0 +1,26 @@
1
+ import * as path from 'path';
2
+ import ffmpeg from 'fluent-ffmpeg';
3
+ import { config } from '../config/index.js';
4
+ if (config.FFMPEG_PATH) {
5
+ ffmpeg.setFfmpegPath(config.FFMPEG_PATH);
6
+ }
7
+ if (config.FFPROBE_PATH) {
8
+ ffmpeg.setFfprobePath(config.FFPROBE_PATH);
9
+ }
10
+ export async function sliceAudio(inputPath, startSec, durationSec, outputDir) {
11
+ const filename = path.basename(inputPath, path.extname(inputPath));
12
+ const outputPath = path.join(outputDir, `${filename}_slice_${startSec}.wav`);
13
+ await new Promise((resolve, reject) => {
14
+ ffmpeg(inputPath)
15
+ .setStartTime(startSec)
16
+ .setDuration(durationSec)
17
+ .audioFrequency(16000)
18
+ .audioChannels(1)
19
+ .format('wav')
20
+ .output(outputPath)
21
+ .on('end', () => resolve())
22
+ .on('error', (err) => reject(err))
23
+ .run();
24
+ });
25
+ return outputPath;
26
+ }
@@ -0,0 +1,78 @@
1
+ # Free Models via OpenRouter
2
+
3
+ This project supports [OpenRouter](https://openrouter.ai) as an LLM provider, which gives access to a number of completely free models (no cost per token).
4
+
5
+ ## Setup
6
+
7
+ ```env
8
+ LLM_PROVIDER=openrouter
9
+ OPENROUTER_API_KEY=sk-or-...
10
+ LLM_MODEL=<model-id-from-table-below>
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Recommended Free Models for Transcript Analysis
16
+
17
+ These models have sufficient context length and instruction-following capability to work well with `generateObject` and structured transcript analysis.
18
+
19
+ | Model ID | Name | Context |
20
+ | ----------------------------------------------- | --------------------------------- | ------- |
21
+ | `meta-llama/llama-3.3-70b-instruct:free` | Meta: Llama 3.3 70B Instruct | 128K |
22
+ | `google/gemma-3-27b-it:free` | Google: Gemma 3 27B | 131K |
23
+ | `mistralai/mistral-small-3.1-24b-instruct:free` | Mistral: Mistral Small 3.1 24B | 128K |
24
+ | `nousresearch/hermes-3-llama-3.1-405b:free` | Nous: Hermes 3 405B Instruct | 131K |
25
+ | `qwen/qwen3-next-80b-a3b-instruct:free` | Qwen: Qwen3 Next 80B A3B Instruct | 262K |
26
+
27
+ **Recommended default:** `meta-llama/llama-3.3-70b-instruct:free`
28
+
29
+ - Most battle-tested for structured JSON output
30
+ - Reliable with `generateObject` (which requires strict schema adherence)
31
+ - 128K context handles most YouTube transcripts comfortably
32
+
33
+ ---
34
+
35
+ ## All Available Free Models (as of March 2026)
36
+
37
+ | Model ID | Name | Context |
38
+ | --------------------------------------------------------------- | --------------------------------- | --------- |
39
+ | `openrouter/hunter-alpha` | Hunter Alpha | 1,048,576 |
40
+ | `openrouter/healer-alpha` | Healer Alpha | 262,144 |
41
+ | `nvidia/nemotron-3-super-120b-a12b:free` | NVIDIA: Nemotron 3 Super | 262,144 |
42
+ | `minimax/minimax-m2.5:free` | MiniMax: MiniMax M2.5 | 196,608 |
43
+ | `openrouter/free` | Free Models Router | 200,000 |
44
+ | `stepfun/step-3.5-flash:free` | StepFun: Step 3.5 Flash | 256,000 |
45
+ | `arcee-ai/trinity-large-preview:free` | Arcee AI: Trinity Large Preview | 131,000 |
46
+ | `liquid/lfm-2.5-1.2b-thinking:free` | LiquidAI: LFM2.5-1.2B-Thinking | 32,768 |
47
+ | `liquid/lfm-2.5-1.2b-instruct:free` | LiquidAI: LFM2.5-1.2B-Instruct | 32,768 |
48
+ | `nvidia/nemotron-3-nano-30b-a3b:free` | NVIDIA: Nemotron 3 Nano 30B A3B | 256,000 |
49
+ | `arcee-ai/trinity-mini:free` | Arcee AI: Trinity Mini | 131,072 |
50
+ | `nvidia/nemotron-nano-12b-v2-vl:free` | NVIDIA: Nemotron Nano 12B 2 VL | 128,000 |
51
+ | `qwen/qwen3-next-80b-a3b-instruct:free` | Qwen: Qwen3 Next 80B A3B Instruct | 262,144 |
52
+ | `nvidia/nemotron-nano-9b-v2:free` | NVIDIA: Nemotron Nano 9B V2 | 128,000 |
53
+ | `openai/gpt-oss-120b:free` | OpenAI: gpt-oss-120b | 131,072 |
54
+ | `openai/gpt-oss-20b:free` | OpenAI: gpt-oss-20b | 131,072 |
55
+ | `z-ai/glm-4.5-air:free` | Z.ai: GLM 4.5 Air | 131,072 |
56
+ | `qwen/qwen3-coder:free` | Qwen: Qwen3 Coder 480B A35B | 262,000 |
57
+ | `cognitivecomputations/dolphin-mistral-24b-venice-edition:free` | Venice: Uncensored | 32,768 |
58
+ | `google/gemma-3n-e2b-it:free` | Google: Gemma 3n 2B | 8,192 |
59
+ | `google/gemma-3n-e4b-it:free` | Google: Gemma 3n 4B | 8,192 |
60
+ | `qwen/qwen3-4b:free` | Qwen: Qwen3 4B | 40,960 |
61
+ | `mistralai/mistral-small-3.1-24b-instruct:free` | Mistral: Mistral Small 3.1 24B | 128,000 |
62
+ | `google/gemma-3-4b-it:free` | Google: Gemma 3 4B | 32,768 |
63
+ | `google/gemma-3-12b-it:free` | Google: Gemma 3 12B | 32,768 |
64
+ | `google/gemma-3-27b-it:free` | Google: Gemma 3 27B | 131,072 |
65
+ | `meta-llama/llama-3.3-70b-instruct:free` | Meta: Llama 3.3 70B Instruct | 128,000 |
66
+ | `meta-llama/llama-3.2-3b-instruct:free` | Meta: Llama 3.2 3B Instruct | 131,072 |
67
+ | `nousresearch/hermes-3-llama-3.1-405b:free` | Nous: Hermes 3 405B Instruct | 131,072 |
68
+
69
+ > Free models may have rate limits or availability constraints. Check [openrouter.ai/models](https://openrouter.ai/models) for the latest list.
70
+
71
+ ---
72
+
73
+ ## Notes
74
+
75
+ - Free models on OpenRouter are subject to rate limits (typically lower than paid tiers).
76
+ - Avoid models with context < 32K for long videos — chunks may not fit.
77
+ - Models marked `:free` are permanently free; others (like `openrouter/hunter-alpha`) may change pricing.
78
+ - If `generateObject` fails with a free model, it usually means the model doesn't follow JSON schema strictly enough — switch to `meta-llama/llama-3.3-70b-instruct:free` as a fallback.