@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,124 @@
1
+ #!/usr/bin/env node
2
+ // Real MCP stdio compile-and-release verification. No player manager or HTTP child.
3
+ import {Client} from "@modelcontextprotocol/client";
4
+ import {StdioClientTransport} from "@modelcontextprotocol/client/stdio";
5
+ import {mkdtemp,writeFile,readFile,readdir,rm} from "node:fs/promises";
6
+ import {tmpdir} from "node:os";
7
+ import {join} from "node:path";
8
+ import {execFileSync} from "node:child_process";
9
+ import assert from "node:assert/strict";
10
+
11
+ const run=await mkdtemp(join(tmpdir(),"markcut-static-e2e-"));
12
+ const exportRoot=join(run,"export");
13
+ const report={timestamp:new Date().toISOString(),checks:[]};
14
+ let client;
15
+ function processes(){
16
+ return execFileSync("ps",["-axo","pid,ppid,command"],{encoding:"utf8"}).split("\n")
17
+ .filter(x=>x.includes("src/mcp/managed-player.mjs")||
18
+ (x.includes("src/player/server.mjs")&&x.includes(".markcut-preview-")));
19
+ }
20
+ const before=processes();
21
+ async function connect(){
22
+ const transport=new StdioClientTransport({
23
+ command:process.execPath,args:["src/mcp/server.mjs"],cwd:process.cwd(),
24
+ env:{...process.env,NEOY_PUBLIC_ORIGIN:"https://neoy.example.test",MARKCUT_EXPORT_ROOT:exportRoot}
25
+ });
26
+ client=new Client({name:"static-e2e",version:"1.0.0"});
27
+ await client.connect(transport);
28
+ }
29
+ async function preview(file){
30
+ const started=Date.now();
31
+ const response=await client.callTool({name:"markcut.preview",arguments:{path:file}});
32
+ if(response.isError)throw new Error(response.content?.[0]?.text||"preview failed");
33
+ const operation=response.structuredContent;
34
+ assert.ok(operation.operationId,"tool should return operation identity immediately");
35
+ assert.match(operation.operationUrl,/^https:\/\/neoy\.example\.test\/files\/_neoy\/markcut\/operations\/[a-f0-9-]{36}\.json$/);
36
+ assert.ok(Date.now()-started<3000,"the tool should not await compilation");
37
+ const statusFile=join(exportRoot,"operations",operation.operationId+".json");
38
+ for(let attempt=0;attempt<300;attempt++){
39
+ const status=JSON.parse(await readFile(statusFile,"utf8"));
40
+ if(status.state==="failed")throw new Error("Compiler failed: "+status.error);
41
+ if(status.state==="ready"){
42
+ const data=status.result;
43
+ assert.match(data.compiledUrl,/^https:\/\/neoy\.example\.test\/files\/_neoy\/markcut\/cache\/[a-f0-9]{64}\.json$/);
44
+ assert.ok(!data.previewUrl,"no local player process");
45
+ return data;
46
+ }
47
+ await new Promise(r=>setTimeout(r,100));
48
+ }
49
+ throw new Error("Compile worker did not finish within 30 seconds");
50
+ }
51
+ async function compiled(p){
52
+ return JSON.parse(await readFile(join(exportRoot,"cache",p.contentHash+".json"),"utf8"));
53
+ }
54
+ async function check(name,fn){
55
+ try {const detail=await fn();report.checks.push({name,status:"PASS",detail});}
56
+ catch(e){report.checks.push({name,status:"FAIL",error:String(e)});throw e;}
57
+ }
58
+ const md=(name)=>'# video\nwidth:640 height:480 fps:30 layout:series\n## '+name+'\n- component duration:2 jsx:"<div>'+name+'</div>"\n';
59
+ try {
60
+ const a=join(run,"A.md"),b=join(run,"B.md");
61
+ await writeFile(a,md("Alpha-v1"));
62
+ await writeFile(b,md("Beta-v1"));
63
+ await connect();
64
+ let A,B;
65
+ await check("two Videos coexist",async()=>{
66
+ [A,B]=await Promise.all([preview(a),preview(b)]);
67
+ assert.notEqual(A.contentHash,B.contentHash);
68
+ assert.match(JSON.stringify(await compiled(A)),/Alpha-v1/);
69
+ assert.match(JSON.stringify(await compiled(B)),/Beta-v1/);
70
+ return {a:A.contentHash,b:B.contentHash};
71
+ });
72
+ await check("repeated compile uses same immutable hash",async()=>{
73
+ const identical=await preview(a);
74
+ assert.equal(identical.contentHash,A.contentHash);
75
+ });
76
+ await writeFile(a,md("Alpha-v2"));
77
+ await check("new version preserves older static JSON",async()=>{
78
+ const latest=await preview(a);
79
+ assert.notEqual(latest.contentHash,A.contentHash);
80
+ assert.match(JSON.stringify(await compiled(A)),/Alpha-v1/);
81
+ assert.match(JSON.stringify(await compiled(latest)),/Alpha-v2/);
82
+ assert.match(JSON.stringify(await compiled(B)),/Beta-v1/);
83
+ });
84
+ await check("no port slots: eleven versions of one Video retain only ten",async()=>{
85
+ const versions=[];
86
+ for(let i=0;i<11;i++){
87
+ await writeFile(a,md("Alpha-"+i));
88
+ versions.push(await preview(a));
89
+ }
90
+ const cache=await readdir(join(run,".markcut","cache"));
91
+ assert.equal(cache.filter(x=>x.endsWith(".json")).length,11); // 10 A + 1 B
92
+ await readFile(join(exportRoot,"cache",versions[10].contentHash+".json"));
93
+ await assert.rejects(readFile(join(exportRoot,"cache",versions[0].contentHash+".json")));
94
+ assert.match(JSON.stringify(await compiled(B)),/Beta-v1/);
95
+ return {aHistory:10,bHistory:1};
96
+ });
97
+ await check("restart does not expire retained static preview",async()=>{
98
+ await client.close();
99
+ await connect();
100
+ const latest=await preview(b);
101
+ assert.equal(latest.contentHash,B.contentHash);
102
+ assert.match(JSON.stringify(await compiled(B)),/Beta-v1/);
103
+ });
104
+ await check("invalid path does not replace previous compiled",async()=>{
105
+ const invalid=await client.callTool({name:"markcut.preview",arguments:{path:join(run,"missing.md")}});
106
+ assert.equal(invalid.isError,true);
107
+ const existing=await compiled(B);
108
+ assert.match(JSON.stringify(existing),/Beta-v1/);
109
+ });
110
+ await client.close();client=null;
111
+ await check("no managed player or child process spawned",async()=>{
112
+ const after=processes();
113
+ assert.deepEqual(after,before);
114
+ return {preexisting:before.length,new:0};
115
+ });
116
+ }catch(error){report.error=String(error);process.exitCode=1;}
117
+ finally{
118
+ try{await client?.close()}catch{}
119
+ report.completedAt=new Date().toISOString();
120
+ const out=process.env.MARKCUT_EVIDENCE_FILE;
121
+ if(out)await writeFile(out,JSON.stringify(report,null,2)+"\n");
122
+ console.log(JSON.stringify(report,null,2));
123
+ await rm(run,{recursive:true,force:true});
124
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: markcut
3
- description: use markdown to describe stream trees, provide CLI to render stream trees to video via `npx @lalalic/markcut`. use it to structure video scenes, and generate TTS, TTI, TTV, STT media automatically.
3
+ description: Author, compile, preview, review and render videos from Markcut Markdown stream trees. Use for scene planning, TTS/TTI/TTV/STT media generation, Google Maps GPS routes, native CLI previews, and interactive ChatGPT MCP video reviews with contextual feedback and approval.
4
4
  ---
5
5
 
6
6
  ## Stream Tree Specs
@@ -78,15 +78,21 @@ npx @lalalic/markcut spots --waypoints "lat,lng;lat,lng" # discover POIs along a
78
78
 
79
79
  ---
80
80
 
81
- ## MCP Markdown Review
81
+ ## MCP Video Preview and Human Review (Player v24)
82
82
 
83
- Use the model-visible MCP tool `markcut.preview({ path })` when a human needs to review a Markdown artifact. This is a generic Markdown review surface and is separate from the video-specific `npx @lalalic/markcut preview <file>` CLI.
83
+ Use the model-visible `markcut.preview({path})` tool when a human needs to **watch and review a Markcut video** in ChatGPT. It is a video player, **not** a generic Markdown renderer and not the same as the local `npx @lalalic/markcut preview <file>` command.
84
84
 
85
- - `path` must be an absolute path to a Markdown file.
86
- - The user sees the rendered Markdown and can either **Approve** or submit feedback with **Request changes**.
87
- - Markcut does not edit the file. The calling agent owns all source changes.
88
- - On `approved`, continue the workflow.
89
- - On `changes_requested`, apply the feedback to the source Markdown, then call `markcut.preview({ path })` again for another review round.
85
+ - Supply an absolute `.md` path (or a supported NeoY mounted HTTPS Markdown URL). An optional `relay` object correlates an Agents Relay task but is not needed for standalone review.
86
+ - The MCP call returns promptly with an asynchronous compilation operation. The hosted iframe shows actual compilation stages, then loads a published immutable compiled JSON and media snapshot. There is no persistent player-manager process.
87
+ - Player controls include Play/Pause, Seek, volume/audio, fullscreen and a **horizontal clickable scene list** sourced from compiled scene metadata. The title bar displays **Markcut · video title**, a **Browser TTS** switch, and the player version at right.
88
+ - **Browser TTS** is an optional preview playback mode (default on when subtitle VTT exists). It speaks existing caption/script text in the browser; it does **not** use STT. Toggle off for generated audio and the original subtitle renderer. This switch does not currently skip compilation-time TTS or affect final renders. Playback timing and seeking are approximate for browser speech.
89
+ - Google Maps `map` streams (route/cinematic) can be prerendered by Remotion into MP4 during MCP compilation. The published compiled JSON then references video streams, not live map tiles or a browser-exposed API key. If the render key is unavailable or prerendering fails, the original live Google Maps stream is preserved as a fallback. Native Map support remains available.
90
+ - **Send feedback** (formerly Request changes) requires text and sends `ui/message` with `decision: "changes_requested"`, the source path, revision/content hash, and `playbackContext` (current `sceneId`, `sceneName`, `timeSeconds`) when available. The calling agent must interpret the feedback, edit the supplied video Markdown, and invoke `markcut.preview` again for the next turn. The player does not edit files.
91
+ - **Approve** sends `decision: "approved"` via `ui/message` for the specific revision. Do not approve a different/newer revision on the basis of an old review.
92
+ - Earlier previews are read-only once superseded; the submitted revision is checked against the latest published hash.
93
+ - **Verification boundary:** compilation, static MP4 publication, and focused tests have passed, but complete real-hosted ChatGPT feedback → agent edit → re-preview → approval E2E still requires acceptance testing. Do not claim it is verified based on tool metadata alone.
94
+
95
+ See [docs/mcp-static-preview.md](../../docs/mcp-static-preview.md) for the lifecycle and [./review.md](./review.md) for content-quality review guidance.
90
96
 
91
97
  ## review
92
98
  use the review contract defined in [./review.md](./review.md) to guide the review process.
@@ -1,16 +1,168 @@
1
- export function buildPreviewBridgeScript(submitToolName) {
2
- return `
3
- const feedback=document.getElementById('feedback'),status=document.getElementById('status'),player=document.getElementById('video-player'),pathLabel=document.getElementById('path-label');
4
- let nextRequestId=0;
5
- const pendingRequests=new Map();
6
- function send(message){window.parent.postMessage(message,'*');}
7
- function rpcRequest(method,params){const id=++nextRequestId;send({jsonrpc:'2.0',id,method,params});return new Promise((resolve,reject)=>pendingRequests.set(id,{resolve,reject}));}
8
- async function waitForVideo(url){status.textContent='Preparing video…';for(;;){try{const response=await fetch(url+'api/ready',{cache:'no-store'});if(response.ok){const ready=await response.json();if(ready.ready){player.src=url;status.textContent='Video ready for review.';return;}}}catch{}await new Promise(resolve=>setTimeout(resolve,1500));}}
9
- function applyToolResult(params){const result=params?.result||params||{},data=result.structuredContent||result.structured_content||{};if(typeof data.path==='string')pathLabel.textContent=data.path;if(typeof data.previewUrl==='string')waitForVideo(data.previewUrl);}
10
- window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||typeof message!=='object'||message.jsonrpc!=='2.0')return;if(Object.prototype.hasOwnProperty.call(message,'id')&&pendingRequests.has(message.id)){const pending=pendingRequests.get(message.id);pendingRequests.delete(message.id);if(message.error)pending.reject(new Error(message.error.message||'MCP request failed'));else pending.resolve(message.result);return;}if(message.method==='ui/notifications/tool-result')applyToolResult(message.params);});
11
- async function initializeBridge(){try{await rpcRequest('ui/initialize',{appInfo:{name:'Markcut Preview',version:'1.1.0'},appCapabilities:{},protocolVersion:'2026-01-26'});send({jsonrpc:'2.0',method:'ui/notifications/initialized'});if(!player.src)status.textContent='Waiting for video preview…';}catch(error){status.textContent=error.message||'MCP initialization failed.';}}
12
- async function submit(decision){const value=feedback.value.trim();if(decision==='changes_requested'&&!value){status.textContent='Feedback is required.';feedback.focus();return;}const result={decision};if(value&&decision==='changes_requested')result.feedback=value;status.textContent='Submitting...';try{await rpcRequest('tools/call',{name:'${submitToolName}',arguments:result});await rpcRequest('ui/message',{role:'user',content:[{type:'text',text:JSON.stringify(result)}]});status.textContent=decision==='approved'?'Approved.':'Changes requested.';}catch(error){status.textContent=error.message||'Unable to submit review.';}}
13
- initializeBridge();
14
- document.getElementById('approve').onclick=()=>submit('approved');document.getElementById('changes').onclick=()=>submit('changes_requested');
1
+ export function buildPreviewBridgeScript() {
2
+ return `
3
+ const feedback=document.getElementById('feedback'),status=document.getElementById('status'),pathLabel=document.getElementById('path-label');
4
+ let nextId=0,identity=null,busy=false,expectedPath=null,activePath=null,activeRelay=null,submittedRevision=null;
5
+ const pending=new Map();
6
+ const resultsByPath=new Map();
7
+ let fallbackGeneration=0;
8
+ async function loadRemoteScenes(path){
9
+ const generation=++fallbackGeneration;
10
+ try{
11
+ const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(path));
12
+ const hash=Array.from(new Uint8Array(digest),x=>x.toString(16).padStart(2,'0')).join('');
13
+ const url=__MARKCUT_ORIGIN__+'/files/_neoy/markcut/revisions/'+hash+'.json';
14
+ for(let attempt=0;attempt<25 && generation===fallbackGeneration;attempt++){
15
+ try{
16
+ const response=await fetch(url+'?try='+attempt,{cache:'no-store'});
17
+ if(response.ok){const data=await response.json();
18
+ if(data.requestedPath===path && (data.compiledUrl||data.compiled) && generation===fallbackGeneration){
19
+ if(window.__markcutRevision?.requestedPath===path)return;
20
+ resultsByPath.set(path,data);renderRevision(data);return;
21
+ }
22
+ }
23
+ }catch(e){if(attempt===24)status.textContent='Scene fetch failed: '+String(e);}
24
+ await new Promise(resolve=>setTimeout(resolve,600));
25
+ }
26
+ if(generation===fallbackGeneration && !window.__markcutRevision)status.textContent='Scene data unavailable from NeoY';
27
+ }catch(e){status.textContent='Scene lookup failed: '+String(e);}
28
+ }
29
+ function send(msg){window.parent.postMessage(msg,'*');}
30
+ function request(method,params){const id=++nextId;send({jsonrpc:'2.0',id,method,params});return new Promise((resolve,reject)=>pending.set(id,{resolve,reject}));}
31
+ function showVideoName(path,title){const el=document.getElementById('video-md-version');if(!el)return;const file=typeof path==='string'?decodeURIComponent(path.split('/').pop()||'video.md'):'video.md';el.textContent=typeof title==='string'&&title.trim()?title.trim():file.replace(/\.md$/i,'').replace(/[-_]+/g,' ');}
32
+ function applyToolInput(params){
33
+ const input=params?.arguments||params?.input||params?.toolInput||params||{};
34
+ if(typeof input.path!=='string')return;
35
+ expectedPath=input.path;
36
+ loadRemoteScenes(expectedPath);
37
+ showVideoName(expectedPath);
38
+ const cached=resultsByPath.get(expectedPath);
39
+ if(cached){if(cached.operationUrl)trackOperation(cached);else renderRevision(cached);return;}
40
+ if(window.__markcutRevision?.path!==expectedPath && window.__markcutRevision?.requestedPath!==expectedPath){
41
+ identity=null;
42
+ window.__markcutRevision=null;
43
+ window.dispatchEvent(new CustomEvent('markcut:revision',{detail:null}));
44
+ pathLabel.textContent=expectedPath;
45
+ status.textContent='Waiting for matching video scenes…';
46
+ }
47
+ }
48
+ let operationGeneration=0;
49
+ async function trackOperation(data){
50
+ const generation=++operationGeneration;
51
+ ++fallbackGeneration; // Prevent an old published revision from replacing in-progress UI.
52
+ let url;
53
+ try{url=new URL(data.operationUrl);}catch{status.textContent='Invalid operation URL';return;}
54
+ if(url.origin!==__MARKCUT_ORIGIN__||!url.pathname.startsWith('/files/_neoy/markcut/operations/')||!(/^[a-f0-9-]{36}[.]json$/.test(url.pathname.split('/').pop()))){
55
+ status.textContent='Invalid operation URL';return;
56
+ }
57
+ document.getElementById('interactive-ui').hidden=true;
58
+ reviewReadonly(true,'Compiling…');
59
+ for(let attempt=0;attempt<1800&&generation===operationGeneration;attempt++){
60
+ try{
61
+ const response=await fetch(url.href+'?t='+Date.now(),{cache:'no-store'});
62
+ if(!response.ok)throw new Error('HTTP '+response.status);
63
+ const progress=await response.json();
64
+ if(generation!==operationGeneration)return;
65
+ if(progress.state==='ready'&&progress.result){renderRevision({...progress.result,requestedPath:data.requestedPath||data.path,relay:data.relay});return;}
66
+ if(progress.state==='failed'){reviewReadonly(true,'Compilation failed: '+progress.error);return;}
67
+ const elapsed=Math.round((Date.now()-(progress.startedAt||Date.now()))/1000);
68
+ status.textContent='Compiling · '+(progress.stage||'working')+' · '+elapsed+'s';
69
+ }catch(e){status.textContent='Waiting for compile status… '+String(e);}
70
+ await new Promise(resolve=>setTimeout(resolve,1000));
71
+ }
72
+ if(generation===operationGeneration)status.textContent='Compilation status timed out';
73
+ }
74
+ function applyToolResult(params){
75
+ const result=params?.result||params||{};
76
+ const data=result.structuredContent||result.structured_content||result._meta?.ui?.props||{};
77
+ if(typeof data.path==='string')resultsByPath.set(data.path,data);
78
+ if(typeof data.requestedPath==='string')resultsByPath.set(data.requestedPath,data);
79
+ if(expectedPath&&data.path!==expectedPath&&data.requestedPath!==expectedPath)return;
80
+ if(data.operationUrl){trackOperation(data);return;}
81
+ renderRevision(data);
82
+ }
83
+ let renderGeneration=0;
84
+ async function currentRevisionMatches(path,hash){
85
+ if(!path||!hash)return false;
86
+ const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(path));
87
+ const key=Array.from(new Uint8Array(digest),x=>x.toString(16).padStart(2,'0')).join('');
88
+ const response=await fetch(__MARKCUT_ORIGIN__+'/files/_neoy/markcut/revisions/'+key+'.json?t='+Date.now(),{cache:'no-store'});
89
+ if(!response.ok)throw new Error('Latest preview status unavailable');
90
+ const latest=await response.json();
91
+ return latest.contentHash===hash;
92
+ }
93
+ function reviewReadonly(readonly,message){
94
+ document.getElementById('approve').disabled=readonly;
95
+ document.getElementById('changes').disabled=readonly;
96
+ if(readonly&&message)status.textContent=message;
97
+ }
98
+ async function renderRevision(data){
99
+ const generation=++renderGeneration;
100
+ if(data.path){pathLabel.textContent=data.path;showVideoName(data.path,data.title||data.videoTitle);}
101
+ if(!data.compiled&&!data.compiledUrl){status.textContent='Compiled scenes unavailable';return;}
102
+ identity={previewId:data.id,revisionId:data.revisionId,contentHash:data.contentHash};
103
+ activePath=data.requestedPath||data.path||expectedPath||null;
104
+ activeRelay=data.relay||null;
105
+ if(submittedRevision!==data.revisionId){busy=false;submittedRevision=null;}
106
+ reviewReadonly(true,'Loading static compiled scenes…');
107
+ try{
108
+ let compiled=data.compiled;
109
+ if(data.compiledUrl){
110
+ const url=new URL(data.compiledUrl);
111
+ if(url.origin!==__MARKCUT_ORIGIN__||!url.pathname.startsWith('/files/_neoy/markcut/cache/')||!(/^[a-f0-9]{64}[.]json$/.test(url.pathname.split('/').pop())))
112
+ throw new Error('Invalid immutable preview URL');
113
+ const response=await fetch(url.href,{cache:'force-cache'});
114
+ if(!response.ok)throw new Error('Static compiled scenes unavailable: HTTP '+response.status);
115
+ compiled=await response.json();
116
+ }
117
+ if(generation!==renderGeneration)return;
118
+ document.getElementById('interactive-ui').hidden=false;
119
+ window.__markcutRevision={...data,compiled};
120
+ window.dispatchEvent(new CustomEvent('markcut:revision',{detail:window.__markcutRevision}));
121
+ status.textContent='';
122
+ if(!data.compiledUrl){reviewReadonly(false);return;}
123
+ const same=await currentRevisionMatches(activePath,identity.contentHash);
124
+ if(generation!==renderGeneration)return;
125
+ reviewReadonly(!same,same?'':'Historical Preview · Read-only');
126
+ }catch(e){
127
+ if(generation!==renderGeneration)return;
128
+ reviewReadonly(true,'Preview unavailable or review state unverified: '+String(e));
129
+ }
130
+ }
131
+ window.addEventListener('message',event=>{
132
+ if(event.source!==window.parent)return;
133
+ const message=event.data;
134
+ if(!message||message.jsonrpc!=='2.0')return;
135
+ if(Object.prototype.hasOwnProperty.call(message,'id')&&pending.has(message.id)){
136
+ const item=pending.get(message.id);pending.delete(message.id);
137
+ if(message.error)item.reject(new Error(message.error.message||'MCP request failed'));
138
+ else item.resolve(message.result);
139
+ }else if(message.method==='ui/notifications/tool-input')applyToolInput(message.params);
140
+ else if(message.method==='ui/notifications/tool-result')applyToolResult(message.params);
141
+ });
142
+ async function initialize(){
143
+ try {await request('ui/initialize',{appInfo:{name:'Markcut Preview',version:'2.0.0'},appCapabilities:{},protocolVersion:'2026-01-26'});send({jsonrpc:'2.0',method:'ui/notifications/initialized'});}
144
+ catch(e){status.textContent=e.message||'MCP initialization failed';}
145
+ }
146
+ async function submit(decision){
147
+ if(busy)return;
148
+ const value=feedback.value.trim();
149
+ if(decision==='changes_requested'&&!value){status.textContent='Feedback is required.';return;}
150
+ if(!identity){status.textContent='Preview identity unavailable.';return;}
151
+ if(window.__markcutRevision?.compiledUrl){
152
+ try{if(!await currentRevisionMatches(activePath,identity.contentHash)){reviewReadonly(true,'Historical Preview · Read-only');return;}}
153
+ catch(e){reviewReadonly(true,'Cannot verify latest preview: '+String(e));return;}
154
+ }
155
+ const result={...identity,decision};
156
+ if(activePath)result.path=activePath;
157
+ if(activeRelay)result.relay=activeRelay;
158
+ if(decision==='changes_requested'){result.feedback=value;if(window.__markcutPlaybackContext)result.playbackContext=window.__markcutPlaybackContext;}
159
+ busy=true;status.textContent='Submitting…';
160
+ const submittedId=result.revisionId;
161
+ try{await request('ui/message',{role:'user',content:[{type:'text',text:JSON.stringify(result)}]});if(identity?.revisionId===submittedId){submittedRevision=submittedId;status.textContent=decision==='approved'?'Approved.':'Feedback sent. Waiting for the updated preview.';}}
162
+ catch(e){busy=false;status.textContent=e.message||'Unable to submit review';}
163
+ }
164
+ document.getElementById('approve').onclick=()=>submit('approved');
165
+ document.getElementById('changes').onclick=()=>submit('changes_requested');
166
+ initialize();
15
167
  `;
16
168
  }
@@ -0,0 +1,60 @@
1
+ import * as React from "react";
2
+ import type {PlayerRef} from "@remotion/player";
3
+
4
+ type Cue={start:number;end:number;text:string};
5
+ function seconds(s:string){const a=s.replace(",",".").split(":").map(Number);return a.reduce((v,n)=>v*60+n,0);}
6
+ export function parseVtt(text:string):Cue[]{
7
+ const lines=text.replace(/\r/g,"").split("\n"),cues:Cue[]=[];
8
+ for(let i=0;i<lines.length;i++){
9
+ if(!lines[i].includes("-->"))continue;
10
+ const match=lines[i].match(/(\d{2}:)?\d{2}:\d{2}[.,]\d+\s*-->\s*((\d{2}:)?\d{2}:\d{2}[.,]\d+)/);
11
+ if(!match)continue;
12
+ const [from,to]=match[0].split("-->").map(x=>x.trim());
13
+ const start=seconds(from),end=seconds(to);
14
+ let content="";while(lines[++i]?.trim())content+=(content?" ":"")+lines[i].replace(/<[^>]*>/g,"");
15
+ if(content)cues.push({start,end,text:content});
16
+ }
17
+ return cues;
18
+ }
19
+ export function useBrowserSpeechPreview(root:any,playerRef:React.RefObject<PlayerRef|null>,enabled:boolean){
20
+ const [cues,setCues]=React.useState<Cue[]>([]);
21
+ const [time,setTime]=React.useState(0);
22
+ React.useEffect(()=>{
23
+ setCues([]);
24
+ if(!enabled||!root?.subtitle?.src)return;
25
+ const ctrl=new AbortController();
26
+ fetch(root.subtitle.src,{signal:ctrl.signal}).then(r=>{if(!r.ok)throw Error("VTT unavailable");return r.text()})
27
+ .then(text=>setCues(parseVtt(text))).catch(()=>{});
28
+ return ()=>ctrl.abort();
29
+ },[root?.subtitle?.src,enabled]);
30
+ React.useEffect(()=>{
31
+ if(!enabled||typeof window==="undefined"||!("speechSynthesis" in window))return;
32
+ const synth=window.speechSynthesis;
33
+ let player:PlayerRef|null=null,disposed=false,lastIndex=-1;
34
+ const stop=()=>{synth.cancel();lastIndex=-1;};
35
+ const update=()=>{
36
+ if(!player||disposed)return;
37
+ const t=player.getCurrentFrame()/(root?.fps||30);
38
+ setTime(t);
39
+ if(!player.isPlaying()){stop();return;}
40
+ const index=cues.findIndex(c=>t>=c.start&&t<c.end);
41
+ if(index===lastIndex)return;
42
+ synth.cancel();lastIndex=index;
43
+ if(index>=0&&!player.isMuted()){
44
+ const utterance=new SpeechSynthesisUtterance(cues[index].text);
45
+ utterance.rate=1;utterance.volume=player.getVolume();
46
+ synth.speak(utterance);
47
+ }
48
+ };
49
+ const attach=()=>{
50
+ if(player||!playerRef.current)return;
51
+ player=playerRef.current;
52
+ for(const event of ["frameupdate","play","pause","seeked","ended","mutechange"])player.addEventListener(event,update);
53
+ };
54
+ attach();
55
+ const poll=setInterval(attach,150);
56
+ return()=>{disposed=true;clearInterval(poll);if(player)for(const event of ["frameupdate","play","pause","seeked","ended","mutechange"])player.removeEventListener(event,update);stop();};
57
+ },[enabled,cues,root?.fps,playerRef]);
58
+ const active=cues.find(c=>time>=c.start&&time<c.end);
59
+ return {caption:enabled?active?.text||"":""};
60
+ }
@@ -0,0 +1,52 @@
1
+ import {spawn} from "node:child_process";
2
+ import {randomUUID,createHash} from "node:crypto";
3
+ import {mkdirSync,writeFileSync,renameSync,statSync,readFileSync,readdirSync,rmSync} from "node:fs";
4
+ import {join} from "node:path";
5
+ import {fileURLToPath} from "node:url";
6
+ import {EXPORT_ROOT,publicOrigin,resolvePreviewPath} from "./remote-assets.mjs";
7
+ import {validateMarkdownPath} from "./preview.mjs";
8
+ const folder=join(EXPORT_ROOT,"operations");
9
+ const RETAIN_MS=24*60*60*1000;
10
+ function cleanupOldOperations(){
11
+ try{
12
+ const cutoff=Date.now()-RETAIN_MS;
13
+ for(const entry of readdirSync(folder,{withFileTypes:true})){
14
+ if(!entry.isFile()||!entry.name.endsWith(".json"))continue;
15
+ const file=join(folder,entry.name);
16
+ const info=statSync(file);
17
+ if(info.mtimeMs<cutoff)rmSync(file,{force:true});
18
+ }
19
+ }catch{}
20
+ }
21
+
22
+ export function writeOperation(id,data){
23
+ if(!/^[a-f0-9-]{36}$/.test(id))throw new Error("Invalid operation ID");
24
+ mkdirSync(folder,{recursive:true});
25
+ const temp=join(folder,id+"."+process.pid+".tmp");
26
+ writeFileSync(temp,JSON.stringify(data),{mode:0o644});
27
+ renameSync(temp,join(folder,id+".json"));
28
+ }
29
+ export function startCompileOperation(path,relay){
30
+ const valid=validateMarkdownPath(resolvePreviewPath(path));
31
+ if(!statSync(valid).isFile())throw new Error("Preview source must be a Markdown file");
32
+ const sourceHash=createHash("sha256").update(readFileSync(valid)).digest("hex");
33
+ cleanupOldOperations();
34
+ const id=randomUUID(),startedAt=Date.now();
35
+ writeOperation(id,{id,state:"compiling",stage:"queued",startedAt,requestedPath:path});
36
+ const worker=fileURLToPath(new URL("./compile-worker.mjs",import.meta.url));
37
+ const child=spawn(process.execPath,[worker,id,valid,path,JSON.stringify(relay||null),sourceHash],{detached:true,stdio:"ignore"});
38
+ child.on("error",e=>{
39
+ writeOperation(id,{id,state:"failed",stage:"failed",startedAt,requestedPath:path,error:e.message});
40
+ });
41
+ child.on("exit",(code,signal)=>{
42
+ try{
43
+ const state=JSON.parse(readFileSync(join(folder,id+".json"),"utf8"));
44
+ if(state.state==="compiling"){
45
+ writeOperation(id,{id,state:"failed",stage:"failed",startedAt,requestedPath:path,
46
+ error:"Compiler exited unexpectedly: "+(signal||code)});
47
+ }
48
+ }catch{}
49
+ });
50
+ child.unref();
51
+ return {operationId:id,operationUrl:publicOrigin()+"/files/_neoy/markcut/operations/"+id+".json",path:valid,requestedPath:path,state:"compiling"};
52
+ }
@@ -0,0 +1,20 @@
1
+ import {createHash} from "node:crypto";
2
+ import {readFileSync} from "node:fs";
3
+ import {ensureVideoPreview} from "./preview.mjs";
4
+ import {publishPreviewRevision} from "./remote-assets.mjs";
5
+ import {writeOperation} from "./compile-operation.mjs";
6
+ const [id,path,requestedPath,relayText,sourceHash]=process.argv.slice(2);
7
+ const startedAt=Date.now();
8
+ function update(stage){writeOperation(id,{id,state:"compiling",stage,startedAt,updatedAt:Date.now(),requestedPath});}
9
+ try{
10
+ update("resolving-and-compiling");
11
+ const actualHash=createHash("sha256").update(readFileSync(path)).digest("hex");
12
+ if(actualHash!==sourceHash)throw new Error("Source changed while queued; retry preview");
13
+ const result=await ensureVideoPreview(path,{onProgress:update});
14
+ update("publishing");
15
+ publishPreviewRevision(requestedPath,{...result,requestedPath});
16
+ writeOperation(id,{id,state:"ready",stage:"complete",startedAt,updatedAt:Date.now(),requestedPath,result:{...result,requestedPath,relay:JSON.parse(relayText)}});
17
+ }catch(e){
18
+ writeOperation(id,{id,state:"failed",stage:"failed",startedAt,updatedAt:Date.now(),requestedPath,error:e?.message||String(e)});
19
+ process.exitCode=1;
20
+ }
@@ -0,0 +1,52 @@
1
+ import {mkdtemp,writeFile,stat,rename,rm,mkdir} from "node:fs/promises";
2
+ import {tmpdir} from "node:os";
3
+ import {join,resolve,dirname} from "node:path";
4
+ import {execFile} from "node:child_process";
5
+ import {promisify} from "node:util";
6
+ import {fileURLToPath} from "node:url";
7
+ const run=promisify(execFile);
8
+ const projectRoot=resolve(dirname(fileURLToPath(import.meta.url)),"../..");
9
+ export function findMapScenes(root){
10
+ return (root?.children||[]).filter(x=>x?.type==="scene"&&x.visible!==false&&
11
+ Array.isArray(x.children)&&x.children.some(child=>child.type==="map"));
12
+ }
13
+ export async function renderMapFootage(compiled,{generatedDir,key,onProgress=()=>{}}){
14
+ const root=compiled.root||compiled;
15
+ const scenes=findMapScenes(root);
16
+ if(!scenes.length)return {compiled,rendered:0,skipped:0};
17
+ if(!key)return {compiled,rendered:0,skipped:scenes.length};
18
+ const output=structuredClone(compiled);
19
+ const outputRoot=output.root||output;
20
+ let rendered=0,skipped=0;
21
+ await mkdir(generatedDir,{recursive:true});
22
+ for(const [index,scene] of findMapScenes(outputRoot).entries()){
23
+ // Render a complete scene so map overlays are retained. The fallback tree
24
+ // remains unchanged until the video has been successfully produced.
25
+ const duration=Number(scene.durationInSeconds)||0;
26
+ if(duration<=0){skipped++;continue;}
27
+ const name="map-footage-"+index+".mp4";
28
+ const destination=join(generatedDir,name);
29
+ const temporary=await mkdtemp(join(tmpdir(),"markcut-map-render-"));
30
+ try{
31
+ const source=structuredClone(scene);
32
+ const isolated={...structuredClone(outputRoot),isSeries:true,children:[source],durationInSeconds:duration};
33
+ delete isolated.subtitle; // Global captions belong to the final video, not map footage.
34
+ const file=join(temporary,"props.json");
35
+ await writeFile(file,JSON.stringify({root:isolated}),{mode:0o600});
36
+ onProgress("rendering-map-footage-"+(index+1)+"-of-"+scenes.length);
37
+ await run(resolve(projectRoot,"node_modules/.bin/remotion"),[
38
+ "render",resolve(projectRoot,"src/index.ts"),"Root",join(temporary,name),
39
+ "--props",file,"--codec","h264","--concurrency","1"
40
+ ],{cwd:projectRoot,timeout:180000,maxBuffer:1024*1024*4,env:process.env});
41
+ if((await stat(join(temporary,name))).size<1024)throw Error("Empty map footage");
42
+ await rename(join(temporary,name),destination);
43
+ scene.children=[{id:"map-footage-"+index,type:"video",visible:true,start:0,end:duration,
44
+ durationInSeconds:duration,src:destination,volume:0}];
45
+ rendered++;
46
+ }catch(error){
47
+ skipped++;
48
+ onProgress("map-footage-fallback-"+(index+1));
49
+ }finally{await rm(temporary,{recursive:true,force:true});}
50
+ }
51
+ return {compiled:output,rendered,skipped};
52
+ }