@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.
Files changed (44) hide show
  1. package/.gitattributes +1 -0
  2. package/docs/evidence-markcut-neutral-preview.md +83 -0
  3. package/docs/evidence-markcut-real-e2e.json +267 -0
  4. package/docs/mcp-static-preview.md +57 -0
  5. package/examples/mcp-preview-review/README.md +6 -0
  6. package/examples/mcp-preview-review/video-a-v1.md +9 -0
  7. package/examples/mcp-preview-review/video-a-v2.md +9 -0
  8. package/examples/mcp-preview-review/video-a-v3.md +9 -0
  9. package/examples/mcp-preview-review/video-a-v3.wav +0 -0
  10. package/examples/mcp-preview-review/video-a-v4.md +13 -0
  11. package/examples/mcp-preview-review/video-a-v4.mp4 +0 -0
  12. package/examples/mcp-preview-review/video-a-v4.srt +11 -0
  13. package/examples/mcp-preview-review/video-a-v4.vtt +10 -0
  14. package/examples/mcp-preview-review/video-a-v4.wav +0 -0
  15. package/examples/mcp-preview-review/video-b-v1.md +6 -0
  16. package/examples/mcp-preview-review/video-b-v2.md +6 -0
  17. package/examples/mcp-preview-review/video-b-v3.md +6 -0
  18. package/package.json +3 -2
  19. package/scripts/build-mcp-preview.sh +7 -0
  20. package/scripts/launch-mcp.sh +6 -0
  21. package/scripts/verify-mcp-preview.mjs +124 -0
  22. package/skills/markcut/SKILL.md +14 -8
  23. package/src/mcp/bridge.mjs +166 -14
  24. package/src/mcp/browser-speech-preview.ts +60 -0
  25. package/src/mcp/compile-operation.mjs +52 -0
  26. package/src/mcp/compile-worker.mjs +20 -0
  27. package/src/mcp/map-footage.mjs +52 -0
  28. package/src/mcp/preview.mjs +225 -73
  29. package/src/mcp/remote-assets.mjs +185 -0
  30. package/src/mcp/remotion-app.bundle.js +4213 -0
  31. package/src/mcp/remotion-app.tsx +72 -0
  32. package/src/mcp/server.mjs +51 -46
  33. package/src/mcp/web-audio-preview.ts +51 -0
  34. package/src/player/SharedPlayer.tsx +15 -0
  35. package/src/player/browser.tsx +2 -16
  36. package/src/player/bundle/player.js +2053 -1989
  37. package/src/player/components/SceneThumbnails.tsx +9 -6
  38. package/src/types/Map.tsx +96 -47
  39. package/tests/mcp-browser-speech.test.ts +8 -0
  40. package/tests/mcp-map-footage.test.ts +24 -0
  41. package/tests/mcp-preview-bridge.test.ts +202 -0
  42. package/tests/mcp-preview.test.ts +76 -165
  43. package/tests/mcp-remote-assets.test.ts +59 -0
  44. package/tests/mcp-static-preview.test.ts +69 -0
