@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "3.2.12",
3
+ "version": "3.2.14",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -1,13 +1,14 @@
1
1
  export function buildPreviewBridgeScript(submitToolName) {
2
2
  return `
3
- const feedback=document.getElementById('feedback'),status=document.getElementById('status'),article=document.querySelector('article'),pathLabel=document.querySelector('main>p code');
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 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.markdown==='string'){article.innerHTML=data.markdown.split(/\\r?\\n/).map(line=>{const escaped=line.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));if(line.startsWith('### '))return '<h3>'+escaped.slice(4)+'</h3>';if(line.startsWith('## '))return '<h2>'+escaped.slice(3)+'</h2>';if(line.startsWith('# '))return '<h1>'+escaped.slice(2)+'</h1>';if(line.startsWith('- '))return '<li>'+escaped.slice(2)+'</li>';return line.trim()?'<p>'+escaped+'</p>':''}).join('\\n');}}
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.0.0'},appCapabilities:{},protocolVersion:'2026-01-26'});send({jsonrpc:'2.0',method:'ui/notifications/initialized'});status.textContent='Ready for review.';}catch(error){status.textContent=error.message||'MCP initialization failed.';}}
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');
@@ -1,11 +1,22 @@
1
- import { readFile } from "node:fs/promises";
2
- import { isAbsolute, extname } from "node:path";
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/markdown-preview-v2.html";
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({ path: "", markdown: "" }),
28
+ text: buildPreviewHtml(),
18
29
  _meta: {
19
30
  ui: {
20
31
  prefersBorder: true,
21
- csp: { connectDomains: [], resourceDomains: [] },
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 readMarkdownPreview(path) {
53
+ export async function ensureVideoPreview(path, options = {}) {
39
54
  const validatedPath = validateMarkdownPath(path);
40
- try {
41
- return { path: validatedPath, markdown: await readFile(validatedPath, "utf8") };
42
- } catch (error) {
43
- throw new Error(`unable to read Markdown file: ${error instanceof Error ? error.message : String(error)}`);
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 escapeHtml(value) {
70
- return value.replace(/[&<>"']/g, (character) => ({
71
- "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
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>MarkCut Markdown review</title>
91
- <style>body{font:15px system-ui,sans-serif;margin:0;padding:24px;color:#17202a;background:#fff}main{max-width:860px;margin:auto}article{border:1px solid #d8dee4;border-radius:8px;padding:20px;overflow:auto}h1,h2,h3{margin-top:0}p{line-height:1.5}textarea{box-sizing:border-box;width:100%;min-height:100px;margin:16px 0 10px;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;color:#cf222e}</style></head>
92
- <body><main><h1>Review Markdown</h1><p><code>${safePath}</code></p><article>${rendered}</article>
93
- <label for="feedback">Feedback (required for requested changes)</label><textarea id="feedback" placeholder="What should be changed?"></textarea><div id="status" role="status"></div>
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
  }
@@ -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, readMarkdownPreview, reviewResult,
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: "Ask a human to review a Markdown file and return an approval or requested changes.",
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 readMarkdownPreview(path);
26
+ const preview = await ensureVideoPreview(path);
27
27
  return {
28
- content: [{ type: "text", text: `Markdown ready for review: ${preview.path}` }],
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 Markdown preview UI.",
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
- "MarkCut Markdown review",
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
  },
@@ -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
+ }
@@ -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,
@@ -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,
@@ -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 r = spawnSync(command, args, { encoding: "utf8", timeout: options.timeout || 600_000, env: process.env });
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
- readMarkdownPreview,
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("reads Markdown and builds a reviewable UI resource", async () => {
25
- const directory = mkdtempSync(join(tmpdir(), "markcut-mcp-"));
26
- const path = join(directory, "review.md");
27
- writeFileSync(path, "# Hello\n\nThis is a review.");
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("tools/call");
37
- expect(html).toContain("ui/message");
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.0.0" },
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
- article: element(),
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) { return id === "feedback" ? elements.feedback : id === "status" ? elements.status : id === "approve" ? elements.approve : elements.changes; },
147
- querySelector(selector: string) { return selector === "article" ? elements.article : elements.pathLabel; },
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
+ });