@lalalic/markcut 3.2.1 → 3.2.2

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 CHANGED
@@ -342,3 +342,27 @@ 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -81,6 +81,11 @@ 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
+
84
89
  spots --waypoints "lat,lng;..." Discover POIs along a route (Directions + Places API)
85
90
  --travelMode DRIVING DRIVING | WALKING | BICYCLING (default DRIVING)
86
91
  --limit 8 Max spots after ranking
@@ -223,6 +228,11 @@ edit=${DEFAULT_EDIT_CLI}`);
223
228
  process.exit(0);
224
229
  }
225
230
 
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
+
226
236
  if (args.command === "spots") {
227
237
  await import("../spots/cli.mjs"); // self-executing top-level script
228
238
  process.exit(0);
@@ -0,0 +1,408 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ existsSync, mkdirSync, readFileSync, statSync, writeFileSync,
4
+ } from "node:fs";
5
+ import { basename, dirname, extname, join, resolve } from "node:path";
6
+ import { z } from "zod";
7
+ import { execSync } from "node:child_process";
8
+ import { VIDEO_EXTS, DEFAULT_VTT_SAMPLE_INTERVAL } from "../config.mjs";
9
+
10
+ export const CANDIDATE_CONTRACT_VERSION = 1;
11
+
12
+ const evidence = z.object({
13
+ start: z.number().finite().min(0),
14
+ end: z.number().finite().gt(0),
15
+ observation: z.string().min(1),
16
+ evidence: z.string().min(1),
17
+ }).refine((item) => item.end > item.start, { message: "evidence end must exceed start" });
18
+
19
+ const evidenceItem = z.object({
20
+ start: z.number().finite().min(0),
21
+ end: z.number().finite().gt(0),
22
+ label: z.string().min(1),
23
+ observation: z.string().min(1),
24
+ evidence: z.string().min(1),
25
+ }).refine((item) => item.end > item.start, { message: "timestamped item end must exceed start" });
26
+
27
+ const observationsSchema = z.object({
28
+ summary: z.string().min(1),
29
+ speech: z.object({
30
+ supplied: z.boolean(),
31
+ speakers: z.array(z.string()),
32
+ keyQuoteAlignment: z.array(z.object({
33
+ quote: z.string().min(1),
34
+ start: z.number().finite().min(0),
35
+ end: z.number().finite().gt(0),
36
+ alignment: z.string().min(1),
37
+ evidence: z.string().min(1),
38
+ }).refine((item) => item.end > item.start)),
39
+ }),
40
+ people: z.array(z.object({
41
+ label: z.string().min(1),
42
+ position: z.string().min(1),
43
+ faceVisible: z.enum(["yes", "partial", "no", "unclear"]),
44
+ clarity: z.enum(["high", "medium", "low", "unclear"]),
45
+ emotion: z.string(),
46
+ reaction: z.string(),
47
+ action: z.string(),
48
+ evidence: z.string().min(1),
49
+ })),
50
+ scenes: z.array(z.object({
51
+ start: z.number().finite().min(0),
52
+ end: z.number().finite().gt(0),
53
+ event: z.string().min(1),
54
+ onScreenText: z.array(z.string()),
55
+ shotChange: z.boolean(),
56
+ visualQuality: z.enum(["high", "acceptable", "low", "unclear"]),
57
+ audioQuality: z.enum(["high", "acceptable", "low", "unclear"]),
58
+ evidence: z.string().min(1),
59
+ }).refine((item) => item.end > item.start)),
60
+ narrative: z.object({
61
+ setup: evidence.nullable(),
62
+ tension: evidence.nullable(),
63
+ turn: evidence.nullable(),
64
+ payoff: evidence.nullable(),
65
+ selfContained: z.boolean(),
66
+ contextNeeded: z.string(),
67
+ }),
68
+ hooks: z.array(evidenceItem),
69
+ emotionalHighlights: z.array(evidenceItem),
70
+ weakRegions: z.array(evidenceItem),
71
+ suggestedCuts: z.array(z.object({
72
+ start: z.number().finite().min(0),
73
+ end: z.number().finite().gt(0),
74
+ rationale: z.string().min(1),
75
+ evidence: z.string().min(1),
76
+ }).refine((item) => item.end > item.start)),
77
+ verticalFit: z.object({
78
+ suitability: z.enum(["good", "acceptable", "poor", "unclear"]),
79
+ cropFeasibility: z.enum(["good", "acceptable", "poor", "unclear"]),
80
+ trackingFeasibility: z.enum(["good", "acceptable", "poor", "unclear"]),
81
+ subjectSafety: z.string().min(1),
82
+ evidence: z.string().min(1),
83
+ }),
84
+ editSuggestions: z.array(z.object({
85
+ type: z.enum(["punch-in", "b-roll", "caption-emphasis"]),
86
+ start: z.number().finite().min(0),
87
+ end: z.number().finite().gt(0),
88
+ suggestion: z.string().min(1),
89
+ evidence: z.string().min(1),
90
+ }).refine((item) => item.end > item.start)),
91
+ observableEvidence: z.array(z.object({
92
+ start: z.number().finite().min(0),
93
+ end: z.number().finite().gt(0),
94
+ observation: z.string().min(1),
95
+ evidence: z.string().min(1),
96
+ }).refine((item) => item.end > item.start)),
97
+ uncertainty: z.array(z.object({
98
+ claim: z.string().min(1),
99
+ reason: z.string().min(1),
100
+ })).min(1),
101
+ }).strict();
102
+
103
+ function emitInfo(message) { console.error(message); }
104
+
105
+ function shQuote(value) {
106
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
107
+ }
108
+
109
+ function run(command, options = {}) {
110
+ return execSync(command, {
111
+ encoding: "utf-8",
112
+ stdio: ["pipe", "pipe", "pipe"],
113
+ timeout: 300_000,
114
+ ...options,
115
+ });
116
+ }
117
+
118
+ function fingerprint(path) {
119
+ try {
120
+ const stats = statSync(path);
121
+ return `${stats.mtimeMs}:${stats.size}`;
122
+ } catch {
123
+ return "0:0";
124
+ }
125
+ }
126
+
127
+ function durationOf(path) {
128
+ const output = run(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${shQuote(path)}`, { timeout: 30_000 });
129
+ return Number.parseFloat(output.trim()) || 0;
130
+ }
131
+
132
+ function numericBound(value) {
133
+ const parsed = typeof value === "number"
134
+ ? value
135
+ : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
136
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
137
+ }
138
+
139
+ function normalizeWhitespace(value) {
140
+ return String(value).replace(/\s+/g, " ").trim();
141
+ }
142
+
143
+ function strictJsonParse(raw) {
144
+ const text = String(raw ?? "").trim();
145
+ const fenceMatch = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n?```\s*$/i);
146
+ const candidate = fenceMatch ? fenceMatch[1] : text;
147
+ if (!/^\{/.test(candidate) || !/\}$/.test(candidate)) {
148
+ throw new Error("model response is not a single JSON object");
149
+ }
150
+ return JSON.parse(candidate);
151
+ }
152
+
153
+ function normalizeTranscript(input, candidateDuration) {
154
+ if (!input) {
155
+ return { supplied: false, text: null };
156
+ }
157
+ const text = readFileSync(input, "utf-8").trim();
158
+ const cuePattern = /((?:\d{2}:)?\d{2}:\d{2}[.,]\d{3})\s*-->\s*((?:\d{2}:)?\d{2}:\d{2}[.,]\d{3})\s*\n([\s\S]*?)(?=\n\n|\n(?:\d{2}:)?\d{2}:\d{2}|$)/g;
159
+ const cues = [];
160
+ let match;
161
+ while ((match = cuePattern.exec(text)) !== null) {
162
+ const toSeconds = (timestamp) => {
163
+ const parts = timestamp.replace(",", ".").split(":").map(Number);
164
+ return parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + parts[1];
165
+ };
166
+ cues.push({ start: toSeconds(match[1]), end: toSeconds(match[2]), text: normalizeWhitespace(match[3]) });
167
+ }
168
+ if (cues.length > 0) {
169
+ return {
170
+ supplied: true,
171
+ text: cues.map((cue) => `[${cue.start.toFixed(3)}-${cue.end.toFixed(3)}] ${cue.text}`).join("\n"),
172
+ cues,
173
+ };
174
+ }
175
+ return { supplied: true, text: normalizeWhitespace(text).slice(0, 20_000), cues: [] };
176
+ }
177
+
178
+ function getPrompt(prompts, name, fallback) {
179
+ const custom = prompts?.get?.(name);
180
+ if (custom) return custom;
181
+ if (prompts?.size === 0) return fallback;
182
+ return fallback;
183
+ }
184
+
185
+ function buildPrompt(candidate, transcript, context, domainPrompt) {
186
+ return `${domainPrompt}
187
+
188
+ TRANSCRIPT CONTEXT: ${transcript.supplied ? transcript.text : "(none supplied; do not invent speech or quote alignment)"}
189
+ CONTEXT: ${context || "(none)"}
190
+ SOURCE IDENTITY: ${candidate.source.id} (${candidate.source.start}s-${candidate.source.end}s)
191
+ CANDIDATE IDENTITY: ${candidate.candidate.id}; candidate timeline 0-${candidate.candidate.duration}s
192
+
193
+ STABLE JSON CONTRACT
194
+ Return exactly one UTF-8 JSON object and no prose outside optional JSON code fences. Every timestamp is candidate-relative seconds. Every editorial observation must cite observable evidence. If perception cannot support a claim, put it in uncertainty. Do not create scores, rankings, viral ratings, or selection decisions. Do not invent speech; keyQuoteAlignment may be non-empty only when transcript text is supplied above and each quote must be an exact transcript substring.
195
+ Required top-level keys and shapes:
196
+ {"summary":string,"speech":{"supplied":boolean,"speakers":string[],"keyQuoteAlignment":[{"quote":string,"start":number,"end":number,"alignment":string,"evidence":string}]},"people":[{"label":string,"position":string,"faceVisible":"yes|partial|no|unclear","clarity":"high|medium|low|unclear","emotion":string,"reaction":string,"action":string,"evidence":string}],"scenes":[{"start":number,"end":number,"event":string,"onScreenText":string[],"shotChange":boolean,"visualQuality":"high|acceptable|low|unclear","audioQuality":"high|acceptable|low|unclear","evidence":string}],"narrative":{"setup":{"start":number,"end":number,"observation":string,"evidence":string}|null,"tension":same|null,"turn":same|null,"payoff":same|null,"selfContained":boolean,"contextNeeded":string},"hooks":[{"start":number,"end":number,"label":string,"observation":string,"evidence":string}],"emotionalHighlights":[hook],"weakRegions":[hook],"suggestedCuts":[{"start":number,"end":number,"rationale":string,"evidence":string}],"verticalFit":{"suitability":"good|acceptable|poor|unclear","cropFeasibility":"good|acceptable|poor|unclear","trackingFeasibility":"good|acceptable|poor|unclear","subjectSafety":string,"evidence":string},"editSuggestions":[{"type":"punch-in|b-roll|caption-emphasis","start":number,"end":number,"suggestion":string,"evidence":string}],"observableEvidence":[{"start":number,"end":number,"observation":string,"evidence":string}],"uncertainty":[{"claim":string,"reason":string}]}`;
197
+ }
198
+
199
+ function validateAgainstTranscript(parsed, transcript) {
200
+ if (!transcript.supplied && parsed.speech.keyQuoteAlignment.length > 0) {
201
+ throw new Error("structured evidence contains quote alignment without transcript");
202
+ }
203
+ const transcriptText = normalizeWhitespace(transcript.text || "").toLowerCase();
204
+ for (const alignment of parsed.speech.keyQuoteAlignment) {
205
+ const quote = normalizeWhitespace(alignment.quote).toLowerCase();
206
+ if (!transcriptText.includes(quote)) {
207
+ throw new Error("key quote alignment cites text absent from caller-supplied transcript");
208
+ }
209
+ }
210
+ return parsed;
211
+ }
212
+
213
+ function rejectSelectionSignals(value, path = "$") {
214
+ if (Array.isArray(value)) {
215
+ value.forEach((item, index) => rejectSelectionSignals(item, `${path}[${index}]`));
216
+ return;
217
+ }
218
+ if (!value || typeof value !== "object") return;
219
+ for (const [key, child] of Object.entries(value)) {
220
+ if (/^(viral[_-]?)?(score|rating|rank|ranking|selection)$/i.test(key) || /viral/i.test(key)) {
221
+ throw new Error(`structured evidence contains forbidden editorial decision field: ${path}.${key}`);
222
+ }
223
+ rejectSelectionSignals(child, `${path}.${key}`);
224
+ }
225
+ }
226
+
227
+ function cacheKey(candidate, transcript, context, prompt, modelCommand, contract) {
228
+ const parts = {
229
+ version: 2,
230
+ contract,
231
+ source: { id: candidate.source.id, fingerprint: fingerprint(candidate.source.path), bounds: [candidate.source.start, candidate.source.end] },
232
+ candidate: { id: candidate.candidate.id, fingerprint: fingerprint(candidate.candidate.path), bounds: [candidate.candidate.start, candidate.candidate.end] },
233
+ transcript: transcript.supplied ? {
234
+ supplied: true,
235
+ contentHash: createHash("sha256").update(transcript.text || "").digest("hex"),
236
+ } : { supplied: false },
237
+ context,
238
+ prompt,
239
+ modelCommand,
240
+ };
241
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
242
+ }
243
+
244
+ function prepareCandidate(candidate, workingDir) {
245
+ const normalizedDir = join(workingDir, ".markcut-candidate-vision");
246
+ mkdirSync(normalizedDir, { recursive: true });
247
+ let mediaPath = candidate.candidate.path;
248
+ if (candidate.candidate.needsSlice || !existsSync(mediaPath)) {
249
+ mediaPath = join(normalizedDir, `${candidate.candidate.id}.mp4`);
250
+ const duration = candidate.source.end - candidate.source.start;
251
+ run(`ffmpeg -y -ss ${candidate.source.start} -i ${shQuote(candidate.source.path)} -t ${duration} -c:v libx264 -preset fast -crf 26 -c:a aac -b:a 96k ${shQuote(mediaPath)}`, { timeout: 300_000 });
252
+ }
253
+ const actualDuration = durationOf(mediaPath);
254
+ if (actualDuration <= 0) throw new Error(`candidate media has no measurable duration: ${mediaPath}`);
255
+ const normalizedPath = join(normalizedDir, `${candidate.candidate.id}_sample.mp4`);
256
+ if (!existsSync(normalizedPath)) {
257
+ run(`ffmpeg -y -i ${shQuote(mediaPath)} -vf "fps=1,scale='min(360,iw)':'min(360,ih)':force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2':-1:-1" -an -c:v libx264 -preset fast -crf 28 ${shQuote(normalizedPath)}`, { timeout: 300_000 });
258
+ }
259
+ return {
260
+ ...candidate,
261
+ candidate: {
262
+ ...candidate.candidate,
263
+ path: mediaPath,
264
+ duration: actualDuration,
265
+ normalizedMediaPath: normalizedPath,
266
+ isTrimmedCandidate: true,
267
+ },
268
+ };
269
+ }
270
+
271
+ function artifact(candidate, parsed, transcript, modelCommandHash) {
272
+ return {
273
+ schemaVersion: 1,
274
+ type: "markcut.candidate-evidence",
275
+ contractVersion: CANDIDATE_CONTRACT_VERSION,
276
+ source: candidate.source,
277
+ candidate: candidate.candidate,
278
+ transcript: {
279
+ supplied: transcript.supplied,
280
+ text: transcript.text,
281
+ speakers: parsed.speech.speakers,
282
+ keyQuoteAlignment: parsed.speech.keyQuoteAlignment,
283
+ },
284
+ observations: parsed,
285
+ model: { commandHash: modelCommandHash },
286
+ };
287
+ }
288
+
289
+ export async function runCandidateVision(inputPath, options) {
290
+ const resolved = resolve(inputPath);
291
+ if (!existsSync(resolved)) throw new Error(`input not found: ${resolved}`);
292
+ const extension = extname(resolved).toLowerCase();
293
+ let manifest;
294
+ if (VIDEO_EXTS.has(extension)) {
295
+ const duration = durationOf(resolved);
296
+ const start = Number.isFinite(options.start) ? options.start : 0;
297
+ const end = Number.isFinite(options.end) ? options.end : duration;
298
+ if (end <= start) throw new Error("candidate end must exceed start");
299
+ manifest = {
300
+ source: { id: options.sourceId || basename(resolved, extension), path: resolved, start, end },
301
+ candidate: {
302
+ id: options.candidateId || `${basename(resolved, extension)}-${start}-${end}`,
303
+ path: resolved,
304
+ start,
305
+ end,
306
+ needsSlice: start > 0 || end < duration,
307
+ },
308
+ };
309
+ } else {
310
+ const input = JSON.parse(readFileSync(resolved, "utf-8"));
311
+ const sourceValue = input.source;
312
+ const candidateValue = input.candidate;
313
+ const sourcePath = resolve(dirname(resolved), sourceValue?.path || input.sourcePath || (typeof sourceValue === "string" ? sourceValue : ""));
314
+ const candidatePath = typeof candidateValue === "string"
315
+ ? ""
316
+ : candidateValue?.path || input.candidatePath || "";
317
+ const candidateId = candidateValue?.id || input.candidateId || (typeof candidateValue === "string" ? candidateValue : "");
318
+ const start = numericBound(sourceValue?.start ?? input.start ?? input.sourceStartSec);
319
+ const end = numericBound(sourceValue?.end ?? input.end ?? input.sourceEndSec);
320
+ const candidateStart = numericBound(candidateValue?.start ?? input.candidateStartSec);
321
+ const candidateEnd = numericBound(candidateValue?.end ?? input.candidateEndSec);
322
+ if (!sourcePath || !Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
323
+ throw new Error("candidate manifest requires source path and numeric start/end bounds");
324
+ }
325
+ if (candidatePath && (!Number.isFinite(candidateStart) || !Number.isFinite(candidateEnd) || candidateEnd <= candidateStart)) {
326
+ throw new Error("candidate path manifests require complete numeric candidate bounds; zero is valid for candidateStartSec");
327
+ }
328
+ manifest = {
329
+ source: { id: input.source?.id || input.sourceId || basename(sourcePath), path: sourcePath, start, end },
330
+ candidate: {
331
+ id: candidateId || input.candidateId || `${input.sourceId || basename(sourcePath)}-${start}-${end}`,
332
+ path: candidatePath ? resolve(dirname(resolved), candidatePath) : sourcePath,
333
+ start: candidatePath ? candidateStart : start,
334
+ end: candidatePath ? candidateEnd : end,
335
+ needsSlice: !candidatePath,
336
+ },
337
+ };
338
+ }
339
+
340
+ if (!Number.isFinite(manifest.candidate.start)) manifest.candidate.start = manifest.source.start;
341
+ if (!Number.isFinite(manifest.candidate.end)) manifest.candidate.end = manifest.source.end;
342
+ manifest.candidate.isTrimmedCandidate = !manifest.candidate.needsSlice;
343
+
344
+ const prepared = prepareCandidate(
345
+ { ...manifest, candidate: { ...manifest.candidate, path: manifest.candidate.path || manifest.source.path } },
346
+ dirname(resolved),
347
+ );
348
+ const transcript = normalizeTranscript(options.transcriptFile ? resolve(options.transcriptFile) : null);
349
+ const domainPrompt = getPrompt(options.prompts, "candidate-evidence", `Produce reusable, timestamped visual/editorial evidence for an already-selected short-form candidate clip.`);
350
+ const prompt = buildPrompt(prepared, transcript, options.context || "", domainPrompt);
351
+ const contract = `candidate-evidence-v${CANDIDATE_CONTRACT_VERSION}`;
352
+ const modelCommand = options.modelCommand || process.env.MARKCUT_VISION_CANDIDATE_CLI || process.env.MARKCUT_VTT_CLI || "";
353
+ if (!modelCommand) throw new Error("candidate vision requires --model-command or MARKCUT_VISION_CANDIDATE_CLI");
354
+ const key = cacheKey(prepared, transcript, options.context || "", prompt, modelCommand, contract);
355
+ const commandHash = createHash("sha256").update(modelCommand).digest("hex").slice(0, 16);
356
+ const cachePath = join(dirname(resolved), ".markcut-candidate-vision", `${key}.json`);
357
+ const outputPath = resolve(options.output || join(dirname(resolved), `${prepared.candidate.id}.candidate-vision.json`));
358
+
359
+ let artifactValue;
360
+ if (existsSync(cachePath)) {
361
+ emitInfo(`Candidate evidence cached: ${key.slice(0, 12)}`);
362
+ artifactValue = JSON.parse(readFileSync(cachePath, "utf-8"));
363
+ } else {
364
+ const substituted = modelCommand
365
+ .replace(/\{input\}/g, shQuote(prepared.candidate.normalizedMediaPath))
366
+ .replace(/\{prompt\}/g, shQuote(prompt));
367
+ emitInfo(`Analyzing candidate ${prepared.candidate.id}...`);
368
+ let raw;
369
+ try {
370
+ raw = run(substituted, { timeout: 600_000 }).trim();
371
+ } catch (error) {
372
+ throw new Error(`candidate vision model failed: ${error.message}`);
373
+ }
374
+ let parsed;
375
+ try {
376
+ parsed = strictJsonParse(raw);
377
+ } catch (error) {
378
+ throw new Error(`candidate vision returned invalid JSON: ${error.message}`);
379
+ }
380
+ const shapeResult = observationsSchema.safeParse(parsed);
381
+ if (!shapeResult.success) {
382
+ throw new Error(`candidate vision returned invalid structured evidence: ${shapeResult.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
383
+ }
384
+ parsed = shapeResult.data;
385
+ if (parsed.speech.supplied !== transcript.supplied) {
386
+ throw new Error("structured evidence transcript-supplied flag contradicts caller input");
387
+ }
388
+ rejectSelectionSignals(parsed);
389
+ validateAgainstTranscript(parsed, transcript);
390
+ artifactValue = artifact(prepared, parsed, transcript, commandHash);
391
+ writeFileSync(cachePath, JSON.stringify(artifactValue, null, 2));
392
+ }
393
+
394
+ mkdirSync(dirname(outputPath), { recursive: true });
395
+ writeFileSync(outputPath, JSON.stringify(artifactValue, null, 2));
396
+ emitInfo(`Candidate evidence written: ${outputPath}`);
397
+ return outputPath;
398
+ }
399
+
400
+ export {
401
+ buildPrompt,
402
+ cacheKey,
403
+ normalizeTranscript,
404
+ observationsSchema,
405
+ rejectSelectionSignals,
406
+ strictJsonParse,
407
+ validateAgainstTranscript,
408
+ };
@@ -0,0 +1,122 @@
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();
@@ -32,6 +32,8 @@
32
32
  *