@@ -0,0 +1,72 @@
1
+ import * as React from 'react';
2
+ import {createRoot} from 'react-dom/client';
3
+ import {createPortal} from 'react-dom';
4
+ import type {PlayerRef} from '@remotion/player';
5
+ import {SharedPlayer} from '../player/SharedPlayer';
6
+ import {SceneThumbnails} from '../player/components/SceneThumbnails';
7
+ import {useWebAudioPreview} from './web-audio-preview';
8
+ import {useBrowserSpeechPreview} from './browser-speech-preview';
9
+ const PLAYER_VERSION='v24';
10
+ function App(){
11
+ const [revision,setRevision]=React.useState<any>(()=>(window as any).__markcutRevision||null);
12
+ const ref=React.useRef<PlayerRef|null>(null);
13
+ const [currentTime,setCurrentTime]=React.useState(0);
14
+ const [browserTts,setBrowserTts]=React.useState(true);
15
+ React.useEffect(()=>{const receive=(e:Event)=>setRevision((e as CustomEvent).detail);window.addEventListener('markcut:revision',receive);return ()=>window.removeEventListener('markcut:revision',receive)},[]);
16
+ const data=revision?.compiled?.root||revision?.compiled;
17
+ const useBrowserTts=browserTts&&Boolean(data?.subtitle?.src);
18
+ const {playbackRoot:generatedRoot,status}=useWebAudioPreview(useBrowserTts?null:data,ref);
19
+ const playbackRoot=React.useMemo(()=>{
20
+ if(!useBrowserTts)return generatedRoot;
21
+ const strip=(n:any):any=>{
22
+ if(!n||typeof n!=="object")return n;
23
+ if(n.type==="audio")return null;
24
+ return {...n,...(Array.isArray(n.children)?{children:n.children.map(strip).filter(Boolean)}:{})};
25
+ };
26
+ return {...strip(data),subtitle:undefined};
27
+ },[data,generatedRoot,useBrowserTts]);
28
+ const {caption}=useBrowserSpeechPreview(data,ref,useBrowserTts);
29
+ const fps=data?.fps||30;
30
+ const scenes=React.useMemo(()=>{
31
+ const children=(data?.children||[]).filter((x:any)=>x.type==='scene'&&x.visible!==false);
32
+ const overlap=Math.max(0,Number(data?.transitionTime)||0);
33
+ let start=0;
34
+ return children.map((scene:any,index:number)=>{
35
+ const duration=Math.max(0,Number(scene.durationInSeconds)||0);
36
+ const entry={name:String(scene.name||'Scene '+(index+1)),start,end:start+duration,id:scene.id};
37
+ start+=Math.max(0,duration-(index<children.length-1?overlap:0));
38
+ return entry;
39
+ });
40
+ },[data]);
41
+ React.useEffect(()=>{
42
+ const player=ref.current;if(!player)return;
43
+ const listener=(e:any)=>{if(typeof e.detail?.frame==='number')setCurrentTime(e.detail.frame/fps);};
44
+ player.addEventListener('frameupdate',listener);
45
+ return ()=>player.removeEventListener('frameupdate',listener);
46
+ },[revision?.revisionId,playbackRoot,fps]);
47
+ React.useEffect(()=>{
48
+ const active=[...scenes].reverse().find(scene=>currentTime>=scene.start);
49
+ (window as any).__markcutPlaybackContext={
50
+ sceneId:active?.id||null,sceneName:active?.name||null,
51
+ timeSeconds:Math.round(currentTime*10)/10
52
+ };
53
+ },[scenes,currentTime]);
54
+ if(!revision)return <div id="remotion-waiting">Player {PLAYER_VERSION} · Waiting for compiled scenes…</div>;
55
+ if(!data)return <div>Missing compiled scene tree</div>;
56
+ const videoName=String(revision.title||revision.videoTitle||revision.path?.split('/').pop()?.replace(/\.md$/i,'').replace(/[-_]+/g,' ')||'Video');
57
+ return <div id="remotion-preview" data-revision={revision.revisionId}>
58
+ {document.getElementById("header-tts")&&createPortal(
59
+ <label style={{display:"flex",gap:5,alignItems:"center",fontSize:12,whiteSpace:"nowrap",cursor:"pointer"}} title="Use browser narration with captions from the script">
60
+ <input type="checkbox" checked={useBrowserTts} disabled={!data?.subtitle?.src} onChange={e=>setBrowserTts(e.target.checked)}/>
61
+ Browser TTS
62
+ </label>,document.getElementById("header-tts")!)}
63
+ <div id="remotion-canvas" style={{width:'100%',aspectRatio:`${data.width||1080}/${data.height||1920}`,maxHeight:'65vh',margin:'auto'}}>
64
+ <SharedPlayer key={revision.revisionId} root={playbackRoot} playerRef={ref} numberOfSharedAudioTags={0} style={{width:'100%',height:'100%'}} />
65
+ </div>
66
+ {useBrowserTts&&caption&&<div role="status" style={{textAlign:"center",padding:"6px 12px",fontSize:13}}>{caption}</div>}
67
+ <SceneThumbnails sceneData={scenes} currentTime={currentTime} onSeek={t=>{ref.current?.seekTo(Math.round(t*fps));setCurrentTime(t);}} />
68
+ {status && !/initializing|ready|paused/i.test(status) && <div id="preview-audio-status" role="status" style={{fontSize:12,padding:'4px 0'}}>{status.replace(/^Web Audio /,'Audio ')}</div>}
69
+ </div>;
70
+ }
71
+ const el=document.getElementById('remotion-root');
72
+ if(el)createRoot(el).render(<App/>);
@@ -3,63 +3,64 @@ import { McpServer } from "@modelcontextprotocol/server";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
4
4
  import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
