vidpipe 1.2.2 → 1.2.3
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/dist/index.d.ts +2 -2
- package/dist/index.js +4766 -123
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/dist/agents/BaseAgent.d.ts +0 -52
- package/dist/agents/BaseAgent.d.ts.map +0 -1
- package/dist/agents/BaseAgent.js +0 -102
- package/dist/agents/BaseAgent.js.map +0 -1
- package/dist/agents/BlogAgent.d.ts +0 -3
- package/dist/agents/BlogAgent.d.ts.map +0 -1
- package/dist/agents/BlogAgent.js +0 -163
- package/dist/agents/BlogAgent.js.map +0 -1
- package/dist/agents/ChapterAgent.d.ts +0 -11
- package/dist/agents/ChapterAgent.d.ts.map +0 -1
- package/dist/agents/ChapterAgent.js +0 -191
- package/dist/agents/ChapterAgent.js.map +0 -1
- package/dist/agents/MediumVideoAgent.d.ts +0 -3
- package/dist/agents/MediumVideoAgent.d.ts.map +0 -1
- package/dist/agents/MediumVideoAgent.js +0 -219
- package/dist/agents/MediumVideoAgent.js.map +0 -1
- package/dist/agents/ShortsAgent.d.ts +0 -3
- package/dist/agents/ShortsAgent.d.ts.map +0 -1
- package/dist/agents/ShortsAgent.js +0 -243
- package/dist/agents/ShortsAgent.js.map +0 -1
- package/dist/agents/SilenceRemovalAgent.d.ts +0 -9
- package/dist/agents/SilenceRemovalAgent.d.ts.map +0 -1
- package/dist/agents/SilenceRemovalAgent.js +0 -209
- package/dist/agents/SilenceRemovalAgent.js.map +0 -1
- package/dist/agents/SocialMediaAgent.d.ts +0 -4
- package/dist/agents/SocialMediaAgent.d.ts.map +0 -1
- package/dist/agents/SocialMediaAgent.js +0 -248
- package/dist/agents/SocialMediaAgent.js.map +0 -1
- package/dist/agents/SummaryAgent.d.ts +0 -11
- package/dist/agents/SummaryAgent.d.ts.map +0 -1
- package/dist/agents/SummaryAgent.js +0 -333
- package/dist/agents/SummaryAgent.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -4
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -230
- package/dist/commands/doctor.js.map +0 -1
- package/dist/config/brand.d.ts +0 -29
- package/dist/config/brand.d.ts.map +0 -1
- package/dist/config/brand.js +0 -83
- package/dist/config/brand.js.map +0 -1
- package/dist/config/environment.d.ts +0 -39
- package/dist/config/environment.d.ts.map +0 -1
- package/dist/config/environment.js +0 -47
- package/dist/config/environment.js.map +0 -1
- package/dist/config/ffmpegResolver.d.ts +0 -3
- package/dist/config/ffmpegResolver.d.ts.map +0 -1
- package/dist/config/ffmpegResolver.js +0 -37
- package/dist/config/ffmpegResolver.js.map +0 -1
- package/dist/config/logger.d.ts +0 -5
- package/dist/config/logger.d.ts.map +0 -1
- package/dist/config/logger.js +0 -13
- package/dist/config/logger.js.map +0 -1
- package/dist/config/pricing.d.ts +0 -34
- package/dist/config/pricing.d.ts.map +0 -1
- package/dist/config/pricing.js +0 -71
- package/dist/config/pricing.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/pipeline.d.ts +0 -57
- package/dist/pipeline.d.ts.map +0 -1
- package/dist/pipeline.js +0 -324
- package/dist/pipeline.js.map +0 -1
- package/dist/providers/ClaudeProvider.d.ts +0 -14
- package/dist/providers/ClaudeProvider.d.ts.map +0 -1
- package/dist/providers/ClaudeProvider.js +0 -182
- package/dist/providers/ClaudeProvider.js.map +0 -1
- package/dist/providers/CopilotProvider.d.ts +0 -17
- package/dist/providers/CopilotProvider.d.ts.map +0 -1
- package/dist/providers/CopilotProvider.js +0 -149
- package/dist/providers/CopilotProvider.js.map +0 -1
- package/dist/providers/OpenAIProvider.d.ts +0 -14
- package/dist/providers/OpenAIProvider.d.ts.map +0 -1
- package/dist/providers/OpenAIProvider.js +0 -175
- package/dist/providers/OpenAIProvider.js.map +0 -1
- package/dist/providers/index.d.ts +0 -18
- package/dist/providers/index.d.ts.map +0 -1
- package/dist/providers/index.js +0 -61
- package/dist/providers/index.js.map +0 -1
- package/dist/providers/types.d.ts +0 -112
- package/dist/providers/types.d.ts.map +0 -1
- package/dist/providers/types.js +0 -8
- package/dist/providers/types.js.map +0 -1
- package/dist/services/captionGeneration.d.ts +0 -7
- package/dist/services/captionGeneration.d.ts.map +0 -1
- package/dist/services/captionGeneration.js +0 -29
- package/dist/services/captionGeneration.js.map +0 -1
- package/dist/services/costTracker.d.ts +0 -63
- package/dist/services/costTracker.d.ts.map +0 -1
- package/dist/services/costTracker.js +0 -137
- package/dist/services/costTracker.js.map +0 -1
- package/dist/services/fileWatcher.d.ts +0 -19
- package/dist/services/fileWatcher.d.ts.map +0 -1
- package/dist/services/fileWatcher.js +0 -120
- package/dist/services/fileWatcher.js.map +0 -1
- package/dist/services/gitOperations.d.ts +0 -3
- package/dist/services/gitOperations.d.ts.map +0 -1
- package/dist/services/gitOperations.js +0 -43
- package/dist/services/gitOperations.js.map +0 -1
- package/dist/services/socialPosting.d.ts +0 -38
- package/dist/services/socialPosting.d.ts.map +0 -1
- package/dist/services/socialPosting.js +0 -102
- package/dist/services/socialPosting.js.map +0 -1
- package/dist/services/transcription.d.ts +0 -3
- package/dist/services/transcription.d.ts.map +0 -1
- package/dist/services/transcription.js +0 -100
- package/dist/services/transcription.js.map +0 -1
- package/dist/services/videoIngestion.d.ts +0 -3
- package/dist/services/videoIngestion.d.ts.map +0 -1
- package/dist/services/videoIngestion.js +0 -104
- package/dist/services/videoIngestion.js.map +0 -1
- package/dist/tools/captions/captionGenerator.d.ts +0 -84
- package/dist/tools/captions/captionGenerator.d.ts.map +0 -1
- package/dist/tools/captions/captionGenerator.js +0 -390
- package/dist/tools/captions/captionGenerator.js.map +0 -1
- package/dist/tools/ffmpeg/aspectRatio.d.ts +0 -101
- package/dist/tools/ffmpeg/aspectRatio.d.ts.map +0 -1
- package/dist/tools/ffmpeg/aspectRatio.js +0 -339
- package/dist/tools/ffmpeg/aspectRatio.js.map +0 -1
- package/dist/tools/ffmpeg/audioExtraction.d.ts +0 -16
- package/dist/tools/ffmpeg/audioExtraction.d.ts.map +0 -1
- package/dist/tools/ffmpeg/audioExtraction.js +0 -87
- package/dist/tools/ffmpeg/audioExtraction.js.map +0 -1
- package/dist/tools/ffmpeg/captionBurning.d.ts +0 -8
- package/dist/tools/ffmpeg/captionBurning.d.ts.map +0 -1
- package/dist/tools/ffmpeg/captionBurning.js +0 -72
- package/dist/tools/ffmpeg/captionBurning.js.map +0 -1
- package/dist/tools/ffmpeg/clipExtraction.d.ts +0 -38
- package/dist/tools/ffmpeg/clipExtraction.d.ts.map +0 -1
- package/dist/tools/ffmpeg/clipExtraction.js +0 -215
- package/dist/tools/ffmpeg/clipExtraction.js.map +0 -1
- package/dist/tools/ffmpeg/faceDetection.d.ts +0 -127
- package/dist/tools/ffmpeg/faceDetection.d.ts.map +0 -1
- package/dist/tools/ffmpeg/faceDetection.js +0 -501
- package/dist/tools/ffmpeg/faceDetection.js.map +0 -1
- package/dist/tools/ffmpeg/frameCapture.d.ts +0 -10
- package/dist/tools/ffmpeg/frameCapture.d.ts.map +0 -1
- package/dist/tools/ffmpeg/frameCapture.js +0 -49
- package/dist/tools/ffmpeg/frameCapture.js.map +0 -1
- package/dist/tools/ffmpeg/silenceDetection.d.ts +0 -10
- package/dist/tools/ffmpeg/silenceDetection.d.ts.map +0 -1
- package/dist/tools/ffmpeg/silenceDetection.js +0 -56
- package/dist/tools/ffmpeg/silenceDetection.js.map +0 -1
- package/dist/tools/ffmpeg/singlePassEdit.d.ts +0 -25
- package/dist/tools/ffmpeg/singlePassEdit.d.ts.map +0 -1
- package/dist/tools/ffmpeg/singlePassEdit.js +0 -124
- package/dist/tools/ffmpeg/singlePassEdit.js.map +0 -1
- package/dist/tools/search/exaClient.d.ts +0 -8
- package/dist/tools/search/exaClient.d.ts.map +0 -1
- package/dist/tools/search/exaClient.js +0 -38
- package/dist/tools/search/exaClient.js.map +0 -1
- package/dist/tools/whisper/whisperClient.d.ts +0 -3
- package/dist/tools/whisper/whisperClient.d.ts.map +0 -1
- package/dist/tools/whisper/whisperClient.js +0 -77
- package/dist/tools/whisper/whisperClient.js.map +0 -1
- package/dist/types/index.d.ts +0 -305
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -44
- package/dist/types/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,143 +1,4786 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/config/environment.ts
|
|
7
|
+
import path from "path";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import dotenv from "dotenv";
|
|
10
|
+
var envPath = path.join(process.cwd(), ".env");
|
|
11
|
+
if (fs.existsSync(envPath)) {
|
|
12
|
+
dotenv.config({ path: envPath });
|
|
13
|
+
}
|
|
14
|
+
var config = null;
|
|
15
|
+
function validateRequiredKeys() {
|
|
16
|
+
if (!config?.OPENAI_API_KEY && !process.env.OPENAI_API_KEY) {
|
|
17
|
+
throw new Error("Missing required: OPENAI_API_KEY (set via --openai-key or env var)");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function initConfig(cli = {}) {
|
|
21
|
+
const repoRoot = process.env.REPO_ROOT || process.cwd();
|
|
22
|
+
config = {
|
|
23
|
+
OPENAI_API_KEY: cli.openaiKey || process.env.OPENAI_API_KEY || "",
|
|
24
|
+
WATCH_FOLDER: cli.watchDir || process.env.WATCH_FOLDER || path.join(repoRoot, "watch"),
|
|
25
|
+
REPO_ROOT: repoRoot,
|
|
26
|
+
FFMPEG_PATH: process.env.FFMPEG_PATH || "ffmpeg",
|
|
27
|
+
// legacy; prefer ffmpegResolver
|
|
28
|
+
FFPROBE_PATH: process.env.FFPROBE_PATH || "ffprobe",
|
|
29
|
+
// legacy; prefer ffmpegResolver
|
|
30
|
+
EXA_API_KEY: cli.exaKey || process.env.EXA_API_KEY || "",
|
|
31
|
+
LLM_PROVIDER: process.env.LLM_PROVIDER || "copilot",
|
|
32
|
+
LLM_MODEL: process.env.LLM_MODEL || "",
|
|
33
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "",
|
|
34
|
+
OUTPUT_DIR: cli.outputDir || process.env.OUTPUT_DIR || path.join(repoRoot, "recordings"),
|
|
35
|
+
BRAND_PATH: cli.brand || process.env.BRAND_PATH || path.join(repoRoot, "brand.json"),
|
|
36
|
+
VERBOSE: cli.verbose ?? false,
|
|
37
|
+
SKIP_GIT: cli.git === false,
|
|
38
|
+
SKIP_SILENCE_REMOVAL: cli.silenceRemoval === false,
|
|
39
|
+
SKIP_SHORTS: cli.shorts === false,
|
|
40
|
+
SKIP_MEDIUM_CLIPS: cli.mediumClips === false,
|
|
41
|
+
SKIP_SOCIAL: cli.social === false,
|
|
42
|
+
SKIP_CAPTIONS: cli.captions === false
|
|
43
|
+
};
|
|
44
|
+
return config;
|
|
45
|
+
}
|
|
46
|
+
function getConfig() {
|
|
47
|
+
if (config) {
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
return initConfig();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/services/fileWatcher.ts
|
|
54
|
+
import { watch } from "chokidar";
|
|
55
|
+
import { EventEmitter } from "events";
|
|
56
|
+
import path2 from "path";
|
|
57
|
+
import fs2 from "fs";
|
|
58
|
+
|
|
59
|
+
// src/config/logger.ts
|
|
60
|
+
import winston from "winston";
|
|
61
|
+
var logger = winston.createLogger({
|
|
62
|
+
level: "info",
|
|
63
|
+
format: winston.format.combine(
|
|
64
|
+
winston.format.timestamp(),
|
|
65
|
+
winston.format.printf(({ timestamp, level, message }) => {
|
|
66
|
+
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
|
67
|
+
})
|
|
68
|
+
),
|
|
69
|
+
transports: [new winston.transports.Console()]
|
|
70
|
+
});
|
|
71
|
+
function setVerbose() {
|
|
72
|
+
logger.level = "debug";
|
|
73
|
+
}
|
|
74
|
+
var logger_default = logger;
|
|
75
|
+
|
|
76
|
+
// src/services/fileWatcher.ts
|
|
77
|
+
var FileWatcher = class _FileWatcher extends EventEmitter {
|
|
78
|
+
watchFolder;
|
|
79
|
+
watcher = null;
|
|
80
|
+
processExisting;
|
|
81
|
+
constructor(options = {}) {
|
|
82
|
+
super();
|
|
83
|
+
const config2 = getConfig();
|
|
84
|
+
this.watchFolder = config2.WATCH_FOLDER;
|
|
85
|
+
this.processExisting = options.processExisting ?? false;
|
|
86
|
+
if (!fs2.existsSync(this.watchFolder)) {
|
|
87
|
+
fs2.mkdirSync(this.watchFolder, { recursive: true });
|
|
88
|
+
logger_default.info(`Created watch folder: ${this.watchFolder}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
static MIN_FILE_SIZE = 1024 * 1024;
|
|
92
|
+
// 1MB
|
|
93
|
+
static EXTRA_STABILITY_DELAY = 3e3;
|
|
94
|
+
/** Read file size, wait, read again — if it changed the file is still being written. */
|
|
95
|
+
async isFileStable(filePath) {
|
|
96
|
+
try {
|
|
97
|
+
const sizeBefore = fs2.statSync(filePath).size;
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, _FileWatcher.EXTRA_STABILITY_DELAY));
|
|
99
|
+
const sizeAfter = fs2.statSync(filePath).size;
|
|
100
|
+
return sizeBefore === sizeAfter;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async handleDetectedFile(filePath) {
|
|
106
|
+
if (path2.extname(filePath).toLowerCase() !== ".mp4") {
|
|
107
|
+
logger_default.debug(`[watcher] Ignoring non-mp4 file: ${filePath}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let fileSize;
|
|
111
|
+
try {
|
|
112
|
+
fileSize = fs2.statSync(filePath).size;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger_default.warn(`[watcher] Could not stat file (may have been removed): ${filePath}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
logger_default.debug(`[watcher] File size: ${(fileSize / 1024 / 1024).toFixed(1)} MB \u2014 ${filePath}`);
|
|
118
|
+
if (fileSize < _FileWatcher.MIN_FILE_SIZE) {
|
|
119
|
+
logger_default.warn(`Skipping small file (${fileSize} bytes), likely a failed recording: ${filePath}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const stable = await this.isFileStable(filePath);
|
|
123
|
+
if (!stable) {
|
|
124
|
+
logger_default.warn(`File is still being written, skipping for now: ${filePath}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
logger_default.info(`New video detected: ${filePath}`);
|
|
128
|
+
this.emit("new-video", filePath);
|
|
129
|
+
}
|
|
130
|
+
scanExistingFiles() {
|
|
131
|
+
const files = fs2.readdirSync(this.watchFolder);
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
if (path2.extname(file).toLowerCase() === ".mp4") {
|
|
134
|
+
const filePath = path2.join(this.watchFolder, file);
|
|
135
|
+
this.handleDetectedFile(filePath).catch(
|
|
136
|
+
(err) => logger_default.error(`Error processing ${filePath}: ${err instanceof Error ? err.message : String(err)}`)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
start() {
|
|
142
|
+
this.watcher = watch(this.watchFolder, {
|
|
143
|
+
persistent: true,
|
|
144
|
+
ignoreInitial: true,
|
|
145
|
+
depth: 0,
|
|
146
|
+
atomic: 100,
|
|
147
|
+
// Polling is more reliable on Windows for detecting renames (e.g. Bandicam temp→final)
|
|
148
|
+
usePolling: true,
|
|
149
|
+
interval: 500,
|
|
150
|
+
awaitWriteFinish: {
|
|
151
|
+
stabilityThreshold: 3e3,
|
|
152
|
+
pollInterval: 200
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
this.watcher.on("add", (filePath) => {
|
|
156
|
+
logger_default.debug(`[watcher] 'add' event: ${filePath}`);
|
|
157
|
+
this.handleDetectedFile(filePath).catch(
|
|
158
|
+
(err) => logger_default.error(`Error processing ${filePath}: ${err instanceof Error ? err.message : String(err)}`)
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
this.watcher.on("change", (filePath) => {
|
|
162
|
+
logger_default.debug(`[watcher] 'change' event: ${filePath}`);
|
|
163
|
+
if (path2.extname(filePath).toLowerCase() !== ".mp4") return;
|
|
164
|
+
logger_default.info(`Change detected on video file: ${filePath}`);
|
|
165
|
+
this.handleDetectedFile(filePath).catch(
|
|
166
|
+
(err) => logger_default.error(`Error processing ${filePath}: ${err instanceof Error ? err.message : String(err)}`)
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
this.watcher.on("unlink", (filePath) => {
|
|
170
|
+
logger_default.debug(`[watcher] 'unlink' event: ${filePath}`);
|
|
171
|
+
});
|
|
172
|
+
this.watcher.on("raw", (event, rawPath, details) => {
|
|
173
|
+
logger_default.debug(`[watcher] raw event=${event} path=${rawPath}`);
|
|
174
|
+
});
|
|
175
|
+
this.watcher.on("error", (error) => {
|
|
176
|
+
logger_default.error(`File watcher error: ${error instanceof Error ? error.message : String(error)}`);
|
|
177
|
+
});
|
|
178
|
+
this.watcher.on("ready", () => {
|
|
179
|
+
logger_default.info("File watcher is fully initialized and ready");
|
|
180
|
+
if (this.processExisting) {
|
|
181
|
+
this.scanExistingFiles();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
logger_default.info(`Watching for new .mp4 files in: ${this.watchFolder}`);
|
|
185
|
+
}
|
|
186
|
+
stop() {
|
|
187
|
+
if (this.watcher) {
|
|
188
|
+
this.watcher.close();
|
|
189
|
+
this.watcher = null;
|
|
190
|
+
logger_default.info("File watcher stopped");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// src/pipeline.ts
|
|
196
|
+
import path17 from "path";
|
|
197
|
+
import { promises as fs19 } from "fs";
|
|
198
|
+
|
|
199
|
+
// src/services/videoIngestion.ts
|
|
200
|
+
import path3 from "path";
|
|
201
|
+
import fs3 from "fs";
|
|
202
|
+
import fsp from "fs/promises";
|
|
203
|
+
import slugify from "slugify";
|
|
204
|
+
import ffmpeg from "fluent-ffmpeg";
|
|
205
|
+
|
|
206
|
+
// src/config/ffmpegResolver.ts
|
|
207
|
+
import { createRequire } from "module";
|
|
208
|
+
import { existsSync } from "fs";
|
|
209
|
+
var require2 = createRequire(import.meta.url);
|
|
210
|
+
function getFFmpegPath() {
|
|
211
|
+
if (process.env.FFMPEG_PATH) {
|
|
212
|
+
logger_default.debug(`FFmpeg: using FFMPEG_PATH env var: ${process.env.FFMPEG_PATH}`);
|
|
213
|
+
return process.env.FFMPEG_PATH;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const staticPath = require2("ffmpeg-static");
|
|
217
|
+
if (staticPath && existsSync(staticPath)) {
|
|
218
|
+
logger_default.debug(`FFmpeg: using ffmpeg-static: ${staticPath}`);
|
|
219
|
+
return staticPath;
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
logger_default.debug("FFmpeg: falling back to system PATH");
|
|
224
|
+
return "ffmpeg";
|
|
225
|
+
}
|
|
226
|
+
function getFFprobePath() {
|
|
227
|
+
if (process.env.FFPROBE_PATH) {
|
|
228
|
+
logger_default.debug(`FFprobe: using FFPROBE_PATH env var: ${process.env.FFPROBE_PATH}`);
|
|
229
|
+
return process.env.FFPROBE_PATH;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const { path: probePath } = require2("@ffprobe-installer/ffprobe");
|
|
233
|
+
if (probePath && existsSync(probePath)) {
|
|
234
|
+
logger_default.debug(`FFprobe: using @ffprobe-installer/ffprobe: ${probePath}`);
|
|
235
|
+
return probePath;
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
logger_default.debug("FFprobe: falling back to system PATH");
|
|
240
|
+
return "ffprobe";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/services/videoIngestion.ts
|
|
244
|
+
var ffmpegBin = getFFmpegPath();
|
|
245
|
+
var ffprobeBin = getFFprobePath();
|
|
246
|
+
ffmpeg.setFfmpegPath(ffmpegBin);
|
|
247
|
+
ffmpeg.setFfprobePath(ffprobeBin);
|
|
248
|
+
function getVideoMetadata(filePath) {
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
|
251
|
+
if (err) {
|
|
252
|
+
return reject(err);
|
|
253
|
+
}
|
|
254
|
+
resolve({ duration: metadata.format.duration ?? 0 });
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function ingestVideo(sourcePath) {
|
|
259
|
+
const config2 = getConfig();
|
|
260
|
+
const baseName = path3.basename(sourcePath, path3.extname(sourcePath));
|
|
261
|
+
const slug = slugify(baseName, { lower: true });
|
|
262
|
+
const recordingsDir = path3.join(config2.OUTPUT_DIR, slug);
|
|
263
|
+
const thumbnailsDir = path3.join(recordingsDir, "thumbnails");
|
|
264
|
+
const shortsDir = path3.join(recordingsDir, "shorts");
|
|
265
|
+
const socialPostsDir = path3.join(recordingsDir, "social-posts");
|
|
266
|
+
logger_default.info(`Ingesting video: ${sourcePath} \u2192 ${slug}`);
|
|
267
|
+
if (fs3.existsSync(recordingsDir)) {
|
|
268
|
+
logger_default.warn(`Output folder already exists, cleaning previous artifacts: ${recordingsDir}`);
|
|
269
|
+
const subDirs = ["thumbnails", "shorts", "social-posts", "chapters", "medium-clips", "captions"];
|
|
270
|
+
for (const sub of subDirs) {
|
|
271
|
+
await fsp.rm(path3.join(recordingsDir, sub), { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
const stalePatterns = [
|
|
274
|
+
"transcript.json",
|
|
275
|
+
"transcript-edited.json",
|
|
276
|
+
"captions.srt",
|
|
277
|
+
"captions.vtt",
|
|
278
|
+
"captions.ass",
|
|
279
|
+
"summary.md",
|
|
280
|
+
"blog-post.md",
|
|
281
|
+
"README.md"
|
|
282
|
+
];
|
|
283
|
+
for (const pattern of stalePatterns) {
|
|
284
|
+
await fsp.rm(path3.join(recordingsDir, pattern), { force: true });
|
|
285
|
+
}
|
|
286
|
+
const files = await fsp.readdir(recordingsDir);
|
|
287
|
+
for (const file of files) {
|
|
288
|
+
if (file.endsWith("-edited.mp4") || file.endsWith("-captioned.mp4")) {
|
|
289
|
+
await fsp.rm(path3.join(recordingsDir, file), { force: true });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
await fsp.mkdir(recordingsDir, { recursive: true });
|
|
294
|
+
await fsp.mkdir(thumbnailsDir, { recursive: true });
|
|
295
|
+
await fsp.mkdir(shortsDir, { recursive: true });
|
|
296
|
+
await fsp.mkdir(socialPostsDir, { recursive: true });
|
|
297
|
+
const destFilename = `${slug}.mp4`;
|
|
298
|
+
const destPath = path3.join(recordingsDir, destFilename);
|
|
299
|
+
let needsCopy = true;
|
|
300
|
+
try {
|
|
301
|
+
const destStats = await fsp.stat(destPath);
|
|
302
|
+
const srcStats = await fsp.stat(sourcePath);
|
|
303
|
+
if (destStats.size === srcStats.size) {
|
|
304
|
+
logger_default.info(`Video already copied (same size), skipping copy`);
|
|
305
|
+
needsCopy = false;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
if (needsCopy) {
|
|
310
|
+
await new Promise((resolve, reject) => {
|
|
311
|
+
const readStream = fs3.createReadStream(sourcePath);
|
|
312
|
+
const writeStream = fs3.createWriteStream(destPath);
|
|
313
|
+
readStream.on("error", reject);
|
|
314
|
+
writeStream.on("error", reject);
|
|
315
|
+
writeStream.on("finish", resolve);
|
|
316
|
+
readStream.pipe(writeStream);
|
|
317
|
+
});
|
|
318
|
+
logger_default.info(`Copied video to ${destPath}`);
|
|
319
|
+
}
|
|
320
|
+
let duration = 0;
|
|
321
|
+
try {
|
|
322
|
+
const meta = await getVideoMetadata(destPath);
|
|
323
|
+
duration = meta.duration;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
logger_default.warn(`ffprobe failed, continuing without duration metadata: ${err instanceof Error ? err.message : String(err)}`);
|
|
326
|
+
}
|
|
327
|
+
const stats = await fsp.stat(destPath);
|
|
328
|
+
logger_default.info(`Video metadata: duration=${duration}s, size=${stats.size} bytes`);
|
|
329
|
+
return {
|
|
330
|
+
originalPath: sourcePath,
|
|
331
|
+
repoPath: destPath,
|
|
332
|
+
videoDir: recordingsDir,
|
|
333
|
+
slug,
|
|
334
|
+
filename: destFilename,
|
|
335
|
+
duration,
|
|
336
|
+
size: stats.size,
|
|
337
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/services/transcription.ts
|
|
342
|
+
import path5 from "path";
|
|
343
|
+
import fsp2 from "fs/promises";
|
|
344
|
+
|
|
345
|
+
// src/tools/ffmpeg/audioExtraction.ts
|
|
346
|
+
import ffmpeg2 from "fluent-ffmpeg";
|
|
347
|
+
import { promises as fs4 } from "fs";
|
|
348
|
+
import path4 from "path";
|
|
349
|
+
var ffmpegPath = getFFmpegPath();
|
|
350
|
+
var ffprobePath = getFFprobePath();
|
|
351
|
+
ffmpeg2.setFfmpegPath(ffmpegPath);
|
|
352
|
+
ffmpeg2.setFfprobePath(ffprobePath);
|
|
353
|
+
async function extractAudio(videoPath, outputPath, options = {}) {
|
|
354
|
+
const { format = "mp3" } = options;
|
|
355
|
+
const outputDir = path4.dirname(outputPath);
|
|
356
|
+
await fs4.mkdir(outputDir, { recursive: true });
|
|
357
|
+
logger_default.info(`Extracting audio (${format}): ${videoPath} \u2192 ${outputPath}`);
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const command = ffmpeg2(videoPath).noVideo().audioChannels(1);
|
|
360
|
+
if (format === "mp3") {
|
|
361
|
+
command.audioCodec("libmp3lame").audioBitrate("64k").audioFrequency(16e3);
|
|
362
|
+
} else {
|
|
363
|
+
command.audioCodec("pcm_s16le").audioFrequency(16e3);
|
|
364
|
+
}
|
|
365
|
+
command.output(outputPath).on("end", () => {
|
|
366
|
+
logger_default.info(`Audio extraction complete: ${outputPath}`);
|
|
367
|
+
resolve(outputPath);
|
|
368
|
+
}).on("error", (err) => {
|
|
369
|
+
logger_default.error(`Audio extraction failed: ${err.message}`);
|
|
370
|
+
reject(new Error(`Audio extraction failed: ${err.message}`));
|
|
371
|
+
}).run();
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async function splitAudioIntoChunks(audioPath, maxChunkSizeMB = 24) {
|
|
375
|
+
const stats = await fs4.stat(audioPath);
|
|
376
|
+
const fileSizeMB = stats.size / (1024 * 1024);
|
|
377
|
+
if (fileSizeMB <= maxChunkSizeMB) {
|
|
378
|
+
return [audioPath];
|
|
379
|
+
}
|
|
380
|
+
const duration = await getAudioDuration(audioPath);
|
|
381
|
+
const numChunks = Math.ceil(fileSizeMB / maxChunkSizeMB);
|
|
382
|
+
const chunkDuration = duration / numChunks;
|
|
383
|
+
const ext = path4.extname(audioPath);
|
|
384
|
+
const base = audioPath.slice(0, -ext.length);
|
|
385
|
+
const chunkPaths = [];
|
|
386
|
+
logger_default.info(
|
|
387
|
+
`Splitting ${fileSizeMB.toFixed(1)}MB audio into ${numChunks} chunks (~${chunkDuration.toFixed(0)}s each)`
|
|
388
|
+
);
|
|
389
|
+
for (let i = 0; i < numChunks; i++) {
|
|
390
|
+
const startTime = i * chunkDuration;
|
|
391
|
+
const chunkPath = `${base}_chunk${i}${ext}`;
|
|
392
|
+
chunkPaths.push(chunkPath);
|
|
393
|
+
await new Promise((resolve, reject) => {
|
|
394
|
+
const cmd = ffmpeg2(audioPath).setStartTime(startTime).setDuration(chunkDuration).audioCodec("copy").output(chunkPath).on("end", () => resolve()).on("error", (err) => reject(new Error(`Chunk split failed: ${err.message}`)));
|
|
395
|
+
cmd.run();
|
|
396
|
+
});
|
|
397
|
+
logger_default.info(`Created chunk ${i + 1}/${numChunks}: ${chunkPath}`);
|
|
398
|
+
}
|
|
399
|
+
return chunkPaths;
|
|
400
|
+
}
|
|
401
|
+
function getAudioDuration(audioPath) {
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
ffmpeg2.ffprobe(audioPath, (err, metadata) => {
|
|
404
|
+
if (err) return reject(new Error(`ffprobe failed: ${err.message}`));
|
|
405
|
+
resolve(metadata.format.duration ?? 0);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/tools/whisper/whisperClient.ts
|
|
411
|
+
import OpenAI from "openai";
|
|
412
|
+
import fs6 from "fs";
|
|
413
|
+
|
|
414
|
+
// src/config/brand.ts
|
|
415
|
+
import fs5 from "fs";
|
|
416
|
+
var defaultBrand = {
|
|
417
|
+
name: "Creator",
|
|
418
|
+
handle: "@creator",
|
|
419
|
+
tagline: "",
|
|
420
|
+
voice: {
|
|
421
|
+
tone: "professional, friendly",
|
|
422
|
+
personality: "A knowledgeable content creator.",
|
|
423
|
+
style: "Clear and concise."
|
|
424
|
+
},
|
|
425
|
+
advocacy: {
|
|
426
|
+
primary: [],
|
|
427
|
+
interests: [],
|
|
428
|
+
avoids: []
|
|
429
|
+
},
|
|
430
|
+
customVocabulary: [],
|
|
431
|
+
hashtags: {
|
|
432
|
+
always: [],
|
|
433
|
+
preferred: [],
|
|
434
|
+
platforms: {}
|
|
435
|
+
},
|
|
436
|
+
contentGuidelines: {
|
|
437
|
+
shortsFocus: "Highlight key moments and insights.",
|
|
438
|
+
blogFocus: "Educational and informative content.",
|
|
439
|
+
socialFocus: "Engaging and authentic posts."
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
var cachedBrand = null;
|
|
443
|
+
function validateBrandConfig(brand) {
|
|
444
|
+
const requiredStrings = ["name", "handle", "tagline"];
|
|
445
|
+
for (const field of requiredStrings) {
|
|
446
|
+
if (!brand[field]) {
|
|
447
|
+
logger_default.warn(`brand.json: missing or empty field "${field}"`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const requiredObjects = [
|
|
451
|
+
{ key: "voice", subKeys: ["tone", "personality", "style"] },
|
|
452
|
+
{ key: "advocacy", subKeys: ["primary", "interests"] },
|
|
453
|
+
{ key: "hashtags", subKeys: ["always", "preferred"] },
|
|
454
|
+
{ key: "contentGuidelines", subKeys: ["shortsFocus", "blogFocus", "socialFocus"] }
|
|
455
|
+
];
|
|
456
|
+
for (const { key, subKeys } of requiredObjects) {
|
|
457
|
+
if (!brand[key]) {
|
|
458
|
+
logger_default.warn(`brand.json: missing section "${key}"`);
|
|
459
|
+
} else {
|
|
460
|
+
const section = brand[key];
|
|
461
|
+
for (const sub of subKeys) {
|
|
462
|
+
if (!section[sub] || Array.isArray(section[sub]) && section[sub].length === 0) {
|
|
463
|
+
logger_default.warn(`brand.json: missing or empty field "${key}.${sub}"`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!brand.customVocabulary || brand.customVocabulary.length === 0) {
|
|
469
|
+
logger_default.warn('brand.json: "customVocabulary" is empty \u2014 Whisper prompt will be blank');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function getBrandConfig() {
|
|
473
|
+
if (cachedBrand) return cachedBrand;
|
|
474
|
+
const config2 = getConfig();
|
|
475
|
+
const brandPath = config2.BRAND_PATH;
|
|
476
|
+
if (!fs5.existsSync(brandPath)) {
|
|
477
|
+
logger_default.warn("brand.json not found \u2014 using defaults");
|
|
478
|
+
cachedBrand = { ...defaultBrand };
|
|
479
|
+
return cachedBrand;
|
|
480
|
+
}
|
|
481
|
+
const raw = fs5.readFileSync(brandPath, "utf-8");
|
|
482
|
+
cachedBrand = JSON.parse(raw);
|
|
483
|
+
validateBrandConfig(cachedBrand);
|
|
484
|
+
logger_default.info(`Brand config loaded: ${cachedBrand.name}`);
|
|
485
|
+
return cachedBrand;
|
|
486
|
+
}
|
|
487
|
+
function getWhisperPrompt() {
|
|
488
|
+
const brand = getBrandConfig();
|
|
489
|
+
return brand.customVocabulary.join(", ");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/tools/whisper/whisperClient.ts
|
|
493
|
+
var MAX_FILE_SIZE_MB = 25;
|
|
494
|
+
var WARN_FILE_SIZE_MB = 20;
|
|
495
|
+
async function transcribeAudio(audioPath) {
|
|
496
|
+
logger_default.info(`Starting Whisper transcription: ${audioPath}`);
|
|
497
|
+
if (!fs6.existsSync(audioPath)) {
|
|
498
|
+
throw new Error(`Audio file not found: ${audioPath}`);
|
|
499
|
+
}
|
|
500
|
+
const stats = fs6.statSync(audioPath);
|
|
501
|
+
const fileSizeMB = stats.size / (1024 * 1024);
|
|
502
|
+
if (fileSizeMB > MAX_FILE_SIZE_MB) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`Audio file exceeds Whisper's 25MB limit (${fileSizeMB.toFixed(1)}MB). The file should be split into smaller chunks before transcription.`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (fileSizeMB > WARN_FILE_SIZE_MB) {
|
|
508
|
+
logger_default.warn(`Audio file is ${fileSizeMB.toFixed(1)}MB \u2014 approaching 25MB limit`);
|
|
509
|
+
}
|
|
510
|
+
const config2 = getConfig();
|
|
511
|
+
const openai = new OpenAI({ apiKey: config2.OPENAI_API_KEY });
|
|
512
|
+
try {
|
|
513
|
+
const prompt = getWhisperPrompt();
|
|
514
|
+
const response = await openai.audio.transcriptions.create({
|
|
515
|
+
model: "whisper-1",
|
|
516
|
+
file: fs6.createReadStream(audioPath),
|
|
517
|
+
response_format: "verbose_json",
|
|
518
|
+
timestamp_granularities: ["word", "segment"],
|
|
519
|
+
...prompt && { prompt }
|
|
520
|
+
});
|
|
521
|
+
const verboseResponse = response;
|
|
522
|
+
const rawSegments = verboseResponse.segments ?? [];
|
|
523
|
+
const rawWords = verboseResponse.words ?? [];
|
|
524
|
+
const words = rawWords.map((w) => ({
|
|
525
|
+
word: w.word,
|
|
526
|
+
start: w.start,
|
|
527
|
+
end: w.end
|
|
528
|
+
}));
|
|
529
|
+
const segments = rawSegments.map((s) => ({
|
|
530
|
+
id: s.id,
|
|
531
|
+
text: s.text.trim(),
|
|
532
|
+
start: s.start,
|
|
533
|
+
end: s.end,
|
|
534
|
+
words: rawWords.filter((w) => w.start >= s.start && w.end <= s.end).map((w) => ({ word: w.word, start: w.start, end: w.end }))
|
|
535
|
+
}));
|
|
536
|
+
logger_default.info(
|
|
537
|
+
`Transcription complete \u2014 ${segments.length} segments, ${words.length} words, language=${response.language}`
|
|
538
|
+
);
|
|
539
|
+
return {
|
|
540
|
+
text: response.text,
|
|
541
|
+
segments,
|
|
542
|
+
words,
|
|
543
|
+
language: response.language ?? "unknown",
|
|
544
|
+
duration: response.duration ?? 0
|
|
545
|
+
};
|
|
546
|
+
} catch (error) {
|
|
547
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
548
|
+
logger_default.error(`Whisper transcription failed: ${message}`);
|
|
549
|
+
const status = error.status;
|
|
550
|
+
if (status === 401) {
|
|
551
|
+
throw new Error("OpenAI API authentication failed. Check your OPENAI_API_KEY.");
|
|
552
|
+
}
|
|
553
|
+
if (status === 429) {
|
|
554
|
+
throw new Error("OpenAI API rate limit exceeded. Please try again later.");
|
|
555
|
+
}
|
|
556
|
+
throw new Error(`Whisper transcription failed: ${message}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/services/transcription.ts
|
|
561
|
+
var MAX_WHISPER_SIZE_MB = 25;
|
|
562
|
+
async function transcribeVideo(video) {
|
|
563
|
+
const config2 = getConfig();
|
|
564
|
+
const cacheDir = path5.join(config2.REPO_ROOT, "cache");
|
|
565
|
+
await fsp2.mkdir(cacheDir, { recursive: true });
|
|
566
|
+
logger_default.info(`Cache directory ready: ${cacheDir}`);
|
|
567
|
+
const mp3Path = path5.join(cacheDir, `${video.slug}.mp3`);
|
|
568
|
+
logger_default.info(`Extracting audio for "${video.slug}"`);
|
|
569
|
+
await extractAudio(video.repoPath, mp3Path);
|
|
570
|
+
const stats = await fsp2.stat(mp3Path);
|
|
571
|
+
const fileSizeMB = stats.size / (1024 * 1024);
|
|
572
|
+
logger_default.info(`Extracted audio: ${fileSizeMB.toFixed(1)}MB`);
|
|
573
|
+
let transcript;
|
|
574
|
+
if (fileSizeMB <= MAX_WHISPER_SIZE_MB) {
|
|
575
|
+
logger_default.info(`Transcribing audio for "${video.slug}"`);
|
|
576
|
+
transcript = await transcribeAudio(mp3Path);
|
|
577
|
+
} else {
|
|
578
|
+
logger_default.info(`Audio exceeds ${MAX_WHISPER_SIZE_MB}MB, splitting into chunks`);
|
|
579
|
+
const chunkPaths = await splitAudioIntoChunks(mp3Path);
|
|
580
|
+
transcript = await transcribeChunks(chunkPaths);
|
|
581
|
+
for (const chunkPath of chunkPaths) {
|
|
582
|
+
if (chunkPath !== mp3Path) {
|
|
583
|
+
await fsp2.unlink(chunkPath).catch(() => {
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const transcriptDir = path5.join(config2.OUTPUT_DIR, video.slug);
|
|
589
|
+
await fsp2.mkdir(transcriptDir, { recursive: true });
|
|
590
|
+
const transcriptPath = path5.join(transcriptDir, "transcript.json");
|
|
591
|
+
await fsp2.writeFile(transcriptPath, JSON.stringify(transcript, null, 2), "utf-8");
|
|
592
|
+
logger_default.info(`Transcript saved: ${transcriptPath}`);
|
|
593
|
+
await fsp2.unlink(mp3Path).catch(() => {
|
|
594
|
+
});
|
|
595
|
+
logger_default.info(`Cleaned up temp file: ${mp3Path}`);
|
|
596
|
+
logger_default.info(
|
|
597
|
+
`Transcription complete for "${video.slug}" \u2014 ${transcript.segments.length} segments, ${transcript.words.length} words`
|
|
598
|
+
);
|
|
599
|
+
return transcript;
|
|
600
|
+
}
|
|
601
|
+
async function transcribeChunks(chunkPaths) {
|
|
602
|
+
let allText = "";
|
|
603
|
+
const allSegments = [];
|
|
604
|
+
const allWords = [];
|
|
605
|
+
let cumulativeOffset = 0;
|
|
606
|
+
let totalDuration = 0;
|
|
607
|
+
let language = "unknown";
|
|
608
|
+
for (let i = 0; i < chunkPaths.length; i++) {
|
|
609
|
+
logger_default.info(`Transcribing chunk ${i + 1}/${chunkPaths.length}: ${chunkPaths[i]}`);
|
|
610
|
+
const result = await transcribeAudio(chunkPaths[i]);
|
|
611
|
+
if (i === 0) language = result.language;
|
|
612
|
+
const offsetSegments = result.segments.map((s) => ({
|
|
613
|
+
...s,
|
|
614
|
+
id: allSegments.length + s.id,
|
|
615
|
+
start: s.start + cumulativeOffset,
|
|
616
|
+
end: s.end + cumulativeOffset,
|
|
617
|
+
words: s.words.map((w) => ({
|
|
618
|
+
...w,
|
|
619
|
+
start: w.start + cumulativeOffset,
|
|
620
|
+
end: w.end + cumulativeOffset
|
|
621
|
+
}))
|
|
622
|
+
}));
|
|
623
|
+
const offsetWords = result.words.map((w) => ({
|
|
624
|
+
...w,
|
|
625
|
+
start: w.start + cumulativeOffset,
|
|
626
|
+
end: w.end + cumulativeOffset
|
|
627
|
+
}));
|
|
628
|
+
allText += (allText ? " " : "") + result.text;
|
|
629
|
+
allSegments.push(...offsetSegments);
|
|
630
|
+
allWords.push(...offsetWords);
|
|
631
|
+
cumulativeOffset += result.duration;
|
|
632
|
+
totalDuration += result.duration;
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
text: allText,
|
|
636
|
+
segments: allSegments,
|
|
637
|
+
words: allWords,
|
|
638
|
+
language,
|
|
639
|
+
duration: totalDuration
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/services/captionGeneration.ts
|
|
644
|
+
import path6 from "path";
|
|
645
|
+
import fsp3 from "fs/promises";
|
|
646
|
+
|
|
647
|
+
// src/tools/captions/captionGenerator.ts
|
|
648
|
+
function pad(n, width) {
|
|
649
|
+
return String(n).padStart(width, "0");
|
|
650
|
+
}
|
|
651
|
+
function toSRT(seconds) {
|
|
652
|
+
const h = Math.floor(seconds / 3600);
|
|
653
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
654
|
+
const s = Math.floor(seconds % 60);
|
|
655
|
+
const ms = Math.round((seconds - Math.floor(seconds)) * 1e3);
|
|
656
|
+
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`;
|
|
657
|
+
}
|
|
658
|
+
function toVTT(seconds) {
|
|
659
|
+
return toSRT(seconds).replace(",", ".");
|
|
660
|
+
}
|
|
661
|
+
function toASS(seconds) {
|
|
662
|
+
const h = Math.floor(seconds / 3600);
|
|
663
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
664
|
+
const s = Math.floor(seconds % 60);
|
|
665
|
+
const cs = Math.round((seconds - Math.floor(seconds)) * 100);
|
|
666
|
+
return `${h}:${pad(m, 2)}:${pad(s, 2)}.${pad(cs, 2)}`;
|
|
667
|
+
}
|
|
668
|
+
var SILENCE_GAP_THRESHOLD = 0.8;
|
|
669
|
+
var MAX_WORDS_PER_GROUP = 8;
|
|
670
|
+
var WORDS_PER_LINE = 4;
|
|
671
|
+
var ACTIVE_COLOR = "\\c&H00FFFF&";
|
|
672
|
+
var BASE_COLOR = "\\c&HFFFFFF&";
|
|
673
|
+
var ACTIVE_FONT_SIZE = 54;
|
|
674
|
+
var BASE_FONT_SIZE = 42;
|
|
675
|
+
var MEDIUM_ACTIVE_FONT_SIZE = 40;
|
|
676
|
+
var MEDIUM_BASE_FONT_SIZE = 32;
|
|
677
|
+
var PORTRAIT_ACTIVE_FONT_SIZE = 78;
|
|
678
|
+
var PORTRAIT_BASE_FONT_SIZE = 66;
|
|
679
|
+
var PORTRAIT_ACTIVE_COLOR = "\\c&H00FF00&";
|
|
680
|
+
var PORTRAIT_BASE_COLOR = "\\c&HFFFFFF&";
|
|
681
|
+
function generateSRT(transcript) {
|
|
682
|
+
return transcript.segments.map((seg, i) => {
|
|
683
|
+
const idx = i + 1;
|
|
684
|
+
const start = toSRT(seg.start);
|
|
685
|
+
const end = toSRT(seg.end);
|
|
686
|
+
const text = seg.text.trim();
|
|
687
|
+
return `${idx}
|
|
688
|
+
${start} --> ${end}
|
|
689
|
+
${text}`;
|
|
690
|
+
}).join("\n\n").concat("\n");
|
|
691
|
+
}
|
|
692
|
+
function generateVTT(transcript) {
|
|
693
|
+
const cues = transcript.segments.map((seg) => {
|
|
694
|
+
const start = toVTT(seg.start);
|
|
695
|
+
const end = toVTT(seg.end);
|
|
696
|
+
const text = seg.text.trim();
|
|
697
|
+
return `${start} --> ${end}
|
|
698
|
+
${text}`;
|
|
699
|
+
}).join("\n\n");
|
|
700
|
+
return `WEBVTT
|
|
701
|
+
|
|
702
|
+
${cues}
|
|
17
703
|
`;
|
|
18
|
-
const program = new Command();
|
|
19
|
-
program
|
|
20
|
-
.name('vidpipe')
|
|
21
|
-
.description('AI-powered video content pipeline: transcribe, summarize, generate shorts, captions, and social posts')
|
|
22
|
-
.version(pkg.version, '-V, --version')
|
|
23
|
-
.argument('[video-path]', 'Path to a video file to process (implies --once)')
|
|
24
|
-
.option('--watch-dir <path>', 'Folder to watch for new recordings (default: env WATCH_FOLDER)')
|
|
25
|
-
.option('--output-dir <path>', 'Output directory for processed videos (default: ./recordings)')
|
|
26
|
-
.option('--openai-key <key>', 'OpenAI API key (default: env OPENAI_API_KEY)')
|
|
27
|
-
.option('--exa-key <key>', 'Exa AI API key for web search (default: env EXA_API_KEY)')
|
|
28
|
-
.option('--once', 'Process a single video and exit (no watching)')
|
|
29
|
-
.option('--brand <path>', 'Path to brand.json config (default: ./brand.json)')
|
|
30
|
-
.option('--no-git', 'Skip git commit/push stage')
|
|
31
|
-
.option('--no-silence-removal', 'Skip silence removal stage')
|
|
32
|
-
.option('--no-shorts', 'Skip shorts generation')
|
|
33
|
-
.option('--no-medium-clips', 'Skip medium clip generation')
|
|
34
|
-
.option('--no-social', 'Skip social media post generation')
|
|
35
|
-
.option('--no-captions', 'Skip caption generation/burning')
|
|
36
|
-
.option('-v, --verbose', 'Verbose logging')
|
|
37
|
-
.option('--doctor', 'Check all prerequisites and exit');
|
|
38
|
-
program.parse();
|
|
39
|
-
const opts = program.opts();
|
|
40
|
-
// Handle --doctor before anything else
|
|
41
|
-
if (opts.doctor) {
|
|
42
|
-
runDoctor();
|
|
43
|
-
// runDoctor() calls process.exit(); this is a safety fallback
|
|
44
|
-
process.exit(0);
|
|
45
704
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
705
|
+
var ASS_HEADER = `[Script Info]
|
|
706
|
+
Title: Auto-generated captions
|
|
707
|
+
ScriptType: v4.00+
|
|
708
|
+
PlayResX: 1920
|
|
709
|
+
PlayResY: 1080
|
|
710
|
+
WrapStyle: 0
|
|
711
|
+
|
|
712
|
+
[V4+ Styles]
|
|
713
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
714
|
+
Style: Default,Montserrat,42,&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,3,1,2,20,20,40,1
|
|
715
|
+
|
|
716
|
+
[Events]
|
|
717
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
718
|
+
`;
|
|
719
|
+
var ASS_HEADER_PORTRAIT = `[Script Info]
|
|
720
|
+
Title: Auto-generated captions
|
|
721
|
+
ScriptType: v4.00+
|
|
722
|
+
PlayResX: 1080
|
|
723
|
+
PlayResY: 1920
|
|
724
|
+
WrapStyle: 0
|
|
725
|
+
|
|
726
|
+
[V4+ Styles]
|
|
727
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
728
|
+
Style: Default,Montserrat,78,&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,3,1,2,30,30,700,1
|
|
729
|
+
Style: Hook,Montserrat,56,&H00333333,&H00333333,&H60D0D0D0,&H60E0E0E0,1,0,0,0,100,100,2,0,3,18,2,8,80,80,60,1
|
|
730
|
+
|
|
731
|
+
[Events]
|
|
732
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
733
|
+
`;
|
|
734
|
+
var ASS_HEADER_MEDIUM = `[Script Info]
|
|
735
|
+
Title: Auto-generated captions
|
|
736
|
+
ScriptType: v4.00+
|
|
737
|
+
PlayResX: 1920
|
|
738
|
+
PlayResY: 1080
|
|
739
|
+
WrapStyle: 0
|
|
740
|
+
|
|
741
|
+
[V4+ Styles]
|
|
742
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
743
|
+
Style: Default,Montserrat,32,&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,2,1,2,20,20,60,1
|
|
744
|
+
|
|
745
|
+
[Events]
|
|
746
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
747
|
+
`;
|
|
748
|
+
function groupWordsBySpeech(words) {
|
|
749
|
+
if (words.length === 0) return [];
|
|
750
|
+
const groups = [];
|
|
751
|
+
let current = [];
|
|
752
|
+
for (let i = 0; i < words.length; i++) {
|
|
753
|
+
current.push(words[i]);
|
|
754
|
+
const isLast = i === words.length - 1;
|
|
755
|
+
const hasGap = !isLast && words[i + 1].start - words[i].end > SILENCE_GAP_THRESHOLD;
|
|
756
|
+
const atMax = current.length >= MAX_WORDS_PER_GROUP;
|
|
757
|
+
if (isLast || hasGap || atMax) {
|
|
758
|
+
groups.push(current);
|
|
759
|
+
current = [];
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return groups;
|
|
763
|
+
}
|
|
764
|
+
function splitGroupIntoLines(group) {
|
|
765
|
+
if (group.length <= WORDS_PER_LINE) return [group];
|
|
766
|
+
const mid = Math.ceil(group.length / 2);
|
|
767
|
+
return [group.slice(0, mid), group.slice(mid)];
|
|
768
|
+
}
|
|
769
|
+
function buildPremiumDialogueLines(words, style = "shorts") {
|
|
770
|
+
const activeFontSize = style === "portrait" ? PORTRAIT_ACTIVE_FONT_SIZE : style === "medium" ? MEDIUM_ACTIVE_FONT_SIZE : ACTIVE_FONT_SIZE;
|
|
771
|
+
const baseFontSize = style === "portrait" ? PORTRAIT_BASE_FONT_SIZE : style === "medium" ? MEDIUM_BASE_FONT_SIZE : BASE_FONT_SIZE;
|
|
772
|
+
const groups = groupWordsBySpeech(words);
|
|
773
|
+
const dialogues = [];
|
|
774
|
+
for (const group of groups) {
|
|
775
|
+
const displayLines = splitGroupIntoLines(group);
|
|
776
|
+
for (let activeIdx = 0; activeIdx < group.length; activeIdx++) {
|
|
777
|
+
const activeWord = group[activeIdx];
|
|
778
|
+
const endTime = activeIdx < group.length - 1 ? group[activeIdx + 1].start : activeWord.end;
|
|
779
|
+
const renderedLines = [];
|
|
780
|
+
let globalIdx = 0;
|
|
781
|
+
for (const line of displayLines) {
|
|
782
|
+
const rendered = line.map((w) => {
|
|
783
|
+
const idx = globalIdx++;
|
|
784
|
+
const text2 = w.word.trim();
|
|
785
|
+
if (idx === activeIdx) {
|
|
786
|
+
if (style === "portrait") {
|
|
787
|
+
return `{${PORTRAIT_ACTIVE_COLOR}\\fs${activeFontSize}\\fscx130\\fscy130\\t(0,150,\\fscx100\\fscy100)}${text2}`;
|
|
788
|
+
}
|
|
789
|
+
return `{${ACTIVE_COLOR}\\fs${activeFontSize}}${text2}`;
|
|
790
|
+
}
|
|
791
|
+
if (style === "portrait") {
|
|
792
|
+
return `{${PORTRAIT_BASE_COLOR}\\fs${baseFontSize}}${text2}`;
|
|
793
|
+
}
|
|
794
|
+
return `{${BASE_COLOR}\\fs${baseFontSize}}${text2}`;
|
|
795
|
+
});
|
|
796
|
+
renderedLines.push(rendered.join(" "));
|
|
797
|
+
}
|
|
798
|
+
const text = renderedLines.join("\\N");
|
|
799
|
+
dialogues.push(
|
|
800
|
+
`Dialogue: 0,${toASS(activeWord.start)},${toASS(endTime)},Default,,0,0,0,,${text}`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return dialogues;
|
|
805
|
+
}
|
|
806
|
+
function generateStyledASS(transcript, style = "shorts") {
|
|
807
|
+
const header = style === "portrait" ? ASS_HEADER_PORTRAIT : style === "medium" ? ASS_HEADER_MEDIUM : ASS_HEADER;
|
|
808
|
+
const allWords = transcript.words;
|
|
809
|
+
if (allWords.length === 0) return header;
|
|
810
|
+
return header + buildPremiumDialogueLines(allWords, style).join("\n") + "\n";
|
|
811
|
+
}
|
|
812
|
+
function generateStyledASSForSegment(transcript, startTime, endTime, buffer = 1, style = "shorts") {
|
|
813
|
+
const header = style === "portrait" ? ASS_HEADER_PORTRAIT : style === "medium" ? ASS_HEADER_MEDIUM : ASS_HEADER;
|
|
814
|
+
const bufferedStart = Math.max(0, startTime - buffer);
|
|
815
|
+
const bufferedEnd = endTime + buffer;
|
|
816
|
+
const words = transcript.words.filter(
|
|
817
|
+
(w) => w.start >= bufferedStart && w.end <= bufferedEnd
|
|
818
|
+
);
|
|
819
|
+
if (words.length === 0) return header;
|
|
820
|
+
const adjusted = words.map((w) => ({
|
|
821
|
+
word: w.word,
|
|
822
|
+
start: w.start - bufferedStart,
|
|
823
|
+
end: w.end - bufferedStart
|
|
824
|
+
}));
|
|
825
|
+
return header + buildPremiumDialogueLines(adjusted, style).join("\n") + "\n";
|
|
826
|
+
}
|
|
827
|
+
function generateStyledASSForComposite(transcript, segments, buffer = 1, style = "shorts") {
|
|
828
|
+
const header = style === "portrait" ? ASS_HEADER_PORTRAIT : style === "medium" ? ASS_HEADER_MEDIUM : ASS_HEADER;
|
|
829
|
+
const allAdjusted = [];
|
|
830
|
+
let runningOffset = 0;
|
|
831
|
+
for (const seg of segments) {
|
|
832
|
+
const bufferedStart = Math.max(0, seg.start - buffer);
|
|
833
|
+
const bufferedEnd = seg.end + buffer;
|
|
834
|
+
const segDuration = bufferedEnd - bufferedStart;
|
|
835
|
+
const words = transcript.words.filter(
|
|
836
|
+
(w) => w.start >= bufferedStart && w.end <= bufferedEnd
|
|
837
|
+
);
|
|
838
|
+
for (const w of words) {
|
|
839
|
+
allAdjusted.push({
|
|
840
|
+
word: w.word,
|
|
841
|
+
start: w.start - bufferedStart + runningOffset,
|
|
842
|
+
end: w.end - bufferedStart + runningOffset
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
runningOffset += segDuration;
|
|
846
|
+
}
|
|
847
|
+
if (allAdjusted.length === 0) return header;
|
|
848
|
+
return header + buildPremiumDialogueLines(allAdjusted, style).join("\n") + "\n";
|
|
849
|
+
}
|
|
850
|
+
var HOOK_TEXT_MAX_LENGTH = 60;
|
|
851
|
+
function generateHookOverlay(hookText, displayDuration = 4, _style = "portrait") {
|
|
852
|
+
const text = hookText.length > HOOK_TEXT_MAX_LENGTH ? hookText.slice(0, HOOK_TEXT_MAX_LENGTH - 3) + "..." : hookText;
|
|
853
|
+
return `Dialogue: 1,${toASS(0)},${toASS(displayDuration)},Hook,,0,0,0,,{\\fad(300,500)}${text}`;
|
|
854
|
+
}
|
|
855
|
+
function generatePortraitASSWithHook(transcript, hookText, startTime, endTime, buffer) {
|
|
856
|
+
const baseASS = generateStyledASSForSegment(transcript, startTime, endTime, buffer, "portrait");
|
|
857
|
+
const hookLine = generateHookOverlay(hookText, 4, "portrait");
|
|
858
|
+
return baseASS + hookLine + "\n";
|
|
859
|
+
}
|
|
860
|
+
function generatePortraitASSWithHookComposite(transcript, segments, hookText, buffer) {
|
|
861
|
+
const baseASS = generateStyledASSForComposite(transcript, segments, buffer, "portrait");
|
|
862
|
+
const hookLine = generateHookOverlay(hookText, 4, "portrait");
|
|
863
|
+
return baseASS + hookLine + "\n";
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/services/captionGeneration.ts
|
|
867
|
+
async function generateCaptions(video, transcript) {
|
|
868
|
+
const config2 = getConfig();
|
|
869
|
+
const captionsDir = path6.join(config2.OUTPUT_DIR, video.slug, "captions");
|
|
870
|
+
await fsp3.mkdir(captionsDir, { recursive: true });
|
|
871
|
+
const srtPath = path6.join(captionsDir, "captions.srt");
|
|
872
|
+
const vttPath = path6.join(captionsDir, "captions.vtt");
|
|
873
|
+
const assPath = path6.join(captionsDir, "captions.ass");
|
|
874
|
+
const srt = generateSRT(transcript);
|
|
875
|
+
const vtt = generateVTT(transcript);
|
|
876
|
+
const ass = generateStyledASS(transcript);
|
|
877
|
+
await Promise.all([
|
|
878
|
+
fsp3.writeFile(srtPath, srt, "utf-8"),
|
|
879
|
+
fsp3.writeFile(vttPath, vtt, "utf-8"),
|
|
880
|
+
fsp3.writeFile(assPath, ass, "utf-8")
|
|
881
|
+
]);
|
|
882
|
+
const paths = [srtPath, vttPath, assPath];
|
|
883
|
+
logger_default.info(`Captions saved: ${paths.join(", ")}`);
|
|
884
|
+
return paths;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/agents/SummaryAgent.ts
|
|
888
|
+
import { promises as fs8 } from "fs";
|
|
889
|
+
import path8 from "path";
|
|
890
|
+
|
|
891
|
+
// src/providers/CopilotProvider.ts
|
|
892
|
+
import { CopilotClient } from "@github/copilot-sdk";
|
|
893
|
+
var DEFAULT_MODEL = "Claude Opus 4.6";
|
|
894
|
+
var DEFAULT_TIMEOUT_MS = 3e5;
|
|
895
|
+
var CopilotProvider = class {
|
|
896
|
+
name = "copilot";
|
|
897
|
+
client = null;
|
|
898
|
+
isAvailable() {
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
getDefaultModel() {
|
|
902
|
+
return DEFAULT_MODEL;
|
|
903
|
+
}
|
|
904
|
+
async createSession(config2) {
|
|
905
|
+
if (!this.client) {
|
|
906
|
+
this.client = new CopilotClient({ autoStart: true, logLevel: "error" });
|
|
907
|
+
}
|
|
908
|
+
const copilotSession = await this.client.createSession({
|
|
909
|
+
systemMessage: { mode: "replace", content: config2.systemPrompt },
|
|
910
|
+
tools: config2.tools.map((t) => ({
|
|
911
|
+
name: t.name,
|
|
912
|
+
description: t.description,
|
|
913
|
+
parameters: t.parameters,
|
|
914
|
+
handler: t.handler
|
|
915
|
+
})),
|
|
916
|
+
streaming: config2.streaming ?? true
|
|
917
|
+
});
|
|
918
|
+
return new CopilotSessionWrapper(
|
|
919
|
+
copilotSession,
|
|
920
|
+
config2.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
/** Tear down the underlying Copilot client. */
|
|
924
|
+
async close() {
|
|
925
|
+
try {
|
|
926
|
+
if (this.client) {
|
|
927
|
+
await this.client.stop();
|
|
928
|
+
this.client = null;
|
|
929
|
+
}
|
|
930
|
+
} catch (err) {
|
|
931
|
+
logger_default.error(`[CopilotProvider] Error during close: ${err}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
var CopilotSessionWrapper = class {
|
|
936
|
+
constructor(session, timeoutMs) {
|
|
937
|
+
this.session = session;
|
|
938
|
+
this.timeoutMs = timeoutMs;
|
|
939
|
+
this.setupEventForwarding();
|
|
940
|
+
this.setupUsageTracking();
|
|
941
|
+
}
|
|
942
|
+
eventHandlers = /* @__PURE__ */ new Map();
|
|
943
|
+
// Latest usage data captured from assistant.usage events
|
|
944
|
+
lastUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
945
|
+
lastCost;
|
|
946
|
+
lastQuotaSnapshots;
|
|
947
|
+
async sendAndWait(message) {
|
|
948
|
+
const start = Date.now();
|
|
949
|
+
this.lastUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
950
|
+
this.lastCost = void 0;
|
|
951
|
+
this.lastQuotaSnapshots = void 0;
|
|
952
|
+
const response = await this.session.sendAndWait(
|
|
953
|
+
{ prompt: message },
|
|
954
|
+
this.timeoutMs
|
|
955
|
+
);
|
|
956
|
+
const content = response?.data?.content ?? "";
|
|
957
|
+
const toolCalls = [];
|
|
958
|
+
return {
|
|
959
|
+
content,
|
|
960
|
+
toolCalls,
|
|
961
|
+
usage: this.lastUsage,
|
|
962
|
+
cost: this.lastCost,
|
|
963
|
+
quotaSnapshots: this.lastQuotaSnapshots,
|
|
964
|
+
durationMs: Date.now() - start
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
on(event, handler) {
|
|
968
|
+
const handlers = this.eventHandlers.get(event) ?? [];
|
|
969
|
+
handlers.push(handler);
|
|
970
|
+
this.eventHandlers.set(event, handlers);
|
|
971
|
+
}
|
|
972
|
+
async close() {
|
|
973
|
+
await this.session.destroy();
|
|
974
|
+
this.eventHandlers.clear();
|
|
975
|
+
}
|
|
976
|
+
/** Capture assistant.usage events for token/cost tracking. */
|
|
977
|
+
setupUsageTracking() {
|
|
978
|
+
this.session.on((event) => {
|
|
979
|
+
if (event.type === "assistant.usage") {
|
|
980
|
+
const d = event.data;
|
|
981
|
+
this.lastUsage = {
|
|
982
|
+
inputTokens: d.inputTokens ?? 0,
|
|
983
|
+
outputTokens: d.outputTokens ?? 0,
|
|
984
|
+
totalTokens: (d.inputTokens ?? 0) + (d.outputTokens ?? 0),
|
|
985
|
+
cacheReadTokens: d.cacheReadTokens,
|
|
986
|
+
cacheWriteTokens: d.cacheWriteTokens
|
|
987
|
+
};
|
|
988
|
+
if (d.cost != null) {
|
|
989
|
+
this.lastCost = {
|
|
990
|
+
amount: d.cost,
|
|
991
|
+
unit: "premium_requests",
|
|
992
|
+
model: d.model ?? DEFAULT_MODEL,
|
|
993
|
+
multiplier: d.multiplier
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
if (d.quotaSnapshots != null) {
|
|
997
|
+
this.lastQuotaSnapshots = d.quotaSnapshots;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
/** Forward CopilotSession events to ProviderEvent subscribers. */
|
|
1003
|
+
setupEventForwarding() {
|
|
1004
|
+
this.session.on((event) => {
|
|
1005
|
+
switch (event.type) {
|
|
1006
|
+
case "assistant.message_delta":
|
|
1007
|
+
this.emit("delta", event.data);
|
|
1008
|
+
break;
|
|
1009
|
+
case "tool.execution_start":
|
|
1010
|
+
this.emit("tool_start", event.data);
|
|
1011
|
+
break;
|
|
1012
|
+
case "tool.execution_complete":
|
|
1013
|
+
this.emit("tool_end", event.data);
|
|
1014
|
+
break;
|
|
1015
|
+
case "assistant.usage":
|
|
1016
|
+
this.emit("usage", event.data);
|
|
1017
|
+
break;
|
|
1018
|
+
case "session.error":
|
|
1019
|
+
this.emit("error", event.data);
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
emit(type, data) {
|
|
1025
|
+
const handlers = this.eventHandlers.get(type);
|
|
1026
|
+
if (handlers) {
|
|
1027
|
+
for (const handler of handlers) {
|
|
1028
|
+
handler({ type, data });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// src/providers/OpenAIProvider.ts
|
|
1035
|
+
import OpenAI2 from "openai";
|
|
1036
|
+
|
|
1037
|
+
// src/config/pricing.ts
|
|
1038
|
+
var COPILOT_PRU_OVERAGE_RATE = 0.04;
|
|
1039
|
+
var MODEL_PRICING = {
|
|
1040
|
+
// === OpenAI Models (from Copilot model picker) ===
|
|
1041
|
+
"gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 0, copilotIncluded: true },
|
|
1042
|
+
"gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6, pruMultiplier: 0, copilotIncluded: true },
|
|
1043
|
+
"gpt-4.1": { inputPer1M: 2, outputPer1M: 8, pruMultiplier: 0, copilotIncluded: true },
|
|
1044
|
+
"gpt-4.1-mini": { inputPer1M: 0.4, outputPer1M: 1.6 },
|
|
1045
|
+
"gpt-5-mini": { inputPer1M: 0.15, outputPer1M: 0.6, pruMultiplier: 0, copilotIncluded: true },
|
|
1046
|
+
"gpt-5": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 1 },
|
|
1047
|
+
"gpt-5-codex": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 1 },
|
|
1048
|
+
"gpt-5.1": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 1 },
|
|
1049
|
+
"gpt-5.1-codex": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 1 },
|
|
1050
|
+
"gpt-5.1-codex-max": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 1 },
|
|
1051
|
+
"gpt-5.1-codex-mini": { inputPer1M: 0.15, outputPer1M: 0.6, pruMultiplier: 0.33 },
|
|
1052
|
+
"gpt-5.2": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 1 },
|
|
1053
|
+
"gpt-5.2-codex": { inputPer1M: 2.5, outputPer1M: 10, pruMultiplier: 1 },
|
|
1054
|
+
"o3": { inputPer1M: 10, outputPer1M: 40, pruMultiplier: 5 },
|
|
1055
|
+
"o4-mini-high": { inputPer1M: 1.1, outputPer1M: 4.4, pruMultiplier: 20 },
|
|
1056
|
+
// === Anthropic Models (from Copilot model picker) ===
|
|
1057
|
+
"claude-haiku-4.5": { inputPer1M: 0.8, outputPer1M: 4, pruMultiplier: 0.33 },
|
|
1058
|
+
"claude-sonnet-4": { inputPer1M: 3, outputPer1M: 15, pruMultiplier: 1 },
|
|
1059
|
+
"claude-sonnet-4.5": { inputPer1M: 3, outputPer1M: 15, pruMultiplier: 1 },
|
|
1060
|
+
"claude-opus-4.5": { inputPer1M: 15, outputPer1M: 75, pruMultiplier: 3 },
|
|
1061
|
+
"claude-opus-4.6": { inputPer1M: 5, outputPer1M: 25, pruMultiplier: 3 },
|
|
1062
|
+
"claude-opus-4.6-fast": { inputPer1M: 5, outputPer1M: 25, pruMultiplier: 9 },
|
|
1063
|
+
// === Google Models (from Copilot model picker) ===
|
|
1064
|
+
"gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 5, pruMultiplier: 1 },
|
|
1065
|
+
"gemini-3-flash": { inputPer1M: 0.1, outputPer1M: 0.4, pruMultiplier: 0.33 },
|
|
1066
|
+
"gemini-3-pro": { inputPer1M: 1.25, outputPer1M: 5, pruMultiplier: 1 }
|
|
1067
|
+
};
|
|
1068
|
+
function calculateTokenCost(model, inputTokens, outputTokens) {
|
|
1069
|
+
const pricing = getModelPricing(model);
|
|
1070
|
+
if (!pricing || !pricing.inputPer1M && !pricing.outputPer1M) return 0;
|
|
1071
|
+
const inputCost = (pricing.inputPer1M ?? 0) / 1e6 * inputTokens;
|
|
1072
|
+
const outputCost = (pricing.outputPer1M ?? 0) / 1e6 * outputTokens;
|
|
1073
|
+
return inputCost + outputCost;
|
|
1074
|
+
}
|
|
1075
|
+
function calculatePRUCost(model) {
|
|
1076
|
+
const pricing = getModelPricing(model);
|
|
1077
|
+
if (!pricing) return 1;
|
|
1078
|
+
if (pricing.copilotIncluded) return 0;
|
|
1079
|
+
return pricing.pruMultiplier ?? 1;
|
|
1080
|
+
}
|
|
1081
|
+
function getModelPricing(model) {
|
|
1082
|
+
return MODEL_PRICING[model] ?? MODEL_PRICING[model.toLowerCase()] ?? Object.entries(MODEL_PRICING).find(
|
|
1083
|
+
([key]) => model.toLowerCase().includes(key.toLowerCase())
|
|
1084
|
+
)?.[1];
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/providers/OpenAIProvider.ts
|
|
1088
|
+
var MAX_TOOL_ROUNDS = 50;
|
|
1089
|
+
function toOpenAITools(tools) {
|
|
1090
|
+
return tools.map((t) => ({
|
|
1091
|
+
type: "function",
|
|
1092
|
+
function: {
|
|
1093
|
+
name: t.name,
|
|
1094
|
+
description: t.description,
|
|
1095
|
+
parameters: t.parameters
|
|
1096
|
+
}
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
1099
|
+
function buildHandlerMap(tools) {
|
|
1100
|
+
return new Map(tools.map((t) => [t.name, t.handler]));
|
|
1101
|
+
}
|
|
1102
|
+
function addUsage(a, b) {
|
|
1103
|
+
return {
|
|
1104
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
1105
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
1106
|
+
totalTokens: a.totalTokens + b.totalTokens
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
var OpenAISession = class {
|
|
1110
|
+
client;
|
|
1111
|
+
model;
|
|
1112
|
+
messages;
|
|
1113
|
+
tools;
|
|
1114
|
+
handlers;
|
|
1115
|
+
listeners = /* @__PURE__ */ new Map();
|
|
1116
|
+
timeoutMs;
|
|
1117
|
+
constructor(client, config2, model) {
|
|
1118
|
+
this.client = client;
|
|
1119
|
+
this.model = model;
|
|
1120
|
+
this.messages = [{ role: "system", content: config2.systemPrompt }];
|
|
1121
|
+
this.tools = toOpenAITools(config2.tools);
|
|
1122
|
+
this.handlers = buildHandlerMap(config2.tools);
|
|
1123
|
+
this.timeoutMs = config2.timeoutMs;
|
|
1124
|
+
}
|
|
1125
|
+
// ── public API ─────────────────────────────────────────────────────
|
|
1126
|
+
async sendAndWait(message) {
|
|
1127
|
+
this.messages.push({ role: "user", content: message });
|
|
1128
|
+
let cumulative = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
1129
|
+
const start = Date.now();
|
|
1130
|
+
let toolRound = 0;
|
|
1131
|
+
while (true) {
|
|
1132
|
+
if (++toolRound > MAX_TOOL_ROUNDS) {
|
|
1133
|
+
logger_default.warn(`OpenAI agent exceeded ${MAX_TOOL_ROUNDS} tool rounds \u2014 aborting to prevent runaway`);
|
|
1134
|
+
throw new Error(`Max tool rounds (${MAX_TOOL_ROUNDS}) exceeded \u2014 possible infinite loop`);
|
|
1135
|
+
}
|
|
1136
|
+
const controller = new AbortController();
|
|
1137
|
+
const timeoutId = this.timeoutMs ? setTimeout(() => controller.abort(), this.timeoutMs) : void 0;
|
|
1138
|
+
let response;
|
|
1139
|
+
try {
|
|
1140
|
+
response = await this.client.chat.completions.create(
|
|
1141
|
+
{
|
|
1142
|
+
model: this.model,
|
|
1143
|
+
messages: this.messages,
|
|
1144
|
+
...this.tools.length > 0 ? { tools: this.tools } : {}
|
|
1145
|
+
},
|
|
1146
|
+
{ signal: controller.signal }
|
|
1147
|
+
);
|
|
1148
|
+
} finally {
|
|
1149
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1150
|
+
}
|
|
1151
|
+
const choice = response.choices[0];
|
|
1152
|
+
const assistantMsg = choice.message;
|
|
1153
|
+
if (response.usage) {
|
|
1154
|
+
const iterUsage = {
|
|
1155
|
+
inputTokens: response.usage.prompt_tokens,
|
|
1156
|
+
outputTokens: response.usage.completion_tokens,
|
|
1157
|
+
totalTokens: response.usage.total_tokens
|
|
1158
|
+
};
|
|
1159
|
+
cumulative = addUsage(cumulative, iterUsage);
|
|
1160
|
+
this.emit("usage", iterUsage);
|
|
1161
|
+
}
|
|
1162
|
+
this.messages.push(assistantMsg);
|
|
1163
|
+
const toolCalls = assistantMsg.tool_calls;
|
|
1164
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
1165
|
+
const cost = calculateTokenCost(this.model, cumulative.inputTokens, cumulative.outputTokens);
|
|
1166
|
+
return {
|
|
1167
|
+
content: assistantMsg.content ?? "",
|
|
1168
|
+
toolCalls: [],
|
|
1169
|
+
usage: cumulative,
|
|
1170
|
+
cost: { amount: cost, unit: "usd", model: this.model },
|
|
1171
|
+
durationMs: Date.now() - start
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
for (const tc of toolCalls) {
|
|
1175
|
+
if (tc.type !== "function") continue;
|
|
1176
|
+
const fnName = tc.function.name;
|
|
1177
|
+
const handler = this.handlers.get(fnName);
|
|
1178
|
+
let result;
|
|
1179
|
+
if (!handler) {
|
|
1180
|
+
logger_default.warn(`OpenAI requested unknown tool: ${fnName}`);
|
|
1181
|
+
result = { error: `Unknown tool: ${fnName}` };
|
|
1182
|
+
} else {
|
|
1183
|
+
this.emit("tool_start", { name: fnName, arguments: tc.function.arguments });
|
|
1184
|
+
try {
|
|
1185
|
+
const args = JSON.parse(tc.function.arguments);
|
|
1186
|
+
result = await handler(args);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
logger_default.error(`Tool ${fnName} failed: ${err}`);
|
|
1189
|
+
result = { error: String(err) };
|
|
1190
|
+
}
|
|
1191
|
+
this.emit("tool_end", { name: fnName, result });
|
|
1192
|
+
}
|
|
1193
|
+
this.messages.push({
|
|
1194
|
+
role: "tool",
|
|
1195
|
+
tool_call_id: tc.id,
|
|
1196
|
+
content: typeof result === "string" ? result : JSON.stringify(result)
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
on(event, handler) {
|
|
1202
|
+
const list = this.listeners.get(event) ?? [];
|
|
1203
|
+
list.push(handler);
|
|
1204
|
+
this.listeners.set(event, list);
|
|
1205
|
+
}
|
|
1206
|
+
async close() {
|
|
1207
|
+
this.messages = [];
|
|
1208
|
+
this.listeners.clear();
|
|
1209
|
+
}
|
|
1210
|
+
// ── internals ──────────────────────────────────────────────────────
|
|
1211
|
+
emit(type, data) {
|
|
1212
|
+
for (const handler of this.listeners.get(type) ?? []) {
|
|
1213
|
+
try {
|
|
1214
|
+
handler({ type, data });
|
|
1215
|
+
} catch {
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
var OpenAIProvider = class {
|
|
1221
|
+
name = "openai";
|
|
1222
|
+
isAvailable() {
|
|
1223
|
+
return !!process.env.OPENAI_API_KEY;
|
|
1224
|
+
}
|
|
1225
|
+
getDefaultModel() {
|
|
1226
|
+
return "gpt-4o";
|
|
1227
|
+
}
|
|
1228
|
+
async createSession(config2) {
|
|
1229
|
+
const client = new OpenAI2();
|
|
1230
|
+
const model = config2.model ?? this.getDefaultModel();
|
|
1231
|
+
logger_default.info(`OpenAI session created (model=${model}, tools=${config2.tools.length})`);
|
|
1232
|
+
return new OpenAISession(client, config2, model);
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
// src/providers/ClaudeProvider.ts
|
|
1237
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
1238
|
+
var DEFAULT_MODEL2 = "claude-opus-4.6";
|
|
1239
|
+
var DEFAULT_MAX_TOKENS = 8192;
|
|
1240
|
+
var MAX_TOOL_ROUNDS2 = 50;
|
|
1241
|
+
function toAnthropicTools(tools) {
|
|
1242
|
+
return tools.map((t) => ({
|
|
1243
|
+
name: t.name,
|
|
1244
|
+
description: t.description,
|
|
1245
|
+
input_schema: t.parameters
|
|
1246
|
+
}));
|
|
1247
|
+
}
|
|
1248
|
+
function extractText(content) {
|
|
1249
|
+
return content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
1250
|
+
}
|
|
1251
|
+
function extractToolUse(content) {
|
|
1252
|
+
return content.filter((b) => b.type === "tool_use");
|
|
1253
|
+
}
|
|
1254
|
+
var ClaudeSession = class {
|
|
1255
|
+
client;
|
|
1256
|
+
systemPrompt;
|
|
1257
|
+
tools;
|
|
1258
|
+
anthropicTools;
|
|
1259
|
+
messages = [];
|
|
1260
|
+
model;
|
|
1261
|
+
maxTokens;
|
|
1262
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1263
|
+
timeoutMs;
|
|
1264
|
+
constructor(client, config2) {
|
|
1265
|
+
this.client = client;
|
|
1266
|
+
this.systemPrompt = config2.systemPrompt;
|
|
1267
|
+
this.tools = config2.tools;
|
|
1268
|
+
this.anthropicTools = toAnthropicTools(config2.tools);
|
|
1269
|
+
this.model = config2.model ?? DEFAULT_MODEL2;
|
|
1270
|
+
this.maxTokens = DEFAULT_MAX_TOKENS;
|
|
1271
|
+
this.timeoutMs = config2.timeoutMs;
|
|
1272
|
+
}
|
|
1273
|
+
on(event, handler) {
|
|
1274
|
+
const list = this.handlers.get(event) ?? [];
|
|
1275
|
+
list.push(handler);
|
|
1276
|
+
this.handlers.set(event, list);
|
|
1277
|
+
}
|
|
1278
|
+
emit(type, data) {
|
|
1279
|
+
for (const handler of this.handlers.get(type) ?? []) {
|
|
1280
|
+
handler({ type, data });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
async sendAndWait(message) {
|
|
1284
|
+
this.messages.push({ role: "user", content: message });
|
|
1285
|
+
let cumulativeUsage = {
|
|
1286
|
+
inputTokens: 0,
|
|
1287
|
+
outputTokens: 0,
|
|
1288
|
+
totalTokens: 0
|
|
1289
|
+
};
|
|
1290
|
+
const startMs = Date.now();
|
|
1291
|
+
let toolRound = 0;
|
|
1292
|
+
while (true) {
|
|
1293
|
+
if (++toolRound > MAX_TOOL_ROUNDS2) {
|
|
1294
|
+
logger_default.warn(`Claude agent exceeded ${MAX_TOOL_ROUNDS2} tool rounds \u2014 aborting to prevent runaway`);
|
|
1295
|
+
throw new Error(`Max tool rounds (${MAX_TOOL_ROUNDS2}) exceeded \u2014 possible infinite loop`);
|
|
1296
|
+
}
|
|
1297
|
+
const controller = new AbortController();
|
|
1298
|
+
const timeoutId = this.timeoutMs ? setTimeout(() => controller.abort(), this.timeoutMs) : void 0;
|
|
1299
|
+
let response;
|
|
1300
|
+
try {
|
|
1301
|
+
response = await this.client.messages.create(
|
|
1302
|
+
{
|
|
1303
|
+
model: this.model,
|
|
1304
|
+
max_tokens: this.maxTokens,
|
|
1305
|
+
system: this.systemPrompt,
|
|
1306
|
+
messages: this.messages,
|
|
1307
|
+
...this.anthropicTools.length > 0 ? { tools: this.anthropicTools } : {}
|
|
1308
|
+
},
|
|
1309
|
+
{ signal: controller.signal }
|
|
1310
|
+
);
|
|
1311
|
+
} finally {
|
|
1312
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1313
|
+
}
|
|
1314
|
+
cumulativeUsage.inputTokens += response.usage.input_tokens;
|
|
1315
|
+
cumulativeUsage.outputTokens += response.usage.output_tokens;
|
|
1316
|
+
cumulativeUsage.totalTokens = cumulativeUsage.inputTokens + cumulativeUsage.outputTokens;
|
|
1317
|
+
if (response.usage.cache_read_input_tokens) {
|
|
1318
|
+
cumulativeUsage.cacheReadTokens = (cumulativeUsage.cacheReadTokens ?? 0) + response.usage.cache_read_input_tokens;
|
|
1319
|
+
}
|
|
1320
|
+
if (response.usage.cache_creation_input_tokens) {
|
|
1321
|
+
cumulativeUsage.cacheWriteTokens = (cumulativeUsage.cacheWriteTokens ?? 0) + response.usage.cache_creation_input_tokens;
|
|
1322
|
+
}
|
|
1323
|
+
this.emit("usage", cumulativeUsage);
|
|
1324
|
+
this.messages.push({ role: "assistant", content: response.content });
|
|
1325
|
+
const toolUseBlocks = extractToolUse(response.content);
|
|
1326
|
+
if (toolUseBlocks.length === 0 || response.stop_reason === "end_turn") {
|
|
1327
|
+
const text = extractText(response.content);
|
|
1328
|
+
const cost = calculateTokenCost(
|
|
1329
|
+
this.model,
|
|
1330
|
+
cumulativeUsage.inputTokens,
|
|
1331
|
+
cumulativeUsage.outputTokens
|
|
1332
|
+
);
|
|
1333
|
+
return {
|
|
1334
|
+
content: text,
|
|
1335
|
+
toolCalls: [],
|
|
1336
|
+
usage: cumulativeUsage,
|
|
1337
|
+
cost: cost > 0 ? { amount: cost, unit: "usd", model: this.model } : void 0,
|
|
1338
|
+
durationMs: Date.now() - startMs
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
const toolResults = [];
|
|
1342
|
+
for (const block of toolUseBlocks) {
|
|
1343
|
+
const tool = this.tools.find((t) => t.name === block.name);
|
|
1344
|
+
if (!tool) {
|
|
1345
|
+
logger_default.warn(`Claude requested unknown tool: ${block.name}`);
|
|
1346
|
+
toolResults.push({
|
|
1347
|
+
type: "tool_result",
|
|
1348
|
+
tool_use_id: block.id,
|
|
1349
|
+
content: JSON.stringify({ error: `Unknown tool: ${block.name}` })
|
|
1350
|
+
});
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
this.emit("tool_start", { name: block.name, arguments: block.input });
|
|
1354
|
+
try {
|
|
1355
|
+
const result = await tool.handler(block.input);
|
|
1356
|
+
toolResults.push({
|
|
1357
|
+
type: "tool_result",
|
|
1358
|
+
tool_use_id: block.id,
|
|
1359
|
+
content: JSON.stringify(result)
|
|
1360
|
+
});
|
|
1361
|
+
this.emit("tool_end", { name: block.name, result });
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1364
|
+
logger_default.error(`Tool ${block.name} failed: ${errorMsg}`);
|
|
1365
|
+
toolResults.push({
|
|
1366
|
+
type: "tool_result",
|
|
1367
|
+
tool_use_id: block.id,
|
|
1368
|
+
content: JSON.stringify({ error: errorMsg }),
|
|
1369
|
+
is_error: true
|
|
1370
|
+
});
|
|
1371
|
+
this.emit("error", { name: block.name, error: errorMsg });
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
this.messages.push({ role: "user", content: toolResults });
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async close() {
|
|
1378
|
+
this.messages = [];
|
|
1379
|
+
this.handlers.clear();
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
var ClaudeProvider = class {
|
|
1383
|
+
name = "claude";
|
|
1384
|
+
isAvailable() {
|
|
1385
|
+
return !!process.env.ANTHROPIC_API_KEY;
|
|
1386
|
+
}
|
|
1387
|
+
getDefaultModel() {
|
|
1388
|
+
return DEFAULT_MODEL2;
|
|
1389
|
+
}
|
|
1390
|
+
async createSession(config2) {
|
|
1391
|
+
const client = new Anthropic();
|
|
1392
|
+
return new ClaudeSession(client, config2);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// src/providers/index.ts
|
|
1397
|
+
var providers = {
|
|
1398
|
+
copilot: () => new CopilotProvider(),
|
|
1399
|
+
openai: () => new OpenAIProvider(),
|
|
1400
|
+
claude: () => new ClaudeProvider()
|
|
1401
|
+
};
|
|
1402
|
+
var currentProvider = null;
|
|
1403
|
+
var currentProviderName = null;
|
|
1404
|
+
function getProvider(name) {
|
|
1405
|
+
const raw = name ?? (process.env.LLM_PROVIDER || "copilot").trim().toLowerCase();
|
|
1406
|
+
const providerName = raw;
|
|
1407
|
+
if (currentProvider && currentProviderName === providerName) {
|
|
1408
|
+
return currentProvider;
|
|
1409
|
+
}
|
|
1410
|
+
currentProvider?.close?.().catch(() => {
|
|
1411
|
+
});
|
|
1412
|
+
if (!providers[providerName]) {
|
|
1413
|
+
throw new Error(
|
|
1414
|
+
`Unknown LLM provider: "${providerName}". Valid options: ${Object.keys(providers).join(", ")}`
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
const provider = providers[providerName]();
|
|
1418
|
+
if (!provider.isAvailable()) {
|
|
1419
|
+
logger_default.warn(
|
|
1420
|
+
`Provider "${providerName}" is not available (missing API key or config). Falling back to copilot provider.`
|
|
1421
|
+
);
|
|
1422
|
+
currentProvider = providers.copilot();
|
|
1423
|
+
currentProviderName = "copilot";
|
|
1424
|
+
return currentProvider;
|
|
1425
|
+
}
|
|
1426
|
+
logger_default.info(`Using LLM provider: ${providerName} (model: ${provider.getDefaultModel()})`);
|
|
1427
|
+
currentProvider = provider;
|
|
1428
|
+
currentProviderName = providerName;
|
|
1429
|
+
return currentProvider;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// src/services/costTracker.ts
|
|
1433
|
+
var CostTracker = class {
|
|
1434
|
+
records = [];
|
|
1435
|
+
latestQuota;
|
|
1436
|
+
currentAgent = "unknown";
|
|
1437
|
+
currentStage = "unknown";
|
|
1438
|
+
/** Set the current agent name (called by BaseAgent before LLM calls) */
|
|
1439
|
+
setAgent(agent) {
|
|
1440
|
+
this.currentAgent = agent;
|
|
1441
|
+
}
|
|
1442
|
+
/** Set the current pipeline stage */
|
|
1443
|
+
setStage(stage) {
|
|
1444
|
+
this.currentStage = stage;
|
|
1445
|
+
}
|
|
1446
|
+
/** Record a usage event from any provider */
|
|
1447
|
+
recordUsage(provider, model, usage, cost, durationMs, quotaSnapshot) {
|
|
1448
|
+
const finalCost = cost ?? {
|
|
1449
|
+
amount: provider === "copilot" ? calculatePRUCost(model) : calculateTokenCost(model, usage.inputTokens, usage.outputTokens),
|
|
1450
|
+
unit: provider === "copilot" ? "premium_requests" : "usd",
|
|
1451
|
+
model
|
|
1452
|
+
};
|
|
1453
|
+
const record = {
|
|
1454
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1455
|
+
provider,
|
|
1456
|
+
model,
|
|
1457
|
+
agent: this.currentAgent,
|
|
1458
|
+
stage: this.currentStage,
|
|
1459
|
+
usage,
|
|
1460
|
+
cost: finalCost,
|
|
1461
|
+
durationMs
|
|
1462
|
+
};
|
|
1463
|
+
this.records.push(record);
|
|
1464
|
+
if (quotaSnapshot) {
|
|
1465
|
+
this.latestQuota = quotaSnapshot;
|
|
1466
|
+
}
|
|
1467
|
+
logger_default.debug(
|
|
1468
|
+
`[CostTracker] ${provider}/${model} | ${this.currentAgent} | in=${usage.inputTokens} out=${usage.outputTokens} | cost=${finalCost.amount.toFixed(4)} ${finalCost.unit}`
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
/** Get the full cost report */
|
|
1472
|
+
getReport() {
|
|
1473
|
+
const report = {
|
|
1474
|
+
totalCostUSD: 0,
|
|
1475
|
+
totalPRUs: 0,
|
|
1476
|
+
totalTokens: { input: 0, output: 0, total: 0 },
|
|
1477
|
+
byProvider: {},
|
|
1478
|
+
byAgent: {},
|
|
1479
|
+
byModel: {},
|
|
1480
|
+
records: [...this.records],
|
|
1481
|
+
copilotQuota: this.latestQuota
|
|
1482
|
+
};
|
|
1483
|
+
for (const record of this.records) {
|
|
1484
|
+
const { provider, model, agent, usage, cost } = record;
|
|
1485
|
+
report.totalTokens.input += usage.inputTokens;
|
|
1486
|
+
report.totalTokens.output += usage.outputTokens;
|
|
1487
|
+
report.totalTokens.total += usage.totalTokens;
|
|
1488
|
+
const usdCost = cost.unit === "usd" ? cost.amount : cost.amount * COPILOT_PRU_OVERAGE_RATE;
|
|
1489
|
+
const prus = cost.unit === "premium_requests" ? cost.amount : 0;
|
|
1490
|
+
report.totalCostUSD += usdCost;
|
|
1491
|
+
report.totalPRUs += prus;
|
|
1492
|
+
if (!report.byProvider[provider]) report.byProvider[provider] = { costUSD: 0, prus: 0, calls: 0 };
|
|
1493
|
+
report.byProvider[provider].costUSD += usdCost;
|
|
1494
|
+
report.byProvider[provider].prus += prus;
|
|
1495
|
+
report.byProvider[provider].calls += 1;
|
|
1496
|
+
if (!report.byAgent[agent]) report.byAgent[agent] = { costUSD: 0, prus: 0, calls: 0 };
|
|
1497
|
+
report.byAgent[agent].costUSD += usdCost;
|
|
1498
|
+
report.byAgent[agent].prus += prus;
|
|
1499
|
+
report.byAgent[agent].calls += 1;
|
|
1500
|
+
if (!report.byModel[model]) report.byModel[model] = { costUSD: 0, prus: 0, calls: 0 };
|
|
1501
|
+
report.byModel[model].costUSD += usdCost;
|
|
1502
|
+
report.byModel[model].prus += prus;
|
|
1503
|
+
report.byModel[model].calls += 1;
|
|
1504
|
+
}
|
|
1505
|
+
return report;
|
|
1506
|
+
}
|
|
1507
|
+
/** Format report as human-readable string for console output */
|
|
1508
|
+
formatReport() {
|
|
1509
|
+
const report = this.getReport();
|
|
1510
|
+
const lines = [
|
|
1511
|
+
"",
|
|
1512
|
+
"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550",
|
|
1513
|
+
" \u{1F4B0} Pipeline Cost Report",
|
|
1514
|
+
"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550",
|
|
1515
|
+
"",
|
|
1516
|
+
` Total Cost: $${report.totalCostUSD.toFixed(4)} USD`
|
|
1517
|
+
];
|
|
1518
|
+
if (report.totalPRUs > 0) {
|
|
1519
|
+
lines.push(` Total PRUs: ${report.totalPRUs} premium requests`);
|
|
1520
|
+
}
|
|
1521
|
+
lines.push(
|
|
1522
|
+
` Total Tokens: ${report.totalTokens.total.toLocaleString()} (${report.totalTokens.input.toLocaleString()} in / ${report.totalTokens.output.toLocaleString()} out)`,
|
|
1523
|
+
` LLM Calls: ${this.records.length}`
|
|
1524
|
+
);
|
|
1525
|
+
if (report.copilotQuota) {
|
|
1526
|
+
lines.push(
|
|
1527
|
+
"",
|
|
1528
|
+
` Copilot Quota: ${report.copilotQuota.remainingPercentage.toFixed(1)}% remaining`,
|
|
1529
|
+
` Used/Total: ${report.copilotQuota.usedRequests}/${report.copilotQuota.entitlementRequests} PRUs`
|
|
1530
|
+
);
|
|
1531
|
+
if (report.copilotQuota.resetDate) {
|
|
1532
|
+
lines.push(` Resets: ${report.copilotQuota.resetDate}`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
if (Object.keys(report.byAgent).length > 1) {
|
|
1536
|
+
lines.push("", " By Agent:");
|
|
1537
|
+
for (const [agent, data] of Object.entries(report.byAgent)) {
|
|
1538
|
+
lines.push(` ${agent}: $${data.costUSD.toFixed(4)} (${data.calls} calls)`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
if (Object.keys(report.byModel).length > 1) {
|
|
1542
|
+
lines.push("", " By Model:");
|
|
1543
|
+
for (const [model, data] of Object.entries(report.byModel)) {
|
|
1544
|
+
lines.push(` ${model}: $${data.costUSD.toFixed(4)} (${data.calls} calls)`);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
lines.push("", "\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550", "");
|
|
1548
|
+
return lines.join("\n");
|
|
1549
|
+
}
|
|
1550
|
+
/** Reset all tracking (for new pipeline run) */
|
|
1551
|
+
reset() {
|
|
1552
|
+
this.records = [];
|
|
1553
|
+
this.latestQuota = void 0;
|
|
1554
|
+
this.currentAgent = "unknown";
|
|
1555
|
+
this.currentStage = "unknown";
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
var costTracker = new CostTracker();
|
|
1559
|
+
|
|
1560
|
+
// src/agents/BaseAgent.ts
|
|
1561
|
+
var BaseAgent = class {
|
|
1562
|
+
constructor(agentName, systemPrompt, provider) {
|
|
1563
|
+
this.agentName = agentName;
|
|
1564
|
+
this.systemPrompt = systemPrompt;
|
|
1565
|
+
this.provider = provider ?? getProvider();
|
|
1566
|
+
}
|
|
1567
|
+
provider;
|
|
1568
|
+
session = null;
|
|
1569
|
+
/** Tools this agent exposes to the LLM. Override in subclasses. */
|
|
1570
|
+
getTools() {
|
|
1571
|
+
return [];
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Send a user message to the agent and return the final response text.
|
|
1575
|
+
*
|
|
1576
|
+
* 1. Lazily creates an LLMSession via the provider
|
|
1577
|
+
* 2. Registers event listeners for logging
|
|
1578
|
+
* 3. Calls sendAndWait and records usage via CostTracker
|
|
1579
|
+
*/
|
|
1580
|
+
async run(userMessage) {
|
|
1581
|
+
if (!this.session) {
|
|
1582
|
+
this.session = await this.provider.createSession({
|
|
1583
|
+
systemPrompt: this.systemPrompt,
|
|
1584
|
+
tools: this.getTools(),
|
|
1585
|
+
streaming: true,
|
|
1586
|
+
model: process.env.LLM_MODEL || void 0,
|
|
1587
|
+
timeoutMs: 3e5
|
|
1588
|
+
// 5 min timeout
|
|
1589
|
+
});
|
|
1590
|
+
this.setupEventHandlers(this.session);
|
|
1591
|
+
}
|
|
1592
|
+
logger_default.info(`[${this.agentName}] Sending message: ${userMessage.substring(0, 80)}\u2026`);
|
|
1593
|
+
costTracker.setAgent(this.agentName);
|
|
1594
|
+
const response = await this.session.sendAndWait(userMessage);
|
|
1595
|
+
costTracker.recordUsage(
|
|
1596
|
+
this.provider.name,
|
|
1597
|
+
response.cost?.model ?? this.provider.getDefaultModel(),
|
|
1598
|
+
response.usage,
|
|
1599
|
+
response.cost,
|
|
1600
|
+
response.durationMs,
|
|
1601
|
+
response.quotaSnapshots ? Object.values(response.quotaSnapshots)[0] : void 0
|
|
1602
|
+
);
|
|
1603
|
+
const content = response.content;
|
|
1604
|
+
logger_default.info(`[${this.agentName}] Response received (${content.length} chars)`);
|
|
1605
|
+
return content;
|
|
1606
|
+
}
|
|
1607
|
+
/** Wire up session event listeners for logging. */
|
|
1608
|
+
setupEventHandlers(session) {
|
|
1609
|
+
session.on("delta", (event) => {
|
|
1610
|
+
logger_default.debug(`[${this.agentName}] delta: ${JSON.stringify(event.data)}`);
|
|
1611
|
+
});
|
|
1612
|
+
session.on("tool_start", (event) => {
|
|
1613
|
+
logger_default.info(`[${this.agentName}] tool start: ${JSON.stringify(event.data)}`);
|
|
1614
|
+
});
|
|
1615
|
+
session.on("tool_end", (event) => {
|
|
1616
|
+
logger_default.info(`[${this.agentName}] tool done: ${JSON.stringify(event.data)}`);
|
|
1617
|
+
});
|
|
1618
|
+
session.on("error", (event) => {
|
|
1619
|
+
logger_default.error(`[${this.agentName}] error: ${JSON.stringify(event.data)}`);
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
/** Tear down the session. */
|
|
1623
|
+
async destroy() {
|
|
1624
|
+
try {
|
|
1625
|
+
if (this.session) {
|
|
1626
|
+
await this.session.close();
|
|
1627
|
+
this.session = null;
|
|
1628
|
+
}
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
logger_default.error(`[${this.agentName}] Error during destroy: ${err}`);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
// src/tools/ffmpeg/frameCapture.ts
|
|
1636
|
+
import ffmpeg3 from "fluent-ffmpeg";
|
|
1637
|
+
import { promises as fs7 } from "fs";
|
|
1638
|
+
import path7 from "path";
|
|
1639
|
+
var ffmpegPath2 = getFFmpegPath();
|
|
1640
|
+
var ffprobePath2 = getFFprobePath();
|
|
1641
|
+
ffmpeg3.setFfmpegPath(ffmpegPath2);
|
|
1642
|
+
ffmpeg3.setFfprobePath(ffprobePath2);
|
|
1643
|
+
async function captureFrame(videoPath, timestamp, outputPath) {
|
|
1644
|
+
const outputDir = path7.dirname(outputPath);
|
|
1645
|
+
await fs7.mkdir(outputDir, { recursive: true });
|
|
1646
|
+
logger_default.info(`Capturing frame at ${timestamp}s \u2192 ${outputPath}`);
|
|
1647
|
+
return new Promise((resolve, reject) => {
|
|
1648
|
+
ffmpeg3(videoPath).seekInput(timestamp).frames(1).output(outputPath).on("end", () => {
|
|
1649
|
+
logger_default.info(`Frame captured: ${outputPath}`);
|
|
1650
|
+
resolve(outputPath);
|
|
1651
|
+
}).on("error", (err) => {
|
|
1652
|
+
logger_default.error(`Frame capture failed: ${err.message}`);
|
|
1653
|
+
reject(new Error(`Frame capture failed: ${err.message}`));
|
|
1654
|
+
}).run();
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// src/agents/SummaryAgent.ts
|
|
1659
|
+
function fmtTime(seconds) {
|
|
1660
|
+
const m = Math.floor(seconds / 60);
|
|
1661
|
+
const s = Math.floor(seconds % 60);
|
|
1662
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
1663
|
+
}
|
|
1664
|
+
function buildTranscriptBlock(transcript) {
|
|
1665
|
+
return transcript.segments.map((seg) => `[${fmtTime(seg.start)} \u2192 ${fmtTime(seg.end)}] ${seg.text.trim()}`).join("\n");
|
|
1666
|
+
}
|
|
1667
|
+
function buildSystemPrompt(shortsInfo, socialPostsInfo, captionsInfo, chaptersInfo) {
|
|
1668
|
+
const brand = getBrandConfig();
|
|
1669
|
+
return `You are a Video Summary Agent writing from the perspective of ${brand.name} (${brand.handle}).
|
|
1670
|
+
Brand voice: ${brand.voice.tone}. ${brand.voice.personality} ${brand.voice.style}
|
|
1671
|
+
|
|
1672
|
+
Your job is to analyse a video transcript and produce a beautiful, narrative-style Markdown README.
|
|
1673
|
+
|
|
1674
|
+
**Workflow**
|
|
1675
|
+
1. Read the transcript carefully.
|
|
1676
|
+
2. Identify 3-8 key topics, decisions, highlights, or memorable moments.
|
|
1677
|
+
3. For each highlight, decide on a representative timestamp and call the "capture_frame" tool to grab a screenshot.
|
|
1678
|
+
4. Once all frames are captured, call the "write_summary" tool with the final Markdown.
|
|
1679
|
+
|
|
1680
|
+
**Markdown structure \u2014 follow this layout exactly:**
|
|
1681
|
+
|
|
1682
|
+
\`\`\`
|
|
1683
|
+
# [Video Title]
|
|
1684
|
+
|
|
1685
|
+
> [Compelling one-line hook/tagline that captures the video's value]
|
|
1686
|
+
|
|
1687
|
+
[2-3 paragraph natural summary that reads like a blog post, NOT a timeline.
|
|
1688
|
+
Weave in key insights naturally. Write in the brand voice: ${brand.voice.tone}.
|
|
1689
|
+
${brand.contentGuidelines.blogFocus}]
|
|
1690
|
+
|
|
1691
|
+
---
|
|
1692
|
+
|
|
1693
|
+
## Key Moments
|
|
1694
|
+
|
|
1695
|
+
[For each key topic: write a narrative paragraph (not bullet points).
|
|
1696
|
+
Embed the timestamp as an inline badge like \`[0:12]\` within the text, NOT as a section header.
|
|
1697
|
+
Embed the screenshot naturally within or after the paragraph.
|
|
1698
|
+
Use blockquotes (>) for standout quotes or insights.]
|
|
1699
|
+
|
|
1700
|
+

|
|
1701
|
+
|
|
1702
|
+
[Continue with next topic paragraph...]
|
|
1703
|
+
|
|
1704
|
+
---
|
|
1705
|
+
|
|
1706
|
+
## \u{1F4CA} Quick Reference
|
|
1707
|
+
|
|
1708
|
+
| Topic | Timestamp |
|
|
1709
|
+
|-------|-----------|
|
|
1710
|
+
| Topic name | \`M:SS\` |
|
|
1711
|
+
| ... | ... |
|
|
1712
|
+
|
|
1713
|
+
---
|
|
1714
|
+
${chaptersInfo}
|
|
1715
|
+
${shortsInfo}
|
|
1716
|
+
${socialPostsInfo}
|
|
1717
|
+
${captionsInfo}
|
|
1718
|
+
|
|
1719
|
+
---
|
|
1720
|
+
|
|
1721
|
+
*Generated on [DATE] \u2022 Duration: [DURATION] \u2022 Tags: [relevant tags]*
|
|
1722
|
+
\`\`\`
|
|
1723
|
+
|
|
1724
|
+
**Writing style rules**
|
|
1725
|
+
- Write in a narrative, blog-post style \u2014 NOT a timestamp-driven timeline.
|
|
1726
|
+
- Timestamps appear as subtle inline badges like \`[0:12]\` or \`[1:30]\` within sentences, never as section headers.
|
|
1727
|
+
- The summary paragraphs should flow naturally and be enjoyable to read.
|
|
1728
|
+
- Use the brand perspective: ${brand.voice.personality}
|
|
1729
|
+
- Topics to emphasize: ${brand.advocacy.interests.join(", ")}
|
|
1730
|
+
- Avoid: ${brand.advocacy.avoids.join(", ")}
|
|
1731
|
+
|
|
1732
|
+
**Screenshot distribution rules \u2014 CRITICAL**
|
|
1733
|
+
- You MUST spread screenshots across the ENTIRE video duration, from beginning to end.
|
|
1734
|
+
- Divide the video into equal segments based on the number of screenshots you plan to capture, and pick one timestamp from each segment.
|
|
1735
|
+
- NO MORE than 2 screenshots should fall within the same 60-second window.
|
|
1736
|
+
- If the video is longer than 2 minutes, your first screenshot must NOT be in the first 10% and your last screenshot must be in the final 30% of the video.
|
|
1737
|
+
- Use the suggested timestamp ranges provided in the user message as guidance, but pick the exact moment within each range that best matches a key topic in the transcript.
|
|
1738
|
+
|
|
1739
|
+
**Tool rules**
|
|
1740
|
+
- Always call "capture_frame" BEFORE "write_summary".
|
|
1741
|
+
- The snapshot index must be a 1-based integer; the filename will be snapshot-001.png, etc.
|
|
1742
|
+
- In the Markdown, reference screenshots as \`thumbnails/snapshot-001.png\` (relative path).
|
|
1743
|
+
- Call "write_summary" exactly once with the complete Markdown string.`;
|
|
1744
|
+
}
|
|
1745
|
+
var SummaryAgent = class extends BaseAgent {
|
|
1746
|
+
videoPath;
|
|
1747
|
+
outputDir;
|
|
1748
|
+
snapshots = [];
|
|
1749
|
+
constructor(videoPath, outputDir, systemPrompt) {
|
|
1750
|
+
super("SummaryAgent", systemPrompt);
|
|
1751
|
+
this.videoPath = videoPath;
|
|
1752
|
+
this.outputDir = outputDir;
|
|
1753
|
+
}
|
|
1754
|
+
// Resolved paths
|
|
1755
|
+
get thumbnailDir() {
|
|
1756
|
+
return path8.join(this.outputDir, "thumbnails");
|
|
1757
|
+
}
|
|
1758
|
+
get markdownPath() {
|
|
1759
|
+
return path8.join(this.outputDir, "README.md");
|
|
1760
|
+
}
|
|
1761
|
+
/* ── Tools exposed to the LLM ─────────────────────────────────────────── */
|
|
1762
|
+
getTools() {
|
|
1763
|
+
return [
|
|
1764
|
+
{
|
|
1765
|
+
name: "capture_frame",
|
|
1766
|
+
description: "Capture a screenshot from the video at a specific timestamp. Provide: timestamp (seconds), description (what is shown), index (1-based integer for filename).",
|
|
1767
|
+
parameters: {
|
|
1768
|
+
type: "object",
|
|
1769
|
+
properties: {
|
|
1770
|
+
timestamp: { type: "number", description: "Timestamp in seconds to capture" },
|
|
1771
|
+
description: { type: "string", description: "Brief description of the visual moment" },
|
|
1772
|
+
index: { type: "integer", description: "1-based snapshot index (used for filename)" }
|
|
1773
|
+
},
|
|
1774
|
+
required: ["timestamp", "description", "index"]
|
|
1775
|
+
},
|
|
1776
|
+
handler: async (rawArgs) => {
|
|
1777
|
+
const args = rawArgs;
|
|
1778
|
+
return this.handleCaptureFrame(args);
|
|
1779
|
+
}
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
name: "write_summary",
|
|
1783
|
+
description: "Write the final Markdown summary to disk. Provide: markdown (full README content), title, overview, and keyTopics array.",
|
|
1784
|
+
parameters: {
|
|
1785
|
+
type: "object",
|
|
1786
|
+
properties: {
|
|
1787
|
+
markdown: { type: "string", description: "Complete Markdown content for README.md" },
|
|
1788
|
+
title: { type: "string", description: "Video title" },
|
|
1789
|
+
overview: { type: "string", description: "Short overview paragraph" },
|
|
1790
|
+
keyTopics: {
|
|
1791
|
+
type: "array",
|
|
1792
|
+
items: { type: "string" },
|
|
1793
|
+
description: "List of key topic names"
|
|
1794
|
+
}
|
|
1795
|
+
},
|
|
1796
|
+
required: ["markdown", "title", "overview", "keyTopics"]
|
|
1797
|
+
},
|
|
1798
|
+
handler: async (rawArgs) => {
|
|
1799
|
+
const args = rawArgs;
|
|
1800
|
+
return this.handleWriteSummary(args);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
];
|
|
1804
|
+
}
|
|
1805
|
+
/* ── Tool dispatch (required by BaseAgent) ─────────────────────────────── */
|
|
1806
|
+
async handleToolCall(toolName, args) {
|
|
1807
|
+
switch (toolName) {
|
|
1808
|
+
case "capture_frame":
|
|
1809
|
+
return this.handleCaptureFrame(args);
|
|
1810
|
+
case "write_summary":
|
|
1811
|
+
return this.handleWriteSummary(args);
|
|
1812
|
+
default:
|
|
1813
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
/* ── Tool implementations ──────────────────────────────────────────────── */
|
|
1817
|
+
async handleCaptureFrame(args) {
|
|
1818
|
+
const idx = String(args.index).padStart(3, "0");
|
|
1819
|
+
const filename = `snapshot-${idx}.png`;
|
|
1820
|
+
const outputPath = path8.join(this.thumbnailDir, filename);
|
|
1821
|
+
await captureFrame(this.videoPath, args.timestamp, outputPath);
|
|
1822
|
+
const snapshot = {
|
|
1823
|
+
timestamp: args.timestamp,
|
|
1824
|
+
description: args.description,
|
|
1825
|
+
outputPath
|
|
1826
|
+
};
|
|
1827
|
+
this.snapshots.push(snapshot);
|
|
1828
|
+
logger_default.info(`[SummaryAgent] Captured snapshot ${idx} at ${fmtTime(args.timestamp)}`);
|
|
1829
|
+
return `Frame captured: thumbnails/${filename}`;
|
|
1830
|
+
}
|
|
1831
|
+
async handleWriteSummary(args) {
|
|
1832
|
+
await fs8.mkdir(this.outputDir, { recursive: true });
|
|
1833
|
+
await fs8.writeFile(this.markdownPath, args.markdown, "utf-8");
|
|
1834
|
+
logger_default.info(`[SummaryAgent] Wrote summary \u2192 ${this.markdownPath}`);
|
|
1835
|
+
return `Summary written to ${this.markdownPath}`;
|
|
1836
|
+
}
|
|
1837
|
+
/** Expose collected data after the run. */
|
|
1838
|
+
getResult(args) {
|
|
1839
|
+
return {
|
|
1840
|
+
title: args.title,
|
|
1841
|
+
overview: args.overview,
|
|
1842
|
+
keyTopics: args.keyTopics,
|
|
1843
|
+
snapshots: this.snapshots,
|
|
1844
|
+
markdownPath: this.markdownPath
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
function buildShortsSection(shorts) {
|
|
1849
|
+
if (!shorts || shorts.length === 0) {
|
|
1850
|
+
return `
|
|
1851
|
+
## \u2702\uFE0F Shorts
|
|
1852
|
+
|
|
1853
|
+
| Short | Duration | Description |
|
|
1854
|
+
|-------|----------|-------------|
|
|
1855
|
+
| *Shorts will appear here once generated* | | |`;
|
|
1856
|
+
}
|
|
1857
|
+
const rows = shorts.map((s) => `| [${s.title}](shorts/${s.slug}.mp4) | ${Math.round(s.totalDuration)}s | ${s.description} |`).join("\n");
|
|
1858
|
+
return `
|
|
1859
|
+
## \u2702\uFE0F Shorts
|
|
1860
|
+
|
|
1861
|
+
| Short | Duration | Description |
|
|
1862
|
+
|-------|----------|-------------|
|
|
1863
|
+
${rows}`;
|
|
1864
|
+
}
|
|
1865
|
+
function buildSocialPostsSection() {
|
|
1866
|
+
return `
|
|
1867
|
+
## \u{1F4F1} Social Media Posts
|
|
1868
|
+
|
|
1869
|
+
- [TikTok](social-posts/tiktok.md)
|
|
1870
|
+
- [YouTube](social-posts/youtube.md)
|
|
1871
|
+
- [Instagram](social-posts/instagram.md)
|
|
1872
|
+
- [LinkedIn](social-posts/linkedin.md)
|
|
1873
|
+
- [X / Twitter](social-posts/x.md)
|
|
1874
|
+
- [Dev.to Blog](social-posts/devto.md)`;
|
|
1875
|
+
}
|
|
1876
|
+
function buildCaptionsSection() {
|
|
1877
|
+
return `
|
|
1878
|
+
## \u{1F3AC} Captions
|
|
1879
|
+
|
|
1880
|
+
- [SRT](captions/captions.srt) | [VTT](captions/captions.vtt) | [ASS (Styled)](captions/captions.ass)`;
|
|
1881
|
+
}
|
|
1882
|
+
function toYouTubeTimestamp(seconds) {
|
|
1883
|
+
const h = Math.floor(seconds / 3600);
|
|
1884
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
1885
|
+
const s = Math.floor(seconds % 60);
|
|
1886
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
|
|
1887
|
+
}
|
|
1888
|
+
function buildChaptersSection(chapters) {
|
|
1889
|
+
if (!chapters || chapters.length === 0) {
|
|
1890
|
+
return "";
|
|
1891
|
+
}
|
|
1892
|
+
const rows = chapters.map((ch) => `| \`${toYouTubeTimestamp(ch.timestamp)}\` | ${ch.title} | ${ch.description} |`).join("\n");
|
|
1893
|
+
return `
|
|
1894
|
+
## \u{1F4D1} Chapters
|
|
1895
|
+
|
|
1896
|
+
| Time | Chapter | Description |
|
|
1897
|
+
|------|---------|-------------|
|
|
1898
|
+
${rows}
|
|
1899
|
+
|
|
1900
|
+
> \u{1F4CB} [YouTube Timestamps](chapters/chapters-youtube.txt) \u2022 [Markdown](chapters/chapters.md) \u2022 [JSON](chapters/chapters.json)`;
|
|
1901
|
+
}
|
|
1902
|
+
async function generateSummary(video, transcript, shorts, chapters) {
|
|
1903
|
+
const config2 = getConfig();
|
|
1904
|
+
const outputDir = path8.join(config2.OUTPUT_DIR, video.slug);
|
|
1905
|
+
const shortsInfo = buildShortsSection(shorts);
|
|
1906
|
+
const socialPostsInfo = buildSocialPostsSection();
|
|
1907
|
+
const captionsInfo = buildCaptionsSection();
|
|
1908
|
+
const chaptersInfo = buildChaptersSection(chapters);
|
|
1909
|
+
const systemPrompt = buildSystemPrompt(shortsInfo, socialPostsInfo, captionsInfo, chaptersInfo);
|
|
1910
|
+
const agent = new SummaryAgent(video.repoPath, outputDir, systemPrompt);
|
|
1911
|
+
const transcriptBlock = buildTranscriptBlock(transcript);
|
|
1912
|
+
const screenshotCount = Math.min(8, Math.max(3, Math.round(video.duration / 120)));
|
|
1913
|
+
const interval = video.duration / screenshotCount;
|
|
1914
|
+
const suggestedRanges = Array.from({ length: screenshotCount }, (_, i) => {
|
|
1915
|
+
const center = Math.round(interval * (i + 0.5));
|
|
1916
|
+
const lo = Math.max(0, Math.round(center - interval / 2));
|
|
1917
|
+
const hi = Math.min(Math.round(video.duration), Math.round(center + interval / 2));
|
|
1918
|
+
return `${fmtTime(lo)}\u2013${fmtTime(hi)} (${lo}s\u2013${hi}s)`;
|
|
1919
|
+
}).join(", ");
|
|
1920
|
+
const userPrompt = [
|
|
1921
|
+
`**Video:** ${video.filename}`,
|
|
1922
|
+
`**Duration:** ${fmtTime(video.duration)} (${Math.round(video.duration)} seconds)`,
|
|
1923
|
+
`**Date:** ${video.createdAt.toISOString().slice(0, 10)}`,
|
|
1924
|
+
"",
|
|
1925
|
+
`**Suggested screenshot time ranges (one screenshot per range):**`,
|
|
1926
|
+
suggestedRanges,
|
|
1927
|
+
"",
|
|
1928
|
+
"---",
|
|
1929
|
+
"",
|
|
1930
|
+
"**Transcript:**",
|
|
1931
|
+
"",
|
|
1932
|
+
transcriptBlock
|
|
1933
|
+
].join("\n");
|
|
1934
|
+
let lastWriteArgs;
|
|
1935
|
+
const origHandleWrite = agent.handleWriteSummary.bind(agent);
|
|
1936
|
+
agent.handleWriteSummary = async (args) => {
|
|
1937
|
+
lastWriteArgs = args;
|
|
1938
|
+
return origHandleWrite(args);
|
|
1939
|
+
};
|
|
1940
|
+
try {
|
|
1941
|
+
await agent.run(userPrompt);
|
|
1942
|
+
if (!lastWriteArgs) {
|
|
1943
|
+
throw new Error("SummaryAgent did not call write_summary");
|
|
1944
|
+
}
|
|
1945
|
+
return agent.getResult(lastWriteArgs);
|
|
1946
|
+
} finally {
|
|
1947
|
+
await agent.destroy();
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// src/tools/ffmpeg/clipExtraction.ts
|
|
1952
|
+
import ffmpeg4 from "fluent-ffmpeg";
|
|
1953
|
+
import { execFile } from "child_process";
|
|
1954
|
+
import { promises as fs9 } from "fs";
|
|
1955
|
+
import pathMod from "path";
|
|
1956
|
+
import { randomUUID } from "crypto";
|
|
1957
|
+
var ffmpegPath3 = getFFmpegPath();
|
|
1958
|
+
var ffprobePath3 = getFFprobePath();
|
|
1959
|
+
ffmpeg4.setFfmpegPath(ffmpegPath3);
|
|
1960
|
+
ffmpeg4.setFfprobePath(ffprobePath3);
|
|
1961
|
+
var DEFAULT_FPS = 25;
|
|
1962
|
+
async function getVideoFps(videoPath) {
|
|
1963
|
+
return new Promise((resolve) => {
|
|
1964
|
+
execFile(
|
|
1965
|
+
ffprobePath3,
|
|
1966
|
+
["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "csv=p=0", videoPath],
|
|
1967
|
+
{ timeout: 5e3 },
|
|
1968
|
+
(error, stdout) => {
|
|
1969
|
+
if (error || !stdout.trim()) {
|
|
1970
|
+
resolve(DEFAULT_FPS);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const parts = stdout.trim().split("/");
|
|
1974
|
+
const fps = parts.length === 2 ? parseInt(parts[0]) / parseInt(parts[1]) : parseFloat(stdout.trim());
|
|
1975
|
+
resolve(isFinite(fps) && fps > 0 ? Math.round(fps) : DEFAULT_FPS);
|
|
1976
|
+
}
|
|
1977
|
+
);
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
async function extractClip(videoPath, start, end, outputPath, buffer = 1) {
|
|
1981
|
+
const outputDir = pathMod.dirname(outputPath);
|
|
1982
|
+
await fs9.mkdir(outputDir, { recursive: true });
|
|
1983
|
+
const bufferedStart = Math.max(0, start - buffer);
|
|
1984
|
+
const bufferedEnd = end + buffer;
|
|
1985
|
+
const duration = bufferedEnd - bufferedStart;
|
|
1986
|
+
logger_default.info(`Extracting clip [${start}s\u2013${end}s] (buffered: ${bufferedStart.toFixed(2)}s\u2013${bufferedEnd.toFixed(2)}s) \u2192 ${outputPath}`);
|
|
1987
|
+
return new Promise((resolve, reject) => {
|
|
1988
|
+
ffmpeg4(videoPath).setStartTime(bufferedStart).setDuration(duration).outputOptions(["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23", "-threads", "4", "-c:a", "aac", "-b:a", "128k"]).output(outputPath).on("end", () => {
|
|
1989
|
+
logger_default.info(`Clip extraction complete: ${outputPath}`);
|
|
1990
|
+
resolve(outputPath);
|
|
1991
|
+
}).on("error", (err) => {
|
|
1992
|
+
logger_default.error(`Clip extraction failed: ${err.message}`);
|
|
1993
|
+
reject(new Error(`Clip extraction failed: ${err.message}`));
|
|
1994
|
+
}).run();
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
async function extractCompositeClip(videoPath, segments, outputPath, buffer = 1) {
|
|
1998
|
+
if (!segments || segments.length === 0) {
|
|
1999
|
+
throw new Error("At least one segment is required");
|
|
2000
|
+
}
|
|
2001
|
+
if (segments.length === 1) {
|
|
2002
|
+
return extractClip(videoPath, segments[0].start, segments[0].end, outputPath, buffer);
|
|
2003
|
+
}
|
|
2004
|
+
const outputDir = pathMod.dirname(outputPath);
|
|
2005
|
+
await fs9.mkdir(outputDir, { recursive: true });
|
|
2006
|
+
const tempDir = pathMod.join(outputDir, `.temp-${randomUUID()}`);
|
|
2007
|
+
await fs9.mkdir(tempDir, { recursive: true });
|
|
2008
|
+
const tempFiles = [];
|
|
2009
|
+
try {
|
|
2010
|
+
for (let i = 0; i < segments.length; i++) {
|
|
2011
|
+
const seg = segments[i];
|
|
2012
|
+
const tempPath = pathMod.join(tempDir, `segment-${i}.mp4`);
|
|
2013
|
+
tempFiles.push(tempPath);
|
|
2014
|
+
const bufferedStart = Math.max(0, seg.start - buffer);
|
|
2015
|
+
const bufferedEnd = seg.end + buffer;
|
|
2016
|
+
logger_default.info(`Extracting segment ${i + 1}/${segments.length} [${seg.start}s\u2013${seg.end}s] (buffered: ${bufferedStart.toFixed(2)}s\u2013${bufferedEnd.toFixed(2)}s)`);
|
|
2017
|
+
await new Promise((resolve, reject) => {
|
|
2018
|
+
ffmpeg4(videoPath).setStartTime(bufferedStart).setDuration(bufferedEnd - bufferedStart).outputOptions(["-threads", "4", "-preset", "ultrafast"]).output(tempPath).on("end", () => resolve()).on("error", (err) => reject(new Error(`Segment ${i} extraction failed: ${err.message}`))).run();
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
const concatListPath = pathMod.join(tempDir, "concat-list.txt");
|
|
2022
|
+
const listContent = tempFiles.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n");
|
|
2023
|
+
await fs9.writeFile(concatListPath, listContent);
|
|
2024
|
+
logger_default.info(`Concatenating ${segments.length} segments \u2192 ${outputPath}`);
|
|
2025
|
+
await new Promise((resolve, reject) => {
|
|
2026
|
+
ffmpeg4().input(concatListPath).inputOptions(["-f", "concat", "-safe", "0"]).outputOptions(["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23", "-threads", "4", "-c:a", "aac"]).output(outputPath).on("end", () => resolve()).on("error", (err) => reject(new Error(`Concat failed: ${err.message}`))).run();
|
|
2027
|
+
});
|
|
2028
|
+
logger_default.info(`Composite clip complete: ${outputPath}`);
|
|
2029
|
+
return outputPath;
|
|
2030
|
+
} finally {
|
|
2031
|
+
await fs9.rm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
async function extractCompositeClipWithTransitions(videoPath, segments, outputPath, transitionDuration = 0.5, buffer = 1) {
|
|
2036
|
+
if (!segments || segments.length === 0) {
|
|
2037
|
+
throw new Error("At least one segment is required");
|
|
2038
|
+
}
|
|
2039
|
+
if (segments.length === 1) {
|
|
2040
|
+
return extractClip(videoPath, segments[0].start, segments[0].end, outputPath, buffer);
|
|
2041
|
+
}
|
|
2042
|
+
if (segments.length === 2 && transitionDuration <= 0) {
|
|
2043
|
+
return extractCompositeClip(videoPath, segments, outputPath, buffer);
|
|
2044
|
+
}
|
|
2045
|
+
const outputDir = pathMod.dirname(outputPath);
|
|
2046
|
+
await fs9.mkdir(outputDir, { recursive: true });
|
|
2047
|
+
const fps = await getVideoFps(videoPath);
|
|
2048
|
+
const filterParts = [];
|
|
2049
|
+
const segDurations = [];
|
|
2050
|
+
for (let i = 0; i < segments.length; i++) {
|
|
2051
|
+
const seg = segments[i];
|
|
2052
|
+
const bufferedStart = Math.max(0, seg.start - buffer);
|
|
2053
|
+
const bufferedEnd = seg.end + buffer;
|
|
2054
|
+
const duration = bufferedEnd - bufferedStart;
|
|
2055
|
+
segDurations.push(duration);
|
|
2056
|
+
filterParts.push(
|
|
2057
|
+
`[0:v]trim=start=${bufferedStart.toFixed(3)}:end=${bufferedEnd.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps}[v${i}]`
|
|
2058
|
+
);
|
|
2059
|
+
filterParts.push(
|
|
2060
|
+
`[0:a]atrim=start=${bufferedStart.toFixed(3)}:end=${bufferedEnd.toFixed(3)},asetpts=PTS-STARTPTS[a${i}]`
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
2063
|
+
let prevVideo = "v0";
|
|
2064
|
+
let prevAudio = "a0";
|
|
2065
|
+
let cumulativeDuration = segDurations[0];
|
|
2066
|
+
for (let i = 1; i < segments.length; i++) {
|
|
2067
|
+
const offset = Math.max(0, cumulativeDuration - transitionDuration);
|
|
2068
|
+
const outVideo = i === segments.length - 1 ? "vout" : `xv${i - 1}`;
|
|
2069
|
+
const outAudio = i === segments.length - 1 ? "aout" : `xa${i - 1}`;
|
|
2070
|
+
filterParts.push(
|
|
2071
|
+
`[${prevVideo}][v${i}]xfade=transition=fade:duration=${transitionDuration.toFixed(3)}:offset=${offset.toFixed(3)}[${outVideo}]`
|
|
2072
|
+
);
|
|
2073
|
+
filterParts.push(
|
|
2074
|
+
`[${prevAudio}][a${i}]acrossfade=d=${transitionDuration.toFixed(3)}[${outAudio}]`
|
|
2075
|
+
);
|
|
2076
|
+
prevVideo = outVideo;
|
|
2077
|
+
prevAudio = outAudio;
|
|
2078
|
+
cumulativeDuration = cumulativeDuration - transitionDuration + segDurations[i];
|
|
2079
|
+
}
|
|
2080
|
+
const filterComplex = filterParts.join(";\n");
|
|
2081
|
+
const args = [
|
|
2082
|
+
"-y",
|
|
2083
|
+
"-i",
|
|
2084
|
+
videoPath,
|
|
2085
|
+
"-filter_complex",
|
|
2086
|
+
filterComplex,
|
|
2087
|
+
"-map",
|
|
2088
|
+
"[vout]",
|
|
2089
|
+
"-map",
|
|
2090
|
+
"[aout]",
|
|
2091
|
+
"-c:v",
|
|
2092
|
+
"libx264",
|
|
2093
|
+
"-preset",
|
|
2094
|
+
"ultrafast",
|
|
2095
|
+
"-crf",
|
|
2096
|
+
"23",
|
|
2097
|
+
"-threads",
|
|
2098
|
+
"4",
|
|
2099
|
+
"-c:a",
|
|
2100
|
+
"aac",
|
|
2101
|
+
"-b:a",
|
|
2102
|
+
"128k",
|
|
2103
|
+
outputPath
|
|
2104
|
+
];
|
|
2105
|
+
logger_default.info(`[ClipExtraction] Compositing ${segments.length} segments with xfade transitions \u2192 ${outputPath}`);
|
|
2106
|
+
return new Promise((resolve, reject) => {
|
|
2107
|
+
execFile(ffmpegPath3, args, { maxBuffer: 50 * 1024 * 1024 }, (error, _stdout, stderr) => {
|
|
2108
|
+
if (error) {
|
|
2109
|
+
logger_default.error(`[ClipExtraction] xfade composite failed: ${stderr}`);
|
|
2110
|
+
reject(new Error(`xfade composite clip failed: ${error.message}`));
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
logger_default.info(`[ClipExtraction] xfade composite complete: ${outputPath}`);
|
|
2114
|
+
resolve(outputPath);
|
|
2115
|
+
});
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// src/tools/ffmpeg/captionBurning.ts
|
|
2120
|
+
import { execFile as execFile2 } from "child_process";
|
|
2121
|
+
import { promises as fs10 } from "fs";
|
|
2122
|
+
import pathMod2 from "path";
|
|
2123
|
+
import os from "os";
|
|
2124
|
+
import { fileURLToPath } from "url";
|
|
2125
|
+
var ffmpegPath4 = getFFmpegPath();
|
|
2126
|
+
var __dirname = pathMod2.dirname(fileURLToPath(import.meta.url));
|
|
2127
|
+
var FONTS_DIR = pathMod2.resolve(__dirname, "..", "..", "..", "assets", "fonts");
|
|
2128
|
+
async function burnCaptions(videoPath, assPath, outputPath) {
|
|
2129
|
+
const outputDir = pathMod2.dirname(outputPath);
|
|
2130
|
+
await fs10.mkdir(outputDir, { recursive: true });
|
|
2131
|
+
logger_default.info(`Burning captions into video \u2192 ${outputPath}`);
|
|
2132
|
+
const workDir = await fs10.mkdtemp(pathMod2.join(os.tmpdir(), "caption-"));
|
|
2133
|
+
const tempAss = pathMod2.join(workDir, "captions.ass");
|
|
2134
|
+
const tempOutput = pathMod2.join(workDir, "output.mp4");
|
|
2135
|
+
await fs10.copyFile(assPath, tempAss);
|
|
2136
|
+
const fontFiles = await fs10.readdir(FONTS_DIR);
|
|
2137
|
+
for (const f of fontFiles) {
|
|
2138
|
+
if (f.endsWith(".ttf") || f.endsWith(".otf")) {
|
|
2139
|
+
await fs10.copyFile(pathMod2.join(FONTS_DIR, f), pathMod2.join(workDir, f));
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
const args = [
|
|
2143
|
+
"-y",
|
|
2144
|
+
"-i",
|
|
2145
|
+
videoPath,
|
|
2146
|
+
"-vf",
|
|
2147
|
+
"ass=captions.ass:fontsdir=.",
|
|
2148
|
+
"-c:a",
|
|
2149
|
+
"copy",
|
|
2150
|
+
"-c:v",
|
|
2151
|
+
"libx264",
|
|
2152
|
+
"-preset",
|
|
2153
|
+
"ultrafast",
|
|
2154
|
+
"-crf",
|
|
2155
|
+
"23",
|
|
2156
|
+
"-threads",
|
|
2157
|
+
"4",
|
|
2158
|
+
tempOutput
|
|
2159
|
+
];
|
|
2160
|
+
return new Promise((resolve, reject) => {
|
|
2161
|
+
execFile2(ffmpegPath4, args, { cwd: workDir, maxBuffer: 10 * 1024 * 1024 }, async (error, _stdout, stderr) => {
|
|
2162
|
+
const cleanup = async () => {
|
|
2163
|
+
const files = await fs10.readdir(workDir).catch(() => []);
|
|
2164
|
+
for (const f of files) {
|
|
2165
|
+
await fs10.unlink(pathMod2.join(workDir, f)).catch(() => {
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
await fs10.rmdir(workDir).catch(() => {
|
|
2169
|
+
});
|
|
2170
|
+
};
|
|
2171
|
+
if (error) {
|
|
2172
|
+
await cleanup();
|
|
2173
|
+
logger_default.error(`Caption burning failed: ${stderr || error.message}`);
|
|
2174
|
+
reject(new Error(`Caption burning failed: ${stderr || error.message}`));
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
try {
|
|
2178
|
+
await fs10.rename(tempOutput, outputPath);
|
|
2179
|
+
} catch {
|
|
2180
|
+
await fs10.copyFile(tempOutput, outputPath);
|
|
2181
|
+
}
|
|
2182
|
+
await cleanup();
|
|
2183
|
+
logger_default.info(`Captions burned: ${outputPath}`);
|
|
2184
|
+
resolve(outputPath);
|
|
2185
|
+
});
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// src/tools/ffmpeg/aspectRatio.ts
|
|
2190
|
+
import { execFile as execFile4 } from "child_process";
|
|
2191
|
+
import { promises as fs12 } from "fs";
|
|
2192
|
+
import pathMod3 from "path";
|
|
2193
|
+
|
|
2194
|
+
// src/tools/ffmpeg/faceDetection.ts
|
|
2195
|
+
import { execFile as execFile3 } from "child_process";
|
|
2196
|
+
import { promises as fs11 } from "fs";
|
|
2197
|
+
import path9 from "path";
|
|
2198
|
+
import os2 from "os";
|
|
2199
|
+
import sharp from "sharp";
|
|
2200
|
+
var ffmpegPath5 = getFFmpegPath();
|
|
2201
|
+
var ffprobePath4 = getFFprobePath();
|
|
2202
|
+
var SAMPLE_FRAMES = 5;
|
|
2203
|
+
var ANALYSIS_WIDTH = 320;
|
|
2204
|
+
var ANALYSIS_HEIGHT = 180;
|
|
2205
|
+
var CORNER_FRACTION = 0.25;
|
|
2206
|
+
var MIN_SKIN_RATIO = 0.05;
|
|
2207
|
+
var MIN_CONFIDENCE = 0.3;
|
|
2208
|
+
var REFINE_MIN_EDGE_DIFF = 3;
|
|
2209
|
+
var REFINE_MIN_SIZE_FRAC = 0.05;
|
|
2210
|
+
var REFINE_MAX_SIZE_FRAC = 0.55;
|
|
2211
|
+
async function getVideoDuration(videoPath) {
|
|
2212
|
+
return new Promise((resolve, reject) => {
|
|
2213
|
+
execFile3(
|
|
2214
|
+
ffprobePath4,
|
|
2215
|
+
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", videoPath],
|
|
2216
|
+
(error, stdout) => {
|
|
2217
|
+
if (error) {
|
|
2218
|
+
reject(new Error(`ffprobe failed: ${error.message}`));
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
resolve(parseFloat(stdout.trim()));
|
|
2222
|
+
}
|
|
2223
|
+
);
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
async function getVideoResolution(videoPath) {
|
|
2227
|
+
return new Promise((resolve, reject) => {
|
|
2228
|
+
execFile3(
|
|
2229
|
+
ffprobePath4,
|
|
2230
|
+
[
|
|
2231
|
+
"-v",
|
|
2232
|
+
"error",
|
|
2233
|
+
"-select_streams",
|
|
2234
|
+
"v:0",
|
|
2235
|
+
"-show_entries",
|
|
2236
|
+
"stream=width,height",
|
|
2237
|
+
"-of",
|
|
2238
|
+
"csv=p=0",
|
|
2239
|
+
videoPath
|
|
2240
|
+
],
|
|
2241
|
+
(error, stdout) => {
|
|
2242
|
+
if (error) {
|
|
2243
|
+
reject(new Error(`ffprobe failed: ${error.message}`));
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
const [w, h] = stdout.trim().split(",").map(Number);
|
|
2247
|
+
resolve({ width: w, height: h });
|
|
2248
|
+
}
|
|
2249
|
+
);
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
async function extractSampleFrames(videoPath, tempDir) {
|
|
2253
|
+
const duration = await getVideoDuration(videoPath);
|
|
2254
|
+
const interval = Math.max(1, Math.floor(duration / (SAMPLE_FRAMES + 1)));
|
|
2255
|
+
const timestamps = [];
|
|
2256
|
+
for (let i = 1; i <= SAMPLE_FRAMES; i++) {
|
|
2257
|
+
timestamps.push(i * interval);
|
|
2258
|
+
}
|
|
2259
|
+
const framePaths = [];
|
|
2260
|
+
for (let i = 0; i < timestamps.length; i++) {
|
|
2261
|
+
const framePath = path9.join(tempDir, `frame_${i}.png`);
|
|
2262
|
+
framePaths.push(framePath);
|
|
2263
|
+
await new Promise((resolve, reject) => {
|
|
2264
|
+
execFile3(
|
|
2265
|
+
ffmpegPath5,
|
|
2266
|
+
[
|
|
2267
|
+
"-y",
|
|
2268
|
+
"-ss",
|
|
2269
|
+
timestamps[i].toFixed(2),
|
|
2270
|
+
"-i",
|
|
2271
|
+
videoPath,
|
|
2272
|
+
"-vf",
|
|
2273
|
+
`scale=${ANALYSIS_WIDTH}:${ANALYSIS_HEIGHT}`,
|
|
2274
|
+
"-frames:v",
|
|
2275
|
+
"1",
|
|
2276
|
+
"-q:v",
|
|
2277
|
+
"2",
|
|
2278
|
+
framePath
|
|
2279
|
+
],
|
|
2280
|
+
{ maxBuffer: 10 * 1024 * 1024 },
|
|
2281
|
+
(error) => {
|
|
2282
|
+
if (error) {
|
|
2283
|
+
reject(new Error(`Frame extraction failed at ${timestamps[i]}s: ${error.message}`));
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
resolve();
|
|
2287
|
+
}
|
|
2288
|
+
);
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
return framePaths;
|
|
2292
|
+
}
|
|
2293
|
+
function isSkinTone(r, g, b) {
|
|
2294
|
+
const max = Math.max(r, g, b);
|
|
2295
|
+
const min = Math.min(r, g, b);
|
|
2296
|
+
return r > 95 && g > 40 && b > 20 && max - min > 15 && Math.abs(r - g) > 15 && r > g && r > b;
|
|
2297
|
+
}
|
|
2298
|
+
async function analyzeCorner(framePath, position) {
|
|
2299
|
+
const cornerW = Math.floor(ANALYSIS_WIDTH * CORNER_FRACTION);
|
|
2300
|
+
const cornerH = Math.floor(ANALYSIS_HEIGHT * CORNER_FRACTION);
|
|
2301
|
+
let left;
|
|
2302
|
+
let top;
|
|
2303
|
+
switch (position) {
|
|
2304
|
+
case "top-left":
|
|
2305
|
+
left = 0;
|
|
2306
|
+
top = 0;
|
|
2307
|
+
break;
|
|
2308
|
+
case "top-right":
|
|
2309
|
+
left = ANALYSIS_WIDTH - cornerW;
|
|
2310
|
+
top = 0;
|
|
2311
|
+
break;
|
|
2312
|
+
case "bottom-left":
|
|
2313
|
+
left = 0;
|
|
2314
|
+
top = ANALYSIS_HEIGHT - cornerH;
|
|
2315
|
+
break;
|
|
2316
|
+
case "bottom-right":
|
|
2317
|
+
left = ANALYSIS_WIDTH - cornerW;
|
|
2318
|
+
top = ANALYSIS_HEIGHT - cornerH;
|
|
2319
|
+
break;
|
|
2320
|
+
}
|
|
2321
|
+
const { data, info } = await sharp(framePath).extract({ left, top, width: cornerW, height: cornerH }).raw().toBuffer({ resolveWithObject: true });
|
|
2322
|
+
const totalPixels = info.width * info.height;
|
|
2323
|
+
const channels = info.channels;
|
|
2324
|
+
let skinCount = 0;
|
|
2325
|
+
let sumR = 0, sumG = 0, sumB = 0;
|
|
2326
|
+
let sumR2 = 0, sumG2 = 0, sumB2 = 0;
|
|
2327
|
+
for (let i = 0; i < data.length; i += channels) {
|
|
2328
|
+
const r = data[i];
|
|
2329
|
+
const g = data[i + 1];
|
|
2330
|
+
const b = data[i + 2];
|
|
2331
|
+
if (isSkinTone(r, g, b)) skinCount++;
|
|
2332
|
+
sumR += r;
|
|
2333
|
+
sumG += g;
|
|
2334
|
+
sumB += b;
|
|
2335
|
+
sumR2 += r * r;
|
|
2336
|
+
sumG2 += g * g;
|
|
2337
|
+
sumB2 += b * b;
|
|
2338
|
+
}
|
|
2339
|
+
const skinToneRatio = skinCount / totalPixels;
|
|
2340
|
+
const meanR = sumR / totalPixels;
|
|
2341
|
+
const meanG = sumG / totalPixels;
|
|
2342
|
+
const meanB = sumB / totalPixels;
|
|
2343
|
+
const varR = sumR2 / totalPixels - meanR * meanR;
|
|
2344
|
+
const varG = sumG2 / totalPixels - meanG * meanG;
|
|
2345
|
+
const varB = sumB2 / totalPixels - meanB * meanB;
|
|
2346
|
+
const variance = (varR + varG + varB) / 3;
|
|
2347
|
+
return {
|
|
2348
|
+
position,
|
|
2349
|
+
x: left,
|
|
2350
|
+
y: top,
|
|
2351
|
+
width: cornerW,
|
|
2352
|
+
height: cornerH,
|
|
2353
|
+
skinToneRatio,
|
|
2354
|
+
variance
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
function columnMeansForRows(data, width, channels, yFrom, yTo) {
|
|
2358
|
+
const means = new Float64Array(width);
|
|
2359
|
+
const count = yTo - yFrom;
|
|
2360
|
+
if (count <= 0) return means;
|
|
2361
|
+
for (let x = 0; x < width; x++) {
|
|
2362
|
+
let sum = 0;
|
|
2363
|
+
for (let y = yFrom; y < yTo; y++) {
|
|
2364
|
+
const idx = (y * width + x) * channels;
|
|
2365
|
+
sum += (data[idx] + data[idx + 1] + data[idx + 2]) / 3;
|
|
2366
|
+
}
|
|
2367
|
+
means[x] = sum / count;
|
|
2368
|
+
}
|
|
2369
|
+
return means;
|
|
2370
|
+
}
|
|
2371
|
+
function rowMeansForCols(data, width, channels, height, xFrom, xTo) {
|
|
2372
|
+
const means = new Float64Array(height);
|
|
2373
|
+
const count = xTo - xFrom;
|
|
2374
|
+
if (count <= 0) return means;
|
|
2375
|
+
for (let y = 0; y < height; y++) {
|
|
2376
|
+
let sum = 0;
|
|
2377
|
+
for (let x = xFrom; x < xTo; x++) {
|
|
2378
|
+
const idx = (y * width + x) * channels;
|
|
2379
|
+
sum += (data[idx] + data[idx + 1] + data[idx + 2]) / 3;
|
|
2380
|
+
}
|
|
2381
|
+
means[y] = sum / count;
|
|
2382
|
+
}
|
|
2383
|
+
return means;
|
|
2384
|
+
}
|
|
2385
|
+
function averageFloat64Arrays(arrays) {
|
|
2386
|
+
if (arrays.length === 0) return new Float64Array(0);
|
|
2387
|
+
const len = arrays[0].length;
|
|
2388
|
+
const result = new Float64Array(len);
|
|
2389
|
+
for (const arr of arrays) {
|
|
2390
|
+
for (let i = 0; i < len; i++) result[i] += arr[i];
|
|
2391
|
+
}
|
|
2392
|
+
for (let i = 0; i < len; i++) result[i] /= arrays.length;
|
|
2393
|
+
return result;
|
|
2394
|
+
}
|
|
2395
|
+
function findPeakDiff(means, searchFrom, searchTo, minDiff) {
|
|
2396
|
+
const lo = Math.max(0, Math.min(searchFrom, searchTo));
|
|
2397
|
+
const hi = Math.min(means.length - 1, Math.max(searchFrom, searchTo));
|
|
2398
|
+
let maxDiff = 0;
|
|
2399
|
+
let maxIdx = -1;
|
|
2400
|
+
for (let i = lo; i < hi; i++) {
|
|
2401
|
+
const d = Math.abs(means[i + 1] - means[i]);
|
|
2402
|
+
if (d > maxDiff) {
|
|
2403
|
+
maxDiff = d;
|
|
2404
|
+
maxIdx = i;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
return maxDiff >= minDiff ? { index: maxIdx, magnitude: maxDiff } : { index: -1, magnitude: maxDiff };
|
|
2408
|
+
}
|
|
2409
|
+
async function refineBoundingBox(framePaths, position) {
|
|
2410
|
+
if (framePaths.length === 0) return null;
|
|
2411
|
+
const isRight = position.includes("right");
|
|
2412
|
+
const isBottom = position.includes("bottom");
|
|
2413
|
+
let fw = 0, fh = 0;
|
|
2414
|
+
const colMeansAll = [];
|
|
2415
|
+
const rowMeansAll = [];
|
|
2416
|
+
for (const fp of framePaths) {
|
|
2417
|
+
const { data, info } = await sharp(fp).raw().toBuffer({ resolveWithObject: true });
|
|
2418
|
+
fw = info.width;
|
|
2419
|
+
fh = info.height;
|
|
2420
|
+
const yFrom2 = isBottom ? Math.floor(fh * 0.35) : 0;
|
|
2421
|
+
const yTo2 = isBottom ? fh : Math.ceil(fh * 0.65);
|
|
2422
|
+
colMeansAll.push(columnMeansForRows(data, fw, info.channels, yFrom2, yTo2));
|
|
2423
|
+
const xFrom2 = isRight ? Math.floor(fw * 0.35) : 0;
|
|
2424
|
+
const xTo2 = isRight ? fw : Math.ceil(fw * 0.65);
|
|
2425
|
+
rowMeansAll.push(rowMeansForCols(data, fw, info.channels, fh, xFrom2, xTo2));
|
|
2426
|
+
}
|
|
2427
|
+
const avgCols = averageFloat64Arrays(colMeansAll);
|
|
2428
|
+
const avgRows = averageFloat64Arrays(rowMeansAll);
|
|
2429
|
+
const xFrom = isRight ? Math.floor(fw * 0.35) : Math.floor(fw * 0.05);
|
|
2430
|
+
const xTo = isRight ? Math.floor(fw * 0.95) : Math.floor(fw * 0.65);
|
|
2431
|
+
const xEdge = findPeakDiff(avgCols, xFrom, xTo, REFINE_MIN_EDGE_DIFF);
|
|
2432
|
+
const yFrom = isBottom ? Math.floor(fh * 0.35) : Math.floor(fh * 0.05);
|
|
2433
|
+
const yTo = isBottom ? Math.floor(fh * 0.95) : Math.floor(fh * 0.65);
|
|
2434
|
+
const yEdge = findPeakDiff(avgRows, yFrom, yTo, REFINE_MIN_EDGE_DIFF);
|
|
2435
|
+
if (xEdge.index < 0 || yEdge.index < 0) {
|
|
2436
|
+
logger_default.info(
|
|
2437
|
+
`[FaceDetection] Edge refinement: no strong edges (xDiff=${xEdge.magnitude.toFixed(1)}, yDiff=${yEdge.magnitude.toFixed(1)})`
|
|
2438
|
+
);
|
|
2439
|
+
return null;
|
|
2440
|
+
}
|
|
2441
|
+
let x, y, w, h;
|
|
2442
|
+
if (isRight) {
|
|
2443
|
+
x = xEdge.index + 1;
|
|
2444
|
+
w = fw - x;
|
|
2445
|
+
} else {
|
|
2446
|
+
x = 0;
|
|
2447
|
+
w = xEdge.index;
|
|
2448
|
+
}
|
|
2449
|
+
if (isBottom) {
|
|
2450
|
+
y = yEdge.index + 1;
|
|
2451
|
+
h = fh - y;
|
|
2452
|
+
} else {
|
|
2453
|
+
y = 0;
|
|
2454
|
+
h = yEdge.index;
|
|
2455
|
+
}
|
|
2456
|
+
if (w < fw * REFINE_MIN_SIZE_FRAC || h < fh * REFINE_MIN_SIZE_FRAC || w > fw * REFINE_MAX_SIZE_FRAC || h > fh * REFINE_MAX_SIZE_FRAC) {
|
|
2457
|
+
logger_default.info(
|
|
2458
|
+
`[FaceDetection] Refined bounds implausible (${w}x${h} in ${fw}x${fh}), using coarse bounds`
|
|
2459
|
+
);
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
logger_default.info(
|
|
2463
|
+
`[FaceDetection] Refined webcam: (${x},${y}) ${w}x${h} at analysis scale (xDiff=${xEdge.magnitude.toFixed(1)}, yDiff=${yEdge.magnitude.toFixed(1)})`
|
|
2464
|
+
);
|
|
2465
|
+
return { x, y, width: w, height: h };
|
|
2466
|
+
}
|
|
2467
|
+
function calculateCornerConfidence(scores) {
|
|
2468
|
+
if (scores.length === 0) return 0;
|
|
2469
|
+
const nonZeroCount = scores.filter((s) => s > 0).length;
|
|
2470
|
+
const consistency = nonZeroCount / scores.length;
|
|
2471
|
+
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
2472
|
+
return consistency * Math.min(avgScore * 10, 1);
|
|
2473
|
+
}
|
|
2474
|
+
async function detectWebcamRegion(videoPath) {
|
|
2475
|
+
const tempDir = await fs11.mkdtemp(path9.join(os2.tmpdir(), "face-detect-"));
|
|
2476
|
+
try {
|
|
2477
|
+
const resolution = await getVideoResolution(videoPath);
|
|
2478
|
+
const framePaths = await extractSampleFrames(videoPath, tempDir);
|
|
2479
|
+
const positions = [
|
|
2480
|
+
"top-left",
|
|
2481
|
+
"top-right",
|
|
2482
|
+
"bottom-left",
|
|
2483
|
+
"bottom-right"
|
|
2484
|
+
];
|
|
2485
|
+
const scoresByPosition = /* @__PURE__ */ new Map();
|
|
2486
|
+
for (const pos of positions) {
|
|
2487
|
+
scoresByPosition.set(pos, []);
|
|
2488
|
+
}
|
|
2489
|
+
for (const framePath of framePaths) {
|
|
2490
|
+
for (const pos of positions) {
|
|
2491
|
+
const analysis = await analyzeCorner(framePath, pos);
|
|
2492
|
+
const score = analysis.skinToneRatio > MIN_SKIN_RATIO ? analysis.skinToneRatio * Math.min(analysis.variance / 1e3, 1) : 0;
|
|
2493
|
+
scoresByPosition.get(pos).push(score);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
let bestPosition = null;
|
|
2497
|
+
let bestConfidence = 0;
|
|
2498
|
+
for (const [pos, scores] of scoresByPosition) {
|
|
2499
|
+
const confidence = calculateCornerConfidence(scores);
|
|
2500
|
+
if (confidence > bestConfidence) {
|
|
2501
|
+
bestConfidence = confidence;
|
|
2502
|
+
bestPosition = pos;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
if (!bestPosition || bestConfidence < MIN_CONFIDENCE) {
|
|
2506
|
+
logger_default.info("[FaceDetection] No webcam region detected");
|
|
2507
|
+
return null;
|
|
2508
|
+
}
|
|
2509
|
+
const refined = await refineBoundingBox(framePaths, bestPosition);
|
|
2510
|
+
const scaleX = resolution.width / ANALYSIS_WIDTH;
|
|
2511
|
+
const scaleY = resolution.height / ANALYSIS_HEIGHT;
|
|
2512
|
+
let origX, origY, origW, origH;
|
|
2513
|
+
if (refined) {
|
|
2514
|
+
origX = Math.round(refined.x * scaleX);
|
|
2515
|
+
origY = Math.round(refined.y * scaleY);
|
|
2516
|
+
origW = Math.round(refined.width * scaleX);
|
|
2517
|
+
origH = Math.round(refined.height * scaleY);
|
|
2518
|
+
} else {
|
|
2519
|
+
const cornerW = Math.floor(ANALYSIS_WIDTH * CORNER_FRACTION);
|
|
2520
|
+
const cornerH = Math.floor(ANALYSIS_HEIGHT * CORNER_FRACTION);
|
|
2521
|
+
origW = Math.round(cornerW * scaleX);
|
|
2522
|
+
origH = Math.round(cornerH * scaleY);
|
|
2523
|
+
switch (bestPosition) {
|
|
2524
|
+
case "top-left":
|
|
2525
|
+
origX = 0;
|
|
2526
|
+
origY = 0;
|
|
2527
|
+
break;
|
|
2528
|
+
case "top-right":
|
|
2529
|
+
origX = resolution.width - origW;
|
|
2530
|
+
origY = 0;
|
|
2531
|
+
break;
|
|
2532
|
+
case "bottom-left":
|
|
2533
|
+
origX = 0;
|
|
2534
|
+
origY = resolution.height - origH;
|
|
2535
|
+
break;
|
|
2536
|
+
case "bottom-right":
|
|
2537
|
+
origX = resolution.width - origW;
|
|
2538
|
+
origY = resolution.height - origH;
|
|
2539
|
+
break;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
const region = {
|
|
2543
|
+
x: origX,
|
|
2544
|
+
y: origY,
|
|
2545
|
+
width: origW,
|
|
2546
|
+
height: origH,
|
|
2547
|
+
position: bestPosition,
|
|
2548
|
+
confidence: Math.round(bestConfidence * 100) / 100
|
|
2549
|
+
};
|
|
2550
|
+
logger_default.info(
|
|
2551
|
+
`[FaceDetection] Webcam detected at ${region.position} (${region.x},${region.y} ${region.width}x${region.height}) confidence=${region.confidence} refined=${!!refined}`
|
|
2552
|
+
);
|
|
2553
|
+
return region;
|
|
2554
|
+
} finally {
|
|
2555
|
+
const files = await fs11.readdir(tempDir).catch(() => []);
|
|
2556
|
+
for (const f of files) {
|
|
2557
|
+
await fs11.unlink(path9.join(tempDir, f)).catch(() => {
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
await fs11.rmdir(tempDir).catch(() => {
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// src/tools/ffmpeg/aspectRatio.ts
|
|
2566
|
+
var ffmpegPath6 = getFFmpegPath();
|
|
2567
|
+
var PLATFORM_RATIOS = {
|
|
2568
|
+
"tiktok": "9:16",
|
|
2569
|
+
"youtube-shorts": "9:16",
|
|
2570
|
+
"instagram-reels": "9:16",
|
|
2571
|
+
"instagram-feed": "4:5",
|
|
2572
|
+
"linkedin": "1:1",
|
|
2573
|
+
"youtube": "16:9",
|
|
2574
|
+
"twitter": "1:1"
|
|
2575
|
+
};
|
|
2576
|
+
var DIMENSIONS = {
|
|
2577
|
+
"16:9": { width: 1920, height: 1080 },
|
|
2578
|
+
"9:16": { width: 1080, height: 1920 },
|
|
2579
|
+
"1:1": { width: 1080, height: 1080 },
|
|
2580
|
+
"4:5": { width: 1080, height: 1350 }
|
|
2581
|
+
};
|
|
2582
|
+
function buildCropFilter(targetRatio, letterbox) {
|
|
2583
|
+
if (letterbox) {
|
|
2584
|
+
const { width, height } = DIMENSIONS[targetRatio];
|
|
2585
|
+
return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
|
|
2586
|
+
}
|
|
2587
|
+
switch (targetRatio) {
|
|
2588
|
+
case "9:16":
|
|
2589
|
+
return "crop=ih*9/16:ih:(iw-ih*9/16)/2:0,scale=1080:1920";
|
|
2590
|
+
case "1:1":
|
|
2591
|
+
return "crop=ih:ih:(iw-ih)/2:0,scale=1080:1080";
|
|
2592
|
+
case "4:5":
|
|
2593
|
+
return "crop=ih*4/5:ih:(iw-ih*4/5)/2:0,scale=1080:1350";
|
|
2594
|
+
case "16:9":
|
|
2595
|
+
return "scale=1920:1080";
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
async function convertAspectRatio(inputPath, outputPath, targetRatio, options = {}) {
|
|
2599
|
+
const outputDir = pathMod3.dirname(outputPath);
|
|
2600
|
+
await fs12.mkdir(outputDir, { recursive: true });
|
|
2601
|
+
const sourceRatio = "16:9";
|
|
2602
|
+
if (sourceRatio === targetRatio && !options.letterbox) {
|
|
2603
|
+
logger_default.info(`Aspect ratio already ${targetRatio}, copying \u2192 ${outputPath}`);
|
|
2604
|
+
await fs12.copyFile(inputPath, outputPath);
|
|
2605
|
+
return outputPath;
|
|
2606
|
+
}
|
|
2607
|
+
const vf = buildCropFilter(targetRatio, options.letterbox ?? false);
|
|
2608
|
+
logger_default.info(`Converting aspect ratio to ${targetRatio} (filter: ${vf}) \u2192 ${outputPath}`);
|
|
2609
|
+
const args = [
|
|
2610
|
+
"-y",
|
|
2611
|
+
"-i",
|
|
2612
|
+
inputPath,
|
|
2613
|
+
"-vf",
|
|
2614
|
+
vf,
|
|
2615
|
+
"-c:v",
|
|
2616
|
+
"libx264",
|
|
2617
|
+
"-preset",
|
|
2618
|
+
"ultrafast",
|
|
2619
|
+
"-crf",
|
|
2620
|
+
"23",
|
|
2621
|
+
"-c:a",
|
|
2622
|
+
"copy",
|
|
2623
|
+
"-threads",
|
|
2624
|
+
"4",
|
|
2625
|
+
outputPath
|
|
2626
|
+
];
|
|
2627
|
+
return new Promise((resolve, reject) => {
|
|
2628
|
+
execFile4(ffmpegPath6, args, { maxBuffer: 10 * 1024 * 1024 }, (error, _stdout, stderr) => {
|
|
2629
|
+
if (error) {
|
|
2630
|
+
logger_default.error(`Aspect ratio conversion failed: ${stderr || error.message}`);
|
|
2631
|
+
reject(new Error(`Aspect ratio conversion failed: ${stderr || error.message}`));
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
logger_default.info(`Aspect ratio conversion complete: ${outputPath}`);
|
|
2635
|
+
resolve(outputPath);
|
|
2636
|
+
});
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
async function convertWithSmartLayout(inputPath, outputPath, config2) {
|
|
2640
|
+
const { label, targetW, screenH, camH, fallbackRatio } = config2;
|
|
2641
|
+
const outputDir = pathMod3.dirname(outputPath);
|
|
2642
|
+
await fs12.mkdir(outputDir, { recursive: true });
|
|
2643
|
+
const webcam = await detectWebcamRegion(inputPath);
|
|
2644
|
+
if (!webcam) {
|
|
2645
|
+
logger_default.info(`[${label}] No webcam found, falling back to center-crop`);
|
|
2646
|
+
return convertAspectRatio(inputPath, outputPath, fallbackRatio);
|
|
2647
|
+
}
|
|
2648
|
+
const resolution = await getVideoResolution(inputPath);
|
|
2649
|
+
let screenCropX;
|
|
2650
|
+
let screenCropW;
|
|
2651
|
+
if (webcam.position === "top-right" || webcam.position === "bottom-right") {
|
|
2652
|
+
screenCropX = 0;
|
|
2653
|
+
screenCropW = webcam.x;
|
|
2654
|
+
} else {
|
|
2655
|
+
screenCropX = webcam.x + webcam.width;
|
|
2656
|
+
screenCropW = Math.max(0, resolution.width - screenCropX);
|
|
2657
|
+
}
|
|
2658
|
+
const targetAR = targetW / camH;
|
|
2659
|
+
const webcamAR = webcam.width / webcam.height;
|
|
2660
|
+
let faceX, faceY, faceW, faceH;
|
|
2661
|
+
if (webcamAR > targetAR) {
|
|
2662
|
+
faceH = webcam.height;
|
|
2663
|
+
faceW = Math.round(faceH * targetAR);
|
|
2664
|
+
faceX = webcam.x + Math.round((webcam.width - faceW) / 2);
|
|
2665
|
+
faceY = webcam.y;
|
|
2666
|
+
} else {
|
|
2667
|
+
faceW = webcam.width;
|
|
2668
|
+
faceH = Math.round(faceW / targetAR);
|
|
2669
|
+
faceX = webcam.x;
|
|
2670
|
+
faceY = webcam.y + Math.round((webcam.height - faceH) / 2);
|
|
2671
|
+
}
|
|
2672
|
+
const filterComplex = [
|
|
2673
|
+
`[0:v]crop=${screenCropW}:ih:${screenCropX}:0,scale=${targetW}:${screenH}:force_original_aspect_ratio=decrease,pad=${targetW}:${screenH}:(ow-iw)/2:(oh-ih)/2:black[screen]`,
|
|
2674
|
+
`[0:v]crop=${faceW}:${faceH}:${faceX}:${faceY},scale=${targetW}:${camH}[cam]`,
|
|
2675
|
+
"[screen][cam]vstack[out]"
|
|
2676
|
+
].join(";");
|
|
2677
|
+
logger_default.info(`[${label}] Split-screen layout: webcam at ${webcam.position} \u2192 ${outputPath}`);
|
|
2678
|
+
const args = [
|
|
2679
|
+
"-y",
|
|
2680
|
+
"-i",
|
|
2681
|
+
inputPath,
|
|
2682
|
+
"-filter_complex",
|
|
2683
|
+
filterComplex,
|
|
2684
|
+
"-map",
|
|
2685
|
+
"[out]",
|
|
2686
|
+
"-map",
|
|
2687
|
+
"0:a",
|
|
2688
|
+
"-c:v",
|
|
2689
|
+
"libx264",
|
|
2690
|
+
"-preset",
|
|
2691
|
+
"ultrafast",
|
|
2692
|
+
"-crf",
|
|
2693
|
+
"23",
|
|
2694
|
+
"-c:a",
|
|
2695
|
+
"aac",
|
|
2696
|
+
"-b:a",
|
|
2697
|
+
"128k",
|
|
2698
|
+
"-threads",
|
|
2699
|
+
"4",
|
|
2700
|
+
outputPath
|
|
2701
|
+
];
|
|
2702
|
+
return new Promise((resolve, reject) => {
|
|
2703
|
+
execFile4(ffmpegPath6, args, { maxBuffer: 10 * 1024 * 1024 }, (error, _stdout, stderr) => {
|
|
2704
|
+
if (error) {
|
|
2705
|
+
logger_default.error(`[${label}] FFmpeg failed: ${stderr || error.message}`);
|
|
2706
|
+
reject(new Error(`${label} conversion failed: ${stderr || error.message}`));
|
|
68
2707
|
return;
|
|
69
|
-
|
|
2708
|
+
}
|
|
2709
|
+
logger_default.info(`[${label}] Complete: ${outputPath}`);
|
|
2710
|
+
resolve(outputPath);
|
|
2711
|
+
});
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
async function convertToPortraitSmart(inputPath, outputPath) {
|
|
2715
|
+
return convertWithSmartLayout(inputPath, outputPath, {
|
|
2716
|
+
label: "SmartPortrait",
|
|
2717
|
+
targetW: 1080,
|
|
2718
|
+
screenH: 1248,
|
|
2719
|
+
camH: 672,
|
|
2720
|
+
fallbackRatio: "9:16"
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
async function convertToSquareSmart(inputPath, outputPath) {
|
|
2724
|
+
return convertWithSmartLayout(inputPath, outputPath, {
|
|
2725
|
+
label: "SmartSquare",
|
|
2726
|
+
targetW: 1080,
|
|
2727
|
+
screenH: 700,
|
|
2728
|
+
camH: 380,
|
|
2729
|
+
fallbackRatio: "1:1"
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
async function convertToFeedSmart(inputPath, outputPath) {
|
|
2733
|
+
return convertWithSmartLayout(inputPath, outputPath, {
|
|
2734
|
+
label: "SmartFeed",
|
|
2735
|
+
targetW: 1080,
|
|
2736
|
+
screenH: 878,
|
|
2737
|
+
camH: 472,
|
|
2738
|
+
fallbackRatio: "4:5"
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
async function generatePlatformVariants(inputPath, outputDir, slug, platforms = ["tiktok", "linkedin"]) {
|
|
2742
|
+
await fs12.mkdir(outputDir, { recursive: true });
|
|
2743
|
+
const ratioMap = /* @__PURE__ */ new Map();
|
|
2744
|
+
for (const p of platforms) {
|
|
2745
|
+
const ratio = PLATFORM_RATIOS[p];
|
|
2746
|
+
if (ratio === "16:9") continue;
|
|
2747
|
+
const list = ratioMap.get(ratio) ?? [];
|
|
2748
|
+
list.push(p);
|
|
2749
|
+
ratioMap.set(ratio, list);
|
|
2750
|
+
}
|
|
2751
|
+
const variants = [];
|
|
2752
|
+
for (const [ratio, associatedPlatforms] of ratioMap) {
|
|
2753
|
+
const suffix = ratio === "9:16" ? "portrait" : ratio === "4:5" ? "feed" : "square";
|
|
2754
|
+
const outPath = pathMod3.join(outputDir, `${slug}-${suffix}.mp4`);
|
|
70
2755
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
2756
|
+
if (ratio === "9:16") {
|
|
2757
|
+
await convertToPortraitSmart(inputPath, outPath);
|
|
2758
|
+
} else if (ratio === "1:1") {
|
|
2759
|
+
await convertToSquareSmart(inputPath, outPath);
|
|
2760
|
+
} else if (ratio === "4:5") {
|
|
2761
|
+
await convertToFeedSmart(inputPath, outPath);
|
|
2762
|
+
} else {
|
|
2763
|
+
await convertAspectRatio(inputPath, outPath, ratio);
|
|
2764
|
+
}
|
|
2765
|
+
const dims = DIMENSIONS[ratio];
|
|
2766
|
+
for (const p of associatedPlatforms) {
|
|
2767
|
+
variants.push({ platform: p, aspectRatio: ratio, path: outPath, width: dims.width, height: dims.height });
|
|
2768
|
+
}
|
|
2769
|
+
} catch (err) {
|
|
2770
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2771
|
+
logger_default.warn(`Skipping ${ratio} variant for ${slug}: ${message}`);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
return variants;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// src/agents/ShortsAgent.ts
|
|
2778
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2779
|
+
import slugify2 from "slugify";
|
|
2780
|
+
import { promises as fs13 } from "fs";
|
|
2781
|
+
import path10 from "path";
|
|
2782
|
+
var SYSTEM_PROMPT = `You are a short-form video content strategist. Your job is to analyze a video transcript with word-level timestamps and identify the most compelling moments to extract as shorts (15\u201360 seconds each).
|
|
2783
|
+
|
|
2784
|
+
## What to look for
|
|
2785
|
+
- **Key insights** \u2014 concise, quotable takeaways
|
|
2786
|
+
- **Funny moments** \u2014 humor, wit, unexpected punchlines
|
|
2787
|
+
- **Controversial takes** \u2014 bold opinions that spark discussion
|
|
2788
|
+
- **Educational nuggets** \u2014 clear explanations of complex topics
|
|
2789
|
+
- **Emotional peaks** \u2014 passion, vulnerability, excitement
|
|
2790
|
+
- **Topic compilations** \u2014 multiple brief mentions of one theme that can be stitched together
|
|
2791
|
+
|
|
2792
|
+
## Short types
|
|
2793
|
+
- **Single segment** \u2014 one contiguous section of the video
|
|
2794
|
+
- **Composite** \u2014 multiple non-contiguous segments combined into one short (great for topic compilations or building a narrative arc)
|
|
2795
|
+
|
|
2796
|
+
## Rules
|
|
2797
|
+
1. Each short must be 15\u201360 seconds total duration.
|
|
2798
|
+
2. Timestamps must align to word boundaries from the transcript.
|
|
2799
|
+
3. Prefer natural sentence boundaries for clean cuts.
|
|
2800
|
+
4. Aim for 3\u20138 shorts per video, depending on length and richness.
|
|
2801
|
+
5. Every short needs a catchy, descriptive title (5\u201310 words).
|
|
2802
|
+
6. Tags should be lowercase, no hashes, 3\u20136 per short.
|
|
2803
|
+
7. A 1-second buffer is automatically added before and after each segment boundary during extraction, so plan segments based on content timestamps without worrying about clipping words at the edges.
|
|
2804
|
+
|
|
2805
|
+
When you have identified the shorts, call the **plan_shorts** tool with your complete plan.`;
|
|
2806
|
+
var PLAN_SHORTS_SCHEMA = {
|
|
2807
|
+
type: "object",
|
|
2808
|
+
properties: {
|
|
2809
|
+
shorts: {
|
|
2810
|
+
type: "array",
|
|
2811
|
+
description: "Array of planned short clips",
|
|
2812
|
+
items: {
|
|
2813
|
+
type: "object",
|
|
2814
|
+
properties: {
|
|
2815
|
+
title: { type: "string", description: "Catchy short title (5\u201310 words)" },
|
|
2816
|
+
description: { type: "string", description: "Brief description of the short content" },
|
|
2817
|
+
tags: {
|
|
2818
|
+
type: "array",
|
|
2819
|
+
items: { type: "string" },
|
|
2820
|
+
description: "Lowercase tags without hashes, 3\u20136 per short"
|
|
2821
|
+
},
|
|
2822
|
+
segments: {
|
|
2823
|
+
type: "array",
|
|
2824
|
+
description: "One or more time segments that compose this short",
|
|
2825
|
+
items: {
|
|
2826
|
+
type: "object",
|
|
2827
|
+
properties: {
|
|
2828
|
+
start: { type: "number", description: "Start time in seconds" },
|
|
2829
|
+
end: { type: "number", description: "End time in seconds" },
|
|
2830
|
+
description: { type: "string", description: "What happens in this segment" }
|
|
2831
|
+
},
|
|
2832
|
+
required: ["start", "end", "description"]
|
|
79
2833
|
}
|
|
80
|
-
|
|
81
|
-
|
|
2834
|
+
}
|
|
2835
|
+
},
|
|
2836
|
+
required: ["title", "description", "tags", "segments"]
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
},
|
|
2840
|
+
required: ["shorts"]
|
|
2841
|
+
};
|
|
2842
|
+
var ShortsAgent = class extends BaseAgent {
|
|
2843
|
+
plannedShorts = [];
|
|
2844
|
+
constructor() {
|
|
2845
|
+
super("ShortsAgent", SYSTEM_PROMPT);
|
|
2846
|
+
}
|
|
2847
|
+
getTools() {
|
|
2848
|
+
return [
|
|
2849
|
+
{
|
|
2850
|
+
name: "plan_shorts",
|
|
2851
|
+
description: "Submit the planned shorts as a structured JSON array. Call this once with all planned shorts.",
|
|
2852
|
+
parameters: PLAN_SHORTS_SCHEMA,
|
|
2853
|
+
handler: async (args) => {
|
|
2854
|
+
return this.handleToolCall("plan_shorts", args);
|
|
82
2855
|
}
|
|
2856
|
+
}
|
|
2857
|
+
];
|
|
2858
|
+
}
|
|
2859
|
+
async handleToolCall(toolName, args) {
|
|
2860
|
+
if (toolName === "plan_shorts") {
|
|
2861
|
+
this.plannedShorts = args.shorts;
|
|
2862
|
+
logger_default.info(`[ShortsAgent] Planned ${this.plannedShorts.length} shorts`);
|
|
2863
|
+
return { success: true, count: this.plannedShorts.length };
|
|
2864
|
+
}
|
|
2865
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
2866
|
+
}
|
|
2867
|
+
getPlannedShorts() {
|
|
2868
|
+
return this.plannedShorts;
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
async function generateShorts(video, transcript) {
|
|
2872
|
+
const agent = new ShortsAgent();
|
|
2873
|
+
const transcriptLines = transcript.segments.map((seg) => {
|
|
2874
|
+
const words = seg.words.map((w) => `[${w.start.toFixed(2)}-${w.end.toFixed(2)}] ${w.word}`).join(" ");
|
|
2875
|
+
return `[${seg.start.toFixed(2)}s \u2013 ${seg.end.toFixed(2)}s] ${seg.text}
|
|
2876
|
+
Words: ${words}`;
|
|
2877
|
+
});
|
|
2878
|
+
const prompt = [
|
|
2879
|
+
`Analyze the following transcript (${transcript.duration.toFixed(0)}s total) and plan shorts.
|
|
2880
|
+
`,
|
|
2881
|
+
`Video: ${video.filename}`,
|
|
2882
|
+
`Duration: ${transcript.duration.toFixed(1)}s
|
|
2883
|
+
`,
|
|
2884
|
+
"--- TRANSCRIPT ---\n",
|
|
2885
|
+
transcriptLines.join("\n\n"),
|
|
2886
|
+
"\n--- END TRANSCRIPT ---"
|
|
2887
|
+
].join("\n");
|
|
2888
|
+
try {
|
|
2889
|
+
await agent.run(prompt);
|
|
2890
|
+
const planned = agent.getPlannedShorts();
|
|
2891
|
+
if (planned.length === 0) {
|
|
2892
|
+
logger_default.warn("[ShortsAgent] No shorts were planned");
|
|
2893
|
+
return [];
|
|
83
2894
|
}
|
|
84
|
-
|
|
85
|
-
|
|
2895
|
+
const shortsDir = path10.join(path10.dirname(video.repoPath), "shorts");
|
|
2896
|
+
await fs13.mkdir(shortsDir, { recursive: true });
|
|
2897
|
+
const shorts = [];
|
|
2898
|
+
for (const plan of planned) {
|
|
2899
|
+
const id = uuidv4();
|
|
2900
|
+
const shortSlug = slugify2(plan.title, { lower: true, strict: true });
|
|
2901
|
+
const totalDuration = plan.segments.reduce((sum, s) => sum + (s.end - s.start), 0);
|
|
2902
|
+
const outputPath = path10.join(shortsDir, `${shortSlug}.mp4`);
|
|
2903
|
+
const segments = plan.segments.map((s) => ({
|
|
2904
|
+
start: s.start,
|
|
2905
|
+
end: s.end,
|
|
2906
|
+
description: s.description
|
|
2907
|
+
}));
|
|
2908
|
+
if (segments.length === 1) {
|
|
2909
|
+
await extractClip(video.repoPath, segments[0].start, segments[0].end, outputPath);
|
|
2910
|
+
} else {
|
|
2911
|
+
await extractCompositeClip(video.repoPath, segments, outputPath);
|
|
2912
|
+
}
|
|
2913
|
+
let variants;
|
|
2914
|
+
try {
|
|
2915
|
+
const defaultPlatforms = ["tiktok", "youtube-shorts", "instagram-reels", "instagram-feed", "linkedin"];
|
|
2916
|
+
const results = await generatePlatformVariants(outputPath, shortsDir, shortSlug, defaultPlatforms);
|
|
2917
|
+
if (results.length > 0) {
|
|
2918
|
+
variants = results.map((v) => ({
|
|
2919
|
+
path: v.path,
|
|
2920
|
+
aspectRatio: v.aspectRatio,
|
|
2921
|
+
platform: v.platform,
|
|
2922
|
+
width: v.width,
|
|
2923
|
+
height: v.height
|
|
2924
|
+
}));
|
|
2925
|
+
logger_default.info(`[ShortsAgent] Generated ${variants.length} platform variants for: ${plan.title}`);
|
|
2926
|
+
}
|
|
2927
|
+
} catch (err) {
|
|
2928
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2929
|
+
logger_default.warn(`[ShortsAgent] Platform variant generation failed for ${plan.title}: ${message}`);
|
|
2930
|
+
}
|
|
2931
|
+
let captionedPath;
|
|
2932
|
+
try {
|
|
2933
|
+
const assContent = segments.length === 1 ? generateStyledASSForSegment(transcript, segments[0].start, segments[0].end) : generateStyledASSForComposite(transcript, segments);
|
|
2934
|
+
const assPath = path10.join(shortsDir, `${shortSlug}.ass`);
|
|
2935
|
+
await fs13.writeFile(assPath, assContent);
|
|
2936
|
+
captionedPath = path10.join(shortsDir, `${shortSlug}-captioned.mp4`);
|
|
2937
|
+
await burnCaptions(outputPath, assPath, captionedPath);
|
|
2938
|
+
logger_default.info(`[ShortsAgent] Burned captions for short: ${plan.title}`);
|
|
2939
|
+
} catch (err) {
|
|
2940
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2941
|
+
logger_default.warn(`[ShortsAgent] Caption burning failed for ${plan.title}: ${message}`);
|
|
2942
|
+
captionedPath = void 0;
|
|
2943
|
+
}
|
|
2944
|
+
if (variants) {
|
|
2945
|
+
const portraitVariant = variants.find((v) => v.aspectRatio === "9:16");
|
|
2946
|
+
if (portraitVariant) {
|
|
2947
|
+
try {
|
|
2948
|
+
const portraitAssContent = segments.length === 1 ? generatePortraitASSWithHook(transcript, plan.title, segments[0].start, segments[0].end) : generatePortraitASSWithHookComposite(transcript, segments, plan.title);
|
|
2949
|
+
const portraitAssPath = path10.join(shortsDir, `${shortSlug}-portrait.ass`);
|
|
2950
|
+
await fs13.writeFile(portraitAssPath, portraitAssContent);
|
|
2951
|
+
const portraitCaptionedPath = portraitVariant.path.replace(".mp4", "-captioned.mp4");
|
|
2952
|
+
await burnCaptions(portraitVariant.path, portraitAssPath, portraitCaptionedPath);
|
|
2953
|
+
portraitVariant.path = portraitCaptionedPath;
|
|
2954
|
+
logger_default.info(`[ShortsAgent] Burned portrait captions with hook for: ${plan.title}`);
|
|
2955
|
+
} catch (err) {
|
|
2956
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2957
|
+
logger_default.warn(`[ShortsAgent] Portrait caption burning failed for ${plan.title}: ${message}`);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
const mdPath = path10.join(shortsDir, `${shortSlug}.md`);
|
|
2962
|
+
const mdContent = [
|
|
2963
|
+
`# ${plan.title}
|
|
2964
|
+
`,
|
|
2965
|
+
plan.description,
|
|
2966
|
+
"",
|
|
2967
|
+
"## Segments\n",
|
|
2968
|
+
...plan.segments.map(
|
|
2969
|
+
(s, i) => `${i + 1}. **${s.start.toFixed(2)}s \u2013 ${s.end.toFixed(2)}s** \u2014 ${s.description}`
|
|
2970
|
+
),
|
|
2971
|
+
"",
|
|
2972
|
+
"## Tags\n",
|
|
2973
|
+
plan.tags.map((t) => `- ${t}`).join("\n"),
|
|
2974
|
+
""
|
|
2975
|
+
].join("\n");
|
|
2976
|
+
await fs13.writeFile(mdPath, mdContent);
|
|
2977
|
+
shorts.push({
|
|
2978
|
+
id,
|
|
2979
|
+
title: plan.title,
|
|
2980
|
+
slug: shortSlug,
|
|
2981
|
+
segments,
|
|
2982
|
+
totalDuration,
|
|
2983
|
+
outputPath,
|
|
2984
|
+
captionedPath,
|
|
2985
|
+
description: plan.description,
|
|
2986
|
+
tags: plan.tags,
|
|
2987
|
+
variants
|
|
2988
|
+
});
|
|
2989
|
+
logger_default.info(`[ShortsAgent] Created short: ${plan.title} (${totalDuration.toFixed(1)}s)`);
|
|
86
2990
|
}
|
|
2991
|
+
logger_default.info(`[ShortsAgent] Generated ${shorts.length} shorts`);
|
|
2992
|
+
return shorts;
|
|
2993
|
+
} finally {
|
|
2994
|
+
await agent.destroy();
|
|
2995
|
+
}
|
|
87
2996
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
2997
|
+
|
|
2998
|
+
// src/agents/MediumVideoAgent.ts
|
|
2999
|
+
import { v4 as uuidv42 } from "uuid";
|
|
3000
|
+
import slugify3 from "slugify";
|
|
3001
|
+
import { promises as fs14 } from "fs";
|
|
3002
|
+
import path11 from "path";
|
|
3003
|
+
var SYSTEM_PROMPT2 = `You are a medium-form video content strategist. Your job is to analyze a video transcript with word-level timestamps and identify the best 1\u20133 minute segments to extract as standalone medium-form clips.
|
|
3004
|
+
|
|
3005
|
+
## What to look for
|
|
3006
|
+
|
|
3007
|
+
- **Complete topics** \u2014 a subject is introduced, explored, and concluded
|
|
3008
|
+
- **Narrative arcs** \u2014 problem \u2192 solution \u2192 result; question \u2192 exploration \u2192 insight
|
|
3009
|
+
- **Educational deep dives** \u2014 clear, thorough explanations of complex topics
|
|
3010
|
+
- **Compelling stories** \u2014 anecdotes with setup, tension, and resolution
|
|
3011
|
+
- **Strong arguments** \u2014 claim \u2192 evidence \u2192 implication sequences
|
|
3012
|
+
- **Topic compilations** \u2014 multiple brief mentions of one theme across the video that can be compiled into a cohesive 1\u20133 minute segment
|
|
3013
|
+
|
|
3014
|
+
## Clip types
|
|
3015
|
+
|
|
3016
|
+
- **Deep Dive** \u2014 a single contiguous section (1\u20133 min) covering one topic in depth
|
|
3017
|
+
- **Compilation** \u2014 multiple non-contiguous segments stitched together around a single theme or narrative thread (1\u20133 min total)
|
|
3018
|
+
|
|
3019
|
+
## Rules
|
|
3020
|
+
|
|
3021
|
+
1. Each clip must be 60\u2013180 seconds total duration.
|
|
3022
|
+
2. Timestamps must align to word boundaries from the transcript.
|
|
3023
|
+
3. Prefer natural sentence and paragraph boundaries for clean entry/exit points.
|
|
3024
|
+
4. Each clip must be self-contained \u2014 a viewer with no other context should understand and get value from the clip.
|
|
3025
|
+
5. Aim for 2\u20134 medium clips per video, depending on length and richness.
|
|
3026
|
+
6. Every clip needs a descriptive title (5\u201312 words) and a topic label.
|
|
3027
|
+
7. For compilations, specify segments in the order they should appear in the final clip (which may differ from chronological order).
|
|
3028
|
+
8. Tags should be lowercase, no hashes, 3\u20136 per clip.
|
|
3029
|
+
9. A 1-second buffer is automatically added around each segment boundary.
|
|
3030
|
+
10. Each clip needs a hook \u2014 the opening line or concept that draws viewers in.
|
|
3031
|
+
|
|
3032
|
+
## Differences from shorts
|
|
3033
|
+
|
|
3034
|
+
- Shorts capture *moments*; medium clips capture *complete ideas*.
|
|
3035
|
+
- Don't just find the most exciting 60 seconds \u2014 find where a topic starts and where it naturally concludes.
|
|
3036
|
+
- It's OK if a medium clip has slower pacing \u2014 depth and coherence matter more than constant high energy.
|
|
3037
|
+
- Look for segments that work as standalone mini-tutorials or explanations.
|
|
3038
|
+
- Avoid overlap with content that would work better as a short (punchy, viral, single-moment).
|
|
3039
|
+
|
|
3040
|
+
When you have identified the clips, call the **plan_medium_clips** tool with your complete plan.`;
|
|
3041
|
+
var PLAN_MEDIUM_CLIPS_SCHEMA = {
|
|
3042
|
+
type: "object",
|
|
3043
|
+
properties: {
|
|
3044
|
+
clips: {
|
|
3045
|
+
type: "array",
|
|
3046
|
+
description: "Array of planned medium-length clips",
|
|
3047
|
+
items: {
|
|
3048
|
+
type: "object",
|
|
3049
|
+
properties: {
|
|
3050
|
+
title: { type: "string", description: "Descriptive clip title (5\u201312 words)" },
|
|
3051
|
+
description: { type: "string", description: "Brief description of the clip content" },
|
|
3052
|
+
tags: {
|
|
3053
|
+
type: "array",
|
|
3054
|
+
items: { type: "string" },
|
|
3055
|
+
description: "Lowercase tags without hashes, 3\u20136 per clip"
|
|
3056
|
+
},
|
|
3057
|
+
segments: {
|
|
3058
|
+
type: "array",
|
|
3059
|
+
description: "One or more time segments that compose this clip",
|
|
3060
|
+
items: {
|
|
3061
|
+
type: "object",
|
|
3062
|
+
properties: {
|
|
3063
|
+
start: { type: "number", description: "Start time in seconds" },
|
|
3064
|
+
end: { type: "number", description: "End time in seconds" },
|
|
3065
|
+
description: { type: "string", description: "What happens in this segment" }
|
|
3066
|
+
},
|
|
3067
|
+
required: ["start", "end", "description"]
|
|
3068
|
+
}
|
|
3069
|
+
},
|
|
3070
|
+
totalDuration: { type: "number", description: "Total clip duration in seconds (60\u2013180)" },
|
|
3071
|
+
hook: { type: "string", description: "Opening hook for the clip" },
|
|
3072
|
+
topic: { type: "string", description: "Main topic covered in the clip" }
|
|
3073
|
+
},
|
|
3074
|
+
required: ["title", "description", "tags", "segments", "totalDuration", "hook", "topic"]
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
},
|
|
3078
|
+
required: ["clips"]
|
|
3079
|
+
};
|
|
3080
|
+
var MediumVideoAgent = class extends BaseAgent {
|
|
3081
|
+
plannedClips = [];
|
|
3082
|
+
constructor() {
|
|
3083
|
+
super("MediumVideoAgent", SYSTEM_PROMPT2);
|
|
3084
|
+
}
|
|
3085
|
+
getTools() {
|
|
3086
|
+
return [
|
|
3087
|
+
{
|
|
3088
|
+
name: "plan_medium_clips",
|
|
3089
|
+
description: "Submit the planned medium-length clips as a structured JSON array. Call this once with all planned clips.",
|
|
3090
|
+
parameters: PLAN_MEDIUM_CLIPS_SCHEMA,
|
|
3091
|
+
handler: async (args) => {
|
|
3092
|
+
return this.handleToolCall("plan_medium_clips", args);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
];
|
|
3096
|
+
}
|
|
3097
|
+
async handleToolCall(toolName, args) {
|
|
3098
|
+
if (toolName === "plan_medium_clips") {
|
|
3099
|
+
this.plannedClips = args.clips;
|
|
3100
|
+
logger_default.info(`[MediumVideoAgent] Planned ${this.plannedClips.length} medium clips`);
|
|
3101
|
+
return { success: true, count: this.plannedClips.length };
|
|
3102
|
+
}
|
|
3103
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
3104
|
+
}
|
|
3105
|
+
getPlannedClips() {
|
|
3106
|
+
return this.plannedClips;
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
async function generateMediumClips(video, transcript) {
|
|
3110
|
+
const agent = new MediumVideoAgent();
|
|
3111
|
+
const transcriptLines = transcript.segments.map((seg) => {
|
|
3112
|
+
const words = seg.words.map((w) => `[${w.start.toFixed(2)}-${w.end.toFixed(2)}] ${w.word}`).join(" ");
|
|
3113
|
+
return `[${seg.start.toFixed(2)}s \u2013 ${seg.end.toFixed(2)}s] ${seg.text}
|
|
3114
|
+
Words: ${words}`;
|
|
3115
|
+
});
|
|
3116
|
+
const prompt = [
|
|
3117
|
+
`Analyze the following transcript (${transcript.duration.toFixed(0)}s total) and plan medium-length clips (1\u20133 minutes each).
|
|
3118
|
+
`,
|
|
3119
|
+
`Video: ${video.filename}`,
|
|
3120
|
+
`Duration: ${transcript.duration.toFixed(1)}s
|
|
3121
|
+
`,
|
|
3122
|
+
"--- TRANSCRIPT ---\n",
|
|
3123
|
+
transcriptLines.join("\n\n"),
|
|
3124
|
+
"\n--- END TRANSCRIPT ---"
|
|
3125
|
+
].join("\n");
|
|
3126
|
+
try {
|
|
3127
|
+
await agent.run(prompt);
|
|
3128
|
+
const planned = agent.getPlannedClips();
|
|
3129
|
+
if (planned.length === 0) {
|
|
3130
|
+
logger_default.warn("[MediumVideoAgent] No medium clips were planned");
|
|
3131
|
+
return [];
|
|
3132
|
+
}
|
|
3133
|
+
const clipsDir = path11.join(path11.dirname(video.repoPath), "medium-clips");
|
|
3134
|
+
await fs14.mkdir(clipsDir, { recursive: true });
|
|
3135
|
+
const clips = [];
|
|
3136
|
+
for (const plan of planned) {
|
|
3137
|
+
const id = uuidv42();
|
|
3138
|
+
const clipSlug = slugify3(plan.title, { lower: true, strict: true });
|
|
3139
|
+
const totalDuration = plan.segments.reduce((sum, s) => sum + (s.end - s.start), 0);
|
|
3140
|
+
const outputPath = path11.join(clipsDir, `${clipSlug}.mp4`);
|
|
3141
|
+
const segments = plan.segments.map((s) => ({
|
|
3142
|
+
start: s.start,
|
|
3143
|
+
end: s.end,
|
|
3144
|
+
description: s.description
|
|
3145
|
+
}));
|
|
3146
|
+
if (segments.length === 1) {
|
|
3147
|
+
await extractClip(video.repoPath, segments[0].start, segments[0].end, outputPath);
|
|
3148
|
+
} else {
|
|
3149
|
+
await extractCompositeClipWithTransitions(video.repoPath, segments, outputPath);
|
|
3150
|
+
}
|
|
3151
|
+
let captionedPath;
|
|
3152
|
+
try {
|
|
3153
|
+
const assContent = segments.length === 1 ? generateStyledASSForSegment(transcript, segments[0].start, segments[0].end, 1, "medium") : generateStyledASSForComposite(transcript, segments, 1, "medium");
|
|
3154
|
+
const assPath = path11.join(clipsDir, `${clipSlug}.ass`);
|
|
3155
|
+
await fs14.writeFile(assPath, assContent);
|
|
3156
|
+
captionedPath = path11.join(clipsDir, `${clipSlug}-captioned.mp4`);
|
|
3157
|
+
await burnCaptions(outputPath, assPath, captionedPath);
|
|
3158
|
+
logger_default.info(`[MediumVideoAgent] Burned captions for clip: ${plan.title}`);
|
|
3159
|
+
} catch (err) {
|
|
3160
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3161
|
+
logger_default.warn(`[MediumVideoAgent] Caption burning failed for ${plan.title}: ${message}`);
|
|
3162
|
+
captionedPath = void 0;
|
|
3163
|
+
}
|
|
3164
|
+
const mdPath = path11.join(clipsDir, `${clipSlug}.md`);
|
|
3165
|
+
const mdContent = [
|
|
3166
|
+
`# ${plan.title}
|
|
3167
|
+
`,
|
|
3168
|
+
`**Topic:** ${plan.topic}
|
|
3169
|
+
`,
|
|
3170
|
+
`**Hook:** ${plan.hook}
|
|
3171
|
+
`,
|
|
3172
|
+
plan.description,
|
|
3173
|
+
"",
|
|
3174
|
+
"## Segments\n",
|
|
3175
|
+
...plan.segments.map(
|
|
3176
|
+
(s, i) => `${i + 1}. **${s.start.toFixed(2)}s \u2013 ${s.end.toFixed(2)}s** \u2014 ${s.description}`
|
|
3177
|
+
),
|
|
3178
|
+
"",
|
|
3179
|
+
"## Tags\n",
|
|
3180
|
+
plan.tags.map((t) => `- ${t}`).join("\n"),
|
|
3181
|
+
""
|
|
3182
|
+
].join("\n");
|
|
3183
|
+
await fs14.writeFile(mdPath, mdContent);
|
|
3184
|
+
clips.push({
|
|
3185
|
+
id,
|
|
3186
|
+
title: plan.title,
|
|
3187
|
+
slug: clipSlug,
|
|
3188
|
+
segments,
|
|
3189
|
+
totalDuration,
|
|
3190
|
+
outputPath,
|
|
3191
|
+
captionedPath,
|
|
3192
|
+
description: plan.description,
|
|
3193
|
+
tags: plan.tags,
|
|
3194
|
+
hook: plan.hook,
|
|
3195
|
+
topic: plan.topic
|
|
3196
|
+
});
|
|
3197
|
+
logger_default.info(`[MediumVideoAgent] Created medium clip: ${plan.title} (${totalDuration.toFixed(1)}s)`);
|
|
3198
|
+
}
|
|
3199
|
+
logger_default.info(`[MediumVideoAgent] Generated ${clips.length} medium clips`);
|
|
3200
|
+
return clips;
|
|
3201
|
+
} finally {
|
|
3202
|
+
await agent.destroy();
|
|
3203
|
+
}
|
|
92
3204
|
}
|
|
93
|
-
|
|
94
|
-
|
|
3205
|
+
|
|
3206
|
+
// src/agents/SocialMediaAgent.ts
|
|
3207
|
+
import * as fs15 from "fs";
|
|
3208
|
+
import * as path12 from "path";
|
|
3209
|
+
|
|
3210
|
+
// src/tools/search/exaClient.ts
|
|
3211
|
+
import Exa from "exa-js";
|
|
3212
|
+
async function searchWeb(query, numResults = 5) {
|
|
3213
|
+
const config2 = getConfig();
|
|
3214
|
+
if (!config2.EXA_API_KEY) {
|
|
3215
|
+
logger_default.warn("EXA_API_KEY not set \u2014 skipping web search");
|
|
3216
|
+
return [];
|
|
3217
|
+
}
|
|
3218
|
+
const exa = new Exa(config2.EXA_API_KEY);
|
|
3219
|
+
try {
|
|
3220
|
+
const results = await exa.searchAndContents(query, {
|
|
3221
|
+
numResults,
|
|
3222
|
+
text: { maxCharacters: 200 }
|
|
3223
|
+
});
|
|
3224
|
+
return results.results.map((r) => ({
|
|
3225
|
+
title: r.title || "",
|
|
3226
|
+
url: r.url,
|
|
3227
|
+
// Exa SDK searchAndContents returns `text` when text option is used, but the type doesn't include it
|
|
3228
|
+
snippet: r.text || ""
|
|
3229
|
+
}));
|
|
3230
|
+
} catch (err) {
|
|
3231
|
+
logger_default.error(`Exa search failed: ${err instanceof Error ? err.message : err}`);
|
|
3232
|
+
return [];
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// src/agents/SocialMediaAgent.ts
|
|
3237
|
+
var SYSTEM_PROMPT3 = `You are a viral social-media content strategist.
|
|
3238
|
+
Given a video transcript and summary you MUST generate one post for each of the 5 platforms listed below.
|
|
3239
|
+
Each post must match the platform's tone, format, and constraints exactly.
|
|
3240
|
+
|
|
3241
|
+
Platform guidelines:
|
|
3242
|
+
1. **TikTok** \u2013 Casual, hook-driven, trending hashtags, 150 chars max, emoji-heavy.
|
|
3243
|
+
2. **YouTube** \u2013 Descriptive, SEO-optimized title + description, relevant tags.
|
|
3244
|
+
3. **Instagram** \u2013 Visual storytelling, emoji-rich, 30 hashtags max, engaging caption.
|
|
3245
|
+
4. **LinkedIn** \u2013 Professional, thought-leadership, industry insights, 1-3 hashtags.
|
|
3246
|
+
5. **X (Twitter)** \u2013 Concise, punchy, 280 chars max, 2-5 hashtags, thread-ready.
|
|
3247
|
+
|
|
3248
|
+
IMPORTANT \u2013 Content format:
|
|
3249
|
+
The "content" field you provide must be the FINAL, ready-to-post text that can be directly copied and pasted onto the platform. Do NOT use markdown headers, bullet points, or any formatting inside the content. Include hashtags inline at the end of the post text where appropriate. The content is saved as-is for direct posting.
|
|
3250
|
+
|
|
3251
|
+
Workflow:
|
|
3252
|
+
1. First call the "search_links" tool with the key topics to find relevant URLs.
|
|
3253
|
+
2. Then call the "create_posts" tool with a JSON object that has a "posts" array.
|
|
3254
|
+
Each element must have: platform, content, hashtags (array), links (array), characterCount.
|
|
3255
|
+
|
|
3256
|
+
Include relevant links in posts when search results provide them.
|
|
3257
|
+
Always call "create_posts" exactly once with all 5 platform posts.`;
|
|
3258
|
+
var SocialMediaAgent = class extends BaseAgent {
|
|
3259
|
+
collectedPosts = [];
|
|
3260
|
+
constructor() {
|
|
3261
|
+
super("SocialMediaAgent", SYSTEM_PROMPT3);
|
|
3262
|
+
}
|
|
3263
|
+
getTools() {
|
|
3264
|
+
return [
|
|
3265
|
+
{
|
|
3266
|
+
name: "search_links",
|
|
3267
|
+
description: "Search for relevant URLs based on topics discussed in the video.",
|
|
3268
|
+
parameters: {
|
|
3269
|
+
type: "object",
|
|
3270
|
+
properties: {
|
|
3271
|
+
topics: {
|
|
3272
|
+
type: "array",
|
|
3273
|
+
items: { type: "string" },
|
|
3274
|
+
description: "List of topics to search for"
|
|
3275
|
+
}
|
|
3276
|
+
},
|
|
3277
|
+
required: ["topics"]
|
|
3278
|
+
},
|
|
3279
|
+
handler: async (args) => {
|
|
3280
|
+
const { topics } = args;
|
|
3281
|
+
logger_default.info(`[SocialMediaAgent] search_links called with topics: ${topics.join(", ")}`);
|
|
3282
|
+
const allResults = {};
|
|
3283
|
+
for (const topic of topics) {
|
|
3284
|
+
allResults[topic] = await searchWeb(topic, 3);
|
|
3285
|
+
}
|
|
3286
|
+
return JSON.stringify({ results: allResults });
|
|
3287
|
+
}
|
|
3288
|
+
},
|
|
3289
|
+
{
|
|
3290
|
+
name: "create_posts",
|
|
3291
|
+
description: "Submit the generated social media posts for all 5 platforms.",
|
|
3292
|
+
parameters: {
|
|
3293
|
+
type: "object",
|
|
3294
|
+
properties: {
|
|
3295
|
+
posts: {
|
|
3296
|
+
type: "array",
|
|
3297
|
+
items: {
|
|
3298
|
+
type: "object",
|
|
3299
|
+
properties: {
|
|
3300
|
+
platform: { type: "string" },
|
|
3301
|
+
content: { type: "string" },
|
|
3302
|
+
hashtags: { type: "array", items: { type: "string" } },
|
|
3303
|
+
links: { type: "array", items: { type: "string" } },
|
|
3304
|
+
characterCount: { type: "number" }
|
|
3305
|
+
},
|
|
3306
|
+
required: ["platform", "content", "hashtags", "links", "characterCount"]
|
|
3307
|
+
},
|
|
3308
|
+
description: "Array of posts, one per platform"
|
|
3309
|
+
}
|
|
3310
|
+
},
|
|
3311
|
+
required: ["posts"]
|
|
3312
|
+
},
|
|
3313
|
+
handler: async (args) => {
|
|
3314
|
+
const { posts } = args;
|
|
3315
|
+
this.collectedPosts = posts;
|
|
3316
|
+
logger_default.info(`[SocialMediaAgent] create_posts received ${posts.length} posts`);
|
|
3317
|
+
return JSON.stringify({ success: true, count: posts.length });
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
];
|
|
3321
|
+
}
|
|
3322
|
+
async handleToolCall(toolName, args) {
|
|
3323
|
+
logger_default.warn(`[SocialMediaAgent] Unexpected handleToolCall for "${toolName}"`);
|
|
3324
|
+
return { error: `Unknown tool: ${toolName}` };
|
|
3325
|
+
}
|
|
3326
|
+
getCollectedPosts() {
|
|
3327
|
+
return this.collectedPosts;
|
|
3328
|
+
}
|
|
3329
|
+
};
|
|
3330
|
+
function toPlatformEnum(raw) {
|
|
3331
|
+
const normalised = raw.toLowerCase().trim();
|
|
3332
|
+
switch (normalised) {
|
|
3333
|
+
case "tiktok":
|
|
3334
|
+
return "tiktok" /* TikTok */;
|
|
3335
|
+
case "youtube":
|
|
3336
|
+
return "youtube" /* YouTube */;
|
|
3337
|
+
case "instagram":
|
|
3338
|
+
return "instagram" /* Instagram */;
|
|
3339
|
+
case "linkedin":
|
|
3340
|
+
return "linkedin" /* LinkedIn */;
|
|
3341
|
+
case "x":
|
|
3342
|
+
case "twitter":
|
|
3343
|
+
return "x" /* X */;
|
|
3344
|
+
default:
|
|
3345
|
+
return normalised;
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
function renderPostFile(post, opts2) {
|
|
3349
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3350
|
+
const platform = toPlatformEnum(post.platform);
|
|
3351
|
+
const lines = ["---"];
|
|
3352
|
+
lines.push(`platform: ${platform}`);
|
|
3353
|
+
lines.push(`status: draft`);
|
|
3354
|
+
lines.push(`scheduledDate: null`);
|
|
3355
|
+
if (post.hashtags.length > 0) {
|
|
3356
|
+
lines.push("hashtags:");
|
|
3357
|
+
for (const tag of post.hashtags) {
|
|
3358
|
+
lines.push(` - "${tag}"`);
|
|
3359
|
+
}
|
|
3360
|
+
} else {
|
|
3361
|
+
lines.push("hashtags: []");
|
|
3362
|
+
}
|
|
3363
|
+
if (post.links.length > 0) {
|
|
3364
|
+
lines.push("links:");
|
|
3365
|
+
for (const link of post.links) {
|
|
3366
|
+
lines.push(` - url: "${link}"`);
|
|
3367
|
+
lines.push(` title: null`);
|
|
3368
|
+
}
|
|
3369
|
+
} else {
|
|
3370
|
+
lines.push("links: []");
|
|
3371
|
+
}
|
|
3372
|
+
lines.push(`characterCount: ${post.characterCount}`);
|
|
3373
|
+
lines.push(`videoSlug: "${opts2.videoSlug}"`);
|
|
3374
|
+
lines.push(`shortSlug: ${opts2.shortSlug ? `"${opts2.shortSlug}"` : "null"}`);
|
|
3375
|
+
lines.push(`createdAt: "${now}"`);
|
|
3376
|
+
lines.push("---");
|
|
3377
|
+
lines.push("");
|
|
3378
|
+
lines.push(post.content);
|
|
3379
|
+
lines.push("");
|
|
3380
|
+
return lines.join("\n");
|
|
3381
|
+
}
|
|
3382
|
+
async function generateShortPosts(video, short, transcript) {
|
|
3383
|
+
const agent = new SocialMediaAgent();
|
|
3384
|
+
try {
|
|
3385
|
+
const relevantText = transcript.segments.filter(
|
|
3386
|
+
(seg) => short.segments.some((ss) => seg.start < ss.end && seg.end > ss.start)
|
|
3387
|
+
).map((seg) => seg.text).join(" ");
|
|
3388
|
+
const userMessage = [
|
|
3389
|
+
"## Short Clip Metadata",
|
|
3390
|
+
`- **Title:** ${short.title}`,
|
|
3391
|
+
`- **Description:** ${short.description}`,
|
|
3392
|
+
`- **Duration:** ${short.totalDuration.toFixed(1)}s`,
|
|
3393
|
+
`- **Tags:** ${short.tags.join(", ")}`,
|
|
3394
|
+
"",
|
|
3395
|
+
"## Relevant Transcript",
|
|
3396
|
+
relevantText.slice(0, 3e3)
|
|
3397
|
+
].join("\n");
|
|
3398
|
+
await agent.run(userMessage);
|
|
3399
|
+
const collectedPosts = agent.getCollectedPosts();
|
|
3400
|
+
const shortsDir = path12.join(path12.dirname(video.repoPath), "shorts");
|
|
3401
|
+
const postsDir = path12.join(shortsDir, short.slug, "posts");
|
|
3402
|
+
fs15.mkdirSync(postsDir, { recursive: true });
|
|
3403
|
+
const socialPosts = collectedPosts.map((p) => {
|
|
3404
|
+
const platform = toPlatformEnum(p.platform);
|
|
3405
|
+
const outputPath = path12.join(postsDir, `${platform}.md`);
|
|
3406
|
+
fs15.writeFileSync(
|
|
3407
|
+
outputPath,
|
|
3408
|
+
renderPostFile(p, { videoSlug: video.slug, shortSlug: short.slug }),
|
|
3409
|
+
"utf-8"
|
|
3410
|
+
);
|
|
3411
|
+
logger_default.info(`[SocialMediaAgent] Wrote short post ${outputPath}`);
|
|
3412
|
+
return {
|
|
3413
|
+
platform,
|
|
3414
|
+
content: p.content,
|
|
3415
|
+
hashtags: p.hashtags,
|
|
3416
|
+
links: p.links,
|
|
3417
|
+
characterCount: p.characterCount,
|
|
3418
|
+
outputPath
|
|
3419
|
+
};
|
|
3420
|
+
});
|
|
3421
|
+
return socialPosts;
|
|
3422
|
+
} finally {
|
|
3423
|
+
await agent.destroy();
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
async function generateSocialPosts(video, transcript, summary, outputDir) {
|
|
3427
|
+
const agent = new SocialMediaAgent();
|
|
3428
|
+
try {
|
|
3429
|
+
const userMessage = [
|
|
3430
|
+
"## Video Metadata",
|
|
3431
|
+
`- **Title:** ${summary.title}`,
|
|
3432
|
+
`- **Slug:** ${video.slug}`,
|
|
3433
|
+
`- **Duration:** ${video.duration}s`,
|
|
3434
|
+
"",
|
|
3435
|
+
"## Summary",
|
|
3436
|
+
summary.overview,
|
|
3437
|
+
"",
|
|
3438
|
+
"## Key Topics",
|
|
3439
|
+
summary.keyTopics.map((t) => `- ${t}`).join("\n"),
|
|
3440
|
+
"",
|
|
3441
|
+
"## Transcript (first 3000 chars)",
|
|
3442
|
+
transcript.text.slice(0, 3e3)
|
|
3443
|
+
].join("\n");
|
|
3444
|
+
await agent.run(userMessage);
|
|
3445
|
+
const collectedPosts = agent.getCollectedPosts();
|
|
3446
|
+
const outDir = outputDir ?? path12.join(video.videoDir, "social-posts");
|
|
3447
|
+
fs15.mkdirSync(outDir, { recursive: true });
|
|
3448
|
+
const socialPosts = collectedPosts.map((p) => {
|
|
3449
|
+
const platform = toPlatformEnum(p.platform);
|
|
3450
|
+
const outputPath = path12.join(outDir, `${platform}.md`);
|
|
3451
|
+
fs15.writeFileSync(
|
|
3452
|
+
outputPath,
|
|
3453
|
+
renderPostFile(p, { videoSlug: video.slug }),
|
|
3454
|
+
"utf-8"
|
|
3455
|
+
);
|
|
3456
|
+
logger_default.info(`[SocialMediaAgent] Wrote ${outputPath}`);
|
|
3457
|
+
return {
|
|
3458
|
+
platform,
|
|
3459
|
+
content: p.content,
|
|
3460
|
+
hashtags: p.hashtags,
|
|
3461
|
+
links: p.links,
|
|
3462
|
+
characterCount: p.characterCount,
|
|
3463
|
+
outputPath
|
|
3464
|
+
};
|
|
3465
|
+
});
|
|
3466
|
+
return socialPosts;
|
|
3467
|
+
} finally {
|
|
3468
|
+
await agent.destroy();
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// src/agents/BlogAgent.ts
|
|
3473
|
+
import * as fs16 from "fs";
|
|
3474
|
+
import * as path13 from "path";
|
|
3475
|
+
function buildSystemPrompt2() {
|
|
3476
|
+
const brand = getBrandConfig();
|
|
3477
|
+
return `You are a technical blog writer for dev.to, writing from the perspective of ${brand.name} (${brand.handle}).
|
|
3478
|
+
|
|
3479
|
+
Voice & style:
|
|
3480
|
+
- Tone: ${brand.voice.tone}
|
|
3481
|
+
- Personality: ${brand.voice.personality}
|
|
3482
|
+
- Style: ${brand.voice.style}
|
|
3483
|
+
|
|
3484
|
+
Content guidelines: ${brand.contentGuidelines.blogFocus}
|
|
3485
|
+
|
|
3486
|
+
Your task is to generate a full dev.to-style technical blog post (800-1500 words) based on a video transcript and summary.
|
|
3487
|
+
|
|
3488
|
+
The blog post MUST include:
|
|
3489
|
+
1. dev.to frontmatter (title, published: false, description, tags, cover_image placeholder)
|
|
3490
|
+
2. An engaging introduction with a hook
|
|
3491
|
+
3. Clear sections covering the main content (e.g. The Problem, The Solution, How It Works)
|
|
3492
|
+
4. Code snippets where the video content discusses code \u2014 use fenced code blocks with language tags
|
|
3493
|
+
5. Key Takeaways section
|
|
3494
|
+
6. A conclusion
|
|
3495
|
+
7. A footer referencing the original video
|
|
3496
|
+
|
|
3497
|
+
Workflow:
|
|
3498
|
+
1. First call "search_web" with key topics to find relevant articles/resources to link to.
|
|
3499
|
+
2. Then call "write_blog" with the complete blog post including frontmatter and body.
|
|
3500
|
+
- Weave the search result links organically into the post text (don't dump them at the end).
|
|
3501
|
+
- Reference the video and any shorts naturally.
|
|
3502
|
+
|
|
3503
|
+
Always call "write_blog" exactly once with the complete post.`;
|
|
3504
|
+
}
|
|
3505
|
+
var BlogAgent = class extends BaseAgent {
|
|
3506
|
+
blogContent = null;
|
|
3507
|
+
constructor() {
|
|
3508
|
+
super("BlogAgent", buildSystemPrompt2());
|
|
3509
|
+
}
|
|
3510
|
+
getTools() {
|
|
3511
|
+
return [
|
|
3512
|
+
{
|
|
3513
|
+
name: "search_web",
|
|
3514
|
+
description: "Search the web for relevant articles and resources to link in the blog post.",
|
|
3515
|
+
parameters: {
|
|
3516
|
+
type: "object",
|
|
3517
|
+
properties: {
|
|
3518
|
+
queries: {
|
|
3519
|
+
type: "array",
|
|
3520
|
+
items: { type: "string" },
|
|
3521
|
+
description: "List of search queries for finding relevant links"
|
|
3522
|
+
}
|
|
3523
|
+
},
|
|
3524
|
+
required: ["queries"]
|
|
3525
|
+
},
|
|
3526
|
+
handler: async (args) => {
|
|
3527
|
+
const { queries } = args;
|
|
3528
|
+
logger_default.info(`[BlogAgent] search_web called with ${queries.length} queries`);
|
|
3529
|
+
const allResults = {};
|
|
3530
|
+
for (const query of queries) {
|
|
3531
|
+
allResults[query] = await searchWeb(query, 3);
|
|
3532
|
+
}
|
|
3533
|
+
return JSON.stringify({ results: allResults });
|
|
3534
|
+
}
|
|
3535
|
+
},
|
|
3536
|
+
{
|
|
3537
|
+
name: "write_blog",
|
|
3538
|
+
description: "Submit the complete dev.to blog post with frontmatter and markdown body.",
|
|
3539
|
+
parameters: {
|
|
3540
|
+
type: "object",
|
|
3541
|
+
properties: {
|
|
3542
|
+
frontmatter: {
|
|
3543
|
+
type: "object",
|
|
3544
|
+
properties: {
|
|
3545
|
+
title: { type: "string" },
|
|
3546
|
+
description: { type: "string" },
|
|
3547
|
+
tags: { type: "array", items: { type: "string" } },
|
|
3548
|
+
cover_image: { type: "string" }
|
|
3549
|
+
},
|
|
3550
|
+
required: ["title", "description", "tags"]
|
|
3551
|
+
},
|
|
3552
|
+
body: {
|
|
3553
|
+
type: "string",
|
|
3554
|
+
description: "The full markdown body of the blog post (excluding frontmatter)"
|
|
3555
|
+
}
|
|
3556
|
+
},
|
|
3557
|
+
required: ["frontmatter", "body"]
|
|
3558
|
+
},
|
|
3559
|
+
handler: async (args) => {
|
|
3560
|
+
const blogArgs = args;
|
|
3561
|
+
this.blogContent = blogArgs;
|
|
3562
|
+
logger_default.info(`[BlogAgent] write_blog received post: "${blogArgs.frontmatter.title}"`);
|
|
3563
|
+
return JSON.stringify({ success: true });
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
];
|
|
3567
|
+
}
|
|
3568
|
+
async handleToolCall(toolName, _args) {
|
|
3569
|
+
logger_default.warn(`[BlogAgent] Unexpected handleToolCall for "${toolName}"`);
|
|
3570
|
+
return { error: `Unknown tool: ${toolName}` };
|
|
3571
|
+
}
|
|
3572
|
+
getBlogContent() {
|
|
3573
|
+
return this.blogContent;
|
|
3574
|
+
}
|
|
3575
|
+
};
|
|
3576
|
+
function renderBlogMarkdown(blog) {
|
|
3577
|
+
const fm = blog.frontmatter;
|
|
3578
|
+
const tags = fm.tags.map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, "")).join(", ");
|
|
3579
|
+
const lines = [
|
|
3580
|
+
"---",
|
|
3581
|
+
`title: "${fm.title}"`,
|
|
3582
|
+
"published: false",
|
|
3583
|
+
`description: "${fm.description}"`,
|
|
3584
|
+
`tags: ${tags}`,
|
|
3585
|
+
`cover_image: ${fm.cover_image || ""}`,
|
|
3586
|
+
"---",
|
|
3587
|
+
"",
|
|
3588
|
+
blog.body
|
|
3589
|
+
];
|
|
3590
|
+
return lines.join("\n");
|
|
3591
|
+
}
|
|
3592
|
+
async function generateBlogPost(video, transcript, summary) {
|
|
3593
|
+
const agent = new BlogAgent();
|
|
3594
|
+
try {
|
|
3595
|
+
const userMessage = [
|
|
3596
|
+
"## Video Metadata",
|
|
3597
|
+
`- **Title:** ${summary.title}`,
|
|
3598
|
+
`- **Slug:** ${video.slug}`,
|
|
3599
|
+
`- **Duration:** ${video.duration}s`,
|
|
3600
|
+
`- **Recorded:** ${video.createdAt.toISOString().split("T")[0]}`,
|
|
3601
|
+
"",
|
|
3602
|
+
"## Summary",
|
|
3603
|
+
summary.overview,
|
|
3604
|
+
"",
|
|
3605
|
+
"## Key Topics",
|
|
3606
|
+
summary.keyTopics.map((t) => `- ${t}`).join("\n"),
|
|
3607
|
+
"",
|
|
3608
|
+
"## Transcript (first 6000 chars)",
|
|
3609
|
+
transcript.text.slice(0, 6e3)
|
|
3610
|
+
].join("\n");
|
|
3611
|
+
await agent.run(userMessage);
|
|
3612
|
+
const blogContent = agent.getBlogContent();
|
|
3613
|
+
if (!blogContent) {
|
|
3614
|
+
throw new Error("BlogAgent did not produce any blog content");
|
|
3615
|
+
}
|
|
3616
|
+
const outDir = path13.join(video.videoDir, "social-posts");
|
|
3617
|
+
fs16.mkdirSync(outDir, { recursive: true });
|
|
3618
|
+
const outputPath = path13.join(outDir, "devto.md");
|
|
3619
|
+
fs16.writeFileSync(outputPath, renderBlogMarkdown(blogContent), "utf-8");
|
|
3620
|
+
logger_default.info(`[BlogAgent] Wrote blog post to ${outputPath}`);
|
|
3621
|
+
return outputPath;
|
|
3622
|
+
} finally {
|
|
3623
|
+
await agent.destroy();
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
// src/agents/ChapterAgent.ts
|
|
3628
|
+
import { promises as fs17 } from "fs";
|
|
3629
|
+
import path14 from "path";
|
|
3630
|
+
function toYouTubeTimestamp2(seconds) {
|
|
3631
|
+
const h = Math.floor(seconds / 3600);
|
|
3632
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
3633
|
+
const s = Math.floor(seconds % 60);
|
|
3634
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
|
|
3635
|
+
}
|
|
3636
|
+
function fmtTime2(seconds) {
|
|
3637
|
+
const m = Math.floor(seconds / 60);
|
|
3638
|
+
const s = Math.floor(seconds % 60);
|
|
3639
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
3640
|
+
}
|
|
3641
|
+
function buildTranscriptBlock2(transcript) {
|
|
3642
|
+
return transcript.segments.map((seg) => `[${fmtTime2(seg.start)} \u2192 ${fmtTime2(seg.end)}] ${seg.text.trim()}`).join("\n");
|
|
3643
|
+
}
|
|
3644
|
+
function generateChaptersJSON(chapters) {
|
|
3645
|
+
return JSON.stringify({ chapters }, null, 2);
|
|
3646
|
+
}
|
|
3647
|
+
function generateYouTubeTimestamps(chapters) {
|
|
3648
|
+
return chapters.map((ch) => `${toYouTubeTimestamp2(ch.timestamp)} ${ch.title}`).join("\n");
|
|
3649
|
+
}
|
|
3650
|
+
function generateChaptersMarkdown(chapters) {
|
|
3651
|
+
const rows = chapters.map((ch) => `| ${toYouTubeTimestamp2(ch.timestamp)} | ${ch.title} | ${ch.description} |`).join("\n");
|
|
3652
|
+
return `## Chapters
|
|
3653
|
+
|
|
3654
|
+
| Time | Chapter | Description |
|
|
3655
|
+
|------|---------|-------------|
|
|
3656
|
+
${rows}
|
|
3657
|
+
`;
|
|
3658
|
+
}
|
|
3659
|
+
function generateFFMetadata(chapters, totalDuration) {
|
|
3660
|
+
let meta = ";FFMETADATA1\n\n";
|
|
3661
|
+
for (let i = 0; i < chapters.length; i++) {
|
|
3662
|
+
const ch = chapters[i];
|
|
3663
|
+
const startMs = Math.round(ch.timestamp * 1e3);
|
|
3664
|
+
const endMs = i < chapters.length - 1 ? Math.round(chapters[i + 1].timestamp * 1e3) : Math.round(totalDuration * 1e3);
|
|
3665
|
+
const escapedTitle = ch.title.replace(/[=;#\\]/g, "\\$&");
|
|
3666
|
+
meta += `[CHAPTER]
|
|
3667
|
+
TIMEBASE=1/1000
|
|
3668
|
+
START=${startMs}
|
|
3669
|
+
END=${endMs}
|
|
3670
|
+
title=${escapedTitle}
|
|
3671
|
+
|
|
3672
|
+
`;
|
|
3673
|
+
}
|
|
3674
|
+
return meta;
|
|
3675
|
+
}
|
|
3676
|
+
function buildChapterSystemPrompt() {
|
|
3677
|
+
return `You are a video chapter generator. Analyze the transcript and identify distinct topic segments.
|
|
3678
|
+
|
|
3679
|
+
Rules:
|
|
3680
|
+
- First chapter MUST start at 0:00
|
|
3681
|
+
- Minimum 3 chapters, maximum 10
|
|
3682
|
+
- Each chapter should be 2-5 minutes long
|
|
3683
|
+
- Chapter titles should be concise (3-7 words)
|
|
3684
|
+
- Look for topic transitions, "moving on", "next", "now let's", etc.
|
|
3685
|
+
- Include a brief 1-sentence description per chapter
|
|
3686
|
+
|
|
3687
|
+
**Output format:**
|
|
3688
|
+
Call the "generate_chapters" tool with an array of chapter objects.
|
|
3689
|
+
Each chapter: { timestamp (seconds from start), title (short, 3-7 words), description (1-sentence summary) }
|
|
3690
|
+
|
|
3691
|
+
**Title style:**
|
|
3692
|
+
- Use title case: "Setting Up the Database"
|
|
3693
|
+
- Be specific: "Configuring PostgreSQL" not "Database Stuff"
|
|
3694
|
+
- Include the action when relevant: "Building the API Routes"
|
|
3695
|
+
- Keep under 50 characters`;
|
|
3696
|
+
}
|
|
3697
|
+
var ChapterAgent = class extends BaseAgent {
|
|
3698
|
+
outputDir;
|
|
3699
|
+
totalDuration;
|
|
3700
|
+
constructor(outputDir, totalDuration) {
|
|
3701
|
+
super("ChapterAgent", buildChapterSystemPrompt());
|
|
3702
|
+
this.outputDir = outputDir;
|
|
3703
|
+
this.totalDuration = totalDuration;
|
|
3704
|
+
}
|
|
3705
|
+
get chaptersDir() {
|
|
3706
|
+
return path14.join(this.outputDir, "chapters");
|
|
3707
|
+
}
|
|
3708
|
+
getTools() {
|
|
3709
|
+
return [
|
|
3710
|
+
{
|
|
3711
|
+
name: "generate_chapters",
|
|
3712
|
+
description: "Write the identified chapters to disk in all formats. Provide: chapters (array of { timestamp, title, description }).",
|
|
3713
|
+
parameters: {
|
|
3714
|
+
type: "object",
|
|
3715
|
+
properties: {
|
|
3716
|
+
chapters: {
|
|
3717
|
+
type: "array",
|
|
3718
|
+
items: {
|
|
3719
|
+
type: "object",
|
|
3720
|
+
properties: {
|
|
3721
|
+
timestamp: { type: "number", description: "Seconds from video start" },
|
|
3722
|
+
title: { type: "string", description: "Short chapter title (3-7 words)" },
|
|
3723
|
+
description: { type: "string", description: "1-sentence summary" }
|
|
3724
|
+
},
|
|
3725
|
+
required: ["timestamp", "title", "description"]
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
},
|
|
3729
|
+
required: ["chapters"]
|
|
3730
|
+
},
|
|
3731
|
+
handler: async (rawArgs) => {
|
|
3732
|
+
const args = rawArgs;
|
|
3733
|
+
return this.handleGenerateChapters(args);
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
];
|
|
3737
|
+
}
|
|
3738
|
+
async handleToolCall(toolName, args) {
|
|
3739
|
+
switch (toolName) {
|
|
3740
|
+
case "generate_chapters":
|
|
3741
|
+
return this.handleGenerateChapters(args);
|
|
3742
|
+
default:
|
|
3743
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
async handleGenerateChapters(args) {
|
|
3747
|
+
const { chapters } = args;
|
|
3748
|
+
await fs17.mkdir(this.chaptersDir, { recursive: true });
|
|
3749
|
+
await Promise.all([
|
|
3750
|
+
fs17.writeFile(
|
|
3751
|
+
path14.join(this.chaptersDir, "chapters.json"),
|
|
3752
|
+
generateChaptersJSON(chapters),
|
|
3753
|
+
"utf-8"
|
|
3754
|
+
),
|
|
3755
|
+
fs17.writeFile(
|
|
3756
|
+
path14.join(this.chaptersDir, "chapters-youtube.txt"),
|
|
3757
|
+
generateYouTubeTimestamps(chapters),
|
|
3758
|
+
"utf-8"
|
|
3759
|
+
),
|
|
3760
|
+
fs17.writeFile(
|
|
3761
|
+
path14.join(this.chaptersDir, "chapters.md"),
|
|
3762
|
+
generateChaptersMarkdown(chapters),
|
|
3763
|
+
"utf-8"
|
|
3764
|
+
),
|
|
3765
|
+
fs17.writeFile(
|
|
3766
|
+
path14.join(this.chaptersDir, "chapters.ffmetadata"),
|
|
3767
|
+
generateFFMetadata(chapters, this.totalDuration),
|
|
3768
|
+
"utf-8"
|
|
3769
|
+
)
|
|
3770
|
+
]);
|
|
3771
|
+
logger_default.info(`[ChapterAgent] Wrote ${chapters.length} chapters in 4 formats \u2192 ${this.chaptersDir}`);
|
|
3772
|
+
return `Chapters written: ${chapters.length} chapters in 4 formats to ${this.chaptersDir}`;
|
|
3773
|
+
}
|
|
3774
|
+
};
|
|
3775
|
+
async function generateChapters(video, transcript) {
|
|
3776
|
+
const config2 = getConfig();
|
|
3777
|
+
const outputDir = path14.join(config2.OUTPUT_DIR, video.slug);
|
|
3778
|
+
const agent = new ChapterAgent(outputDir, video.duration);
|
|
3779
|
+
const transcriptBlock = buildTranscriptBlock2(transcript);
|
|
3780
|
+
const userPrompt = [
|
|
3781
|
+
`**Video:** ${video.filename}`,
|
|
3782
|
+
`**Duration:** ${fmtTime2(video.duration)} (${Math.round(video.duration)} seconds)`,
|
|
3783
|
+
"",
|
|
3784
|
+
"---",
|
|
3785
|
+
"",
|
|
3786
|
+
"**Transcript:**",
|
|
3787
|
+
"",
|
|
3788
|
+
transcriptBlock
|
|
3789
|
+
].join("\n");
|
|
3790
|
+
let capturedChapters;
|
|
3791
|
+
const origHandler = agent.handleGenerateChapters.bind(agent);
|
|
3792
|
+
agent.handleGenerateChapters = async (args) => {
|
|
3793
|
+
capturedChapters = args.chapters;
|
|
3794
|
+
return origHandler(args);
|
|
3795
|
+
};
|
|
3796
|
+
try {
|
|
3797
|
+
await agent.run(userPrompt);
|
|
3798
|
+
if (!capturedChapters) {
|
|
3799
|
+
throw new Error("ChapterAgent did not call generate_chapters");
|
|
3800
|
+
}
|
|
3801
|
+
return capturedChapters;
|
|
3802
|
+
} finally {
|
|
3803
|
+
await agent.destroy();
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
// src/services/gitOperations.ts
|
|
3808
|
+
import { execSync } from "child_process";
|
|
3809
|
+
async function commitAndPush(videoSlug, message) {
|
|
3810
|
+
const { REPO_ROOT } = getConfig();
|
|
3811
|
+
const commitMessage = message || `Auto-processed video: ${videoSlug}`;
|
|
3812
|
+
try {
|
|
3813
|
+
logger_default.info(`Staging all changes in ${REPO_ROOT}`);
|
|
3814
|
+
execSync("git add -A", { cwd: REPO_ROOT, stdio: "pipe" });
|
|
3815
|
+
logger_default.info(`Committing: ${commitMessage}`);
|
|
3816
|
+
execSync(`git commit -m "${commitMessage}"`, { cwd: REPO_ROOT, stdio: "pipe" });
|
|
3817
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT, stdio: "pipe" }).toString().trim();
|
|
3818
|
+
logger_default.info(`Pushing to origin ${branch}`);
|
|
3819
|
+
execSync(`git push origin ${branch}`, { cwd: REPO_ROOT, stdio: "pipe" });
|
|
3820
|
+
logger_default.info("Git commit and push completed successfully");
|
|
3821
|
+
} catch (error) {
|
|
3822
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
3823
|
+
if (msg.includes("nothing to commit")) {
|
|
3824
|
+
logger_default.info("Nothing to commit, working tree clean");
|
|
3825
|
+
return;
|
|
3826
|
+
}
|
|
3827
|
+
logger_default.error(`Git operation failed: ${msg}`);
|
|
3828
|
+
throw error;
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
// src/agents/SilenceRemovalAgent.ts
|
|
3833
|
+
import ffmpeg6 from "fluent-ffmpeg";
|
|
3834
|
+
import path16 from "path";
|
|
3835
|
+
|
|
3836
|
+
// src/tools/ffmpeg/silenceDetection.ts
|
|
3837
|
+
import ffmpeg5 from "fluent-ffmpeg";
|
|
3838
|
+
var ffmpegPath7 = getFFmpegPath();
|
|
3839
|
+
ffmpeg5.setFfmpegPath(ffmpegPath7);
|
|
3840
|
+
async function detectSilence(audioPath, minDuration = 1, noiseThreshold = "-30dB") {
|
|
3841
|
+
logger_default.info(`Detecting silence in: ${audioPath} (min=${minDuration}s, threshold=${noiseThreshold})`);
|
|
3842
|
+
return new Promise((resolve, reject) => {
|
|
3843
|
+
const regions = [];
|
|
3844
|
+
let stderr = "";
|
|
3845
|
+
ffmpeg5(audioPath).audioFilters(`silencedetect=noise=${noiseThreshold}:d=${minDuration}`).format("null").output("-").on("stderr", (line) => {
|
|
3846
|
+
stderr += line + "\n";
|
|
3847
|
+
}).on("end", () => {
|
|
3848
|
+
let pendingStart = null;
|
|
3849
|
+
for (const line of stderr.split("\n")) {
|
|
3850
|
+
const startMatch = line.match(/silence_start:\s*([\d.]+)/);
|
|
3851
|
+
if (startMatch) {
|
|
3852
|
+
pendingStart = parseFloat(startMatch[1]);
|
|
3853
|
+
}
|
|
3854
|
+
const endMatch = line.match(/silence_end:\s*([\d.]+)\s*\|\s*silence_duration:\s*([\d.]+)/);
|
|
3855
|
+
if (endMatch) {
|
|
3856
|
+
const end = parseFloat(endMatch[1]);
|
|
3857
|
+
const duration = parseFloat(endMatch[2]);
|
|
3858
|
+
const start = pendingStart ?? Math.max(0, end - duration);
|
|
3859
|
+
regions.push({ start, end, duration });
|
|
3860
|
+
pendingStart = null;
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
const badRegions = regions.filter((r) => r.end <= r.start);
|
|
3864
|
+
if (badRegions.length > 0) {
|
|
3865
|
+
logger_default.warn(`[SilenceDetect] Found ${badRegions.length} invalid regions (end <= start) \u2014 filtering out`);
|
|
3866
|
+
}
|
|
3867
|
+
const validRegions = regions.filter((r) => r.end > r.start);
|
|
3868
|
+
if (validRegions.length > 0) {
|
|
3869
|
+
logger_default.info(`Sample silence regions: ${validRegions.slice(0, 3).map((r) => `${r.start.toFixed(1)}s-${r.end.toFixed(1)}s (${r.duration.toFixed(2)}s)`).join(", ")}`);
|
|
3870
|
+
}
|
|
3871
|
+
logger_default.info(`Detected ${validRegions.length} silence regions`);
|
|
3872
|
+
resolve(validRegions);
|
|
3873
|
+
}).on("error", (err) => {
|
|
3874
|
+
logger_default.error(`Silence detection failed: ${err.message}`);
|
|
3875
|
+
reject(new Error(`Silence detection failed: ${err.message}`));
|
|
3876
|
+
}).run();
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
// src/tools/ffmpeg/singlePassEdit.ts
|
|
3881
|
+
import { execFile as execFile5 } from "child_process";
|
|
3882
|
+
import { promises as fs18 } from "fs";
|
|
3883
|
+
import path15 from "path";
|
|
3884
|
+
import os3 from "os";
|
|
3885
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3886
|
+
var ffmpegPath8 = getFFmpegPath();
|
|
3887
|
+
var __dirname2 = path15.dirname(fileURLToPath2(import.meta.url));
|
|
3888
|
+
var FONTS_DIR2 = path15.resolve(__dirname2, "..", "..", "..", "assets", "fonts");
|
|
3889
|
+
function buildFilterComplex(keepSegments, options) {
|
|
3890
|
+
if (keepSegments.length === 0) {
|
|
3891
|
+
throw new Error("keepSegments must not be empty");
|
|
3892
|
+
}
|
|
3893
|
+
const filterParts = [];
|
|
3894
|
+
const concatInputs = [];
|
|
3895
|
+
const hasCaptions = options?.assFilename;
|
|
3896
|
+
for (let i = 0; i < keepSegments.length; i++) {
|
|
3897
|
+
const seg = keepSegments[i];
|
|
3898
|
+
filterParts.push(
|
|
3899
|
+
`[0:v]trim=start=${seg.start.toFixed(3)}:end=${seg.end.toFixed(3)},setpts=PTS-STARTPTS[v${i}]`
|
|
3900
|
+
);
|
|
3901
|
+
filterParts.push(
|
|
3902
|
+
`[0:a]atrim=start=${seg.start.toFixed(3)}:end=${seg.end.toFixed(3)},asetpts=PTS-STARTPTS[a${i}]`
|
|
3903
|
+
);
|
|
3904
|
+
concatInputs.push(`[v${i}][a${i}]`);
|
|
3905
|
+
}
|
|
3906
|
+
const concatOutV = hasCaptions ? "[cv]" : "[outv]";
|
|
3907
|
+
const concatOutA = hasCaptions ? "[ca]" : "[outa]";
|
|
3908
|
+
filterParts.push(
|
|
3909
|
+
`${concatInputs.join("")}concat=n=${keepSegments.length}:v=1:a=1${concatOutV}${concatOutA}`
|
|
3910
|
+
);
|
|
3911
|
+
if (hasCaptions) {
|
|
3912
|
+
const fontsdir = options?.fontsdir ?? ".";
|
|
3913
|
+
filterParts.push(`[cv]ass=${options.assFilename}:fontsdir=${fontsdir}[outv]`);
|
|
3914
|
+
}
|
|
3915
|
+
return filterParts.join(";\n");
|
|
3916
|
+
}
|
|
3917
|
+
async function singlePassEdit(inputPath, keepSegments, outputPath) {
|
|
3918
|
+
const filterComplex = buildFilterComplex(keepSegments);
|
|
3919
|
+
const args = [
|
|
3920
|
+
"-y",
|
|
3921
|
+
"-i",
|
|
3922
|
+
inputPath,
|
|
3923
|
+
"-filter_complex",
|
|
3924
|
+
filterComplex,
|
|
3925
|
+
"-map",
|
|
3926
|
+
"[outv]",
|
|
3927
|
+
"-map",
|
|
3928
|
+
"[outa]",
|
|
3929
|
+
"-c:v",
|
|
3930
|
+
"libx264",
|
|
3931
|
+
"-preset",
|
|
3932
|
+
"ultrafast",
|
|
3933
|
+
"-crf",
|
|
3934
|
+
"23",
|
|
3935
|
+
"-threads",
|
|
3936
|
+
"4",
|
|
3937
|
+
"-c:a",
|
|
3938
|
+
"aac",
|
|
3939
|
+
"-b:a",
|
|
3940
|
+
"128k",
|
|
3941
|
+
outputPath
|
|
3942
|
+
];
|
|
3943
|
+
logger_default.info(`[SinglePassEdit] Editing ${keepSegments.length} segments \u2192 ${outputPath}`);
|
|
3944
|
+
return new Promise((resolve, reject) => {
|
|
3945
|
+
execFile5(ffmpegPath8, args, { maxBuffer: 50 * 1024 * 1024 }, (error, _stdout, stderr) => {
|
|
3946
|
+
if (error) {
|
|
3947
|
+
logger_default.error(`[SinglePassEdit] FFmpeg failed: ${stderr}`);
|
|
3948
|
+
reject(new Error(`Single-pass edit failed: ${error.message}`));
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
3951
|
+
logger_default.info(`[SinglePassEdit] Complete: ${outputPath}`);
|
|
3952
|
+
resolve(outputPath);
|
|
3953
|
+
});
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
async function singlePassEditAndCaption(inputPath, keepSegments, assPath, outputPath) {
|
|
3957
|
+
const tempDir = await fs18.mkdtemp(path15.join(os3.tmpdir(), "caption-"));
|
|
3958
|
+
const tempAss = path15.join(tempDir, "captions.ass");
|
|
3959
|
+
await fs18.copyFile(assPath, tempAss);
|
|
3960
|
+
const fontFiles = await fs18.readdir(FONTS_DIR2);
|
|
3961
|
+
for (const f of fontFiles) {
|
|
3962
|
+
if (f.endsWith(".ttf") || f.endsWith(".otf")) {
|
|
3963
|
+
await fs18.copyFile(path15.join(FONTS_DIR2, f), path15.join(tempDir, f));
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
const filterComplex = buildFilterComplex(keepSegments, {
|
|
3967
|
+
assFilename: "captions.ass",
|
|
3968
|
+
fontsdir: "."
|
|
3969
|
+
});
|
|
3970
|
+
const args = [
|
|
3971
|
+
"-y",
|
|
3972
|
+
"-i",
|
|
3973
|
+
inputPath,
|
|
3974
|
+
"-filter_complex",
|
|
3975
|
+
filterComplex,
|
|
3976
|
+
"-map",
|
|
3977
|
+
"[outv]",
|
|
3978
|
+
"-map",
|
|
3979
|
+
"[ca]",
|
|
3980
|
+
"-c:v",
|
|
3981
|
+
"libx264",
|
|
3982
|
+
"-preset",
|
|
3983
|
+
"ultrafast",
|
|
3984
|
+
"-crf",
|
|
3985
|
+
"23",
|
|
3986
|
+
"-threads",
|
|
3987
|
+
"4",
|
|
3988
|
+
"-c:a",
|
|
3989
|
+
"aac",
|
|
3990
|
+
"-b:a",
|
|
3991
|
+
"128k",
|
|
3992
|
+
outputPath
|
|
3993
|
+
];
|
|
3994
|
+
logger_default.info(`[SinglePassEdit] Processing ${keepSegments.length} segments with captions \u2192 ${outputPath}`);
|
|
3995
|
+
return new Promise((resolve, reject) => {
|
|
3996
|
+
execFile5(ffmpegPath8, args, { cwd: tempDir, maxBuffer: 50 * 1024 * 1024 }, async (error, _stdout, stderr) => {
|
|
3997
|
+
const files = await fs18.readdir(tempDir).catch(() => []);
|
|
3998
|
+
for (const f of files) {
|
|
3999
|
+
await fs18.unlink(path15.join(tempDir, f)).catch(() => {
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
await fs18.rmdir(tempDir).catch(() => {
|
|
4003
|
+
});
|
|
4004
|
+
if (error) {
|
|
4005
|
+
logger_default.error(`[SinglePassEdit] FFmpeg failed: ${stderr}`);
|
|
4006
|
+
reject(new Error(`Single-pass edit failed: ${error.message}`));
|
|
95
4007
|
return;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
4008
|
+
}
|
|
4009
|
+
logger_default.info(`[SinglePassEdit] Complete: ${outputPath}`);
|
|
4010
|
+
resolve(outputPath);
|
|
4011
|
+
});
|
|
4012
|
+
});
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
// src/agents/SilenceRemovalAgent.ts
|
|
4016
|
+
var ffmpegPath9 = getFFmpegPath();
|
|
4017
|
+
var ffprobePath5 = getFFprobePath();
|
|
4018
|
+
ffmpeg6.setFfmpegPath(ffmpegPath9);
|
|
4019
|
+
ffmpeg6.setFfprobePath(ffprobePath5);
|
|
4020
|
+
var SYSTEM_PROMPT4 = `You are a video editor AI that decides which silent regions in a video should be removed.
|
|
4021
|
+
You will receive a transcript with timestamps and a list of detected silence regions.
|
|
4022
|
+
|
|
4023
|
+
Be CONSERVATIVE. Only remove silence that is CLEARLY dead air \u2014 no speech, no demonstration, no purpose.
|
|
4024
|
+
Aim to remove no more than 10-15% of total video duration.
|
|
4025
|
+
When in doubt, KEEP the silence.
|
|
4026
|
+
|
|
4027
|
+
KEEP silences that are:
|
|
4028
|
+
- Dramatic pauses after impactful statements
|
|
4029
|
+
- Brief thinking pauses (< 2 seconds) in natural speech
|
|
4030
|
+
- Pauses before important reveals or demonstrations
|
|
4031
|
+
- Pauses where the speaker is clearly showing something on screen
|
|
4032
|
+
- Silence during screen demonstrations or typing \u2014 the viewer is watching the screen
|
|
4033
|
+
|
|
4034
|
+
REMOVE silences that are:
|
|
4035
|
+
- Dead air with no purpose (> 3 seconds of nothing)
|
|
4036
|
+
- Gaps between topics where the speaker was gathering thoughts
|
|
4037
|
+
- Silence at the very beginning or end of the video
|
|
4038
|
+
|
|
4039
|
+
Return a JSON array of silence regions to REMOVE (not keep).
|
|
4040
|
+
When you have decided, call the **decide_removals** tool with your removal list.`;
|
|
4041
|
+
var DECIDE_REMOVALS_SCHEMA = {
|
|
4042
|
+
type: "object",
|
|
4043
|
+
properties: {
|
|
4044
|
+
removals: {
|
|
4045
|
+
type: "array",
|
|
4046
|
+
description: "Array of silence regions to remove",
|
|
4047
|
+
items: {
|
|
4048
|
+
type: "object",
|
|
4049
|
+
properties: {
|
|
4050
|
+
start: { type: "number", description: "Start time in seconds" },
|
|
4051
|
+
end: { type: "number", description: "End time in seconds" },
|
|
4052
|
+
reason: { type: "string", description: "Why this silence should be removed" }
|
|
4053
|
+
},
|
|
4054
|
+
required: ["start", "end", "reason"]
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
},
|
|
4058
|
+
required: ["removals"]
|
|
4059
|
+
};
|
|
4060
|
+
var SilenceRemovalAgent = class extends BaseAgent {
|
|
4061
|
+
removals = [];
|
|
4062
|
+
constructor() {
|
|
4063
|
+
super("SilenceRemovalAgent", SYSTEM_PROMPT4);
|
|
4064
|
+
}
|
|
4065
|
+
getTools() {
|
|
4066
|
+
return [
|
|
4067
|
+
{
|
|
4068
|
+
name: "decide_removals",
|
|
4069
|
+
description: "Submit the list of silence regions to remove. Call this once with all removal decisions.",
|
|
4070
|
+
parameters: DECIDE_REMOVALS_SCHEMA,
|
|
4071
|
+
handler: async (args) => {
|
|
4072
|
+
return this.handleToolCall("decide_removals", args);
|
|
4073
|
+
}
|
|
4074
|
+
}
|
|
4075
|
+
];
|
|
4076
|
+
}
|
|
4077
|
+
async handleToolCall(toolName, args) {
|
|
4078
|
+
if (toolName === "decide_removals") {
|
|
4079
|
+
this.removals = args.removals;
|
|
4080
|
+
logger_default.info(`[SilenceRemovalAgent] Decided to remove ${this.removals.length} silence regions`);
|
|
4081
|
+
return { success: true, count: this.removals.length };
|
|
4082
|
+
}
|
|
4083
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
4084
|
+
}
|
|
4085
|
+
getRemovals() {
|
|
4086
|
+
return this.removals;
|
|
4087
|
+
}
|
|
4088
|
+
};
|
|
4089
|
+
function getVideoDuration2(videoPath) {
|
|
4090
|
+
return new Promise((resolve, reject) => {
|
|
4091
|
+
ffmpeg6.ffprobe(videoPath, (err, metadata) => {
|
|
4092
|
+
if (err) return reject(new Error(`ffprobe failed: ${err.message}`));
|
|
4093
|
+
resolve(metadata.format.duration ?? 0);
|
|
4094
|
+
});
|
|
4095
|
+
});
|
|
4096
|
+
}
|
|
4097
|
+
async function removeDeadSilence(video, transcript) {
|
|
4098
|
+
const noEdit = { editedPath: video.repoPath, removals: [], keepSegments: [], wasEdited: false };
|
|
4099
|
+
const silenceRegions = await detectSilence(video.repoPath, 0.5);
|
|
4100
|
+
if (silenceRegions.length === 0) {
|
|
4101
|
+
logger_default.info("[SilenceRemoval] No silence regions detected \u2014 skipping");
|
|
4102
|
+
return noEdit;
|
|
4103
|
+
}
|
|
4104
|
+
const totalSilence = silenceRegions.reduce((sum, r) => sum + r.duration, 0);
|
|
4105
|
+
logger_default.info(`[SilenceRemoval] ${silenceRegions.length} silence regions detected (${totalSilence.toFixed(1)}s total silence)`);
|
|
4106
|
+
let regionsForAgent = silenceRegions.filter((r) => r.duration >= 2);
|
|
4107
|
+
if (regionsForAgent.length === 0) {
|
|
4108
|
+
logger_default.info("[SilenceRemoval] No silence regions >= 2s \u2014 skipping");
|
|
4109
|
+
return noEdit;
|
|
4110
|
+
}
|
|
4111
|
+
if (regionsForAgent.length > 30) {
|
|
4112
|
+
regionsForAgent = [...regionsForAgent].sort((a, b) => b.duration - a.duration).slice(0, 30);
|
|
4113
|
+
regionsForAgent.sort((a, b) => a.start - b.start);
|
|
4114
|
+
logger_default.info(`[SilenceRemoval] Capped to top 30 longest regions for agent analysis`);
|
|
4115
|
+
}
|
|
4116
|
+
const agent = new SilenceRemovalAgent();
|
|
4117
|
+
const transcriptLines = transcript.segments.map(
|
|
4118
|
+
(seg) => `[${seg.start.toFixed(2)}s \u2013 ${seg.end.toFixed(2)}s] ${seg.text}`
|
|
4119
|
+
);
|
|
4120
|
+
const silenceLines = regionsForAgent.map(
|
|
4121
|
+
(r, i) => `${i + 1}. ${r.start.toFixed(2)}s \u2013 ${r.end.toFixed(2)}s (${r.duration.toFixed(2)}s)`
|
|
4122
|
+
);
|
|
4123
|
+
const prompt = [
|
|
4124
|
+
`Video: ${video.filename} (${transcript.duration.toFixed(1)}s total)
|
|
4125
|
+
`,
|
|
4126
|
+
"--- TRANSCRIPT ---\n",
|
|
4127
|
+
transcriptLines.join("\n"),
|
|
4128
|
+
"\n--- END TRANSCRIPT ---\n",
|
|
4129
|
+
"--- SILENCE REGIONS ---\n",
|
|
4130
|
+
silenceLines.join("\n"),
|
|
4131
|
+
"\n--- END SILENCE REGIONS ---\n",
|
|
4132
|
+
"Analyze the context around each silence region and decide which to remove."
|
|
4133
|
+
].join("\n");
|
|
4134
|
+
let removals;
|
|
4135
|
+
try {
|
|
4136
|
+
await agent.run(prompt);
|
|
4137
|
+
removals = agent.getRemovals();
|
|
4138
|
+
} finally {
|
|
4139
|
+
await agent.destroy();
|
|
4140
|
+
}
|
|
4141
|
+
if (removals.length === 0) {
|
|
4142
|
+
logger_default.info("[SilenceRemoval] Agent decided to keep all silences \u2014 skipping edit");
|
|
4143
|
+
return noEdit;
|
|
4144
|
+
}
|
|
4145
|
+
const maxRemoval = transcript.duration * 0.2;
|
|
4146
|
+
let totalRemoval = 0;
|
|
4147
|
+
const cappedRemovals = [];
|
|
4148
|
+
const byDuration = [...removals].sort((a, b) => b.end - b.start - (a.end - a.start));
|
|
4149
|
+
for (const r of byDuration) {
|
|
4150
|
+
const dur = r.end - r.start;
|
|
4151
|
+
if (totalRemoval + dur <= maxRemoval) {
|
|
4152
|
+
cappedRemovals.push(r);
|
|
4153
|
+
totalRemoval += dur;
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
if (cappedRemovals.length < removals.length) {
|
|
4157
|
+
logger_default.warn(`[SilenceRemoval] Capped from ${removals.length} to ${cappedRemovals.length} regions (${totalRemoval.toFixed(1)}s) to stay under 20% threshold`);
|
|
4158
|
+
}
|
|
4159
|
+
removals = cappedRemovals;
|
|
4160
|
+
if (removals.length === 0) {
|
|
4161
|
+
logger_default.info("[SilenceRemoval] All removals exceeded 20% cap \u2014 skipping edit");
|
|
4162
|
+
return noEdit;
|
|
4163
|
+
}
|
|
4164
|
+
const videoDuration = await getVideoDuration2(video.repoPath);
|
|
4165
|
+
const sortedRemovals = [...removals].sort((a, b) => a.start - b.start);
|
|
4166
|
+
const keepSegments = [];
|
|
4167
|
+
let cursor = 0;
|
|
4168
|
+
for (const removal of sortedRemovals) {
|
|
4169
|
+
if (removal.start > cursor) {
|
|
4170
|
+
keepSegments.push({ start: cursor, end: removal.start });
|
|
4171
|
+
}
|
|
4172
|
+
cursor = Math.max(cursor, removal.end);
|
|
4173
|
+
}
|
|
4174
|
+
if (cursor < videoDuration) {
|
|
4175
|
+
keepSegments.push({ start: cursor, end: videoDuration });
|
|
4176
|
+
}
|
|
4177
|
+
if (keepSegments.length === 0) {
|
|
4178
|
+
logger_default.warn("[SilenceRemoval] No segments to keep \u2014 returning original");
|
|
4179
|
+
return noEdit;
|
|
4180
|
+
}
|
|
4181
|
+
const editedPath = path16.join(video.videoDir, `${video.slug}-edited.mp4`);
|
|
4182
|
+
await singlePassEdit(video.repoPath, keepSegments, editedPath);
|
|
4183
|
+
const effectiveRemovals = [];
|
|
4184
|
+
let prevEnd = 0;
|
|
4185
|
+
for (const seg of keepSegments) {
|
|
4186
|
+
if (seg.start > prevEnd) {
|
|
4187
|
+
effectiveRemovals.push({ start: prevEnd, end: seg.start });
|
|
4188
|
+
}
|
|
4189
|
+
prevEnd = seg.end;
|
|
4190
|
+
}
|
|
4191
|
+
const actualRemoved = effectiveRemovals.reduce((sum, r) => sum + (r.end - r.start), 0);
|
|
4192
|
+
logger_default.info(
|
|
4193
|
+
`[SilenceRemoval] Removed ${effectiveRemovals.length} silence regions (${actualRemoved.toFixed(1)}s). Output: ${editedPath}`
|
|
4194
|
+
);
|
|
4195
|
+
return {
|
|
4196
|
+
editedPath,
|
|
4197
|
+
removals: effectiveRemovals,
|
|
4198
|
+
keepSegments,
|
|
4199
|
+
wasEdited: true
|
|
4200
|
+
};
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
// src/pipeline.ts
|
|
4204
|
+
async function runStage(stageName, fn, stageResults) {
|
|
4205
|
+
costTracker.setStage(stageName);
|
|
4206
|
+
const start = Date.now();
|
|
4207
|
+
try {
|
|
4208
|
+
const result = await fn();
|
|
4209
|
+
const duration = Date.now() - start;
|
|
4210
|
+
stageResults.push({ stage: stageName, success: true, duration });
|
|
4211
|
+
logger_default.info(`Stage ${stageName} completed in ${duration}ms`);
|
|
4212
|
+
return result;
|
|
4213
|
+
} catch (err) {
|
|
4214
|
+
const duration = Date.now() - start;
|
|
4215
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4216
|
+
stageResults.push({ stage: stageName, success: false, error: message, duration });
|
|
4217
|
+
logger_default.error(`Stage ${stageName} failed after ${duration}ms: ${message}`);
|
|
4218
|
+
return void 0;
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
function adjustTranscript(transcript, removals) {
|
|
4222
|
+
const sorted = [...removals].sort((a, b) => a.start - b.start);
|
|
4223
|
+
function adjustTime(t) {
|
|
4224
|
+
let offset = 0;
|
|
4225
|
+
for (const r of sorted) {
|
|
4226
|
+
if (t <= r.start) break;
|
|
4227
|
+
if (t >= r.end) {
|
|
4228
|
+
offset += r.end - r.start;
|
|
4229
|
+
} else {
|
|
4230
|
+
offset += t - r.start;
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
return t - offset;
|
|
4234
|
+
}
|
|
4235
|
+
return {
|
|
4236
|
+
...transcript,
|
|
4237
|
+
duration: adjustTime(transcript.duration),
|
|
4238
|
+
segments: transcript.segments.filter((seg) => !sorted.some((r) => seg.start >= r.start && seg.end <= r.end)).map((seg) => ({
|
|
4239
|
+
...seg,
|
|
4240
|
+
start: adjustTime(seg.start),
|
|
4241
|
+
end: adjustTime(seg.end)
|
|
4242
|
+
})),
|
|
4243
|
+
words: transcript.words.filter((w) => !sorted.some((r) => w.start >= r.start && w.end <= r.end)).map((w) => ({
|
|
4244
|
+
...w,
|
|
4245
|
+
start: adjustTime(w.start),
|
|
4246
|
+
end: adjustTime(w.end)
|
|
4247
|
+
}))
|
|
4248
|
+
};
|
|
4249
|
+
}
|
|
4250
|
+
async function processVideo(videoPath) {
|
|
4251
|
+
const pipelineStart = Date.now();
|
|
4252
|
+
const stageResults = [];
|
|
4253
|
+
const cfg = getConfig();
|
|
4254
|
+
costTracker.reset();
|
|
4255
|
+
logger_default.info(`Pipeline starting for: ${videoPath}`);
|
|
4256
|
+
const video = await runStage("ingestion" /* Ingestion */, () => ingestVideo(videoPath), stageResults);
|
|
4257
|
+
if (!video) {
|
|
4258
|
+
const totalDuration2 = Date.now() - pipelineStart;
|
|
4259
|
+
logger_default.error("Ingestion failed \u2014 cannot proceed without video metadata");
|
|
4260
|
+
return { video: { originalPath: videoPath, repoPath: "", videoDir: "", slug: "", filename: "", duration: 0, size: 0, createdAt: /* @__PURE__ */ new Date() }, transcript: void 0, editedVideoPath: void 0, captions: void 0, captionedVideoPath: void 0, summary: void 0, shorts: [], mediumClips: [], socialPosts: [], blogPost: void 0, stageResults, totalDuration: totalDuration2 };
|
|
4261
|
+
}
|
|
4262
|
+
let transcript;
|
|
4263
|
+
transcript = await runStage("transcription" /* Transcription */, () => transcribeVideo(video), stageResults);
|
|
4264
|
+
let editedVideoPath;
|
|
4265
|
+
let adjustedTranscript;
|
|
4266
|
+
let silenceRemovals = [];
|
|
4267
|
+
let silenceKeepSegments;
|
|
4268
|
+
if (transcript && !cfg.SKIP_SILENCE_REMOVAL) {
|
|
4269
|
+
const result = await runStage("silence-removal" /* SilenceRemoval */, () => removeDeadSilence(video, transcript), stageResults);
|
|
4270
|
+
if (result && result.wasEdited) {
|
|
4271
|
+
editedVideoPath = result.editedPath;
|
|
4272
|
+
silenceRemovals = result.removals;
|
|
4273
|
+
silenceKeepSegments = result.keepSegments;
|
|
4274
|
+
adjustedTranscript = adjustTranscript(transcript, silenceRemovals);
|
|
4275
|
+
const totalRemoved = silenceRemovals.reduce((sum, r) => sum + (r.end - r.start), 0);
|
|
4276
|
+
const expectedDuration = transcript.duration - totalRemoved;
|
|
4277
|
+
const adjustedDuration = adjustedTranscript.duration;
|
|
4278
|
+
const drift = Math.abs(expectedDuration - adjustedDuration);
|
|
4279
|
+
logger_default.info(`[Pipeline] Silence removal: original=${transcript.duration.toFixed(1)}s, removed=${totalRemoved.toFixed(1)}s, expected=${expectedDuration.toFixed(1)}s, adjusted=${adjustedDuration.toFixed(1)}s, drift=${drift.toFixed(1)}s`);
|
|
4280
|
+
await fs19.writeFile(
|
|
4281
|
+
path17.join(video.videoDir, "transcript-edited.json"),
|
|
4282
|
+
JSON.stringify(adjustedTranscript, null, 2)
|
|
4283
|
+
);
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
const captionTranscript = adjustedTranscript ?? transcript;
|
|
4287
|
+
let captions;
|
|
4288
|
+
if (captionTranscript && !cfg.SKIP_CAPTIONS) {
|
|
4289
|
+
captions = await runStage("captions" /* Captions */, () => generateCaptions(video, captionTranscript), stageResults);
|
|
4290
|
+
}
|
|
4291
|
+
let captionedVideoPath;
|
|
4292
|
+
if (captions && !cfg.SKIP_CAPTIONS) {
|
|
4293
|
+
const assFile = captions.find((p) => p.endsWith(".ass"));
|
|
4294
|
+
if (assFile && silenceKeepSegments) {
|
|
4295
|
+
const captionedOutput = path17.join(video.videoDir, `${video.slug}-captioned.mp4`);
|
|
4296
|
+
captionedVideoPath = await runStage(
|
|
4297
|
+
"caption-burn" /* CaptionBurn */,
|
|
4298
|
+
() => singlePassEditAndCaption(video.repoPath, silenceKeepSegments, assFile, captionedOutput),
|
|
4299
|
+
stageResults
|
|
4300
|
+
);
|
|
4301
|
+
} else if (assFile) {
|
|
4302
|
+
const videoToBurn = editedVideoPath ?? video.repoPath;
|
|
4303
|
+
const captionedOutput = path17.join(video.videoDir, `${video.slug}-captioned.mp4`);
|
|
4304
|
+
captionedVideoPath = await runStage(
|
|
4305
|
+
"caption-burn" /* CaptionBurn */,
|
|
4306
|
+
() => burnCaptions(videoToBurn, assFile, captionedOutput),
|
|
4307
|
+
stageResults
|
|
4308
|
+
);
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
let shorts = [];
|
|
4312
|
+
if (transcript && !cfg.SKIP_SHORTS) {
|
|
4313
|
+
const result = await runStage("shorts" /* Shorts */, () => generateShorts(video, transcript), stageResults);
|
|
4314
|
+
if (result) shorts = result;
|
|
4315
|
+
}
|
|
4316
|
+
let mediumClips = [];
|
|
4317
|
+
if (transcript && !cfg.SKIP_MEDIUM_CLIPS) {
|
|
4318
|
+
const result = await runStage("medium-clips" /* MediumClips */, () => generateMediumClips(video, transcript), stageResults);
|
|
4319
|
+
if (result) mediumClips = result;
|
|
4320
|
+
}
|
|
4321
|
+
let chapters;
|
|
4322
|
+
if (transcript) {
|
|
4323
|
+
chapters = await runStage("chapters" /* Chapters */, () => generateChapters(video, transcript), stageResults);
|
|
4324
|
+
}
|
|
4325
|
+
let summary;
|
|
4326
|
+
if (transcript) {
|
|
4327
|
+
summary = await runStage("summary" /* Summary */, () => generateSummary(video, transcript, shorts, chapters), stageResults);
|
|
4328
|
+
}
|
|
4329
|
+
let socialPosts = [];
|
|
4330
|
+
if (transcript && summary && !cfg.SKIP_SOCIAL) {
|
|
4331
|
+
const result = await runStage(
|
|
4332
|
+
"social-media" /* SocialMedia */,
|
|
4333
|
+
() => generateSocialPosts(video, transcript, summary, path17.join(video.videoDir, "social-posts")),
|
|
4334
|
+
stageResults
|
|
4335
|
+
);
|
|
4336
|
+
if (result) socialPosts = result;
|
|
4337
|
+
}
|
|
4338
|
+
if (transcript && shorts.length > 0 && !cfg.SKIP_SOCIAL) {
|
|
4339
|
+
await runStage(
|
|
4340
|
+
"short-posts" /* ShortPosts */,
|
|
4341
|
+
async () => {
|
|
4342
|
+
for (const short of shorts) {
|
|
4343
|
+
const posts = await generateShortPosts(video, short, transcript);
|
|
4344
|
+
socialPosts.push(...posts);
|
|
4345
|
+
}
|
|
4346
|
+
},
|
|
4347
|
+
stageResults
|
|
4348
|
+
);
|
|
4349
|
+
}
|
|
4350
|
+
if (transcript && mediumClips.length > 0 && !cfg.SKIP_SOCIAL) {
|
|
4351
|
+
await runStage(
|
|
4352
|
+
"medium-clip-posts" /* MediumClipPosts */,
|
|
4353
|
+
async () => {
|
|
4354
|
+
for (const clip of mediumClips) {
|
|
4355
|
+
const asShortClip = {
|
|
4356
|
+
id: clip.id,
|
|
4357
|
+
title: clip.title,
|
|
4358
|
+
slug: clip.slug,
|
|
4359
|
+
segments: clip.segments,
|
|
4360
|
+
totalDuration: clip.totalDuration,
|
|
4361
|
+
outputPath: clip.outputPath,
|
|
4362
|
+
captionedPath: clip.captionedPath,
|
|
4363
|
+
description: clip.description,
|
|
4364
|
+
tags: clip.tags
|
|
4365
|
+
};
|
|
4366
|
+
const posts = await generateShortPosts(video, asShortClip, transcript);
|
|
4367
|
+
const clipsDir = path17.join(path17.dirname(video.repoPath), "medium-clips");
|
|
4368
|
+
const postsDir = path17.join(clipsDir, clip.slug, "posts");
|
|
4369
|
+
await fs19.mkdir(postsDir, { recursive: true });
|
|
4370
|
+
for (const post of posts) {
|
|
4371
|
+
const destPath = path17.join(postsDir, path17.basename(post.outputPath));
|
|
4372
|
+
await fs19.copyFile(post.outputPath, destPath);
|
|
4373
|
+
await fs19.unlink(post.outputPath).catch(() => {
|
|
4374
|
+
});
|
|
4375
|
+
post.outputPath = destPath;
|
|
4376
|
+
}
|
|
4377
|
+
socialPosts.push(...posts);
|
|
4378
|
+
}
|
|
4379
|
+
},
|
|
4380
|
+
stageResults
|
|
4381
|
+
);
|
|
4382
|
+
}
|
|
4383
|
+
let blogPost;
|
|
4384
|
+
if (transcript && summary) {
|
|
4385
|
+
blogPost = await runStage(
|
|
4386
|
+
"blog" /* Blog */,
|
|
4387
|
+
() => generateBlogPost(video, transcript, summary),
|
|
4388
|
+
stageResults
|
|
4389
|
+
);
|
|
4390
|
+
}
|
|
4391
|
+
if (!cfg.SKIP_GIT) {
|
|
4392
|
+
await runStage("git-push" /* GitPush */, () => commitAndPush(video.slug), stageResults);
|
|
4393
|
+
}
|
|
4394
|
+
const totalDuration = Date.now() - pipelineStart;
|
|
4395
|
+
const report = costTracker.getReport();
|
|
4396
|
+
if (report.records.length > 0) {
|
|
4397
|
+
logger_default.info(costTracker.formatReport());
|
|
4398
|
+
const costMd = generateCostMarkdown(report);
|
|
4399
|
+
const costPath = path17.join(video.videoDir, "cost-report.md");
|
|
4400
|
+
await fs19.writeFile(costPath, costMd, "utf-8");
|
|
4401
|
+
logger_default.info(`Cost report saved: ${costPath}`);
|
|
4402
|
+
}
|
|
4403
|
+
logger_default.info(`Pipeline completed in ${totalDuration}ms`);
|
|
4404
|
+
return {
|
|
4405
|
+
video,
|
|
4406
|
+
transcript,
|
|
4407
|
+
editedVideoPath,
|
|
4408
|
+
captions,
|
|
4409
|
+
captionedVideoPath,
|
|
4410
|
+
summary,
|
|
4411
|
+
chapters,
|
|
4412
|
+
shorts,
|
|
4413
|
+
mediumClips,
|
|
4414
|
+
socialPosts,
|
|
4415
|
+
blogPost,
|
|
4416
|
+
stageResults,
|
|
4417
|
+
totalDuration
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
function generateCostMarkdown(report) {
|
|
4421
|
+
let md = "# Pipeline Cost Report\n\n";
|
|
4422
|
+
md += `| Metric | Value |
|
|
4423
|
+
|--------|-------|
|
|
4424
|
+
`;
|
|
4425
|
+
md += `| Total Cost | $${report.totalCostUSD.toFixed(4)} USD |
|
|
4426
|
+
`;
|
|
4427
|
+
if (report.totalPRUs > 0) md += `| Total PRUs | ${report.totalPRUs} |
|
|
4428
|
+
`;
|
|
4429
|
+
md += `| Input Tokens | ${report.totalTokens.input.toLocaleString()} |
|
|
4430
|
+
`;
|
|
4431
|
+
md += `| Output Tokens | ${report.totalTokens.output.toLocaleString()} |
|
|
4432
|
+
`;
|
|
4433
|
+
md += `| LLM Calls | ${report.records.length} |
|
|
4434
|
+
|
|
4435
|
+
`;
|
|
4436
|
+
if (Object.keys(report.byAgent).length > 0) {
|
|
4437
|
+
md += "## By Agent\n\n| Agent | Cost | PRUs | Calls |\n|-------|------|------|-------|\n";
|
|
4438
|
+
for (const [agent, data] of Object.entries(report.byAgent)) {
|
|
4439
|
+
md += `| ${agent} | $${data.costUSD.toFixed(4)} | ${data.prus} | ${data.calls} |
|
|
4440
|
+
`;
|
|
4441
|
+
}
|
|
4442
|
+
md += "\n";
|
|
4443
|
+
}
|
|
4444
|
+
if (Object.keys(report.byModel).length > 1) {
|
|
4445
|
+
md += "## By Model\n\n| Model | Cost | PRUs | Calls |\n|-------|------|------|-------|\n";
|
|
4446
|
+
for (const [model, data] of Object.entries(report.byModel)) {
|
|
4447
|
+
md += `| ${model} | $${data.costUSD.toFixed(4)} | ${data.prus} | ${data.calls} |
|
|
4448
|
+
`;
|
|
4449
|
+
}
|
|
4450
|
+
md += "\n";
|
|
4451
|
+
}
|
|
4452
|
+
return md;
|
|
4453
|
+
}
|
|
4454
|
+
async function processVideoSafe(videoPath) {
|
|
4455
|
+
try {
|
|
4456
|
+
return await processVideo(videoPath);
|
|
4457
|
+
} catch (err) {
|
|
4458
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4459
|
+
logger_default.error(`Pipeline failed with uncaught error: ${message}`);
|
|
4460
|
+
return null;
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
// src/commands/doctor.ts
|
|
4465
|
+
import { spawnSync } from "child_process";
|
|
4466
|
+
import { existsSync as existsSync2 } from "fs";
|
|
4467
|
+
import { createRequire as createRequire2 } from "module";
|
|
4468
|
+
import path18 from "path";
|
|
4469
|
+
var require3 = createRequire2(import.meta.url);
|
|
4470
|
+
function normalizeProviderName(raw) {
|
|
4471
|
+
return (raw || "copilot").trim().toLowerCase();
|
|
4472
|
+
}
|
|
4473
|
+
function resolveFFmpegPath() {
|
|
4474
|
+
if (process.env.FFMPEG_PATH) {
|
|
4475
|
+
return { path: process.env.FFMPEG_PATH, source: "FFMPEG_PATH env" };
|
|
4476
|
+
}
|
|
4477
|
+
try {
|
|
4478
|
+
const staticPath = require3("ffmpeg-static");
|
|
4479
|
+
if (staticPath && existsSync2(staticPath)) {
|
|
4480
|
+
return { path: staticPath, source: "ffmpeg-static" };
|
|
4481
|
+
}
|
|
4482
|
+
} catch {
|
|
4483
|
+
}
|
|
4484
|
+
return { path: "ffmpeg", source: "system PATH" };
|
|
4485
|
+
}
|
|
4486
|
+
function resolveFFprobePath() {
|
|
4487
|
+
if (process.env.FFPROBE_PATH) {
|
|
4488
|
+
return { path: process.env.FFPROBE_PATH, source: "FFPROBE_PATH env" };
|
|
4489
|
+
}
|
|
4490
|
+
try {
|
|
4491
|
+
const { path: probePath } = require3("@ffprobe-installer/ffprobe");
|
|
4492
|
+
if (probePath && existsSync2(probePath)) {
|
|
4493
|
+
return { path: probePath, source: "@ffprobe-installer/ffprobe" };
|
|
4494
|
+
}
|
|
4495
|
+
} catch {
|
|
4496
|
+
}
|
|
4497
|
+
return { path: "ffprobe", source: "system PATH" };
|
|
4498
|
+
}
|
|
4499
|
+
function parseVersionFromOutput(output) {
|
|
4500
|
+
const match = output.match(/(\d+\.\d+(?:\.\d+)?)/);
|
|
4501
|
+
return match ? match[1] : "unknown";
|
|
4502
|
+
}
|
|
4503
|
+
function getFFmpegInstallHint() {
|
|
4504
|
+
const platform = process.platform;
|
|
4505
|
+
const lines = ["Install FFmpeg:"];
|
|
4506
|
+
if (platform === "win32") {
|
|
4507
|
+
lines.push(" winget install Gyan.FFmpeg");
|
|
4508
|
+
lines.push(" choco install ffmpeg (alternative)");
|
|
4509
|
+
} else if (platform === "darwin") {
|
|
4510
|
+
lines.push(" brew install ffmpeg");
|
|
4511
|
+
} else {
|
|
4512
|
+
lines.push(" sudo apt install ffmpeg (Debian/Ubuntu)");
|
|
4513
|
+
lines.push(" sudo dnf install ffmpeg (Fedora)");
|
|
4514
|
+
lines.push(" sudo pacman -S ffmpeg (Arch)");
|
|
4515
|
+
}
|
|
4516
|
+
lines.push(" Or set FFMPEG_PATH to a custom binary location");
|
|
4517
|
+
return lines.join("\n ");
|
|
4518
|
+
}
|
|
4519
|
+
function checkNode() {
|
|
4520
|
+
const raw = process.version;
|
|
4521
|
+
const major = parseInt(raw.slice(1), 10);
|
|
4522
|
+
const ok = major >= 20;
|
|
4523
|
+
return {
|
|
4524
|
+
label: "Node.js",
|
|
4525
|
+
ok,
|
|
4526
|
+
required: true,
|
|
4527
|
+
message: ok ? `Node.js ${raw} (required: \u226520)` : `Node.js ${raw} \u2014 version \u226520 required`
|
|
4528
|
+
};
|
|
4529
|
+
}
|
|
4530
|
+
function checkFFmpeg() {
|
|
4531
|
+
const { path: binPath, source } = resolveFFmpegPath();
|
|
4532
|
+
try {
|
|
4533
|
+
const result = spawnSync(binPath, ["-version"], { encoding: "utf-8", timeout: 1e4 });
|
|
4534
|
+
if (result.status === 0 && result.stdout) {
|
|
4535
|
+
const ver = parseVersionFromOutput(result.stdout);
|
|
4536
|
+
return { label: "FFmpeg", ok: true, required: true, message: `FFmpeg ${ver} (source: ${source})` };
|
|
4537
|
+
}
|
|
4538
|
+
} catch {
|
|
4539
|
+
}
|
|
4540
|
+
return {
|
|
4541
|
+
label: "FFmpeg",
|
|
4542
|
+
ok: false,
|
|
4543
|
+
required: true,
|
|
4544
|
+
message: `FFmpeg not found \u2014 ${getFFmpegInstallHint()}`
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4547
|
+
function checkFFprobe() {
|
|
4548
|
+
const { path: binPath, source } = resolveFFprobePath();
|
|
4549
|
+
try {
|
|
4550
|
+
const result = spawnSync(binPath, ["-version"], { encoding: "utf-8", timeout: 1e4 });
|
|
4551
|
+
if (result.status === 0 && result.stdout) {
|
|
4552
|
+
const ver = parseVersionFromOutput(result.stdout);
|
|
4553
|
+
return { label: "FFprobe", ok: true, required: true, message: `FFprobe ${ver} (source: ${source})` };
|
|
4554
|
+
}
|
|
4555
|
+
} catch {
|
|
4556
|
+
}
|
|
4557
|
+
return {
|
|
4558
|
+
label: "FFprobe",
|
|
4559
|
+
ok: false,
|
|
4560
|
+
required: true,
|
|
4561
|
+
message: `FFprobe not found \u2014 usually included with FFmpeg.
|
|
4562
|
+
${getFFmpegInstallHint()}`
|
|
4563
|
+
};
|
|
4564
|
+
}
|
|
4565
|
+
function checkOpenAIKey() {
|
|
4566
|
+
const set = !!process.env.OPENAI_API_KEY;
|
|
4567
|
+
return {
|
|
4568
|
+
label: "OPENAI_API_KEY",
|
|
4569
|
+
ok: set,
|
|
4570
|
+
required: true,
|
|
4571
|
+
message: set ? "OPENAI_API_KEY is set" : "OPENAI_API_KEY not set \u2014 get one at https://platform.openai.com/api-keys"
|
|
4572
|
+
};
|
|
4573
|
+
}
|
|
4574
|
+
function checkExaKey() {
|
|
4575
|
+
const set = !!process.env.EXA_API_KEY;
|
|
4576
|
+
return {
|
|
4577
|
+
label: "EXA_API_KEY",
|
|
4578
|
+
ok: set,
|
|
4579
|
+
required: false,
|
|
4580
|
+
message: set ? "EXA_API_KEY is set" : "EXA_API_KEY not set (optional \u2014 web search in social posts)"
|
|
4581
|
+
};
|
|
4582
|
+
}
|
|
4583
|
+
function checkGit() {
|
|
4584
|
+
try {
|
|
4585
|
+
const result = spawnSync("git", ["--version"], { encoding: "utf-8", timeout: 1e4 });
|
|
4586
|
+
if (result.status === 0 && result.stdout) {
|
|
4587
|
+
const ver = parseVersionFromOutput(result.stdout);
|
|
4588
|
+
return { label: "Git", ok: true, required: false, message: `Git ${ver}` };
|
|
100
4589
|
}
|
|
101
|
-
|
|
102
|
-
|
|
4590
|
+
} catch {
|
|
4591
|
+
}
|
|
4592
|
+
return {
|
|
4593
|
+
label: "Git",
|
|
4594
|
+
ok: false,
|
|
4595
|
+
required: false,
|
|
4596
|
+
message: "Git not found (optional \u2014 needed for auto-commit stage)"
|
|
4597
|
+
};
|
|
4598
|
+
}
|
|
4599
|
+
function checkWatchFolder() {
|
|
4600
|
+
const watchDir = process.env.WATCH_FOLDER || path18.join(process.cwd(), "watch");
|
|
4601
|
+
const exists = existsSync2(watchDir);
|
|
4602
|
+
return {
|
|
4603
|
+
label: "Watch folder",
|
|
4604
|
+
ok: exists,
|
|
4605
|
+
required: false,
|
|
4606
|
+
message: exists ? `Watch folder exists: ${watchDir}` : `Watch folder missing: ${watchDir}`
|
|
4607
|
+
};
|
|
4608
|
+
}
|
|
4609
|
+
function runDoctor() {
|
|
4610
|
+
console.log("\n\u{1F50D} VidPipe Doctor \u2014 Checking prerequisites...\n");
|
|
4611
|
+
const results = [
|
|
4612
|
+
checkNode(),
|
|
4613
|
+
checkFFmpeg(),
|
|
4614
|
+
checkFFprobe(),
|
|
4615
|
+
checkOpenAIKey(),
|
|
4616
|
+
checkExaKey(),
|
|
4617
|
+
checkGit(),
|
|
4618
|
+
checkWatchFolder()
|
|
4619
|
+
];
|
|
4620
|
+
for (const r of results) {
|
|
4621
|
+
const icon = r.ok ? "\u2705" : r.required ? "\u274C" : "\u2B1A";
|
|
4622
|
+
console.log(` ${icon} ${r.message}`);
|
|
4623
|
+
}
|
|
4624
|
+
console.log("\nLLM Provider");
|
|
4625
|
+
const providerName = normalizeProviderName(process.env.LLM_PROVIDER);
|
|
4626
|
+
const isDefault = !process.env.LLM_PROVIDER;
|
|
4627
|
+
const providerLabel = isDefault ? `${providerName} (default)` : providerName;
|
|
4628
|
+
const validProviders = ["copilot", "openai", "claude"];
|
|
4629
|
+
if (!validProviders.includes(providerName)) {
|
|
4630
|
+
console.log(` \u274C Provider: ${providerLabel} \u2014 unknown provider`);
|
|
4631
|
+
results.push({ label: "LLM Provider", ok: false, required: true, message: `Unknown provider: ${providerName}` });
|
|
4632
|
+
} else if (providerName === "copilot") {
|
|
4633
|
+
console.log(` \u2705 Provider: ${providerLabel}`);
|
|
4634
|
+
console.log(" \u2705 Copilot \u2014 uses GitHub auth");
|
|
4635
|
+
} else if (providerName === "openai") {
|
|
4636
|
+
console.log(` \u2705 Provider: ${providerLabel}`);
|
|
4637
|
+
if (process.env.OPENAI_API_KEY) {
|
|
4638
|
+
console.log(" \u2705 OPENAI_API_KEY is set (also used for Whisper)");
|
|
4639
|
+
} else {
|
|
4640
|
+
console.log(" \u274C OPENAI_API_KEY not set (required for openai provider)");
|
|
4641
|
+
results.push({ label: "LLM Provider", ok: false, required: true, message: "OPENAI_API_KEY not set for OpenAI LLM" });
|
|
4642
|
+
}
|
|
4643
|
+
} else if (providerName === "claude") {
|
|
4644
|
+
console.log(` \u2705 Provider: ${providerLabel}`);
|
|
4645
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
4646
|
+
console.log(" \u2705 ANTHROPIC_API_KEY is set");
|
|
4647
|
+
} else {
|
|
4648
|
+
console.log(" \u274C ANTHROPIC_API_KEY not set (required for claude provider)");
|
|
4649
|
+
results.push({ label: "LLM Provider", ok: false, required: true, message: "ANTHROPIC_API_KEY not set for Claude LLM" });
|
|
103
4650
|
}
|
|
104
|
-
|
|
4651
|
+
}
|
|
4652
|
+
const defaultModels = {
|
|
4653
|
+
copilot: "Claude Opus 4.6",
|
|
4654
|
+
openai: "gpt-4o",
|
|
4655
|
+
claude: "claude-opus-4.6"
|
|
4656
|
+
};
|
|
4657
|
+
if (validProviders.includes(providerName)) {
|
|
4658
|
+
const defaultModel = defaultModels[providerName];
|
|
4659
|
+
const modelOverride = process.env.LLM_MODEL;
|
|
4660
|
+
if (modelOverride) {
|
|
4661
|
+
console.log(` \u2139\uFE0F Model override: ${modelOverride} (default: ${defaultModel})`);
|
|
4662
|
+
} else {
|
|
4663
|
+
console.log(` \u2139\uFE0F Default model: ${defaultModel}`);
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
const failedRequired = results.filter((r) => r.required && !r.ok);
|
|
4667
|
+
console.log();
|
|
4668
|
+
if (failedRequired.length === 0) {
|
|
4669
|
+
console.log(" All required checks passed! \u2705\n");
|
|
105
4670
|
process.exit(0);
|
|
4671
|
+
} else {
|
|
4672
|
+
console.log(` ${failedRequired.length} required check${failedRequired.length > 1 ? "s" : ""} failed \u274C
|
|
4673
|
+
`);
|
|
4674
|
+
process.exit(1);
|
|
4675
|
+
}
|
|
4676
|
+
}
|
|
4677
|
+
|
|
4678
|
+
// src/index.ts
|
|
4679
|
+
import path19 from "path";
|
|
4680
|
+
import { readFileSync } from "fs";
|
|
4681
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4682
|
+
var __dirname3 = path19.dirname(fileURLToPath3(import.meta.url));
|
|
4683
|
+
var pkg = JSON.parse(readFileSync(path19.resolve(__dirname3, "..", "package.json"), "utf-8"));
|
|
4684
|
+
var BANNER = `
|
|
4685
|
+
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
4686
|
+
\u2551 VidPipe v${pkg.version.padEnd(24)}\u2551
|
|
4687
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
4688
|
+
`;
|
|
4689
|
+
var program = new Command();
|
|
4690
|
+
program.name("vidpipe").description("AI-powered video content pipeline: transcribe, summarize, generate shorts, captions, and social posts").version(pkg.version, "-V, --version").argument("[video-path]", "Path to a video file to process (implies --once)").option("--watch-dir <path>", "Folder to watch for new recordings (default: env WATCH_FOLDER)").option("--output-dir <path>", "Output directory for processed videos (default: ./recordings)").option("--openai-key <key>", "OpenAI API key (default: env OPENAI_API_KEY)").option("--exa-key <key>", "Exa AI API key for web search (default: env EXA_API_KEY)").option("--once", "Process a single video and exit (no watching)").option("--brand <path>", "Path to brand.json config (default: ./brand.json)").option("--no-git", "Skip git commit/push stage").option("--no-silence-removal", "Skip silence removal stage").option("--no-shorts", "Skip shorts generation").option("--no-medium-clips", "Skip medium clip generation").option("--no-social", "Skip social media post generation").option("--no-captions", "Skip caption generation/burning").option("-v, --verbose", "Verbose logging").option("--doctor", "Check all prerequisites and exit");
|
|
4691
|
+
program.parse();
|
|
4692
|
+
var opts = program.opts();
|
|
4693
|
+
if (opts.doctor) {
|
|
4694
|
+
runDoctor();
|
|
4695
|
+
process.exit(0);
|
|
4696
|
+
}
|
|
4697
|
+
var videoArg = program.args[0];
|
|
4698
|
+
var onceMode = opts.once || !!videoArg;
|
|
4699
|
+
var cliOptions = {
|
|
4700
|
+
watchDir: opts.watchDir,
|
|
4701
|
+
outputDir: opts.outputDir,
|
|
4702
|
+
openaiKey: opts.openaiKey,
|
|
4703
|
+
exaKey: opts.exaKey,
|
|
4704
|
+
brand: opts.brand,
|
|
4705
|
+
verbose: opts.verbose,
|
|
4706
|
+
git: opts.git,
|
|
4707
|
+
silenceRemoval: opts.silenceRemoval,
|
|
4708
|
+
shorts: opts.shorts,
|
|
4709
|
+
mediumClips: opts.mediumClips,
|
|
4710
|
+
social: opts.social,
|
|
4711
|
+
captions: opts.captions
|
|
4712
|
+
};
|
|
4713
|
+
var queue = [];
|
|
4714
|
+
var processing = false;
|
|
4715
|
+
var shutdownRequested = false;
|
|
4716
|
+
var watcher = null;
|
|
4717
|
+
async function processQueue() {
|
|
4718
|
+
if (processing || queue.length === 0) return;
|
|
4719
|
+
processing = true;
|
|
4720
|
+
try {
|
|
4721
|
+
while (queue.length > 0) {
|
|
4722
|
+
const videoPath = queue.shift();
|
|
4723
|
+
logger_default.info(`Processing video: ${videoPath}`);
|
|
4724
|
+
await processVideoSafe(videoPath);
|
|
4725
|
+
if (onceMode) {
|
|
4726
|
+
logger_default.info("--once flag set, exiting after first video.");
|
|
4727
|
+
await shutdown();
|
|
4728
|
+
return;
|
|
4729
|
+
}
|
|
4730
|
+
if (shutdownRequested) break;
|
|
4731
|
+
}
|
|
4732
|
+
} finally {
|
|
4733
|
+
processing = false;
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
function enqueue(videoPath) {
|
|
4737
|
+
queue.push(videoPath);
|
|
4738
|
+
logger_default.info(`Queued video: ${videoPath} (queue length: ${queue.length})`);
|
|
4739
|
+
processQueue().catch((err) => logger_default.error("Queue processing error:", err));
|
|
4740
|
+
}
|
|
4741
|
+
async function shutdown() {
|
|
4742
|
+
if (shutdownRequested) return;
|
|
4743
|
+
shutdownRequested = true;
|
|
4744
|
+
logger_default.info("Shutting down...");
|
|
4745
|
+
if (watcher) {
|
|
4746
|
+
watcher.stop();
|
|
4747
|
+
}
|
|
4748
|
+
while (processing) {
|
|
4749
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
4750
|
+
}
|
|
4751
|
+
logger_default.info("Goodbye.");
|
|
4752
|
+
process.exit(0);
|
|
106
4753
|
}
|
|
107
4754
|
async function main() {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
process.on('SIGINT', () => shutdown());
|
|
138
|
-
process.on('SIGTERM', () => shutdown());
|
|
4755
|
+
logger_default.info(BANNER);
|
|
4756
|
+
initConfig(cliOptions);
|
|
4757
|
+
if (opts.verbose) setVerbose();
|
|
4758
|
+
validateRequiredKeys();
|
|
4759
|
+
const config2 = getConfig();
|
|
4760
|
+
logger_default.info(`Watch folder: ${config2.WATCH_FOLDER}`);
|
|
4761
|
+
logger_default.info(`Output dir: ${config2.OUTPUT_DIR}`);
|
|
4762
|
+
if (videoArg) {
|
|
4763
|
+
const resolvedPath = path19.resolve(videoArg);
|
|
4764
|
+
logger_default.info(`Processing single video: ${resolvedPath}`);
|
|
4765
|
+
await processVideoSafe(resolvedPath);
|
|
4766
|
+
logger_default.info("Done.");
|
|
4767
|
+
process.exit(0);
|
|
4768
|
+
}
|
|
4769
|
+
watcher = new FileWatcher();
|
|
4770
|
+
watcher.on("new-video", (filePath) => {
|
|
4771
|
+
enqueue(filePath);
|
|
4772
|
+
});
|
|
4773
|
+
watcher.start();
|
|
4774
|
+
if (onceMode) {
|
|
4775
|
+
logger_default.info("Running in --once mode. Will exit after processing the next video.");
|
|
4776
|
+
} else {
|
|
4777
|
+
logger_default.info("Watching for new videos. Press Ctrl+C to stop.");
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
process.on("SIGINT", () => shutdown());
|
|
4781
|
+
process.on("SIGTERM", () => shutdown());
|
|
139
4782
|
main().catch((err) => {
|
|
140
|
-
|
|
141
|
-
|
|
4783
|
+
logger_default.error(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
|
|
4784
|
+
process.exit(1);
|
|
142
4785
|
});
|
|
143
4786
|
//# sourceMappingURL=index.js.map
|