@lalalic/markcut 3.0.0 → 3.1.0

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 (34) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/map-dynamic-camera.md +244 -0
  4. package/skills/markcut/docs/markdown-descriptive.md +2 -0
  5. package/src/descriptive/compiler.ts +41 -0
  6. package/src/descriptive/dsl.ts +42 -5
  7. package/src/descriptive/markdown.ts +5 -0
  8. package/src/descriptive/resolve.test.ts +5 -5
  9. package/src/descriptive/resolve.ts +51 -12
  10. package/src/player/bundle/player.js +448 -99
  11. package/src/player/pipeline.mjs +64 -13
  12. package/src/player/pipeline.ts +5 -4
  13. package/src/player/server.mjs +22 -42
  14. package/src/render/cli.mjs +54 -3
  15. package/src/render/validate-assets.mjs +140 -0
  16. package/src/schema/index.ts +56 -1
  17. package/src/spots/cli.mjs +266 -0
  18. package/src/types/Map.tsx +501 -127
  19. package/src/utils/tween.ts +49 -1
  20. package/tests/dsl.test.ts +43 -0
  21. package/tests/fixtures/map-dynamic.json +52 -0
  22. package/tests/fixtures/md/animate-diagrams.md +9 -7
  23. package/tests/fixtures/md/map-all-views.md +28 -0
  24. package/tests/md-descriptive.test.ts +58 -0
  25. package/tests/render.test.ts +1 -0
  26. package/tests/schema.test.ts +58 -1
  27. package/tests/validate-assets.test.ts +106 -0
  28. package/B] +0 -2
  29. package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
  30. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
  31. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
  32. package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
  33. package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
  34. package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
@@ -14,7 +14,7 @@ import * as React from "react";
14
14
  import { interpolate, useCurrentFrame, Easing } from "remotion";
15
15
 
16
16
  /** Built-in easing name → Remotion easing function. */