5
5
  import { z } from "zod";
6
+ import {publishPreviewRevision} from "./remote-assets.mjs";
7
+ import {startCompileOperation} from "./compile-operation.mjs";
6
8
  import {
7
- TOOL_NAME, SUBMIT_TOOL_NAME, RESOURCE_URI, PLAYER_ORIGIN, buildPreviewResourceResult, ensureVideoPreview, reviewResult,
9
+ TOOL_NAME, RESOURCE_URI, LEGACY_RESOURCE_URIS, buildPreviewResourceResult, previewCsp, getPreviewMedia, ensureVideoPreview, stopManagedVideoPreview,
8
10
  } from "./preview.mjs";
9
11
 
10
12
  const server = new McpServer({ name: "markcut", version: "3.2.0" });
13
+ process.once("exit", stopManagedVideoPreview);
14
+ // Stdio clients commonly terminate the server via SIGTERM rather than EOF.
15
+ // Node's default signal termination does not reliably run exit handlers.
16
+ for (const signal of ["SIGTERM", "SIGINT"]) {
17
+ process.once(signal, () => { stopManagedVideoPreview(); process.exit(0); });
18
+ }
19
+ process.stdin.once("end", stopManagedVideoPreview);
20
+ process.stdin.once("close", stopManagedVideoPreview);
11
21
 
