@lalalic/markcut 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.env.example +27 -0
  2. package/.github/user-steer.md +9 -0
  3. package/.vscode/settings.json +3 -0
  4. package/AGENTS.md +271 -0
  5. package/README.md +219 -0
  6. package/SKILL.md +209 -0
  7. package/docs/dynamic-components.md +191 -0
  8. package/docs/edit-mode.md +220 -0
  9. package/docs/json-descriptive.md +539 -0
  10. package/docs/label-mode.md +110 -0
  11. package/docs/markdown-descriptive.md +751 -0
  12. package/docs/templates.md +52 -0
  13. package/package.json +64 -0
  14. package/remotion.config.ts +5 -0
  15. package/scripts/artlist-dl.mjs +190 -0
  16. package/scripts/build-pipeline.sh +19 -0
  17. package/scripts/build-player.sh +20 -0
  18. package/src/Root.tsx +55 -0
  19. package/src/config.mjs +88 -0
  20. package/src/context/EventContext.tsx +168 -0
  21. package/src/context/index.tsx +42 -0
  22. package/src/descriptive/compiler.test.ts +1135 -0
  23. package/src/descriptive/compiler.ts +1230 -0
  24. package/src/descriptive/dsl.ts +455 -0
  25. package/src/descriptive/markdown.test.ts +866 -0
  26. package/src/descriptive/markdown.ts +674 -0
  27. package/src/descriptive/resolve.test.ts +951 -0
  28. package/src/descriptive/resolve.ts +891 -0
  29. package/src/entry.tsx +163 -0
  30. package/src/index.ts +4 -0
  31. package/src/player/browser.tsx +356 -0
  32. package/src/player/bundle/player.js +60259 -0
  33. package/src/player/bundler.mjs +269 -0
  34. package/src/player/label-server.mjs +599 -0
  35. package/src/player/pipeline.mjs +11123 -0
  36. package/src/player/pipeline.ts +117 -0
  37. package/src/player/server-shared.mjs +144 -0
  38. package/src/player/server.mjs +1006 -0
  39. package/src/render/cli-tools.ts +177 -0
  40. package/src/render/cli.mjs +628 -0
  41. package/src/schema/index.ts +259 -0
  42. package/src/types/Audio.tsx +56 -0
  43. package/src/types/Component.tsx +135 -0
  44. package/src/types/Effect.tsx +88 -0
  45. package/src/types/Folder.tsx +180 -0
  46. package/src/types/FrameSyncStyle.tsx +51 -0
  47. package/src/types/Image.tsx +51 -0
  48. package/src/types/Include.tsx +394 -0
  49. package/src/types/Map.tsx +252 -0
  50. package/src/types/Rhythm.tsx +58 -0
  51. package/src/types/Scene.tsx +42 -0
  52. package/src/types/Subtitle.tsx +218 -0
  53. package/src/types/Video.tsx +70 -0
  54. package/src/types/keyframes.ts +454 -0
  55. package/src/utils/__tests__/vtt.test.ts +129 -0
  56. package/src/utils/index.ts +168 -0
  57. package/src/utils/tween.ts +118 -0
  58. package/src/vision/cli.mjs +1187 -0
  59. package/src/vision/vision_prompts.md +67 -0
  60. package/tests/dsl.test.ts +317 -0
  61. package/tests/fixtures/audio.json +25 -0
  62. package/tests/fixtures/basic.json +27 -0
  63. package/tests/fixtures/component-all.json +38 -0
  64. package/tests/fixtures/components.json +38 -0
  65. package/tests/fixtures/effects.json +64 -0
  66. package/tests/fixtures/full.json +51 -0
  67. package/tests/fixtures/map.json +23 -0
  68. package/tests/fixtures/md/all-nodes.md +28 -0
  69. package/tests/fixtures/md/basic.md +6 -0
  70. package/tests/fixtures/md/component-imports.md +20 -0
  71. package/tests/fixtures/md/edge-cases.md +33 -0
  72. package/tests/fixtures/md/effects.md +17 -0
  73. package/tests/fixtures/md/frontmatter.md +20 -0
  74. package/tests/fixtures/md/full-feature.md +58 -0
  75. package/tests/fixtures/md/imports-block.md +19 -0
  76. package/tests/fixtures/md/include-main.md +11 -0
  77. package/tests/fixtures/md/include-sub.md +25 -0
  78. package/tests/fixtures/md/jsx-code-fence.md +21 -0
  79. package/tests/fixtures/md/map.md +11 -0
  80. package/tests/fixtures/md/nested-scenes.md +25 -0
  81. package/tests/fixtures/md/rhythm.md +17 -0
  82. package/tests/fixtures/md/scenes.md +16 -0
  83. package/tests/fixtures/md/tween.md +11 -0
  84. package/tests/fixtures/md/vars-test.md +6 -0
  85. package/tests/fixtures/scenes.json +40 -0
  86. package/tests/fixtures/subtitle.json +59 -0
  87. package/tests/fixtures/subvideo.json +59 -0
  88. package/tests/fixtures/templates/courseware.md +351 -0
  89. package/tests/fixtures/tween-visual.json +28 -0
  90. package/tests/fixtures/video-series.json +54 -0
  91. package/tests/md-descriptive.test.ts +742 -0
  92. package/tests/render.test.ts +985 -0
  93. package/tests/schema.test.ts +68 -0
  94. package/tests/server.test.ts +308 -0
  95. package/tests/utils.ts +391 -0
  96. package/tests/vitest.config.ts +18 -0
  97. package/tests/vitest.integration.config.ts +16 -0
  98. package/tsconfig.json +20 -0