17
- const EASING_MAP: Record<string, ((t: number) => number) | undefined> = {
17
+ export const EASING_MAP: Record<string, ((t: number) => number) | undefined> = {
18
18
  linear: undefined,
19
19
  ease: Easing.ease,
20
20
  easeIn: Easing.in(Easing.ease),
@@ -116,3 +116,51 @@ export function useTweenBindings(action: { start?: number; end?: number }): Reco
116
116
 
117
117
  return { tween, interpolate };
118
118
  }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Tween spec resolution (used by stream leaf renderers, e.g. Map.tsx)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * A tween expression as parsed by the descriptive DSL: `tween(from, to, easing?)`
126
+ * becomes `{ __tween: [from, to, easing?] }` (see parseProps in dsl.ts).
127
+ */
128
+ export interface TweenSpec {
129
+ __tween: Array<number | string>;
130
+ }
131
+
132
+ /** A static number OR a tween spec. */
133
+ export type Tweenable = number | TweenSpec;
134
+
135
+ /**
136
+ * Resolve a `Tweenable` to a per-frame value — deterministic per frame.
137
+ *
138
+ * - A plain number is returned as-is (static).
139
+ * - A `{__tween:[from,to,easing?]}` spec is interpolated over the node's
140
+ * `[start, end]` (seconds) using the current frame.
141
+ * - Anything malformed falls back to `fallback`.
142
+ */
143
+ export function resolveTween(
144
+ frame: number,
145
+ fps: number,
146
+ spec: Tweenable | undefined,
147
+ start: number,
148
+ end: number,
149
+ fallback: number,
150
+ ): number {
151
+ if (typeof spec === "number") return spec;
152
+ const tween = spec?.__tween;
153
+ if (!tween || tween.length < 2) return fallback;
154
+ const from = Number(tween[0]);
155
+ const to = Number(tween[1]);
156
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return fallback;
157
+ const easingName = typeof tween[2] === "string" ? tween[2] : undefined;
158
+ const easingFn = easingName ? EASING_MAP[easingName] : undefined;
159
+ const startF = Math.max(0, Math.floor(start * fps));
160
+ const endF = Math.max(startF + 1, Math.floor(end * fps));
161
+ return interpolate(frame, [startF, endF], [from, to], {
162
+ extrapolateLeft: "clamp",
163
+ extrapolateRight: "clamp",
164
+ easing: easingFn,
165
+ });
166
+ }
package/tests/dsl.test.ts CHANGED
@@ -100,6 +100,19 @@ describe("dsl — parseWaypoints", () => {
100
100
  expect(parseWaypoints("[40.7,-74.0]")).toEqual([{ lat: 40.7, lng: -74.0, label: undefined }]);
101
101
  });
102
102
 
103
+ it("parses waypoint media as a 4th field", () => {
104
+ expect(parseWaypoints('[40.7,-74.0,"NYC","photo1.jpg"; 34.05,-118.25,"LA","clip.mp4"]')).toEqual([
105
+ { lat: 40.7, lng: -74.0, label: "NYC", media: "photo1.jpg" },
106
+ { lat: 34.05, lng: -118.25, label: "LA", media: "clip.mp4" },
107
+ ]);
108
+ });
109
+
110
+ it("parses waypoint media without a label", () => {
111
+ expect(parseWaypoints('[40.7,-74.0,"","img.jpg"]')).toEqual([
112
+ { lat: 40.7, lng: -74.0, label: undefined, media: "img.jpg" },
113
+ ]);
114
+ });
115
+
103
116
  it("returns empty array for non-bracket input", () => {
104
117
  expect(parseWaypoints("not a list")).toEqual([]);
105
118
  });
@@ -133,6 +146,36 @@ describe("dsl — parseProps", () => {
133
146
  it("returns {} for unclosed object", () => {
134
147
  expect(parseProps("{a:1")).toEqual({});
135
148
  });
149
+
150
+ it("parses tween() expressions into tagged specs", () => {
151
+ expect(parseProps("{zoom:tween(6, 12)}")).toEqual({ zoom: { __tween: [6, 12] } });
152
+ });
153
+
154
+ it("parses tween() with easing", () => {
155
+ expect(parseProps("{zoom:tween(6, 12, easeInOut)}")).toEqual({
156
+ zoom: { __tween: [6, 12, "easeInOut"] },
157
+ });
158
+ });
159
+
160
+ it("parses tween() nested inside objects and arrays", () => {
161
+ expect(parseProps("{center:{lat:tween(37.0, 37.9), lng:120}, pov:{heading:tween(200, 320)}}")).toEqual({
162
+ center: { lat: { __tween: [37.0, 37.9] }, lng: 120 },
163
+ pov: { heading: { __tween: [200, 320] } },
164
+ });
165
+ });
166
+
167
+ it("parses tween() with quoted string values (colors)", () => {
168
+ expect(parseProps('{color:tween("#000000", "#FFFFFF", easeOut)}')).toEqual({
169
+ color: { __tween: ["#000000", "#FFFFFF", "easeOut"] },
170
+ });
171
+ });
172
+
173
+ it("keeps static numbers alongside tweens", () => {
174
+ expect(parseProps("{zoom:tween(6, 12), tilt:45}")).toEqual({
175
+ zoom: { __tween: [6, 12] },
176
+ tilt: 45,
177
+ });
178
+ });
136
179
  });
137
180
 
138
181
  describe("dsl — parseOnSpec", () => {
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "root",
3
+ "type": "root",
4
+ "width": 640,
5
+ "height": 480,
6
+ "fps": 30,
7
+ "isSeries": true,
8
+ "transition": "fade",
9
+ "transitionTime": 0.3,
10
+ "children": [
11
+ {
12
+ "id": "overview",
13
+ "type": "map",
14
+ "view": "overview",
15
+ "name": "satellite-dolly",
16
+ "mapType": "satellite",
17
+ "center": { "lat": 37.7749, "lng": -122.4194 },
18
+ "zoom": 6,
19
+ "camera": { "zoom": { "__tween": [6, 12, "easeInOut"] } },
20
+ "start": 0,
21
+ "end": 3
22
+ },
23
+ {
24
+ "id": "cinematic",
25
+ "type": "map",
26
+ "view": "cinematic",
27
+ "name": "cinematic-flyover",
28
+ "waypoints": [
29
+ { "lat": 37.7749, "lng": -122.4194, "label": "SF" },
30
+ { "lat": 34.0522, "lng": -118.2437, "label": "LA" }
31
+ ],
32
+ "cinematic": { "mode": "flyAlong", "headingFollow": true, "tilt": { "__tween": [20, 45] } },
33
+ "camera": { "zoom": { "__tween": [12, 14, "easeInOut"] } },
34
+ "start": 0,
35
+ "end": 8
36
+ },
37
+ {
38
+ "id": "streetview",
39
+ "type": "map",
40
+ "view": "streetview",
41
+ "name": "streetview-pan",
42
+ "streetView": {
43
+ "location": { "lat": 37.7749, "lng": -122.4194 },
44
+ "radius": 50,
45
+ "pov": { "heading": { "__tween": [200, 320, "easeInOut"] }, "pitch": 0 },
46
+ "zoom": { "__tween": [0, 0.5, "easeInOut"] }
47
+ },
48
+ "start": 0,
49
+ "end": 6
50
+ }
51
+ ]
52
+ }
@@ -13,8 +13,8 @@ layout:parallel
13
13
  ~~~
14
14
 
15
15
  ## FlowChart
16
- layout:parallel
17
- - component id:flowChart duration:12
16
+ layout:series
17
+ - component id:flowChart isBackground:true
18
18
  ~~~jsx
19
19
  <div style={{position:'absolute',top:0,left:0,width:640,height:480,background:'#1a1a2e',padding:10,fontFamily:'monospace',boxSizing:'border-box',display:'flex',flexDirection:'column'}}>
20
20
  <p style={{color:'#00d4ff',fontSize:12,textAlign:'center',margin:'0 0 4px 0',flexShrink:0}}>Flow — {highlight}</p>
@@ -33,8 +33,10 @@ layout:parallel
33
33
  F --> G["Send Response"]
34
34
  classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px,color:#000
35
35
  ~~~
36
- highlight:"A"
37
- animateEdges:true
38
- - event duration:3 start:3 on:(start, flowChart.highlight="B";flowChart.animateEdges=["B->C"])
39
- - event duration:3 start:6 on:(start, flowChart.highlight="C")
40
- - event duration:3 start:9 on:(start, flowChart.highlight=["D","G"])
36
+ - script on:(start, flowChart.highlight="A")
37
+ ~~~script
38
+
39
+ ~~~
40
+ - script on:(start, flowChart.highlight="B")
41
+ - script on:(start, flowChart.highlight="C")
42
+ - script on:(start, flowChart.highlight=["D","G"])
@@ -0,0 +1,28 @@
1
+ # video
2
+ seed:2668727180
3
+ width:640 height:480 fps:30 layout:series transition:fade transitionTime:0.5
4
+
5
+ ## Satellite-Dolly
6
+ layout:parallel
7
+ - script "We begin high above San Francisco, then dive into the city."
8
+ - map view:overview mapType:satellite duration:4 center:{lat:37.7749,lng:-122.4194} camera:{zoom:tween(6, 12, easeInOut)}
9
+
10
+ ## Route
11
+ layout:parallel
12
+ - script "The route winds from the Golden Gate to the airport, with photos at each stop."
13
+ - map view:route duration:6 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗" waypoints:[37.8199,-122.4783,"Golden Gate","https://picsum.photos/seed/gg-bridge/96/96"; 37.7749,-122.4194,"Civic Center","https://picsum.photos/seed/civic-center/96/96"; 37.6213,-122.3790,"SFO","https://picsum.photos/seed/sfo-airport/96/96"]
14
+
15
+ ## Cinematic
16
+ layout:parallel
17
+ - script "The camera tilts and chases the road like a drone."
18
+ - map view:cinematic duration:8 travelMode:DRIVING mapType:satellite routeMarker:"🚗" cinematic:{mode:flyAlong, headingFollow:true, tilt:tween(0, 45, easeInOut)} camera:{zoom:tween(12, 14, easeInOut)} waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
19
+
20
+ ## Street-View
21
+ layout:parallel
22
+ - script "And finally, we land on the street itself."
23
+ - map view:streetview duration:8 streetView:{location:{lat:37.7793,lng:-122.4193}, radius:50, pov:{heading:tween(200, 420, easeInOut), pitch:tween(0, -8)}, zoom:tween(0, 0.6, easeInOut)}
24
+
25
+ ## Street-View-Walk
26
+ layout:parallel
27
+ - script "A quick walk down the block."
28
+ - map view:streetview duration:6 streetView:{route:[{lat:37.7793,lng:-122.4193}, {lat:37.7785,lng:-122.4185}, {lat:37.7777,lng:-122.4178}, {lat:37.7769,lng:-122.4170}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
@@ -897,6 +897,64 @@ describe("resolveDialogue", () => {
897
897
  });
898
898
  });
899
899
 
900
+ // ── Map Dynamic Views ─────────────────────────────────────────────────────
901
+
902
+ describe("map dynamic views (tween camera)", () => {
903
+ const md = `
904
+ # video
905
+ width:640 height:480 fps:30 layout:series
906
+
907
+ ## Overview
908
+ layout:parallel
909
+ - script "This is the city."
910
+ - map view:overview mapType:satellite duration:4 camera:{zoom:tween(6, 12, easeInOut)}
911
+
912
+ ## Cinematic
913
+ layout:parallel
914
+ - script "Flying over."
915
+ - map view:cinematic duration:8 cinematic:{mode:flyAlong, tilt:tween(0,45)} waypoints:[37.77,-122.41,"SF","photo1.jpg"; 34.05,-118.25,"LA","photo2.jpg"]
916
+
917
+ ## Streetview
918
+ layout:parallel
919
+ - script "On the ground."
920
+ - map view:streetview duration:6 streetView:{location:{lat:37.77,lng:-122.41}, pov:{heading:tween(200,320)}, zoom:tween(0,1)}
921
+ `;
922
+
923
+ it("parses view/camera/cinematic/streetView into descriptive nodes", () => {
924
+ const parsed = parseMarkdownDescriptive(md);
925
+ const scenes = parsed.children as any[];
926
+ const overview = scenes[0].children.find((c: any) => c.type === "map");
927
+ expect(overview.view).toBe("overview");
928
+ expect(overview.camera?.zoom).toEqual({ __tween: [6, 12, "easeInOut"] });
929
+
930
+ const cinematic = scenes[1].children.find((c: any) => c.type === "map");
931
+ expect(cinematic.view).toBe("cinematic");
932
+ expect(cinematic.cinematic?.mode).toBe("flyAlong");
933
+ expect(cinematic.cinematic?.tilt).toEqual({ __tween: [0, 45] });
934
+ expect(cinematic.waypoints[0].media).toBe("photo1.jpg");
935
+ expect(cinematic.waypoints[1].media).toBe("photo2.jpg");
936
+
937
+ const street = scenes[2].children.find((c: any) => c.type === "map");
938
+ expect(street.view).toBe("streetview");
939
+ expect(street.streetView?.pov?.heading).toEqual({ __tween: [200, 320] });
940
+ expect(street.streetView?.zoom).toEqual({ __tween: [0, 1] });
941
+ });
942
+
943
+ it("compiles view fields into the stream tree", () => {
944
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
945
+ const maps = (compiled.children as any[]).flatMap((c: any) =>
946
+ (c.children ?? []).filter((ch: any) => ch.type === "map"),
947
+ );
948
+ expect(maps).toHaveLength(3);
949
+ expect(maps[0].view).toBe("overview");
950
+ expect(maps[0].camera?.zoom).toEqual({ __tween: [6, 12, "easeInOut"] });
951
+ expect(maps[1].view).toBe("cinematic");
952
+ expect(maps[1].cinematic?.tilt).toEqual({ __tween: [0, 45] });
953
+ expect(maps[2].view).toBe("streetview");
954
+ expect(maps[2].streetView?.pov?.heading).toEqual({ __tween: [200, 320] });
955
+ });
956
+ });
957
+
900
958
  // ── Helper ────────────────────────────────────────────────────────────────
901
959
 
902
960
  function findComponents(node: any): any[] {
@@ -385,6 +385,7 @@ describe("Full Feature Combination", () => {
385
385
  "effects.json",
386
386
  "subtitle.json",
387
387
  "map.json",
388
+ "map-dynamic.json",
388
389
  "audio.json",
389
390
  "subvideo.json",
390
391
  "full.json",
@@ -6,7 +6,7 @@
6
6
  * field that the player server sets after bundling frontmatter imports.
7
7
  */
8
8
  import { describe, it, expect } from "vitest";
9
- import { root, component } from "../src/schema/index";
9
+ import { root, component, mapStream } from "../src/schema/index";
10
10
 
11
11
  describe("root.imports — component registry passthrough", () => {
12
12
  it("preserves a string bundle URL through root.parse()", () => {
@@ -69,3 +69,60 @@ describe("component node", () => {
69
69
  ).toThrow();
70
70
  });
71
71
  });
72
+
73
+ describe("map stream — dynamic camera views", () => {
74
+ const base = { type: "map", waypoints: [{ lat: 37.77, lng: -122.41 }], start: 0, end: 5 };
75
+
76
+ it("defaults view to route", () => {
77
+ expect(mapStream.parse(base).view).toBe("route");
78
+ });
79
+
80
+ it("accepts all views", () => {
81
+ for (const view of ["overview", "route", "cinematic", "streetview"] as const) {
82
+ expect(mapStream.parse({ ...base, view }).view).toBe(view);
83
+ }
84
+ });
85
+
86
+ it("carries camera tween specs and static numbers", () => {
87
+ const m = mapStream.parse({
88
+ ...base,
89
+ view: "overview",
90
+ camera: { zoom: { __tween: [6, 12, "easeInOut"] }, tilt: 45 },
91
+ });
92
+ expect(m.camera?.zoom).toEqual({ __tween: [6, 12, "easeInOut"] });
93
+ expect(m.camera?.tilt).toBe(45);
94
+ });
95
+
96
+ it("carries cinematic config with tweenable tilt/range", () => {
97
+ const m = mapStream.parse({
98
+ ...base,
99
+ view: "cinematic",
100
+ cinematic: { mode: "flyTo", tilt: { __tween: [0, 45] }, range: 2000, fallback: "2d" },
101
+ });
102
+ expect(m.cinematic?.mode).toBe("flyTo");
103
+ expect(m.cinematic?.tilt).toEqual({ __tween: [0, 45] });
104
+ expect(m.cinematic?.range).toBe(2000);
105
+ expect(m.cinematic?.fallback).toBe("2d");
106
+ });
107
+
108
+ it("carries streetView config with pov tweens, zoom and walk route", () => {
109
+ const m = mapStream.parse({
110
+ ...base,
111
+ view: "streetview",
112
+ streetView: {
113
+ location: { lat: 37.77, lng: -122.41 },
114
+ pov: { heading: { __tween: [200, 320] }, pitch: -10 },
115
+ zoom: 0.5,
116
+ route: [{ lat: 37.77, lng: -122.41 }, { lat: 37.78, lng: -122.42 }],
117
+ },
118
+ });
119
+ expect(m.streetView?.pov?.heading).toEqual({ __tween: [200, 320] });
120
+ expect(m.streetView?.pov?.pitch).toBe(-10);
121
+ expect(m.streetView?.zoom).toBe(0.5);
122
+ expect(m.streetView?.route?.length).toBe(2);
123
+ });
124
+
125
+ it("rejects an invalid view", () => {
126
+ expect(() => mapStream.parse({ ...base, view: "drone" })).toThrow();
127
+ });
128
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ classifyAssetPath,
4
+ collectTreeAssets,
5
+ validateAssetsRelative,
6
+ } from "../src/render/validate-assets.mjs";
7
+
8
+ const BASE = "/proj/md";
9
+
10
+ describe("classifyAssetPath", () => {
11
+ it("allows remote URIs", () => {
12
+ expect(classifyAssetPath("https://x/y.png")).toBe("remote");
13
+ expect(classifyAssetPath("http://x/y.mp4")).toBe("remote");
14
+ expect(classifyAssetPath("data:image/png;base64,AAA")).toBe("remote");
15
+ expect(classifyAssetPath("blob:https://x/id")).toBe("remote");
16
+ expect(classifyAssetPath("file:///tmp/a.png")).toBe("remote");
17
+ });
18
+
19
+ it("flags root-absolute and absolute paths", () => {
20
+ expect(classifyAssetPath("/Users/me/a.png")).toBe("root-absolute");
21
+ expect(classifyAssetPath("/a/b.png")).toBe("root-absolute");
22
+ });
23
+
24
+ it("flags .. escapes", () => {
25
+ expect(classifyAssetPath("../x.png")).toBe("escapes");
26
+ expect(classifyAssetPath("../../x.png")).toBe("escapes");
27
+ });
28
+
29
+ it("accepts source-folder-relative paths", () => {
30
+ expect(classifyAssetPath("assets/x.png")).toBe("relative");
31
+ expect(classifyAssetPath(".markcut/generated/tts/a.mp3")).toBe("relative");
32
+ expect(classifyAssetPath("a/b/c.png")).toBe("relative");
33
+ });
34
+
35
+ it("treats non-.vtt subtitle src as inline text (not a path)", () => {
36
+ expect(classifyAssetPath("Hello world", { subtitle: true })).toBe("text");
37
+ expect(classifyAssetPath("00:00:00.000 --> 00:00:02.000\nhi", { subtitle: true })).toBe("text");
38
+ expect(classifyAssetPath("subs.vtt", { subtitle: true })).toBe("relative");
39
+ expect(classifyAssetPath("subs.vtt?lang=en", { subtitle: true })).toBe("relative");
40
+ });
41
+ });
42
+
43
+ describe("validateAssetsRelative", () => {
44
+ const tree = {
45
+ type: "root",
46
+ subtitle: { src: ".markcut/sub/subtitles.vtt" },
47
+ children: [
48
+ { id: "bg", type: "image", src: "assets/bg.png" },
49
+ { id: "clip", type: "video", src: "/Users/me/abs.mp4" },
50
+ { id: "walk", type: "audio", src: "a/../../escape.mp3" },
51
+ { id: "m", type: "map", waypoints: [{ media: "../outside.png" }] },
52
+ { id: "sub", type: "subtitle", src: "caption text" },
53
+ { id: "inc", type: "include", src: "../other.md" },
54
+ ],
55
+ };
56
+
57
+ it("returns no errors when all assets are baseDir-relative", () => {
58
+ const ok = {
59
+ type: "root",
60
+ subtitle: { src: ".markcut/sub/subtitles.vtt" },
61
+ children: [
62
+ { id: "bg", type: "image", src: "assets/bg.png" },
63
+ { id: "clip", type: "video", src: ".markcut/generated/media/photo_1920x1080.jpg" },
64
+ { id: "a", type: "audio", src: "assets/tts/1.mp3" },
65
+ { id: "m", type: "map", waypoints: [{ media: "assets/thumb.png" }] },
66
+ ],
67
+ };
68
+ expect(validateAssetsRelative(ok, BASE)).toEqual([]);
69
+ });
70
+
71
+ it("flags absolute, root-absolute and escaping assets with node id + field", () => {
72
+ const errors = validateAssetsRelative(tree, BASE);
73
+ // clip (root-absolute), walk (nested ..), m.waypoints[0].media (..),
74
+ // inc.src (..) — subtitle text + bg are fine.
75
+ expect(errors).toHaveLength(4);
76
+ const joined = errors.join("\n");
77
+ expect(joined).toContain('node "clip" (type: video) — field src = "/Users/me/abs.mp4"');
78
+ expect(joined).toContain('node "walk" (type: audio)');
79
+ expect(joined).toContain("waypoints[0].media");
80
+ expect(joined).toContain('node "inc" (type: include)');
81
+ expect(joined).toContain("relative to the source folder");
82
+ });
83
+
84
+ it("skips include.src for descriptive trees (skipIncludeSrc)", () => {
85
+ const errors = validateAssetsRelative(tree, BASE, { skipIncludeSrc: true });
86
+ expect(errors).toHaveLength(3);
87
+ expect(errors.join("\n")).not.toContain('node "inc"');
88
+ });
89
+
90
+ it("collects root.subtitle.src as an asset reference", () => {
91
+ const refs = collectTreeAssets(tree);
92
+ expect(refs.some((r) => r.field === "subtitle.src" && r.value === ".markcut/sub/subtitles.vtt")).toBe(true);
93
+ });
94
+
95
+ it("flags a bad root subtitle path", () => {
96
+ const bad = { type: "root", subtitle: { src: "../shared/subs.vtt" }, children: [] };
97
+ const errors = validateAssetsRelative(bad, BASE);
98
+ expect(errors).toHaveLength(1);
99
+ expect(errors[0]).toContain("subtitle.src");
100
+ });
101
+
102
+ it("is robust to empty trees", () => {
103
+ expect(validateAssetsRelative({ type: "root" }, BASE)).toEqual([]);
104
+ expect(validateAssetsRelative(null, BASE)).toEqual([]);
105
+ });
106
+ });
package/B] DELETED
@@ -1,2 +0,0 @@
1
- [main 2b839d7] mermaid.animateEdges=true|[A-
2
- 5 files changed, 162 insertions(+), 18 deletions(-)
@@ -1,9 +0,0 @@
1
- {
2
- "test-clip": {
3
- "width": 320,
4
- "height": 240,
5
- "created": "0000-00-00T00:00:00Z",
6
- "location": null,
7
- "duration": 3
8
- }
9
- }
@@ -1,5 +0,0 @@
1
- WEBVTT
2
-
3
- 00:00.000 --> 00:01.100
4
- Thank you.
5
-