@lalalic/markcut 3.2.14 → 3.2.16
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/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/types/Map.tsx +96 -47
- 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
|
@@ -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
|
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
|
|
|
@@ -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
|
+
});
|