@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.
- package/.gitattributes +1 -0
- package/README.md +10 -0
- package/docs/evidence-markcut-neutral-preview.md +83 -0
- package/docs/evidence-markcut-real-e2e.json +267 -0
- package/docs/mcp-static-preview.md +57 -0
- package/examples/mcp-preview-review/README.md +6 -0
- package/examples/mcp-preview-review/video-a-v1.md +9 -0
- package/examples/mcp-preview-review/video-a-v2.md +9 -0
- package/examples/mcp-preview-review/video-a-v3.md +9 -0
- package/examples/mcp-preview-review/video-a-v3.wav +0 -0
- package/examples/mcp-preview-review/video-a-v4.md +13 -0
- package/examples/mcp-preview-review/video-a-v4.mp4 +0 -0
- package/examples/mcp-preview-review/video-a-v4.srt +11 -0
- package/examples/mcp-preview-review/video-a-v4.vtt +10 -0
- package/examples/mcp-preview-review/video-a-v4.wav +0 -0
- package/examples/mcp-preview-review/video-b-v1.md +6 -0
- package/examples/mcp-preview-review/video-b-v2.md +6 -0
- package/examples/mcp-preview-review/video-b-v3.md +6 -0
- package/package.json +3 -2
- package/scripts/build-mcp-preview.sh +7 -0
- package/scripts/launch-mcp.sh +6 -0
- package/scripts/verify-mcp-preview.mjs +124 -0
- package/skills/markcut/SKILL.md +14 -8
- package/src/mcp/bridge.mjs +166 -14
- package/src/mcp/browser-speech-preview.ts +60 -0
- package/src/mcp/compile-operation.mjs +52 -0
- package/src/mcp/compile-worker.mjs +20 -0
- package/src/mcp/map-footage.mjs +52 -0
- package/src/mcp/preview.mjs +225 -73
- package/src/mcp/remote-assets.mjs +185 -0
- package/src/mcp/remotion-app.bundle.js +4213 -0
- package/src/mcp/remotion-app.tsx +72 -0
- package/src/mcp/server.mjs +51 -46
- package/src/mcp/web-audio-preview.ts +51 -0
- package/src/player/SharedPlayer.tsx +15 -0
- package/src/player/browser.tsx +2 -16
- package/src/player/bundle/player.js +2053 -1989
- package/src/player/components/SceneThumbnails.tsx +9 -6
- package/src/queue-cli.mjs +17 -0
- package/src/render/cli-tools.ts +6 -5
- package/src/types/Map.tsx +96 -47
- 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-browser-speech.test.ts +8 -0
- package/tests/mcp-map-footage.test.ts +24 -0
- package/tests/mcp-preview-bridge.test.ts +202 -0
- package/tests/mcp-preview.test.ts +76 -165
- package/tests/mcp-remote-assets.test.ts +59 -0
- package/tests/mcp-static-preview.test.ts +69 -0
- 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
|
-
|
|
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 =
|
|
43
|
-
if (currentTime >=
|
|
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
|
-
{
|
|
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
|
+
}
|
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/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
|
|
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
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1201
|
+
active = false;
|
|
1202
|
+
finish();
|
|
1203
|
+
polylines.forEach((line) => line.setMap(null));
|
|
1155
1204
|
};
|
|
1156
1205
|
}, [routesLibrary, map, waypoints, travelMode]);
|
|
1157
1206
|
|
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
|
|
|
@@ -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
|
+
});
|