@lalalic/markcut 2.5.1 → 2.6.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/README.md CHANGED
@@ -215,6 +215,95 @@ src/
215
215
  ```bash
216
216
  npm run render # render a JSON stream tree to MP4
217
217
  npm run preview # open Remotion Studio
218
+ npm run vision # analyze images/videos in a folder (metadata + AI perception)
218
219
  npm run typecheck # TypeScript type check
219
220
  npm test # run unit + integration tests
220
221
  ```
222
+
223
+ ## Vision Pipeline
224
+
225
+ ```bash
226
+ # Full pipeline: extract metadata → normalize → AI perception (images & videos)
227
+ npx markcut vision <folder>
228
+
229
+ # With interactive labeling step before AI
230
+ npx markcut vision <folder> --label
231
+
232
+ # Provide context about people/places
233
+ npx markcut vision <folder> --instruct "the girl is Maggie, the man in red is me"
234
+ ```
235
+
236
+ ```mermaid
237
+ flowchart LR
238
+ subgraph Input[Input]
239
+ A[Media Folder<br/>images + videos]
240
+ end
241
+
242
+ subgraph Metadata["1. Metadata Extraction"]
243
+ B[exiftool / ffprobe]
244
+ C[metadata.json<br/>width, height, created,<br/>GPS location, duration]
245
+ end
246
+
247
+ subgraph Normalize["2. Normalization"]
248
+ D[ffmpeg resize<br/>images → 384px<br/>videos → 360p]
249
+ end
250
+
251
+ subgraph Label["Optional: Labeling"]
252
+ E[Build preview tree<br/>with seed]
253
+ F[Label preview server<br/>user annotates scenes]
254
+ G[Merge labels → userHints<br/>into metadata.json]
255
+ end
256
+
257
+ subgraph Image["3. Image Perception"]
258
+ H[ITT: image-to-text<br/>mlx-vlm model]
259
+ I[Perception saved:<br/>description]
260
+ end
261
+
262
+ subgraph Video["4. Video Understanding"]
263
+ J[VTT: video-to-text<br/>mlx-vlm model]
264
+ K[STT: speech-to-text<br/>whisper]
265
+ L[VTT→ overall desc]
266
+ M[STT → VTT subtitles]
267
+ N[Build merged segment<br/>boundaries]
268
+ O[LLM merges boundaries<br/>into final segments]
269
+ P[Per-segment vision<br/>clip extraction + ITT]
270
+ end
271
+
272
+ subgraph Output[Output]
273
+ Q[metadata.json<br/>with perception desc,<br/>subtitles & segments]
274
+ end
275
+
276
+ A --> B --> C --> D
277
+ D --> Image
278
+ D --> Video
279
+ C -.->|--label| E --> F --> G --> D
280
+ Image --> H --> I --> Q
281
+ Video --> J --> L
282
+ Video --> K --> M
283
+ L --> N
284
+ M --> N
285
+ N --> O --> P --> Q
286
+
287
+ style Input fill:#1a1a2e,stroke:#16213e,color:#fff
288
+ style Metadata fill:#0f3460,stroke:#16213e,color:#fff
289
+ style Normalize fill:#533483,stroke:#16213e,color:#fff
290
+ style Label fill:#e94560,stroke:#16213e,color:#fff
291
+ style Image fill:#0f3460,stroke:#16213e,color:#fff
292
+ style Video fill:#0f3460,stroke:#16213e,color:#fff
293
+ style Output fill:#1a1a2e,stroke:#16213e,color:#fff
294
+ ```
295
+
296
+ ### Vision CLI Options
297
+
298
+ | Option | Description |
299
+ |---|---|
300
+ | `--label` | Add interactive labeling step before AI pipeline (opens browser preview) |
301
+ | `--instruct "text"` | Background context about people/places (injected into AI prompts) |
302
+ | `--prompts-file <path>` | Custom prompts markdown file (default: `src/vision/vision_prompts.md`) |
303
+ | `--vtt-sample-interval <n>` | Sample one video frame every N seconds for VTT (default: 5) |
304
+ | `--pick <files>` | Comma-separated filenames to process (skip others) |
305
+ | `--skip-stt` | Skip speech-to-text for videos |
306
+ | `--dry-run` | Show what would be processed without running AI |
307
+ | `--show-prompts` | Print the prompts template file and exit |
308
+
309
+ ## Architecture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -45,7 +45,7 @@ npx @lalalic/markcut --show-clis # get command specific information
45
45
  npx @lalalic/markcut preview <file> # assemble and preview the video with a local server, and chat to edit the video and auto refresh
