@lalalic/markcut 3.2.12 → 3.2.14
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 +10 -0
- package/package.json +1 -1
- package/src/mcp/bridge.mjs +4 -3
- package/src/mcp/preview.mjs +77 -38
- package/src/mcp/server.mjs +7 -7
- package/src/player/server.mjs +12 -0
- package/src/queue-cli.mjs +17 -0
- package/src/render/cli-tools.ts +6 -5
- package/src/vision/candidate.mjs +2 -1
- package/src/vision/cli.mjs +2 -1
- package/src/vision/default-infer.mjs +3 -1
- package/tests/mcp-preview.test.ts +25 -18
- package/tests/queue-cli.test.ts +17 -0
package/README.md
CHANGED
|
@@ -370,3 +370,13 @@ export MARKCUT_VTT_CLI='my-video-backend --prompt "{prompt}" {input}'
|
|
|
370
370
|
```
|
|
371
371
|
|
|
372
372
|
## Architecture
|
|
373
|
+
|
|
374
|
+
### Global queue CLI template (opt-in)
|
|
375
|
+
|
|
376
|
+
Set `MARKCUT_QUEUE_CLI_TEMPLATE` in Markcut's environment to route generation and vision CLI subprocesses through the PM2-managed queue worker. `{command}` is the shell-quoted original command; the queue wrapper sets `MARKCUT_QUEUE_ACTIVE=1` for descendants to prevent re-entrance.
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
export MARKCUT_QUEUE_CLI_TEMPLATE='node "$HOME/.agents/skills/queue/queue.mjs" run --queue media-ai -- /bin/sh -c {command}'
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The template must use an executable reachable from the process environment. To avoid ambiguity with shell quoting of `$HOME`, use a literal absolute path in production. If unset, execution is unchanged. Rendering cache continues to be managed by Markcut, not the queue. No service restart or Markcut-specific queue daemon is required.
|
package/package.json
CHANGED
package/src/mcp/bridge.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export function buildPreviewBridgeScript(submitToolName) {
|
|
2
2
|
return `
|
|
3
|
-
const feedback=document.getElementById('feedback'),status=document.getElementById('status'),
|
|
3
|
+
const feedback=document.getElementById('feedback'),status=document.getElementById('status'),player=document.getElementById('video-player'),pathLabel=document.getElementById('path-label');
|
|
4
4
|
let nextRequestId=0;
|
|
5
5
|
const pendingRequests=new Map();
|
|
6
6
|
function send(message){window.parent.postMessage(message,'*');}
|
|
7
7
|
function rpcRequest(method,params){const id=++nextRequestId;send({jsonrpc:'2.0',id,method,params});return new Promise((resolve,reject)=>pendingRequests.set(id,{resolve,reject}));}
|
|
8
|
-
function
|
|
8
|
+
async function waitForVideo(url){status.textContent='Preparing video…';for(;;){try{const response=await fetch(url+'api/ready',{cache:'no-store'});if(response.ok){const ready=await response.json();if(ready.ready){player.src=url;status.textContent='Video ready for review.';return;}}}catch{}await new Promise(resolve=>setTimeout(resolve,1500));}}
|
|
9
|
+
function applyToolResult(params){const result=params?.result||params||{},data=result.structuredContent||result.structured_content||{};if(typeof data.path==='string')pathLabel.textContent=data.path;if(typeof data.previewUrl==='string')waitForVideo(data.previewUrl);}
|
|
9
10
|
window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||typeof message!=='object'||message.jsonrpc!=='2.0')return;if(Object.prototype.hasOwnProperty.call(message,'id')&&pendingRequests.has(message.id)){const pending=pendingRequests.get(message.id);pendingRequests.delete(message.id);if(message.error)pending.reject(new Error(message.error.message||'MCP request failed'));else pending.resolve(message.result);return;}if(message.method==='ui/notifications/tool-result')applyToolResult(message.params);});
|
|
10
|
-
async function initializeBridge(){try{await rpcRequest('ui/initialize',{appInfo:{name:'Markcut Preview',version:'1.
|
|
11
|
+
async function initializeBridge(){try{await rpcRequest('ui/initialize',{appInfo:{name:'Markcut Preview',version:'1.1.0'},appCapabilities:{},protocolVersion:'2026-01-26'});send({jsonrpc:'2.0',method:'ui/notifications/initialized'});if(!player.src)status.textContent='Waiting for video preview…';}catch(error){status.textContent=error.message||'MCP initialization failed.';}}
|
|
11
12
|
async function submit(decision){const value=feedback.value.trim();if(decision==='changes_requested'&&!value){status.textContent='Feedback is required.';feedback.focus();return;}const result={decision};if(value&&decision==='changes_requested')result.feedback=value;status.textContent='Submitting...';try{await rpcRequest('tools/call',{name:'${submitToolName}',arguments:result});await rpcRequest('ui/message',{role:'user',content:[{type:'text',text:JSON.stringify(result)}]});status.textContent=decision==='approved'?'Approved.':'Changes requested.';}catch(error){status.textContent=error.message||'Unable to submit review.';}}
|
|
12
13
|
initializeBridge();
|
|
13
14
|
document.getElementById('approve').onclick=()=>submit('approved');document.getElementById('changes').onclick=()=>submit('changes_requested');
|
package/src/mcp/preview.mjs
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { isAbsolute, extname, dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { buildPreviewBridgeScript } from "./bridge.mjs";
|
|
4
7
|
|
|
5
8
|
export const TOOL_NAME = "markcut.preview";
|
|
6
9
|
export const SUBMIT_TOOL_NAME = "markcut.preview.submit";
|
|
7
|
-
export const RESOURCE_URI = "ui://markcut/
|
|
10
|
+
export const RESOURCE_URI = "ui://markcut/video-preview-v3.html";
|
|
8
11
|
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
12
|
+
export const PLAYER_PORT = 4571;
|
|
13
|
+
export const PLAYER_ORIGIN = `http://127.0.0.1:${PLAYER_PORT}`;
|
|
14
|
+
|
|
15
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const playerServer = resolve(moduleDir, "../player/server.mjs");
|
|
17
|
+
let managedPlayer = null;
|
|
18
|
+
let managedPath = null;
|
|
19
|
+
let startPromise = null;
|
|
9
20
|
|
|
10
21
|
export function buildPreviewResourceResult() {
|
|
11
22
|
return {
|
|
@@ -14,11 +25,15 @@ export function buildPreviewResourceResult() {
|
|
|
14
25
|
contents: [{
|
|
15
26
|
uri: RESOURCE_URI,
|
|
16
27
|
mimeType: RESOURCE_MIME_TYPE,
|
|
17
|
-
text: buildPreviewHtml(
|
|
28
|
+
text: buildPreviewHtml(),
|
|
18
29
|
_meta: {
|
|
19
30
|
ui: {
|
|
20
31
|
prefersBorder: true,
|
|
21
|
-
csp: {
|
|
32
|
+
csp: {
|
|
33
|
+
connectDomains: [PLAYER_ORIGIN],
|
|
34
|
+
resourceDomains: [],
|
|
35
|
+
frameDomains: [PLAYER_ORIGIN],
|
|
36
|
+
},
|
|
22
37
|
},
|
|
23
38
|
},
|
|
24
39
|
}],
|
|
@@ -35,13 +50,59 @@ export function validateMarkdownPath(value) {
|
|
|
35
50
|
return value;
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
export async function
|
|
53
|
+
export async function ensureVideoPreview(path, options = {}) {
|
|
39
54
|
const validatedPath = validateMarkdownPath(path);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
await access(validatedPath, fsConstants.R_OK);
|
|
56
|
+
if (managedPlayer && managedPath === validatedPath && managedPlayer.exitCode == null && !managedPlayer.killed) {
|
|
57
|
+
return { path: validatedPath, previewUrl: `${PLAYER_ORIGIN}/` };
|
|
58
|
+
}
|
|
59
|
+
if (startPromise && managedPath === validatedPath) return startPromise;
|
|
60
|
+
|
|
61
|
+
if (managedPlayer && managedPlayer.exitCode == null && !managedPlayer.killed) {
|
|
62
|
+
managedPlayer.kill("SIGTERM");
|
|
44
63
|
}
|
|
64
|
+
managedPlayer = null;
|
|
65
|
+
managedPath = validatedPath;
|
|
66
|
+
|
|
67
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
68
|
+
const settleMs = options.settleMs ?? 250;
|
|
69
|
+
startPromise = new Promise((resolvePromise, rejectPromise) => {
|
|
70
|
+
const child = spawnImpl(process.execPath, [playerServer, validatedPath, `--port=${PLAYER_PORT}`], {
|
|
71
|
+
cwd: resolve(moduleDir, "../.."),
|
|
72
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
73
|
+
});
|
|
74
|
+
managedPlayer = child;
|
|
75
|
+
let stderr = "";
|
|
76
|
+
let settled = false;
|
|
77
|
+
const finish = (error) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
startPromise = null;
|
|
81
|
+
if (error) {
|
|
82
|
+
if (managedPlayer === child) managedPlayer = null;
|
|
83
|
+
rejectPromise(error);
|
|
84
|
+
} else {
|
|
85
|
+
resolvePromise({ path: validatedPath, previewUrl: `${PLAYER_ORIGIN}/` });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
89
|
+
child.once("error", (error) => finish(new Error(`unable to start Markcut player: ${error.message}`)));
|
|
90
|
+
child.once("exit", (code, signal) => {
|
|
91
|
+
if (managedPlayer === child) managedPlayer = null;
|
|
92
|
+
if (!settled) finish(new Error(`Markcut player exited during startup (${signal || code || "unknown"})${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
93
|
+
});
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
if (!settled) finish();
|
|
96
|
+
}, settleMs);
|
|
97
|
+
});
|
|
98
|
+
return startPromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function stopManagedVideoPreview() {
|
|
102
|
+
if (managedPlayer && managedPlayer.exitCode == null && !managedPlayer.killed) managedPlayer.kill("SIGTERM");
|
|
103
|
+
managedPlayer = null;
|
|
104
|
+
managedPath = null;
|
|
105
|
+
startPromise = null;
|
|
45
106
|
}
|
|
46
107
|
|
|
47
108
|
export function reviewResult(decision, feedback = "") {
|
|
@@ -59,38 +120,16 @@ export function reviewMessage(result) {
|
|
|
59
120
|
return {
|
|
60
121
|
jsonrpc: "2.0",
|
|
61
122
|
method: "ui/message",
|
|
62
|
-
params: {
|
|
63
|
-
role: "user",
|
|
64
|
-
content: [{ type: "text", text: JSON.stringify(normalized) }],
|
|
65
|
-
},
|
|
123
|
+
params: { role: "user", content: [{ type: "text", text: JSON.stringify(normalized) }] },
|
|
66
124
|
};
|
|
67
125
|
}
|
|
68
126
|
|
|
69
|
-
function
|
|
70
|
-
return value.replace(/[&<>"']/g, (character) => ({
|
|
71
|
-
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
72
|
-
})[character]);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function renderMarkdown(markdown) {
|
|
76
|
-
return markdown.split(/\r?\n/).map((line) => {
|
|
77
|
-
const escaped = escapeHtml(line);
|
|
78
|
-
if (line.startsWith("### ")) return `<h3>${escaped.slice(4)}</h3>`;
|
|
79
|
-
if (line.startsWith("## ")) return `<h2>${escaped.slice(3)}</h2>`;
|
|
80
|
-
if (line.startsWith("# ")) return `<h1>${escaped.slice(2)}</h1>`;
|
|
81
|
-
if (line.startsWith("- ")) return `<li>${escaped.slice(2)}</li>`;
|
|
82
|
-
return line.trim() ? `<p>${escaped}</p>` : "";
|
|
83
|
-
}).join("\n");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function buildPreviewHtml({ path = "", markdown = "" }) {
|
|
87
|
-
const safePath = escapeHtml(path);
|
|
88
|
-
const rendered = renderMarkdown(markdown);
|
|
127
|
+
export function buildPreviewHtml() {
|
|
89
128
|
return `<!doctype html>
|
|
90
|
-
<html><head><meta charset="utf-8"><title>
|
|
91
|
-
<style>body{font:15px system-ui,sans-serif;margin:0;
|
|
92
|
-
<body><main><h1>Review
|
|
93
|
-
<label for="feedback">Feedback (required for requested changes)</label><textarea id="feedback" placeholder="What should be changed?"></textarea><div id="status" role="status"
|
|
129
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Markcut video review</title>
|
|
130
|
+
<style>body{font:15px system-ui,sans-serif;margin:0;color:#17202a;background:#fff}main{max-width:980px;margin:auto;padding:16px}.meta{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.meta h1{font-size:18px;margin:0}.path{font-size:12px;color:#656d76;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.player{width:100%;aspect-ratio:9/16;max-height:72vh;border:1px solid #d8dee4;border-radius:10px;background:#0d1117;overflow:hidden}.player iframe{width:100%;height:100%;border:0;display:block}textarea{box-sizing:border-box;width:100%;min-height:82px;margin:12px 0 8px;padding:10px;border:1px solid #8c959f;border-radius:6px;font:inherit}button{border:0;border-radius:6px;padding:9px 14px;margin-right:8px;font:inherit;cursor:pointer}#approve{background:#1f883d;color:#fff}#changes{background:#cf222e;color:#fff}#status{min-height:1.4em;margin:8px 0;color:#656d76}</style></head>
|
|
131
|
+
<body><main><div class="meta"><h1>Review video</h1><div class="path" id="path-label"></div></div><div class="player"><iframe id="video-player" title="Markcut video preview" allow="autoplay; fullscreen"></iframe></div>
|
|
132
|
+
<label for="feedback">Feedback (required for requested changes)</label><textarea id="feedback" placeholder="What should be changed?"></textarea><div id="status" role="status">Starting preview…</div>
|
|
94
133
|
<button id="approve">Approve</button><button id="changes">Request changes</button></main>
|
|
95
134
|
<script>${buildPreviewBridgeScript(SUBMIT_TOOL_NAME)}</script></body></html>`;
|
|
96
135
|
}
|
package/src/mcp/server.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
|
4
4
|
import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import {
|
|
7
|
-
TOOL_NAME, SUBMIT_TOOL_NAME, RESOURCE_URI, buildPreviewResourceResult,
|
|
7
|
+
TOOL_NAME, SUBMIT_TOOL_NAME, RESOURCE_URI, PLAYER_ORIGIN, buildPreviewResourceResult, ensureVideoPreview, reviewResult,
|
|
8
8
|
} from "./preview.mjs";
|
|
9
9
|
|
|
10
10
|
const server = new McpServer({ name: "markcut", version: "3.2.0" });
|
|
@@ -13,7 +13,7 @@ registerAppTool(
|
|
|
13
13
|
server,
|
|
14
14
|
TOOL_NAME,
|
|
15
15
|
{
|
|
16
|
-
description: "
|
|
16
|
+
description: "Show the Markcut video player for a Markdown video definition and ask a human to approve it or request changes.",
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
path: z.string().describe("Absolute path to a .md file"),
|
|
19
19
|
}),
|
|
@@ -23,9 +23,9 @@ registerAppTool(
|
|
|
23
23
|
},
|
|
24
24
|
async ({ path }) => {
|
|
25
25
|
try {
|
|
26
|
-
const preview = await
|
|
26
|
+
const preview = await ensureVideoPreview(path);
|
|
27
27
|
return {
|
|
28
|
-
content: [{ type: "text", text: `
|
|
28
|
+
content: [{ type: "text", text: `Video ready for review: ${preview.path}` }],
|
|
29
29
|
structuredContent: { ...preview, resourceUri: RESOURCE_URI },
|
|
30
30
|
_meta: {
|
|
31
31
|
ui: { resourceUri: RESOURCE_URI, props: preview },
|
|
@@ -45,7 +45,7 @@ registerAppTool(
|
|
|
45
45
|
server,
|
|
46
46
|
SUBMIT_TOOL_NAME,
|
|
47
47
|
{
|
|
48
|
-
description: "Submit the review decision from the
|
|
48
|
+
description: "Submit the review decision from the Markcut video preview UI.",
|
|
49
49
|
inputSchema: z.object({
|
|
50
50
|
decision: z.enum(["approved", "changes_requested"]),
|
|
51
51
|
feedback: z.string().optional(),
|
|
@@ -72,13 +72,13 @@ registerAppTool(
|
|
|
72
72
|
|
|
73
73
|
registerAppResource(
|
|
74
74
|
server,
|
|
75
|
-
"
|
|
75
|
+
"Markcut video review",
|
|
76
76
|
RESOURCE_URI,
|
|
77
77
|
{
|
|
78
78
|
_meta: {
|
|
79
79
|
ui: {
|
|
80
80
|
prefersBorder: true,
|
|
81
|
-
csp: { connectDomains: [], resourceDomains: [] },
|
|
81
|
+
csp: { connectDomains: [PLAYER_ORIGIN], resourceDomains: [], frameDomains: [PLAYER_ORIGIN] },
|
|
82
82
|
},
|
|
83
83
|
},
|
|
84
84
|
},
|
package/src/player/server.mjs
CHANGED
|
@@ -1158,6 +1158,18 @@ Edit request: ${text}`;
|
|
|
1158
1158
|
return;
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
|
+
// API: lightweight readiness probe for embedded MCP App previews.
|
|
1162
|
+
if (path === "/api/ready" && req.method === "GET") {
|
|
1163
|
+
const ready = compiledRootCache.has(variantLabel);
|
|
1164
|
+
res.writeHead(200, {
|
|
1165
|
+
"Content-Type": "application/json",
|
|
1166
|
+
"Cache-Control": "no-store",
|
|
1167
|
+
"Access-Control-Allow-Origin": "*",
|
|
1168
|
+
});
|
|
1169
|
+
res.end(JSON.stringify({ ready }));
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1161
1173
|
// API: Get video.json data for a specific variant
|
|
1162
1174
|
if (path === "/api/video-data") {
|
|
1163
1175
|
try {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Optional global queue CLI template for expensive Markcut operations. */
|
|
2
|
+
export function shellQuote(value) {
|
|
3
|
+
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
4
|
+
}
|
|
5
|
+
export function queueCommand(command, env = process.env) {
|
|
6
|
+
const template = env.MARKCUT_QUEUE_CLI_TEMPLATE;
|
|
7
|
+
if (!template || env.MARKCUT_QUEUE_ACTIVE === "1") return command;
|
|
8
|
+
if (!template.includes("{command}")) throw Error("MARKCUT_QUEUE_CLI_TEMPLATE must contain {command}");
|
|
9
|
+
// The worker runs a shell to execute the original CLI without re-entering the queue.
|
|
10
|
+
return template.replaceAll("{command}", shellQuote(`MARKCUT_QUEUE_ACTIVE=1 ${command}`));
|
|
11
|
+
}
|
|
12
|
+
export function queueArgv(command, argv, env = process.env) {
|
|
13
|
+
const template = env.MARKCUT_QUEUE_CLI_TEMPLATE;
|
|
14
|
+
if (!template || env.MARKCUT_QUEUE_ACTIVE === "1") return { command, argv };
|
|
15
|
+
const cmd = [command, ...argv].map(shellQuote).join(" ");
|
|
16
|
+
return { command: "/bin/sh", argv: ["-c", queueCommand(cmd, env)] };
|
|
17
|
+
}
|
package/src/render/cli-tools.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Override via MARKCUT_TTS_CLI / MARKCUT_STT_CLI / MARKCUT_TTI_CLI / MARKCUT_TTV_CLI.
|
|
12
12
|
*/
|
|
13
13
|
import { execSync, exec } from "node:child_process";
|
|
14
|
+
import { queueCommand } from "../queue-cli.mjs";
|
|
14
15
|
import { existsSync, mkdirSync } from "node:fs";
|
|
15
16
|
import { dirname } from "node:path";
|
|
16
17
|
import {
|
|
@@ -89,7 +90,7 @@ export function generateTTS(text: string, outputPath: string, cli?: string): str
|
|
|
89
90
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
90
91
|
const cmd = substituteCli(cli ?? DEFAULT_TTS_CLI, text, outputPath);
|
|
91
92
|
try {
|
|
92
|
-
execSync(cmd, { stdio: "pipe" });
|
|
93
|
+
execSync(queueCommand(cmd), { stdio: "pipe" });
|
|
93
94
|
} catch (e: any) {
|
|
94
95
|
console.warn(` ⚠ TTS failed: ${e.message}`);
|
|
95
96
|
return "";
|
|
@@ -104,7 +105,7 @@ export function generateTTS(text: string, outputPath: string, cli?: string): str
|
|
|
104
105
|
export async function generateSTT(audioPath: string, outputDir: string, cli?: string): Promise<void> {
|
|
105
106
|
const cmd = substituteCli(cli ?? DEFAULT_STT_CLI, audioPath, outputDir);
|
|
106
107
|
try {
|
|
107
|
-
execSync(cmd, { stdio: "pipe" });
|
|
108
|
+
execSync(queueCommand(cmd), { stdio: "pipe" });
|
|
108
109
|
} catch (e: any) {
|
|
109
110
|
console.warn(` ⚠ STT failed: ${e.message}`);
|
|
110
111
|
}
|
|
@@ -119,7 +120,7 @@ export function generateTTI(prompt: string, outputPath: string, cli?: string, se
|
|
|
119
120
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
120
121
|
const cmd = substituteCli(cli ?? DEFAULT_TTI_CLI, prompt, outputPath, seed);
|
|
121
122
|
try {
|
|
122
|
-
execSync(cmd, {
|
|
123
|
+
execSync(queueCommand(cmd), {
|
|
123
124
|
encoding: "utf-8",
|
|
124
125
|
stdio: ["pipe", "pipe", "pipe"],
|
|
125
126
|
timeout: 300_000,
|
|
@@ -162,7 +163,7 @@ export function generateTTV(
|
|
|
162
163
|
}
|
|
163
164
|
try {
|
|
164
165
|
execSync(
|
|
165
|
-
`ffmpeg -y -loop 1 -i "${pngPath}" -c:v libx264 -t 3 -pix_fmt yuv420p "${outputPath}"
|
|
166
|
+
queueCommand(`ffmpeg -y -loop 1 -i "${pngPath}" -c:v libx264 -t 3 -pix_fmt yuv420p "${outputPath}"`),
|
|
166
167
|
{ stdio: ["pipe", "pipe", "pipe"], timeout: 60_000 },
|
|
167
168
|
);
|
|
168
169
|
} catch (e: any) {
|
|
@@ -177,7 +178,7 @@ export function generateTTV(
|
|
|
177
178
|
// Custom CLI mode: substitute template and run
|
|
178
179
|
const cmd = substituteCli(cli, prompt, outputPath, seed);
|
|
179
180
|
try {
|
|
180
|
-
execSync(cmd, {
|
|
181
|
+
execSync(queueCommand(cmd), {
|
|
181
182
|
encoding: "utf-8",
|
|
182
183
|
stdio: ["pipe", "pipe", "pipe"],
|
|
183
184
|
timeout: 300_000,
|
package/src/vision/candidate.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
|
+
import { queueCommand } from "../queue-cli.mjs";
|
|
8
9
|
import { VIDEO_EXTS, DEFAULT_VTT_SAMPLE_INTERVAL } from "../config.mjs";
|
|
9
10
|
|
|
10
11
|
export const CANDIDATE_CONTRACT_VERSION = 1;
|
|
@@ -107,7 +108,7 @@ function shQuote(value) {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
function run(command, options = {}) {
|
|
110
|
-
return execSync(command, {
|
|
111
|
+
return execSync(queueCommand(command), {
|
|
111
112
|
encoding: "utf-8",
|
|
112
113
|
stdio: ["pipe", "pipe", "pipe"],
|
|
113
114
|
timeout: 300_000,
|
package/src/vision/cli.mjs
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { execSync, spawn } from "node:child_process";
|
|
40
|
+
import { queueCommand } from "../queue-cli.mjs";
|
|
40
41
|
import { createHash } from "node:crypto";
|
|
41
42
|
import {
|
|
42
43
|
existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync, rmSync,
|
|
@@ -67,7 +68,7 @@ function emitWarn(msg) { console.error(`⚠️ ${msg}`); }
|
|
|
67
68
|
function emitError(msg) { console.error(`❌ ${msg}`); }
|
|
68
69
|
|
|
69
70
|
function run(cmd, opts = {}) {
|
|
70
|
-
return execSync(cmd, {
|
|
71
|
+
return execSync(queueCommand(cmd), {
|
|
71
72
|
encoding: "utf-8",
|
|
72
73
|
stdio: ["pipe", "pipe", "pipe"],
|
|
73
74
|
timeout: 300_000,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { queueArgv } from "../queue-cli.mjs";
|
|
3
4
|
import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { join } from "node:path";
|
|
@@ -28,7 +29,8 @@ function which(name) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function run(command, args, options = {}) {
|
|
31
|
-
const
|
|
32
|
+
const queued = queueArgv(command, args);
|
|
33
|
+
const r = spawnSync(queued.command, queued.argv, { encoding: "utf8", timeout: options.timeout || 600_000, env: process.env });
|
|
32
34
|
return { ok: r.status === 0, stdout: (r.stdout || "").trim(), stderr: (r.stderr || "").trim(), status: r.status };
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -6,7 +6,7 @@ import vm from "node:vm";
|
|
|
6
6
|
import {
|
|
7
7
|
buildPreviewHtml,
|
|
8
8
|
buildPreviewResourceResult,
|
|
9
|
-
|
|
9
|
+
PLAYER_ORIGIN,
|
|
10
10
|
RESOURCE_MIME_TYPE,
|
|
11
11
|
RESOURCE_URI,
|
|
12
12
|
reviewMessage,
|
|
@@ -21,21 +21,15 @@ describe("MarkCut MCP Markdown preview", () => {
|
|
|
21
21
|
expect(() => validateMarkdownPath("/tmp/notes.txt")).toThrow(/Markdown/);
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it("
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const preview = await readMarkdownPreview(path);
|
|
29
|
-
const html = buildPreviewHtml(preview);
|
|
30
|
-
expect(preview.markdown).toContain("# Hello");
|
|
31
|
-
expect(html).toContain("This is a review.");
|
|
24
|
+
it("builds a video-player review UI instead of rendering Markdown", () => {
|
|
25
|
+
const html = buildPreviewHtml();
|
|
26
|
+
expect(html).toContain('id="video-player"');
|
|
27
|
+
expect(html).toContain("Review video");
|
|
32
28
|
expect(html).toContain("Approve");
|
|
33
29
|
expect(html).toContain("Request changes");
|
|
34
|
-
expect(html).toContain("ui/initialize");
|
|
35
30
|
expect(html).toContain("ui/notifications/tool-result");
|
|
36
|
-
expect(html).toContain("
|
|
37
|
-
expect(html).toContain("
|
|
38
|
-
expect(html).toContain("review.md");
|
|
31
|
+
expect(html).toContain("previewUrl");
|
|
32
|
+
expect(html).not.toContain("<article>");
|
|
39
33
|
});
|
|
40
34
|
|
|
41
35
|
it("returns Codex-compatible private no-cache resource metadata", () => {
|
|
@@ -49,6 +43,7 @@ describe("MarkCut MCP Markdown preview", () => {
|
|
|
49
43
|
});
|
|
50
44
|
expect(result.contents[0].text).toContain("ui/initialize");
|
|
51
45
|
expect(result.contents[0].text).toContain("ui/notifications/initialized");
|
|
46
|
+
expect(result.contents[0]._meta.ui.csp.frameDomains).toEqual([PLAYER_ORIGIN]);
|
|
52
47
|
});
|
|
53
48
|
|
|
54
49
|
it("returns approved", () => {
|
|
@@ -80,7 +75,7 @@ describe("MarkCut MCP Markdown preview", () => {
|
|
|
80
75
|
id: 1,
|
|
81
76
|
method: "ui/initialize",
|
|
82
77
|
params: {
|
|
83
|
-
appInfo: { name: "Markcut Preview", version: "1.
|
|
78
|
+
appInfo: { name: "Markcut Preview", version: "1.1.0" },
|
|
84
79
|
appCapabilities: {},
|
|
85
80
|
protocolVersion: "2026-01-26",
|
|
86
81
|
},
|
|
@@ -98,6 +93,11 @@ describe("MarkCut MCP Markdown preview", () => {
|
|
|
98
93
|
await tick();
|
|
99
94
|
expect(harness.messages[1]).toMatchObject({ method: "ui/notifications/initialized" });
|
|
100
95
|
|
|
96
|
+
harness.toolResult({ path: "/tmp/video.md", previewUrl: PLAYER_ORIGIN + "/" });
|
|
97
|
+
await tick();
|
|
98
|
+
expect(harness.elements.player.src).toBe(PLAYER_ORIGIN + "/");
|
|
99
|
+
expect(harness.elements.pathLabel.textContent).toBe("/tmp/video.md");
|
|
100
|
+
|
|
101
101
|
harness.elements.approve.onclick();
|
|
102
102
|
expect(harness.messages[2]).toMatchObject({ id: 2, method: "tools/call" });
|
|
103
103
|
expect(harness.messages).not.toContainEqual(expect.objectContaining({ method: "ui/message" }));
|
|
@@ -136,23 +136,30 @@ function createBridgeHarness() {
|
|
|
136
136
|
const elements = {
|
|
137
137
|
feedback: element(),
|
|
138
138
|
status: element(),
|
|
139
|
-
|
|
139
|
+
player: element(),
|
|
140
140
|
pathLabel: element(),
|
|
141
141
|
approve: element(),
|
|
142
142
|
changes: element(),
|
|
143
143
|
};
|
|
144
144
|
const parent = { postMessage(message: any) { messages.push(message); } };
|
|
145
145
|
const document = {
|
|
146
|
-
getElementById(id: string) {
|
|
147
|
-
|
|
146
|
+
getElementById(id: string) {
|
|
147
|
+
if (id === "feedback") return elements.feedback;
|
|
148
|
+
if (id === "status") return elements.status;
|
|
149
|
+
if (id === "video-player") return elements.player;
|
|
150
|
+
if (id === "path-label") return elements.pathLabel;
|
|
151
|
+
if (id === "approve") return elements.approve;
|
|
152
|
+
return elements.changes;
|
|
153
|
+
},
|
|
148
154
|
};
|
|
149
155
|
const window = { parent, addEventListener(_type: string, listener: (event: any) => void) { listeners.push(listener); } };
|
|
150
|
-
const context = vm.createContext({ window, document });
|
|
156
|
+
const context = vm.createContext({ window, document, setTimeout, fetch: async () => ({ ok: true, json: async () => ({ ready: true }) }) });
|
|
151
157
|
return {
|
|
152
158
|
messages,
|
|
153
159
|
elements,
|
|
154
160
|
run() { vm.runInContext(buildPreviewBridgeScript("markcut.preview.submit"), context); },
|
|
155
161
|
dispatch(event: any) { listeners.forEach((listener) => listener(event)); },
|
|
156
162
|
respond(message: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", ...message } }); },
|
|
163
|
+
toolResult(structuredContent: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { result: { structuredContent } } } }); },
|
|
157
164
|
};
|
|
158
165
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { queueCommand, queueArgv } from "../src/queue-cli.mjs";
|
|
3
|
+
describe("global queue CLI template", () => {
|
|
4
|
+
it("is disabled unless global env is set", () => expect(queueCommand("echo one", {})).toBe("echo one"));
|
|
5
|
+
it("wraps a shell command with a recursion guard", () => {
|
|
6
|
+
const env = { MARKCUT_QUEUE_CLI_TEMPLATE: "node queue.mjs run --queue media-ai -- /bin/sh -c {command}" };
|
|
7
|
+
const result = queueCommand('echo "a b"', env);
|
|
8
|
+
expect(result).toContain("MARKCUT_QUEUE_ACTIVE=1");
|
|
9
|
+
expect(result).toContain("node queue.mjs run --queue media-ai");
|
|
10
|
+
expect(queueCommand("echo no nested", {...env, MARKCUT_QUEUE_ACTIVE:"1"})).toBe("echo no nested");
|
|
11
|
+
});
|
|
12
|
+
it("rejects unusable templates", () => expect(() => queueCommand("echo 1", {MARKCUT_QUEUE_CLI_TEMPLATE:"invalid"})).toThrow(/\{command\}/));
|
|
13
|
+
it("handles spawned CLI argv without escaping loss", () => {
|
|
14
|
+
const r=queueArgv("python",["file with spaces", "quoted'word"],{MARKCUT_QUEUE_CLI_TEMPLATE:"queue run -- /bin/sh -c {command}"});
|
|
15
|
+
expect(r.command).toBe("/bin/sh");expect(r.argv[1]).toContain("MARKCUT_QUEUE_ACTIVE");
|
|
16
|
+
});
|
|
17
|
+
});
|