@lalalic/markcut 3.2.1 → 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
@@ -342,3 +342,22 @@ flowchart LR
342
342
  | `--show-prompts` | Print the prompts template file and exit |
343
343
 
344
344
  ## Architecture
345
+
346
+
347
+ ### Browser ChatGPT Vision backend
348
+
349
+ Markcut can use the authenticated Neo Browser ChatGPT inference command as its configurable ITT/VTT backend without changing the default local VLMs. Each inference uses a fresh worker-owned Temporary Chat tab, uploads the media, waits for a verified user turn and complete assistant result in the same tab, then closes that owned tab. It does not depend on reopening a ChatGPT conversation.
350
+
351
+ Expose the Neo inference command on `PATH` as `chatgpt-browser-infer`, or point Markcut at it explicitly:
352
+
353
+ ```bash
354
+ export MARKCUT_CHATGPT_BROWSER_INFER_CLI='/path/to/neo/skills/chatgpt-browser-worker/bin/chatgpt-browser-infer'
355
+ export MARKCUT_ITT_CLI='markcut vision-chatgpt --mode image --prompt "{prompt}" --input {input}'
356
+ export MARKCUT_VTT_CLI='markcut vision-chatgpt --mode video --prompt "{prompt}" --input {input}'
357
+ ```
358
+
359
+ For a source checkout, replace `markcut` above with `node src/render/cli.mjs`.
360
+
361
+ Image inputs are uploaded directly. Video uses deterministic chronological frame sampling by default, builds a contact sheet with timing context, and analyzes that image through the same inference surface. This is the production path because direct MP4 upload through the current ChatGPT web client was materially slower and did not complete reliably in E2E testing. Set `MARKCUT_CHATGPT_DIRECT_VIDEO=1` only to experiment with direct MP4 first; failure still falls back to frames. Prompts requesting JSON enable strict whole-response JSON validation; truncated or prose-wrapped JSON is never accepted as success (a single whole-response JSON code fence is normalized), and incomplete/failed inference attempts are retried in a fresh owned tab.
362
+
363
+ `MARKCUT_CHATGPT_VISION_TIMEOUT_MS` controls the overall Markcut-side timeout. The inference command itself owns attachment readiness, submission verification, complete-result detection, retries, and owned-tab cleanup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "3.2.1",
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"
@@ -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,141 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, execSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, extname, join, resolve } from "node:path";
6
+
7
+ const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".heic", ".avif"]);
8
+ const VIDEO_EXTS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv"]);
9
+
10
+ function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
11
+
12
+ export function parseArgs(argv) {
13
+ const out = { mode: "auto", inputs: [], prompt: "", timeoutMs: Number(process.env.MARKCUT_CHATGPT_VISION_TIMEOUT_MS) || 600_000, maxFrames: 8 };
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const arg = argv[i];
16
+ if (arg === "--mode") out.mode = argv[++i] || "auto";
17
+ else if (arg === "--prompt") out.prompt = argv[++i] || "";
18
+ else if (arg === "--input") {
19
+ while (argv[i + 1] && !argv[i + 1].startsWith("--")) out.inputs.push(argv[++i]);
20
+ } else if (arg === "--timeout-ms") out.timeoutMs = Number(argv[++i]);
21
+ else if (arg === "--max-frames") out.maxFrames = Number(argv[++i]);
22
+ else if (!arg.startsWith("--")) out.inputs.push(arg);
23
+ else throw new Error(`Unknown argument: ${arg}`);
24
+ }
25
+ if (!out.prompt) throw new Error("--prompt is required");
26
+ if (out.inputs.length === 0) throw new Error("--input is required");
27
+ if (!Number.isFinite(out.timeoutMs) || out.timeoutMs <= 0) throw new Error("--timeout-ms must be > 0");
28
+ if (!Number.isFinite(out.maxFrames) || out.maxFrames < 1 || out.maxFrames > 16) throw new Error("--max-frames must be 1..16");
29
+ out.inputs = out.inputs.map((p) => resolve(p.replace(/^@/, "")));
30
+ for (const input of out.inputs) if (!existsSync(input)) throw new Error(`Input not found: ${input}`);
31
+ if (out.mode === "auto") out.mode = out.inputs.some((p) => VIDEO_EXTS.has(extname(p).toLowerCase())) ? "video" : "image";
32
+ if (!['image', 'video'].includes(out.mode)) throw new Error("--mode must be image, video, or auto");
33
+ return out;
34
+ }
35
+
36
+ function ffprobeDuration(videoPath) {
37
+ const raw = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath], { encoding: "utf8" }).trim();
38
+ const value = Number(raw);
39
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`Unable to read video duration: ${videoPath}`);
40
+ return value;
41
+ }
42
+
43
+ export function prepareMedia(mode, inputs, workDir, maxFrames = 8) {
44
+ if (mode === "image") return { files: inputs, context: inputs.map((p) => `Image: ${basename(p)}`).join("\n") };
45
+ if (inputs.length !== 1) throw new Error("video mode accepts exactly one input video");
46
+ const videoPath = inputs[0];
47
+ if (!VIDEO_EXTS.has(extname(videoPath).toLowerCase())) throw new Error(`Unsupported video input: ${videoPath}`);
48
+ const duration = ffprobeDuration(videoPath);
49
+ // Preserve chronology even for short clips. A one-frame fallback cannot
50
+ // distinguish ordering, so request at least two samples whenever maxFrames
51
+ // permits it; longer videos still scale at roughly one sample per 5 seconds.
52
+ const count = Math.max(1, Math.min(maxFrames, Math.max(2, Math.ceil(duration / 5))));
53
+ const framesDir = join(workDir, "frames");
54
+ mkdirSync(framesDir, { recursive: true });
55
+ const pattern = join(framesDir, "frame-%03d.jpg");
56
+ const fps = count / duration;
57
+ execFileSync("ffmpeg", ["-y", "-i", videoPath, "-vf", `fps=${fps},scale='min(640,iw)':-2`, "-frames:v", String(count), "-q:v", "3", pattern], { stdio: "ignore" });
58
+ const frames = readdirSync(framesDir).filter((f) => f.endsWith(".jpg")).sort().map((f) => join(framesDir, f));
59
+ if (frames.length === 0) throw new Error("No representative frames extracted from video");
60
+ const cols = Math.min(4, frames.length);
61
+ const contact = join(workDir, "contact-sheet.jpg");
62
+ if (frames.length === 1) {
63
+ execFileSync("ffmpeg", ["-y", "-i", frames[0], "-vf", "scale=320:-2", "-frames:v", "1", contact], { stdio: "ignore" });
64
+ } else {
65
+ execSync(`ffmpeg -y ${frames.map((p) => `-i ${shellQuote(p)}`).join(" ")} -filter_complex ${shellQuote(`xstack=inputs=${frames.length}:layout=${frames.map((_, i) => `${i % cols}*w0_${Math.floor(i / cols)}*h0`).join('|')},scale=${cols * 320}:-2`)} -frames:v 1 ${shellQuote(contact)}`, { stdio: "ignore" });
66
+ }
67
+ const timing = frames.map((p, i) => `${basename(p)} ≈ ${(i * duration / frames.length).toFixed(1)}s`).join(", ");
68
+ return { files: [contact], context: `Video: ${basename(videoPath)}\nDuration: ${duration.toFixed(2)}s\nRepresentative frames are chronological, left-to-right then top-to-bottom.\nTiming: ${timing}` };
69
+ }
70
+
71
+ function runInference(launcher, prompt, files, timeoutMs, env) {
72
+ const expectJson = /\bjson\b/i.test(prompt);
73
+ const attemptSeconds = Math.max(30, Math.floor(timeoutMs / 1000 / 3));
74
+ const parts = [launcher, "--prompt", shellQuote(prompt), "--result-timeout", String(attemptSeconds), "--attempts", "3"];
75
+ if (expectJson) parts.push("--expect-json");
76
+ for (const file of files) parts.push("--file", shellQuote(file));
77
+ return execSync(parts.join(" "), { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs }).trim();
78
+ }
79
+
80
+ function mediaPrompt(prompt, context) {
81
+ return `${prompt}\n\nMedia context:\n${context}\n\nAnalyze only the attached media. Preserve chronology for video.`;
82
+ }
83
+
84
+ export function runVision({ mode, inputs, prompt, timeoutMs, maxFrames }, env = process.env) {
85
+ const launcher = env.MARKCUT_CHATGPT_BROWSER_INFER_CLI || "chatgpt-browser-infer";
86
+ const workDir = mkdtempSync(join(tmpdir(), "markcut-chatgpt-vision-"));
87
+ const deadline = Date.now() + timeoutMs;
88
+ const remaining = () => {
89
+ const value = deadline - Date.now();
90
+ if (value <= 0) throw new Error("ChatGPT browser vision overall timeout expired");
91
+ return value;
92
+ };
93
+ try {
94
+ if (mode === "image") {
95
+ const media = prepareMedia("image", inputs, workDir, maxFrames);
96
+ return runInference(launcher, mediaPrompt(prompt, media.context), media.files, remaining(), env);
97
+ }
98
+ if (inputs.length !== 1) throw new Error("video mode accepts exactly one input video");
99
+ const videoPath = inputs[0];
100
+ let directError = null;
101
+ if (env.MARKCUT_CHATGPT_DIRECT_VIDEO === "1") {
102
+ const directContext = `Video: ${basename(videoPath)}\nAnalyze the video directly and preserve chronology.`;
103
+ try {
104
+ // Direct MP4 analysis is experimental because the web client may accept
105
+ // the upload without exposing usable temporal media to the model. Never
106
+ // let it consume the entire caller budget needed for frame fallback.
107
+ const directBudget = Math.max(1, Math.min(remaining(), Math.floor(timeoutMs / 2)));
108
+ return runInference(launcher, mediaPrompt(prompt, directContext), [videoPath], directBudget, env);
109
+ } catch (error) {
110
+ directError = error;
111
+ }
112
+ }
113
+ const fallback = prepareMedia("video", inputs, workDir, maxFrames);
114
+ try {
115
+ const fallbackContext = directError
116
+ ? `${fallback.context}\nDirect video upload failed; using deterministic frame analysis.`
117
+ : `${fallback.context}\nUsing deterministic frame analysis.`;
118
+ return runInference(launcher, mediaPrompt(prompt, fallbackContext), fallback.files, remaining(), env);
119
+ } catch (fallbackError) {
120
+ const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
121
+ if (!directError) throw new Error(`ChatGPT video frame inference failed: ${fallbackMessage}`);
122
+ const directMessage = directError instanceof Error ? directError.message : String(directError);
123
+ throw new Error(`ChatGPT video inference failed directly and via frame fallback. direct=${directMessage}; fallback=${fallbackMessage}`);
124
+ }
125
+ } finally {
126
+ rmSync(workDir, { recursive: true, force: true });
127
+ }
128
+ }
129
+
130
+ export function main(argv = process.argv.slice(2), env = process.env) {
131
+ try {
132
+ const result = runVision(parseArgs(argv), env);
133
+ process.stdout.write(`${result}\n`);
134
+ return 0;
135
+ } catch (error) {
136
+ process.stderr.write(`markcut chatgpt vision: ${error instanceof Error ? error.message : String(error)}\n`);
137
+ return 1;
138
+ }
139
+ }
140
+
141
+ if (process.argv[1] && process.argv[1].endsWith("chatgpt-browser-cli.mjs")) process.exitCode = main();
@@ -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,99 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { parseArgs, prepareMedia, runVision } from "../src/vision/chatgpt-browser-cli.mjs";
7
+
8
+ const roots: string[] = [];
9
+ afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); });
10
+ function temp() { const p = mkdtempSync(join(tmpdir(), "markcut-browser-test-")); roots.push(p); return p; }
11
+
12
+ function makeMock(root: string, { failVideo = false } = {}) {
13
+ const path = join(root, "mock-infer.mjs");
14
+ writeFileSync(path, `
15
+ const args=process.argv.slice(2);
16
+ const files=[];
17
+ let prompt='';
18
+ for(let i=0;i<args.length;i++){
19
+ if(args[i]==='--prompt') prompt=args[++i];
20
+ else if(args[i]==='--file') files.push(args[++i]);
21
+ }
22
+ if(!prompt.includes('Media context:')) process.exit(3);
23
+ if(!files.length) process.exit(4);
24
+ if(${failVideo ? "true" : "false"} && files.some(f=>f.endsWith('.mp4'))) process.exit(7);
25
+ process.stdout.write(JSON.stringify({prompt,files})+'\\n');
26
+ `);
27
+ return `node ${JSON.stringify(path)}`;
28
+ }
29
+
30
+ it("parses Markcut template-style image arguments", () => {
31
+ const root = temp(); const a = join(root, "a.jpg"); const b = join(root, "b.jpg"); writeFileSync(a, "a"); writeFileSync(b, "b");
32
+ const args = parseArgs(["--mode", "image", "--prompt", "describe", "--input", `@${a}`, b]);
33
+ expect(args.mode).toBe("image"); expect(args.inputs).toEqual([a, b]);
34
+ });
35
+
36
+ it("returns synchronous browser inference stdout", () => {
37
+ const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
38
+ const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher } as NodeJS.ProcessEnv;
39
+ const result = JSON.parse(runVision({ mode:"image", inputs:[image], prompt:"describe", timeoutMs:2000, maxFrames:8 }, env));
40
+ expect(result.files).toEqual([image]);
41
+ expect(result.prompt).toContain("Image: a.jpg");
42
+ });
43
+
44
+ it("extracts deterministic chronological video representation", () => {
45
+ const root = temp(); const video = join(root, "clip.mp4"); const work = join(root, "work");
46
+ execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "testsrc=s=64x64:d=2:r=4", "-c:v", "libx264", video], { stdio:"ignore" });
47
+ const prepared = prepareMedia("video", [video], work, 4);
48
+ expect(prepared.files).toHaveLength(1); expect(existsSync(prepared.files[0])).toBe(true);
49
+ expect(prepared.context).toContain("chronological"); expect(prepared.context).toContain("Duration:");
50
+ expect(prepared.context).toContain("frame-001.jpg"); expect(prepared.context).toContain("frame-002.jpg");
51
+ });
52
+
53
+ it("uses deterministic frame analysis for video by default", () => {
54
+ const root = temp(); const video = join(root, "clip.mp4");
55
+ execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "testsrc=s=64x64:d=2:r=4", "-c:v", "libx264", video], { stdio:"ignore" });
56
+ const launcher = makeMock(root);
57
+ const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher } as NodeJS.ProcessEnv;
58
+ delete env.MARKCUT_CHATGPT_DIRECT_VIDEO;
59
+ const result = JSON.parse(runVision({ mode:"video", inputs:[video], prompt:"describe chronology", timeoutMs:4000, maxFrames:4 }, env));
60
+ expect(result.files).toHaveLength(1);
61
+ expect(result.files[0]).toContain("contact-sheet.jpg");
62
+ expect(result.prompt).toContain("Using deterministic frame analysis");
63
+ });
64
+
65
+ it("can opt into direct video and falls back to deterministic frames", () => {
66
+ const root = temp(); const video = join(root, "clip.mp4");
67
+ execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", "testsrc=s=64x64:d=2:r=4", "-c:v", "libx264", video], { stdio:"ignore" });
68
+ const launcher = makeMock(root, { failVideo: true });
69
+ const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher, MARKCUT_CHATGPT_DIRECT_VIDEO: "1" } as NodeJS.ProcessEnv;
70
+ const result = JSON.parse(runVision({ mode:"video", inputs:[video], prompt:"describe chronology", timeoutMs:4000, maxFrames:4 }, env));
71
+ expect(result.files).toHaveLength(1);
72
+ expect(result.files[0]).toContain("contact-sheet.jpg");
73
+ expect(result.prompt).toContain("Direct video upload failed");
74
+ });
75
+
76
+ it("requests strict JSON validation when prompt requires JSON", () => {
77
+ const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image");
78
+ const path = join(root, "expect-json.mjs");
79
+ writeFileSync(path, `
80
+ if(!process.argv.includes('--expect-json')) process.exit(9);
81
+ process.stdout.write('{"ok":true}\\n');
82
+ `);
83
+ const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: `node ${JSON.stringify(path)}` } as NodeJS.ProcessEnv;
84
+ expect(runVision({ mode:"image", inputs:[image], prompt:"Return JSON only", timeoutMs:2000, maxFrames:8 }, env)).toBe('{"ok":true}');
85
+ });
86
+
87
+ it("returns nonzero from CLI when inference fails", () => {
88
+ const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image");
89
+ expect(() => execFileSync("node", ["src/vision/chatgpt-browser-cli.mjs", "--mode", "image", "--prompt", "x", "--input", image], { cwd: process.cwd(), env: { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: "exit 7" }, stdio:"pipe" })).toThrow();
90
+ });
91
+
92
+ describe("concurrency contract", () => {
93
+ it("keeps per-invocation media state isolated", async () => {
94
+ const root = temp(); const image = join(root, "a.jpg"); writeFileSync(image, "image"); const launcher = makeMock(root);
95
+ const env = { ...process.env, MARKCUT_CHATGPT_BROWSER_INFER_CLI: launcher } as NodeJS.ProcessEnv;
96
+ const results = await Promise.all(Array.from({ length: 4 }, (_, i) => Promise.resolve().then(() => runVision({ mode: "image", inputs: [image], prompt: `p${i}`, timeoutMs: 2000, maxFrames: 8 }, env))));
97
+ expect(results.map((x) => JSON.parse(x).prompt)).toHaveLength(4);
98
+ });
99
+ });