@lalalic/markcut 3.2.13 → 3.2.15

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.
Files changed (51) hide show
  1. package/.gitattributes +1 -0
  2. package/README.md +10 -0
  3. package/docs/evidence-markcut-neutral-preview.md +83 -0
  4. package/docs/evidence-markcut-real-e2e.json +267 -0
  5. package/docs/mcp-static-preview.md +57 -0
  6. package/examples/mcp-preview-review/README.md +6 -0
  7. package/examples/mcp-preview-review/video-a-v1.md +9 -0
  8. package/examples/mcp-preview-review/video-a-v2.md +9 -0
  9. package/examples/mcp-preview-review/video-a-v3.md +9 -0
  10. package/examples/mcp-preview-review/video-a-v3.wav +0 -0
  11. package/examples/mcp-preview-review/video-a-v4.md +13 -0
  12. package/examples/mcp-preview-review/video-a-v4.mp4 +0 -0
  13. package/examples/mcp-preview-review/video-a-v4.srt +11 -0
  14. package/examples/mcp-preview-review/video-a-v4.vtt +10 -0
  15. package/examples/mcp-preview-review/video-a-v4.wav +0 -0
  16. package/examples/mcp-preview-review/video-b-v1.md +6 -0
  17. package/examples/mcp-preview-review/video-b-v2.md +6 -0
  18. package/examples/mcp-preview-review/video-b-v3.md +6 -0
  19. package/package.json +3 -2
  20. package/scripts/build-mcp-preview.sh +7 -0
  21. package/scripts/launch-mcp.sh +6 -0
  22. package/scripts/verify-mcp-preview.mjs +124 -0
  23. package/skills/markcut/SKILL.md +14 -8
  24. package/src/mcp/bridge.mjs +166 -14
  25. package/src/mcp/browser-speech-preview.ts +60 -0
  26. package/src/mcp/compile-operation.mjs +52 -0
  27. package/src/mcp/compile-worker.mjs +20 -0
  28. package/src/mcp/map-footage.mjs +52 -0
  29. package/src/mcp/preview.mjs +225 -73
  30. package/src/mcp/remote-assets.mjs +185 -0
  31. package/src/mcp/remotion-app.bundle.js +4213 -0
  32. package/src/mcp/remotion-app.tsx +72 -0
  33. package/src/mcp/server.mjs +51 -46
  34. package/src/mcp/web-audio-preview.ts +51 -0
  35. package/src/player/SharedPlayer.tsx +15 -0
  36. package/src/player/browser.tsx +2 -16
  37. package/src/player/bundle/player.js +2053 -1989
  38. package/src/player/components/SceneThumbnails.tsx +9 -6
  39. package/src/queue-cli.mjs +17 -0
  40. package/src/render/cli-tools.ts +6 -5
  41. package/src/types/Map.tsx +96 -47
  42. package/src/vision/candidate.mjs +2 -1
  43. package/src/vision/cli.mjs +2 -1
  44. package/src/vision/default-infer.mjs +3 -1
  45. package/tests/mcp-browser-speech.test.ts +8 -0
  46. package/tests/mcp-map-footage.test.ts +24 -0
  47. package/tests/mcp-preview-bridge.test.ts +202 -0
  48. package/tests/mcp-preview.test.ts +76 -165
  49. package/tests/mcp-remote-assets.test.ts +59 -0
  50. package/tests/mcp-static-preview.test.ts +69 -0
  51. package/tests/queue-cli.test.ts +17 -0
@@ -17,12 +17,14 @@ interface SceneThumbnailsProps {
17
17
  currentTime: number;
18
18
  /** Called when a thumbnail is clicked; receives scene start time in seconds */
19
19
  onSeek?: (timeSeconds: number) => void;
20
+ sceneData?: Scene[];
20
21
  }
21
22
 
