@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 +0 -3
- package/README.md +0 -19
- package/package.json +1 -1
- package/src/render/cli.mjs +0 -10
- package/src/vision/chatgpt-browser-cli.mjs +0 -141
- package/tests/vision-chatgpt-browser.test.ts +0 -99
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
package/src/render/cli.mjs
CHANGED
|
@@ -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
|
-
});
|