@lalalic/markcut 3.2.3 → 3.2.4

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 CHANGED
@@ -13,9 +13,6 @@ GOOGLE_MAPS_API_KEY=your_key_here
13
13
  # Shared by vision and render pipelines:
14
14
  # MARKCUT_ITT_CLI= # Image-to-text — placeholders: {input} {prompt}
15
15
  # MARKCUT_VTT_CLI= # Video-to-text — placeholders: {input} {prompt}
16
- # MARKCUT_CHATGPT_BROWSER_INFER_CLI=chatgpt-browser-infer # authenticated synchronous browser inference
17
- # MARKCUT_CHATGPT_VISION_TIMEOUT_MS=600000
18
- # MARKCUT_CHATGPT_DIRECT_VIDEO=0 # set 1 to try experimental direct MP4 before deterministic frames
19
16
  # MARKCUT_STT_CLI= # Speech-to-text — placeholders: {input} {output}
20
17
  # MARKCUT_TTS_CLI= # Text-to-speech — placeholders: {input} {output}
21
18
  # MARKCUT_AGENT_CLI= # General-purpose agent — placeholders: {prompt}
package/README.md CHANGED
@@ -342,22 +342,3 @@ flowchart LR
342
342
  | `--show-prompts` | Print the prompts template file and exit |
343
343
 
344
344
  ## Architecture
345
-
346
-
347
- ### Browser ChatGPT Vision backend
348
-
349
- Markcut can use the authenticated Neo Browser ChatGPT inference command as its configurable ITT/VTT backend without changing the default local VLMs. Each inference uses a fresh worker-owned Temporary Chat tab, uploads the media, waits for a verified user turn and complete assistant result in the same tab, then closes that owned tab. It does not depend on reopening a ChatGPT conversation.
350
-
351
- Expose the Neo inference command on `PATH` as `chatgpt-browser-infer`, or point Markcut at it explicitly:
352
-
353
- ```bash
354
- export MARKCUT_CHATGPT_BROWSER_INFER_CLI='/path/to/neo/skills/chatgpt-browser-worker/bin/chatgpt-browser-infer'
355
- export MARKCUT_ITT_CLI='markcut vision-chatgpt --mode image --prompt "{prompt}" --input {input}'
356
- export MARKCUT_VTT_CLI='markcut vision-chatgpt --mode video --prompt "{prompt}" --input {input}'
357
- ```
358
-
359
- For a source checkout, replace `markcut` above with `node src/render/cli.mjs`.
360
-
361
- Image inputs are uploaded directly. Video uses deterministic chronological frame sampling by default, builds a contact sheet with timing context, and analyzes that image through the same inference surface. This is the production path because direct MP4 upload through the current ChatGPT web client was materially slower and did not complete reliably in E2E testing. Set `MARKCUT_CHATGPT_DIRECT_VIDEO=1` only to experiment with direct MP4 first; failure still falls back to frames. Prompts requesting JSON enable strict whole-response JSON validation; truncated or prose-wrapped JSON is never accepted as success (a single whole-response JSON code fence is normalized), and incomplete/failed inference attempts are retried in a fresh owned tab.
362
-
363
- `MARKCUT_CHATGPT_VISION_TIMEOUT_MS` controls the overall Markcut-side timeout. The inference command itself owns attachment readiness, submission verification, complete-result detection, retries, and owned-tab cleanup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "3.2.3",
3
+ "version": "3.2.4",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -81,11 +81,6 @@ Commands:
81
81
  --label Add interactive labeling step before AI pipeline
82
82
  --instruct "text" Background context about people/places (injected into prompts)
83
83
 
84
- vision-chatgpt Browser ChatGPT ITT/VTT facade
85
- --mode image|video|auto Media mode (default: auto)
86
- --prompt "text" Vision instructions
87
- --input <path...> Local media input(s)
88
-
89
84
  spots --waypoints "lat,lng;..." Discover POIs along a route (Directions + Places API)