12
- registerAppTool(
13
- server,
14
- TOOL_NAME,
22
+ // Discoverable at federation startup: unlike dynamically added resource URIs,
23
+ // an app-only tool remains routable for every independently identified preview.
24
+ server.registerTool(
25
+ "markcut.preview.media",
15
26
  {
16
- description: "Show the Markcut video player for a Markdown video definition and ask a human to approve it or request changes.",
17
- inputSchema: z.object({
18
- path: z.string().describe("Absolute path to a .md file"),
19
- }),
20
- _meta: {
21
- ui: { resourceUri: RESOURCE_URI },
22
- },
27
+ description: "Read compiled Remotion scene data for an existing Markcut preview revision.",
28
+ inputSchema: z.object({contentHash:z.string().regex(/^[a-f0-9]{64}$/)}),
29
+ _meta: { ui: { visibility: ["app"] } },
23
30
  },
24
- async ({ path }) => {
31
+ async ({contentHash}) => {
25
32
  try {
26
- const preview = await ensureVideoPreview(path);
27
- return {
28
- content: [{ type: "text", text: `Video ready for review: ${preview.path}` }],
29
- structuredContent: { ...preview, resourceUri: RESOURCE_URI },
30
- _meta: {
31
- ui: { resourceUri: RESOURCE_URI, props: preview },
32
- "ui/resourceUri": RESOURCE_URI,
33
- },
34
- };
33
+ return {content:[{type:"text",text:"Preview media ready"}], structuredContent:getPreviewMedia(contentHash)};
35
34
  } catch (error) {
36
- return {
37
- isError: true,
38
- content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
39
- };
35
+ return {isError:true,content:[{type:"text",text:error.message||String(error)}]};
40
36
  }
41
37
  },
42
38
  );
43
39
 
44
40
  registerAppTool(
45
41
  server,
46
- SUBMIT_TOOL_NAME,
42
+ TOOL_NAME,
47
43
  {
48
- description: "Submit the review decision from the Markcut video preview UI.",
44
+ description: "Show the Markcut video player for a Markdown video definition and ask a human to approve it or request changes. When a review ui/message arrives with decision=changes_requested, edit the supplied path using feedback and call markcut.preview again with the same path; do not consider it approved. When decision=approved, treat only that contentHash/revisionId as approved and stop the review loop. Never approve a newer revision from an older message.",
49
45
  inputSchema: z.object({
50
- decision: z.enum(["approved", "changes_requested"]),
51
- feedback: z.string().optional(),
46
+ path: z.string().describe("Absolute .md path or NeoY HTTPS mounted file URL"),
47
+ relay: z.object({jobId:z.string(),taskId:z.union([z.string(),z.number()]),executionId:z.string().optional()}).optional().describe("Optional Agents Relay task correlation. No Relay dependency for standalone ChatGPT/Work reviews."),
52
48
  }),
53
49
  _meta: {
54
- ui: { visibility: ["app"] },
50
+ ui: { resourceUri: RESOURCE_URI },
55
51
  },
56
52
  },
57
- async ({ decision, feedback }) => {
53
+ async ({ path, relay }) => {
58
54
  try {
59
- const result = reviewResult(decision, feedback);
55
+ const operation=startCompileOperation(path,relay);
56
+ const payload={...operation,...(relay ? {relay} : {})};
60
57
  return {
61
- content: [{ type: "text", text: JSON.stringify(result) }],
62
- structuredContent: result,
58
+ content: [{ type: "text", text: `Compilation started: ${operation.path}` }],
59
+ structuredContent: payload,
60
+ _meta: {
61
+ ui: { resourceUri: RESOURCE_URI, props: payload },
62
+ "ui/resourceUri": RESOURCE_URI,
63
+ },
63
64
  };
64
65
  } catch (error) {
65
66
  return {
@@ -70,19 +71,23 @@ registerAppTool(
70
71
  },
71
72
  );
72
73
 
73
- registerAppResource(
74
- server,
75
- "Markcut video review",
76
- RESOURCE_URI,
77
- {
78
- _meta: {
79
- ui: {
80
- prefersBorder: true,
81
- csp: { connectDomains: [PLAYER_ORIGIN], resourceDomains: [], frameDomains: [PLAYER_ORIGIN] },
74
+ // All historical static entrypoints resolve to the latest bridge/player HTML.
75
+ // Register at startup so federation can discover every alias.
76
+ for (const uri of [RESOURCE_URI, ...LEGACY_RESOURCE_URIS]) {
77
+ registerAppResource(
78
+ server,
79
+ `Markcut video review (latest; alias ${uri})`,
80
+ uri,
81
+ {
82
+ _meta: {
83
+ ui: {
84
+ prefersBorder: true,
85
+ csp: previewCsp(),
86
+ },
82
87
  },
83
88
  },
84
- },
85
- async () => buildPreviewResourceResult(),
86
- );
89
+ async () => buildPreviewResourceResult(uri),
90
+ );
91
+ }
87
92
 
88
93
  await server.connect(new StdioServerTransport());
@@ -0,0 +1,51 @@
1
+ import * as React from 'react';
2
+ import type {PlayerRef} from '@remotion/player';
3
+
4
+ export function useWebAudioPreview(root:any, playerRef:React.RefObject<PlayerRef|null>) {
5
+ const [status,setStatus]=React.useState('');
6
+ const {audio,playbackRoot}=React.useMemo(()=>{
7
+ let audio:any=null;
8
+ const strip=(node:any):any=>{
9
+ if(!node||typeof node!=='object')return node;
10
+ if(node.type==='audio'&&String(node.src).startsWith('https://')){audio??=node;return null;}
11
+ return {...node,...(Array.isArray(node.children)?{children:node.children.map(strip).filter(Boolean)}:{})};
12
+ };
13
+ return {audio,playbackRoot:strip(root)};
14
+ },[root]);
15
+ React.useEffect(()=>{
16
+ if(!audio)return;
17
+ const ctx=new AudioContext();
18
+ let buffer:AudioBuffer|null=null,source:AudioBufferSourceNode|null=null,gain:GainNode|null=null,disposed=false;
19
+ let generation=0;
20
+ const stop=()=>{generation++;if(source){source.onended=null;try{source.stop()}catch{}source.disconnect();source=null;}gain?.disconnect();gain=null;};
21
+ fetch(audio.src,{mode:'cors'}).then(response=>{if(!response.ok)throw new Error('HTTP '+response.status);return response.arrayBuffer();})
22
+ .then(bytes=>ctx.decodeAudioData(bytes)).then(b=>{buffer=b;if(!disposed)setStatus(`Web Audio ready · ${b.duration.toFixed(2)}s`);}).catch(e=>setStatus('Decode failed: '+String(e)));
23
+ const play=async()=>{
24
+ const ticket=++generation;
25
+ try{
26
+ await ctx.resume();
27
+ if(ticket!==generation||disposed)return;
28
+ if(!buffer){setStatus('Audio decoding; retry Play');return;}
29
+ const player=playerRef.current;if(!player)return;
30
+ const offset=player.getCurrentFrame()/(root.fps||30);
31
+ if(offset>=buffer.duration)return;
32
+ if(source){source.onended=null;try{source.stop()}catch{}source.disconnect();}
33
+ const s=ctx.createBufferSource();const g=ctx.createGain();s.buffer=buffer;
34
+ g.gain.value=player.isMuted()?0:player.getVolume();
35
+ s.connect(g);g.connect(ctx.destination);s.start(0,Math.max(0,offset));
36
+ source=s;gain=g;setStatus(`Web Audio playing · ${offset.toFixed(2)}s · volume ${g.gain.value}`);
37
+ }catch(e){setStatus('Playback failed: '+String(e));}
38
+ };
39
+ const pause=()=>{stop();setStatus('Web Audio paused');};
40
+ const seek=()=>{stop();if(playerRef.current?.isPlaying())void play();};
41
+ const volume=()=>{if(gain&&playerRef.current)gain.gain.value=playerRef.current.isMuted()?0:playerRef.current.getVolume();};
42
+ let player:PlayerRef|null=null;
43
+ const attach=()=>{if(player)return;const p=playerRef.current;if(!p)return;player=p;
44
+ p.addEventListener('play',play);p.addEventListener('pause',pause);p.addEventListener('ended',pause);
45
+ p.addEventListener('seeked',seek);p.addEventListener('volumechange',volume);p.addEventListener('mutechange',volume);
46
+ };
47
+ attach();const poll=setInterval(attach,100);
48
+ return()=>{disposed=true;clearInterval(poll);if(player){player.removeEventListener('play',play);player.removeEventListener('pause',pause);player.removeEventListener('ended',pause);player.removeEventListener('seeked',seek);player.removeEventListener('volumechange',volume);player.removeEventListener('mutechange',volume);}stop();void ctx.close();};
49
+ },[audio,playerRef,root]);
50
+ return {playbackRoot,status};
51
+ }
@@ -0,0 +1,15 @@
1
+ import * as React from 'react';
2
+ import {Player} from '@remotion/player';
3
+ import {MarkCut, getDurationInSeconds} from '../entry';
4
+
5
+ // Shared Remotion composition, controls, and native MarkCut audio tree.
6
+ export function SharedPlayer({root, playerRef, ...props}: {root:any;playerRef?:React.Ref<any>;[key:string]:any}) {
7
+ const fps=root?.fps||30;
8
+ const durationInFrames=Math.max(1,Math.ceil((getDurationInSeconds(root,true)||5)*fps));
9
+ const inputProps=React.useMemo(()=>({root,compose:{}}),[root]);
10
+ return <Player ref={playerRef as any} component={MarkCut} inputProps={inputProps}
11
+ durationInFrames={durationInFrames} fps={fps}
12
+ compositionWidth={root?.width||1080} compositionHeight={root?.height||1920}
13
+ controls={true} showPlaybackRateControl={true} allowFullscreen={true}
14
+ clickToPlay={false} doubleClickToFullscreen={true} autoPlay={false} {...props}/>;
15
+ }
@@ -13,6 +13,7 @@ import { createRoot } from "react-dom/client";
13
13
  import * as ReactDOM from "react-dom";
14
14
  import * as Remotion from "remotion";
15
15
  import { Player } from "@remotion/player";
16
+ import { SharedPlayer } from "./SharedPlayer";
16
17
  import { MarkCut, getDurationInSeconds } from "../entry";
17
18
  import { HeaderBar, EditControls, EditMessagePanel, LabelControls, SceneThumbnails, VariantBar } from "./components/index";
18
19
  import type { EditEntry } from "./components/EditMessagePanel";
@@ -528,22 +529,7 @@ function PlayerApp() {
528
529
 
529
530
  {/* ── Player frame ── */}
530
531
  <div id="player-frame" style={{ flex: 1, width: "100%", maxWidth: 480, minHeight: 0 }}>
531
- <Player
532
- ref={playerRef}
533
- component={MarkCut}
534
- inputProps={inputProps}
535
- durationInFrames={durationInFrames}
536
- fps={fps}
537
- compositionWidth={width}
538
- compositionHeight={height}
539
- style={{ width: "100%", height: "100%" }}
540
- controls={true}
541
- showPlaybackRateControl={true}
542
- allowFullscreen={true}
543
- clickToPlay={false}
544
- doubleClickToFullscreen={true}
545
- autoPlay={autoPlay}
546
- />
532
+ <SharedPlayer root={data} playerRef={playerRef} style={{ width: "100%", height: "100%" }} autoPlay={autoPlay} />
547
533
 
548
534
  {/* ── Edit message overlay (shown while waiting, then auto-hides) ── */}
549
535
  {mode === "edit" && showEditOverlay && (