46
46
  npx @lalalic/markcut render <file> # render the video to mp4
47
47
  npx @lalalic/markcut vision <folder> # vision understanding medias in folder
48
- npx @lalalic/markcut vision <folder> --label # full pipeline vision understanding medias in folder, and provide ui to label the medias with text, time ranges
48
+ npx @lalalic/markcut vision <folder> --label # an extra step to provide UI to label the medias with text, time ranges
49
49
 
50
50
  ```
51
51
 
@@ -16,7 +16,7 @@ This section defines the markcut Markdown Descriptive grammar. An agent should r
16
16
  │ ───...───
17
17
  ├─ # video ← Root node (REQUIRED, heading level 1)
18
18
  │ width:1080 height:1920 fps:30 layout:series
19
- │ subtitle:{src:"captions.vtt",type:"Typewriter"}
19
+ │ subtitle:{type:"Typewriter"}
20
20
  │ ~~~css stylesheet ← root.stylesheet
21
21
  │ .bg { background: #000; }
22
22
  │ ~~~
@@ -223,7 +223,7 @@ The line after `# video` holds canvas-level config as space-separated `key:value
223
223
  ```markdown
224
224
  # video
225
225
  width:1920 height:1080 fps:30 layout:series transition:fade transitionTime:1.2
226
- subtitle:{src:"captions.vtt",type:"Typewriter",fontSize:48}
226
+ subtitle:{type:"Typewriter",fontSize:48}
227
227
  ```
228
228
 
229
229
  Supported root keys: `width`, `height`, `fps`, `layout`, `tts`, `stt`, `tti`, `ttv`, `transition`, `transitionTime`, `instruction`, `metadata`, `stylesheet`, `subtitle`, `voices`.