90
85
  --travelMode DRIVING DRIVING | WALKING | BICYCLING (default DRIVING)
91
86
  --limit 8 Max spots after ranking
@@ -228,11 +223,6 @@ edit=${DEFAULT_EDIT_CLI}`);
228
223
  process.exit(0);
229
224
  }
230
225
 
231
- if (args.command === "vision-chatgpt") {
232
- const { main: visionChatGptMain } = await import("../vision/chatgpt-browser-cli.mjs");
233
- process.exit(visionChatGptMain(process.argv.slice(3)));
234
- }
235
-
236
226
  if (args.command === "spots") {
237
227
  await import("../spots/cli.mjs"); // self-executing top-level script
238
228
  process.exit(0);
@@ -1,141 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execFileSync, execSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { basename, extname, join, resolve } from "node:path";
6
-
7
- const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".heic", ".avif"]);
8
- const VIDEO_EXTS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv"]);
9
-
10
- function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
11
-
12
- export function parseArgs(argv) {
13
- const out = { mode: "auto", inputs: [], prompt: "", timeoutMs: Number(process.env.MARKCUT_CHATGPT_VISION_TIMEOUT_MS) || 600_000, maxFrames: 8 };
14
- for (let i = 0; i < argv.length; i++) {
15
- const arg = argv[i];
16
- if (arg === "--mode") out.mode = argv[++i] || "auto";
17
- else if (arg === "--prompt") out.prompt = argv[++i] || "";
18
- else if (arg === "--input") {
19
- while (argv[i + 1] && !argv[i + 1].startsWith("--")) out.inputs.push(argv[++i]);
20
- } else if (arg === "--timeout-ms") out.timeoutMs = Number(argv[++i]);
21
- else if (arg === "--max-frames") out.maxFrames = Number(argv[++i]);
22
- else if (!arg.startsWith("--")) out.inputs.push(arg);
23
- else throw new Error(`Unknown argument: ${arg}`);
24
- }
25
- if (!out.prompt) throw new Error("--prompt is required");
26
- if (out.inputs.length === 0) throw new Error("--input is required");
27
- if (!Number.isFinite(out.timeoutMs) || out.timeoutMs <= 0) throw new Error("--timeout-ms must be > 0");
28
- if (!Number.isFinite(out.maxFrames) || out.maxFrames < 1 || out.maxFrames > 16) throw new Error("--max-frames must be 1..16");
29
- out.inputs = out.inputs.map((p) => resolve(p.replace(/^@/, "")));
30
- for (const input of out.inputs) if (!existsSync(input)) throw new Error(`Input not found: ${input}`);
31
- if (out.mode === "auto") out.mode = out.inputs.some((p) => VIDEO_EXTS.has(extname(p).toLowerCase())) ? "video" : "image";
32
- if (!['image', 'video'].includes(out.mode)) throw new Error("--mode must be image, video, or auto");
33
- return out;
34
- }
35
-
36
- function ffprobeDuration(videoPath) {
37
- const raw = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath], { encoding: "utf8" }).trim();
38
- const value = Number(raw);
39
- if (!Number.isFinite(value) || value <= 0) throw new Error(`Unable to read video duration: ${videoPath}`);
40
- return value;
41
- }
42
-
43
- export function prepareMedia(mode, inputs, workDir, maxFrames = 8) {
44
- if (mode === "image") return { files: inputs, context: inputs.map((p) => `Image: ${basename(p)}`).join("\n") };
45
- if (inputs.length !== 1) throw new Error("video mode accepts exactly one input video");
46
- const videoPath = inputs[0];
47
- if (!VIDEO_EXTS.has(extname(videoPath).toLowerCase())) throw new Error(`Unsupported video input: ${videoPath}`);
48
- const duration = ffprobeDuration(videoPath);
49
- // Preserve chronology even for short clips. A one-frame fallback cannot
50
- // distinguish ordering, so request at least two samples whenever maxFrames
51
- // permits it; longer videos still scale at roughly one sample per 5 seconds.
52
- const count = Math.max(1, Math.min(maxFrames, Math.max(2, Math.ceil(duration / 5))));
53
- const framesDir = join(workDir, "frames");
54
- mkdirSync(framesDir, { recursive: true });
55
- const pattern = join(framesDir, "frame-%03d.jpg");
56
- const fps = count / duration;
57
- execFileSync("ffmpeg", ["-y", "-i", videoPath, "-vf", `fps=${fps},scale='min(640,iw)':-2`, "-frames:v", String(count), "-q:v", "3", pattern], { stdio: "ignore" });
58
- const frames = readdirSync(framesDir).filter((f) => f.endsWith(".jpg")).sort().map((f) => join(framesDir, f));
59
- if (frames.length === 0) throw new Error("No representative frames extracted from video");
60
- const cols = Math.min(4, frames.length);
61
- const contact = join(workDir, "contact-sheet.jpg");
62
- if (frames.length === 1) {
63
- execFileSync("ffmpeg", ["-y", "-i", frames[0], "-vf", "scale=320:-2", "-frames:v", "1", contact], { stdio: "ignore" });
64
- } else {
65
- execSync(`ffmpeg -y ${frames.map((p) => `-i ${shellQuote(p)}`).join(" ")} -filter_complex ${shellQuote(`xstack=inputs=${frames.length}:layout=${frames.map((_, i) => `${i % cols}*w0_${Math.floor(i / cols)}*h0`).join('|')},scale=${cols * 320}:-2`)} -frames:v 1 ${shellQuote(contact)}`, { stdio: "ignore" });
66
- }
67
- const timing = frames.map((p, i) => `${basename(p)} ≈ ${(i * duration / frames.length).toFixed(1)}s`).join(", ");
68
- return { files: [contact], context: `Video: ${basename(videoPath)}\nDuration: ${duration.toFixed(2)}s\nRepresentative frames are chronological, left-to-right then top-to-bottom.\nTiming: ${timing}` };
69
- }
70
-
71
- function runInference(launcher, prompt, files, timeoutMs, env) {
72
- const expectJson = /\bjson\b/i.test(prompt);
73
- const attemptSeconds = Math.max(30, Math.floor(timeoutMs / 1000 / 3));
74
- const parts = [launcher, "--prompt", shellQuote(prompt), "--result-timeout", String(attemptSeconds), "--attempts", "3"];
75
- if (expectJson) parts.push("--expect-json");
76
- for (const file of files) parts.push("--file", shellQuote(file));
77
- return execSync(parts.join(" "), { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs }).trim();
78
- }
79
-
80
- function mediaPrompt(prompt, context) {
81
- return `${prompt}\n\nMedia context:\n${context}\n\nAnalyze only the attached media. Preserve chronology for video.`;
82
- }
83
-
84
- export function runVision({ mode, inputs, prompt, timeoutMs, maxFrames }, env = process.env) {
85
- const launcher = env.MARKCUT_CHATGPT_BROWSER_INFER_CLI || "chatgpt-browser-infer";
86
- const workDir = mkdtempSync(join(tmpdir(), "markcut-chatgpt-vision-"));
87
- const deadline = Date.now() + timeoutMs;
88
- const remaining = () => {
89
- const value = deadline - Date.now();
90
- if (value <= 0) throw new Error("ChatGPT browser vision overall timeout expired");
91
- return value;
92
- };
93
- try {
94
- if (mode === "image") {
95
- const media = prepareMedia("image", inputs, workDir, maxFrames);
96
- return runInference(launcher, mediaPrompt(prompt, media.context), media.files, remaining(), env);
97
- }
98
- if (inputs.length !== 1) throw new Error("video mode accepts exactly one input video");
99
- const videoPath = inputs[0];
100
- let directError = null;
101
- if (env.MARKCUT_CHATGPT_DIRECT_VIDEO === "1") {
102
- const directContext = `Video: ${basename(videoPath)}\nAnalyze the video directly and preserve chronology.`;
103
- try {
104
- // Direct MP4 analysis is experimental because the web client may accept
105
- // the upload without exposing usable temporal media to the model. Never
106
- // let it consume the entire caller budget needed for frame fallback.
107
- const directBudget = Math.max(1, Math.min(remaining(), Math.floor(timeoutMs / 2)));
108
- return runInference(launcher, mediaPrompt(prompt, directContext), [videoPath], directBudget, env);
109
- } catch (error) {
110
- directError = error;
111
- }
112
- }
113
- const fallback = prepareMedia("video", inputs, workDir, maxFrames);
114
- try {
115
- const fallbackContext = directError
116
- ? `${fallback.context}\nDirect video upload failed; using deterministic frame analysis.`
117
- : `${fallback.context}\nUsing deterministic frame analysis.`;
118
- return runInference(launcher, mediaPrompt(prompt, fallbackContext), fallback.files, remaining(), env);
119
- } catch (fallbackError) {
120
- const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
121
- if (!directError) throw new Error(`ChatGPT video frame inference failed: ${fallbackMessage}`);
122
- const directMessage = directError instanceof Error ? directError.message : String(directError);
123
- throw new Error(`ChatGPT video inference failed directly and via frame fallback. direct=${directMessage}; fallback=${fallbackMessage}`);
124
- }
125
- } finally {
126
- rmSync(workDir, { recursive: true, force: true });
127
- }
128
- }
129
-
130
- export function main(argv = process.argv.slice(2), env = process.env) {
131
- try {
132
- const result = runVision(parseArgs(argv), env);
133
- process.stdout.write(`${result}\n`);
134
- return 0;
135
- } catch (error) {
136
- process.stderr.write(`markcut chatgpt vision: ${error instanceof Error ? error.message : String(error)}\n`);
137
- return 1;
138
- }
139
- }
140
-
141
- if (process.argv[1] && process.argv[1].endsWith("chatgpt-browser-cli.mjs")) process.exitCode = main();
@@ -1,99 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { execFileSync } from "node:child_process";
6
- import { parseArgs, prepareMedia, runVision } from "../src/vision/chatgpt-browser-cli.mjs";
7
-
8
- const roots: string[] = [];
9
- afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); });
10
- function temp() { const p = mkdtempSync(join(tmpdir(), "markcut-browser-test-")); roots.push(p); return p; }
11
-
12
- function makeMock(root: string, { failVideo = false } = {}) {
13
- const path = join(root, "mock-infer.mjs");
14
- writeFileSync(path, `
15
- const args=process.argv.slice(2);
16
- const files=[];
17
- let prompt='';
18
- for(let i=0;i<args.length;i++){
19
- if(args[i]==='--prompt') prompt=args[++i];
20
- else if(args[i]==='--file') files.push(args[++i]);
21
- }
22
- if(!prompt.includes('Media context:')) process.exit(3);
23
- if(!files.length) process.exit(4);
24
- if(${failVideo ? "true" : "false"} && files.some(f=>f.endsWith('.mp4'))) process.exit(7);
25
- process.stdout.write(JSON.stringify({prompt,files})+'\\n');
26
- `);
27
- return `node ${JSON.stringify(path)}`;
28
- }
29
-
30
- it("parses Markcut template-style image arguments", () => {
31
- const root = temp(); const a = join(root, "a.jpg"); const b = join(root, "b.jpg"); writeFileSync(a, "a"); writeFileSync(b, "b");
32
- const args = parseArgs(["--mode", "image", "--prompt", "describe", "--input", `@${a}`, b]);
33
- expect(args.mode).toBe("image"); expect(args.inputs).toEqual([a, b]);
34
- });
35
-
36
- it("returns synchronous browser inference stdout", () => {
37
- const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
38
- const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher } as NodeJS.ProcessEnv;
39
- const result = JSON.parse(runVision({ mode:"image", inputs:[image], prompt:"describe", timeoutMs:2000, maxFrames:8 }, env));
40
- expect(result.files).toEqual([image]);
41
- expect(result.prompt).toContain("Image: a.jpg");
42
- });
43
-
44
- it("extracts deterministic chronological video representation", () => {
45
- const root = temp(); const video = join(root, "clip.mp4"); const work = join(root, "work");
46
- execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "testsrc=s=64x64:d=2:r=4", "-c:v", "libx264", video], { stdio:"ignore" });
47
- const prepared = prepareMedia("video", [video], work, 4);
48
- expect(prepared.files).toHaveLength(1); expect(existsSync(prepared.files[0])).toBe(true);
49
- expect(prepared.context).toContain("chronological"); expect(prepared.context).toContain("Duration:");
50
- expect(prepared.context).toContain("frame-001.jpg"); expect(prepared.context).toContain("frame-002.jpg");
51
- });
52
-
53
- it("uses deterministic frame analysis for video by default", () => {
54
- const root = temp(); const video = join(root, "clip.mp4");
55
- execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "testsrc=s=64x64:d=2:r=4", "-c:v", "libx264", video], { stdio:"ignore" });
56
- const launcher = makeMock(root);
57
- const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher } as NodeJS.ProcessEnv;
58
- delete env.MARKCUT_CHATGPT_DIRECT_VIDEO;
59
- const result = JSON.parse(runVision({ mode:"video", inputs:[video], prompt:"describe chronology", timeoutMs:4000, maxFrames:4 }, env));
60
- expect(result.files).toHaveLength(1);
61
- expect(result.files[0]).toContain("contact-sheet.jpg");
62
- expect(result.prompt).toContain("Using deterministic frame analysis");
63
- });
64
-
65
- it("can opt into direct video and falls back to deterministic frames", () => {
66
- const root = temp(); const video = join(root, "clip.mp4");
67
- execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "testsrc=s=64x64:d=2:r=4", "-c:v", "libx264", video], { stdio:"ignore" });
68
- const launcher = makeMock(root, { failVideo: true });
69
- const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher, MARKCUT_CHATGPT_DIRECT_VIDEO: "1" } as NodeJS.ProcessEnv;
70
- const result = JSON.parse(runVision({ mode:"video", inputs:[video], prompt:"describe chronology", timeoutMs:4000, maxFrames:4 }, env));
71
- expect(result.files).toHaveLength(1);
72
- expect(result.files[0]).toContain("contact-sheet.jpg");
73
- expect(result.prompt).toContain("Direct video upload failed");
74
- });
75
-
76
- it("requests strict JSON validation when prompt requires JSON", () => {
77
- const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image");
78
- const path = join(root, "expect-json.mjs");
79
- writeFileSync(path, `
80
- if(!process.argv.includes('--expect-json')) process.exit(9);
81
- process.stdout.write('{"ok":true}\\n');
82
- `);
83
- const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: `node ${JSON.stringify(path)}` } as NodeJS.ProcessEnv;
84
- expect(runVision({ mode:"image", inputs:[image], prompt:"Return JSON only", timeoutMs:2000, maxFrames:8 }, env)).toBe('{"ok":true}');
85
- });
86
-
87
- it("returns nonzero from CLI when inference fails", () => {
88
- const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image");
89
- expect(() => execFileSync("node", ["src/vision/chatgpt-browser-cli.mjs", "--mode", "image", "--prompt", "x", "--input", image], { cwd: process.cwd(), env: { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: "exit 7" }, stdio:"pipe" })).toThrow();
90
- });
91
-
92
- describe("concurrency contract", () => {
93
- it("keeps per-invocation media state isolated", async () => {
94
- const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
95
- const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher } as NodeJS.ProcessEnv;
96
- const results = await Promise.all(Array.from({ length: 4 }, (_, i) => Promise.resolve().then(() => runVision({ mode: "image", inputs: [image], prompt: `p${i}`, timeoutMs: 2000, maxFrames: 8 }, env))));
97
- expect(results.map((x) => JSON.parse(x).prompt)).toHaveLength(4);
98
- });
99
- });