22
- export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
23
+ export function SceneThumbnails({ currentTime, onSeek, sceneData }: SceneThumbnailsProps) {
23
24
  const [scenes, setScenes] = React.useState<Scene[]>([]);
24
25
 
25
26
  React.useEffect(() => {
27
+ if(sceneData)return;
26
28
  const variant = (window as any).VARIANT || "default";
27
29
  const url = variant !== "default" ? `/api/video-info?variant=${variant}` : "/api/video-info";
28
30
  fetch(url)
@@ -31,16 +33,17 @@ export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
31
33
  if (info.scenes) setScenes(info.scenes);
32
34
  })
33
35
  .catch(() => {});
34
- }, []);
36
+ }, [sceneData]);
35
37
 
36
- if (scenes.length === 0) return null;
38
+ const displayScenes=sceneData||scenes;
39
+ if (displayScenes.length === 0) return null;
37
40
 
38
41
  // With transition overlaps, scenes can overlap in time (scene[i].end > scene[i+1].start).
39
42
  // Iterate backward so the latest scene whose start <= currentTime wins — i.e., as
40
43
  // soon as the next scene starts (during a transition), it becomes the active one.
41
44
  let activeIdx = -1;
42
- for (let i = scenes.length - 1; i >= 0; i--) {
43
- if (currentTime >= scenes[i].start) {
45
+ for (let i = displayScenes.length - 1; i >= 0; i--) {
46
+ if (currentTime >= displayScenes[i].start) {
44
47
  activeIdx = i;
45
48
  break;
46
49
  }
@@ -48,7 +51,7 @@ export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
48
51
 
49
52
  return (
50
53
  <div id="scene-thumbnails">
51
- {scenes.map((scene, i) => {
54
+ {displayScenes.map((scene, i) => {
52
55
  const isActive = i === activeIdx;
53
56
  return (
54
57
  <span
@@ -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,
package/src/types/Map.tsx CHANGED
@@ -302,6 +302,34 @@ function hideGoogleMapsUi(): void {
302
302
  document.head.appendChild(style);
303
303
  }
304
304
 
305
+ // Cache identical routing requests within one preview session. Scrubbing the
306
+ // Remotion timeline remounts scenes; do not issue a billable Routes request
307
+ // for every seek. Failed requests are evicted so a later retry can recover.
308
+ const routeCache = new Map<string, ReturnType<typeof google.maps.routes.Route.computeRoutes>>();
309
+
310
+ function cachedRoute(
311
+ library: google.maps.RoutesLibrary,
312
+ waypoints: { lat: number; lng: number }[],
313
+ mode: string,
314
+ ): ReturnType<typeof google.maps.routes.Route.computeRoutes> {
315
+ const key = JSON.stringify({ waypoints, mode });
316
+ const hit = routeCache.get(key);
317
+ if (hit) return hit;
318
+ const pending = library.Route.computeRoutes({
319
+ origin: waypoints[0]!,
320
+ destination: waypoints[waypoints.length - 1]!,
321
+ intermediates: waypoints.slice(1, -1).map((point) => ({ location: point })),
322
+ travelMode: mode as google.maps.TravelMode,
323
+ fields: ["legs", "path", "durationMillis"],
324
+ });
325
+ routeCache.set(key, pending);
326
+ pending.catch(() => {
327
+ if (routeCache.get(key) === pending) routeCache.delete(key);
328
+ });
329
+ if (routeCache.size > 64) routeCache.delete(routeCache.keys().next().value!);
330
+ return pending;
331
+ }
332
+
305
333
  // ============================================================
306
334
  // MapLeaf — entry point, dispatches to the view renderer
307
335
  // ============================================================
@@ -1043,8 +1071,20 @@ function useRouteLegs(
1043
1071
  let active = true;
1044
1072
  const renderHandle = delayRender("Loading map directions...");
1045
1073
  handle.current = renderHandle;
1046
-
1047
- const service = new routesLibrary.DirectionsService();
1074
+ let completed = false;
1075
+ const finish = () => {
1076
+ if (completed) return;
1077
+ completed = true;
1078
+ clearTimeout(timeout);
1079
+ if (handle.current === renderHandle) {
1080
+ continueRender(renderHandle);
1081
+ handle.current = null;
1082
+ }
1083
+ };
1084
+ const timeout = window.setTimeout(() => {
1085
+ console.error("Markcut Routes API timeout; inferred route cannot be verified");
1086
+ finish();
1087
+ }, 6000);
1048
1088
 
1049
1089
  Promise.all(
1050
1090
  waypoints.slice(0, -1).map((wp, i) => {
@@ -1053,38 +1093,29 @@ function useRouteLegs(
1053
1093
  if (isSyntheticMode(mode)) {
1054
1094
  return Promise.resolve<RouteLeg | null>(makeSyntheticLeg(wp, to, mode));
1055
1095
  }
1056
- return service
1057
- .route({
1058
- origin: wp,
1059
- destination: to,
1060
- travelMode: google.maps.TravelMode[mode as keyof typeof google.maps.TravelMode] ?? google.maps.TravelMode.DRIVING,
1061
- provideRouteAlternatives: false,
1062
- })
1063
- .then((response) => {
1064
- const leg = response.routes[0]?.legs[0];
1065
- if (!leg) return null;
1066
- const steps = (leg.steps ?? []).map((s) => ({
1067
- path: (s.path ?? []).map((p) => ({ lat: p.lat(), lng: p.lng() })),
1068
- durationSec: s.duration?.value ?? 1,
1069
- }));
1070
- return {
1071
- mode,
1072
- from: wp,
1073
- to,
1074
- durationSec: leg.duration?.value ?? (steps.reduce((sum, st) => sum + st.durationSec, 0) || 1),
1075
- steps,
1076
- } satisfies RouteLeg;
1077
- })
1078
- .catch(() => null);
1096
+ return cachedRoute(routesLibrary, [wp, to], mode).then(({ routes }) => {
1097
+ const route = routes?.[0];
1098
+ if (!route) return null;
1099
+ const durationSec = Math.max(1, (route.durationMillis ?? 1000) / 1000);
1100
+ const path = (route.path ?? []).map((point) => ({ lat: point.lat, lng: point.lng }));
1101
+ return {
1102
+ mode, from: wp, to, durationSec,
1103
+ steps: [{ path: path.length ? path : [wp, to], durationSec }],
1104
+ } satisfies RouteLeg;
1105
+ }).catch((error) => {
1106
+ console.error("Markcut Routes API request failed", error);
1107
+ return null;
1108
+ });
1079
1109
  }),
1080
1110
  ).then((resolved) => {
1081
1111
  if (!active) return;
1082
1112
  setLegs(resolved.filter((l): l is RouteLeg => l !== null));
1083
- if (handle.current !== null) continueRender(handle.current);
1113
+ finish();
1084
1114
  });
1085
1115
 
1086
1116
  return () => {
1087
1117
  active = false;
1118
+ finish();
1088
1119
  };
1089
1120
  }, [routesLibrary, map, waypoints, travelMode]);
1090
1121
 
@@ -1129,29 +1160,47 @@ function useRouteLeg(
1129
1160
  if (!routesLibrary || !map || waypoints.length < 2) return;
1130
1161
  const renderHandle = delayRender("Loading map directions...");
1131
1162
  handle.current = renderHandle;
1163
+ let completed = false;
1164
+ const finish = () => {
1165
+ if (completed) return;
1166
+ completed = true;
1167
+ clearTimeout(timeout);
1168
+ if (handle.current === renderHandle) {
1169
+ continueRender(renderHandle);
1170
+ handle.current = null;
1171
+ }
1172
+ };
1173
+ const timeout = window.setTimeout(() => {
1174
+ console.error("Markcut Routes API timeout; cinematic route unavailable");
1175
+ finish();
1176
+ }, 6000);
1132
1177
 
1133
- const renderer = new routesLibrary.DirectionsRenderer({ map, suppressMarkers: true });
1134
- const service = new routesLibrary.DirectionsService();
1135
-
1136
- service
1137
- .route({
1138
- origin: waypoints[0]!,
1139
- destination: waypoints[waypoints.length - 1]!,
1140
- waypoints: waypoints.slice(1, -1).map((wp) => ({ location: wp, stopover: true })),
1141
- travelMode: google.maps.TravelMode[travelMode as keyof typeof google.maps.TravelMode],
1142
- provideRouteAlternatives: false,
1143
- })
1144
- .then((response) => {
1145
- renderer.setDirections(response);
1146
- setLeg(response.routes[0]?.legs[0] ?? null);
1147
- if (handle.current !== null) continueRender(handle.current);
1148
- })
1149
- .catch(() => {
1150
- if (handle.current !== null) continueRender(handle.current);
1151
- });
1152
-
1178
+ let active = true;
1179
+ let polylines: google.maps.Polyline[] = [];
1180
+ cachedRoute(routesLibrary, waypoints, travelMode).then(({ routes }) => {
1181
+ if (!active) return;
1182
+ const route = routes?.[0];
1183
+ if (route) {
1184
+ polylines = route.createPolylines();
1185
+ polylines.forEach((line) => line.setMap(map));
1186
+ const path = route.path ?? [];
1187
+ const durationSec = Math.max(1, (route.durationMillis ?? 1000) / 1000);
1188
+ // Adapt the modern route to the existing frame-position math.
1189
+ const coordinates = path.map((point) => ({
1190
+ lat: () => point.lat, lng: () => point.lng,
1191
+ }));
1192
+ setLeg({
1193
+ duration: { value: durationSec },
1194
+ steps: [{ duration: { value: durationSec }, path: coordinates }],
1195
+ } as unknown as google.maps.DirectionsLeg);
1196
+ }
1197
+ }).catch((error) => {
1198
+ console.error("Markcut Routes API request failed", error);
1199
+ }).finally(finish);
1153
1200
  return () => {
1154
- renderer.setMap(null);
1201
+ active = false;
1202
+ finish();
1203
+ polylines.forEach((line) => line.setMap(null));
1155
1204
  };
1156
1205
  }, [routesLibrary, map, waypoints, travelMode]);
1157
1206
 
@@ -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
 
@@ -0,0 +1,8 @@
1
+ import {describe,expect,it} from 'vitest';
2
+ import {parseVtt} from '../src/mcp/browser-speech-preview';
3
+ describe('browser TTS captions',()=>{
4
+ it('uses existing VTT words and timing without STT',()=>{
5
+ const cues=parseVtt('WEBVTT\n\n1\n00:00:01.200 --> 00:00:02.900\n<b>Hello</b> world\n\n2\n00:00:03.000 --> 00:00:04.500\nRoute begins\n');
6
+ expect(cues).toEqual([{start:1.2,end:2.9,text:'Hello world'},{start:3,end:4.5,text:'Route begins'}]);
7
+ });
8
+ });
@@ -0,0 +1,24 @@
1
+ import {describe,it,expect} from "vitest";
2
+ import {findMapScenes,renderMapFootage} from "../src/mcp/map-footage.mjs";
3
+
4
+ const root=()=>({type:"root",children:[
5
+ {type:"scene",name:"Opening",visible:true,children:[{type:"component"}]},
6
+ {type:"scene",name:"Route",visible:true,durationInSeconds:3,
7
+ children:[{id:"map-one",type:"map",view:"route",start:0,end:3,visible:true}]},
8
+ {type:"scene",name:"Cinematic",visible:true,durationInSeconds:3,
9
+ children:[{id:"map-two",type:"map",view:"cinematic",start:0,end:3,visible:true}]}
10
+ ]});
11
+
12
+ describe("Remotion map-footage precompilation",()=>{
13
+ it("discovers the existing Google route and cinematic scenes",()=>{
14
+ expect(findMapScenes(root()).map(x=>x.name)).toEqual(["Route","Cinematic"]);
15
+ });
16
+ it("retains live Google Maps unchanged when a render key is unavailable",async()=>{
17
+ const compiled=root(),before=JSON.stringify(compiled);
18
+ const result=await renderMapFootage(compiled,{generatedDir:"/tmp/no-render",key:""});
19
+ expect(result.rendered).toBe(0);
20
+ expect(result.skipped).toBe(2);
21
+ expect(JSON.stringify(result.compiled)).toBe(before);
22
+ expect(findMapScenes(compiled)).toHaveLength(2);
23
+ });
24
+ });
@@ -0,0 +1,202 @@
1
+ import {describe,it,expect} from 'vitest';
2
+ import vm from 'node:vm';
3
+ import {webcrypto} from 'node:crypto';
4
+ import {buildPreviewBridgeScript} from '../src/mcp/bridge.mjs';
5
+
6
+ function harness(remoteData?:any, fetchOverride?:(url:string)=>Promise<any>) {
7
+ const messages:any[]=[];
8
+ const listeners:Record<string,Array<(evt:any)=>void>>={};
9
+ const element=()=>({value:'',textContent:'',onclick:null as null|(()=>void)});
10
+ const nodes:Record<string,ReturnType<typeof element>>={
11
+ feedback:element(),status:element(),'path-label':element(),'video-md-version':element(),'interactive-ui':element(),approve:element(),changes:element()
12
+ };
13
+ const parent={postMessage:(m:any)=>messages.push(m)};
14
+ const events:any[]=[];
15
+ const window={
16
+ parent,
17
+ addEventListener:(type:string,listener:(evt:any)=>void)=>(listeners[type]??=[]).push(listener),
18
+ dispatchEvent:(evt:any)=>{events.push(evt);return true},
19
+ };
20
+ const fetch=fetchOverride|| (async()=>({ok:true,json:async()=>remoteData}));
21
+ const context=vm.createContext({crypto:webcrypto,TextEncoder,URL,fetch,setTimeout,window,document:{getElementById:(id:string)=>nodes[id]},CustomEvent:class {type:string;detail:any;constructor(t:string,opts:any){this.type=t;this.detail=opts.detail}}});
22
+ const dispatch=(message:any,source=parent)=>(listeners.message??[]).forEach(listener=>listener({source,data:{jsonrpc:'2.0',...message}}));
23
+ const result=(data:any)=>dispatch({method:'ui/notifications/tool-result',params:{result:{structuredContent:data}}});
24
+ const respond=(id:number,result:any={})=>dispatch({id,result});
25
+ vm.runInContext(buildPreviewBridgeScript().replaceAll("__MARKCUT_ORIGIN__",JSON.stringify("https://neoy.qili2.com")),context);
26
+ return {messages,nodes,events,dispatch,result,respond};
27
+ }
28
+ const nextTick=()=>new Promise(resolve=>setTimeout(resolve,0));
29
+
30
+ describe('MCP Remotion bridge',()=>{
31
+ it('hydrates scenes from NeoY when ChatGPT does not deliver tool-result',async()=>{
32
+ const path='https://neoy.qili2.com/files/markcut-review/video-a-v4.md';
33
+ const data={id:'remote',revisionId:'remote-r1',contentHash:'sha',path,requestedPath:path,compiled:{children:[{type:'scene'}]}};
34
+ const h=harness(data);
35
+ h.dispatch({method:'ui/notifications/tool-input',params:{arguments:{path}}});
36
+ for(let i=0;i<12&&!h.events.some(e=>e.detail?.revisionId==='remote-r1');i++)await nextTick();
37
+ expect(h.events.at(-1)?.detail?.revisionId).toBe('remote-r1');
38
+ expect(h.nodes.status.textContent).toBe('');
39
+ });
40
+ it('initializes, delivers compiled scenes and submits review without secondary MCP calls',async()=>{
41
+ const h=harness();
42
+ expect(h.messages[0]).toMatchObject({id:1,method:'ui/initialize'});
43
+ h.respond(1);
44
+ await nextTick();
45
+ expect(h.messages[1]).toMatchObject({method:'ui/notifications/initialized'});
46
+ const compiled={width:640,height:480,fps:30,children:[{name:'Scene-A',durationInSeconds:2}]};
47
+ h.result({id:'one',revisionId:'rev1',contentHash:'abcdef',path:'/tmp/a.md',compiled});
48
+ expect(h.events.map(e=>e.type)).toEqual(['markcut:revision']);
49
+ expect(h.events[0].detail.compiled).toEqual(compiled);
50
+ expect(h.nodes['path-label'].textContent).toBe('/tmp/a.md');
51
+ expect(h.nodes['video-md-version'].textContent).toBe('a');
52
+ expect(h.messages.filter(m=>m.method==='tools/call')).toHaveLength(0);
53
+ h.nodes.approve.onclick?.();
54
+ expect(h.messages[2]).toMatchObject({id:2,method:'ui/message'});
55
+ expect(JSON.parse(h.messages[2].params.content[0].text)).toEqual({previewId:'one',revisionId:'rev1',contentHash:'abcdef',path:'/tmp/a.md',decision:'approved'});
56
+ h.respond(2);
57
+ await nextTick();
58
+ expect(h.nodes.status.textContent).toBe('Approved.');
59
+ });
60
+ it('keeps previews isolated per iframe and enforces feedback',async()=>{
61
+ const a=harness(),b=harness();
62
+ a.respond(1);b.respond(1);await nextTick();
63
+ a.result({id:'a',revisionId:'ra',contentHash:'hash-a',compiled:{children:[{name:'A'}]}});
64
+ b.result({id:'b',revisionId:'rb',contentHash:'hash-b',compiled:{children:[{name:'B'}]}});
65
+ expect(a.events[0].detail.compiled.children[0].name).toBe('A');
66
+ expect(b.events[0].detail.compiled.children[0].name).toBe('B');
67
+ a.nodes.changes.onclick?.();
68
+ expect(a.nodes.status.textContent).toBe('Feedback is required.');
69
+ expect(a.messages.filter(m=>m.method==='ui/message')).toHaveLength(0);
70
+ a.nodes.feedback.value='Shorten the opening';
71
+ a.nodes.changes.onclick?.();
72
+ expect(JSON.parse(a.messages[2].params.content[0].text)).toMatchObject({previewId:'a',decision:'changes_requested',feedback:'Shorten the opening'});
73
+ expect(b.messages.filter(m=>m.method==='ui/message')).toHaveLength(0);
74
+ });
75
+ it('does not show the latest B result inside an A-bound iframe',()=>{
76
+ const a=harness(),b=harness();
77
+ const aData={id:'a',revisionId:'ra',contentHash:'a',path:'/a.md',compiled:{children:[{name:'A'}]}};
78
+ const bData={id:'b',revisionId:'rb',contentHash:'b',path:'/b.md',compiled:{children:[{name:'B'}]}};
79
+ a.dispatch({method:'ui/notifications/tool-input',params:{arguments:{path:'/a.md'}}});
80
+ b.dispatch({method:'ui/notifications/tool-input',params:{arguments:{path:'/b.md'}}});
81
+ a.result(bData);b.result(bData);
82
+ expect(a.nodes['path-label'].textContent).toBe('/a.md');
83
+ expect(a.events.filter(e=>e.detail?.revisionId)).toHaveLength(0);
84
+ expect(b.events.at(-1).detail.revisionId).toBe('rb');
85
+ a.result(aData);
86
+ expect(a.events.at(-1).detail.revisionId).toBe('ra');
87
+ a.result(bData);
88
+ expect(a.events.at(-1).detail.revisionId).toBe('ra');
89
+ });
90
+ it('accepts a URL invocation whose result resolves to an absolute local path in either notification order',()=>{
91
+ const url='https://neoy.qili2.com/files/markcut-review/video-a-v4.md';
92
+ const data={id:'u',revisionId:'url-rev',contentHash:'hash',path:'/Users/chengli/Workspace/markcut-manual-review-20261013/video-a-v4.md',requestedPath:url,compiled:{children:[]}};
93
+ const first=harness();
94
+ first.dispatch({method:'ui/notifications/tool-input',params:{arguments:{path:url}}});
95
+ first.result(data);
96
+ expect(first.events.at(-1).detail.revisionId).toBe('url-rev');
97
+ const second=harness();
98
+ second.result(data);
99
+ second.dispatch({method:'ui/notifications/tool-input',params:{arguments:{path:url}}});
100
+ expect(second.events.at(-1).detail.revisionId).toBe('url-rev');
101
+ second.nodes.approve.onclick?.();
102
+ expect(JSON.parse(second.messages.at(-1).params.content[0].text).revisionId).toBe('url-rev');
103
+ });
104
+ it('preserves optional Relay job/task context across review decisions',()=>{
105
+ const h=harness();
106
+ const relay={jobId:'job-7',taskId:19,executionId:'exec-1'};
107
+ h.result({id:'r',revisionId:'r',contentHash:'hash',path:'/video.md',relay,compiled:{children:[]}});
108
+ h.nodes.feedback.value='shorter title';h.nodes.changes.onclick?.();
109
+ expect(JSON.parse(h.messages.at(-1).params.content[0].text).relay).toEqual(relay);
110
+ });
111
+ it('handles request changes followed by a new revision and approval in the same app',async()=>{
112
+ const h=harness();h.respond(1);await nextTick();
113
+ const first={id:'first',revisionId:'r1',contentHash:'sha1',path:'/video.md',compiled:{children:[{name:'First'}]}};
114
+ const next={id:'second',revisionId:'r2',contentHash:'sha2',path:'/video.md',compiled:{children:[{name:'Next'}]}};
115
+ h.result(first);
116
+ h.nodes.feedback.value='lowercase title';h.nodes.changes.onclick?.();
117
+ expect(JSON.parse(h.messages[2].params.content[0].text)).toMatchObject({path:'/video.md',revisionId:'r1',decision:'changes_requested',feedback:'lowercase title'});
118
+ h.respond(2);await nextTick();
119
+ expect(h.nodes.status.textContent).toContain('Waiting for the updated preview');
120
+ h.nodes.approve.onclick?.();
121
+ expect(h.messages.filter(m=>m.method==='ui/message')).toHaveLength(1);
122
+ h.result(next);
123
+ h.nodes.approve.onclick?.();
124
+ const decisions=h.messages.filter(m=>m.method==='ui/message').map(m=>JSON.parse(m.params.content[0].text));
125
+ expect(decisions).toHaveLength(2);
126
+ expect(decisions[1]).toMatchObject({path:'/video.md',revisionId:'r2',contentHash:'sha2',decision:'approved'});
127
+ });
128
+
129
+ });
130
+
131
+
132
+ describe('static compiled-JSON bridge',()=>{
133
+ const base='https://neoy.qili2.com/files/_neoy/markcut/cache/';
134
+ const staticData={width:640,height:480,fps:30,children:[{name:'Static-Scene'}]};
135
+ const makeFetch=(latestHash:string)=>async (url:string)=>{
136
+ if(url.includes('/cache/'))return {ok:true,json:async()=>staticData};
137
+ if(url.includes('/revisions/'))return {ok:true,json:async()=>({contentHash:latestHash})};
138
+ return {ok:false,status:404,json:async()=>({})};
139
+ };
140
+ it('loads immutable JSON over HTTPS and enables review only for current hash',async()=>{
141
+ const hash='a'.repeat(64),h=harness(undefined,makeFetch(hash));
142
+ h.result({id:hash,revisionId:hash,contentHash:hash,path:'/tmp/video.md',
143
+ compiledUrl:base+hash+'.json'});
144
+ for(let i=0;i<5 && !h.events.length;i++)await nextTick();
145
+ expect(h.events.at(-1)?.detail?.compiled.children[0].name).toBe('Static-Scene');
146
+ await nextTick();
147
+ expect(h.nodes.approve.disabled).toBe(false);
148
+ h.nodes.approve.onclick?.();
149
+ await nextTick();
150
+ expect(h.messages.filter(m=>m.method==='ui/message')).toHaveLength(1);
151
+ });
152
+ it('shows historical previews but blocks review submission',async()=>{
153
+ const hash='b'.repeat(64),h=harness(undefined,makeFetch('c'.repeat(64)));
154
+ h.result({id:hash,revisionId:hash,contentHash:hash,path:'/tmp/video.md',
155
+ compiledUrl:base+hash+'.json'});
156
+ for(let i=0;i<5;i++)await nextTick();
157
+ expect(h.events.at(-1)?.detail?.compiled.children[0].name).toBe('Static-Scene');
158
+ expect(h.nodes.approve.disabled).toBe(true);
159
+ expect(h.nodes.status.textContent).toContain('Historical Preview');
160
+ h.nodes.approve.onclick?.();
161
+ await nextTick();
162
+ expect(h.messages.filter(m=>m.method==='ui/message')).toHaveLength(0);
163
+ });
164
+ });
165
+
166
+ describe('async compilation UI transition',()=>{
167
+ const origin='https://neoy.qili2.com/files/_neoy/markcut/';
168
+ const hash='d'.repeat(64);
169
+ const opId='12345678-1234-1234-1234-123456789abc';
170
+ const operation={operationId:opId,operationUrl:origin+'operations/'+opId+'.json',path:'/tmp/video.md',requestedPath:'/tmp/video.md'};
171
+ const compiled={width:640,height:480,fps:30,children:[{name:'Ready Scene'}]};
172
+ it('keeps interactive controls hidden while compiling and reveals the player on ready',async()=>{
173
+ let n=0;
174
+ const fetch=async (url:string)=>{
175
+ if(url.includes('/operations/')){
176
+ n++;
177
+ return {ok:true,json:async()=>n===1?{state:'compiling',stage:'generating-media',startedAt:Date.now()}:
178
+ {state:'ready',result:{id:hash,revisionId:hash,contentHash:hash,path:'/tmp/video.md',compiledUrl:origin+'cache/'+hash+'.json'}}};
179
+ }
180
+ if(url.includes('/cache/'))return {ok:true,json:async()=>compiled};
181
+ return {ok:true,json:async()=>({contentHash:hash})};
182
+ };
183
+ const h=harness(undefined,fetch);
184
+ h.result(operation);
185
+ expect(h.nodes['interactive-ui'].hidden).toBe(true);
186
+ expect(h.nodes.approve.disabled).toBe(true);
187
+ await nextTick();
188
+ expect(h.nodes.status.textContent).toContain('generating-media');
189
+ await new Promise(resolve=>setTimeout(resolve,1150));
190
+ expect(h.events.at(-1)?.detail?.compiled.children[0].name).toBe('Ready Scene');
191
+ expect(h.nodes['interactive-ui'].hidden).toBe(false);
192
+ expect(h.nodes.approve.disabled).toBe(false);
193
+ });
194
+ it('keeps player hidden on a failed compile',async()=>{
195
+ const h=harness(undefined,async()=>({ok:true,json:async()=>({state:'failed',error:'TTS unavailable'})}));
196
+ h.result(operation);
197
+ await nextTick();
198
+ expect(h.nodes['interactive-ui'].hidden).toBe(true);
199
+ expect(h.nodes.approve.disabled).toBe(true);
200
+ expect(h.nodes.status.textContent).toContain('TTS unavailable');
201
+ });
202
+ });