@veolab/discoverylab 1.3.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/chunk-34KRJWZL.js +477 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/{chunk-FIL7IWEL.js → chunk-DGXAP477.js} +1 -1
- package/dist/{chunk-HGWEHWKJ.js → chunk-DKAX5RCX.js} +1 -1
- package/dist/{chunk-7EDIUVIO.js → chunk-EU63HPKT.js} +1 -1
- package/dist/chunk-QMUEC6B5.js +288 -0
- package/dist/{chunk-FNUN7EPB.js → chunk-RCY26WEK.js} +2 -2
- package/dist/{chunk-ZLHIHMSL.js → chunk-SWZIBO2R.js} +1 -1
- package/dist/chunk-VYYAP5G5.js +265 -0
- package/dist/{chunk-VVIOB362.js → chunk-XAMA3JJG.js} +18 -1
- package/dist/{chunk-LXSWDEXV.js → chunk-XWBFSSNB.js} +10224 -389
- package/dist/{chunk-AHVBE25Y.js → chunk-YNLUOZSZ.js} +274 -667
- package/dist/cli.js +33 -31
- package/dist/{db-6WLEVKUV.js → db-745LC5YC.js} +2 -2
- package/dist/document-AE4XI2CP.js +104 -0
- package/dist/{esvp-KVOWYW6G.js → esvp-4LIAU76K.js} +3 -3
- package/dist/{esvp-mobile-GZ5EMYPG.js → esvp-mobile-FKFHDS5Q.js} +4 -4
- package/dist/frames-RCNLSDD6.js +24 -0
- package/dist/{gridCompositor-M3K3LCLZ.js → gridCompositor-VUWBZXYL.js} +262 -3
- package/dist/index.d.ts +32 -0
- package/dist/index.html +1197 -9
- package/dist/index.js +15 -10
- package/dist/notion-api-OXSWOJPZ.js +190 -0
- package/dist/{ocr-QDYNCSPE.js → ocr-FXRLEP66.js} +1 -1
- package/dist/{playwright-VZ7PXDC5.js → playwright-GYKUH34L.js} +3 -3
- package/dist/renderer-D22GCMMD.js +17 -0
- package/dist/{server-6N3KIEGP.js → server-NTT2XGCC.js} +1 -1
- package/dist/server-TKYRIYJ6.js +24 -0
- package/dist/{setup-2SQC5UHJ.js → setup-O6WQQAGP.js} +3 -3
- package/dist/templates/bundle/bundle.js +4 -2
- package/dist/{tools-YGM5HRIB.js → tools-FVVWKEGC.js} +15 -7
- package/package.json +2 -2
- package/skills/knowledge-brain/SKILL.md +64 -0
- package/dist/chunk-MLKGABMK.js +0 -9
- package/dist/server-T5X6GGOO.js +0 -22
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FRAMES_DIR
|
|
3
|
+
} from "./chunk-XAMA3JJG.js";
|
|
4
|
+
import {
|
|
5
|
+
__require
|
|
6
|
+
} from "./chunk-4VNS5WPM.js";
|
|
7
|
+
|
|
8
|
+
// src/core/analyze/frames.ts
|
|
9
|
+
import { spawn, execSync } from "child_process";
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
11
|
+
import { join, basename } from "path";
|
|
12
|
+
function getVideoInfo(videoPath) {
|
|
13
|
+
if (!existsSync(videoPath)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const output = execSync(
|
|
18
|
+
`ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`,
|
|
19
|
+
{ encoding: "utf-8" }
|
|
20
|
+
);
|
|
21
|
+
const data = JSON.parse(output);
|
|
22
|
+
const videoStream = data.streams?.find((s) => s.codec_type === "video");
|
|
23
|
+
if (!videoStream) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
let fps = 30;
|
|
27
|
+
if (videoStream.r_frame_rate) {
|
|
28
|
+
const [num, den] = videoStream.r_frame_rate.split("/");
|
|
29
|
+
fps = den ? parseInt(num, 10) / parseInt(den, 10) : parseFloat(num);
|
|
30
|
+
}
|
|
31
|
+
const duration = parseFloat(data.format?.duration || videoStream.duration || "0");
|
|
32
|
+
const frameCount = parseInt(videoStream.nb_frames, 10) || Math.round(duration * fps);
|
|
33
|
+
return {
|
|
34
|
+
duration,
|
|
35
|
+
frameCount,
|
|
36
|
+
fps,
|
|
37
|
+
width: videoStream.width,
|
|
38
|
+
height: videoStream.height,
|
|
39
|
+
codec: videoStream.codec_name
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error("Failed to get video info:", error);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function extractFrames(options) {
|
|
47
|
+
const { projectId, videoPath, outputFormat = "png" } = options;
|
|
48
|
+
if (!existsSync(videoPath)) {
|
|
49
|
+
return { success: false, error: `Video file not found: ${videoPath}` };
|
|
50
|
+
}
|
|
51
|
+
const framesDir = join(FRAMES_DIR, projectId);
|
|
52
|
+
if (!existsSync(framesDir)) {
|
|
53
|
+
mkdirSync(framesDir, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
const videoInfo = getVideoInfo(videoPath);
|
|
56
|
+
if (!videoInfo) {
|
|
57
|
+
return { success: false, error: "Failed to read video information" };
|
|
58
|
+
}
|
|
59
|
+
const args = buildFFmpegArgs(options, framesDir, videoInfo, outputFormat);
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const proc = spawn("ffmpeg", args);
|
|
62
|
+
let stderr = "";
|
|
63
|
+
proc.stderr.on("data", (data) => {
|
|
64
|
+
stderr += data.toString();
|
|
65
|
+
});
|
|
66
|
+
proc.on("close", (code) => {
|
|
67
|
+
if (code !== 0) {
|
|
68
|
+
resolve({ success: false, error: `FFmpeg failed: ${stderr.slice(-500)}` });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const frames = listExtractedFrames(framesDir, videoInfo.fps, options.fps || 1);
|
|
72
|
+
resolve({
|
|
73
|
+
success: true,
|
|
74
|
+
framesDir,
|
|
75
|
+
frameCount: frames.length,
|
|
76
|
+
frames
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
proc.on("error", (err) => {
|
|
80
|
+
resolve({ success: false, error: err.message });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function buildFFmpegArgs(options, framesDir, videoInfo, outputFormat) {
|
|
85
|
+
const args = ["-y"];
|
|
86
|
+
args.push("-i", options.videoPath);
|
|
87
|
+
if (options.startTime !== void 0) {
|
|
88
|
+
args.push("-ss", options.startTime.toString());
|
|
89
|
+
}
|
|
90
|
+
if (options.endTime !== void 0) {
|
|
91
|
+
args.push("-to", options.endTime.toString());
|
|
92
|
+
}
|
|
93
|
+
if (options.keyFramesOnly) {
|
|
94
|
+
args.push("-vf", "select=eq(pict_type\\,I)");
|
|
95
|
+
args.push("-vsync", "vfr");
|
|
96
|
+
} else if (options.fps) {
|
|
97
|
+
args.push("-vf", `fps=${options.fps}`);
|
|
98
|
+
} else {
|
|
99
|
+
args.push("-vf", "fps=1");
|
|
100
|
+
}
|
|
101
|
+
if (options.maxFrames) {
|
|
102
|
+
args.push("-frames:v", options.maxFrames.toString());
|
|
103
|
+
}
|
|
104
|
+
if (outputFormat === "jpg" && options.quality) {
|
|
105
|
+
args.push("-q:v", options.quality.toString());
|
|
106
|
+
}
|
|
107
|
+
const outputPattern = join(framesDir, `frame-%04d.${outputFormat}`);
|
|
108
|
+
args.push(outputPattern);
|
|
109
|
+
return args;
|
|
110
|
+
}
|
|
111
|
+
function listExtractedFrames(framesDir, videoFps, extractFps) {
|
|
112
|
+
const files = readdirSync(framesDir).filter((f) => f.startsWith("frame-") && (f.endsWith(".png") || f.endsWith(".jpg"))).sort();
|
|
113
|
+
return files.map((filename, index) => {
|
|
114
|
+
const timestamp = index / extractFps;
|
|
115
|
+
const match = filename.match(/frame-(\d+)/);
|
|
116
|
+
const frameNumber = match ? parseInt(match[1], 10) : index + 1;
|
|
117
|
+
return {
|
|
118
|
+
path: join(framesDir, filename),
|
|
119
|
+
frameNumber,
|
|
120
|
+
timestamp,
|
|
121
|
+
filename
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function isBlankFrame(imagePath) {
|
|
126
|
+
try {
|
|
127
|
+
const output = execSync(
|
|
128
|
+
`ffmpeg -i "${imagePath}" -vf "crop=iw*0.8:ih*0.8:iw*0.1:ih*0.1,signalstats" -f null - 2>&1`,
|
|
129
|
+
{ encoding: "utf-8", timeout: 5e3 }
|
|
130
|
+
);
|
|
131
|
+
const match = output.match(/YAVG:\s*([\d.]+)/);
|
|
132
|
+
if (!match) return { isBlank: false, brightness: 128 };
|
|
133
|
+
const brightness = parseFloat(match[1]);
|
|
134
|
+
return {
|
|
135
|
+
isBlank: brightness > 240 || brightness < 15,
|
|
136
|
+
brightness
|
|
137
|
+
};
|
|
138
|
+
} catch {
|
|
139
|
+
return { isBlank: false, brightness: 128 };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function filterBlankFrames(frames) {
|
|
143
|
+
const { unlinkSync } = __require("fs");
|
|
144
|
+
return frames.filter((frame) => {
|
|
145
|
+
if (!existsSync(frame.path)) return false;
|
|
146
|
+
const { isBlank } = isBlankFrame(frame.path);
|
|
147
|
+
if (isBlank) {
|
|
148
|
+
try {
|
|
149
|
+
unlinkSync(frame.path);
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async function detectKeyFrames(videoPath, threshold = 0.3) {
|
|
158
|
+
if (!existsSync(videoPath)) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const output = execSync(
|
|
163
|
+
`ffmpeg -i "${videoPath}" -vf "select='gt(scene,${threshold})',showinfo" -f null - 2>&1`,
|
|
164
|
+
{ encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
|
|
165
|
+
);
|
|
166
|
+
const keyFrames = [];
|
|
167
|
+
const lines = output.split("\n");
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
const match = line.match(/n:\s*(\d+).*pts_time:\s*([\d.]+)/);
|
|
170
|
+
if (match) {
|
|
171
|
+
const frameNumber = parseInt(match[1], 10);
|
|
172
|
+
const timestamp = parseFloat(match[2]);
|
|
173
|
+
const scoreMatch = line.match(/scene:\s*([\d.]+)/);
|
|
174
|
+
const score = scoreMatch ? parseFloat(scoreMatch[1]) : threshold;
|
|
175
|
+
keyFrames.push({
|
|
176
|
+
path: "",
|
|
177
|
+
frameNumber,
|
|
178
|
+
timestamp,
|
|
179
|
+
filename: `keyframe-${frameNumber}.png`,
|
|
180
|
+
score,
|
|
181
|
+
isSceneChange: score > threshold
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return keyFrames;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error("Failed to detect key frames:", error);
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function extractKeyFramesOnly(projectId, videoPath, threshold = 0.3) {
|
|
192
|
+
const keyFrames = await detectKeyFrames(videoPath, threshold);
|
|
193
|
+
if (keyFrames.length === 0) {
|
|
194
|
+
return extractFrames({
|
|
195
|
+
projectId,
|
|
196
|
+
videoPath,
|
|
197
|
+
fps: 1,
|
|
198
|
+
maxFrames: 30
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const framesDir = join(FRAMES_DIR, projectId);
|
|
202
|
+
if (!existsSync(framesDir)) {
|
|
203
|
+
mkdirSync(framesDir, { recursive: true });
|
|
204
|
+
}
|
|
205
|
+
const extractedFrames = [];
|
|
206
|
+
for (let i = 0; i < keyFrames.length; i++) {
|
|
207
|
+
const kf = keyFrames[i];
|
|
208
|
+
const outputPath = join(framesDir, `keyframe-${String(i + 1).padStart(4, "0")}.png`);
|
|
209
|
+
try {
|
|
210
|
+
execSync(
|
|
211
|
+
`ffmpeg -y -ss ${kf.timestamp} -i "${videoPath}" -frames:v 1 "${outputPath}"`,
|
|
212
|
+
{ stdio: "ignore" }
|
|
213
|
+
);
|
|
214
|
+
if (existsSync(outputPath)) {
|
|
215
|
+
extractedFrames.push({
|
|
216
|
+
path: outputPath,
|
|
217
|
+
frameNumber: i + 1,
|
|
218
|
+
timestamp: kf.timestamp,
|
|
219
|
+
filename: basename(outputPath)
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
success: true,
|
|
227
|
+
framesDir,
|
|
228
|
+
frameCount: extractedFrames.length,
|
|
229
|
+
frames: extractedFrames
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function generateThumbnail(videoPath, outputPath, timestamp = 0, width = 320) {
|
|
233
|
+
if (!existsSync(videoPath)) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
execSync(
|
|
238
|
+
`ffmpeg -y -ss ${timestamp} -i "${videoPath}" -vframes 1 -vf "scale=${width}:-1" "${outputPath}"`,
|
|
239
|
+
{ stdio: "ignore" }
|
|
240
|
+
);
|
|
241
|
+
return existsSync(outputPath);
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function compareFrames(frame1, frame2) {
|
|
247
|
+
try {
|
|
248
|
+
const output = execSync(
|
|
249
|
+
`compare -metric RMSE "${frame1}" "${frame2}" null: 2>&1 || true`,
|
|
250
|
+
{ encoding: "utf-8" }
|
|
251
|
+
);
|
|
252
|
+
const match = output.match(/^([\d.]+)/);
|
|
253
|
+
if (match) {
|
|
254
|
+
return parseFloat(match[1]);
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function cleanupFrames(projectId) {
|
|
262
|
+
const framesDir = join(FRAMES_DIR, projectId);
|
|
263
|
+
if (existsSync(framesDir)) {
|
|
264
|
+
const files = readdirSync(framesDir);
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
try {
|
|
267
|
+
const filePath = join(framesDir, file);
|
|
268
|
+
const stat = statSync(filePath);
|
|
269
|
+
if (stat.isFile()) {
|
|
270
|
+
__require("fs").unlinkSync(filePath);
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export {
|
|
279
|
+
getVideoInfo,
|
|
280
|
+
extractFrames,
|
|
281
|
+
isBlankFrame,
|
|
282
|
+
filterBlankFrames,
|
|
283
|
+
detectKeyFrames,
|
|
284
|
+
extractKeyFramesOnly,
|
|
285
|
+
generateThumbnail,
|
|
286
|
+
compareFrames,
|
|
287
|
+
cleanupFrames
|
|
288
|
+
};
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
import {
|
|
6
6
|
DATA_DIR,
|
|
7
7
|
DB_PATH
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-XAMA3JJG.js";
|
|
9
9
|
|
|
10
10
|
// src/mcp/tools/setup.ts
|
|
11
11
|
import { z } from "zod";
|
|
@@ -224,7 +224,7 @@ var setupInitTool = {
|
|
|
224
224
|
inputSchema: z.object({}),
|
|
225
225
|
handler: async () => {
|
|
226
226
|
try {
|
|
227
|
-
const { getDatabase, DATA_DIR: DATA_DIR2, PROJECTS_DIR, EXPORTS_DIR, FRAMES_DIR } = await import("./db-
|
|
227
|
+
const { getDatabase, DATA_DIR: DATA_DIR2, PROJECTS_DIR, EXPORTS_DIR, FRAMES_DIR } = await import("./db-745LC5YC.js");
|
|
228
228
|
getDatabase();
|
|
229
229
|
return createJsonResult({
|
|
230
230
|
message: "DiscoveryLab initialized successfully",
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getBundlePath,
|
|
3
|
+
getTemplate
|
|
4
|
+
} from "./chunk-DKAX5RCX.js";
|
|
5
|
+
import {
|
|
6
|
+
DATA_DIR,
|
|
7
|
+
EXPORTS_DIR
|
|
8
|
+
} from "./chunk-XAMA3JJG.js";
|
|
9
|
+
|
|
10
|
+
// src/core/templates/renderer.ts
|
|
11
|
+
import { existsSync, mkdirSync } from "fs";
|
|
12
|
+
import { execSync } from "child_process";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
var renderJobs = /* @__PURE__ */ new Map();
|
|
15
|
+
var jobCounter = 0;
|
|
16
|
+
function generateJobId() {
|
|
17
|
+
return `render_${Date.now()}_${++jobCounter}`;
|
|
18
|
+
}
|
|
19
|
+
function getOutputDir(projectId) {
|
|
20
|
+
const dir = join(EXPORTS_DIR, projectId);
|
|
21
|
+
if (!existsSync(dir)) {
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
function getLineCount(text) {
|
|
27
|
+
return String(text || "").replace(/\r\n/g, "\n").split("\n").length;
|
|
28
|
+
}
|
|
29
|
+
function shouldPreserveTerminalTabContent(tab, profile) {
|
|
30
|
+
return tab.content.length <= profile.maxCharsPerTab && getLineCount(tab.content) <= profile.maxLinesPerTab;
|
|
31
|
+
}
|
|
32
|
+
function clampTextForTerminalRender(text, profile) {
|
|
33
|
+
const normalized = String(text || "").replace(/\r\n/g, "\n").replace(/\t/g, " ");
|
|
34
|
+
const rawLines = normalized.split("\n").map((line) => line.replace(/\s+$/g, ""));
|
|
35
|
+
const truncatedByLines = rawLines.length > profile.maxLinesPerTab;
|
|
36
|
+
const visibleLines = truncatedByLines ? rawLines.slice(0, profile.maxLinesPerTab - 1) : rawLines.slice(0, profile.maxLinesPerTab);
|
|
37
|
+
if (truncatedByLines) {
|
|
38
|
+
const hiddenLines = Math.max(1, rawLines.length - visibleLines.length);
|
|
39
|
+
visibleLines.push(`... +${hiddenLines} more lines`);
|
|
40
|
+
}
|
|
41
|
+
let output = visibleLines.join("\n").trim();
|
|
42
|
+
if (output.length > profile.maxCharsPerTab) {
|
|
43
|
+
output = `${output.slice(0, Math.max(0, profile.maxCharsPerTab - 12)).trimEnd()}
|
|
44
|
+
...`;
|
|
45
|
+
}
|
|
46
|
+
return output || "// no terminal content";
|
|
47
|
+
}
|
|
48
|
+
function formatCompactBytes(value) {
|
|
49
|
+
const bytes = Number(value);
|
|
50
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "";
|
|
51
|
+
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}MB`;
|
|
52
|
+
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)}KB`;
|
|
53
|
+
return `${Math.round(bytes)}B`;
|
|
54
|
+
}
|
|
55
|
+
function compactTerminalJsonContent(tab, profile) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(tab.content);
|
|
58
|
+
if (!Array.isArray(parsed)) return null;
|
|
59
|
+
const lines = parsed.slice(0, profile.maxJsonItemsPerTab).map((item) => {
|
|
60
|
+
if (!item || typeof item !== "object") return null;
|
|
61
|
+
const record = item;
|
|
62
|
+
const status = record.status ?? "...";
|
|
63
|
+
const duration = Number.isFinite(Number(record.durationMs)) ? `${Math.round(Number(record.durationMs))}ms` : "";
|
|
64
|
+
const size = formatCompactBytes(record.responseSize);
|
|
65
|
+
const rawUrl = typeof record.url === "string" ? record.url : "";
|
|
66
|
+
let displayUrl = rawUrl || tab.route || tab.label;
|
|
67
|
+
try {
|
|
68
|
+
const parsedUrl = new URL(rawUrl);
|
|
69
|
+
displayUrl = parsedUrl.pathname || `${parsedUrl.origin}${parsedUrl.pathname}`;
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
return [status, duration, size, displayUrl].filter(Boolean).join(" ");
|
|
73
|
+
}).filter((line) => !!line);
|
|
74
|
+
if (parsed.length > lines.length) {
|
|
75
|
+
lines.push(`... +${parsed.length - lines.length} more requests`);
|
|
76
|
+
}
|
|
77
|
+
return clampTextForTerminalRender(lines.join("\n"), profile);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function getTemplateRenderOptimizationProfile(templateId, props, realVideoDuration) {
|
|
83
|
+
const totalChars = Array.isArray(props.terminalTabs) ? props.terminalTabs.reduce((sum, tab) => sum + String(tab?.content || "").length, 0) : 0;
|
|
84
|
+
const tabCount = Array.isArray(props.terminalTabs) ? props.terminalTabs.length : 0;
|
|
85
|
+
const isLongVideo = (realVideoDuration || props.videoDuration || 0) >= 30;
|
|
86
|
+
const isHeavyTerminal = totalChars >= 1600 || tabCount >= 6;
|
|
87
|
+
const showcaseAggressive = templateId === "showcase";
|
|
88
|
+
if (isLongVideo || isHeavyTerminal || showcaseAggressive) {
|
|
89
|
+
return {
|
|
90
|
+
maxTabs: showcaseAggressive ? 4 : 5,
|
|
91
|
+
maxCharsPerTab: showcaseAggressive ? 1e3 : isLongVideo ? 1800 : 2200,
|
|
92
|
+
maxLinesPerTab: showcaseAggressive ? 22 : 40,
|
|
93
|
+
maxJsonItemsPerTab: showcaseAggressive ? 6 : 12,
|
|
94
|
+
maxTotalChars: showcaseAggressive ? 3500 : 7e3
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
maxTabs: 6,
|
|
99
|
+
maxCharsPerTab: 2800,
|
|
100
|
+
maxLinesPerTab: 60,
|
|
101
|
+
maxJsonItemsPerTab: 16,
|
|
102
|
+
maxTotalChars: 1e4
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function optimizeTemplatePropsForRender(templateId, props, realVideoDuration) {
|
|
106
|
+
if (!Array.isArray(props.terminalTabs) || props.terminalTabs.length === 0) {
|
|
107
|
+
return { ...props };
|
|
108
|
+
}
|
|
109
|
+
const profile = getTemplateRenderOptimizationProfile(templateId, props, realVideoDuration);
|
|
110
|
+
const terminalTabs = props.terminalTabs.slice(0, profile.maxTabs).map((tab) => {
|
|
111
|
+
if (shouldPreserveTerminalTabContent(tab, profile)) {
|
|
112
|
+
return { ...tab };
|
|
113
|
+
}
|
|
114
|
+
const compactJson = compactTerminalJsonContent(tab, profile);
|
|
115
|
+
return {
|
|
116
|
+
...tab,
|
|
117
|
+
content: compactJson || clampTextForTerminalRender(tab.content, profile)
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
let totalChars = terminalTabs.reduce((sum, tab) => sum + tab.content.length, 0);
|
|
121
|
+
while (totalChars > profile.maxTotalChars && terminalTabs.length > 1) {
|
|
122
|
+
terminalTabs.pop();
|
|
123
|
+
totalChars = terminalTabs.reduce((sum, tab) => sum + tab.content.length, 0);
|
|
124
|
+
}
|
|
125
|
+
if (totalChars > profile.maxTotalChars && terminalTabs.length === 1) {
|
|
126
|
+
const [tab] = terminalTabs;
|
|
127
|
+
const compactJson = compactTerminalJsonContent(tab, profile);
|
|
128
|
+
terminalTabs[0] = {
|
|
129
|
+
...tab,
|
|
130
|
+
content: compactJson || clampTextForTerminalRender(tab.content, profile)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
...props,
|
|
135
|
+
terminalTabs,
|
|
136
|
+
hasNetworkData: terminalTabs.length > 0
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async function startRender(projectId, templateId, props, onProgress) {
|
|
140
|
+
const bundlePath = getBundlePath();
|
|
141
|
+
if (!bundlePath) {
|
|
142
|
+
throw new Error("Templates not installed. Bundle not found at ~/.discoverylab/templates/bundle/");
|
|
143
|
+
}
|
|
144
|
+
const template = getTemplate(templateId);
|
|
145
|
+
if (!template) {
|
|
146
|
+
throw new Error(`Template "${templateId}" not found in manifest`);
|
|
147
|
+
}
|
|
148
|
+
const jobId = generateJobId();
|
|
149
|
+
const outputDir = getOutputDir(projectId);
|
|
150
|
+
const outputPath = join(outputDir, `template-${templateId}.mp4`);
|
|
151
|
+
const job = {
|
|
152
|
+
id: jobId,
|
|
153
|
+
projectId,
|
|
154
|
+
templateId,
|
|
155
|
+
status: "queued",
|
|
156
|
+
progress: 0,
|
|
157
|
+
outputPath,
|
|
158
|
+
startedAt: Date.now()
|
|
159
|
+
};
|
|
160
|
+
renderJobs.set(jobId, job);
|
|
161
|
+
renderAsync(job, bundlePath, templateId, template.compositionId, props, onProgress).catch((err) => {
|
|
162
|
+
job.status = "error";
|
|
163
|
+
job.error = err.message;
|
|
164
|
+
job.completedAt = Date.now();
|
|
165
|
+
});
|
|
166
|
+
return job;
|
|
167
|
+
}
|
|
168
|
+
async function renderAsync(job, bundlePath, templateId, compositionId, props, onProgress) {
|
|
169
|
+
job.status = "rendering";
|
|
170
|
+
const originalCwd = process.cwd();
|
|
171
|
+
try {
|
|
172
|
+
process.chdir(DATA_DIR);
|
|
173
|
+
const { selectComposition, renderMedia } = await import("@remotion/renderer");
|
|
174
|
+
const realVideoDuration = getVideoDuration(props.videoUrl);
|
|
175
|
+
const optimizedProps = optimizeTemplatePropsForRender(templateId, props, realVideoDuration);
|
|
176
|
+
if (realVideoDuration && realVideoDuration > 0) {
|
|
177
|
+
optimizedProps.videoDuration = realVideoDuration;
|
|
178
|
+
}
|
|
179
|
+
const composition = await selectComposition({
|
|
180
|
+
serveUrl: bundlePath,
|
|
181
|
+
id: compositionId,
|
|
182
|
+
inputProps: optimizedProps
|
|
183
|
+
});
|
|
184
|
+
const fps = composition.fps || 30;
|
|
185
|
+
let durationOverride;
|
|
186
|
+
if (realVideoDuration && realVideoDuration > 0) {
|
|
187
|
+
const videoFrames = Math.ceil(realVideoDuration * fps);
|
|
188
|
+
if (videoFrames > composition.durationInFrames) {
|
|
189
|
+
durationOverride = videoFrames;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
await renderMedia({
|
|
193
|
+
composition: durationOverride ? { ...composition, durationInFrames: durationOverride } : composition,
|
|
194
|
+
serveUrl: bundlePath,
|
|
195
|
+
codec: "h264",
|
|
196
|
+
outputLocation: job.outputPath,
|
|
197
|
+
inputProps: optimizedProps,
|
|
198
|
+
onProgress: ({ progress }) => {
|
|
199
|
+
job.progress = progress;
|
|
200
|
+
onProgress?.(progress);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
job.status = "done";
|
|
204
|
+
job.progress = 1;
|
|
205
|
+
job.completedAt = Date.now();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
job.status = "error";
|
|
208
|
+
job.error = err.message;
|
|
209
|
+
job.completedAt = Date.now();
|
|
210
|
+
throw err;
|
|
211
|
+
} finally {
|
|
212
|
+
process.chdir(originalCwd);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function getVideoDuration(videoUrl) {
|
|
216
|
+
try {
|
|
217
|
+
let filePath = videoUrl;
|
|
218
|
+
if (videoUrl.startsWith("http")) {
|
|
219
|
+
const url = new URL(videoUrl);
|
|
220
|
+
const pathParam = url.searchParams.get("path");
|
|
221
|
+
if (pathParam) {
|
|
222
|
+
filePath = decodeURIComponent(pathParam);
|
|
223
|
+
} else {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!existsSync(filePath)) return null;
|
|
228
|
+
const output = execSync(
|
|
229
|
+
`ffprobe -v quiet -print_format json -show_format "${filePath}"`,
|
|
230
|
+
{ encoding: "utf-8", timeout: 1e4 }
|
|
231
|
+
);
|
|
232
|
+
const data = JSON.parse(output);
|
|
233
|
+
const duration = parseFloat(data.format?.duration || "0");
|
|
234
|
+
return duration > 0 ? duration : null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function getRenderJob(jobId) {
|
|
240
|
+
return renderJobs.get(jobId) ?? null;
|
|
241
|
+
}
|
|
242
|
+
function getProjectRenderJobs(projectId) {
|
|
243
|
+
return Array.from(renderJobs.values()).filter((j) => j.projectId === projectId);
|
|
244
|
+
}
|
|
245
|
+
function getCachedRender(projectId, templateId) {
|
|
246
|
+
const outputPath = join(EXPORTS_DIR, projectId, `template-${templateId}.mp4`);
|
|
247
|
+
if (existsSync(outputPath)) return outputPath;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
function cleanupRenderJobs(maxAge = 36e5) {
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
for (const [id, job] of renderJobs) {
|
|
253
|
+
if (job.completedAt && now - job.completedAt > maxAge) {
|
|
254
|
+
renderJobs.delete(id);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export {
|
|
260
|
+
startRender,
|
|
261
|
+
getRenderJob,
|
|
262
|
+
getProjectRenderJobs,
|
|
263
|
+
getCachedRender,
|
|
264
|
+
cleanupRenderJobs
|
|
265
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
__export
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-4VNS5WPM.js";
|
|
4
4
|
|
|
5
5
|
// src/db/index.ts
|
|
6
6
|
import Database from "better-sqlite3";
|
|
@@ -58,6 +58,11 @@ var projects = sqliteTable("projects", {
|
|
|
58
58
|
// AI-generated requirements from linked content
|
|
59
59
|
taskTestMap: text("task_test_map"),
|
|
60
60
|
// AI-generated test map from linked content
|
|
61
|
+
// Marketing / Export
|
|
62
|
+
marketingTitle: text("marketing_title"),
|
|
63
|
+
// Cleaned title without device names
|
|
64
|
+
marketingDescription: text("marketing_description"),
|
|
65
|
+
// AI-generated marketing-quality description
|
|
61
66
|
// Status
|
|
62
67
|
status: text("status").default("draft"),
|
|
63
68
|
// 'draft' | 'analyzed' | 'exported' | 'archived'
|
|
@@ -232,6 +237,18 @@ function createTables(sqlite) {
|
|
|
232
237
|
sqlite.exec(`ALTER TABLE projects ADD COLUMN ocr_confidence REAL`);
|
|
233
238
|
} catch (e) {
|
|
234
239
|
}
|
|
240
|
+
try {
|
|
241
|
+
sqlite.exec(`ALTER TABLE projects ADD COLUMN marketing_title TEXT`);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
sqlite.exec(`ALTER TABLE projects ADD COLUMN marketing_description TEXT`);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
sqlite.exec(`UPDATE projects SET marketing_title = name WHERE marketing_title IS NULL`);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
}
|
|
235
252
|
sqlite.exec(`
|
|
236
253
|
CREATE TABLE IF NOT EXISTS project_exports (
|
|
237
254
|
id TEXT PRIMARY KEY,
|