@lalalic/markcut 3.2.2 → 3.2.3
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 +3 -0
- package/README.md +5 -10
- package/package.json +1 -1
- package/src/vision/chatgpt-browser-cli.mjs +54 -35
- package/tests/vision-chatgpt-browser.test.ts +64 -15
package/.env.example
CHANGED
|
@@ -13,6 +13,9 @@ 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
|
|
16
19
|
# MARKCUT_STT_CLI= # Speech-to-text — placeholders: {input} {output}
|
|
17
20
|
# MARKCUT_TTS_CLI= # Text-to-speech — placeholders: {input} {output}
|
|
18
21
|
# MARKCUT_AGENT_CLI= # General-purpose agent — placeholders: {prompt}
|
package/README.md
CHANGED
|
@@ -346,23 +346,18 @@ flowchart LR
|
|
|
346
346
|
|
|
347
347
|
### Browser ChatGPT Vision backend
|
|
348
348
|
|
|
349
|
-
Markcut can use the Neo
|
|
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
350
|
|
|
351
|
-
|
|
351
|
+
Expose the Neo inference command on `PATH` as `chatgpt-browser-infer`, or point Markcut at it explicitly:
|
|
352
352
|
|
|
353
353
|
```bash
|
|
354
|
+
export MARKCUT_CHATGPT_BROWSER_INFER_CLI='/path/to/neo/skills/chatgpt-browser-worker/bin/chatgpt-browser-infer'
|
|
354
355
|
export MARKCUT_ITT_CLI='markcut vision-chatgpt --mode image --prompt "{prompt}" --input {input}'
|
|
355
356
|
export MARKCUT_VTT_CLI='markcut vision-chatgpt --mode video --prompt "{prompt}" --input {input}'
|
|
356
357
|
```
|
|
357
358
|
|
|
358
359
|
For a source checkout, replace `markcut` above with `node src/render/cli.mjs`.
|
|
359
360
|
|
|
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.
|
|
361
362
|
|
|
362
|
-
|
|
363
|
-
- `MARKCUT_CHATGPT_MEDIA_FILES_JSON` — JSON array of attachments. Images are passed directly; video is deterministically reduced to a chronological contact sheet of representative frames plus timing context.
|
|
364
|
-
- `MARKCUT_CHATGPT_OUTPUT_FILE` — authoritative result path that the delegated ChatGPT task must write.
|
|
365
|
-
- `NEO_JOB_ID` / `NEO_TASK_ID` — unique correlation for the delegated worker's canonical `task.started` and terminal task event.
|
|
366
|
-
- `MARKCUT_CHATGPT_TAB_CLOSE_POLICY=after-terminal` — requests that only the isolated worker-owned tab remain open until the exact task terminal event.
|
|
367
|
-
|
|
368
|
-
The launcher may stay active through `after-terminal`; launcher/process exit is not treated as task completion. Markcut's only result is the declared durable output file, and it exits nonzero on launcher failure, timeout, preprocessing failure, or an empty/missing result. Carrier-only `task.process.launched`, `task.process.exited`, and `task.process.async_exited` events never substitute for worker-owned task lifecycle.
|
|
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,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync, execSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
5
|
import { basename, extname, join, resolve } from "node:path";
|
|
7
6
|
|
|
@@ -47,7 +46,10 @@ export function prepareMedia(mode, inputs, workDir, maxFrames = 8) {
|
|
|
47
46
|
const videoPath = inputs[0];
|
|
48
47
|
if (!VIDEO_EXTS.has(extname(videoPath).toLowerCase())) throw new Error(`Unsupported video input: ${videoPath}`);
|
|
49
48
|
const duration = ffprobeDuration(videoPath);
|
|
50
|
-
|
|
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))));
|
|
51
53
|
const framesDir = join(workDir, "frames");
|
|
52
54
|
mkdirSync(framesDir, { recursive: true });
|
|
53
55
|
const pattern = join(framesDir, "frame-%03d.jpg");
|
|
@@ -66,43 +68,60 @@ export function prepareMedia(mode, inputs, workDir, maxFrames = 8) {
|
|
|
66
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}` };
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.`;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
export function runVision({ mode, inputs, prompt, timeoutMs, maxFrames }, env = process.env) {
|
|
82
|
-
const launcher = env.
|
|
83
|
-
if (!launcher) {
|
|
84
|
-
throw new Error("MARKCUT_CHATGPT_BROWSER_WORKER_AGENT_CLI is required; it must launch the Neo browser-worker agent runtime");
|
|
85
|
-
}
|
|
85
|
+
const launcher = env.MARKCUT_CHATGPT_BROWSER_INFER_CLI || "chatgpt-browser-infer";
|
|
86
86
|
const workDir = mkdtempSync(join(tmpdir(), "markcut-chatgpt-vision-"));
|
|
87
|
-
const
|
|
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
|
+
};
|
|
88
93
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|
|
106
125
|
} finally {
|
|
107
126
|
rmSync(workDir, { recursive: true, force: true });
|
|
108
127
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { existsSync, mkdtempSync,
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
@@ -8,9 +8,22 @@ import { parseArgs, prepareMedia, runVision } from "../src/vision/chatgpt-browse
|
|
|
8
8
|
const roots: string[] = [];
|
|
9
9
|
afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); });
|
|
10
10
|
function temp() { const p = mkdtempSync(join(tmpdir(), "markcut-browser-test-")); roots.push(p); return p; }
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
`);
|
|
14
27
|
return `node ${JSON.stringify(path)}`;
|
|
15
28
|
}
|
|
16
29
|
|
|
@@ -20,11 +33,12 @@ it("parses Markcut template-style image arguments", () => {
|
|
|
20
33
|
expect(args.mode).toBe("image"); expect(args.inputs).toEqual([a, b]);
|
|
21
34
|
});
|
|
22
35
|
|
|
23
|
-
it("
|
|
36
|
+
it("returns synchronous browser inference stdout", () => {
|
|
24
37
|
const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
|
|
25
|
-
const env = { ...process.env,
|
|
26
|
-
|
|
27
|
-
expect(
|
|
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");
|
|
28
42
|
});
|
|
29
43
|
|
|
30
44
|
it("extracts deterministic chronological video representation", () => {
|
|
@@ -33,18 +47,53 @@ it("extracts deterministic chronological video representation", () => {
|
|
|
33
47
|
const prepared = prepareMedia("video", [video], work, 4);
|
|
34
48
|
expect(prepared.files).toHaveLength(1); expect(existsSync(prepared.files[0])).toBe(true);
|
|
35
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}');
|
|
36
85
|
});
|
|
37
86
|
|
|
38
|
-
it("returns nonzero from CLI when
|
|
87
|
+
it("returns nonzero from CLI when inference fails", () => {
|
|
39
88
|
const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image");
|
|
40
|
-
expect(() => execFileSync("node", ["src/vision/chatgpt-browser-cli.mjs", "--mode", "image", "--prompt", "x", "--input", image], { cwd: process.cwd(), env: { ...process.env,
|
|
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();
|
|
41
90
|
});
|
|
42
91
|
|
|
43
|
-
describe("concurrency", () => {
|
|
44
|
-
it("
|
|
92
|
+
describe("concurrency contract", () => {
|
|
93
|
+
it("keeps per-invocation media state isolated", async () => {
|
|
45
94
|
const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
|
|
46
|
-
const env = { ...process.env,
|
|
47
|
-
const results = await Promise.all(Array.from({length:4}, (_, i) => Promise.resolve().then(() => runVision({ mode:"image", inputs:[image], prompt
|
|
48
|
-
expect(results
|
|
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);
|
|
49
98
|
});
|
|
50
99
|
});
|