@lalalic/markcut 2.5.1 → 2.7.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/AGENTS.md +15 -16
- 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/components/StoryboardCaption.tsx +74 -0
- package/src/components/StoryboardInfo.tsx +195 -0
- package/src/components/StoryboardSlot.tsx +96 -0
- package/src/config.mjs +2 -1
- package/src/descriptive/compiler.ts +52 -12
- package/src/descriptive/markdown.ts +5 -1
- package/src/descriptive/resolve.ts +160 -5
- package/src/entry.tsx +28 -5
- package/src/player/bundle/player.js +395 -65
- package/src/player/pipeline.mjs +149 -16
- package/src/player/pipeline.ts +4 -0
- package/src/player/server-shared.mjs +25 -9
- package/src/player/server.mjs +11 -3
- package/src/render/cli.mjs +12 -4
- package/src/schema/index.ts +1 -1
- package/src/types/Folder.tsx +1 -1
- package/src/types/Subtitle.tsx +4 -0
- package/src/vision/cli.mjs +43 -8
- package/tests/tmp/vision-1784830584961/images/.normalized/test-photo_384.jpg +0 -0
- package/tests/tmp/vision-1784830584961/images/metadata.json +8 -0
- package/tests/tmp/vision-1784830584961/images/test-photo.png +0 -0
- package/tests/vision.test.ts +207 -0
- package/tmp/frontmatter-test.ts +21 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parseMarkdownDescriptive } from '../src/descriptive/markdown';
|
|
2
|
+
import { applyStoryboardOverrides } from '../src/descriptive/resolve';
|
|
3
|
+
import { compileDescriptiveRoot } from '../src/descriptive/compiler';
|
|
4
|
+
|
|
5
|
+
const md = [
|
|
6
|
+
'---',
|
|
7
|
+
'title: "My Book"',
|
|
8
|
+
'author: John Doe',
|
|
9
|
+
'keywords: dog, confidence, training',
|
|
10
|
+
'---',
|
|
11
|
+
'# video',
|
|
12
|
+
'width:1080 height:1920 fps:30',
|
|
13
|
+
'## Test',
|
|
14
|
+
'- image prompt:"a cat" duration:3',
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
const root = parseMarkdownDescriptive(md);
|
|
18
|
+
console.log('rawFrontmatter:', (root as any).rawFrontmatter);
|
|
19
|
+
const r = applyStoryboardOverrides(root, { variants: ['default'] });
|
|
20
|
+
const c = compileDescriptiveRoot(r);
|
|
21
|
+
console.log('data:', JSON.stringify(c.children[0].data, null, 2));
|