@lalalic/markcut 3.2.0 → 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 +24 -0
- package/docs/research/2026-09-19-chatgpt-browser-vision.md +250 -0
- package/package.json +1 -1
- package/src/render/cli.mjs +10 -0
- package/src/vision/candidate.mjs +408 -0
- package/src/vision/chatgpt-browser-cli.mjs +122 -0
- package/src/vision/cli.mjs +48 -5
- package/src/vision/vision_prompts.md +9 -1
- package/tests/vision-candidate.test.ts +271 -0
- package/tests/vision-chatgpt-browser.test.ts +50 -0
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.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# ChatGPT Browser Harness as a Markcut Vision Backend
|
|
2
|
+
|
|
3
|
+
Date: 2026-09-19
|
|
4
|
+
PR: #3
|
|
5
|
+
Recommendation: **not currently viable**
|
|
6
|
+
|
|
7
|
+
## Objective
|
|
8
|
+
|
|
9
|
+
Evaluate whether Markcut's configurable Vision CLI can use an authenticated ChatGPT web session, operated through the `chatgpt-browser-worker` skill and `browser-harness`, as an image/video understanding backend without using the ChatGPT API or the legacy MacDeveloperBridge Chrome/token path.
|
|
10
|
+
|
|
11
|
+
## Existing Markcut seam
|
|
12
|
+
|
|
13
|
+
Markcut already has a narrow backend seam in `src/config.mjs`:
|
|
14
|
+
|
|
15
|
+
- `DEFAULT_ITT_CLI` for image-to-text
|
|
16
|
+
- `DEFAULT_VTT_CLI` for video-to-text
|
|
17
|
+
- `DEFAULT_AGENT_CLI` for downstream agent work
|
|
18
|
+
|
|
19
|
+
The Vision CLI substitutes `{prompt}` and `{input}` into these command templates, so a future browser-backed backend does not require rewriting the Vision pipeline. The correct integration shape would be a small executable command that accepts the same prompt/input contract and emits normalized text/JSON on stdout.
|
|
20
|
+
|
|
21
|
+
```mermaid
|
|
22
|
+
flowchart LR
|
|
23
|
+
M[markcut vision] --> C[configured ITT/VTT command]
|
|
24
|
+
C -->|current| L[mlx-vlm]
|
|
25
|
+
C -->|experimental| B[chatgpt-browser-worker adapter]
|
|
26
|
+
B --> H[browser-harness]
|
|
27
|
+
H --> W[authenticated ChatGPT web UI]
|
|
28
|
+
W --> R[verified assistant result]
|
|
29
|
+
R --> N[normalize JSON/text]
|
|
30
|
+
N --> M
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Browser-worker path tested
|
|
34
|
+
|
|
35
|
+
The experiment used the `chatgpt-browser-worker` contract from the Agents Relay worktree. The worker uses `browser-harness` as the only browser interaction layer and preserves ChatGPT thread identity and Project identity.
|
|
36
|
+
|
|
37
|
+
Observed worker thread:
|
|
38
|
+
|
|
39
|
+
- Project: `agents-relay`
|
|
40
|
+
- Thread: `6aaf32d4-4164-83ea-860a-c2453ce57e51`
|
|
41
|
+
- Requested thinking: `high`
|
|
42
|
+
- Effective thinking: `unknown` (UI could not verify it; no inference was made)
|
|
43
|
+
|
|
44
|
+
A second experiment targeted the existing `markcut` ChatGPT Project. The helper selected the Project, but current ChatGPT UI drift prevented its create helper from reliably observing/sending the composer without manual self-healing.
|
|
45
|
+
|
|
46
|
+
## Synthetic fixtures
|
|
47
|
+
|
|
48
|
+
To avoid private media and make correctness objectively measurable, the test generated:
|
|
49
|
+
|
|
50
|
+
1. `image-test-unique.png`
|
|
51
|
+
- red rectangle
|
|
52
|
+
- blue circle
|
|
53
|
+
- green triangle
|
|
54
|
+
- visible text identifying all three shapes and a unique fixture label
|
|
55
|
+
2. `video-test.mp4`
|
|
56
|
+
- 6 seconds total
|
|
57
|
+
- 0-2s red phase
|
|
58
|
+
- 2-4s blue phase
|
|
59
|
+
- 4-6s green phase
|
|
60
|
+
|
|
61
|
+
Fixtures were generated locally and kept out of Git.
|
|
62
|
+
|
|
63
|
+
## Control: existing Markcut image backend
|
|
64
|
+
|
|
65
|
+
The current configured image backend was run directly with the same image fixture using the existing `mlx-vlm`/MiniCPM path.
|
|
66
|
+
|
|
67
|
+
Observed runtime: approximately 8 seconds.
|
|
68
|
+
|
|
69
|
+
Observed result correctly identified:
|
|
70
|
+
|
|
71
|
+
- red rectangle
|
|
72
|
+
- blue circle
|
|
73
|
+
- green triangle
|
|
74
|
+
- `RED RECTANGLE | BLUE CIRCLE | GREEN TRIANGLE`
|
|
75
|
+
- `MARKCUT VISION UNIQUE FIXTURE 2026-09-19`
|
|
76
|
+
|
|
77
|
+
This establishes that the fixture and current Markcut backend are functioning.
|
|
78
|
+
|
|
79
|
+
## ChatGPT image experiment
|
|
80
|
+
|
|
81
|
+
The Browser Harness path successfully:
|
|
82
|
+
|
|
83
|
+
- selected an authenticated ChatGPT Project;
|
|
84
|
+
- created and preserved a real ChatGPT thread id;
|
|
85
|
+
- uploaded an image into the composer;
|
|
86
|
+
- submitted a media-analysis prompt through the browser UI;
|
|
87
|
+
- observed assistant message ids from the actual ChatGPT DOM.
|
|
88
|
+
|
|
89
|
+
However, the result path was not reliable enough for a backend:
|
|
90
|
+
|
|
91
|
+
1. One submitted image turn produced a terminal-looking but truncated assistant message:
|
|
92
|
+
|
|
93
|
+
`{"description":"A`
|
|
94
|
+
|
|
95
|
+
2. The existing `result` helper incorrectly considered that partial text stable/final because its generating-state detector did not match the current UI state.
|
|
96
|
+
3. A later image retry entered a persistent `Stop answering` state while the attachment and prompt remained in the composer and no durable new user turn was observed.
|
|
97
|
+
4. The current helper's composer logic also drifted from the current ChatGPT DOM. In the current UI, the visible composer is a `contenteditable` element while a separate textarea may coexist. The helper can fill the wrong semantic surface or fail to observe the send control.
|
|
98
|
+
5. Direct self-healing through fresh DOM observation could send ordinary text turns, but media-turn behavior was still inconsistent.
|
|
99
|
+
|
|
100
|
+
### Structured-output reliability
|
|
101
|
+
|
|
102
|
+
Result: **fail** for backend-grade use.
|
|
103
|
+
|
|
104
|
+
A Markcut backend needs deterministic success/failure signaling and complete output. A truncated JSON prefix that is misclassified as complete is unsafe because downstream parsing or cache persistence can accept corrupted evidence.
|
|
105
|
+
|
|
106
|
+
## Video experiment
|
|
107
|
+
|
|
108
|
+
Direct video upload was attempted as the preferred path. The experiment reached a second failure mode before a trustworthy model result could be obtained: after opening the `markcut` Project for a fresh direct-video test, the current helper/UI combination failed to observe the expected composer surface consistently.
|
|
109
|
+
|
|
110
|
+
Because image media submission was already non-deterministic and the direct-video setup could not produce a verified, repeatable turn, the experiment did **not** treat a frame-sampling fallback as proof that direct video upload works.
|
|
111
|
+
|
|
112
|
+
A frame-sampling fallback remains technically possible because Markcut already owns ffmpeg normalization/sampling and can present sampled images to a backend. But the browser-backed image transport itself must first become reliable.
|
|
113
|
+
|
|
114
|
+
```mermaid
|
|
115
|
+
flowchart TD
|
|
116
|
+
V[video input] --> D{direct web video upload reliable?}
|
|
117
|
+
D -->|current result: no| F[Markcut frame sampling]
|
|
118
|
+
F --> I[image batches]
|
|
119
|
+
I --> B[ChatGPT browser backend]
|
|
120
|
+
B --> Q{media turn + result verified?}
|
|
121
|
+
Q -->|current result: no| X[do not enable backend]
|
|
122
|
+
Q -->|future: yes| O[chronology / scene JSON]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Failure modes observed
|
|
126
|
+
|
|
127
|
+
| Area | Observation | Impact |
|
|
128
|
+
| --- | --- | --- |
|
|
129
|
+
| Composer detection | Current UI exposes both contenteditable and textarea-like surfaces | helper may target the wrong composer |
|
|
130
|
+
| Send detection | helper could report no `Send prompt` even when manual re-observation later found a usable control | brittle create/continue lifecycle |
|
|
131
|
+
| Media upload | attachment preview can exist without a reliable submitted turn | no durable proof input reached model |
|
|
132
|
+
| Generating detection | partial result was considered final | corrupted JSON may be accepted |
|
|
133
|
+
| Result verification | stable DOM text alone is insufficient when streaming state is misdetected | false completion |
|
|
134
|
+
| Project page lifecycle | new Project chat can change composer state/URL after first send | helper assumptions stale |
|
|
135
|
+
| Video | direct video path was not repeatably driven to a verified result | no production evidence |
|
|
136
|
+
|
|
137
|
+
## Minimum changes required before re-benchmarking
|
|
138
|
+
|
|
139
|
+
1. **Fix semantic composer selection**
|
|
140
|
+
- prefer the visible `contenteditable` composer by accessibility role/name;
|
|
141
|
+
- reject ambiguous multiple composer surfaces;
|
|
142
|
+
- verify typed text appears in the same visible surface before sending.
|
|
143
|
+
|
|
144
|
+
2. **Fix send verification**
|
|
145
|
+
- re-observe the current send control after typing/upload;
|
|
146
|
+
- after click, require a new user-turn node or other durable conversation evidence;
|
|
147
|
+
- do not return `prompt_sent: true` from a click alone.
|
|
148
|
+
|
|
149
|
+
3. **Fix streaming/final-result verification**
|
|
150
|
+
- do not infer completion only from absence of one historical stop selector;
|
|
151
|
+
- require the assistant message text to remain stable across a longer interval and require the conversation to return to an input-ready state;
|
|
152
|
+
- for structured output, parse the requested JSON before returning success.
|
|
153
|
+
|
|
154
|
+
4. **Add media-specific lifecycle states**
|
|
155
|
+
- upload started
|
|
156
|
+
- attachment observed
|
|
157
|
+
- attachment ready
|
|
158
|
+
- turn submitted
|
|
159
|
+
- assistant started
|
|
160
|
+
- assistant complete
|
|
161
|
+
- output validated
|
|
162
|
+
|
|
163
|
+
5. **Add regression fixtures**
|
|
164
|
+
- one small PNG
|
|
165
|
+
- one short MP4
|
|
166
|
+
- exact expected JSON fields
|
|
167
|
+
- timeout/stuck-generation case
|
|
168
|
+
- truncated-JSON case
|
|
169
|
+
|
|
170
|
+
6. **Only then add a Markcut adapter command**
|
|
171
|
+
- keep it experimental and opt-in;
|
|
172
|
+
- preserve the existing ITT/VTT CLI template boundary;
|
|
173
|
+
- do not change the default backend.
|
|
174
|
+
|
|
175
|
+
## Proposed future adapter shape
|
|
176
|
+
|
|
177
|
+
If the browser worker is repaired, the narrowest Markcut integration remains a command adapter rather than changes throughout the Vision pipeline:
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
markcut vision
|
|
181
|
+
-> MARKCUT_ITT_CLI / MARKCUT_VTT_CLI
|
|
182
|
+
-> chatgpt-browser-vision executable
|
|
183
|
+
-> chatgpt-browser-worker typed lifecycle
|
|
184
|
+
-> browser-harness
|
|
185
|
+
-> verified JSON stdout
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The adapter should be responsible for media upload, schema validation, retry/resume of the same ChatGPT thread, and non-zero exit on unverified/truncated output.
|
|
189
|
+
|
|
190
|
+
## Recommendation
|
|
191
|
+
|
|
192
|
+
**Not currently viable.**
|
|
193
|
+
|
|
194
|
+
The architectural seam is good and ChatGPT web Vision is reachable through the intended browser-harness stack, but the current `chatgpt-browser-worker` implementation does not yet provide backend-grade guarantees for media submission or final-result verification. The existing Markcut local Vision backend remains substantially more deterministic for this workflow.
|
|
195
|
+
|
|
196
|
+
Do not switch Markcut's default Vision backend. Repair and regression-test the browser worker first, then rerun this PR's image and video fixtures. Once image media turns are deterministic, evaluate video in this order:
|
|
197
|
+
|
|
198
|
+
1. direct MP4 upload;
|
|
199
|
+
2. Markcut-owned frame sampling if direct upload is unsupported or unstable;
|
|
200
|
+
3. compare output quality, JSON validity, p50/p95 latency, and retry rate against the existing backend.
|
|
201
|
+
|
|
202
|
+
## Commands/evidence
|
|
203
|
+
|
|
204
|
+
Control image command used:
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
uvx --from mlx-vlm mlx_vlm.generate \
|
|
208
|
+
--model mlx-community/MiniCPM-V-4.6-bf16 \
|
|
209
|
+
--max-tokens 512 \
|
|
210
|
+
--prompt 'Describe this image precisely, including colored shapes and visible text. Return concise JSON.' \
|
|
211
|
+
--image .tmp/pr3-vision/image-test-unique.png \
|
|
212
|
+
--temperature 0.0 \
|
|
213
|
+
--thinking-mode disabled
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Browser path used the `chatgpt-browser-worker` skill plus `browser-harness`; no ChatGPT API call, copied cookie, or MacDeveloperBridge ChatGPT runtime was used.
|
|
217
|
+
|
|
218
|
+
## Re-benchmark after browser-worker attachment and tab-lifecycle fixes
|
|
219
|
+
|
|
220
|
+
A second pass was run after Neo main added verified attachment submission (`defe80d`) and operation-owned tab cleanup (`3b4331e`). This changed the result in two useful ways:
|
|
221
|
+
|
|
222
|
+
- attachment submission itself is now materially safer: a prior smoke run observed the requested filename, prompt text, a new durable user turn, and a real user message ID before reporting success;
|
|
223
|
+
- `result --expect-json` now rejects malformed/truncated assistant output instead of treating it as completed. A deliberately observed truncated response failed with `assistant result is not valid JSON`, which closes the false-completion path seen in the first pass;
|
|
224
|
+
- operation-owned tab cleanup was verified by comparing page targets before and after an operation that opened a temporary thread tab; the target sets were identical after return.
|
|
225
|
+
|
|
226
|
+
The re-benchmark also exposed a new blocking failure at the durable-thread boundary. Threads that are visible and usable when reached through the existing ChatGPT sidebar can load as a shell-only page when opened directly by their durable conversation URL in a fresh tab. In the observed failures, the URL and Project sidebar loaded, but the conversation had **0 visible user messages, 0 visible assistant messages, and 0 composer surfaces**. `operate_bh.py` therefore correctly failed with `ChatGPT composer was not observed` rather than sending into an ambiguous page.
|
|
227
|
+
|
|
228
|
+
This matters because the current worker contract reopens a durable thread by URL for `send`, `status`, and `result`. A Vision backend cannot be considered reliable if durable thread recovery depends on whether the ChatGPT SPA was entered through sidebar navigation versus direct URL navigation.
|
|
229
|
+
|
|
230
|
+
### Updated failure boundary
|
|
231
|
+
|
|
232
|
+
```mermaid
|
|
233
|
+
flowchart TD
|
|
234
|
+
A[Markcut media input] --> B[chatgpt-browser-worker send]
|
|
235
|
+
B --> C{attachment + durable user turn verified?}
|
|
236
|
+
C -->|yes| D[assistant generation]
|
|
237
|
+
C -->|no| X[fail safely]
|
|
238
|
+
D --> E{result JSON valid?}
|
|
239
|
+
E -->|no| X
|
|
240
|
+
E -->|yes| F[backend result]
|
|
241
|
+
R[resume/reopen thread by durable URL] --> G{conversation UI + composer restored?}
|
|
242
|
+
G -->|current rerun: not reliably| X
|
|
243
|
+
G -->|future| B
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Updated recommendation
|
|
247
|
+
|
|
248
|
+
The browser-worker changes fix two important defects from the first pass—attachment-submit evidence and structured-result validation—but PR #3 should still remain research-only. The remaining blocker is now narrower and clearer: **reliable durable-thread reopening / SPA recovery**. Until the worker can reopen the exact `thread_id` and observe the same conversation state independent of navigation path, direct image/video benchmarking cannot be considered repeatable enough for a Markcut backend.
|
|
249
|
+
|
|
250
|
+
The next browser-worker fix should therefore target thread reopening semantics: prefer semantic sidebar navigation to the exact observed thread when direct URL hydration yields an empty shell, verify the requested `thread_id` after navigation, and only then expose the composer or result operations. After that, rerun the same PNG and MP4 fixtures without changing Markcut itself.
|
package/package.json
CHANGED
package/src/render/cli.mjs
CHANGED
|
@@ -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();
|
package/src/vision/cli.mjs
CHANGED
|
@@ -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
|
-
|
|
807
|
-
|
|
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
|
+
});
|