@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 +89 -0
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +1 -1
- package/skills/markcut/docs/markdown-descriptive.md +2 -2
- package/src/descriptive/resolve.ts +3 -3
- package/src/render/cli.mjs +3 -3
- package/src/vision/cli.mjs +43 -8
- package/tests/vision.test.ts +207 -0
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
package/skills/markcut/SKILL.md
CHANGED
|
@@ -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 #
|
|
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:{
|
|
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:{
|
|
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
|
|
947
|
+
// Insert seed: <N> right after the `# video` line, before any config.
|
|
948
948
|
const lines = raw.split("\n");
|
|
949
|
-
const
|
|
950
|
-
const insertAt =
|
|
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")) {
|
package/src/render/cli.mjs
CHANGED
|
@@ -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>
|
|
76
|
-
--label
|
|
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
|
package/src/vision/cli.mjs
CHANGED
|
@@ -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>
|
|
1097
|
-
markcut vision <folder> --label
|
|
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
|
|
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 === "--
|
|
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
|
-
|
|
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
|
+
});
|