@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 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 `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.
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
- For an installed package, select it with the existing CLI-template surface:
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
- 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
+ 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
- - `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.
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.2",
3
+ "version": "3.2.3",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
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";
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
- const count = Math.max(1, Math.min(maxFrames, Math.ceil(duration / 5)));
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 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}`);
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.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
- }
85
+ const launcher = env.MARKCUT_CHATGPT_BROWSER_INFER_CLI || "chatgpt-browser-infer";
86
86
  const workDir = mkdtempSync(join(tmpdir(), "markcut-chatgpt-vision-"));
87
- const outputPath = join(workDir, "result.txt");
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
- 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);
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, readFileSync, rmSync, writeFileSync } from "node:fs";
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
- 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`);
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("uses a unique file output per invocation and propagates result", () => {
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, 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");
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 launcher fails", () => {
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, MARKCUT_CHATGPT_BROWSER_WORKER_CLI: "exit 7" }, stdio:"pipe" })).toThrow();
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("does not collide across simultaneous invocations", async () => {
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, 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"]);
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
  });