@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.
- package/.env.example +27 -0
- package/.github/user-steer.md +9 -0
- package/.vscode/settings.json +3 -0
- package/AGENTS.md +271 -0
- package/README.md +219 -0
- package/SKILL.md +209 -0
- package/docs/dynamic-components.md +191 -0
- package/docs/edit-mode.md +220 -0
- package/docs/json-descriptive.md +539 -0
- package/docs/label-mode.md +110 -0
- package/docs/markdown-descriptive.md +751 -0
- package/docs/templates.md +52 -0
- package/package.json +64 -0
- package/remotion.config.ts +5 -0
- package/scripts/artlist-dl.mjs +190 -0
- package/scripts/build-pipeline.sh +19 -0
- package/scripts/build-player.sh +20 -0
- package/src/Root.tsx +55 -0
- package/src/config.mjs +88 -0
- package/src/context/EventContext.tsx +168 -0
- package/src/context/index.tsx +42 -0
- package/src/descriptive/compiler.test.ts +1135 -0
- package/src/descriptive/compiler.ts +1230 -0
- package/src/descriptive/dsl.ts +455 -0
- package/src/descriptive/markdown.test.ts +866 -0
- package/src/descriptive/markdown.ts +674 -0
- package/src/descriptive/resolve.test.ts +951 -0
- package/src/descriptive/resolve.ts +891 -0
- package/src/entry.tsx +163 -0
- package/src/index.ts +4 -0
- package/src/player/browser.tsx +356 -0
- package/src/player/bundle/player.js +60259 -0
- package/src/player/bundler.mjs +269 -0
- package/src/player/label-server.mjs +599 -0
- package/src/player/pipeline.mjs +11123 -0
- package/src/player/pipeline.ts +117 -0
- package/src/player/server-shared.mjs +144 -0
- package/src/player/server.mjs +1006 -0
- package/src/render/cli-tools.ts +177 -0
- package/src/render/cli.mjs +628 -0
- package/src/schema/index.ts +259 -0
- package/src/types/Audio.tsx +56 -0
- package/src/types/Component.tsx +135 -0
- package/src/types/Effect.tsx +88 -0
- package/src/types/Folder.tsx +180 -0
- package/src/types/FrameSyncStyle.tsx +51 -0
- package/src/types/Image.tsx +51 -0
- package/src/types/Include.tsx +394 -0
- package/src/types/Map.tsx +252 -0
- package/src/types/Rhythm.tsx +58 -0
- package/src/types/Scene.tsx +42 -0
- package/src/types/Subtitle.tsx +218 -0
- package/src/types/Video.tsx +70 -0
- package/src/types/keyframes.ts +454 -0
- package/src/utils/__tests__/vtt.test.ts +129 -0
- package/src/utils/index.ts +168 -0
- package/src/utils/tween.ts +118 -0
- package/src/vision/cli.mjs +1187 -0
- package/src/vision/vision_prompts.md +67 -0
- package/tests/dsl.test.ts +317 -0
- package/tests/fixtures/audio.json +25 -0
- package/tests/fixtures/basic.json +27 -0
- package/tests/fixtures/component-all.json +38 -0
- package/tests/fixtures/components.json +38 -0
- package/tests/fixtures/effects.json +64 -0
- package/tests/fixtures/full.json +51 -0
- package/tests/fixtures/map.json +23 -0
- package/tests/fixtures/md/all-nodes.md +28 -0
- package/tests/fixtures/md/basic.md +6 -0
- package/tests/fixtures/md/component-imports.md +20 -0
- package/tests/fixtures/md/edge-cases.md +33 -0
- package/tests/fixtures/md/effects.md +17 -0
- package/tests/fixtures/md/frontmatter.md +20 -0
- package/tests/fixtures/md/full-feature.md +58 -0
- package/tests/fixtures/md/imports-block.md +19 -0
- package/tests/fixtures/md/include-main.md +11 -0
- package/tests/fixtures/md/include-sub.md +25 -0
- package/tests/fixtures/md/jsx-code-fence.md +21 -0
- package/tests/fixtures/md/map.md +11 -0
- package/tests/fixtures/md/nested-scenes.md +25 -0
- package/tests/fixtures/md/rhythm.md +17 -0
- package/tests/fixtures/md/scenes.md +16 -0
- package/tests/fixtures/md/tween.md +11 -0
- package/tests/fixtures/md/vars-test.md +6 -0
- package/tests/fixtures/scenes.json +40 -0
- package/tests/fixtures/subtitle.json +59 -0
- package/tests/fixtures/subvideo.json +59 -0
- package/tests/fixtures/templates/courseware.md +351 -0
- package/tests/fixtures/tween-visual.json +28 -0
- package/tests/fixtures/video-series.json +54 -0
- package/tests/md-descriptive.test.ts +742 -0
- package/tests/render.test.ts +985 -0
- package/tests/schema.test.ts +68 -0
- package/tests/server.test.ts +308 -0
- package/tests/utils.ts +391 -0
- package/tests/vitest.config.ts +18 -0
- package/tests/vitest.integration.config.ts +16 -0
- 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
|
+
}
|