@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.
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/map-dynamic-camera.md +244 -0
- package/skills/markcut/docs/markdown-descriptive.md +2 -0
- package/src/descriptive/compiler.ts +41 -0
- package/src/descriptive/dsl.ts +42 -5
- package/src/descriptive/markdown.ts +5 -0
- package/src/descriptive/resolve.test.ts +5 -5
- package/src/descriptive/resolve.ts +51 -12
- package/src/player/bundle/player.js +448 -99
- package/src/player/pipeline.mjs +64 -13
- package/src/player/pipeline.ts +5 -4
- package/src/player/server.mjs +22 -42
- package/src/render/cli.mjs +54 -3
- package/src/render/validate-assets.mjs +140 -0
- package/src/schema/index.ts +56 -1
- package/src/spots/cli.mjs +266 -0
- package/src/types/Map.tsx +501 -127
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +43 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/md/animate-diagrams.md +9 -7
- package/tests/fixtures/md/map-all-views.md +28 -0
- package/tests/md-descriptive.test.ts +58 -0
- package/tests/render.test.ts +1 -0
- package/tests/schema.test.ts +58 -1
- package/tests/validate-assets.test.ts +106 -0
- package/B] +0 -2
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
package/src/player/pipeline.mjs
CHANGED
|
@@ -343,6 +343,7 @@ function compileLeaf(node2, ctx, parentKind) {
|
|
|
343
343
|
const stream = {
|
|
344
344
|
...base,
|
|
345
345
|
type: "map",
|
|
346
|
+
view: node2.view ?? "route",
|
|
346
347
|
waypoints: node2.waypoints,
|
|
347
348
|
routeColor: node2.routeColor ?? "#4285F4",
|
|
348
349
|
routeWeight: node2.routeWeight ?? 4,
|
|
@@ -353,6 +354,9 @@ function compileLeaf(node2, ctx, parentKind) {
|
|
|
353
354
|
region: node2.region,
|
|
354
355
|
travelMode: node2.travelMode ?? "DRIVING",
|
|
355
356
|
routeMarker: node2.routeMarker ?? "\u{1F697}",
|
|
357
|
+
camera: node2.camera,
|
|
358
|
+
cinematic: node2.cinematic,
|
|
359
|
+
streetView: node2.streetView,
|
|
356
360
|
googleMapsApiKey: ctx.googleMapsApiKey
|
|
357
361
|
};
|
|
358
362
|
return { stream, duration: end ?? 0 };
|
|
@@ -9953,18 +9957,41 @@ function parseWaypoints(raw) {
|
|
|
9953
9957
|
const lat = Number(bits[0] ?? 0);
|
|
9954
9958
|
const lng = Number(bits[1] ?? 0);
|
|
9955
9959
|
const labelRaw = bits[2];
|
|
9956
|
-
const
|
|
9957
|
-
|
|
9960
|
+
const labelRawUq = labelRaw ? unquote(labelRaw) : void 0;
|
|
9961
|
+
const label = labelRawUq ? labelRawUq : void 0;
|
|
9962
|
+
const mediaRaw = bits[3];
|
|
9963
|
+
const media = mediaRaw ? unquote(mediaRaw) : void 0;
|
|
9964
|
+
return { lat, lng, label, media };
|
|
9958
9965
|
});
|
|
9959
9966
|
}
|
|
9967
|
+
function rewriteTweenExprs(s) {
|
|
9968
|
+
return s.replace(
|
|
9969
|
+
/tween\(\s*([^,()]+?)\s*,\s*([^,()]+?)\s*(?:,\s*([^()]+?))?\s*\)/g,
|
|
9970
|
+
(_match, fromRaw, toRaw, easingRaw) => {
|
|
9971
|
+
const scalar = (v) => {
|
|
9972
|
+
const t = v.trim();
|
|
9973
|
+
if (/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(t)) return t;
|
|
9974
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return t;
|
|
9975
|
+
if (t === "true" || t === "false" || t === "null") return t;
|
|
9976
|
+
return JSON.stringify(t);
|
|
9977
|
+
};
|
|
9978
|
+
const items = [scalar(fromRaw), scalar(toRaw)];
|
|
9979
|
+
if (easingRaw !== void 0 && easingRaw.trim()) {
|
|
9980
|
+
items.push(scalar(easingRaw));
|
|
9981
|
+
}
|
|
9982
|
+
return `{"__tween":[${items.join(",")}]}`;
|
|
9983
|
+
}
|
|
9984
|
+
);
|
|
9985
|
+
}
|
|
9960
9986
|
function parseProps(raw) {
|
|
9961
9987
|
const s = raw.trim();
|
|
9962
9988
|
if (!s.startsWith("{") && !s.startsWith("[")) return {};
|
|
9963
9989
|
if (!s.endsWith("}") && !s.endsWith("]")) return {};
|
|
9990
|
+
const withTweens = rewriteTweenExprs(s);
|
|
9964
9991
|
try {
|
|
9965
|
-
return JSON.parse(
|
|
9992
|
+
return JSON.parse(withTweens);
|
|
9966
9993
|
} catch {
|
|
9967
|
-
let normalized =
|
|
9994
|
+
let normalized = withTweens.replace(
|
|
9968
9995
|
/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
|
|
9969
9996
|
'$1"$2":'
|
|
9970
9997
|
);
|
|
@@ -9980,7 +10007,7 @@ function parseProps(raw) {
|
|
|
9980
10007
|
return JSON.parse(normalized);
|
|
9981
10008
|
} catch {
|
|
9982
10009
|
try {
|
|
9983
|
-
const result = (0, eval)("(" +
|
|
10010
|
+
const result = (0, eval)("(" + withTweens + ")");
|
|
9984
10011
|
return typeof result === "object" && result !== null ? result : {};
|
|
9985
10012
|
} catch {
|
|
9986
10013
|
return {};
|
|
@@ -10233,6 +10260,10 @@ function preserveVariantAttrs(node2, attrs) {
|
|
|
10233
10260
|
"mapType",
|
|
10234
10261
|
"data",
|
|
10235
10262
|
"prompt",
|
|
10263
|
+
"view",
|
|
10264
|
+
"camera",
|
|
10265
|
+
"cinematic",
|
|
10266
|
+
"streetView",
|
|
10236
10267
|
"name",
|
|
10237
10268
|
"title",
|
|
10238
10269
|
"transition",
|
|
@@ -10455,6 +10486,7 @@ function parseNodeLine(content3, lineNum) {
|
|
|
10455
10486
|
waypoints: attrs.waypoints ?? [],
|
|
10456
10487
|
duration: attrs.duration,
|
|
10457
10488
|
start: attrs.start,
|
|
10489
|
+
view: attrs.view,
|
|
10458
10490
|
routeMarker: attrs.routeMarker,
|
|
10459
10491
|
travelMode: attrs.travelMode,
|
|
10460
10492
|
routeColor: attrs.routeColor,
|
|
@@ -10462,6 +10494,9 @@ function parseNodeLine(content3, lineNum) {
|
|
|
10462
10494
|
zoom: attrs.zoom,
|
|
10463
10495
|
center: attrs.center,
|
|
10464
10496
|
mapType: attrs.mapType,
|
|
10497
|
+
camera: attrs.camera,
|
|
10498
|
+
cinematic: attrs.cinematic,
|
|
10499
|
+
streetView: attrs.streetView,
|
|
10465
10500
|
language: attrs.language ?? attrs.lang,
|
|
10466
10501
|
region: attrs.region,
|
|
10467
10502
|
instruction: attrs.instruction,
|
|
@@ -10764,7 +10799,22 @@ function probeDuration(src, baseDir) {
|
|
|
10764
10799
|
}
|
|
10765
10800
|
function resolveSrc(src, baseDir) {
|
|
10766
10801
|
if (/^(https?:|file:|\/)/.test(src)) return src;
|
|
10767
|
-
return resolvePath(baseDir
|
|
10802
|
+
return resolvePath(baseDir, src);
|
|
10803
|
+
}
|
|
10804
|
+
function relativizeAssetsUnder(node2, baseDir) {
|
|
10805
|
+
if (!node2 || typeof node2 !== "object") return node2;
|
|
10806
|
+
if (Array.isArray(node2)) return node2.map((v) => relativizeAssetsUnder(v, baseDir));
|
|
10807
|
+
const out = {};
|
|
10808
|
+
for (const [k, v] of Object.entries(node2)) {
|
|
10809
|
+
if (typeof v === "string" && v.startsWith(baseDir + "/")) {
|
|
10810
|
+
out[k] = v.slice(baseDir.length + 1);
|
|
10811
|
+
} else if (v && typeof v === "object") {
|
|
10812
|
+
out[k] = relativizeAssetsUnder(v, baseDir);
|
|
10813
|
+
} else {
|
|
10814
|
+
out[k] = v;
|
|
10815
|
+
}
|
|
10816
|
+
}
|
|
10817
|
+
return out;
|
|
10768
10818
|
}
|
|
10769
10819
|
var COMMON_RESOLUTIONS = [
|
|
10770
10820
|
{ width: 1920, height: 1080 },
|
|
@@ -10809,7 +10859,7 @@ function resolveMediaSrc(src, targetWidth, targetHeight, baseDir) {
|
|
|
10809
10859
|
console.warn(` \u26A0 No matching media file for "${src}" at ${targetWidth}x${targetHeight}`);
|
|
10810
10860
|
return exactAbs;
|
|
10811
10861
|
}
|
|
10812
|
-
async function resolveMediaSrcs(root, options
|
|
10862
|
+
async function resolveMediaSrcs(root, options) {
|
|
10813
10863
|
const clone = JSON.parse(JSON.stringify(root));
|
|
10814
10864
|
const baseDir = options.baseDir;
|
|
10815
10865
|
const targetWidth = clone.width ?? 1080;
|
|
@@ -10830,7 +10880,7 @@ async function resolveMediaSrcs(root, options = {}) {
|
|
|
10830
10880
|
});
|
|
10831
10881
|
return clone;
|
|
10832
10882
|
}
|
|
10833
|
-
async function resolveMediaDurations(root, options
|
|
10883
|
+
async function resolveMediaDurations(root, options) {
|
|
10834
10884
|
const clone = JSON.parse(JSON.stringify(root));
|
|
10835
10885
|
const baseDir = options.baseDir;
|
|
10836
10886
|
walkDown(clone, (node2) => {
|
|
@@ -11242,9 +11292,9 @@ function applyStoryboardOverrides(root, options) {
|
|
|
11242
11292
|
}
|
|
11243
11293
|
return clone;
|
|
11244
11294
|
}
|
|
11245
|
-
async function resolveIncludes(root, options
|
|
11295
|
+
async function resolveIncludes(root, options) {
|
|
11246
11296
|
const clone = JSON.parse(JSON.stringify(root));
|
|
11247
|
-
const baseDir = options.baseDir
|
|
11297
|
+
const baseDir = options.baseDir;
|
|
11248
11298
|
const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
|
|
11249
11299
|
mkdirSync2(outputDir, { recursive: true });
|
|
11250
11300
|
function extractImportEntriesFromRaw(raw) {
|
|
@@ -11323,7 +11373,7 @@ async function resolveIncludes(root, options = {}) {
|
|
|
11323
11373
|
}
|
|
11324
11374
|
return clone;
|
|
11325
11375
|
}
|
|
11326
|
-
async function resolveAll2(root, options
|
|
11376
|
+
async function resolveAll2(root, options) {
|
|
11327
11377
|
let result = root;
|
|
11328
11378
|
if (result.seed == null && options.seed == null && options.sourcePath) {
|
|
11329
11379
|
const autoSeed = parseInt(computeCacheKey(result).slice(0, 8), 16);
|
|
@@ -11391,6 +11441,7 @@ async function resolveAll2(root, options = {}) {
|
|
|
11391
11441
|
mergedOutputDir: options.subtitleOutputDir
|
|
11392
11442
|
});
|
|
11393
11443
|
}
|
|
11444
|
+
result = relativizeAssetsUnder(result, options.baseDir);
|
|
11394
11445
|
return result;
|
|
11395
11446
|
}
|
|
11396
11447
|
|
|
@@ -11408,7 +11459,7 @@ function isDescriptiveRoot(data) {
|
|
|
11408
11459
|
)) return true;
|
|
11409
11460
|
return false;
|
|
11410
11461
|
}
|
|
11411
|
-
async function resolveAndCompile(data, options
|
|
11462
|
+
async function resolveAndCompile(data, options) {
|
|
11412
11463
|
const resolved = await resolveAll2(data, {
|
|
11413
11464
|
sourcePath: options.sourcePath,
|
|
11414
11465
|
baseDir: options.baseDir,
|
|
@@ -11427,7 +11478,7 @@ async function resolveAndCompile(data, options = {}) {
|
|
|
11427
11478
|
});
|
|
11428
11479
|
return compiled;
|
|
11429
11480
|
}
|
|
11430
|
-
async function resolveAndCompileMarkdown(markdown, options
|
|
11481
|
+
async function resolveAndCompileMarkdown(markdown, options) {
|
|
11431
11482
|
const descriptive = parseMarkdownDescriptive(markdown);
|
|
11432
11483
|
return resolveAndCompile(descriptive, options);
|
|
11433
11484
|
}
|
package/src/player/pipeline.ts
CHANGED
|
@@ -21,8 +21,9 @@ import type { DescriptiveRoot } from "../descriptive/compiler";
|
|
|
21
21
|
import type { Root } from "../schema/index";
|
|
22
22
|
|
|
23
23
|
export interface ResolveAndCompileOptions {
|
|
24
|
-
/** Base directory for resolving
|
|
25
|
-
|
|
24
|
+
/** Base directory (the source file's folder) for resolving/emitting asset paths.
|
|
25
|
+
* Always set by the CLI/server; no fallback. */
|
|
26
|
+
baseDir: string;
|
|
26
27
|
/** Output directory for generated TTS audio / STT VTT files */
|
|
27
28
|
scriptOutputDir?: string;
|
|
28
29
|
/** Output directory for generated TTI/TTV media files */
|
|
@@ -88,7 +89,7 @@ export function isDescriptiveRoot(data: any): boolean {
|
|
|
88
89
|
*/
|
|
89
90
|
export async function resolveAndCompile(
|
|
90
91
|
data: DescriptiveRoot,
|
|
91
|
-
options: ResolveAndCompileOptions
|
|
92
|
+
options: ResolveAndCompileOptions,
|
|
92
93
|
): Promise<Root> {
|
|
93
94
|
// 1. Async resolve: durations, TTS, STT, includes
|
|
94
95
|
const resolved = await resolveAll(data, {
|
|
@@ -121,7 +122,7 @@ export async function resolveAndCompile(
|
|
|
121
122
|
*/
|
|
122
123
|
export async function resolveAndCompileMarkdown(
|
|
123
124
|
markdown: string,
|
|
124
|
-
options: ResolveAndCompileOptions
|
|
125
|
+
options: ResolveAndCompileOptions,
|
|
125
126
|
): Promise<Root> {
|
|
126
127
|
const descriptive = parseMarkdownDescriptive(markdown);
|
|
127
128
|
return resolveAndCompile(descriptive, options);
|
package/src/player/server.mjs
CHANGED
|
@@ -326,59 +326,39 @@ function makePathsRelative(obj) {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
/**
|
|
329
|
-
* Convert
|
|
330
|
-
*
|
|
331
|
-
*
|
|
329
|
+
* Convert asset paths in the compiled root to server-relative URLs for the
|
|
330
|
+
* browser. The compiled JSON now carries md-folder-relative paths (e.g.
|
|
331
|
+
* ".markcut/generated/tts/x.mp3" or "assets/photo.jpg") because the resolve
|
|
332
|
+
* step emits them relative to the source .md folder — the server document
|
|
333
|
+
* root. This prepends "/" so the browser can fetch them (resolveAsset() then
|
|
334
|
+
* maps the URL back onto the md folder).
|
|
332
335
|
*
|
|
333
|
-
*
|
|
334
|
-
* - compiled.imports (component bundle URL)
|
|
335
|
-
* - compiled.subtitle.src (VTT file)
|
|
336
|
-
* - Any node.src (media/tts/images) that is a relative path under MARKCUT_BASE
|
|
336
|
+
* Also handles legacy absolute paths under MARKCUT_BASE (older cached trees).
|
|
337
337
|
*/
|
|
338
338
|
function resolveAssetPaths(root) {
|
|
339
339
|
const out = JSON.parse(JSON.stringify(root));
|
|
340
340
|
|
|
341
|
+
function toServerUrl(p) {
|
|
342
|
+
if (typeof p !== "string") return p;
|
|
343
|
+
if (/^(https?:|data:|blob:)/.test(p)) return p;
|
|
344
|
+
if (p.startsWith("/")) return p; // already a server URL
|
|
345
|
+
// Legacy: absolute path under the .markcut base (older cached trees)
|
|
346
|
+
if (p.startsWith(MARKCUT_BASE)) return p.slice(MARKCUT_BASE.length);
|
|
347
|
+
// md-folder-relative path (new scheme): ".markcut/...", "assets/..."
|
|
348
|
+
const rel = p.startsWith("./") ? p.slice(2) : p;
|
|
349
|
+
return "/" + rel;
|
|
350
|
+
}
|
|
351
|
+
|
|
341
352
|
function walkNode(node) {
|
|
342
353
|
if (!node || typeof node !== "object") return;
|
|
343
354
|
if (Array.isArray(node)) { node.forEach(walkNode); return; }
|
|
344
355
|
|
|
345
|
-
|
|
346
|
-
if (typeof node.src === "string" && !node.src.startsWith("http://") && !node.src.startsWith("https://") && !node.src.startsWith("data:")) {
|
|
347
|
-
// Already a relative path under MARKCUT_BASE (shouldn't be absolute)
|
|
348
|
-
if (!node.src.startsWith("/") && !node.src.startsWith(".")) {
|
|
349
|
-
// Plain relative path — prefix with / (server serves from project root)
|
|
350
|
-
node.src = "/" + node.src;
|
|
351
|
-
} else if (node.src.startsWith("./")) {
|
|
352
|
-
// Dot-prefixed relative path — strip ./ and prefix with /
|
|
353
|
-
node.src = "/" + node.src.slice(2);
|
|
354
|
-
}
|
|
355
|
-
// Convert absolute paths under MARKCUT_BASE
|
|
356
|
-
if (typeof node.src === "string" && node.src.startsWith(MARKCUT_BASE)) {
|
|
357
|
-
node.src = "/" + node.src.replace(MARKCUT_BASE + "/", "");
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Convert subtitle src
|
|
356
|
+
if (typeof node.src === "string") node.src = toServerUrl(node.src);
|
|
362
357
|
if (node.subtitle && typeof node.subtitle.src === "string") {
|
|
363
|
-
|
|
364
|
-
node.subtitle.src = "/" + node.subtitle.src.replace(MARKCUT_BASE + "/", "");
|
|
365
|
-
} else if (!node.subtitle.src.startsWith("/") && !node.subtitle.src.startsWith("http")) {
|
|
366
|
-
node.subtitle.src = "/" + node.subtitle.src;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Convert imports (component bundle URL)
|
|
371
|
-
if (typeof node.imports === "string") {
|
|
372
|
-
if (node.imports.startsWith(MARKCUT_BASE)) {
|
|
373
|
-
node.imports = "/" + node.imports.replace(MARKCUT_BASE + "/", "");
|
|
374
|
-
} else if (!node.imports.startsWith("/") && !node.imports.startsWith("http")) {
|
|
375
|
-
node.imports = "/" + node.imports;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (Array.isArray(node.children)) {
|
|
380
|
-
node.children.forEach(walkNode);
|
|
358
|
+
node.subtitle.src = toServerUrl(node.subtitle.src);
|
|
381
359
|
}
|
|
360
|
+
if (typeof node.imports === "string") node.imports = toServerUrl(node.imports);
|
|
361
|
+
if (Array.isArray(node.children)) node.children.forEach(walkNode);
|
|
382
362
|
}
|
|
383
363
|
|
|
384
364
|
walkNode(out);
|
package/src/render/cli.mjs
CHANGED
|
@@ -80,6 +80,12 @@ Commands:
|
|
|
80
80
|
vision <folder> Full pipeline: extract → normalize → percept → segments
|
|
81
81
|
--label Add interactive labeling step before AI pipeline
|
|
82
82
|
--instruct "text" Background context about people/places (injected into prompts)
|
|
83
|
+
|
|
84
|
+
spots --waypoints "lat,lng;..." Discover POIs along a route (Directions + Places API)
|
|
85
|
+
--travelMode DRIVING DRIVING | WALKING | BICYCLING (default DRIVING)
|
|
86
|
+
--limit 8 Max spots after ranking
|
|
87
|
+
--photos Attach a static-map thumbnail per spot
|
|
88
|
+
--markdown Print waypoints:[...] markdown to stderr
|
|
83
89
|
--prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
|
|
84
90
|
--vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
|
|
85
91
|
--skip-stt Skip speech-to-text for videos
|
|
@@ -90,6 +96,7 @@ Commands:
|
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
|
|
99
|
+
/**
|
|
93
100
|
/**
|
|
94
101
|
* Render a stream tree to an MP4 video with compact progress output.
|
|
95
102
|
*
|
|
@@ -100,7 +107,7 @@ Commands:
|
|
|
100
107
|
* Use --verbose to see every frame line (original behavior).
|
|
101
108
|
*/
|
|
102
109
|
|
|
103
|
-
function renderOne(streamTree, outputPath, verbose) {
|
|
110
|
+
function renderOne(streamTree, outputPath, verbose, publicDir) {
|
|
104
111
|
const tmpProps = join(ROOT, ".tmp", "render-stream.json");
|
|
105
112
|
mkdirSync(dirname(tmpProps), { recursive: true });
|
|
106
113
|
writeFileSync(tmpProps, JSON.stringify({ root: streamTree }));
|
|
@@ -116,7 +123,10 @@ console.log(`\n▶ Rendering → ${outputPath}`);
|
|
|
116
123
|
if (args.dev) {
|
|
117
124
|
spawnOpts.env = { ...process.env, NODE_ENV: "development" };
|
|
118
125
|
}
|
|
119
|
-
|
|
126
|
+
// Serve local media (TTS, subtitles, images) from the .markcut base dir.
|
|
127
|
+
const renderArgs = ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"];
|
|
128
|
+
if (publicDir) renderArgs.push("--public-dir", publicDir);
|
|
129
|
+
const proc = spawn("npx", renderArgs, spawnOpts);
|
|
120
130
|
|
|
121
131
|
let lastLoggedFrame = 0;
|
|
122
132
|
let totalFrames = 0;
|
|
@@ -213,6 +223,11 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
213
223
|
process.exit(0);
|
|
214
224
|
}
|
|
215
225
|
|
|
226
|
+
if (args.command === "spots") {
|
|
227
|
+
await import("../spots/cli.mjs"); // self-executing top-level script
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
|
|
216
231
|
if (args.command === "preview") {
|
|
217
232
|
// --storyboard implies --edit: show story structure fast (prompts as
|
|
218
233
|
// placeholder components) and let user chat to reshape before generation.
|
|
@@ -276,6 +291,7 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
276
291
|
if (args.command === "render") {
|
|
277
292
|
let streamTree;
|
|
278
293
|
let rawInput = "";
|
|
294
|
+
let renderPublicDir; // .markcut base dir → Remotion publicDir for local media
|
|
279
295
|
|
|
280
296
|
if (args.file) {
|
|
281
297
|
const filePath = resolve(args.file);
|
|
@@ -334,6 +350,10 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
334
350
|
includeOutputDir: generatedDir(filePath, "includes"),
|
|
335
351
|
subtitleOutputDir: variantDir(filePath),
|
|
336
352
|
});
|
|
353
|
+
// Serve the source .md folder as Remotion's public dir (the "default
|
|
354
|
+
// root"): every asset in the JSON is md-folder-relative, so paths like
|
|
355
|
+
// .markcut/generated/... and assets/... resolve via staticFile.
|
|
356
|
+
renderPublicDir = fileDir;
|
|
337
357
|
} else {
|
|
338
358
|
const parsed = JSON.parse(raw);
|
|
339
359
|
const root = parsed.root ?? parsed;
|
|
@@ -348,6 +368,7 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
348
368
|
includeOutputDir: generatedDir(filePath, "includes"),
|
|
349
369
|
subtitleOutputDir: variantDir(filePath),
|
|
350
370
|
});
|
|
371
|
+
renderPublicDir = fileDir;
|
|
351
372
|
} else {
|
|
352
373
|
streamTree = root;
|
|
353
374
|
}
|
|
@@ -378,8 +399,24 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
378
399
|
}
|
|
379
400
|
}
|
|
380
401
|
|
|
402
|
+
// Guard: every local asset must be relative to the source folder (the
|
|
403
|
+
// render publicDir). Absolute paths or ".." escapes would 404 in Remotion
|
|
404
|
+
// (staticFile serves --public-dir), so fail fast with actionable errors.
|
|
405
|
+
if (renderPublicDir) {
|
|
406
|
+
const { validateAssetsRelative } = await import("./validate-assets.mjs");
|
|
407
|
+
const assetErrors = validateAssetsRelative(streamTree, renderPublicDir);
|
|
408
|
+
if (assetErrors.length > 0) {
|
|
409
|
+
for (const e of assetErrors) emitError(e);
|
|
410
|
+
emitError(
|
|
411
|
+
`Aborting render: ${assetErrors.length} asset(s) not relative to baseDir ` +
|
|
412
|
+
`(${renderPublicDir}). Fix the source or re-run resolve, then retry.`
|
|
413
|
+
);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
381
418
|
const output = args.output ? resolve(args.output) : join(ROOT, "out", "video.mp4");
|
|
382
|
-
await renderOne(streamTree, output, args.verbose);
|
|
419
|
+
await renderOne(streamTree, output, args.verbose, renderPublicDir);
|
|
383
420
|
|
|
384
421
|
console.log("\n✅ Render complete.");
|
|
385
422
|
process.exit(0);
|
|
@@ -487,6 +524,20 @@ function hasScript(root) {
|
|
|
487
524
|
}
|
|
488
525
|
walkMedia(descriptive.children);
|
|
489
526
|
|
|
527
|
+
// ── Check: all assets relative to baseDir ───────────────────────────
|
|
528
|
+
// Every local asset must be a path relative to the source file's folder
|
|
529
|
+
// (the render publicDir). Absolute paths, "/..." and ".." escapes would
|
|
530
|
+
// 404 in render / break media in preview.
|
|
531
|
+
{
|
|
532
|
+
const { validateAssetsRelative } = await import("./validate-assets.mjs");
|
|
533
|
+
const baseDir = dirname(filePath);
|
|
534
|
+
// Descriptive trees: include.src may point outside baseDir legitimately
|
|
535
|
+
// (it is compiled against its own baseDir, then relativized).
|
|
536
|
+
for (const e of validateAssetsRelative(descriptive, baseDir, { skipIncludeSrc: true })) {
|
|
537
|
+
errors.push(e);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
490
541
|
// ── Results ─────────────────────────────────────────────────────────
|
|
491
542
|
for (const w of warnings) emitWarn(w);
|
|
492
543
|
if (errors.length > 0) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset-relative validation for stream trees.
|
|
3
|
+
*
|
|
4
|
+
* Render contract (2026-08-10): every local asset in the compiled tree is a
|
|
5
|
+
* path RELATIVE to the source folder — the source .md file's folder, the
|
|
6
|
+
* "default root". Remotion serves that folder via `--public-dir` (render) and
|
|
7
|
+
* the player serves it as the document root (preview). Absolute filesystem
|
|
8
|
+
* paths, paths starting with "/", or `..` escapes all break staticFile()
|
|
9
|
+
* resolution → 404 in render / broken media in preview.
|
|
10
|
+
*
|
|
11
|
+
* validateAssetsRelative(tree, baseDir) → string[] (empty = all OK)
|
|
12
|
+
*
|
|
13
|
+
* Used by:
|
|
14
|
+
* - `markcut verify` — walks the parsed descriptive tree (skips include.src,
|
|
15
|
+
* which legitimately resolves against its own baseDir)
|
|
16
|
+
* - `markcut render` — guards the compiled tree right before rendering
|
|
17
|
+
*/
|
|
18
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
const REMOTE_RE = /^(https?:|data:|blob:|file:)/i;
|
|
21
|
+
/** Subtitle `src` is only a file when it points at a .vtt — otherwise it is inline caption text. */
|
|
22
|
+
const VTT_RE = /\.vtt(?:$|[?#])/i;
|
|
23
|
+
const INLINE_VTT_RE = /-->/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Classify a single asset reference:
|
|
27
|
+
* "remote" — http(s)/data/blob/file URI, loaded as-is (OK)
|
|
28
|
+
* "root-absolute" — starts with "/", rooted at the serve root, NOT source-folder-relative (ERROR)
|
|
29
|
+
* "absolute" — absolute filesystem path (ERROR)
|
|
30
|
+
* "escapes" — relative but starts with ".." (ERROR)
|
|
31
|
+
* "relative" — looks safe; callers should re-check against baseDir for nested ".."
|
|
32
|
+
* "text" — not a path at all (inline subtitle text / inline VTT body)
|
|
33
|
+
* "empty" — falsy
|
|
34
|
+
*/
|
|
35
|
+
export function classifyAssetPath(src, { subtitle = false } = {}) {
|
|
36
|
+
if (!src) return "empty";
|
|
37
|
+
if (subtitle && (INLINE_VTT_RE.test(src) || !VTT_RE.test(src))) return "text";
|
|
38
|
+
if (REMOTE_RE.test(src)) return "remote";
|
|
39
|
+
if (src.startsWith("/")) return "root-absolute";
|
|
40
|
+
if (isAbsolute(src)) return "absolute";
|
|
41
|
+
if (src.startsWith("..")) return "escapes";
|
|
42
|
+
return "relative";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectNodeAssets(node, path, out, skipIncludeSrc) {
|
|
46
|
+
if (!node || typeof node !== "object") return;
|
|
47
|
+
|
|
48
|
+
if (node.type === "include") {
|
|
49
|
+
// include.src may legitimately point outside baseDir in *descriptive* trees
|
|
50
|
+
// (it is compiled against its own baseDir, then relativized to the outer
|
|
51
|
+
// baseDir). Callers that validate descriptive trees pass skipIncludeSrc.
|
|
52
|
+
if (!skipIncludeSrc && typeof node.src === "string" && node.src) {
|
|
53
|
+
out.push({ node, field: "src", value: node.src, path, subtitle: false });
|
|
54
|
+
}
|
|
55
|
+
// NOTE: include.imports is a module bundle URL (dynamic import), not a
|
|
56
|
+
// staticFile asset — intentionally not validated.
|
|
57
|
+
} else {
|
|
58
|
+
const isSubtitle = node.type === "subtitle";
|
|
59
|
+
if (typeof node.src === "string" && node.src) {
|
|
60
|
+
out.push({ node, field: "src", value: node.src, path, subtitle: isSubtitle });
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(node.waypoints)) {
|
|
63
|
+
node.waypoints.forEach((wp, i) => {
|
|
64
|
+
if (wp && typeof wp.media === "string" && wp.media) {
|
|
65
|
+
out.push({ node, field: `waypoints[${i}].media`, value: wp.media, path, subtitle: false });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Array.isArray(node.children)) {
|
|
72
|
+
node.children.forEach((c, i) => collectNodeAssets(c, `${path}.children[${i}]`, out, skipIncludeSrc));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Collect every asset reference in a tree.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} tree - root node (may carry root.subtitle) or plain node
|
|
80
|
+
* @param {{skipIncludeSrc?: boolean}} [opts]
|
|
81
|
+
* @returns {Array<{node: object, field: string, value: string, path: string, subtitle: boolean}>}
|
|
82
|
+
*/
|
|
83
|
+
export function collectTreeAssets(tree, { skipIncludeSrc = false } = {}) {
|
|
84
|
+
const out = [];
|
|
85
|
+
if (tree && typeof tree.subtitle === "object" && tree.subtitle &&
|
|
86
|
+
typeof tree.subtitle.src === "string" && tree.subtitle.src) {
|
|
87
|
+
out.push({ node: tree, field: "subtitle.src", value: tree.subtitle.src, path: "root", subtitle: true });
|
|
88
|
+
}
|
|
89
|
+
if (tree && Array.isArray(tree.children)) {
|
|
90
|
+
tree.children.forEach((c, i) => collectNodeAssets(c, `root.children[${i}]`, out, skipIncludeSrc));
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatError(ref, problem, baseDir) {
|
|
96
|
+
const where = ref.node?.id
|
|
97
|
+
? `node "${ref.node.id}"`
|
|
98
|
+
: ref.node?.name
|
|
99
|
+
? `node "${ref.node.name}"`
|
|
100
|
+
: ref.path;
|
|
101
|
+
const type = ref.node?.type ? ` (type: ${ref.node.type})` : "";
|
|
102
|
+
return [
|
|
103
|
+
`Asset not relative to baseDir: ${where}${type} — field ${ref.field} = "${ref.value}"`,
|
|
104
|
+
` ${problem}. Local assets must be relative to the source folder: ${baseDir}`,
|
|
105
|
+
` (e.g. assets/x.png or .markcut/generated/...) — absolute paths and ".." escapes 404 in render`,
|
|
106
|
+
` because Remotion serves media from --public-dir = the source folder.`,
|
|
107
|
+
` Fix: re-run resolve/render so the path is emitted source-folder-relative, or move the file under the source folder.`,
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Walk a tree and return actionable errors for every asset that is not
|
|
113
|
+
* relative to `baseDir`. Empty array = all assets are baseDir-relative.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} tree - root node of the (descriptive or compiled) tree
|
|
116
|
+
* @param {string} baseDir - source folder (md file's folder) assets must stay inside
|
|
117
|
+
* @param {{skipIncludeSrc?: boolean}} [opts] - pass skipIncludeSrc:true for descriptive trees
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
export function validateAssetsRelative(tree, baseDir, opts = {}) {
|
|
121
|
+
const errors = [];
|
|
122
|
+
for (const ref of collectTreeAssets(tree, opts)) {
|
|
123
|
+
const kind = classifyAssetPath(ref.value, { subtitle: ref.subtitle });
|
|
124
|
+
let problem = null;
|
|
125
|
+
if (kind === "root-absolute") {
|
|
126
|
+
problem = `It starts with "/" — that is rooted at the serve root, not the source folder`;
|
|
127
|
+
} else if (kind === "absolute") {
|
|
128
|
+
problem = "It is an absolute filesystem path";
|
|
129
|
+
} else if (kind === "escapes") {
|
|
130
|
+
problem = 'It escapes the source folder via ".."';
|
|
131
|
+
} else if (kind === "relative") {
|
|
132
|
+
// Nested ".." like "a/../../x" isn't caught by the prefix check.
|
|
133
|
+
if (relative(baseDir, resolve(baseDir, ref.value)).startsWith("..")) {
|
|
134
|
+
problem = 'It escapes the source folder via ".."';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (problem) errors.push(formatError(ref, problem, baseDir));
|
|
138
|
+
}
|
|
139
|
+
return errors;
|
|
140
|
+
}
|
package/src/schema/index.ts
CHANGED
|
@@ -212,7 +212,7 @@ export const scene = base.extend({
|
|
|
212
212
|
export type Scene = z.infer<typeof scene>;
|
|
213
213
|
|
|
214
214
|
// ---------------------------------------------------------------------------
|
|
215
|
-
// Map — animated route visualization
|
|
215
|
+
// Map — animated route visualization with dynamic camera views
|
|
216
216
|
// ---------------------------------------------------------------------------
|
|
217
217
|
export const mapWaypoint = z.object({
|
|
218
218
|
lat: z.number(),
|
|
@@ -221,8 +221,60 @@ export const mapWaypoint = z.object({
|
|
|
221
221
|
media: z.string().optional().describe("image/video src for waypoint marker"),
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A tween expression: `tween(from, to, easing?)` → `{__tween:[from,to,easing?]}`,
|
|
226
|
+
* parsed by the descriptive DSL (see parseProps in dsl.ts).
|
|
227
|
+
*/
|
|
228
|
+
export const tweenSpec = z.object({
|
|
229
|
+
__tween: z.array(z.union([z.number(), z.string()])),
|
|
230
|
+
});
|
|
231
|
+
export type TweenSpec = z.infer<typeof tweenSpec>;
|
|
232
|
+
|
|
233
|
+
/** A static number OR an animated tween spec. */
|
|
234
|
+
export const tweenableNumber = z.union([z.number(), tweenSpec]);
|
|
235
|
+
export type TweenableNumber = z.infer<typeof tweenableNumber>;
|
|
236
|
+
|
|
237
|
+
/** Generic camera tween shared by overview/route/cinematic-lite. */
|
|
238
|
+
export const mapCamera = z.object({
|
|
239
|
+
zoom: tweenableNumber.optional(),
|
|
240
|
+
center: z.object({ lat: tweenableNumber, lng: tweenableNumber }).optional(),
|
|
241
|
+
heading: tweenableNumber.optional(),
|
|
242
|
+
tilt: tweenableNumber.optional(),
|
|
243
|
+
});
|
|
244
|
+
export type MapCamera = z.infer<typeof mapCamera>;
|
|
245
|
+
|
|
246
|
+
/** Cinematic camera behavior (chase/tilt/flyTo/orbit, 3D flyover). All fields
|
|
247
|
+
* optional — the renderer applies defaults (mode flyAlong, tilt 45, etc.). */
|
|
248
|
+
export const mapCinematic = z.object({
|
|
249
|
+
mode: z.enum(["flyAlong", "flyTo", "orbit"]).optional().describe("camera move; default flyAlong"),
|
|
250
|
+
followRoute: z.boolean().optional().describe("center follows the animated marker; default true"),
|
|
251
|
+
headingFollow: z.boolean().optional().describe("heading = route bearing (forward is up); default true"),
|
|
252
|
+
tilt: tweenableNumber.optional().describe("0 = top-down reveal, 45 = chase; tween(0,45) = tilt reveal; default 45"),
|
|
253
|
+
range: tweenableNumber.optional().describe("3D camera distance (m); tween(8000,300) = fly-to"),
|
|
254
|
+
altitude: z.number().optional().describe("3D center altitude (m)"),
|
|
255
|
+
roll: tweenableNumber.optional().describe("3D bank (deg); default 0"),
|
|
256
|
+
fallback: z.enum(["2d", "none"]).optional().describe("'2d' renders safe 2D chase; 'none' opts into experimental Map3D; default '2d'"),
|
|
257
|
+
});
|
|
258
|
+
export type MapCinematic = z.infer<typeof mapCinematic>;
|
|
259
|
+
|
|
260
|
+
/** Immersive Street View config with POV/position animation. */
|
|
261
|
+
export const mapStreetView = z.object({
|
|
262
|
+
pano: z.string().optional().describe("explicit panorama id (deterministic)"),
|
|
263
|
+
location: z.object({ lat: z.number(), lng: z.number() }).optional().describe("else nearest-pano search"),
|
|
264
|
+
route: z.array(z.object({ lat: z.number(), lng: z.number() })).optional().describe("walk/drive path (nearest pano per stop)"),
|
|
265
|
+
radius: z.number().optional().describe("nearest-pano search radius (m); default 50"),
|
|
266
|
+
source: z.enum(["default", "outdoor", "indoor"]).optional().describe("default 'default'"),
|
|
267
|
+
pov: z.object({
|
|
268
|
+
heading: tweenableNumber.optional().describe("tween(200,320) = pan"),
|
|
269
|
+
pitch: tweenableNumber.optional().describe("tween(0,-10) = tilt sweep"),
|
|
270
|
+
}).optional(),
|
|
271
|
+
zoom: tweenableNumber.optional().describe("street view field-of-view zoom; tween(0,1) = dolly-zoom feel"),
|
|
272
|
+
});
|
|
273
|
+
export type MapStreetView = z.infer<typeof mapStreetView>;
|
|
274
|
+
|
|
224
275
|
export const mapStream = base.extend({
|
|
225
276
|
type: z.literal("map").default("map"),
|
|
277
|
+
view: z.enum(["overview", "route", "cinematic", "streetview"]).default("route").describe("camera experience: static/dolly overview, animated route, cinematic flyover, immersive street view"),
|
|
226
278
|
waypoints: z.array(mapWaypoint).default(() => []),
|
|
227
279
|
routeColor: z.string().default("#4285F4"),
|
|
228
280
|
routeWeight: z.number().default(4),
|
|
@@ -233,6 +285,9 @@ export const mapStream = base.extend({
|
|
|
233
285
|
region: z.string().optional().describe("Google Maps region code, e.g. CN"),
|
|
234
286
|
travelMode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]).default("DRIVING").describe("Directions API travel mode"),
|
|
235
287
|
routeMarker: z.string().default("🚗").describe("emoji/character for the animated traveling marker"),
|
|
288
|
+
camera: mapCamera.optional().describe("generic camera tween (dolly/pan/tilt)"),
|
|
289
|
+
cinematic: mapCinematic.optional().describe("cinematic camera behavior"),
|
|
290
|
+
streetView: mapStreetView.optional().describe("immersive street view config"),
|
|
236
291
|
googleMapsApiKey: z.string().optional().describe("injected by compiler from GOOGLE_MAPS_API_KEY env var"),
|
|
237
292
|
});
|
|
238
293
|
export type MapStream = z.infer<typeof mapStream>;
|