@lalalic/markcut 3.1.0 → 3.2.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 +5 -1
- package/skills/markcut/SKILL.md +14 -17
- package/skills/markcut/docs/map-dynamic-camera.md +92 -8
- package/skills/markcut/docs/markdown-descriptive.md +1 -1
- package/skills/markcut/review.md +480 -0
- package/src/descriptive/compiler.ts +60 -1
- package/src/descriptive/dsl.ts +27 -7
- package/src/descriptive/markdown.ts +2 -1
- package/src/descriptive/resolve.test.ts +98 -0
- package/src/descriptive/resolve.ts +156 -12
- package/src/player/bundle/player.js +222961 -222169
- package/src/player/pipeline.mjs +255 -17
- package/src/schema/index.ts +4 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +649 -75
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/tests/dsl.test.ts +35 -0
- package/tests/evals/README.md +41 -0
- package/tests/evals/dataset.json +170 -0
- package/tests/evals/gen_dataset.py +63 -0
- package/tests/evals/metrics.py +70 -0
- package/tests/evals/openrouter_model.py +143 -0
- package/tests/evals/storyboard_app.py +55 -0
- package/tests/evals/test_storyboard.py +32 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/map-all-views.md +8 -1
- package/tests/fixtures/md/map-children.md +11 -0
- package/tests/fixtures/md/map-multimode.md +9 -0
- package/tests/fixtures/streetview-walk.json +36 -0
- package/tests/md-descriptive.test.ts +75 -0
- package/tests/render.test.ts +92 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +18 -0
- package/.vscode/settings.json +0 -3
|
@@ -962,3 +962,101 @@ describe("resolveIncludes — variant overrides", () => {
|
|
|
962
962
|
expect(incNode.durationInSeconds).toBe(2);
|
|
963
963
|
});
|
|
964
964
|
});
|
|
965
|
+
|
|
966
|
+
// ── resolveRouteStops (map overlay auto-timing) ─────────────────────────────
|
|
967
|
+
|
|
968
|
+
describe("resolveRouteStops", () => {
|
|
969
|
+
it("auto-times map children from synthetic FLIGHT leg durations (no network)", async () => {
|
|
970
|
+
const { resolveRouteStops } = await import("./resolve");
|
|
971
|
+
const root: any = {
|
|
972
|
+
type: "root",
|
|
973
|
+
children: [
|
|
974
|
+
{
|
|
975
|
+
type: "scene",
|
|
976
|
+
children: [
|
|
977
|
+
{
|
|
978
|
+
type: "map",
|
|
979
|
+
view: "route",
|
|
980
|
+
travelMode: "FLIGHT",
|
|
981
|
+
waypoints: [
|
|
982
|
+
{ lat: 37.7749, lng: -122.4194, label: "SF" },
|
|
983
|
+
{ lat: 34.0522, lng: -118.2437, label: "LA" },
|
|
984
|
+
{ lat: 33.9425, lng: -118.4081, label: "PIER" },
|
|
985
|
+
],
|
|
986
|
+
children: [
|
|
987
|
+
{ type: "image", at: "LA", duration: 4 },
|
|
988
|
+
{ type: "image", at: "PIER", duration: 2 },
|
|
989
|
+
],
|
|
990
|
+
},
|
|
991
|
+
],
|
|
992
|
+
},
|
|
993
|
+
],
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
await resolveRouteStops(root);
|
|
997
|
+
|
|
998
|
+
const map = root.children[0].children[0];
|
|
999
|
+
const [la, pier] = map.children;
|
|
1000
|
+
|
|
1001
|
+
// Map duration = default drive budget (10s) + dwells (4 + 2) = 16s.
|
|
1002
|
+
expect(map.duration).toBeCloseTo(16, 1);
|
|
1003
|
+
|
|
1004
|
+
// First child arrives at LA after leg 1 (SF→LA ≈ 2367s of 2481s total)
|
|
1005
|
+
// scaled into the 10s drive budget → ≈ 9.5s, dwells 4s.
|
|
1006
|
+
expect(la.start).toBeGreaterThan(9);
|
|
1007
|
+
expect(la.start).toBeLessThan(10);
|
|
1008
|
+
expect(la.end - la.start).toBeCloseTo(4, 6);
|
|
1009
|
+
|
|
1010
|
+
// Second child arrives at PIER after leg 2 + the LA dwell → ≈ 14s.
|
|
1011
|
+
expect(pier.start).toBeGreaterThan(13.5);
|
|
1012
|
+
expect(pier.start).toBeLessThan(14.5);
|
|
1013
|
+
expect(pier.end - pier.start).toBeCloseTo(2, 6);
|
|
1014
|
+
|
|
1015
|
+
// Children with explicit `start` are left untouched.
|
|
1016
|
+
const explicit: any = {
|
|
1017
|
+
type: "map",
|
|
1018
|
+
view: "route",
|
|
1019
|
+
travelMode: "FLIGHT",
|
|
1020
|
+
waypoints: [
|
|
1021
|
+
{ lat: 37.7749, lng: -122.4194, label: "SF" },
|
|
1022
|
+
{ lat: 34.0522, lng: -118.2437, label: "LA" },
|
|
1023
|
+
],
|
|
1024
|
+
children: [{ type: "image", at: "LA", duration: 4, start: 3, end: 7 }],
|
|
1025
|
+
};
|
|
1026
|
+
const explicitRoot: any = { type: "root", children: [explicit] };
|
|
1027
|
+
await resolveRouteStops(explicitRoot);
|
|
1028
|
+
expect(explicit.children[0].start).toBe(3);
|
|
1029
|
+
expect(explicit.children[0].end).toBe(7);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it("respects an explicit map duration (drives shrink to fit drives+dwells)", async () => {
|
|
1033
|
+
const { resolveRouteStops } = await import("./resolve");
|
|
1034
|
+
const root: any = {
|
|
1035
|
+
type: "root",
|
|
1036
|
+
children: [
|
|
1037
|
+
{
|
|
1038
|
+
type: "map",
|
|
1039
|
+
view: "route",
|
|
1040
|
+
duration: 20,
|
|
1041
|
+
travelMode: "FLIGHT",
|
|
1042
|
+
waypoints: [
|
|
1043
|
+
{ lat: 37.7749, lng: -122.4194, label: "SF" },
|
|
1044
|
+
{ lat: 34.0522, lng: -118.2437, label: "LA" },
|
|
1045
|
+
{ lat: 33.9425, lng: -118.4081, label: "PIER" },
|
|
1046
|
+
],
|
|
1047
|
+
children: [{ type: "image", at: "LA", duration: 4 }],
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
await resolveRouteStops(root);
|
|
1053
|
+
|
|
1054
|
+
const map = root.children[0];
|
|
1055
|
+
expect(map.duration).toBe(20);
|
|
1056
|
+
// Dwell 4s stays; drives get the remaining 16s budget proportionally.
|
|
1057
|
+
const la = map.children[0];
|
|
1058
|
+
expect(la.start).toBeGreaterThan(14);
|
|
1059
|
+
expect(la.start).toBeLessThan(17);
|
|
1060
|
+
expect(la.end - la.start).toBeCloseTo(4, 6);
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
@@ -384,15 +384,20 @@ export async function resolveScripts(
|
|
|
384
384
|
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
385
385
|
mkdirSync(options.outputDir, { recursive: true });
|
|
386
386
|
|
|
387
|
-
// Collect all audio nodes that have script text but no src yet
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
387
|
+
// Collect all audio nodes that have script text but no src yet. Each node
|
|
388
|
+
// carries the nearest ancestor's `tts` override (root.tts by default), so a
|
|
389
|
+
// scene-level `tts` wins over root while other scenes keep root's voice.
|
|
390
|
+
const allScriptNodes: Array<{ node: any; id: string; ttsOverride?: string }> = [];
|
|
391
|
+
const collect = (node: any, inherited?: string): void => {
|
|
392
|
+
const ttsOverride =
|
|
393
|
+
typeof node.tts === "string" && node.tts.length > 0 ? node.tts : inherited;
|
|
394
|
+
if (node.type === "audio" && node.script && typeof node.script === "string" && !node.src) {
|
|
395
|
+
const id = node.id ?? `audio-${allScriptNodes.length}`;
|
|
396
|
+
allScriptNodes.push({ node, id, ttsOverride });
|
|
397
|
+
}
|
|
398
|
+
for (const c of node.children ?? []) collect(c, ttsOverride);
|
|
399
|
+
};
|
|
400
|
+
collect(clone as any);
|
|
396
401
|
|
|
397
402
|
const totalScripts = allScriptNodes.length;
|
|
398
403
|
let scriptsDone = 0;
|
|
@@ -403,10 +408,10 @@ export async function resolveScripts(
|
|
|
403
408
|
console.log(` 🔊 TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
|
|
404
409
|
}
|
|
405
410
|
|
|
406
|
-
for (const { node, id } of allScriptNodes) {
|
|
411
|
+
for (const { node, id, ttsOverride } of allScriptNodes) {
|
|
407
412
|
scriptsDone++;
|
|
408
|
-
// TTS CLI
|
|
409
|
-
let ttsCli =
|
|
413
|
+
// TTS CLI: nearest ancestor `tts` (root.tts by default) → options → default
|
|
414
|
+
let ttsCli = ttsOverride ?? options.ttsCli ?? DEFAULT_TTS_CLI;
|
|
410
415
|
|
|
411
416
|
// Per-speaker voice appends extra CLI flags from root voices config
|
|
412
417
|
if (node.speaker && clone.voices) {
|
|
@@ -1212,6 +1217,11 @@ export async function resolveAll(
|
|
|
1212
1217
|
});
|
|
1213
1218
|
}
|
|
1214
1219
|
|
|
1220
|
+
// Step 6: Auto-time map overlay children (at:"Waypoint") from the route —
|
|
1221
|
+
// arrival + dwell, so the author writes no timing. Only maps with anchored
|
|
1222
|
+
// children lacking explicit start trigger Directions calls.
|
|
1223
|
+
result = await resolveRouteStops(result);
|
|
1224
|
+
|
|
1215
1225
|
// Final step: emit every generated asset path relative to the source .md
|
|
1216
1226
|
// folder so the compiled JSON carries md-folder-relative paths. Render
|
|
1217
1227
|
// serves that folder via --public-dir and the preview server serves it as
|
|
@@ -1221,3 +1231,137 @@ export async function resolveAll(
|
|
|
1221
1231
|
|
|
1222
1232
|
return result;
|
|
1223
1233
|
}
|
|
1234
|
+
|
|
1235
|
+
// ── Route stops (auto-time map overlay children) ─────────────────────────
|
|
1236
|
+
|
|
1237
|
+
/** Default duration for a map child with no explicit duration / audio. */
|
|
1238
|
+
const DEFAULT_CHILD_SECONDS = 3;
|
|
1239
|
+
|
|
1240
|
+
/** Default drive budget (seconds) when the map has no explicit duration —
|
|
1241
|
+
* drives fill this, dwells are added on top. Real Directions durations are
|
|
1242
|
+
* only used for leg ratios. */
|
|
1243
|
+
const DEFAULT_MAP_DRIVE_SECONDS = 10;
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* For every `map` node with children anchored via `at:"WaypointLabel"`, derive
|
|
1247
|
+
* each child's `start`/`end` from the route's travel times:
|
|
1248
|
+
*
|
|
1249
|
+
* - leg durations from Directions REST (road modes) or haversine cruise
|
|
1250
|
+
* speed (FLIGHT/BOAT synthetic) — same math the renderer uses
|
|
1251
|
+
* - the pin holds (dwells) at a waypoint while its children play
|
|
1252
|
+
* - drives are scaled proportionally to leg duration across the remaining
|
|
1253
|
+
* budget, so arrivals match the renderer's `routePositionAtLegs`
|
|
1254
|
+
*
|
|
1255
|
+
* Children that already have an explicit `start` are left untouched (but still
|
|
1256
|
+
* contribute to the pin's pause window). When the map has no explicit duration,
|
|
1257
|
+
* it is set to drives + dwells.
|
|
1258
|
+
*/
|
|
1259
|
+
export async function resolveRouteStops(
|
|
1260
|
+
root: DescriptiveRoot,
|
|
1261
|
+
): Promise<DescriptiveRoot> {
|
|
1262
|
+
const { routeLegTimings } = await import("../utils/directions");
|
|
1263
|
+
|
|
1264
|
+
async function resolveMap(node: any): Promise<void> {
|
|
1265
|
+
if (!node || typeof node !== "object") return;
|
|
1266
|
+
if (Array.isArray(node)) {
|
|
1267
|
+
for (const n of node) await resolveMap(n);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (node.type === "map" && Array.isArray(node.children) && node.children.length) {
|
|
1272
|
+
const children = node.children as any[];
|
|
1273
|
+
const anchored = children.filter((c) => c && c.at);
|
|
1274
|
+
if (anchored.length) {
|
|
1275
|
+
await timeMapChildren(node, anchored, routeLegTimings);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
for (const child of node.children ?? []) await resolveMap(child);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
await resolveMap((root as any).children);
|
|
1283
|
+
return root;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async function timeMapChildren(
|
|
1287
|
+
map: any,
|
|
1288
|
+
anchored: any[],
|
|
1289
|
+
routeLegTimings: (waypoints: any[], defaultMode?: string) => Promise<{ durationSec: number }[]>,
|
|
1290
|
+
): Promise<void> {
|
|
1291
|
+
const waypoints = map.waypoints ?? [];
|
|
1292
|
+
if (waypoints.length < 2) return;
|
|
1293
|
+
|
|
1294
|
+
// 1. Per-leg travel times.
|
|
1295
|
+
const legs = await routeLegTimings(waypoints, map.travelMode ?? "DRIVING");
|
|
1296
|
+
const totalDrive = legs.reduce((s, l) => s + (l.durationSec || 0), 0);
|
|
1297
|
+
if (totalDrive <= 0) return;
|
|
1298
|
+
|
|
1299
|
+
// 2. Per-waypoint dwell = max duration of anchored children there.
|
|
1300
|
+
const childDuration = (c: any): number =>
|
|
1301
|
+
typeof c.duration === "number" && c.duration > 0 ? c.duration : DEFAULT_CHILD_SECONDS;
|
|
1302
|
+
const dwellAt = new Map<string, number>();
|
|
1303
|
+
for (const c of anchored) {
|
|
1304
|
+
const d = childDuration(c);
|
|
1305
|
+
dwellAt.set(c.at, Math.max(dwellAt.get(c.at) ?? 0, d));
|
|
1306
|
+
}
|
|
1307
|
+
const totalDwell = [...dwellAt.values()].reduce((s, d) => s + d, 0);
|
|
1308
|
+
|
|
1309
|
+
// 3. Timeline: drives scaled proportionally across the drive budget; dwells
|
|
1310
|
+
// inserted after each arrival. arrival(i) = S·cumDrive(i)/D + cumDwells(<i).
|
|
1311
|
+
// Real Directions durations only provide RATIOS — the video length is the
|
|
1312
|
+
// author's map `duration` (or a default), so a 40-minute drive still plays
|
|
1313
|
+
// as a short clip with the pin timing proportional to each leg.
|
|
1314
|
+
const hasOwnDur = typeof map.duration === "number" || typeof map.endAt === "number";
|
|
1315
|
+
const totalDwellBudget = Math.max(0, map.duration ?? map.endAt ?? DEFAULT_MAP_DRIVE_SECONDS);
|
|
1316
|
+
const mapDur = hasOwnDur
|
|
1317
|
+
? (map.duration ?? map.endAt ?? totalDwellBudget + totalDwell)
|
|
1318
|
+
: totalDwellBudget + totalDwell;
|
|
1319
|
+
const driveBudget = Math.max(0.1, mapDur - totalDwell);
|
|
1320
|
+
const scale = driveBudget / totalDrive;
|
|
1321
|
+
|
|
1322
|
+
const arrivalAt = new Map<string, number>();
|
|
1323
|
+
// The pin is at waypoint[0] from t=0 — `at` there means "from the start".
|
|
1324
|
+
const wp0 = waypoints[0]!;
|
|
1325
|
+
arrivalAt.set(wp0.label ?? String(wp0.lat) + "," + String(wp0.lng), 0);
|
|
1326
|
+
let cumDrive = 0;
|
|
1327
|
+
let cumDwells = 0;
|
|
1328
|
+
for (let i = 0; i < legs.length; i++) {
|
|
1329
|
+
cumDrive += legs[i]!.durationSec || 0;
|
|
1330
|
+
const wp = waypoints[i + 1]!;
|
|
1331
|
+
const arrival = (cumDrive * scale) + cumDwells;
|
|
1332
|
+
arrivalAt.set(wp.label ?? String(wp.lat) + "," + String(wp.lng), arrival);
|
|
1333
|
+
if (dwellAt.has(wp.label)) cumDwells += dwellAt.get(wp.label)!;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// 4. Stamp children that lack explicit timing.
|
|
1337
|
+
const schedule: string[] = [];
|
|
1338
|
+
for (const c of anchored) {
|
|
1339
|
+
const arrival = arrivalAt.get(c.at);
|
|
1340
|
+
if (arrival == null) {
|
|
1341
|
+
console.warn(` ⚠ map child at:"${c.at}" — no waypoint with that label; renders full-screen`);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (typeof c.start === "number") {
|
|
1345
|
+
schedule.push(` 🛑 ${c.at}: manual ${c.start.toFixed(1)}s (unchanged)`);
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const dur = childDuration(c);
|
|
1349
|
+
c.start = arrival;
|
|
1350
|
+
c.end = arrival + dur;
|
|
1351
|
+
schedule.push(` 🛑 ${c.at}: ${arrival.toFixed(1)}s → ${(arrival + dur).toFixed(1)}s (${dur.toFixed(1)}s)`);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (schedule.length) {
|
|
1355
|
+
console.log(` 🗺 route stops (${mapDur.toFixed(1)}s total):`);
|
|
1356
|
+
for (const line of schedule) console.log(line);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// 5. Extend map duration if the author didn't set one (or it's too short).
|
|
1360
|
+
if (!hasOwnDur) {
|
|
1361
|
+
map.duration = mapDur;
|
|
1362
|
+
} else if ((map.duration ?? 0) < mapDur) {
|
|
1363
|
+
map.duration = mapDur;
|
|
1364
|
+
console.log(` 🗺 extended map duration → ${mapDur.toFixed(1)}s (drives + dwells)`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|