@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.
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
@@ -1,165 +1,76 @@
1
- import { describe, expect, it } from "vitest";
2
- import { mkdtempSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
- import vm from "node:vm";
6
- import {
7
- buildPreviewHtml,
8
- buildPreviewResourceResult,
9
- PLAYER_ORIGIN,
10
- RESOURCE_MIME_TYPE,
11
- RESOURCE_URI,
12
- reviewMessage,
13
- reviewResult,
14
- validateMarkdownPath,
15
- } from "../src/mcp/preview.mjs";
16
- import { buildPreviewBridgeScript } from "../src/mcp/bridge.mjs";
17
-
18
- describe("MarkCut MCP Markdown preview", () => {
19
- it("requires an absolute .md path", () => {
20
- expect(() => validateMarkdownPath("notes.md")).toThrow(/absolute/);
21
- expect(() => validateMarkdownPath("/tmp/notes.txt")).toThrow(/Markdown/);
22
- });
23
-
24
- it("builds a video-player review UI instead of rendering Markdown", () => {
25
- const html = buildPreviewHtml();
26
- expect(html).toContain('id="video-player"');
27
- expect(html).toContain("Review video");
28
- expect(html).toContain("Approve");
29
- expect(html).toContain("Request changes");
30
- expect(html).toContain("ui/notifications/tool-result");
31
- expect(html).toContain("previewUrl");
32
- expect(html).not.toContain("<article>");
33
- });
34
-
35
- it("returns Codex-compatible private no-cache resource metadata", () => {
36
- const result = buildPreviewResourceResult();
37
- expect(result.ttlMs).toBe(0);
38
- expect(result.cacheScope).toBe("private");
39
- expect(result.contents).toHaveLength(1);
40
- expect(result.contents[0]).toMatchObject({
41
- uri: RESOURCE_URI,
42
- mimeType: RESOURCE_MIME_TYPE,
43
- });
44
- expect(result.contents[0].text).toContain("ui/initialize");
45
- expect(result.contents[0].text).toContain("ui/notifications/initialized");
46
- expect(result.contents[0]._meta.ui.csp.frameDomains).toEqual([PLAYER_ORIGIN]);
47
- });
48
-
49
- it("returns approved", () => {
50
- expect(reviewResult("approved")).toEqual({ decision: "approved" });
51
- });
52
-
53
- it("requires non-empty feedback for requested changes", () => {
54
- expect(() => reviewResult("changes_requested", " ")).toThrow(/feedback is required/);
55
- expect(reviewResult("changes_requested", " Fix the title ")).toEqual({
56
- decision: "changes_requested", feedback: "Fix the title",
57
- });
58
- });
59
-
60
- it("formats decisions as an MCP Apps ui/message notification", () => {
61
- expect(reviewMessage({ decision: "approved" })).toEqual({
62
- jsonrpc: "2.0",
63
- method: "ui/message",
64
- params: { role: "user", content: [{ type: "text", text: '{"decision":"approved"}' }] },
65
- });
66
- expect(reviewMessage({ decision: "changes_requested", feedback: "Fix the title" }).params.content[0].text)
67
- .toBe('{"decision":"changes_requested","feedback":"Fix the title"}');
68
- });
69
-
70
- it("performs the MCP Apps handshake and awaits a successful submit", async () => {
71
- const harness = createBridgeHarness();
72
- harness.run();
73
- expect(harness.messages[0]).toEqual({
74
- jsonrpc: "2.0",
75
- id: 1,
76
- method: "ui/initialize",
77
- params: {
78
- appInfo: { name: "Markcut Preview", version: "1.1.0" },
79
- appCapabilities: {},
80
- protocolVersion: "2026-01-26",
81
- },
82
- });
83
- expect(harness.messages.some((message) => message.method === "ui/notifications/initialized")).toBe(false);
84
- harness.respond({
85
- id: 1,
86
- result: {
87
- protocolVersion: "2026-01-26",
88
- hostInfo: { name: "test-host", version: "1" },
89
- hostCapabilities: {},
90
- hostContext: {},
91
- },
92
- });
93
- await tick();
94
- expect(harness.messages[1]).toMatchObject({ method: "ui/notifications/initialized" });
95
-
96
- harness.toolResult({ path: "/tmp/video.md", previewUrl: PLAYER_ORIGIN + "/" });
97
- await tick();
98
- expect(harness.elements.player.src).toBe(PLAYER_ORIGIN + "/");
99
- expect(harness.elements.pathLabel.textContent).toBe("/tmp/video.md");
100
-
101
- harness.elements.approve.onclick();
102
- expect(harness.messages[2]).toMatchObject({ id: 2, method: "tools/call" });
103
- expect(harness.messages).not.toContainEqual(expect.objectContaining({ method: "ui/message" }));
104
- harness.respond({ id: 2, result: { structuredContent: { decision: "approved" } } });
105
- await tick();
106
- expect(harness.messages[3]).toMatchObject({ method: "ui/message" });
107
- expect(new Set(harness.messages.filter((message) => "id" in message).map((message) => message.id)).size).toBe(3);
108
- });
109
-
110
- it("surfaces submit errors without sending a decision message and rejects invalid bridge messages", async () => {
111
- const harness = createBridgeHarness();
112
- harness.run();
113
- harness.dispatch({ source: {}, data: { jsonrpc: "2.0", id: 1, result: {} } });
114
- expect(harness.elements.status.textContent).toBe("");
115
- harness.respond({ id: 1, result: {} });
116
- await tick();
117
- harness.elements.changes.onclick();
118
- expect(harness.elements.status.textContent).toBe("Feedback is required.");
119
- harness.elements.feedback.value = "Fix the title";
120
- harness.elements.changes.onclick();
121
- harness.respond({ id: 2, error: { message: "submit failed" } });
122
- await tick();
123
- expect(harness.elements.status.textContent).toBe("submit failed");
124
- expect(harness.messages.some((message) => message.method === "ui/message")).toBe(false);
125
- });
126
- });
127
-
128
- function tick() {
129
- return new Promise((resolve) => setTimeout(resolve, 0));
130
- }
131
-
132
- function createBridgeHarness() {
133
- const messages: any[] = [];
134
- const listeners: ((event: any) => void)[] = [];
135
- const element = (value = "") => ({ value, textContent: "", innerHTML: "", onclick: undefined as (() => void) | undefined, focus() {} });
136
- const elements = {
137
- feedback: element(),
138
- status: element(),
139
- player: element(),
140
- pathLabel: element(),
141
- approve: element(),
142
- changes: element(),
143
- };
144
- const parent = { postMessage(message: any) { messages.push(message); } };
145
- const document = {
146
- getElementById(id: string) {
147
- if (id === "feedback") return elements.feedback;
148
- if (id === "status") return elements.status;
149
- if (id === "video-player") return elements.player;
150
- if (id === "path-label") return elements.pathLabel;
151
- if (id === "approve") return elements.approve;
152
- return elements.changes;
153
- },
154
- };
155
- const window = { parent, addEventListener(_type: string, listener: (event: any) => void) { listeners.push(listener); } };
156
- const context = vm.createContext({ window, document, setTimeout, fetch: async () => ({ ok: true, json: async () => ({ ready: true }) }) });
157
- return {
158
- messages,
159
- elements,
160
- run() { vm.runInContext(buildPreviewBridgeScript("markcut.preview.submit"), context); },
161
- dispatch(event: any) { listeners.forEach((listener) => listener(event)); },
162
- respond(message: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", ...message } }); },
163
- toolResult(structuredContent: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { result: { structuredContent } } } }); },
164
- };
165
- }
1
+ import {describe,it,expect} from 'vitest';
2
+ process.env.NEOY_PUBLIC_ORIGIN = 'https://neoy.example.test';
3
+ import {buildPreviewHtml,buildPreviewResourceResult,RESOURCE_URI,LEGACY_RESOURCE_URIS,validateMarkdownPath,reviewResult,getPreviewMedia,readPreviewAudio,withPreviewAudio} from '../src/mcp/preview.mjs';
4
+ import {buildPreviewBridgeScript} from '../src/mcp/bridge.mjs';
5
+ import {mkdtemp,writeFile,rm} from 'node:fs/promises';
6
+ import {tmpdir} from 'node:os';
7
+ import {join} from 'node:path';
8
+ describe('Markcut Remotion MCP App',()=>{
9
+ it('requires absolute Markdown path',()=>{expect(()=>validateMarkdownPath('a.md')).toThrow(/absolute/);expect(()=>validateMarkdownPath('/tmp/a.txt')).toThrow(/Markdown/);});
10
+ it('loads the React Remotion bundle from remote HTTPS',()=>{
11
+ const html=buildPreviewHtml();
12
+ expect(html).toContain('id="remotion-root"');
13
+ expect(html).toContain('id="player-version"');
14
+ expect(html).toContain('>v24</span>');
15
+ expect(html).toContain('id="video-md-version"');
16
+ expect(html).toContain('https://neoy.example.test/files/_neoy/markcut/player/');
17
+ expect(html).not.toContain('id="remotion-bundle"');
18
+ expect(html.length).toBeLessThan(20000);
19
+ expect(html).not.toContain('<video id="video-player"');
20
+ expect(html).not.toContain('data:video/mp4');
21
+ expect(html).not.toContain('127.0.0.1');
22
+ expect(html).not.toContain('src="/player.js"');
23
+ });
24
+ it('registers latest resource at startup and aliases previous URIs',()=>{
25
+ for(const uri of [RESOURCE_URI,...LEGACY_RESOURCE_URIS]){
26
+ const result=buildPreviewResourceResult(uri);
27
+ expect(result.contents[0].uri).toBe(uri);
28
+ expect(result.contents[0].text).toContain('remotion-canvas');
29
+ expect(result.contents[0]._meta.ui.csp.connectDomains).toEqual([
30
+ 'https://neoy.example.test',
31
+ 'https://maps.googleapis.com', 'https://maps.gstatic.com',
32
+ 'https://maps-api-v3.googleapis.com', 'https://routes.googleapis.com',
33
+ ]);
34
+ expect(result.contents[0]._meta.ui.csp.resourceDomains).toContain('https://maps.googleapis.com');
35
+ }
36
+ });
37
+ it('does not perform secondary tool calls for compiled scenes',()=>{
38
+ const script=buildPreviewBridgeScript();
39
+ expect(script).toContain('ui/notifications/tool-result');
40
+ expect(script).toContain("CustomEvent('markcut:revision'");
41
+ expect(script).toContain('data.compiled');
42
+ expect(script).not.toContain("'tools/call'");
43
+ expect(script).not.toContain('videoBase64');
44
+ expect(script).toContain('fetch(');
45
+ expect(script).toContain('/files/_neoy/markcut/revisions/');
46
+ });
47
+ it('prevents reading unregistered compiled scenes',()=>{
48
+ expect(()=>getPreviewMedia('a'.repeat(64))).toThrow(/Unknown or expired/);
49
+ });
50
+ it('includes a local audio track for Remotion playback',async()=>{
51
+ const dir=await mkdtemp(join(tmpdir(),'markcut-audio-'));
52
+ try {
53
+ await writeFile(join(dir,'clip.md'),'# video\naudio: sample.wav\n');
54
+ await writeFile(join(dir,'sample.wav'),Buffer.from([82,73,70,70]));
55
+ const media=await readPreviewAudio(join(dir,'clip.md'));
56
+ expect(media.audioSrc).toMatch(/^data:audio\/wav;base64,/);
57
+ expect(media.audioSrc).toContain('UklGRg==');
58
+ await writeFile(join(dir,'clip.md'),'# video\naudio: ../secret.wav\n');
59
+ await expect(readPreviewAudio(join(dir,'clip.md'))).rejects.toThrow(/audio must/);
60
+ await writeFile(join(dir,'clip.md'),'# video\n');
61
+ await writeFile(join(dir,'clip.wav'),Buffer.from([82,73,70,70]));
62
+ expect((await readPreviewAudio(join(dir,'clip.md'))).audioSrc).toContain('UklGRg==');
63
+ } finally {await rm(dir,{recursive:true,force:true});}
64
+ });
65
+ it('uses native MarkCut AudioLeaf as a background track without changing original duration',()=>{
66
+ const compiled={root:{id:'root',type:'root',isSeries:true,durationInSeconds:6,children:[{id:'scene',type:'component',durationInSeconds:6}]}};
67
+ const mixed=withPreviewAudio(compiled,'data:audio/wav;base64,QQ==');
68
+ expect(mixed.root.children[1]).toMatchObject({type:'audio',isBackground:true,start:0,end:6,volume:1});
69
+ expect(compiled.root.children).toHaveLength(1);
70
+ expect(mixed.root.durationInSeconds).toBe(6);
71
+ });
72
+ it('requires review feedback',()=>{
73
+ expect(reviewResult('approved')).toEqual({decision:'approved'});
74
+ expect(()=>reviewResult('changes_requested','')).toThrow(/feedback/);
75
+ });
76
+ });
@@ -0,0 +1,59 @@
1
+ import {describe,it,expect} from "vitest";
2
+ import {externalizePreviewMedia,resolveMountedFile,resolvePreviewSource,resolvePreviewPath,publishPlayerBundle,publicOrigin,EXPORT_ROOT} from "../src/mcp/remote-assets.mjs";
3
+ import {readFileSync,rmSync,writeFileSync,mkdirSync,mkdtempSync,symlinkSync,realpathSync} from "node:fs";
4
+ import {join} from "node:path";
5
+ import {tmpdir} from "node:os";
6
+ const id="11111111-2222-4333-8444-555555555555";
7
+ describe("NeoY backed preview files",()=>{
8
+ it("publishes generated media as persistent files under NeoY export root",()=>{
9
+ const input={root:{children:[
10
+ {type:"audio",src:"data:audio/wav;base64,UklGRg=="},
11
+ {type:"scene",children:[{type:"component",jsx:"<img src='data:image/svg+xml,%3Csvg%3E%3C%2Fsvg%3E' />"}]}
12
+ ]}};
13
+ try{
14
+ const {compiled,assets}=externalizePreviewMedia(input,id,"https://example.org");
15
+ expect(assets).toEqual(["0.wav","1.svg"]);
16
+ expect(compiled.root.children[0].src).toBe("https://example.org/files/_neoy/markcut/generated/"+id+"/0.wav");
17
+ expect(compiled.root.children[1].children[0].jsx).toContain("https://example.org/files/_neoy/markcut/generated/"+id+"/1.svg");
18
+ expect(readFileSync(join(EXPORT_ROOT,"generated",id,"0.wav")).toString("hex")).toBe("52494646");
19
+ expect(JSON.stringify(compiled)).not.toContain("data:");
20
+ expect(input.root.children[0].src).toContain("data:audio/");
21
+ }finally{rmSync(join(EXPORT_ROOT,"generated",id),{recursive:true,force:true});}
22
+ });
23
+ it("rejects a local HTTP media reference",()=>{
24
+ expect(()=>externalizePreviewMedia({src:"http://127.0.0.1:4571/asset"},id,"https://example.org")).toThrow(/HTTPS|local media URL/);
25
+ });
26
+ it("resolves files below NeoY mounts",()=>{
27
+ expect(resolveMountedFile("/tmp/vlog/README.md","https://example.org",{vlog:"/tmp/vlog"})).toBe("https://example.org/files/vlog/README.md");
28
+ });
29
+ it("maps video and image references relative to mounted markdown",()=>{
30
+ const url=resolvePreviewSource("./assets/shot.mp4","/tmp/vlog/runs/20260909","https://example.org",{vlog:"/tmp/vlog"});
31
+ expect(url).toBe("https://example.org/files/vlog/runs/20260909/assets/shot.mp4");
32
+ expect(()=>resolvePreviewSource("../secret.mp4","/tmp/vlog","https://example.org",{vlog:"/tmp/vlog"})).toThrow(/mounted root/);
33
+ });
34
+ it("resolves only allowlisted NeoY Markdown URLs to absolute files",()=>{
35
+ const base=mkdtempSync(join(tmpdir(),"markcut-mount-"));
36
+ const file=join(base,"video.md");
37
+ const outside=join(base,"..","secret.md");
38
+ writeFileSync(file,"# video");
39
+ symlinkSync("/etc/hosts",join(base,"escape.md"));
40
+ try{
41
+ const roots={vlog:base};
42
+ expect(resolvePreviewPath("https://example.org/files/vlog/video.md","https://example.org",roots)).toBe(realpathSync(file));
43
+ expect(resolvePreviewPath("/files/vlog/video.md","https://example.org",roots)).toBe(realpathSync(file));
44
+ expect(()=>resolvePreviewPath("https://other.org/files/vlog/video.md","https://example.org",roots)).toThrow(/Only NeoY/);
45
+ expect(()=>resolvePreviewPath("/files/vlog/escape.md","https://example.org",roots)).toThrow(/escapes/);
46
+ expect(()=>resolvePreviewPath("/files/_neoy/markcut/generated/a.md","https://example.org",roots)).toThrow(/Invalid/);
47
+ }finally{rmSync(base,{recursive:true,force:true});}
48
+ });
49
+ it("publishes hashed player script under NeoY exports",()=>{
50
+ const script=join(EXPORT_ROOT,"test-fixture.js");
51
+ mkdirSync(EXPORT_ROOT,{recursive:true});
52
+ writeFileSync(script,"console.log('test')");
53
+ try{
54
+ const url=publishPlayerBundle(script,"https://example.org");
55
+ expect(url).toMatch(/^https:\/\/example.org\/files\/_neoy\/markcut\/player\/[a-f0-9]{20}\.js$/);
56
+ expect(readFileSync(join(EXPORT_ROOT,"player",url.split("/").pop()!)).toString()).toBe("console.log('test')");
57
+ }finally{rmSync(script,{force:true});}
58
+ });
59
+ });
@@ -0,0 +1,69 @@
1
+ import {describe,it,expect} from 'vitest';
2
+ import {mkdtemp,writeFile,readFile,readdir,rm} from 'node:fs/promises';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+
6
+ describe('stateless Markcut MCP cache',()=>{
7
+ it('reuses identical sources, preserves latest and retains ten previews without player processes',async()=>{
8
+ const run=await mkdtemp(join(tmpdir(),'markcut-static-'));
9
+ const prior=process.env.MARKCUT_EXPORT_ROOT;
10
+ const priorOrigin=process.env.NEOY_PUBLIC_ORIGIN;
11
+ process.env.NEOY_PUBLIC_ORIGIN='https://neoy.example.test';
12
+ process.env.MARKCUT_EXPORT_ROOT=join(run,'exports');
13
+ try{
14
+ const {ensureVideoPreview}=await import('../src/mcp/preview.mjs');
15
+ const input=join(run,'video.md');
16
+ await writeFile(join(run,'photo.png'),Buffer.from('initial-media-bytes'));
17
+ const versions=[];
18
+ for(let i=0;i<11;i++){
19
+ await writeFile(input,'# video\\nwidth:640 height:480 fps:30 layout:series\\n## Intro\\n- image src:photo.png duration:1 name:"Version '+i+'"\\n');
20
+ const stages:string[]=[];
21
+ const result=await ensureVideoPreview(input,{onProgress:(stage:string)=>stages.push(stage)});
22
+ if(i===0)expect(stages).toEqual([
23
+ 'checking-cache','parsing-markdown','resolving-assets-and-generating-media',
24
+ 'compiling-scenes','freezing-preview-assets','publishing-static-preview'
25
+ ]);
26
+ versions.push(result);
27
+ expect(result.compiledUrl).toContain('/files/_neoy/markcut/cache/'+result.contentHash+'.json');
28
+ expect(JSON.parse(await readFile(join(run,'.markcut/video/default/compiled.json'),'utf8'))).toBeTruthy();
29
+ }
30
+ expect((await readFile(input,'utf8'))).not.toContain('seed:');
31
+ const again=await ensureVideoPreview(input);
32
+ expect(again.contentHash).toBe(versions[10].contentHash);
33
+ const cache=await readdir(join(run,'.markcut/cache'));
34
+ expect(cache.filter(x=>x.endsWith('.json'))).toHaveLength(10);
35
+ expect(cache).not.toContain(versions[0].contentHash+'.json');
36
+ expect(cache).toContain(versions[1].contentHash+'.json');
37
+ const published=await readdir(join(run,'exports','cache'));
38
+ expect(published).toHaveLength(10);
39
+ expect(JSON.parse(await readFile(join(run,'.markcut/video/default/cache-index.json'),'utf8')).latest).toBe(versions[10].contentHash);
40
+ expect(JSON.parse(await readFile(join(run,'exports','cache',versions[1].contentHash+'.json'),'utf8'))).toBeTruthy();
41
+ const other=join(run,'other.md');
42
+ await writeFile(other,'# video\nwidth:640 height:480 fps:30 layout:series\n## Other\n- image src:photo.png duration:1\n');
43
+ const second=await ensureVideoPreview(other);
44
+ expect(second.contentHash).not.toBe(again.contentHash);
45
+ expect(await readdir(join(run,'.markcut/cache'))).toHaveLength(11);
46
+ expect(JSON.parse(await readFile(join(run,'.markcut/other/default/cache-index.json'),'utf8')).latest).toBe(second.contentHash);
47
+ const previousLatest=await readFile(join(run,'.markcut/video/default/compiled.json'),'utf8');
48
+ await writeFile(input,'# video\naudio: ../outside.wav\n');
49
+ await expect(ensureVideoPreview(input)).rejects.toThrow(/audio must/);
50
+ expect(await readFile(join(run,'.markcut/video/default/compiled.json'),'utf8')).toBe(previousLatest);
51
+ // Local source assets are published as immutable files, not live paths.
52
+ const media=join(run,'media.md'), photo=join(run,'photo2.png');
53
+ await writeFile(photo,Buffer.from('small-image-payload'));
54
+ await writeFile(media,'# video\nwidth:640 height:480 fps:30 layout:series\n## Photo\n- image src:photo2.png duration:1\n');
55
+ const mediaPreview=await ensureVideoPreview(media);
56
+ const mediaJson=await readFile(join(run,'exports','cache',mediaPreview.contentHash+'.json'),'utf8');
57
+ expect(mediaJson).toContain('/files/_neoy/markcut/generated/'+mediaPreview.contentHash+'/file-0.png');
58
+ expect(await readFile(join(run,'exports','generated',mediaPreview.contentHash,'file-0.png'))).toEqual(await readFile(photo));
59
+ await writeFile(photo,Buffer.from('different-image-payload'));
60
+ const modified=await ensureVideoPreview(media);
61
+ expect(modified.contentHash).not.toBe(mediaPreview.contentHash);
62
+ expect(await readFile(join(run,'exports','generated',mediaPreview.contentHash,'file-0.png'))).toEqual(Buffer.from('small-image-payload'));
63
+ }finally{
64
+ if(prior===undefined)delete process.env.MARKCUT_EXPORT_ROOT;else process.env.MARKCUT_EXPORT_ROOT=prior;
65
+ if(priorOrigin===undefined)delete process.env.NEOY_PUBLIC_ORIGIN;else process.env.NEOY_PUBLIC_ORIGIN=priorOrigin;
66
+ await rm(run,{recursive:true,force:true});
67
+ }
68
+ },20000);
69
+ });