@@ -0,0 +1,1187 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `markcut vision <folder>` — Vision understanding CLI.
4
+ *
5
+ * Two modes:
6
+ *
7
+ * 1. **Metadata** (default): Extract media metadata (dimensions, created,
8
+ * GPS, duration) into metadata.json. No AI, no normalization.
9
+ *
10
+ * 2. **Label + full pipeline** (`--label`):
11
+ * a) Extract metadata into metadata.json (if not already done)
12
+ * b) Build a preview video JSON from metadata (root.children ordered
13
+ * by created time), open label preview server, let the user
14
+ * annotate each scene, save user hints into metadata.json, wait close
15
+ * c) Normalize media to token-efficient sizes
16
+ * d) Image perception: ITT with userHint injected into prompts
17
+ * e) Video perception: VTT (overall description) + STT → VTT subtitles
18
+ * → segment (by userHint boundaries + VTT cues, or by vision)
19
+ * → run agent CLI on each segment clip
20
+ * f) Save complete metadata.json with metadata + userHint + perception
21
+ *
22
+ * Options:
23
+ * --label Run full pipeline: preview → label → normalize → percept → segments
24
+ * --agent <tmpl> Custom text LLM CLI for detect-scenes ({prompt})
25
+ * --itt <tmpl> Custom ITT CLI template with {input}, {prompt}
26
+ * --vtt <tmpl> Custom VTT CLI template with {input}, {prompt}
27
+ * --stt <tmpl> Custom STT CLI template with {input}, {output}
28
+ * --prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
29
+ * --vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
30
+ * --context "text" Background context about people/places (injected into prompts)
31
+ * --pick <files> Comma-separated filenames to process
32
+ * --skip-stt Skip speech-to-text for videos
33
+ * --dry-run Show what would be processed without running AI
34
+ * --show-prompts Print the prompts file and exit
35
+ * --show-clis Print the default ITT/VTT/STT CLI templates
36
+ * --help Show this help
37
+ *
38
+ * Prompt overrides:
39
+ * --<prompt-name> "text" Override any prompt template from vision_prompts.md
40
+ */
41
+
42
+ import { execSync, spawn } from "node:child_process";
43
+ import { createHash } from "node:crypto";
44
+ import {
45
+ existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync, rmSync,
46
+ } from "node:fs";
47
+ import { join, resolve, dirname, basename, extname } from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+ import {
50
+ IMAGE_EXTS, VIDEO_EXTS,
51
+ MAX_IMAGE_DIMENSION, MAX_VIDEO_DURATION, MAX_VIDEO_DIMENSION,
52
+ DEFAULT_ITT_CLI, DEFAULT_VTT_SAMPLE_INTERVAL, DEFAULT_VTT_CLI, DEFAULT_STT_CLI, DEFAULT_AGENT_CLI,
53
+ } from "../config.mjs";
54
+
55
+ // ── Paths ─────────────────────────────────────────────────────────────────
56
+
57
+ const __filename = fileURLToPath(import.meta.url);
58
+ const __dirname = dirname(__filename);
59
+
60
+ // ── Constants ─────────────────────────────────────────────────────────────
61
+
62
+ const PROMPTS_FILE = join(__dirname, "vision_prompts.md");
63
+
64
+ // ── Helpers ───────────────────────────────────────────────────────────────
65
+
66
+ function emitInfo(msg) { console.error(msg); }
67
+ function emitSuccess(msg) { console.error(`āœ… ${msg}`); }
68
+ function emitWarn(msg) { console.error(`āš ļø ${msg}`); }
69
+ function emitError(msg) { console.error(`āŒ ${msg}`); }
70
+
71
+ function run(cmd, opts = {}) {
72
+ return execSync(cmd, {
73
+ encoding: "utf-8",
74
+ stdio: ["pipe", "pipe", "pipe"],
75
+ timeout: 300_000,
76
+ ...opts,
77
+ });
78
+ }
79
+
80
+ function fileFingerprint(filePath) {
81
+ try {
82
+ const s = statSync(filePath);
83
+ return `${s.mtimeMs}:${s.size}`;
84
+ } catch { return "0:0"; }
85
+ }
86
+
87
+ function perceptionCacheKey(filePath, actualCmd, type) {
88
+ const parts = { file: fileFingerprint(filePath), type, cmd: actualCmd };
89
+ return createHash("sha1").update(JSON.stringify(parts)).digest("hex").slice(0, 16);
90
+ }
91
+
92
+ function loadMetadata(folder) {
93
+ const path = join(folder, "metadata.json");
94
+ try {
95
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
96
+ const cacheMap = {};
97
+ for (const [key, entry] of Object.entries(raw)) {
98
+ if (entry._cache && entry.perception?.desc) {
99
+ cacheMap[entry._cache] = entry.perception || {};
100
+ }
101
+ }
102
+ return { results: raw, cacheMap };
103
+ } catch {
104
+ return { results: {}, cacheMap: {} };
105
+ }
106
+ }
107
+
108
+ function shQuote(s) {
109
+ return `'${s.replace(/'/g, "'\\''")}'`;
110
+ }
111
+
112
+ function loadPrompts(filePath) {
113
+ const prompts = new Map();
114
+ if (!existsSync(filePath)) {
115
+ emitWarn(`Prompts file not found: ${filePath}`);
116
+ return prompts;
117
+ }
118
+ const content = readFileSync(filePath, "utf-8");
119
+ const sectionRe = /^##\s+(\S[\w-]*)\s*\n[\s\S]*?^~~~md\n([\s\S]*?)~~~\s*$/gm;
120
+ let match;
121
+ while ((match = sectionRe.exec(content)) !== null) {
122
+ const name = match[1].trim();
123
+ const template = match[2].trim();
124
+ if (name && template) prompts.set(name, template);
125
+ }
126
+ return prompts;
127
+ }
128
+
129
+ function substituteTemplate(tmpl, vars) {
130
+ let result = tmpl;
131
+ for (const [key, value] of Object.entries(vars)) {
132
+ result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
133
+ }
134
+ return result;
135
+ }
136
+
137
+ function getPrompt(prompts, name) {
138
+ const p = prompts.get(name);
139
+ if (!p) throw new Error(`Missing prompt: "${name}" — check vision_prompts.md`);
140
+ return p;
141
+ }
142
+
143
+ // ── Media Scanning ────────────────────────────────────────────────────────
144
+
145
+ function scanMedia(folder) {
146
+ const images = [];
147
+ const videos = [];
148
+ const all = readdirSync(folder).sort();
149
+ for (const name of all) {
150
+ const full = join(folder, name);
151
+ const stat = statSync(full);
152
+ if (!stat.isFile()) continue;
153
+ const ext = extname(name).toLowerCase();
154
+ if (IMAGE_EXTS.has(ext)) images.push(full);
155
+ else if (VIDEO_EXTS.has(ext)) videos.push(full);
156
+ }
157
+ return { images, videos };
158
+ }
159
+
160
+ // ── Metadata Extraction ───────────────────────────────────────────────────
161
+
162
+ function extractMetadata(filePath) {
163
+ let width = 0, height = 0, created = null, duration = null;
164
+ let location = null;
165
+
166
+ // Try exiftool first
167
+ try {
168
+ const cmd = `exiftool -json ${shQuote(filePath)} 2>/dev/null`;
169
+ const out = run(cmd, { timeout: 30_000 });
170
+ const items = JSON.parse(out);
171
+ if (items && items.length > 0) {
172
+ const tag = items[0];
173
+ width = parseInt(tag.ImageWidth || tag.ExifImageWidth || tag.Width || 0, 10);
174
+ height = parseInt(tag.ImageHeight || tag.ExifImageHeight || tag.Height || 0, 10);
175
+ const dateStr = tag.CreateDate || tag.DateTimeOriginal || tag.MediaCreateDate || "";
176
+ if (dateStr) {
177
+ const parts = dateStr.split(" ");
178
+ const datePart = (parts[0] || "").replace(/:/g, "-");
179
+ const timePart = parts[1] || "";
180
+ created = datePart + (timePart ? "T" + timePart + "Z" : "Z");
181
+ }
182
+ if (tag.Duration) {
183
+ const dur = parseFloat(tag.Duration);
184
+ if (!isNaN(dur)) duration = dur;
185
+ }
186
+ let lat = null, lng = null;
187
+ const iso = tag["QuickTime:ISO6709"] || tag["com.apple.quicktime.location.ISO6709"] || "";
188
+ if (iso) {
189
+ const gps = parseISO6709(iso);
190
+ if (gps) { lat = gps.lat; lng = gps.lng; }
191
+ }
192
+ if (lat == null || lng == null) {
193
+ lat = tryParseGPS(tag.GPSLatitude, tag.GPSLatitudeRef);
194
+ lng = tryParseGPS(tag.GPSLongitude, tag.GPSLongitudeRef);
195
+ }
196
+ if (lat == null || lng == null) {
197
+ lat = parseFloat(tag.GPSLatitude || "");
198
+ lng = parseFloat(tag.GPSLongitude || "");
199
+ if (!isNaN(lat) && (tag.GPSLatitudeRef === "S" || tag.GPSLatitudeRef === "South")) lat = -lat;
200
+ if (!isNaN(lng) && (tag.GPSLongitudeRef === "W" || tag.GPSLongitudeRef === "West")) lng = -lng;
201
+ }
202
+ if (!isNaN(lat) && !isNaN(lng)) {
203
+ location = { lat, lng, place: null };
204
+ }
205
+ }
206
+ } catch { /* fall through */ }
207
+
208
+ // Fallback: ffprobe
209
+ if (!width || !height) {
210
+ try {
211
+ const probe = JSON.parse(run(
212
+ `ffprobe -v quiet -print_format json -show_format -show_streams ${shQuote(filePath)}`,
213
+ ));
214
+ const streams = probe.streams || [];
215
+ const format = probe.format || {};
216
+ const videoStream = streams.find((s) => s.codec_type === "video") || {};
217
+ width = width || videoStream.width || 0;
218
+ height = height || videoStream.height || 0;
219
+ duration = duration || (format.duration ? parseFloat(format.duration) : null);
220
+ created = created || format.tags?.creation_time || videoStream.tags?.creation_time || null;
221
+ if (!location && format.tags) {
222
+ const iso = format.tags["com.apple.quicktime.location.ISO6709"] || "";
223
+ if (iso) { const gps = parseISO6709(iso); if (gps) location = gps; }
224
+ }
225
+ if (!location && format.tags) {
226
+ const glat = parseFloat(format.tags.GPSLatitude || format.tags["location.lat"] || format.tags.lat || "");
227
+ const glng = parseFloat(format.tags.GPSLongitude || format.tags["location.lng"] || format.tags.lng || "");
228
+ if (!isNaN(glat) && !isNaN(glng)) {
229
+ let lat = glat, lng = glng;
230
+ if (format.tags.GPSLatitudeRef === "S" || format.tags.GPSLatitudeRef === "South") lat = -lat;
231
+ if (format.tags.GPSLongitudeRef === "W" || format.tags.GPSLongitudeRef === "West") lng = -lng;
232
+ location = { lat, lng, place: null };
233
+ }
234
+ }
235
+ } catch { emitWarn(`ffprobe also failed on: ${filePath}`); }
236
+ }
237
+ return { width, height, created, location, duration };
238
+ }
239
+
240
+ function parseISO6709(str) {
241
+ const m = str.match(/^([+-]\d+\.?\d*)([+-]\d+\.?\d*)/);
242
+ if (!m) return null;
243
+ const lat = parseFloat(m[1]);
244
+ const lng = parseFloat(m[2]);
245
+ if (isNaN(lat) || isNaN(lng)) return null;
246
+ return { lat, lng, place: null };
247
+ }
248
+
249
+ function tryParseGPS(val, ref) {
250
+ if (!val) return null;
251
+ const s = String(val).trim();
252
+ let dec = NaN;
253
+ let negative = false;
254
+ const hemiMatch = s.match(/[NSnsEeWw]\s*$/);
255
+ if (hemiMatch) {
256
+ const h = hemiMatch[0].toUpperCase();
257
+ if (h === "S" || h === "W") negative = true;
258
+ }
259
+ const direct = parseFloat(s);
260
+ if (!isNaN(direct) && s.indexOf("deg") === -1) {
261
+ dec = direct;
262
+ } else {
263
+ const dms = s.match(/([+-]?\d+(?:\.\d+)?)\s*deg\s*(\d+(?:\.\d+)?)\s*'\s*(\d+(?:\.\d+)?)/);
264
+ if (dms) { dec = parseFloat(dms[1]) + parseFloat(dms[2]) / 60 + parseFloat(dms[3]) / 3600; }
265
+ }
266
+ if (!isNaN(dec)) {
267
+ if (ref) {
268
+ if (ref === "S" || ref === "South" || ref === "W" || ref === "West" || ref.startsWith("-")) dec = -dec;
269
+ } else if (negative) { dec = -dec; }
270
+ return dec;
271
+ }
272
+ return null;
273
+ }
274
+
275
+ // ── Media Normalization ───────────────────────────────────────────────────
276
+
277
+ function normalizeImage(srcPath, normDir, maxDim = MAX_IMAGE_DIMENSION) {
278
+ const srcExt = extname(srcPath);
279
+ const outName = `${basename(srcPath, srcExt)}_${maxDim}.jpg`;
280
+ const outPath = join(normDir, outName);
281
+ if (existsSync(outPath)) return outPath;
282
+ const filter = `scale='min(${maxDim},iw)':'min(${maxDim},ih)':force_original_aspect_ratio=decrease`;
283
+ const cmd = `ffmpeg -y -i ${shQuote(srcPath)} -vf ${shQuote(filter)} -q:v 3 -update 1 ${shQuote(outPath)}`;
284
+ try { run(cmd); return outPath; }
285
+ catch (e) { emitWarn(`Failed to normalize image ${basename(srcPath)}: ${e.message}`); return srcPath; }
286
+ }
287
+
288
+ function normalizeVideo(srcPath, normDir, duration, maxDim = MAX_VIDEO_DIMENSION, maxDur = MAX_VIDEO_DURATION, maxSamples = 60) {
289
+ const srcExt = extname(srcPath); // Keep original case for basename stripping
290
+ const ext = srcExt.toLowerCase() || ".mp4";
291
+ const base = basename(srcPath, srcExt);
292
+ const totalDur = duration || getVideoDuration(srcPath);
293
+
294
+ if (totalDur <= maxDur) {
295
+ const timeHint = `0to${Math.floor(totalDur)}`;
296
+ const outName = `${base}_${timeHint}.mp4`;
297
+ const outPath = join(normDir, outName);
298
+ if (existsSync(outPath)) return { path: outPath, trimmedDuration: totalDur, timeHint };
299
+ const filter = `scale='min(${maxDim},iw)':'min(${maxDim},ih)':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2':-1:-1`;
300
+ const cmd = `ffmpeg -y -i ${shQuote(srcPath)} -t ${totalDur} -vf ${shQuote(filter)} -c:v libx264 -preset fast -crf 28 -c:a aac -b:a 64k ${shQuote(outPath)}`;
301
+ try { run(cmd, { timeout: 600_000 }); return { path: outPath, trimmedDuration: totalDur, timeHint }; }
302
+ catch (e) { emitWarn(`Failed to normalize video ${basename(srcPath)}: ${e.message}`); return { path: srcPath, trimmedDuration: totalDur, timeHint }; }
303
+ }
304
+
305
+ const timeHint = `full`;
306
+ const outName = `${base}_${timeHint}.mp4`;
307
+ const outPath = join(normDir, outName);
308
+ if (existsSync(outPath)) return { path: outPath, trimmedDuration: totalDur, timeHint };
309
+
310
+ const frameCount = getVideoFrameCount(srcPath);
311
+ const step = Math.max(1, Math.floor(frameCount / maxSamples));
312
+ const actualSamples = Math.min(maxSamples, Math.ceil(frameCount / step));
313
+ const filter = `select='not(mod(n,${step}))',setpts=N/TB,scale='min(${maxDim},iw)':'min(${maxDim},ih)':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2':-1:-1`;
314
+ const cmd = `ffmpeg -y -i ${shQuote(srcPath)} -vf ${shQuote(filter)} -c:v libx264 -preset fast -crf 28 -an -t ${actualSamples} -r 1 ${shQuote(outPath)}`;
315
+ try {
316
+ emitInfo(` Sampling ${actualSamples} frames across ${totalDur.toFixed(1)}s video`);
317
+ run(cmd, { timeout: 600_000 });
318
+ return { path: outPath, trimmedDuration: totalDur, timeHint };
319
+ } catch (e) { emitWarn(`Failed to normalize video ${basename(srcPath)}: ${e.message}`); return { path: srcPath, trimmedDuration: totalDur, timeHint }; }
320
+ }
321
+
322
+ function getVideoDuration(filePath) {
323
+ try { return parseFloat(run(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${shQuote(filePath)}`).trim()) || 0; }
324
+ catch { return 0; }
325
+ }
326
+
327
+ function getVideoFrameCount(filePath) {
328
+ // Fast path: try nb_frames from container metadata (no decoding)
329
+ try {
330
+ const n = parseInt(run(`ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of default=noprint_wrappers=1:nokey=1 ${shQuote(filePath)}`, { timeout: 15_000 }).trim(), 10);
331
+ if (!isNaN(n) && n > 0) return n;
332
+ } catch {}
333
+ // Fallback: estimate from duration Ɨ fps (much faster than -count_frames)
334
+ try {
335
+ const dur = getVideoDuration(filePath);
336
+ const fpsRaw = run(`ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 ${shQuote(filePath)}`, { timeout: 15_000 }).trim();
337
+ if (fpsRaw && dur > 0) {
338
+ const parts = fpsRaw.split("/");
339
+ const fps = parts.length === 2 ? parseInt(parts[0], 10) / parseInt(parts[1], 10) : parseFloat(fpsRaw);
340
+ if (fps > 0) return Math.round(dur * fps);
341
+ }
342
+ } catch {}
343
+ return 0;
344
+ }
345
+
346
+ /**
347
+ * Detect visual scene changes using ffprobe's scene detection filter.
348
+ * Returns an array of boundary timestamps in milliseconds.
349
+ * Uses `select='gt(scene,THRESHOLD)'` to find shot boundaries.
350
+ */
351
+ function detectSceneChanges(filePath, threshold = 0.3) {
352
+ try {
353
+ const cmd = `ffmpeg -i ${shQuote(filePath)} -vf "select='gt(scene,${threshold})',showinfo" -vsync vfr -f null - 2>&1`;
354
+ const out = run(cmd, { timeout: 120_000 });
355
+ const times = [];
356
+ const re = /pts_time:(\d+\.?\d*)/g;
357
+ let m;
358
+ while ((m = re.exec(out)) !== null) {
359
+ const t = parseFloat(m[1]);
360
+ if (!isNaN(t) && t > 0) times.push(Math.round(t * 1000));
361
+ }
362
+ // Deduplicate within 500ms window
363
+ const unique = [];
364
+ for (const t of times.sort((a, b) => a - b)) {
365
+ if (unique.length === 0 || t - unique[unique.length - 1] > 500) unique.push(t);
366
+ }
367
+ return unique;
368
+ } catch { return []; }
369
+ }
370
+
371
+ // ── ITT / VTT / STT ───────────────────────────────────────────────────────
372
+
373
+ function runITT(inputPaths, promptText, ittCli) {
374
+ const paths = Array.isArray(inputPaths) ? inputPaths : [inputPaths];
375
+ const tmpl = ittCli || DEFAULT_ITT_CLI;
376
+ const inputStr = paths.map(p => `${shQuote(p)}`).join(" ");
377
+ const cmd = substituteTemplate(tmpl, { input: inputStr, prompt: promptText });
378
+ try { return run(cmd).trim(); }
379
+ catch (e) { emitWarn(`ITT failed for ${basename(paths[0])}: ${e.message}`); return ""; }
380
+ }
381
+
382
+ function extractVideoFrames(videoPath, intervalSeconds = DEFAULT_VTT_SAMPLE_INTERVAL) {
383
+ const tmpDir = join(dirname(videoPath), ".pi-vtt");
384
+ mkdirSync(tmpDir, { recursive: true });
385
+ let duration = getVideoDuration(videoPath);
386
+ // If duration is unavailable (e.g. segmented clip without moov metadata), try adding genpts
387
+ if (duration <= 0) {
388
+ try { run(`ffprobe -v error -fflags +genpts -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${shQuote(videoPath)}`, { timeout: 30_000 }); }
389
+ catch {}
390
+ duration = getVideoDuration(videoPath);
391
+ }
392
+ const n = Math.max(5, Math.min(10, Math.ceil((duration || 5) / intervalSeconds)));
393
+ const baseName = basename(videoPath, extname(videoPath));
394
+ const framePattern = join(tmpDir, `${baseName}_%03d.jpg`);
395
+ const fps = n / Math.max(duration || 5, 1);
396
+ try { run(`ffmpeg -y -fflags +genpts -i ${shQuote(videoPath)} -vf "fps=${fps},scale=360:-1" -q:v 3 ${shQuote(framePattern)}`, { timeout: 120_000 }); }
397
+ catch (e) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} throw new Error(`Frame extraction failed: ${e.message}`); }
398
+ const frames = readdirSync(tmpDir).filter((f) => f.startsWith(baseName) && f.endsWith(".jpg")).sort().map((f) => join(tmpDir, f));
399
+ if (frames.length === 0) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} throw new Error("No frames extracted from video"); }
400
+ return { frames, cleanup: () => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} } };
401
+ }
402
+
403
+ function runVTT(videoPath, promptText, vttCli, ittCli, sampleInterval = DEFAULT_VTT_SAMPLE_INTERVAL) {
404
+ if (vttCli) {
405
+ const tryRun = () => { const cmd = substituteTemplate(vttCli, { input: shQuote(videoPath), prompt: promptText }); return run(cmd, { timeout: 600_000 }).trim(); };
406
+ for (let attempt = 0; attempt < 2; attempt++) {
407
+ try { return tryRun(); } catch (e) {
408
+ const msg = e.message || "";
409
+ if (attempt === 0 && msg.includes("Cannot open video") || msg.includes("moov atom not found")) {
410
+ emitWarn(` Corrupted video file, removing and retrying...`);
411
+ try { rmSync(videoPath, { force: true }); } catch {} continue;
412
+ }
413
+ emitWarn(`VTT failed for ${basename(videoPath)}: ${msg}`); return "";
414
+ }
415
+ }
416
+ return "";
417
+ }
418
+ let extracted;
419
+ try { extracted = extractVideoFrames(videoPath, sampleInterval); }
420
+ catch (e) { emitWarn(` ${e.message}`); return ""; }
421
+ const result = runITT(extracted.frames, promptText, ittCli);
422
+ extracted.cleanup();
423
+ return result;
424
+ }
425
+
426
+ /**
427
+ * Run the agent CLI on a single video clip with a vision prompt.
428
+ * The agent CLI template supports {input} (clip path) and {prompt}.
429
+ * Defaults to the ITT CLI (frame-based image perception).
430
+ */
431
+ function runAgent(clipPath, promptText, agentCli) {
432
+ const tmpl = agentCli || DEFAULT_AGENT_CLI;
433
+ // Always quote the prompt text to avoid shell injection from multi-line content
434
+ const cmd = substituteTemplate(tmpl, { input: shQuote(clipPath), prompt: shQuote(promptText) });
435
+ try { return run(cmd, { timeout: 300_000 }).trim(); }
436
+ catch (e) { emitWarn(`Agent failed for ${basename(clipPath)}: ${e.message}`); return ""; }
437
+ }
438
+
439
+ function runSTT(videoPath, normDir, sttCli) {
440
+ const base = basename(videoPath, extname(videoPath));
441
+ const mediaDir = dirname(videoPath);
442
+ const audioPath = join(normDir, `${base}_audio.mp3`);
443
+ const vttPath = join(mediaDir, `${base}.vtt`);
444
+ if (existsSync(vttPath)) return `${base}.vtt`;
445
+
446
+ try { run(`ffmpeg -y -i ${shQuote(videoPath)} -vn -acodec libmp3lame -q:a 2 ${shQuote(audioPath)}`, { timeout: 300_000 }); }
447
+ catch (e) { emitWarn(`Audio extraction failed for ${base}: ${e.message}`); return null; }
448
+
449
+ const tmpl = sttCli || DEFAULT_STT_CLI;
450
+ const cmd = substituteTemplate(tmpl, { input: audioPath, output: normDir });
451
+ try { run(cmd, { timeout: 600_000 }); } catch (e) { emitWarn(`STT failed for ${base}: ${e.message}`); return null; }
452
+
453
+ const whisperVtt = join(normDir, `${base}_audio.vtt`);
454
+ if (existsSync(whisperVtt)) renameSync(whisperVtt, vttPath);
455
+ return existsSync(vttPath) ? `${base}.vtt` : null;
456
+ }
457
+
458
+ function parseVTT(vttPath) {
459
+ const cues = [];
460
+ if (!existsSync(vttPath)) return cues;
461
+ const content = readFileSync(vttPath, "utf-8");
462
+ // Supports both HH:MM:SS.mmm and MM:SS.mmm formats
463
+ const cueRe = /(\d{2}:)?\d{2}:\d{2}\.\d{3}\s*-->\s*(\d{2}:)?\d{2}:\d{2}\.\d{3}\s*\n([\s\S]*?)(?=\n\n|\n\d{2}:\d{2}|\s*$)/g;
464
+ let match;
465
+ while ((match = cueRe.exec(content)) !== null) {
466
+ const raw = match[0];
467
+ const parts = raw.split(/\s*-->\s*/);
468
+ if (parts.length < 2) continue;
469
+ const start = timeToSeconds(parts[0].trim());
470
+ const end = timeToSeconds(parts[1].trim().split("\n")[0].trim());
471
+ const text = parts[1].substring(parts[1].indexOf("\n") + 1).trim().replace(/\n/g, " ");
472
+ if (text) cues.push({ start, end, text });
473
+ }
474
+ return cues;
475
+ }
476
+
477
+ function timeToSeconds(ts) {
478
+ const parts = ts.trim().split(":");
479
+ if (parts.length === 3) {
480
+ return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2]);
481
+ }
482
+ // MM:SS.mmm (no hours)
483
+ return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);
484
+ }
485
+
486
+ function formatTime(seconds) {
487
+ const h = Math.floor(seconds / 3600);
488
+ const m = Math.floor((seconds % 3600) / 60);
489
+ const s = seconds % 60;
490
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${s.toFixed(3).padStart(6, "0")}`;
491
+ }
492
+
493
+ function queryLLM(promptText) {
494
+ const tmpDir = join(__dirname, ".tmp");
495
+ mkdirSync(tmpDir, { recursive: true });
496
+ const dummyPng = join(tmpDir, "_.png");
497
+ if (!existsSync(dummyPng)) {
498
+ writeFileSync(dummyPng, Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64"));
499
+ }
500
+ const cmd = `uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/Qwen2.5-VL-7B-Instruct-4bit --max-tokens 2048 --prompt ${shQuote(promptText)} --image ${shQuote(dummyPng)}`;
501
+ try { return run(cmd, { timeout: 300_000 }).trim(); }
502
+ catch (e) { emitWarn(`LLM query failed: ${e.message}`); return ""; }
503
+ }
504
+
505
+ // ── JSON parsing helpers ──────────────────────────────────────────────────
506
+
507
+ function looseJSONParse(text) {
508
+ if (!text) return null;
509
+ let s = text.trim();
510
+ s = s.replace(/^(You are a helpful assistant[.\s]*)/i, "");
511
+ s = s.replace(/<\|im_start\|>/g, "").replace(/<\|im_end\|>/g, "");
512
+ s = s.replace(/<\|vision[^|]*\|>/g, "");
513
+ s = s.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
514
+ const firstBrace = s.indexOf("{");
515
+ const lastBrace = s.lastIndexOf("}");
516
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) return null;
517
+ let content = s.slice(firstBrace, lastBrace + 1);
518
+ try { return JSON.parse(content); } catch { /* continue */ }
519
+ content = content.replace(/\d+\.\s*(\{)/g, (m, brace) => brace);
520
+ const innerBrace = content.indexOf("{", 1);
521
+ const innerBraceEnd = content.lastIndexOf("}");
522
+ if (innerBrace > 0 && innerBraceEnd > innerBrace) {
523
+ const inner = content.slice(innerBrace, innerBraceEnd + 1);
524
+ try { return JSON.parse(inner); } catch { /* continue */ }
525
+ content = inner;
526
+ }
527
+ let fixed = content.replace(/,(\s*[}\]])/g, '$1').replace(/'/g, '"').replace(/\btrue\b/gi, 'true').replace(/\bfalse\b/gi, 'false').replace(/\bnull\b/gi, 'null');
528
+ try { return JSON.parse(fixed); } catch { /* continue */ }
529
+ fixed = fixed.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":');
530
+ try { return JSON.parse(fixed); } catch { /* continue */ }
531
+ fixed = fixed.replace(/:\s*([a-zA-Z][a-zA-Z0-9_ \/-]+?)\s*([,\}\]])/g, (m, val, sep) => {
532
+ const t = val.trim();
533
+ if (t === "true" || t === "false" || t === "null") return `:${t}${sep}`;
534
+ if (/^\d+(\.\d+)?$/.test(t)) return `:${t}${sep}`;
535
+ if (t.startsWith('"') && t.endsWith('"')) return `:${t}${sep}`;
536
+ return `:"${t}"${sep}`;
537
+ });
538
+ try { return JSON.parse(fixed); } catch { return null; }
539
+ }
540
+
541
+ function extractRawText(text) {
542
+ if (!text) return "";
543
+ const lines = text.split("\n");
544
+ const respLines = [];
545
+ let inResponse = false, seenAssistant = false;
546
+ for (const line of lines) {
547
+ const s = line.trim();
548
+ if (s.startsWith("====")) { if (seenAssistant) break; continue; }
549
+ if (s.startsWith("Files:") || s.startsWith("Prompt:")) continue;
550
+ if (s.startsWith("Generation:") || s.startsWith("Peak memory:")) continue;
551
+ if (s.includes("<|im_start|>assistant")) { seenAssistant = true; inResponse = true; continue; }
552
+ if (inResponse) respLines.push(line);
553
+ }
554
+ let result = respLines.join("\n").trim();
555
+ result = result.replace(/\n={3,}[\s\S]*$/, "").trim();
556
+ result = result.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
557
+ return result;
558
+ }
559
+
560
+ function stripThinkBlocks(text) {
561
+ return text ? text.replace(/^<think>[\s\S]*?<\/think>\s*/g, "") : text;
562
+ }
563
+
564
+ function normalizePerception(perception) {
565
+ return { ...perception };
566
+ }
567
+
568
+ // ── Geocoding ─────────────────────────────────────────────────────────────
569
+
570
+ async function enrichLocation(location) {
571
+ if (!location || location.lat == null || location.lng == null) return location;
572
+ try {
573
+ const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${location.lat}&lon=${location.lng}&format=json&zoom=14`, {
574
+ headers: { "User-Agent": "markcut-vision/1.0" },
575
+ });
576
+ if (!res.ok) return location;
577
+ const data = await res.json();
578
+ return { ...location, place: data?.display_name || data?.name || null };
579
+ } catch { return location; }
580
+ }
581
+
582
+ // ═════════════════════════════════════════════════════════════════════════
583
+ // Metadata extraction (default mode)
584
+ // ═════════════════════════════════════════════════════════════════════════
585
+
586
+ async function runMetadataMode(folder, pickSet, dryRun) {
587
+ emitInfo(`\nšŸ” Scanning for metadata: ${folder}`);
588
+ const { images, videos } = scanMedia(folder);
589
+ emitInfo(` Found ${images.length} images, ${videos.length} videos`);
590
+
591
+ if (images.length === 0 && videos.length === 0) {
592
+ emitWarn("No media files found.");
593
+ return;
594
+ }
595
+
596
+ const { results } = loadMetadata(folder);
597
+
598
+ for (const filePath of [...images, ...videos]) {
599
+ if (pickSet.size > 0 && !pickSet.has(basename(filePath))) continue;
600
+ const base = basename(filePath, extname(filePath));
601
+ const isVideo = VIDEO_EXTS.has(extname(filePath).toLowerCase());
602
+ emitInfo(`\n${isVideo ? "šŸŽ¬" : "šŸ“·"} ${base}`);
603
+
604
+ if (dryRun) { if (!results[base]) results[base] = { _dryRun: true }; continue; }
605
+
606
+ const meta = extractMetadata(filePath);
607
+ const existing = results[base] || {};
608
+ results[base] = {
609
+ ...existing,
610
+ width: meta.width || existing.width || 0,
611
+ height: meta.height || existing.height || 0,
612
+ created: meta.created || existing.created || null,
613
+ location: meta.location || existing.location || null,
614
+ ...(isVideo ? { duration: meta.duration || existing.duration || null } : {}),
615
+ };
616
+ writeFileSync(join(folder, "metadata.json"), JSON.stringify(results, null, 2), "utf-8");
617
+ emitInfo(` ${meta.width}x${meta.height}${meta.created ? `, ${meta.created}` : ""}${meta.duration ? `, ${meta.duration.toFixed(1)}s` : ""}`);
618
+ }
619
+
620
+ emitInfo(`\nšŸ“Š Metadata extracted: ${Object.keys(results).length} files → metadata.json`);
621
+ }
622
+
623
+ // ═════════════════════════════════════════════════════════════════════════
624
+ // Label preview + full pipeline (--label mode)
625
+ // ═════════════════════════════════════════════════════════════════════════
626
+
627
+ function buildPreviewTree(folder, metadata) {
628
+ // Build a map of lowercase basename → actual filename for case-sensitive matching
629
+ const allFiles = readdirSync(folder);
630
+ const fileMap = new Map();
631
+ for (const f of allFiles) {
632
+ const st = statSync(join(folder, f));
633
+ if (!st.isFile()) continue;
634
+ const key = basename(f, extname(f)).toLowerCase();
635
+ if (!fileMap.has(key)) fileMap.set(key, f);
636
+ }
637
+
638
+ const entries = [];
639
+ for (const [baseName, data] of Object.entries(metadata)) {
640
+ const actualName = fileMap.get(baseName.toLowerCase());
641
+ if (!actualName) continue;
642
+ const filePath = join(folder, actualName);
643
+ const isVideo = VIDEO_EXTS.has(extname(actualName).toLowerCase());
644
+ const existingHints = data.userHints ? { ...data.userHints } : (data.userHint ? { overall: data.userHint } : null);
645
+ entries.push({
646
+ name: baseName,
647
+ created: data.created || "1970-01-01T00:00:00Z",
648
+ relSrc: `${actualName}`,
649
+ dur: isVideo ? (data.duration || 5) : 3,
650
+ isVideo,
651
+ userHints: existingHints,
652
+ });
653
+ }
654
+ entries.sort((a, b) => a.created.localeCompare(b.created));
655
+
656
+ return {
657
+ id: "root", type: "root", width: 1080, height: 1920, fps: 30,
658
+ isSeries: true, transition: "fade", transitionTime: 0.5,
659
+ children: entries.map((e) => ({
660
+ id: e.name, type: "folder", isSeries: false,
661
+ children: [{
662
+ id: `${e.name}-media`,
663
+ type: e.isVideo ? "video" : "image",
664
+ src: e.relSrc, fit: "cover",
665
+ actions: [{ start: 0, end: e.dur }],
666
+ // Preserve existing labels from metadata so re-labeling shows them
667
+ userHints: e.userHints || undefined,
668
+ }],
669
+ })),
670
+ };
671
+ }
672
+
673
+ function mergeLabelsIntoMetadata(folder, metadata) {
674
+ const labelsPath = join(folder, "labels.json");
675
+ if (!existsSync(labelsPath)) {
676
+ emitWarn("No labels.json found — no hints to merge.");
677
+ return metadata;
678
+ }
679
+ const labelsTree = JSON.parse(readFileSync(labelsPath, "utf-8"));
680
+ const children = labelsTree.children || [];
681
+ let mergeCount = 0;
682
+ for (const child of children) {
683
+ const media = child.children?.[0];
684
+ if (!media) continue;
685
+ const baseName = child.id;
686
+ if (!metadata[baseName]) continue;
687
+ // New format: media.userHints = { overall: "...", timed: { at_XXXX: "...", ... } }
688
+ if (media.userHints) {
689
+ const hints = media.userHints;
690
+ const merged = {};
691
+ if (hints.overall) merged.overall = hints.overall;
692
+ if (hints.timed && typeof hints.timed === "object") {
693
+ const timedMerged = {};
694
+ for (const [key, val] of Object.entries(hints.timed)) {
695
+ if (val) timedMerged[key] = val;
696
+ }
697
+ if (Object.keys(timedMerged).length > 0) merged.timed = timedMerged;
698
+ }
699
+ if (Object.keys(merged).length > 0) {
700
+ metadata[baseName].userHints = merged;
701
+ // For backward compat, also set userHint to the overall or first timed
702
+ metadata[baseName].userHint = hints.overall || (hints.timed ? Object.values(hints.timed)[0] : "") || "";
703
+ mergeCount++;
704
+ }
705
+ } else if (media.description) {
706
+ // Legacy format: single description
707
+ metadata[baseName].userHint = media.description;
708
+ metadata[baseName].userHints = { overall: media.description };
709
+ mergeCount++;
710
+ }
711
+ }
712
+ try { rmSync(labelsPath, { force: true }); } catch {}
713
+ emitInfo(` Merged ${mergeCount} labels → userHints in metadata.json`);
714
+ return metadata;
715
+ }
716
+
717
+ // ── Perception helpers ────────────────────────────────────────────────────
718
+
719
+ function analyzeImage(imagePath, normPath, prompts, ittCli, context = "", userHint = "", userHints = null) {
720
+ let ctxParts = [];
721
+ if (context) ctxParts.push(`Context: ${context}`);
722
+ if (userHint) ctxParts.push(`User hint: ${userHint}`);
723
+ const ctx = ctxParts.length > 0 ? ctxParts.join("\n") + "\n\n" : "";
724
+ const prompt = ctx + getPrompt(prompts, "image-perception");
725
+ const raw = runITT(normPath, prompt, ittCli);
726
+ const parsed = looseJSONParse(raw);
727
+ const rawText = stripThinkBlocks(extractRawText(raw));
728
+ return normalizePerception({
729
+ desc: rawText.slice(0, 500) || parsed?.desc || raw.slice(0, 500),
730
+ });
731
+ }
732
+
733
+ /**
734
+ * Build a merged cue timeline from VTT cues, user hints, and ffprobe scene changes.
735
+ * Start with VTT cues as the base, then merge in user hint timestamps (highest
736
+ * priority) and ffprobe scene changes (lowest). Sorts by time and deduplicates
737
+ * boundaries within 500ms keeping the highest-weight source.
738
+ *
739
+ * Returns a unified array of { timeMs, sourceTag, label } ready for the prompt.
740
+ */
741
+ function buildMergedCues(userHint, cues, sceneChangesMs, totalDurationMs) {
742
+ const entries = [];
743
+
744
+ // Source weight: userHint=3, VTT/subtitle=2, ffprobe scene=1
745
+ // Track earliest occurrence per timestamp cluster for weight comparison
746
+
747
+ // 1. VTT cues as the base
748
+ for (const cue of cues) {
749
+ const t = Math.floor(cue.end * 1000);
750
+ if (t > 0 && t < totalDurationMs) {
751
+ entries.push({ timeMs: t, source: 2, sourceTag: "subtitle", label: cue.text.slice(0, 80) });
752
+ }
753
+ }
754
+
755
+ // 2. Merge user hints into the timeline (highest weight)
756
+ // Supports both flat { at_XXXX: "..." } and nested { overall: "...", timed: { at_XXXX: "..." } }
757
+ var hintSrc = userHint;
758
+ if (hintSrc && typeof hintSrc === "object" && !Array.isArray(hintSrc)) {
759
+ // If userHint has a "timed" sub-object, prefer entries from there
760
+ var timedEntries = hintSrc.timed && typeof hintSrc.timed === "object" ? hintSrc.timed : hintSrc;
761
+ for (const [key, val] of Object.entries(timedEntries)) {
762
+ const m = key.match(/^at[_-]?(\d+)$/);
763
+ if (m) {
764
+ const t = parseInt(m[1], 10);
765
+ if (t > 0 && t < totalDurationMs) {
766
+ entries.push({ timeMs: t, source: 3, sourceTag: "userHint", label: String(val) });
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ // 3. Merge ffprobe scene changes (lowest weight)
773
+ for (const t of sceneChangesMs) {
774
+ if (t > 0 && t < totalDurationMs) {
775
+ entries.push({ timeMs: t, source: 1, sourceTag: "scene", label: "(shot change)" });
776
+ }
777
+ }
778
+
779
+ // Sort by time, then by weight descending (so higher weight wins dedup)
780
+ entries.sort((a, b) => a.timeMs - b.timeMs || b.source - a.source);
781
+
782
+ // Deduplicate: within 500ms, keep the first (highest-weight, since sorted above)
783
+ const merged = [];
784
+ for (const e of entries) {
785
+ const last = merged[merged.length - 1];
786
+ if (last && Math.abs(last.timeMs - e.timeMs) < 500) continue;
787
+ merged.push(e);
788
+ }
789
+
790
+ return merged;
791
+ }
792
+
793
+ function analyzeVideo(videoPath, normInfo, normDir, prompts, vttCli, sttCli, context = "", userHint = "", ittCli = null, sampleInterval = DEFAULT_VTT_SAMPLE_INTERVAL, agentCli = null, userHints = null) {
794
+ let ctxParts = [];
795
+ if (context) ctxParts.push(`Context: ${context}`);
796
+ if (userHint && typeof userHint === "string") ctxParts.push(`User hint: ${userHint}`);
797
+ const ctx = ctxParts.length > 0 ? ctxParts.join("\n") + "\n\n" : "";
798
+ const perception = {};
799
+
800
+ // 1. VTT → overall video description
801
+ emitInfo(` Running video understanding...`);
802
+ const descPrompt = ctx + getPrompt(prompts, "video-perception");
803
+ const descRaw = runVTT(normInfo.path, descPrompt, vttCli, ittCli, sampleInterval);
804
+ const descText = stripThinkBlocks(extractRawText(descRaw));
805
+ perception.desc = descText.slice(0, 500) || looseJSONParse(descRaw)?.desc || descRaw.slice(0, 500);
806
+
807
+ // 2. STT → VTT subtitle
808
+ emitInfo(` Running speech-to-text...`);
809
+ perception.subtitle = runSTT(videoPath, normDir, sttCli);
810
+
811
+ // 3. Build merged cue timeline from VTT + user hints + ffprobe
812
+ emitInfo(` Building merged segment boundaries...`);
813
+ const cues = perception.subtitle ? parseVTT(join(dirname(videoPath), perception.subtitle)) : [];
814
+ const totalDurMs = Math.round((normInfo.trimmedDuration || getVideoDuration(videoPath)) * 1000);
815
+
816
+ // Parse userHint/userHints: string → overall context only; object with at_* keys → timestamp boundaries
817
+ let hintObject = {};
818
+ if (userHints && typeof userHints === "object") {
819
+ // New format: { overall: "...", at_XXXX: "...", ... }
820
+ hintObject = userHints;
821
+ } else if (userHint && typeof userHint === "object") {
822
+ hintObject = userHint;
823
+ }
824
+
825
+ const sceneChangesMs = detectSceneChanges(videoPath);
826
+ if (sceneChangesMs.length > 0) emitInfo(` ffprobe: ${sceneChangesMs.length} scene changes detected`);
827
+
828
+ const mergedCues = buildMergedCues(hintObject, cues, sceneChangesMs, totalDurMs);
829
+ emitInfo(` ${mergedCues.length} unified boundary cues`);
830
+
831
+ // 4. Feed the merged timeline to the LLM for final segment merging
832
+ let segments = {};
833
+ if (mergedCues.length > 0) {
834
+ const candidateLines = mergedCues.map(c => `- ${c.timeMs}ms [${c.sourceTag}]: ${c.label}`).join("\n");
835
+ const transcript = cues.map(c => `${formatTime(c.start)} --> ${formatTime(c.end)}\n${c.text}`).join("\n\n");
836
+
837
+ const segInput = `Candidates:\n${candidateLines}\n\nTranscript:\n${transcript || "(no speech)"}\n\nDuration: ${totalDurMs}ms`;
838
+ const segPrompt = ctx + getPrompt(prompts, "detect-scenes");
839
+ // Use agent CLI with a dummy input (detect-scenes is text reasoning; agent CLI handles text + vision)
840
+ const tmpDir = join(__dirname, ".tmp");
841
+ mkdirSync(tmpDir, { recursive: true });
842
+ const dummyInput = join(tmpDir, "_.png");
843
+ if (!existsSync(dummyInput)) {
844
+ writeFileSync(dummyInput, Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64"));
845
+ }
846
+ const segRaw = runAgent(dummyInput, `${segPrompt}\n\nInput:\n${segInput}`, agentCli || ittCli);
847
+ const segParsed = looseJSONParse(segRaw);
848
+
849
+ if (segParsed && typeof segParsed === "object" && Object.keys(segParsed).length > 0) {
850
+ segments = segParsed;
851
+ emitInfo(` LLM merged into ${Object.keys(segments).length} segments`);
852
+ } else {
853
+ // Fallback: use merged cues as raw cut points
854
+ emitInfo(` LLM merge returned empty — using raw boundaries`);
855
+ let prev = 0;
856
+ for (const c of mergedCues) {
857
+ const key = `${prev}to${c.timeMs}`;
858
+ segments[key] = { subtitle: c.label };
859
+ prev = c.timeMs;
860
+ }
861
+ if (prev < totalDurMs) {
862
+ const key = `${prev}to${totalDurMs}`;
863
+ segments[key] = { subtitle: "" };
864
+ }
865
+ }
866
+ }
867
+
868
+ // Fallback: no boundaries at all → single full-video segment
869
+ if (Object.keys(segments).length === 0) {
870
+ emitInfo(` No segment boundaries — using full video as single segment`);
871
+ segments = { [`0to${totalDurMs}`]: { subtitle: "" } };
872
+ }
873
+
874
+ // 5. Percept each segment
875
+ perception.segments = {};
876
+ if (Object.keys(segments).length > 0) {
877
+ const segDir = join(normDir, "segments");
878
+ mkdirSync(segDir, { recursive: true });
879
+ const visSegPrompt = getPrompt(prompts, "video-perception");
880
+
881
+ for (const [key, seg] of Object.entries(segments)) {
882
+ const msMatch = key.match(/^(\d+)to(\d+)$/);
883
+ if (!msMatch) continue;
884
+ const startMs = parseInt(msMatch[1], 10);
885
+ const endMs = parseInt(msMatch[2], 10);
886
+ if (endMs <= startMs) continue;
887
+
888
+ const startSec = startMs / 1000;
889
+ const durSec = (endMs - startMs) / 1000;
890
+
891
+ const clipName = `${basename(normInfo.path, extname(normInfo.path))}_seg_${key}.mp4`;
892
+ const clipOut = join(segDir, clipName);
893
+ let clipPath = normInfo.path;
894
+
895
+ if (!existsSync(clipOut)) {
896
+ const srcForClip = existsSync(normInfo.path) ? normInfo.path : videoPath;
897
+ const clipFilter = "scale='min(360,iw)':'min(360,ih)':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2':-1:-1";
898
+ try {
899
+ // Use -ss after -i (output seek) for accurate duration metadata
900
+ run(`ffmpeg -y -i ${shQuote(srcForClip)} -ss ${startSec} -t ${durSec} -vf ${shQuote(clipFilter)} -c:v libx264 -preset fast -crf 28 -an -fflags +genpts ${shQuote(clipOut)}`, { timeout: 120_000 });
901
+ if (existsSync(clipOut)) clipPath = clipOut;
902
+ } catch { /* fallback */ }
903
+ } else { clipPath = clipOut; }
904
+
905
+ // Use runVTT for video clips (extracts frames, then runs ITT)
906
+ const segRawVis = runVTT(clipPath, visSegPrompt, vttCli, ittCli, sampleInterval);
907
+ const segVisText = extractRawText(segRawVis);
908
+ seg.vision = stripThinkBlocks(looseJSONParse(segRawVis)?.desc || segVisText.slice(0, 300) || segRawVis.slice(0, 300));
909
+ try { if (clipPath !== normInfo.path) rmSync(clipPath, { force: true }); } catch {}
910
+ }
911
+ try { rmSync(segDir, { recursive: true, force: true }); } catch {}
912
+ perception.segments = segments;
913
+ }
914
+
915
+ return normalizePerception(perception);
916
+ }
917
+
918
+ // ── Full pipeline runner (step 2: label → normalize → percept → segments) ──
919
+
920
+ async function runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context, pickSet, vttSampleInterval, skipSTT, dryRun, agentCli = null) {
921
+ const metadataPath = join(folder, "metadata.json");
922
+
923
+ // ── Ensure metadata exists ──────────────────────────────────────────
924
+ if (!existsSync(metadataPath)) {
925
+ emitInfo(`\nšŸ“‹ No metadata.json found. Extracting metadata first...`);
926
+ await runMetadataMode(folder, pickSet, false);
927
+ }
928
+
929
+ if (!existsSync(metadataPath)) {
930
+ emitError("No media files found — nothing to process.");
931
+ return;
932
+ }
933
+
934
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
935
+ if (Object.keys(metadata).length === 0) {
936
+ emitError("metadata.json is empty — no media found.");
937
+ return;
938
+ }
939
+
940
+ // ── Build preview and open label server ─────────────────────────────
941
+ emitInfo(`\nšŸ—ļø Building preview from metadata (${Object.keys(metadata).length} entries)...`);
942
+ const previewTree = buildPreviewTree(folder, metadata);
943
+ const previewJsonPath = join(folder, ".preview.json");
944
+ writeFileSync(previewJsonPath, JSON.stringify(previewTree, null, 2), "utf-8");
945
+
946
+ const labelServer = join(__dirname, "..", "player", "label-server.mjs");
947
+ if (!existsSync(labelServer)) {
948
+ emitError(`Label server not found at ${labelServer}`);
949
+ process.exit(1);
950
+ }
951
+
952
+ emitInfo(`\nšŸ·ļø Opening label preview...`);
953
+ emitInfo(` Label each scene, then close the browser/tab when done.`);
954
+ emitInfo(` Labels will be merged into metadata.json as user hints.\n`);
955
+
956
+ const port = 3031;
957
+ const child = spawn("node", [labelServer, previewJsonPath, `--port=${port}`], {
958
+ cwd: resolve(__dirname, "..", ".."),
959
+ stdio: ["ignore", "pipe", "inherit"],
960
+ });
961
+
962
+ let serverReady = false;
963
+ if (child.stdout) {
964
+ child.stdout.on("data", (chunk) => {
965
+ process.stdout.write(chunk);
966
+ if (!serverReady && chunk.toString().includes("Label Preview")) {
967
+ serverReady = true;
968
+ try { execSync(`open http://localhost:${port}`, { stdio: "ignore" }); } catch {}
969
+ }
970
+ });
971
+ }
972
+
973
+ // Wait for user to close the browser tab / label server
974
+ await new Promise((resolvePromise) => { child.on("exit", () => resolvePromise()); });
975
+
976
+ // ── Merge labels into metadata ──────────────────────────────────────
977
+ emitInfo(`\nšŸ’¾ Merging labels into metadata.json...`);
978
+ const updatedMetadata = mergeLabelsIntoMetadata(folder, metadata);
979
+ writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2), "utf-8");
980
+ emitSuccess(`metadata.json updated with user hints`);
981
+
982
+ try { rmSync(previewJsonPath, { force: true }); } catch {}
983
+
984
+ // ── Proceed with the rest of the pipeline ──────────────────────────
985
+ if (dryRun) {
986
+ emitInfo("\nāš ļø Dry run — skipping normalize + percept after label merge.");
987
+ return;
988
+ }
989
+
990
+ await runNormalizeAndPercept(folder, metadataPath, prompts, ittCli, vttCli, sttCli, context, pickSet, vttSampleInterval, skipSTT, agentCli);
991
+ }
992
+
993
+ // ── Normalize + Percept (internal step) ───────────────────────────────────
994
+
995
+ async function runNormalizeAndPercept(folder, metadataPath, prompts, ittCli, vttCli, sttCli, context, pickSet, vttSampleInterval, skipSTT, agentCli = null) {
996
+ const { results, cacheMap } = loadMetadata(folder);
997
+ const cache = { ...cacheMap };
998
+ const normDir = join(folder, ".normalized");
999
+ mkdirSync(normDir, { recursive: true });
1000
+
1001
+ const allFiles = readdirSync(folder);
1002
+ const imageFiles = allFiles.filter(f => IMAGE_EXTS.has(extname(f).toLowerCase()));
1003
+ const videoFiles = allFiles.filter(f => VIDEO_EXTS.has(extname(f).toLowerCase()));
1004
+
1005
+ emitInfo(`\nšŸ”§ Normalizing + perceiving: ${folder}`);
1006
+ emitInfo(` ${imageFiles.length} images, ${videoFiles.length} videos`);
1007
+
1008
+ // Process images
1009
+ for (const fileName of imageFiles) {
1010
+ if (pickSet.size > 0 && !pickSet.has(fileName)) continue;
1011
+ const base = basename(fileName, extname(fileName));
1012
+ const meta = results[base];
1013
+ if (!meta) { emitWarn(` No metadata for ${fileName}, skipping.`); continue; }
1014
+ const userHint = meta.userHints?.overall || meta.userHint || "";
1015
+ const userHints = meta.userHints || (userHint ? { overall: userHint } : {});
1016
+
1017
+ emitInfo(`\nšŸ“· Image: ${base}${userHint ? ` (hint: "${userHint}")` : ""}`);
1018
+ const imgPath = join(folder, fileName);
1019
+ const normPath = normalizeImage(imgPath, normDir);
1020
+
1021
+ let perception = {};
1022
+ let cacheKey = "";
1023
+ const ctx = context ? `Context: ${context}\n\n` : "";
1024
+ const imgPrompt = (userHint ? `User hint: ${userHint}\n\n` : "") + ctx + getPrompt(prompts, "image-perception");
1025
+ const imgInput = `${ittCli ? "" : "@"}${shQuote(normPath)}`;
1026
+ const imgCmd = substituteTemplate(ittCli || DEFAULT_ITT_CLI, { input: imgInput, prompt: imgPrompt });
1027
+ cacheKey = perceptionCacheKey(imgPath, imgCmd, "image");
1028
+ if (cache[cacheKey]) {
1029
+ perception = cache[cacheKey];
1030
+ emitInfo(` (cached)`);
1031
+ } else {
1032
+ perception = analyzeImage(imgPath, normPath, prompts, ittCli, context, userHint, userHints);
1033
+ if (perception.desc) cache[cacheKey] = perception;
1034
+ emitInfo(` ${perception.desc?.slice(0, 80)}...`);
1035
+ }
1036
+ results[base] = { ...meta, perception, _cache: cacheKey };
1037
+ writeFileSync(metadataPath, JSON.stringify(results, null, 2), "utf-8");
1038
+
1039
+ // Enrich location for images too
1040
+ let location = meta.location;
1041
+ if (location) location = await enrichLocation(location);
1042
+ if (location !== meta.location) {
1043
+ results[base] = { ...results[base], location };
1044
+ writeFileSync(metadataPath, JSON.stringify(results, null, 2), "utf-8");
1045
+ }
1046
+ }
1047
+
1048
+ // Process videos
1049
+ for (const fileName of videoFiles) {
1050
+ if (pickSet.size > 0 && !pickSet.has(fileName)) continue;
1051
+ const base = basename(fileName, extname(fileName));
1052
+ const meta = results[base];
1053
+ if (!meta) { emitWarn(` No metadata for ${fileName}, skipping.`); continue; }
1054
+ const userHint = meta.userHints?.overall || meta.userHint || "";
1055
+ const userHints = meta.userHints || (userHint ? { overall: userHint } : {});
1056
+
1057
+ emitInfo(`\nšŸŽ¬ Video: ${base}${userHint ? ` (hint: "${userHint}")` : ""}`);
1058
+ const vidPath = join(folder, fileName);
1059
+ const normInfo = normalizeVideo(vidPath, normDir, meta.duration);
1060
+ const stt = skipSTT ? null : sttCli;
1061
+
1062
+ let perception = {};
1063
+ let cacheKey = "";
1064
+ const ctxV = context ? `Context: ${context}\n\n` : "";
1065
+ const vidPrompt = (userHint ? `User hint: ${userHint}\n\n` : "") + ctxV + getPrompt(prompts, "video-perception");
1066
+ let vttActualCmd;
1067
+ if (vttCli) {
1068
+ vttActualCmd = substituteTemplate(vttCli, { input: shQuote(normInfo.path), prompt: vidPrompt });
1069
+ } else {
1070
+ const dur = meta.duration || 0;
1071
+ const n = Math.max(5, Math.min(10, Math.ceil(dur / vttSampleInterval)));
1072
+ const framePaths = Array.from({ length: n }, (_, i) => `${basename(normInfo.path, extname(normInfo.path))}_${String(i + 1).padStart(3, "0")}.jpg`);
1073
+ vttActualCmd = substituteTemplate(ittCli || DEFAULT_ITT_CLI, { input: framePaths.map(p => `@${p}`).join(" "), prompt: vidPrompt });
1074
+ }
1075
+ cacheKey = perceptionCacheKey(vidPath, vttActualCmd, "video");
1076
+ if (cache[cacheKey]) {
1077
+ perception = cache[cacheKey];
1078
+ emitInfo(` (cached)`);
1079
+ } else {
1080
+ perception = analyzeVideo(vidPath, normInfo, normDir, prompts, vttCli, stt, context, userHint, ittCli, vttSampleInterval, agentCli, userHints);
1081
+ if (perception.desc) cache[cacheKey] = perception;
1082
+ }
1083
+
1084
+ let location = meta.location;
1085
+ if (location) location = await enrichLocation(location);
1086
+
1087
+ results[base] = { ...meta, location, perception, _cache: cacheKey };
1088
+ writeFileSync(metadataPath, JSON.stringify(results, null, 2), "utf-8");
1089
+ }
1090
+
1091
+ emitInfo(`\nāœ… Pipeline complete: ${Object.keys(results).length} files → metadata.json`);
1092
+ }
1093
+
1094
+ // ═════════════════════════════════════════════════════════════════════════
1095
+ // CLI entry
1096
+ // ═════════════════════════════════════════════════════════════════════════
1097
+
1098
+ function printUsage() {
1099
+ console.log(`
1100
+ markcut vision — Analyze images and videos in a folder for video generation
1101
+
1102
+ Usage:
1103
+ markcut vision <folder> Extract metadata into metadata.json
1104
+ markcut vision <folder> --label Full pipeline: preview → label → normalize → percept → segments
1105
+
1106
+ Options:
1107
+ --label Open label preview → then continue with full AI pipeline
1108
+ --agent <template> Custom text LLM CLI for detect-scenes ({prompt})
1109
+ --itt <template> Custom ITT CLI template with {input}, {prompt}
1110
+ --vtt <template> Custom VTT CLI template with {input}, {prompt}
1111
+ --stt <template> Custom STT CLI template with {input}, {output}
1112
+ --prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
1113
+ --vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
1114
+ --context "text" Background context about people/places (injected into prompts)
1115
+ --pick <files> Comma-separated filenames to process
1116
+ --skip-stt Skip speech-to-text for videos
1117
+ --dry-run Show what would be processed without running AI
1118
+ --show-prompts Print the prompts file and exit
1119
+ --show-clis Print the default ITT/VTT/STT CLI templates
1120
+ --help Show this help
1121
+
1122
+ Prompt overrides:
1123
+ --<prompt-name> "text" Override any prompt template from vision_prompts.md
1124
+ `);
1125
+ }
1126
+
1127
+ export async function main(args) {
1128
+ let folder = "";
1129
+ let agentCli = null;
1130
+ let ittCli = null;
1131
+ let vttCli = null;
1132
+ let sttCli = null;
1133
+ let promptsFile = PROMPTS_FILE;
1134
+ let context = "";
1135
+ let vttSampleInterval = DEFAULT_VTT_SAMPLE_INTERVAL;
1136
+ let skipSTT = false;
1137
+ let dryRun = false;
1138
+ let doLabel = false;
1139
+ const pickSet = new Set();
1140
+ const promptOverrides = new Map();
1141
+
1142
+ let i = 2;
1143
+ if (args[i] === "vision") i++;
1144
+ if (args[i] && !args[i].startsWith("--")) folder = args[i++];
1145
+
1146
+ while (i < args.length) {
1147
+ const flag = args[i++];
1148
+ if (flag === "--help") { printUsage(); return; }
1149
+ else if (flag === "--label") { doLabel = true; }
1150
+ else if (flag === "--agent" && args[i]) { agentCli = args[i++]; }
1151
+ else if (flag === "--itt" && args[i]) { ittCli = args[i++]; }
1152
+ else if (flag === "--vtt" && args[i]) { vttCli = args[i++]; }
1153
+ else if (flag === "--stt" && args[i]) { sttCli = args[i++]; }
1154
+ else if (flag === "--prompts-file" && args[i]) { promptsFile = resolve(args[i++]); }
1155
+ else if (flag === "--vtt-sample-interval" && args[i]) { vttSampleInterval = parseInt(args[i++], 10) || DEFAULT_VTT_SAMPLE_INTERVAL; }
1156
+ else if (flag === "--context" && args[i]) { context = args[i++]; }
1157
+ else if (flag === "--show-prompts") { console.log(readFileSync(promptsFile, "utf-8")); return; }
1158
+ else if (flag === "--show-clis") {
1159
+ console.log(`DEFAULT_ITT_CLI:\n${DEFAULT_ITT_CLI}\n`);
1160
+ console.log(`DEFAULT_VTT_CLI: (empty — uses ITT via frame extraction, sample every ${DEFAULT_VTT_SAMPLE_INTERVAL}s)\n`);
1161
+ console.log(`DEFAULT_STT_CLI:\n${DEFAULT_STT_CLI}`);
1162
+ return;
1163
+ }
1164
+ else if (flag === "--skip-stt") { skipSTT = true; }
1165
+ else if (flag === "--pick" && args[i]) { for (const f of args[i++].split(",")) pickSet.add(f.trim()); }
1166
+ else if (flag === "--dry-run") { dryRun = true; }
1167
+ else if (flag.startsWith("--") && args[i]) { promptOverrides.set(flag.slice(2), args[i++]); }
1168
+ }
1169
+
1170
+ if (!folder) { emitError("No folder specified."); printUsage(); process.exit(1); }
1171
+ folder = resolve(folder);
1172
+ if (!existsSync(folder)) { emitError(`Folder not found: ${folder}`); process.exit(1); }
1173
+
1174
+ const prompts = loadPrompts(promptsFile);
1175
+ for (const [name, value] of promptOverrides) prompts.set(name, value);
1176
+
1177
+ if (doLabel) {
1178
+ await runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context, pickSet, vttSampleInterval, skipSTT, dryRun, agentCli);
1179
+ } else {
1180
+ await runMetadataMode(folder, pickSet, dryRun);
1181
+ }
1182
+ }
1183
+
1184
+ // Direct execution
1185
+ if (process.argv[1] && process.argv[1].includes("src/vision/cli.mjs")) {
1186
+ main(process.argv).catch((e) => { emitError(e.message); process.exit(1); });
1187
+ }