@lalalic/markcut 3.2.2 → 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/README.md +0 -24
- package/package.json +1 -1
- package/src/render/cli.mjs +0 -10
- package/src/vision/chatgpt-browser-cli.mjs +0 -122
- package/tests/vision-chatgpt-browser.test.ts +0 -50
package/README.md
CHANGED
|
@@ -342,27 +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 Neo `chatgpt-browser-worker` agent as the configurable ITT/VTT backend without changing the default local VLMs. Each invocation creates one unique durable file output plus unique Neo job/task correlation, then hands the complete one-shot request to the existing browser-worker agent runtime.
|
|
350
|
-
|
|
351
|
-
For an installed package, select it with the existing CLI-template surface:
|
|
352
|
-
|
|
353
|
-
```bash
|
|
354
|
-
export MARKCUT_ITT_CLI='markcut vision-chatgpt --mode image --prompt "{prompt}" --input {input}'
|
|
355
|
-
export MARKCUT_VTT_CLI='markcut vision-chatgpt --mode video --prompt "{prompt}" --input {input}'
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
For a source checkout, replace `markcut` above with `node src/render/cli.mjs`.
|
|
359
|
-
|
|
360
|
-
The facade intentionally does not call `chatgpt-browser-worker/scripts/*` or automate Chrome itself. Set `MARKCUT_CHATGPT_BROWSER_WORKER_AGENT_CLI` to the local command that launches the Neo **browser-worker agent/runtime**. The older `MARKCUT_CHATGPT_BROWSER_WORKER_CLI` name remains as a compatibility fallback.
|
|
361
|
-
|
|
362
|
-
- `MARKCUT_CHATGPT_PROMPT_FILE` — complete one-shot prompt, including the exact `Output: file` contract and worker-owned event IDs.
|
|
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.
|
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,122 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execFileSync, execSync } from "node:child_process";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
7
|
-
|
|
8
|
-
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".heic", ".avif"]);
|
|
9
|
-
const VIDEO_EXTS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv"]);
|
|
10
|
-
|
|
11
|
-
function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
|
|
12
|
-
|
|
13
|
-
export function parseArgs(argv) {
|
|
14
|
-
const out = { mode: "auto", inputs: [], prompt: "", timeoutMs: Number(process.env.MARKCUT_CHATGPT_VISION_TIMEOUT_MS) || 600_000, maxFrames: 8 };
|
|
15
|
-
for (let i = 0; i < argv.length; i++) {
|
|
16
|
-
const arg = argv[i];
|
|
17
|
-
if (arg === "--mode") out.mode = argv[++i] || "auto";
|
|
18
|
-
else if (arg === "--prompt") out.prompt = argv[++i] || "";
|
|
19
|
-
else if (arg === "--input") {
|
|
20
|
-
while (argv[i + 1] && !argv[i + 1].startsWith("--")) out.inputs.push(argv[++i]);
|
|
21
|
-
} else if (arg === "--timeout-ms") out.timeoutMs = Number(argv[++i]);
|
|
22
|
-
else if (arg === "--max-frames") out.maxFrames = Number(argv[++i]);
|
|
23
|
-
else if (!arg.startsWith("--")) out.inputs.push(arg);
|
|
24
|
-
else throw new Error(`Unknown argument: ${arg}`);
|
|
25
|
-
}
|
|
26
|
-
if (!out.prompt) throw new Error("--prompt is required");
|
|
27
|
-
if (out.inputs.length === 0) throw new Error("--input is required");
|
|
28
|
-
if (!Number.isFinite(out.timeoutMs) || out.timeoutMs <= 0) throw new Error("--timeout-ms must be > 0");
|
|
29
|
-
if (!Number.isFinite(out.maxFrames) || out.maxFrames < 1 || out.maxFrames > 16) throw new Error("--max-frames must be 1..16");
|
|
30
|
-
out.inputs = out.inputs.map((p) => resolve(p.replace(/^@/, "")));
|
|
31
|
-
for (const input of out.inputs) if (!existsSync(input)) throw new Error(`Input not found: ${input}`);
|
|
32
|
-
if (out.mode === "auto") out.mode = out.inputs.some((p) => VIDEO_EXTS.has(extname(p).toLowerCase())) ? "video" : "image";
|
|
33
|
-
if (!['image', 'video'].includes(out.mode)) throw new Error("--mode must be image, video, or auto");
|
|
34
|
-
return out;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function ffprobeDuration(videoPath) {
|
|
38
|
-
const raw = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath], { encoding: "utf8" }).trim();
|
|
39
|
-
const value = Number(raw);
|
|
40
|
-
if (!Number.isFinite(value) || value <= 0) throw new Error(`Unable to read video duration: ${videoPath}`);
|
|
41
|
-
return value;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function prepareMedia(mode, inputs, workDir, maxFrames = 8) {
|
|
45
|
-
if (mode === "image") return { files: inputs, context: inputs.map((p) => `Image: ${basename(p)}`).join("\n") };
|
|
46
|
-
if (inputs.length !== 1) throw new Error("video mode accepts exactly one input video");
|
|
47
|
-
const videoPath = inputs[0];
|
|
48
|
-
if (!VIDEO_EXTS.has(extname(videoPath).toLowerCase())) throw new Error(`Unsupported video input: ${videoPath}`);
|
|
49
|
-
const duration = ffprobeDuration(videoPath);
|
|
50
|
-
const count = Math.max(1, Math.min(maxFrames, Math.ceil(duration / 5)));
|
|
51
|
-
const framesDir = join(workDir, "frames");
|
|
52
|
-
mkdirSync(framesDir, { recursive: true });
|
|
53
|
-
const pattern = join(framesDir, "frame-%03d.jpg");
|
|
54
|
-
const fps = count / duration;
|
|
55
|
-
execFileSync("ffmpeg", ["-y", "-i", videoPath, "-vf", `fps=${fps},scale='min(640,iw)':-2`, "-frames:v", String(count), "-q:v", "3", pattern], { stdio: "ignore" });
|
|
56
|
-
const frames = readdirSync(framesDir).filter((f) => f.endsWith(".jpg")).sort().map((f) => join(framesDir, f));
|
|
57
|
-
if (frames.length === 0) throw new Error("No representative frames extracted from video");
|
|
58
|
-
const cols = Math.min(4, frames.length);
|
|
59
|
-
const contact = join(workDir, "contact-sheet.jpg");
|
|
60
|
-
if (frames.length === 1) {
|
|
61
|
-
execFileSync("ffmpeg", ["-y", "-i", frames[0], "-vf", "scale=320:-2", "-frames:v", "1", contact], { stdio: "ignore" });
|
|
62
|
-
} else {
|
|
63
|
-
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" });
|
|
64
|
-
}
|
|
65
|
-
const timing = frames.map((p, i) => `${basename(p)} ≈ ${(i * duration / frames.length).toFixed(1)}s`).join(", ");
|
|
66
|
-
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
|
-
}
|
|
68
|
-
|
|
69
|
-
function waitForResult(outputPath, timeoutMs) {
|
|
70
|
-
const deadline = Date.now() + timeoutMs;
|
|
71
|
-
while (Date.now() < deadline) {
|
|
72
|
-
if (existsSync(outputPath)) {
|
|
73
|
-
const text = readFileSync(outputPath, "utf8").trim();
|
|
74
|
-
if (text) return text;
|
|
75
|
-
}
|
|
76
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
|
|
77
|
-
}
|
|
78
|
-
throw new Error(`Timed out waiting for Browser ChatGPT result: ${outputPath}`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function runVision({ mode, inputs, prompt, timeoutMs, maxFrames }, env = process.env) {
|
|
82
|
-
const launcher = env.MARKCUT_CHATGPT_BROWSER_WORKER_AGENT_CLI || env.MARKCUT_CHATGPT_BROWSER_WORKER_CLI;
|
|
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
|
-
}
|
|
86
|
-
const workDir = mkdtempSync(join(tmpdir(), "markcut-chatgpt-vision-"));
|
|
87
|
-
const outputPath = join(workDir, "result.txt");
|
|
88
|
-
try {
|
|
89
|
-
const media = prepareMedia(mode, inputs, workDir, maxFrames);
|
|
90
|
-
const jobId = `markcut-vision-${randomUUID()}`;
|
|
91
|
-
const taskId = `browser-vision-${randomUUID()}`;
|
|
92
|
-
const fullPrompt = `${prompt}\n\nMedia context:\n${media.context}\n\nAnalyze only the attached media. Preserve chronology for video. Write only the final answer to the declared file output.\n\nExecution event contract:\n- Job: ${jobId}\n- Task: ${taskId}\n- Publish exactly one worker-owned task.started before analysis.\n- Publish exactly one worker-owned task.completed only after the output file is durable; publish task.failed instead if execution cannot complete.\n- task.process.* events are carrier lifecycle only and never substitute for task lifecycle.\n\nOutput:\nfile\n${outputPath}`;
|
|
93
|
-
const promptPath = join(workDir, "prompt.txt");
|
|
94
|
-
writeFileSync(promptPath, fullPrompt, "utf8");
|
|
95
|
-
const childEnv = {
|
|
96
|
-
...env,
|
|
97
|
-
MARKCUT_CHATGPT_PROMPT_FILE: promptPath,
|
|
98
|
-
MARKCUT_CHATGPT_MEDIA_FILES_JSON: JSON.stringify(media.files),
|
|
99
|
-
MARKCUT_CHATGPT_OUTPUT_FILE: outputPath,
|
|
100
|
-
MARKCUT_CHATGPT_TAB_CLOSE_POLICY: "after-terminal",
|
|
101
|
-
NEO_JOB_ID: jobId,
|
|
102
|
-
NEO_TASK_ID: taskId,
|
|
103
|
-
};
|
|
104
|
-
execSync(launcher, { env: childEnv, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs });
|
|
105
|
-
return waitForResult(outputPath, timeoutMs);
|
|
106
|
-
} finally {
|
|
107
|
-
rmSync(workDir, { recursive: true, force: true });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function main(argv = process.argv.slice(2), env = process.env) {
|
|
112
|
-
try {
|
|
113
|
-
const result = runVision(parseArgs(argv), env);
|
|
114
|
-
process.stdout.write(`${result}\n`);
|
|
115
|
-
return 0;
|
|
116
|
-
} catch (error) {
|
|
117
|
-
process.stderr.write(`markcut chatgpt vision: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
118
|
-
return 1;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (process.argv[1] && process.argv[1].endsWith("chatgpt-browser-cli.mjs")) process.exitCode = main();
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, 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
|
-
function makeMock(root: string) {
|
|
12
|
-
const path = join(root, "mock-worker.mjs");
|
|
13
|
-
writeFileSync(path, `import {readFileSync,writeFileSync} from 'node:fs';\nconst p=readFileSync(process.env.MARKCUT_CHATGPT_PROMPT_FILE,'utf8');\nif(!p.includes('Output:\\nfile\\n'+process.env.MARKCUT_CHATGPT_OUTPUT_FILE)) process.exit(3);\nif(!process.env.NEO_JOB_ID?.startsWith('markcut-vision-')||!process.env.NEO_TASK_ID?.startsWith('browser-vision-')) process.exit(5);\nif(process.env.MARKCUT_CHATGPT_TAB_CLOSE_POLICY!=='after-terminal') process.exit(6);\nif(!p.includes('Publish exactly one worker-owned task.started')||!p.includes(process.env.NEO_JOB_ID)||!p.includes(process.env.NEO_TASK_ID)) process.exit(7);\nconst files=JSON.parse(process.env.MARKCUT_CHATGPT_MEDIA_FILES_JSON);\nif(!files.length||files.some(f=>!readFileSync(f))) process.exit(4);\nwriteFileSync(process.env.MARKCUT_CHATGPT_OUTPUT_FILE,'mock answer');\n`);
|
|
14
|
-
return `node ${JSON.stringify(path)}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
it("parses Markcut template-style image arguments", () => {
|
|
18
|
-
const root = temp(); const a = join(root, "a.jpg"); const b = join(root, "b.jpg"); writeFileSync(a, "a"); writeFileSync(b, "b");
|
|
19
|
-
const args = parseArgs(["--mode", "image", "--prompt", "describe", "--input", `@${a}`, b]);
|
|
20
|
-
expect(args.mode).toBe("image"); expect(args.inputs).toEqual([a, b]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("uses a unique file output per invocation and propagates result", () => {
|
|
24
|
-
const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
|
|
25
|
-
const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_WORKER_AGENT_CLI: launcher } as NodeJS.ProcessEnv;
|
|
26
|
-
expect(runVision({ mode:"image", inputs:[image], prompt:"describe", timeoutMs:2000, maxFrames:8 }, env)).toBe("mock answer");
|
|
27
|
-
expect(runVision({ mode:"image", inputs:[image], prompt:"describe again", timeoutMs:2000, maxFrames:8 }, env)).toBe("mock answer");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("extracts deterministic chronological video representation", () => {
|
|
31
|
-
const root = temp(); const video = join(root, "clip.mp4"); const work = join(root, "work");
|
|
32
|
-
execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "testsrc=s=64x64:d=2:r=4", "-c:v", "libx264", video], { stdio:"ignore" });
|
|
33
|
-
const prepared = prepareMedia("video", [video], work, 4);
|
|
34
|
-
expect(prepared.files).toHaveLength(1); expect(existsSync(prepared.files[0])).toBe(true);
|
|
35
|
-
expect(prepared.context).toContain("chronological"); expect(prepared.context).toContain("Duration:");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("returns nonzero from CLI when launcher fails", () => {
|
|
39
|
-
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, MARKCUT_CHATGPT_BROWSER_WORKER_CLI: "exit 7" }, stdio:"pipe" })).toThrow();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("concurrency", () => {
|
|
44
|
-
it("does not collide across simultaneous invocations", async () => {
|
|
45
|
-
const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
|
|
46
|
-
const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_WORKER_AGENT_CLI: launcher } as NodeJS.ProcessEnv;
|
|
47
|
-
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))));
|
|
48
|
-
expect(results).toEqual(["mock answer", "mock answer", "mock answer", "mock answer"]);
|
|
49
|
-
});
|
|
50
|
-
});
|