@lalalic/markcut 3.2.14 → 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/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
package/src/mcp/preview.mjs
CHANGED
|
@@ -1,39 +1,83 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile, readdir, rename, rm, stat, utimes } from "node:fs/promises";
|
|
3
|
+
import { access, readFile } from "node:fs/promises";
|
|
4
|
+
import { constants as fsConstants, readFileSync } from "node:fs";
|
|
5
|
+
import { isAbsolute, extname, dirname, resolve, basename } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { buildPreviewBridgeScript } from "./bridge.mjs";
|
|
8
|
+
import { externalizePreviewMedia, publicOrigin, resolveMountedFile, resolvePreviewPath, publishPlayerBundle, publishCompiledCache, prunePublishedCache, EXPORT_ROOT } from "./remote-assets.mjs";
|
|
7
9
|
|
|
8
10
|
export const TOOL_NAME = "markcut.preview";
|
|
9
|
-
export const
|
|
10
|
-
|
|
11
|
+
export const RESOURCE_URI = "ui://markcut/video-preview-v24.html";
|
|
12
|
+
// Legacy entrypoints remain registered, but serve the current implementation.
|
|
13
|
+
export const LEGACY_RESOURCE_URIS = [
|
|
14
|
+
"ui://markcut/video-preview-v3.html",
|
|
15
|
+
"ui://markcut/video-preview-v4.html",
|
|
16
|
+
"ui://markcut/video-preview-v5.html",
|
|
17
|
+
"ui://markcut/video-preview-v6.html",
|
|
18
|
+
"ui://markcut/video-preview-v7.html",
|
|
19
|
+
"ui://markcut/video-preview-v8.html",
|
|
20
|
+
"ui://markcut/video-preview-v9.html",
|
|
21
|
+
"ui://markcut/video-preview-v10.html",
|
|
22
|
+
"ui://markcut/video-preview-v11.html",
|
|
23
|
+
"ui://markcut/video-preview-v12.html",
|
|
24
|
+
"ui://markcut/video-preview-v13.html",
|
|
25
|
+
"ui://markcut/video-preview-v14.html",
|
|
26
|
+
"ui://markcut/video-preview-v15.html",
|
|
27
|
+
"ui://markcut/video-preview-v16.html",
|
|
28
|
+
"ui://markcut/video-preview-v17.html",
|
|
29
|
+
"ui://markcut/video-preview-v18.html",
|
|
30
|
+
"ui://markcut/video-preview-v19.html",
|
|
31
|
+
"ui://markcut/video-preview-v20.html",
|
|
32
|
+
"ui://markcut/video-preview-v21.html",
|
|
33
|
+
"ui://markcut/video-preview-v22.html",
|
|
34
|
+
"ui://markcut/video-preview-v23.html",
|
|
35
|
+
];
|
|
11
36
|
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
37
|
+
// Google Maps JS loads libraries, tiles and Routes RPCs from these HTTPS origins.
|
|
38
|
+
// The MCP app sandbox enforces the declared domains; native localhost does not.
|
|
39
|
+
export const MAPS_CSP_DOMAINS = Object.freeze([
|
|
40
|
+
"https://maps.googleapis.com",
|
|
41
|
+
"https://maps.gstatic.com",
|
|
42
|
+
"https://maps-api-v3.googleapis.com",
|
|
43
|
+
"https://routes.googleapis.com",
|
|
44
|
+
]);
|
|
45
|
+
export function previewCsp() {
|
|
46
|
+
return {
|
|
47
|
+
connectDomains: [publicOrigin(), ...MAPS_CSP_DOMAINS],
|
|
48
|
+
resourceDomains: [publicOrigin(), ...MAPS_CSP_DOMAINS],
|
|
49
|
+
frameDomains: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
15
52
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
53
|
+
const bundlePath = resolve(moduleDir,"remotion-app.bundle.js");
|
|
54
|
+
const pendingCompiles = new Map();
|
|
55
|
+
const activeCompilesByPath = new Map();
|
|
56
|
+
const KEEP = 10;
|
|
20
57
|
|
|
21
|
-
export function
|
|
58
|
+
export function getPreviewMedia(contentHash) {
|
|
59
|
+
if (!/^[a-f0-9]{64}$/.test(contentHash)) throw new Error("Invalid Markcut content hash");
|
|
60
|
+
try {
|
|
61
|
+
return {compiled:JSON.parse(readFileSync(resolve(EXPORT_ROOT,"cache",contentHash+".json"),"utf8"))};
|
|
62
|
+
} catch {throw new Error("Unknown or expired Markcut compiled scenes");}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function previewResourceUri(contentHash) {
|
|
66
|
+
return `${RESOURCE_URI}/${contentHash}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildPreviewResourceResult(resourceUri = RESOURCE_URI) {
|
|
22
70
|
return {
|
|
23
71
|
ttlMs: 0,
|
|
24
72
|
cacheScope: "private",
|
|
25
73
|
contents: [{
|
|
26
|
-
uri:
|
|
74
|
+
uri: resourceUri,
|
|
27
75
|
mimeType: RESOURCE_MIME_TYPE,
|
|
28
76
|
text: buildPreviewHtml(),
|
|
29
77
|
_meta: {
|
|
30
78
|
ui: {
|
|
31
79
|
prefersBorder: true,
|
|
32
|
-
csp:
|
|
33
|
-
connectDomains: [PLAYER_ORIGIN],
|
|
34
|
-
resourceDomains: [],
|
|
35
|
-
frameDomains: [PLAYER_ORIGIN],
|
|
36
|
-
},
|
|
80
|
+
csp: previewCsp(),
|
|
37
81
|
},
|
|
38
82
|
},
|
|
39
83
|
}],
|
|
@@ -50,60 +94,164 @@ export function validateMarkdownPath(value) {
|
|
|
50
94
|
return value;
|
|
51
95
|
}
|
|
52
96
|
|
|
53
|
-
export async function
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
97
|
+
export async function readPreviewAudio(path) {
|
|
98
|
+
const markdown = await readFile(path, "utf8");
|
|
99
|
+
const match = markdown.match(/^audio:\s*([^\r\n]+)\s*$/m);
|
|
100
|
+
const filename = match ? match[1].trim() : `${basename(path, ".md")}.wav`;
|
|
101
|
+
if (!/^[\w. -]+\.(wav|mp3|m4a|ogg)$/i.test(filename) || filename.startsWith(".")) throw new Error("audio must be a local audio filename in the Markdown directory");
|
|
102
|
+
const file = resolve(dirname(path), filename);
|
|
103
|
+
try { await access(file, fsConstants.R_OK); } catch (e) { if (!match && e.code === "ENOENT") return {}; throw e; }
|
|
104
|
+
const remote = resolveMountedFile(file);
|
|
105
|
+
if (remote) return {audioSrc: file}; // Freeze mounted local audio into this immutable preview.
|
|
106
|
+
const data = await readFile(file);
|
|
107
|
+
if (data.length > 2 * 1024 * 1024) throw new Error("audio file exceeds 2 MB preview limit for unmounted media");
|
|
108
|
+
const ext = filename.split(".").pop().toLowerCase();
|
|
109
|
+
const mime = {wav:"audio/wav",mp3:"audio/mpeg",m4a:"audio/mp4",ogg:"audio/ogg"}[ext];
|
|
110
|
+
return {audioSrc:`data:${mime};base64,${data.toString("base64")}`};
|
|
111
|
+
}
|
|
60
112
|
|
|
61
|
-
|
|
62
|
-
|
|
113
|
+
export function withPreviewAudio(compiled, audioSrc) {
|
|
114
|
+
if (!audioSrc) return compiled;
|
|
115
|
+
const root = compiled.root || compiled;
|
|
116
|
+
const children = root.children || [];
|
|
117
|
+
const duration = root.durationInSeconds || children.filter(child=>!child.isBackground).reduce((sum,child)=>sum+(child.durationInSeconds||child.duration||0),0);
|
|
118
|
+
if (!duration) throw new Error('compiled preview has no duration for audio track');
|
|
119
|
+
return {...compiled, ...(compiled.root ? {root:{...root,children:[...children,{id:'mcp-preview-audio',type:'audio',src:audioSrc,volume:1,isBackground:true,start:0,end:duration,duration,durationInSeconds:duration}]}} : {children:[...children,{id:'mcp-preview-audio',type:'audio',src:audioSrc,volume:1,isBackground:true,start:0,end:duration,duration,durationInSeconds:duration}]})};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function atomicJson(path, value) {
|
|
123
|
+
const temp = path + "." + process.pid + "." + Math.random().toString(16).slice(2) + ".tmp";
|
|
124
|
+
await writeFile(temp, JSON.stringify(value, null, 2), {flag:"wx"});
|
|
125
|
+
try { await rename(temp, path); }
|
|
126
|
+
finally { await rm(temp, {force:true}); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function pruneCache(dir, indexFile, entries, currentHash) {
|
|
130
|
+
const unique=[currentHash,...entries.filter(hash=>hash!==currentHash)];
|
|
131
|
+
const kept=unique.slice(0,KEEP);
|
|
132
|
+
const retired=unique.slice(KEEP);
|
|
133
|
+
const index={latest:currentHash, entries:kept};
|
|
134
|
+
await atomicJson(indexFile,index);
|
|
135
|
+
// Only prune local entries that are not used by another variant index.
|
|
136
|
+
for(const hash of retired) {
|
|
137
|
+
const siblings=await readdir(resolve(dir,".."),{withFileTypes:true});
|
|
138
|
+
let referenced=false;
|
|
139
|
+
for(const folder of siblings) {
|
|
140
|
+
if(!folder.isDirectory() || folder.name==="cache" || folder.name==="generated")continue;
|
|
141
|
+
const variants=await readdir(resolve(dir,"..",folder.name),{withFileTypes:true}).catch(()=>[]);
|
|
142
|
+
for(const variant of variants) {
|
|
143
|
+
if(!variant.isDirectory())continue;
|
|
144
|
+
const data=await readFile(resolve(dir,"..",folder.name,variant.name,"cache-index.json"),"utf8").catch(()=>"");
|
|
145
|
+
try {if(JSON.parse(data).entries?.includes(hash))referenced=true;} catch{}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if(!referenced){
|
|
149
|
+
await rm(resolve(dir,hash+".json"),{force:true});
|
|
150
|
+
await prunePublishedCache(hash);
|
|
151
|
+
}
|
|
63
152
|
}
|
|
64
|
-
|
|
65
|
-
managedPath = validatedPath;
|
|
153
|
+
}
|
|
66
154
|
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
155
|
+
export async function ensureVideoPreview(path, options = {}) {
|
|
156
|
+
const validatedPath = validateMarkdownPath(resolvePreviewPath(path));
|
|
157
|
+
await access(validatedPath, fsConstants.R_OK);
|
|
158
|
+
const source=await readFile(validatedPath,"utf8");
|
|
159
|
+
const sourceHash=createHash("sha256").update(source).digest("hex");
|
|
160
|
+
const key=validatedPath+"\0"+sourceHash;
|
|
161
|
+
if(pendingCompiles.has(key))return pendingCompiles.get(key);
|
|
162
|
+
const prior=activeCompilesByPath.get(validatedPath)||Promise.resolve();
|
|
163
|
+
const task=prior.catch(()=>{}).then(async()=>{
|
|
164
|
+
const {parseMarkdownDescriptive,resolveAll,compileDescriptiveRoot}=await import("../player/pipeline.mjs");
|
|
165
|
+
const report=typeof options.onProgress==="function" ? options.onProgress : ()=>{};
|
|
166
|
+
report("checking-cache");
|
|
167
|
+
const baseDir=dirname(validatedPath);
|
|
168
|
+
const outputRoot=resolve(baseDir,".markcut");
|
|
169
|
+
const generated=resolve(outputRoot,"generated");
|
|
170
|
+
const dir=resolve(outputRoot,basename(validatedPath,".md"),"default");
|
|
171
|
+
const cacheDir=resolve(outputRoot,"cache");
|
|
172
|
+
const indexFile=resolve(dir,"cache-index.json");
|
|
173
|
+
const audio=await readPreviewAudio(validatedPath);
|
|
174
|
+
const referencedAssets=[];
|
|
175
|
+
for(const match of source.matchAll(/\b(?:src|audio)\s*:\s*["']?([^"'\s;]+\.(?:png|jpe?g|webp|svg|gif|mp4|mov|webm|wav|mp3|m4a|ogg|vtt|srt))/gi)){
|
|
176
|
+
const candidate=match[1];
|
|
177
|
+
if(candidate.includes("://")||candidate.startsWith("/"))continue;
|
|
178
|
+
try {
|
|
179
|
+
const info=await stat(resolve(baseDir,candidate));
|
|
180
|
+
if(info.isFile())referencedAssets.push([candidate,info.size,info.mtimeMs]);
|
|
181
|
+
}catch{}
|
|
182
|
+
}
|
|
183
|
+
referencedAssets.sort((a,b)=>String(a[0]).localeCompare(String(b[0])));
|
|
184
|
+
// Compilation generates random scene ids. Identity must be based on inputs,
|
|
185
|
+
// not on non-semantic runtime ids, so unchanged files reuse identical cache.
|
|
186
|
+
if(audio.audioSrc && audio.audioSrc.startsWith("/")){
|
|
187
|
+
const info=await stat(audio.audioSrc);
|
|
188
|
+
referencedAssets.push([audio.audioSrc,info.size,info.mtimeMs]);
|
|
189
|
+
}
|
|
190
|
+
const hash=createHash("sha256").update(validatedPath).update("\0")
|
|
191
|
+
.update(source).update("\0").update(JSON.stringify(referencedAssets)).update("\0").update(audio.audioSrc||"")
|
|
192
|
+
.update("\0").update(process.env.GOOGLE_MAPS_API_KEY||"")
|
|
193
|
+
.update("\0").update("map-footage-v2").digest("hex");
|
|
194
|
+
const id=hash;
|
|
195
|
+
let index={entries:[]};
|
|
196
|
+
try {index=JSON.parse(await readFile(indexFile,"utf8"));} catch{}
|
|
197
|
+
const cached=resolve(cacheDir,hash+".json");
|
|
198
|
+
let finalCompiled;
|
|
199
|
+
try {finalCompiled=JSON.parse(await readFile(cached,"utf8"));} catch{}
|
|
200
|
+
if(!finalCompiled) {
|
|
201
|
+
report("parsing-markdown");
|
|
202
|
+
const descriptive=parseMarkdownDescriptive(source);
|
|
203
|
+
report("resolving-assets-and-generating-media");
|
|
204
|
+
const resolved=await resolveAll(descriptive,{
|
|
205
|
+
sourcePath:validatedPath,baseDir,
|
|
206
|
+
seed:parseInt(sourceHash.slice(0,8),16), // avoid rewriting original Markdown
|
|
207
|
+
scriptOutputDir:resolve(generated,"tts"),
|
|
208
|
+
mediaOutputDir:resolve(generated,"media"),
|
|
209
|
+
includeOutputDir:resolve(generated,"includes"),
|
|
73
210
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
rejectPromise(error);
|
|
84
|
-
} else {
|
|
85
|
-
resolvePromise({ path: validatedPath, previewUrl: `${PLAYER_ORIGIN}/` });
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
89
|
-
child.once("error", (error) => finish(new Error(`unable to start Markcut player: ${error.message}`)));
|
|
90
|
-
child.once("exit", (code, signal) => {
|
|
91
|
-
if (managedPlayer === child) managedPlayer = null;
|
|
92
|
-
if (!settled) finish(new Error(`Markcut player exited during startup (${signal || code || "unknown"})${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
211
|
+
report("compiling-scenes");
|
|
212
|
+
const compiled=compileDescriptiveRoot(resolved,{googleMapsApiKey:process.env.GOOGLE_MAPS_API_KEY});
|
|
213
|
+
const withAudio=withPreviewAudio(compiled,audio.audioSrc);
|
|
214
|
+
// Keep the live Google Maps stream when prerendering is unavailable.
|
|
215
|
+
const {renderMapFootage}=await import("./map-footage.mjs");
|
|
216
|
+
const footage=await renderMapFootage(withAudio,{
|
|
217
|
+
generatedDir:resolve(generated,"map-footage",id),
|
|
218
|
+
key:process.env.GOOGLE_MAPS_API_KEY,
|
|
219
|
+
onProgress:report,
|
|
93
220
|
});
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
221
|
+
report("freezing-preview-assets");
|
|
222
|
+
const external=externalizePreviewMedia(footage.compiled,id,publicOrigin(),baseDir);
|
|
223
|
+
finalCompiled=external.compiled;
|
|
224
|
+
await mkdir(cacheDir,{recursive:true});
|
|
225
|
+
try {await writeFile(cached,JSON.stringify(finalCompiled,null,2),{flag:"wx"});}
|
|
226
|
+
catch(error){if(error.code!=="EEXIST")throw error;}
|
|
227
|
+
}
|
|
228
|
+
const currentSource=await readFile(validatedPath,"utf8");
|
|
229
|
+
if(createHash("sha256").update(currentSource).digest("hex")!==sourceHash)
|
|
230
|
+
throw new Error("Source changed during compilation; retry preview");
|
|
231
|
+
await mkdir(dir,{recursive:true});
|
|
232
|
+
report("publishing-static-preview");
|
|
233
|
+
// Static publication is completed before updating the latest pointer.
|
|
234
|
+
const compiledUrl=publishCompiledCache(hash, finalCompiled);
|
|
235
|
+
await atomicJson(resolve(dir,"compiled.json"),finalCompiled);
|
|
236
|
+
await utimes(cached,new Date(),new Date());
|
|
237
|
+
await pruneCache(cacheDir,indexFile,index.entries||[],hash);
|
|
238
|
+
const preview={
|
|
239
|
+
id,revisionId:id,contentHash:hash,sourceHash,path:validatedPath,
|
|
240
|
+
compiledUrl, resourceUri:RESOURCE_URI,
|
|
241
|
+
assetBaseUrl:publicOrigin()+"/files/_neoy/markcut/generated/"+id+"/",
|
|
242
|
+
};
|
|
243
|
+
return preview;
|
|
97
244
|
});
|
|
98
|
-
|
|
245
|
+
activeCompilesByPath.set(validatedPath,task);
|
|
246
|
+
pendingCompiles.set(key,task);
|
|
247
|
+
try{return await task;} finally{
|
|
248
|
+
pendingCompiles.delete(key);
|
|
249
|
+
if(activeCompilesByPath.get(validatedPath)===task)activeCompilesByPath.delete(validatedPath);
|
|
250
|
+
}
|
|
99
251
|
}
|
|
100
252
|
|
|
101
|
-
export
|
|
102
|
-
|
|
103
|
-
managedPlayer = null;
|
|
104
|
-
managedPath = null;
|
|
105
|
-
startPromise = null;
|
|
106
|
-
}
|
|
253
|
+
// Compatibility export: there is no longer a managed player to stop.
|
|
254
|
+
export function stopManagedVideoPreview() {}
|
|
107
255
|
|
|
108
256
|
export function reviewResult(decision, feedback = "") {
|
|
109
257
|
if (decision === "approved") return { decision };
|
|
@@ -125,11 +273,15 @@ export function reviewMessage(result) {
|
|
|
125
273
|
}
|
|
126
274
|
|
|
127
275
|
export function buildPreviewHtml() {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
<
|
|
133
|
-
<button
|
|
134
|
-
<
|
|
276
|
+
const playerScriptUrl = publishPlayerBundle(bundlePath);
|
|
277
|
+
// Base64 keeps HTML parser-sensitive sequences inside the large JS bundle
|
|
278
|
+
// from terminating the embedding script tag or leaking source into the DOM.
|
|
279
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
280
|
+
<title>Markcut</title>
|
|
281
|
+
<style>body{font:14px system-ui;margin:0;background:#fff;color:#17202a}main{padding:12px;max-width:960px;margin:auto}h1{font-size:17px}.path{font-size:12px;overflow-wrap:anywhere}.transport{display:flex;align-items:center;gap:10px;padding:12px 0}.transport input{flex:1;min-width:50px}button{cursor:pointer;padding:8px;border-radius:6px;border:1px solid #a8a8a8}#approve{background:#198038;color:white}#changes{background:#c72b29;color:white}textarea{width:100%;box-sizing:border-box;min-height:66px}.review-actions{display:flex;justify-content:space-between;align-items:center;width:100%;gap:16px}.review-actions button{min-width:150px}#remotion-canvas{background:#090b12}#status{padding:8px 0}#status:empty{display:none}#scene-thumbnails{display:flex;gap:7px;overflow-x:auto;padding:12px 2px;scrollbar-width:thin} .scene-pill{flex-shrink:0;padding:7px 11px;border:1px solid #d4dbe4;border-radius:9px;cursor:pointer;background:#f5f7fa;color:#475569;font-size:12px;white-space:nowrap} .scene-pill.active{background:#e7f0ff;color:#125ea2;border-color:#408bd7}</style></head>
|
|
282
|
+
<body><main><header style="display:flex;justify-content:space-between;align-items:center"><h1>Markcut · <span id="video-md-version">video</span></h1><div style="display:flex;align-items:center;gap:14px;margin-left:auto"><span id="header-tts"></span><span id="player-version" style="font-size:12px;color:#667">v24</span></div></header><div class="path" id="path-label" hidden></div><div id="interactive-ui" hidden><div id="remotion-root"></div>
|
|
283
|
+
<textarea id="feedback" aria-label="Feedback" placeholder="Feedback on this scene…"></textarea><div id="status" role="status">Starting preview…</div>
|
|
284
|
+
<div class="review-actions"><button id="changes">Send feedback</button><button id="approve">Approve</button></div></div></main>
|
|
285
|
+
<script src="${playerScriptUrl}"></script>
|
|
286
|
+
<script>${buildPreviewBridgeScript().replaceAll("__MARKCUT_ORIGIN__",JSON.stringify(publicOrigin()))}</script></body></html>`;
|
|
135
287
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {readFileSync,mkdirSync,writeFileSync,existsSync,realpathSync,renameSync,copyFileSync,statSync} from "node:fs";
|
|
2
|
+
import {homedir} from "node:os";
|
|
3
|
+
import {join,resolve,sep,isAbsolute,extname} from "node:path";
|
|
4
|
+
import {fileURLToPath} from "node:url";
|
|
5
|
+
import {createHash} from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const supportDir = join(homedir(),"Library","Application Support","NeoY");
|
|
8
|
+
export const EXPORT_ROOT = process.env.MARKCUT_EXPORT_ROOT || join(supportDir,"exports","_neoy","markcut");
|
|
9
|
+
const DATA_URI = /data:(image|audio|video)\/[a-z0-9.+-]+(?:;base64)?,[a-z0-9%+\/=._~!$&()*,:;@?-]+/gi;
|
|
10
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export function publicOrigin() {
|
|
13
|
+
if(process.env.NEOY_PUBLIC_ORIGIN) {
|
|
14
|
+
const url=new URL(process.env.NEOY_PUBLIC_ORIGIN);
|
|
15
|
+
if(url.protocol!=="https:")throw new Error("NeoY public origin requires HTTPS");
|
|
16
|
+
return url.origin;
|
|
17
|
+
}
|
|
18
|
+
const config=JSON.parse(readFileSync(join(supportDir,"deployment.json"),"utf8"));
|
|
19
|
+
if(config.tunnelMode==="named" && config.publicHostname) return "https://"+config.publicHostname;
|
|
20
|
+
const quick=join(supportDir,"public-url");
|
|
21
|
+
if(existsSync(quick)){
|
|
22
|
+
const url=new URL(readFileSync(quick,"utf8").trim());
|
|
23
|
+
if(url.protocol==="https:")return url.origin;
|
|
24
|
+
}
|
|
25
|
+
throw new Error("No public HTTPS URL configured for NeoY");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveMountedFile(path,origin=null,mountedRoots=null) {
|
|
29
|
+
let mounts=mountedRoots||{};
|
|
30
|
+
if(!mountedRoots)try{mounts=JSON.parse(readFileSync(join(supportDir,"file-mounts.json"),"utf8"));}catch{}
|
|
31
|
+
const absolute=resolve(path);
|
|
32
|
+
for(const [name,base] of Object.entries(mounts)){
|
|
33
|
+
if(!/^[a-z0-9_-]{1,64}$/i.test(name)||typeof base!=="string")continue;
|
|
34
|
+
const root=resolve(base);
|
|
35
|
+
if(!absolute.startsWith(root+sep))continue;
|
|
36
|
+
const relative=absolute.slice(root.length+1).split(sep);
|
|
37
|
+
if(relative.some(part=>part.startsWith(".")||part===".."||part===""))continue;
|
|
38
|
+
return (origin||publicOrigin())+"/files/"+encodeURIComponent(name)+"/"+relative.map(encodeURIComponent).join("/");
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolvePreviewPath(input, origin=publicOrigin(), mountedRoots=null) {
|
|
44
|
+
if(typeof input!=="string")throw new Error("Preview path must be a string");
|
|
45
|
+
if(isAbsolute(input) && !input.startsWith("/files/"))return input;
|
|
46
|
+
const url=new URL(input.startsWith("/files/") ? origin+input : input);
|
|
47
|
+
if(url.origin!==origin || url.search || url.hash || !url.pathname.startsWith("/files/"))
|
|
48
|
+
throw new Error("Only NeoY file URLs under /files/<mount>/ are accepted");
|
|
49
|
+
const parts=url.pathname.slice("/files/".length).split("/").map(decodeURIComponent);
|
|
50
|
+
const [name,...relative]=parts;
|
|
51
|
+
if(!name || name==="_neoy" || !/^[a-z0-9_-]{1,64}$/i.test(name) || !relative.length ||
|
|
52
|
+
relative.some(part=>!part || part==="." || part===".." || part.startsWith(".") || part.includes("/")))
|
|
53
|
+
throw new Error("Invalid mounted preview path");
|
|
54
|
+
let roots=mountedRoots||{};
|
|
55
|
+
if(!mountedRoots)try{roots=JSON.parse(readFileSync(join(supportDir,"file-mounts.json"),"utf8"));}catch{}
|
|
56
|
+
const base=roots[name];
|
|
57
|
+
if(typeof base!=="string" || !base.startsWith("/"))throw new Error("Unknown NeoY file mount: "+name);
|
|
58
|
+
const root=realpathSync(base);
|
|
59
|
+
const file=realpathSync(resolve(root,...relative));
|
|
60
|
+
if(!file.startsWith(root+sep))throw new Error("Preview path escapes NeoY mounted root");
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function publishPlayerBundle(bundlePath, origin=publicOrigin()) {
|
|
65
|
+
const bytes=readFileSync(bundlePath);
|
|
66
|
+
const hash=createHash("sha256").update(bytes).digest("hex").slice(0,20);
|
|
67
|
+
const directory=join(EXPORT_ROOT,"player");
|
|
68
|
+
mkdirSync(directory,{recursive:true});
|
|
69
|
+
const filename=hash+".js";
|
|
70
|
+
const target=join(directory,filename);
|
|
71
|
+
if(!existsSync(target))writeFileSync(target,bytes,{flag:"wx",mode:0o644});
|
|
72
|
+
return origin+"/files/_neoy/markcut/player/"+filename;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolvePreviewSource(src,sourceDir,origin=publicOrigin(),mounts=null) {
|
|
76
|
+
if(src.startsWith("data:"))return src;
|
|
77
|
+
if(src.startsWith("https://"))return src;
|
|
78
|
+
if(src.startsWith("http://"))throw new Error("Preview media URL must be HTTPS");
|
|
79
|
+
if(src.startsWith("blob:"))throw new Error("Blob URLs cannot be shared with MCP App");
|
|
80
|
+
if(!sourceDir && !src.startsWith("file://") && !isAbsolute(src))return src;
|
|
81
|
+
const path=src.startsWith("file://")?fileURLToPath(src):isAbsolute(src)?src:resolve(sourceDir,src);
|
|
82
|
+
const mapped=resolveMountedFile(path,origin,mounts);
|
|
83
|
+
if(!mapped)throw new Error("Preview media file is not under a NeoY mounted root: "+path);
|
|
84
|
+
return mapped;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function externalizePreviewMedia(compiled,revisionId,origin=publicOrigin(),sourceDir=null) {
|
|
88
|
+
if(!/^(?:[a-f0-9-]{36}|[a-f0-9]{64})$/i.test(revisionId))throw new Error("Invalid revision identity");
|
|
89
|
+
const directory=join(EXPORT_ROOT,"generated",revisionId);
|
|
90
|
+
const assets=[];
|
|
91
|
+
let bytes=0;
|
|
92
|
+
const frozenPaths=new Map();
|
|
93
|
+
function snapshotSource(src){
|
|
94
|
+
if(!sourceDir||src.startsWith("https://")||src.startsWith("data:"))return src;
|
|
95
|
+
if(src.startsWith("http://")||src.startsWith("blob:"))
|
|
96
|
+
throw new Error("Preview media must use HTTPS or local source files");
|
|
97
|
+
const full=src.startsWith("file://")?fileURLToPath(src):isAbsolute(src)?src:resolve(sourceDir,src);
|
|
98
|
+
const root=realpathSync(sourceDir);
|
|
99
|
+
let real;
|
|
100
|
+
try {real=realpathSync(full);} catch {return resolvePreviewSource(src,sourceDir,origin);}
|
|
101
|
+
if(!real.startsWith(root+sep))return resolvePreviewSource(src,sourceDir,origin);
|
|
102
|
+
if(frozenPaths.has(real))return frozenPaths.get(real);
|
|
103
|
+
const stats=statSync(real);
|
|
104
|
+
if(!stats.isFile()||stats.size>512*1024*1024)throw new Error("Preview local media must be a file of at most 512 MB");
|
|
105
|
+
const ext=extname(real).toLowerCase();
|
|
106
|
+
if(!/^[.](png|jpg|jpeg|webp|svg|gif|mp4|mov|webm|wav|mp3|m4a|ogg|vtt|srt)$/.test(ext))
|
|
107
|
+
throw new Error("Unsupported local preview media extension: "+ext);
|
|
108
|
+
mkdirSync(directory,{recursive:true});
|
|
109
|
+
const filename="file-"+frozenPaths.size+ext;
|
|
110
|
+
const target=join(directory,filename);
|
|
111
|
+
if(existsSync(target)){
|
|
112
|
+
const existing=statSync(target);
|
|
113
|
+
if(existing.size!==stats.size)throw new Error("Immutable preview media collision");
|
|
114
|
+
}else copyFileSync(real,target);
|
|
115
|
+
const url=origin+"/files/_neoy/markcut/generated/"+revisionId+"/"+filename;
|
|
116
|
+
frozenPaths.set(real,url);
|
|
117
|
+
assets.push(filename);
|
|
118
|
+
return url;
|
|
119
|
+
}
|
|
120
|
+
function replace(value){
|
|
121
|
+
if(typeof value==="string"){
|
|
122
|
+
if(/https?:\/\/(?:localhost|127\.0\.0\.1)(?=[:/]|$)/i.test(value))
|
|
123
|
+
throw new Error("Preview contains local media URL");
|
|
124
|
+
const converted = sourceDir ? value.replace(/(\bsrc\s*=\s*)(['"])([^'"\n]+)\2/g,
|
|
125
|
+
(all,prefix,quote,path)=>prefix+quote+snapshotSource(path)+quote) : value;
|
|
126
|
+
return converted.replace(DATA_URI,uri=>{
|
|
127
|
+
const comma=uri.indexOf(",");
|
|
128
|
+
const mime=uri.slice(5,comma).split(";")[0].toLowerCase();
|
|
129
|
+
const data=uri.slice(comma+1);
|
|
130
|
+
const buffer=uri.slice(0,comma).includes(";base64") ? Buffer.from(data,"base64") : Buffer.from(decodeURIComponent(data));
|
|
131
|
+
bytes+=buffer.length;
|
|
132
|
+
if(bytes>MAX_BYTES||assets.length>=64)throw new Error("Preview media asset limit exceeded");
|
|
133
|
+
const ext=({ "audio/wav":"wav","audio/mpeg":"mp3","audio/mp4":"m4a","audio/ogg":"ogg",
|
|
134
|
+
"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/svg+xml":"svg","video/mp4":"mp4" })[mime];
|
|
135
|
+
if(!ext)throw new Error("Unsupported media MIME type: "+mime);
|
|
136
|
+
mkdirSync(directory,{recursive:true});
|
|
137
|
+
const filename=assets.length+"."+ext;
|
|
138
|
+
const target=join(directory,filename);
|
|
139
|
+
if(existsSync(target)) {
|
|
140
|
+
if(!readFileSync(target).equals(buffer))throw new Error("Immutable preview asset collision");
|
|
141
|
+
}else writeFileSync(target,buffer,{flag:"wx",mode:0o644});
|
|
142
|
+
assets.push(filename);
|
|
143
|
+
return origin+"/files/_neoy/markcut/generated/"+revisionId+"/"+filename;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if(Array.isArray(value))return value.map(replace);
|
|
147
|
+
if(value&&typeof value==="object")return Object.fromEntries(Object.entries(value).map(([key,v])=>
|
|
148
|
+
[key,key==="src" && typeof v==="string" && !v.startsWith("data:")
|
|
149
|
+
? snapshotSource(v) : replace(v)]));
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
return {compiled:replace(compiled),assets};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fallback for hosts that deliver tool-input but omit tool-result notifications.
|
|
156
|
+
export function publishPreviewRevision(requestedPath,payload) {
|
|
157
|
+
const key=createHash("sha256").update(requestedPath).digest("hex");
|
|
158
|
+
const dir=join(EXPORT_ROOT,"revisions");
|
|
159
|
+
mkdirSync(dir,{recursive:true});
|
|
160
|
+
const target=join(dir,key+".json");
|
|
161
|
+
const temp=join(dir,key+"."+process.pid+".tmp");
|
|
162
|
+
writeFileSync(temp,JSON.stringify(payload),{mode:0o644});
|
|
163
|
+
renameSync(temp,target);
|
|
164
|
+
return publicOrigin()+"/files/_neoy/markcut/revisions/"+key+".json";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
export function publishCompiledCache(hash, compiled, origin=publicOrigin()) {
|
|
169
|
+
if(!/^[a-f0-9]{64}$/.test(hash))throw new Error("Invalid compiled hash");
|
|
170
|
+
const dir=join(EXPORT_ROOT,"cache");
|
|
171
|
+
mkdirSync(dir,{recursive:true});
|
|
172
|
+
const file=join(dir,hash+".json");
|
|
173
|
+
const bytes=JSON.stringify(compiled);
|
|
174
|
+
if(existsSync(file)){
|
|
175
|
+
if(readFileSync(file,"utf8")!==bytes)throw new Error("Immutable compiled cache collision");
|
|
176
|
+
}else writeFileSync(file,bytes,{flag:"wx",mode:0o644});
|
|
177
|
+
return origin+"/files/_neoy/markcut/cache/"+hash+".json";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function prunePublishedCache(hash) {
|
|
181
|
+
if(!/^[a-f0-9]{64}$/.test(hash))throw new Error("Invalid compiled hash");
|
|
182
|
+
const {rm}=await import("node:fs/promises");
|
|
183
|
+
await rm(join(EXPORT_ROOT,"cache",hash+".json"),{force:true});
|
|
184
|
+
await rm(join(EXPORT_ROOT,"generated",hash),{recursive:true,force:true});
|
|
185
|
+
}
|