@@ -944,10 +944,10 @@ export async function resolveAll(
944
944
  try {
945
945
  const raw = readFileSync(options.sourcePath, "utf-8");
946
946
  if (options.sourcePath.endsWith(".md")) {
947
- // Insert seed: <N> after the first line (# video) or after existing header
947
+ // Insert seed: <N> right after the `# video` line, before any config.
948
948
  const lines = raw.split("\n");
949
- const headerEnd = lines.findIndex((l) => l.trim() === "" || l.startsWith("##"));
950
- const insertAt = headerEnd > 1 ? headerEnd : Math.min(1, lines.length);
949
+ const videoIdx = lines.findIndex((l) => l.trim().startsWith("# video"));
950
+ const insertAt = videoIdx >= 0 ? videoIdx + 1 : 1;
951
951
  lines.splice(insertAt, 0, `seed:${autoSeed}`);
952
952
  writeFileSync(options.sourcePath, lines.join("\n"), "utf-8");
953
953
  } else if (options.sourcePath.endsWith(".json")) {
@@ -72,11 +72,11 @@ Commands:
72
72
  --output <path> Output path (default: out/video.mp4)
73
73
  --verbose Show full per-frame progress (default: compact)
74
74
 
75
- vision <folder> Extract metadata into metadata.json
76
- --label Full pipeline: preview label normalize → percept → segments
75
+ vision <folder> Full pipeline: extract → normalize → percept → segments
76
+ --label Add interactive labeling step before AI pipeline
77
+ --instruct "text" Background context about people/places (injected into prompts)
77
78
  --prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
78
79
  --vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
79
- --context "text" Background context about people/places (injected into prompts)
80
80
  --skip-stt Skip speech-to-text for videos
81
81
  --dry-run Show what would be processed without running AI
82
82
  --show-prompts Print the prompts file and exit
@@ -648,9 +648,12 @@ function buildPreviewTree(folder, metadata) {
648
648
  }
649
649
  entries.sort((a, b) => a.created.localeCompare(b.created));
650
650
 
651
+ // Random seed for TTI/TTV generation.
652
+ const seed = Math.floor(Math.random() * 0xFFFFFFFF);
653
+
651
654
  return {
652
655
  id: "root", type: "root", width: 1080, height: 1920, fps: 30,
653
- isSeries: true, transition: "fade", transitionTime: 0.5,
656
+ seed, isSeries: true, transition: "fade", transitionTime: 0.5,
654
657
  children: entries.map((e) => ({
655
658
  id: e.name, type: "folder", isSeries: false,
656
659
  children: [{
@@ -1093,14 +1096,14 @@ function printUsage() {
1093
1096
  markcut vision — Analyze images and videos in a folder for video generation
1094
1097
 
1095
1098
  Usage:
1096
- markcut vision <folder> Extract metadata into metadata.json
1097
- markcut vision <folder> --label Full pipeline: preview label normalize percept → segments
1099
+ markcut vision <folder> Full pipeline: extract → normalize → percept → segments
1100
+ markcut vision <folder> --label Same as default, plus interactive labeling step before AI
1098
1101
 
1099
1102
  Options:
1100
- --label Open label preview then continue with full AI pipeline
1103
+ --label Open interactive labeling preview before running AI pipeline
1104
+ --instruct "text" Background context about people/places (injected into prompts)
1101
1105
  --prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
1102
1106
  --vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
1103
- --context "text" Background context about people/places (injected into prompts)
1104
1107
  --pick <files> Comma-separated filenames to process
1105
1108
  --skip-stt Skip speech-to-text for videos
1106
1109
  --dry-run Show what would be processed without running AI
@@ -1134,9 +1137,8 @@ export async function main(args) {
1134
1137
 
1135
1138
  else if (flag === "--prompts-file" && args[i]) { promptsFile = resolve(args[i++]); }
1136
1139
  else if (flag === "--vtt-sample-interval" && args[i]) { vttSampleInterval = parseInt(args[i++], 10) || DEFAULT_VTT_SAMPLE_INTERVAL; }
1137
- else if (flag === "--context" && args[i]) { context = args[i++]; }
1140
+ else if (flag === "--instruct" && args[i]) { context = args[i++]; }
1138
1141
  else if (flag === "--show-prompts") { console.log(readFileSync(promptsFile, "utf-8")); return; }
1139
-
1140
1142
  else if (flag === "--skip-stt") { skipSTT = true; }
1141
1143
  else if (flag === "--pick" && args[i]) { for (const f of args[i++].split(",")) pickSet.add(f.trim()); }
1142
1144
  else if (flag === "--dry-run") { dryRun = true; }
@@ -1151,12 +1153,45 @@ export async function main(args) {
1151
1153
  for (const [name, value] of promptOverrides) prompts.set(name, value);
1152
1154
 
1153
1155
  if (doLabel) {
1156
+ // Full pipeline with interactive labeling
1154
1157
  await runFullPipeline(folder, prompts, context, pickSet, vttSampleInterval, skipSTT, dryRun);
1155
1158
  } else {
1156
- await runMetadataMode(folder, pickSet, dryRun);
1159
+ // Default: full pipeline without labeling (extract normalize → percept → segments)
1160
+ const metadataPath = join(folder, "metadata.json");
1161
+ if (!existsSync(metadataPath)) {
1162
+ emitInfo(`\n📋 Extracting metadata...`);
1163
+ await runMetadataMode(folder, pickSet, false);
1164
+ }
1165
+ if (!existsSync(metadataPath) || Object.keys(JSON.parse(readFileSync(metadataPath, "utf-8"))).length === 0) {
1166
+ emitError("No media files found — nothing to process.");
1167
+ return;
1168
+ }
1169
+ emitInfo(`\n🔧 Running full pipeline (normalize → perceive)...`);
1170
+ await runNormalizeAndPercept(folder, metadataPath, prompts, context, pickSet, vttSampleInterval, skipSTT);
1157
1171
  }
1158
1172
  }
1159
1173
 
1174
+ // ═════════════════════════════════════════════════════════════════════════
1175
+ // Exported for testing
1176
+ // ═════════════════════════════════════════════════════════════════════════
1177
+
1178
+ export {
1179
+ scanMedia,
1180
+ buildPreviewTree,
1181
+ mergeLabelsIntoMetadata,
1182
+ buildMergedCues,
1183
+ parseVTT,
1184
+ loadPrompts,
1185
+ substituteTemplate,
1186
+ shQuote,
1187
+ normalizePerception,
1188
+ extractRawText,
1189
+ stripThinkBlocks,
1190
+ looseJSONParse,
1191
+ formatTime,
1192
+ timeToSeconds,
1193
+ };
1194
+
1160
1195
  // Direct execution
1161
1196
  if (process.argv[1] && process.argv[1].includes("src/vision/cli.mjs")) {
1162
1197
  main(process.argv).catch((e) => { emitError(e.message); process.exit(1); });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Integration tests for the vision CLI (`markcut vision`).
3
+ *
4
+ * Tests the actual workflow end-to-end:
5
+ * 1. Metadata extraction (exiftool/ffprobe)
6
+ * 2. Media normalization (ffmpeg resize)
7
+ * 3. Image perception (ITT via mlx-vlm)
8
+ * 4. Video perception (VTT + STT + segment analysis)
9
+ */
10
+ import { describe, expect, it, beforeAll, afterAll } from "vitest";
11
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { execSync } from "node:child_process";
15
+
16
+ const __dirname = resolve(fileURLToPath(import.meta.url), "..");
17
+ const ROOT = resolve(__dirname, "..");
18
+
19
+ // ── Test Fixture Setup ────────────────────────────────────────────────────
20
+
21
+ const TEST_ROOT = resolve(ROOT, "tests", "tmp", "vision-" + Date.now());
22
+ const IMAGE_DIR = join(TEST_ROOT, "images");
23
+ const VIDEO_ROOT = TEST_ROOT + "-video";
24
+ const VIDEO_DIR = join(VIDEO_ROOT, "videos");
25
+
26
+ /** Create a small test PNG (solid red, 64x64) using ffmpeg. */
27
+ function createTestImage(outDir: string): string {
28
+ mkdirSync(outDir, { recursive: true });
29
+ const outPath = join(outDir, "test-photo.png");
30
+ execSync(
31
+ `ffmpeg -y -f lavfi -i color=c=red:s=64x64:d=1 -frames:v 1 ${outPath}`,
32
+ { stdio: "pipe" },
33
+ );
34
+ return outPath;
35
+ }
36
+
37
+ /** Create a short test video (colored bars + sine tone, 3s) using ffmpeg. */
38
+ function createTestVideo(outDir: string): string {
39
+ mkdirSync(outDir, { recursive: true });
40
+ const outPath = join(outDir, "test-clip.mp4");
41
+ execSync(
42
+ `ffmpeg -y -f lavfi -i testsrc=s=320x240:d=3:r=15 ` +
43
+ `-f lavfi -i sine=frequency=440:duration=3 ` +
44
+ `-c:v libx264 -preset ultrafast -crf 40 -c:a aac -shortest ${outPath}`,
45
+ { stdio: "pipe", timeout: 30_000 },
46
+ );
47
+ return outPath;
48
+ }
49
+
50
+ // ── Image Pipeline ───────────────────────────────────────────────────────
51
+
52
+ describe("image pipeline", () => {
53
+ let imgPath: string;
54
+
55
+ beforeAll(() => {
56
+ imgPath = createTestImage(IMAGE_DIR);
57
+ expect(existsSync(imgPath)).toBe(true);
58
+ });
59
+
60
+ afterAll(() => {
61
+ rmSync(TEST_ROOT, { recursive: true, force: true });
62
+ });
63
+
64
+ it("1. extracts metadata from image", async () => {
65
+ const { main } = await import("../src/vision/cli.mjs");
66
+ await main(["node", "cli.mjs", "vision", IMAGE_DIR]);
67
+
68
+ const metaPath = join(IMAGE_DIR, "metadata.json");
69
+ expect(existsSync(metaPath)).toBe(true);
70
+
71
+ const metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
72
+ expect(Object.keys(metadata).length).toBeGreaterThan(0);
73
+
74
+ const key = Object.keys(metadata)[0]!;
75
+ const entry = metadata[key];
76
+ expect(entry).toHaveProperty("width");
77
+ expect(entry).toHaveProperty("height");
78
+ expect(entry).toHaveProperty("created");
79
+ expect(entry.width).toBeGreaterThan(0);
80
+ expect(entry.height).toBeGreaterThan(0);
81
+ });
82
+
83
+ it("2. normalizes image", () => {
84
+ const normDir = join(IMAGE_DIR, ".normalized");
85
+ expect(existsSync(normDir)).toBe(true);
86
+
87
+ const normFiles = readdirSync(normDir).filter(f => f.endsWith(".jpg"));
88
+ expect(normFiles.length).toBeGreaterThan(0);
89
+
90
+ // Verify normalized file exists and has valid dimensions
91
+ const normPath = join(normDir, normFiles[0]!);
92
+ expect(existsSync(normPath)).toBe(true);
93
+ const probe = JSON.parse(
94
+ execSync(`ffprobe -v quiet -print_format json -show_streams ${normPath}`, { stdio: "pipe" }).toString(),
95
+ );
96
+ const stream = probe.streams?.[0];
97
+ expect(stream).toBeDefined();
98
+ expect(stream.width).toBeLessThanOrEqual(384);
99
+ expect(stream.height).toBeLessThanOrEqual(384);
100
+ });
101
+
102
+ it("3. runs image perception (ITT) and saves to metadata", () => {
103
+ const metaPath = join(IMAGE_DIR, "metadata.json");
104
+ const metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
105
+
106
+ const key = Object.keys(metadata)[0]!;
107
+ const entry = metadata[key];
108
+
109
+ expect(entry).toHaveProperty("perception");
110
+ expect(entry.perception).toHaveProperty("desc");
111
+ expect(entry.perception.desc).toBeTruthy();
112
+ expect(typeof entry.perception.desc).toBe("string");
113
+ expect(entry.perception.desc.length).toBeGreaterThan(10);
114
+ });
115
+ });
116
+
117
+ // ── Video Pipeline ───────────────────────────────────────────────────────
118
+
119
+ describe("video pipeline", () => {
120
+ let vidPath: string;
121
+
122
+ beforeAll(() => {
123
+ vidPath = createTestVideo(VIDEO_DIR);
124
+ expect(existsSync(vidPath)).toBe(true);
125
+ }, 30_000);
126
+
127
+ afterAll(() => {
128
+ rmSync(VIDEO_ROOT, { recursive: true, force: true });
129
+ });
130
+
131
+ it("1. extracts metadata from video", async () => {
132
+ const { main } = await import("../src/vision/cli.mjs");
133
+ await main(["node", "cli.mjs", "vision", VIDEO_DIR]);
134
+
135
+ const metaPath = join(VIDEO_DIR, "metadata.json");
136
+ expect(existsSync(metaPath)).toBe(true);
137
+
138
+ const metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
139
+ expect(Object.keys(metadata).length).toBeGreaterThan(0);
140
+
141
+ const key = Object.keys(metadata)[0]!;
142
+ const entry = metadata[key];
143
+ expect(entry).toHaveProperty("width");
144
+ expect(entry).toHaveProperty("height");
145
+ expect(entry).toHaveProperty("created");
146
+ expect(entry).toHaveProperty("duration");
147
+ expect(entry.duration).toBeGreaterThan(0);
148
+ }, 60_000);
149
+
150
+ it("2. normalizes video", () => {
151
+ const normDir = join(VIDEO_DIR, ".normalized");
152
+ expect(existsSync(normDir)).toBe(true);
153
+
154
+ const normFiles = readdirSync(normDir).filter(f => f.endsWith(".mp4"));
155
+ expect(normFiles.length).toBeGreaterThan(0);
156
+
157
+ const normPath = join(normDir, normFiles[0]!);
158
+ expect(existsSync(normPath)).toBe(true);
159
+ const probe = JSON.parse(
160
+ execSync(`ffprobe -v quiet -print_format json -show_streams ${normPath}`, { stdio: "pipe" }).toString(),
161
+ );
162
+ const stream = probe.streams?.find((s: any) => s.codec_type === "video");
163
+ expect(stream).toBeDefined();
164
+ expect(stream.width).toBeLessThanOrEqual(360);
165
+ expect(stream.height).toBeLessThanOrEqual(360);
166
+ });
167
+
168
+ it("3. runs video perception (VTT) and saves description", () => {
169
+ const metaPath = join(VIDEO_DIR, "metadata.json");
170
+ const metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
171
+
172
+ const key = Object.keys(metadata)[0]!;
173
+ const entry = metadata[key];
174
+
175
+ expect(entry).toHaveProperty("perception");
176
+ expect(entry.perception).toHaveProperty("desc");
177
+ expect(entry.perception.desc).toBeTruthy();
178
+ expect(typeof entry.perception.desc).toBe("string");
179
+ expect(entry.perception.desc.length).toBeGreaterThan(10);
180
+ });
181
+
182
+ it("4. runs STT and produces subtitle file", () => {
183
+ const metaPath = join(VIDEO_DIR, "metadata.json");
184
+ const metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
185
+
186
+ const key = Object.keys(metadata)[0]!;
187
+ const entry = metadata[key];
188
+
189
+ // STT may be null if no speech detected (test video has sine tone, no speech)
190
+ // But the subtitle field should exist in the perception object
191
+ expect(entry.perception).toHaveProperty("subtitle");
192
+ });
193
+
194
+ it("5. produces segment analysis", () => {
195
+ const metaPath = join(VIDEO_DIR, "metadata.json");
196
+ const metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
197
+
198
+ const key = Object.keys(metadata)[0]!;
199
+ const entry = metadata[key];
200
+
201
+ // Video segments - may be single segment if no boundaries detected
202
+ expect(entry.perception).toHaveProperty("segments");
203
+ expect(typeof entry.perception.segments).toBe("object");
204
+ // Should have at least one segment (even if it's the full video)
205
+ expect(Object.keys(entry.perception.segments).length).toBeGreaterThan(0);
206
+ });
207
+ });