33
33
  * Prompt overrides:
34
34
  * --<prompt-name> "text" Override any prompt template from vision_prompts.md
35
+ * Use `--candidate-evidence "..."` in candidate mode to replace only the domain
36
+ * instructions; the machine-readable JSON envelope remains fixed.
35
37
  */
36
38
 
37
39
  import { execSync, spawn } from "node:child_process";
@@ -46,6 +48,7 @@ import {
46
48
  MAX_IMAGE_DIMENSION, MAX_VIDEO_DURATION, MAX_VIDEO_DIMENSION,
47
49
  DEFAULT_ITT_CLI, DEFAULT_VTT_SAMPLE_INTERVAL, DEFAULT_VTT_CLI, DEFAULT_STT_CLI, DEFAULT_AGENT_CLI,
48
50
  } from "../config.mjs";
51
+ import { runCandidateVision } from "./candidate.mjs";
49
52
 
50
53
  // ── Paths ─────────────────────────────────────────────────────────────────
51
54
 
@@ -788,7 +791,7 @@ function buildMergedCues(userHint, cues, sceneChangesMs, totalDurationMs) {
788
791
  return merged;
789
792
  }
790
793
 
791
- function analyzeVideo(videoPath, normInfo, normDir, prompts, context = "", userHint = "", sampleInterval = DEFAULT_VTT_SAMPLE_INTERVAL, userHints = null) {
794
+ function analyzeVideo(videoPath, normInfo, normDir, prompts, context = "", userHint = "", sampleInterval = DEFAULT_VTT_SAMPLE_INTERVAL, userHints = null, skipSTT = false) {
792
795
  let ctxParts = [];
793
796
  if (context) ctxParts.push(`Context: ${context}`);
794
797
  if (userHint && typeof userHint === "string") ctxParts.push(`User hint: ${userHint}`);
@@ -803,8 +806,13 @@ function analyzeVideo(videoPath, normInfo, normDir, prompts, context = "", userH
803
806
  perception.desc = descText.slice(0, 500) || looseJSONParse(descRaw)?.desc || descRaw.slice(0, 500);
804
807
 
805
808
  // 2. STT → VTT subtitle
806
- emitInfo(` Running speech-to-text...`);
807
- perception.subtitle = runSTT(videoPath, normDir, DEFAULT_STT_CLI);
809
+ if (skipSTT) {
810
+ emitInfo(` Skipping speech-to-text...`);
811
+ perception.subtitle = null;
812
+ } else {
813
+ emitInfo(` Running speech-to-text...`);
814
+ perception.subtitle = runSTT(videoPath, normDir, DEFAULT_STT_CLI);
815
+ }
808
816
 
809
817
  // 3. Build merged cue timeline from VTT + user hints + ffprobe
810
818
  emitInfo(` Building merged segment boundaries...`);
@@ -1073,7 +1081,7 @@ async function runNormalizeAndPercept(folder, metadataPath, prompts, context, pi
1073
1081
  perception = cache[cacheKey];
1074
1082
  emitInfo(` (cached)`);
1075
1083
  } else {
1076
- perception = analyzeVideo(vidPath, normInfo, normDir, prompts, context, userHint, vttSampleInterval, userHints);
1084
+ perception = analyzeVideo(vidPath, normInfo, normDir, prompts, context, userHint, vttSampleInterval, userHints, skipSTT);
1077
1085
  if (perception.desc) cache[cacheKey] = perception;
1078
1086
  }
1079
1087
 
@@ -1123,6 +1131,14 @@ export async function main(args) {
1123
1131
  let skipSTT = false;
1124
1132
  let dryRun = false;
1125
1133
  let doLabel = false;
1134
+ let candidateMode = false;
1135
+ let output = "";
1136
+ let modelCommand = "";
1137
+ let transcriptFile = "";
1138
+ let candidateId = "";
1139
+ let sourceId = "";
1140
+ let start = Number.NaN;
1141
+ let end = Number.NaN;
1126
1142
  const pickSet = new Set();
1127
1143
  const promptOverrides = new Map();
1128
1144
 
@@ -1134,10 +1150,18 @@ export async function main(args) {
1134
1150
  const flag = args[i++];
1135
1151
  if (flag === "--help") { printUsage(); return; }
1136
1152
  else if (flag === "--label") { doLabel = true; }
1153
+ else if (flag === "--candidate") { candidateMode = true; }
1154
+ else if (flag === "--output" && args[i]) { output = resolve(args[i++]); }
1155
+ else if (flag === "--model-command" && args[i]) { modelCommand = args[i++]; }
1156
+ else if (flag === "--transcript-file" && args[i]) { transcriptFile = resolve(args[i++]); }
1157
+ else if (flag === "--candidate-id" && args[i]) { candidateId = args[i++]; }
1158
+ else if (flag === "--source-id" && args[i]) { sourceId = args[i++]; }
1159
+ else if (flag === "--start" && args[i]) { start = Number.parseFloat(args[i++]); }
1160
+ else if (flag === "--end" && args[i]) { end = Number.parseFloat(args[i++]); }
1137
1161
 
1138
1162
  else if (flag === "--prompts-file" && args[i]) { promptsFile = resolve(args[i++]); }
1139
1163
  else if (flag === "--vtt-sample-interval" && args[i]) { vttSampleInterval = parseInt(args[i++], 10) || DEFAULT_VTT_SAMPLE_INTERVAL; }
1140
- else if (flag === "--instruct" && args[i]) { context = args[i++]; }
1164
+ else if (flag === "--instruct" && args[i] || flag === "--context" && args[i]) { context = args[i++]; }
1141
1165
  else if (flag === "--show-prompts") { console.log(readFileSync(promptsFile, "utf-8")); return; }
1142
1166
  else if (flag === "--skip-stt") { skipSTT = true; }
1143
1167
  else if (flag === "--pick" && args[i]) { for (const f of args[i++].split(",")) pickSet.add(f.trim()); }
@@ -1152,6 +1176,25 @@ export async function main(args) {
1152
1176
  const prompts = loadPrompts(promptsFile);
1153
1177
  for (const [name, value] of promptOverrides) prompts.set(name, value);
1154
1178
 
1179
+ if (candidateMode) {
1180
+ if (statSync(folder).isFile() && !VIDEO_EXTS.has(extname(folder).toLowerCase()) && extname(folder).toLowerCase() !== ".json") {
1181
+ emitError("Candidate mode accepts one video file or a JSON candidate manifest.");
1182
+ process.exit(1);
1183
+ }
1184
+ await runCandidateVision(folder, {
1185
+ context,
1186
+ output,
1187
+ modelCommand,
1188
+ transcriptFile,
1189
+ candidateId,
1190
+ sourceId,
1191
+ start,
1192
+ end,
1193
+ prompts,
1194
+ });
1195
+ return;
1196
+ }
1197
+
1155
1198
  if (doLabel) {
1156
1199
  // Full pipeline with interactive labeling
1157
1200
  await runFullPipeline(folder, prompts, context, pickSet, vttSampleInterval, skipSTT, dryRun);
@@ -64,4 +64,12 @@ Output format:
64
64
  {"0to5000": {"description": "Introduction and welcome"}, "5000to15250": {"description": "Starting to swim"}, "15250to30000": {"description": "Building sandcastle"}}
65
65
 
66
66
  If inputs are too sparse for segmentation, return an empty object {}.
67
- ~~~
67
+ ~~~
68
+
69
+ ## candidate-evidence
70
+
71
+ Observe one already-selected short-form candidate and return reusable editorial evidence.
72
+
73
+ ~~~md
74
+ Observe this already-trimmed candidate clip for a downstream editorial judge. Describe only what is visually or aurally observable. Cover subjects, faces, emotion, actions, scenes, on-screen text, quality, narrative development, hooks, highlights, weak regions, suggested boundaries, vertical reframing, and edit opportunities. Distinguish observation from uncertainty. Do not score, rank, select, or reject the candidate.
75
+ ~~~
@@ -0,0 +1,271 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+
6
+ process.env.MARKCUT_VTT_CLI = "printf 'whole video'";
7
+ process.env.MARKCUT_AGENT_CLI = "printf '{}'";
8
+
9
+ const { main } = await import("../src/vision/cli.mjs");
10
+ const {
11
+ cacheKey,
12
+ normalizeTranscript,
13
+ validateAgainstTranscript,
14
+ } = await import("../src/vision/candidate.mjs");
15
+
16
+ const ROOT = resolve(__dirname, "tmp", `vision-candidate-${Date.now()}`);
17
+ const SKIP_STT_ROOT = join(ROOT, "skip-stt");
18
+
19
+ function quote(value: string): string {
20
+ return `'${value.replace(/'/g, "'\\''")}'`;
21
+ }
22
+
23
+ function createVideo(dir: string, name: string, duration = 3): string {
24
+ const path = join(dir, name);
25
+ execSync(
26
+ `ffmpeg -y -f lavfi -i testsrc=size=180x320:rate=10:duration=${duration} ` +
27
+ `-f lavfi -i sine=frequency=640:duration=${duration} -shortest ` +
28
+ `-c:v libx264 -preset ultrafast -crf 30 -pix_fmt yuv420p -c:a aac ${quote(path)}`,
29
+ { stdio: "pipe" },
30
+ );
31
+ return path;
32
+ }
33
+
34
+ function evidenceResponse(options: { uncertainty?: boolean; quote?: string } = {}): object {
35
+ return {
36
+ summary: "A short visible test moment",
37
+ speech: {
38
+ supplied: Boolean(options.quote),
39
+ speakers: options.quote ? ["Host"] : [],
40
+ keyQuoteAlignment: options.quote ? [{
41
+ quote: options.quote,
42
+ start: 0,
43
+ end: 1,
44
+ alignment: "The visible host is speaking during this cue.",
45
+ evidence: "0.0-1.0 transcript cue while the host faces camera",
46
+ }] : [],
47
+ },
48
+ people: [],
49
+ scenes: [{
50
+ start: 0,
51
+ end: 1,
52
+ event: "A stationary test pattern is visible",
53
+ onScreenText: [],
54
+ shotChange: false,
55
+ visualQuality: "acceptable",
56
+ audioQuality: "unclear",
57
+ evidence: "0.0-1.0 sampled frames show the pattern",
58
+ }],
59
+ narrative: {
60
+ setup: null,
61
+ tension: null,
62
+ turn: null,
63
+ payoff: null,
64
+ selfContained: true,
65
+ contextNeeded: "",
66
+ },
67
+ hooks: [],
68
+ emotionalHighlights: [],
69
+ weakRegions: [],
70
+ suggestedCuts: [],
71
+ verticalFit: {
72
+ suitability: "good",
73
+ cropFeasibility: "acceptable",
74
+ trackingFeasibility: "unclear",
75
+ subjectSafety: "The frame leaves room for a vertical crop",
76
+ evidence: "0.0-1.0 sampled frames",
77
+ },
78
+ editSuggestions: [],
79
+ observableEvidence: [{
80
+ start: 0,
81
+ end: 1,
82
+ observation: "The sample test pattern remains visible",
83
+ evidence: "sampled frames at 0.0 and 1.0 seconds",
84
+ }],
85
+ uncertainty: options.uncertainty === false ? [] : [{
86
+ claim: "Speech content cannot be verified from frames alone",
87
+ reason: "The candidate analysis path does not perform its own STT",
88
+ }],
89
+ };
90
+ }
91
+
92
+ function fixture(path: string, value: unknown): string {
93
+ writeFileSync(path, typeof value === "string" ? value : JSON.stringify(value));
94
+ return path;
95
+ }
96
+
97
+ describe("candidate-only vision", () => {
98
+ let candidate: string;
99
+ let validFixture: string;
100
+ let trimmedCandidate: string;
101
+
102
+ beforeAll(() => {
103
+ mkdirSync(ROOT, { recursive: true });
104
+ candidate = createVideo(ROOT, "candidate.mp4");
105
+ writeFileSync(
106
+ join(ROOT, "transcript.vtt"),
107
+ "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nThis is the best part\n",
108
+ );
109
+ validFixture = fixture(join(ROOT, "valid.json"), evidenceResponse({
110
+ quote: "This is the best part",
111
+ }));
112
+ trimmedCandidate = createVideo(ROOT, "trimmed.mp4", 2.5);
113
+ }, 30_000);
114
+
115
+ afterAll(() => {
116
+ rmSync(ROOT, { recursive: true, force: true });
117
+ });
118
+
119
+ it("analyzes only the candidate and emits bounded structured evidence", async () => {
120
+ const output = join(ROOT, "evidence.json");
121
+ const invocationCount = join(ROOT, "invocations.txt");
122
+ const countingModel = `printf 'invoked\\n' >> ${quote(invocationCount)}; cat ${quote(validFixture)}`;
123
+ await main([
124
+ "node", "cli.mjs", "vision", candidate, "--candidate",
125
+ "--source-id", "source-1", "--candidate-id", "candidate-9",
126
+ "--start", "0.5", "--end", "2.5",
127
+ "--transcript-file", join(ROOT, "transcript.vtt"),
128
+ "--model-command", countingModel,
129
+ "--output", output,
130
+ ]);
131
+
132
+ const artifact = JSON.parse(readFileSync(output, "utf-8"));
133
+ expect(artifact.type).toBe("markcut.candidate-evidence");
134
+ expect(artifact.source).toMatchObject({ id: "source-1", start: 0.5, end: 2.5 });
135
+ expect(artifact.candidate).toMatchObject({ id: "candidate-9", start: 0.5, end: 2.5 });
136
+ expect(artifact.observations).toHaveProperty("summary");
137
+ expect(artifact.observations.uncertainty.length).toBeGreaterThan(0);
138
+ expect(readFileSync(invocationCount, "utf-8").trim().split("\n")).toHaveLength(1);
139
+ expect(artifact.candidate.path).not.toBe(candidate);
140
+ const boundedDuration = Number(execSync(
141
+ `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${quote(artifact.candidate.path)}`,
142
+ { encoding: "utf-8" },
143
+ ).trim());
144
+ expect(boundedDuration).toBeGreaterThanOrEqual(1.9);
145
+ expect(boundedDuration).toBeLessThanOrEqual(2.1);
146
+
147
+ const mediaDir = join(ROOT, ".markcut-candidate-vision");
148
+ const generated = execSync(`find ${quote(mediaDir)} -maxdepth 1 -type f -print`, { encoding: "utf-8" }).trim().split("\n");
149
+ expect(generated.some((file) => file.endsWith("_audio.mp3"))).toBe(false);
150
+ expect(generated.some((file) => file.endsWith(".vtt"))).toBe(false);
151
+
152
+ const sample = generated.find((file) => file.endsWith("_sample.mp4"));
153
+ expect(sample).toBeTruthy();
154
+ const streams = JSON.parse(execSync(
155
+ `ffprobe -v quiet -print_format json -show_streams ${quote(sample!)}`,
156
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
157
+ )).streams as Array<{ codec_type: string }>;
158
+ expect(streams.some((stream) => stream.codec_type === "audio")).toBe(false);
159
+ }, 60_000);
160
+
161
+ it("rejects malformed model output instead of writing valid evidence", async () => {
162
+ const badFixture = fixture(join(ROOT, "malformed.txt"), "This looks useful but is only prose.");
163
+ const output = join(ROOT, "invalid-evidence.json");
164
+ await expect(main([
165
+ "node", "cli.mjs", "vision", candidate, "--candidate",
166
+ "--model-command", `cat ${quote(badFixture)}`,
167
+ "--output", output,
168
+ ])).rejects.toThrow(/invalid JSON/);
169
+ expect(existsSync(output)).toBe(false);
170
+ });
171
+
172
+ it("accepts zero and preserves explicit pre-trimmed candidate intervals", async () => {
173
+ const manifestPath = join(ROOT, "zero-candidate-interval.json");
174
+ const output = join(ROOT, "zero-interval-evidence.json");
175
+ const noTranscriptFixture = fixture(join(ROOT, "valid-no-transcript.json"), evidenceResponse({ uncertainty: true }));
176
+ writeFileSync(manifestPath, JSON.stringify({
177
+ source: {
178
+ id: "source-22",
179
+ path: basename(trimmedCandidate),
180
+ start: 12,
181
+ end: 14.5,
182
+ },
183
+ candidatePath: basename(trimmedCandidate),
184
+ candidateId: "candidate-zero",
185
+ candidateStartSec: 0,
186
+ candidateEndSec: 2.5,
187
+ }));
188
+
189
+ await main([
190
+ "node", "cli.mjs", "vision", manifestPath, "--candidate",
191
+ "--model-command", `cat ${quote(noTranscriptFixture)}`,
192
+ "--output", output,
193
+ ]);
194
+
195
+ const artifact = JSON.parse(readFileSync(output, "utf-8"));
196
+ expect(artifact.source).toMatchObject({ id: "source-22", start: 12, end: 14.5 });
197
+ expect(artifact.candidate).toMatchObject({
198
+ id: "candidate-zero",
199
+ start: 0,
200
+ end: 2.5,
201
+ path: trimmedCandidate,
202
+ });
203
+ });
204
+
205
+ it("rejects fabricated transcript quotes", () => {
206
+ const transcript = normalizeTranscript(join(ROOT, "transcript.vtt"));
207
+ const parsed = evidenceResponse({ quote: "This sentence was never spoken" });
208
+ expect(() => validateAgainstTranscript(parsed as any, transcript)).toThrow(/absent from caller-supplied transcript/);
209
+ });
210
+
211
+ it("invalidates cache for prompt, contract, model, and candidate-bound changes", () => {
212
+ const identity = {
213
+ source: { id: "source", path: candidate, start: 0, end: 1 },
214
+ candidate: { id: "candidate", path: candidate, start: 0, end: 1 },
215
+ };
216
+ const transcript = { supplied: false, text: null };
217
+ const base = cacheKey(identity as any, transcript as any, "", "prompt", "model", "v1");
218
+
219
+ expect(cacheKey(identity as any, transcript as any, "changed", "prompt", "model", "v1")).not.toBe(base);
220
+ expect(cacheKey(identity as any, transcript as any, "", "changed prompt", "model", "v1")).not.toBe(base);
221
+ expect(cacheKey(identity as any, transcript as any, "", "prompt", "changed model", "v1")).not.toBe(base);
222
+ expect(cacheKey(identity as any, transcript as any, "", "prompt", "model", "v2")).not.toBe(base);
223
+
224
+ const changedBounds = {
225
+ source: { ...identity.source, start: 1, end: 2 },
226
+ candidate: { ...identity.candidate, start: 1, end: 2 },
227
+ };
228
+ expect(cacheKey(changedBounds as any, transcript as any, "", "prompt", "model", "v1")).not.toBe(base);
229
+ });
230
+
231
+ it("invalidates cache when normalized transcript content changes", () => {
232
+ const transcriptPath = join(ROOT, "cache-transcript.vtt");
233
+ const identity = {
234
+ source: { id: "source", path: candidate, start: 0, end: 1 },
235
+ candidate: { id: "candidate", path: candidate, start: 0, end: 1 },
236
+ };
237
+ writeFileSync(
238
+ transcriptPath,
239
+ "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nAlpha transcript line\n",
240
+ );
241
+ const firstTranscript = normalizeTranscript(transcriptPath);
242
+ const first = cacheKey(identity as any, firstTranscript as any, "", "prompt", "model", "v1");
243
+
244
+ writeFileSync(
245
+ transcriptPath,
246
+ "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nBravo transcript line\n",
247
+ );
248
+ const secondTranscript = normalizeTranscript(transcriptPath);
249
+ const second = cacheKey(identity as any, secondTranscript as any, "", "prompt", "model", "v1");
250
+
251
+ expect(secondTranscript.text).not.toBe(firstTranscript.text);
252
+ expect(second).not.toBe(first);
253
+ });
254
+
255
+ it("honors --skip-stt in normal vision mode", async () => {
256
+ mkdirSync(SKIP_STT_ROOT, { recursive: true });
257
+ const video = createVideo(SKIP_STT_ROOT, "long.mp4");
258
+ await main(["node", "cli.mjs", "vision", SKIP_STT_ROOT, "--skip-stt"]);
259
+
260
+ const metadataPath = join(SKIP_STT_ROOT, "metadata.json");
261
+ expect(existsSync(metadataPath)).toBe(true);
262
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
263
+ const entry = metadata.long;
264
+ expect(entry.perception.desc).toContain("whole video");
265
+ expect(entry.perception.subtitle).toBeNull();
266
+
267
+ const generated = execSync(`find ${quote(SKIP_STT_ROOT)} -type f \\( -name '*_audio.mp3' -o -name '*.vtt' \\) -print`, { encoding: "utf-8" }).trim();
268
+ expect(generated).toBe("");
269
+ expect(existsSync(video)).toBe(true);
270
+ }, 120_000);
271
+ });
@@ -0,0 +1,50 @@
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
+ });