@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.
Files changed (36) hide show
  1. package/package.json +5 -1
  2. package/skills/markcut/SKILL.md +14 -17
  3. package/skills/markcut/docs/map-dynamic-camera.md +92 -8
  4. package/skills/markcut/docs/markdown-descriptive.md +1 -1
  5. package/skills/markcut/review.md +480 -0
  6. package/src/descriptive/compiler.ts +60 -1
  7. package/src/descriptive/dsl.ts +27 -7
  8. package/src/descriptive/markdown.ts +2 -1
  9. package/src/descriptive/resolve.test.ts +98 -0
  10. package/src/descriptive/resolve.ts +156 -12
  11. package/src/player/bundle/player.js +222961 -222169
  12. package/src/player/pipeline.mjs +255 -17
  13. package/src/schema/index.ts +4 -0
  14. package/src/types/Effect.tsx +12 -1
  15. package/src/types/Map.tsx +649 -75
  16. package/src/utils/directions.ts +101 -0
  17. package/src/utils/index.ts +11 -0
  18. package/src/utils/route-legs.ts +199 -0
  19. package/tests/dsl.test.ts +35 -0
  20. package/tests/evals/README.md +41 -0
  21. package/tests/evals/dataset.json +170 -0
  22. package/tests/evals/gen_dataset.py +63 -0
  23. package/tests/evals/metrics.py +70 -0
  24. package/tests/evals/openrouter_model.py +143 -0
  25. package/tests/evals/storyboard_app.py +55 -0
  26. package/tests/evals/test_storyboard.py +32 -0
  27. package/tests/fixtures/map-overlay.json +56 -0
  28. package/tests/fixtures/md/map-all-views.md +8 -1
  29. package/tests/fixtures/md/map-children.md +11 -0
  30. package/tests/fixtures/md/map-multimode.md +9 -0
  31. package/tests/fixtures/streetview-walk.json +36 -0
  32. package/tests/md-descriptive.test.ts +75 -0
  33. package/tests/render.test.ts +92 -0
  34. package/tests/route-legs.test.ts +178 -0
  35. package/tests/schema.test.ts +18 -0
  36. package/.vscode/settings.json +0 -3
@@ -243,6 +243,7 @@ function wrapWithEffects(node2, result, parentKind) {
243
243
  start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
244
244
  end: isOutermost && isBgNoEnd ? void 0 : effEnd,
245
245
  visible: innerStream.visible ?? true,
246
+ at: node2.at,
246
247
  ...pickOn(node2)
247
248
  };
248
249
  }
@@ -261,6 +262,7 @@ function compileLeaf(node2, ctx, parentKind) {
261
262
  style: node2.style,
262
263
  visible: node2.visible ?? true,
263
264
  isBackground: node2.isBackground,
265
+ at: node2.at,
264
266
  start: isBgNoOwnTiming ? typeof node2.start === "number" ? node2.start : void 0 : start,
265
267
  end,
266
268
  startFrom: isBgNoOwnTiming ? void 0 : node2.type === "video" || node2.type === "audio" ? node2.startFrom : void 0,
@@ -375,7 +377,7 @@ function compileChildren(children, ctx, parentKind) {
375
377
  } else if (isRhythm(child)) {
376
378
  result = compileRhythm(child, ctx, parentKind);
377
379
  } else if (isMap(child)) {
378
- result = compileLeaf(child, ctx, parentKind);
380
+ result = compileMap(child, ctx, parentKind);
379
381
  } else {
380
382
  result = compileLeaf(child, ctx, parentKind);
381
383
  }
@@ -526,6 +528,44 @@ function compileRhythm(node2, ctx, parentKind) {
526
528
  };
527
529
  return { stream, duration: end };
528
530
  }
531
+ function compileMap(node2, ctx, parentKind) {
532
+ const id = node2.id ?? uid();
533
+ const start = parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
534
+ const ownDuration = deriveLeafDuration(node2, ctx);
535
+ const end = ownDuration != null ? start + ownDuration : void 0;
536
+ const children = node2.children ?? [];
537
+ const compiledChildren = children.length ? compileChildren(children, ctx, "parallel") : [];
538
+ const maxChildEnd = compiledChildren.reduce((max, c) => Math.max(max, c.duration), 0);
539
+ const mapDuration = Math.max(end ?? 0, maxChildEnd, ownDuration ?? 0);
540
+ const stream = {
541
+ id,
542
+ type: "map",
543
+ style: node2.style,
544
+ visible: node2.visible ?? true,
545
+ isBackground: node2.isBackground,
546
+ start,
547
+ end: mapDuration,
548
+ durationInSeconds: mapDuration,
549
+ view: node2.view ?? "route",
550
+ waypoints: node2.waypoints,
551
+ routeColor: node2.routeColor ?? "#4285F4",
552
+ routeWeight: node2.routeWeight ?? 4,
553
+ zoom: node2.zoom ?? 10,
554
+ center: node2.center,
555
+ mapType: node2.mapType ?? "roadmap",
556
+ language: node2.language,
557
+ region: node2.region,
558
+ travelMode: node2.travelMode ?? "DRIVING",
559
+ routeMarker: node2.routeMarker ?? "\u{1F697}",
560
+ camera: node2.camera,
561
+ cinematic: node2.cinematic,
562
+ streetView: node2.streetView,
563
+ googleMapsApiKey: ctx.googleMapsApiKey,
564
+ children: compiledChildren.map((c) => c.stream),
565
+ ...pickOn(node2)
566
+ };
567
+ return { stream, duration: mapDuration };
568
+ }
529
569
  function compileContainer(node2, ctx, parentKind) {
530
570
  const id = node2.id ?? uid();
531
571
  ensureUniqueIds(node2.children, id);
@@ -1038,6 +1078,114 @@ var require_format = __commonJS({
1038
1078
  }
1039
1079
  });
1040
1080
 
1081
+ // src/utils/route-legs.ts
1082
+ function haversineKm(a, b) {
1083
+ const dLat = toRad(b.lat - a.lat);
1084
+ const dLng = toRad(b.lng - a.lng);
1085
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
1086
+ return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(h));
1087
+ }
1088
+ function greatCirclePath(a, b, points = 64) {
1089
+ const \u03C61 = toRad(a.lat);
1090
+ const \u03BB1 = toRad(a.lng);
1091
+ const \u03C62 = toRad(b.lat);
1092
+ const \u03BB2 = toRad(b.lng);
1093
+ const d = haversineKm(a, b) / EARTH_RADIUS_KM;
1094
+ const out = [];
1095
+ for (let i = 0; i <= points; i++) {
1096
+ if (d === 0) {
1097
+ out.push({ lat: a.lat, lng: a.lng });
1098
+ continue;
1099
+ }
1100
+ const f = i / points;
1101
+ const A = Math.sin((1 - f) * d) / Math.sin(d);
1102
+ const B = Math.sin(f * d) / Math.sin(d);
1103
+ const x = A * Math.cos(\u03C61) * Math.cos(\u03BB1) + B * Math.cos(\u03C62) * Math.cos(\u03BB2);
1104
+ const y = A * Math.cos(\u03C61) * Math.sin(\u03BB1) + B * Math.cos(\u03C62) * Math.sin(\u03BB2);
1105
+ const z = A * Math.sin(\u03C61) + B * Math.sin(\u03C62);
1106
+ out.push({ lat: toDeg(Math.atan2(z, Math.sqrt(x * x + y * y))), lng: toDeg(Math.atan2(y, x)) });
1107
+ }
1108
+ return out;
1109
+ }
1110
+ function makeSyntheticLeg(from, to, mode) {
1111
+ const speedKmh = (mode || "").toUpperCase() === "BOAT" ? BOAT_SPEED_KMH : FLIGHT_SPEED_KMH;
1112
+ const durationSec = haversineKm(from, to) / speedKmh * 3600;
1113
+ return {
1114
+ mode: mode.toUpperCase(),
1115
+ from,
1116
+ to,
1117
+ durationSec,
1118
+ steps: [{ path: greatCirclePath(from, to), durationSec }]
1119
+ };
1120
+ }
1121
+ var FLIGHT_SPEED_KMH, BOAT_SPEED_KMH, EARTH_RADIUS_KM, toRad, toDeg;
1122
+ var init_route_legs = __esm({
1123
+ "src/utils/route-legs.ts"() {
1124
+ "use strict";
1125
+ FLIGHT_SPEED_KMH = 850;
1126
+ BOAT_SPEED_KMH = 40;
1127
+ EARTH_RADIUS_KM = 6371;
1128
+ toRad = (d) => d * Math.PI / 180;
1129
+ toDeg = (d) => d * 180 / Math.PI;
1130
+ }
1131
+ });
1132
+
1133
+ // src/utils/directions.ts
1134
+ var directions_exports = {};
1135
+ __export(directions_exports, {
1136
+ legDurationSec: () => legDurationSec,
1137
+ routeLegTimings: () => routeLegTimings
1138
+ });
1139
+ function apiKey() {
1140
+ return typeof process !== "undefined" && process.env.GOOGLE_MAPS_API_KEY || "";
1141
+ }
1142
+ async function legDurationSec(from, to, mode, key = apiKey()) {
1143
+ const m = (mode || "").toUpperCase();
1144
+ if (!ROAD_MODES.has(m)) {
1145
+ return makeSyntheticLeg(from, to, m).durationSec;
1146
+ }
1147
+ if (!key) {
1148
+ return straightLineSec(from, to);
1149
+ }
1150
+ const url = new URL("https://maps.googleapis.com/maps/api/directions/json");
1151
+ url.searchParams.set("origin", `${from.lat},${from.lng}`);
1152
+ url.searchParams.set("destination", `${to.lat},${to.lng}`);
1153
+ url.searchParams.set("mode", m.toLowerCase());
1154
+ url.searchParams.set("key", key);
1155
+ try {
1156
+ const res = await fetch(url);
1157
+ const data = await res.json();
1158
+ const dur = data?.routes?.[0]?.legs?.[0]?.duration?.value;
1159
+ if (typeof dur === "number" && dur > 0) return dur;
1160
+ return straightLineSec(from, to);
1161
+ } catch {
1162
+ return straightLineSec(from, to);
1163
+ }
1164
+ }
1165
+ function straightLineSec(from, to) {
1166
+ return haversineKm(from, to) / 50 * 3600;
1167
+ }
1168
+ async function routeLegTimings(waypoints, defaultMode = "DRIVING", key = apiKey()) {
1169
+ if (waypoints.length < 2) return [];
1170
+ const out = [];
1171
+ for (let i = 0; i < waypoints.length - 1; i++) {
1172
+ const from = waypoints[i];
1173
+ const to = waypoints[i + 1];
1174
+ const mode = (from.mode ?? defaultMode).toUpperCase();
1175
+ const durationSec = await legDurationSec(from, to, mode, key) ?? 0;
1176
+ out.push({ mode, from, to, durationSec });
1177
+ }
1178
+ return out;
1179
+ }
1180
+ var ROAD_MODES;
1181
+ var init_directions = __esm({
1182
+ "src/utils/directions.ts"() {
1183
+ "use strict";
1184
+ init_route_legs();
1185
+ ROAD_MODES = /* @__PURE__ */ new Set(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]);
1186
+ }
1187
+ });
1188
+
1041
1189
  // src/player/pipeline.ts
1042
1190
  init_compiler();
1043
1191
 
@@ -9956,14 +10104,22 @@ function parseWaypoints(raw) {
9956
10104
  const bits = splitTokens(part.replace(/,/g, " "));
9957
10105
  const lat = Number(bits[0] ?? 0);
9958
10106
  const lng = Number(bits[1] ?? 0);
9959
- const labelRaw = bits[2];
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 };
10107
+ let mode;
10108
+ const values = [];
10109
+ for (const tok of bits.slice(2)) {
10110
+ const value2 = unquote(tok);
10111
+ if (!isQuoted(tok) && value2 && KNOWN_TRAVEL_MODES.has(value2.toUpperCase())) {
10112
+ mode = value2.toUpperCase();
10113
+ continue;
10114
+ }
10115
+ values.push(value2);
10116
+ }
10117
+ const label = values[0] || void 0;
10118
+ const media = values[1] || void 0;
10119
+ return { lat, lng, label, media, mode };
9965
10120
  });
9966
10121
  }
10122
+ var KNOWN_TRAVEL_MODES = /* @__PURE__ */ new Set(["DRIVING", "WALKING", "BICYCLING", "TRANSIT", "FLIGHT", "BOAT"]);
9967
10123
  function rewriteTweenExprs(s) {
9968
10124
  return s.replace(
9969
10125
  /tween\(\s*([^,()]+?)\s*,\s*([^,()]+?)\s*(?:,\s*([^()]+?))?\s*\)/g,
@@ -10665,7 +10821,7 @@ function processMDASTListItem(item, parent, lines) {
10665
10821
  Object.assign(node2, attrs);
10666
10822
  }
10667
10823
  } else if (child.type === "list") {
10668
- if (node2.type === "series" || node2.type === "parallel" || node2.type === "transitionSeries" || node2.type === "effect" || node2.type === "include" || node2.type === "rhythm") {
10824
+ if (node2.type === "series" || node2.type === "parallel" || node2.type === "transitionSeries" || node2.type === "effect" || node2.type === "include" || node2.type === "rhythm" || node2.type === "map") {
10669
10825
  for (const subItem of child.children) {
10670
10826
  processMDASTListItem(subItem, node2, lines);
10671
10827
  }
@@ -10960,13 +11116,15 @@ async function resolveScripts(root, options) {
10960
11116
  const clone = JSON.parse(JSON.stringify(root));
10961
11117
  mkdirSync2(options.outputDir, { recursive: true });
10962
11118
  const allScriptNodes = [];
10963
- walkDown(clone, (node2) => {
10964
- if (node2.type !== "audio") return;
10965
- if (!node2.script || typeof node2.script !== "string") return;
10966
- if (node2.src) return;
10967
- const id = node2.id ?? `audio-${allScriptNodes.length}`;
10968
- allScriptNodes.push({ node: node2, id });
10969
- });
11119
+ const collect = (node2, inherited) => {
11120
+ const ttsOverride = typeof node2.tts === "string" && node2.tts.length > 0 ? node2.tts : inherited;
11121
+ if (node2.type === "audio" && node2.script && typeof node2.script === "string" && !node2.src) {
11122
+ const id = node2.id ?? `audio-${allScriptNodes.length}`;
11123
+ allScriptNodes.push({ node: node2, id, ttsOverride });
11124
+ }
11125
+ for (const c of node2.children ?? []) collect(c, ttsOverride);
11126
+ };
11127
+ collect(clone);
10970
11128
  const totalScripts = allScriptNodes.length;
10971
11129
  let scriptsDone = 0;
10972
11130
  let cacheHits = 0;
@@ -10974,9 +11132,9 @@ async function resolveScripts(root, options) {
10974
11132
  if (totalScripts > 0) {
10975
11133
  console.log(` \u{1F50A} TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
10976
11134
  }
10977
- for (const { node: node2, id } of allScriptNodes) {
11135
+ for (const { node: node2, id, ttsOverride } of allScriptNodes) {
10978
11136
  scriptsDone++;
10979
- let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
11137
+ let ttsCli = ttsOverride ?? options.ttsCli ?? DEFAULT_TTS_CLI;
10980
11138
  if (node2.speaker && clone.voices) {
10981
11139
  const speakerVoice = clone.voices[node2.speaker];
10982
11140
  if (speakerVoice) {
@@ -11441,9 +11599,89 @@ async function resolveAll2(root, options) {
11441
11599
  mergedOutputDir: options.subtitleOutputDir
11442
11600
  });
11443
11601
  }
11602
+ result = await resolveRouteStops(result);
11444
11603
  result = relativizeAssetsUnder(result, options.baseDir);
11445
11604
  return result;
11446
11605
  }
11606
+ var DEFAULT_CHILD_SECONDS = 3;
11607
+ var DEFAULT_MAP_DRIVE_SECONDS = 10;
11608
+ async function resolveRouteStops(root) {
11609
+ const { routeLegTimings: routeLegTimings2 } = await Promise.resolve().then(() => (init_directions(), directions_exports));
11610
+ async function resolveMap(node2) {
11611
+ if (!node2 || typeof node2 !== "object") return;
11612
+ if (Array.isArray(node2)) {
11613
+ for (const n of node2) await resolveMap(n);
11614
+ return;
11615
+ }
11616
+ if (node2.type === "map" && Array.isArray(node2.children) && node2.children.length) {
11617
+ const children = node2.children;
11618
+ const anchored = children.filter((c) => c && c.at);
11619
+ if (anchored.length) {
11620
+ await timeMapChildren(node2, anchored, routeLegTimings2);
11621
+ }
11622
+ }
11623
+ for (const child of node2.children ?? []) await resolveMap(child);
11624
+ }
11625
+ await resolveMap(root.children);
11626
+ return root;
11627
+ }
11628
+ async function timeMapChildren(map, anchored, routeLegTimings2) {
11629
+ const waypoints = map.waypoints ?? [];
11630
+ if (waypoints.length < 2) return;
11631
+ const legs = await routeLegTimings2(waypoints, map.travelMode ?? "DRIVING");
11632
+ const totalDrive = legs.reduce((s, l) => s + (l.durationSec || 0), 0);
11633
+ if (totalDrive <= 0) return;
11634
+ const childDuration = (c) => typeof c.duration === "number" && c.duration > 0 ? c.duration : DEFAULT_CHILD_SECONDS;
11635
+ const dwellAt = /* @__PURE__ */ new Map();
11636
+ for (const c of anchored) {
11637
+ const d = childDuration(c);
11638
+ dwellAt.set(c.at, Math.max(dwellAt.get(c.at) ?? 0, d));
11639
+ }
11640
+ const totalDwell = [...dwellAt.values()].reduce((s, d) => s + d, 0);
11641
+ const hasOwnDur = typeof map.duration === "number" || typeof map.endAt === "number";
11642
+ const totalDwellBudget = Math.max(0, map.duration ?? map.endAt ?? DEFAULT_MAP_DRIVE_SECONDS);
11643
+ const mapDur = hasOwnDur ? map.duration ?? map.endAt ?? totalDwellBudget + totalDwell : totalDwellBudget + totalDwell;
11644
+ const driveBudget = Math.max(0.1, mapDur - totalDwell);
11645
+ const scale = driveBudget / totalDrive;
11646
+ const arrivalAt = /* @__PURE__ */ new Map();
11647
+ const wp0 = waypoints[0];
11648
+ arrivalAt.set(wp0.label ?? String(wp0.lat) + "," + String(wp0.lng), 0);
11649
+ let cumDrive = 0;
11650
+ let cumDwells = 0;
11651
+ for (let i = 0; i < legs.length; i++) {
11652
+ cumDrive += legs[i].durationSec || 0;
11653
+ const wp = waypoints[i + 1];
11654
+ const arrival = cumDrive * scale + cumDwells;
11655
+ arrivalAt.set(wp.label ?? String(wp.lat) + "," + String(wp.lng), arrival);
11656
+ if (dwellAt.has(wp.label)) cumDwells += dwellAt.get(wp.label);
11657
+ }
11658
+ const schedule = [];
11659
+ for (const c of anchored) {
11660
+ const arrival = arrivalAt.get(c.at);
11661
+ if (arrival == null) {
11662
+ console.warn(` \u26A0 map child at:"${c.at}" \u2014 no waypoint with that label; renders full-screen`);
11663
+ continue;
11664
+ }
11665
+ if (typeof c.start === "number") {
11666
+ schedule.push(` \u{1F6D1} ${c.at}: manual ${c.start.toFixed(1)}s (unchanged)`);
11667
+ continue;
11668
+ }
11669
+ const dur = childDuration(c);
11670
+ c.start = arrival;
11671
+ c.end = arrival + dur;
11672
+ schedule.push(` \u{1F6D1} ${c.at}: ${arrival.toFixed(1)}s \u2192 ${(arrival + dur).toFixed(1)}s (${dur.toFixed(1)}s)`);
11673
+ }
11674
+ if (schedule.length) {
11675
+ console.log(` \u{1F5FA} route stops (${mapDur.toFixed(1)}s total):`);
11676
+ for (const line of schedule) console.log(line);
11677
+ }
11678
+ if (!hasOwnDur) {
11679
+ map.duration = mapDur;
11680
+ } else if ((map.duration ?? 0) < mapDur) {
11681
+ map.duration = mapDur;
11682
+ console.log(` \u{1F5FA} extended map duration \u2192 ${mapDur.toFixed(1)}s (drives + dwells)`);
11683
+ }
11684
+ }
11447
11685
 
11448
11686
  // src/player/pipeline.ts
11449
11687
  function isDescriptiveRoot(data) {
@@ -42,6 +42,7 @@ const BaseShape = {
42
42
  endAt: z.number().min(0).optional().describe("trim seconds at source end (video/audio)"),
43
43
  durationInSeconds: z.number().optional().describe("set by engine; do not edit by hand"),
44
44
  on: eventSpec.optional().describe("event that fires at a specific frame, mutating registered component state"),
45
+ at: z.string().optional().describe("when child of a map: waypoint label to anchor at (renderer positions at that waypoint's screen pixel)"),
45
46
  };
46
47
 
47
48
  export const base = z.object(BaseShape);
@@ -219,6 +220,8 @@ export const mapWaypoint = z.object({
219
220
  lng: z.number(),
220
221
  label: z.string().optional(),
221
222
  media: z.string().optional().describe("image/video src for waypoint marker"),
223
+ mode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT", "FLIGHT", "BOAT"]).optional()
224
+ .describe("travel mode for the leg FROM this waypoint to the next; defaults to map travelMode"),
222
225
  });
223
226
 
224
227
  /**
@@ -289,6 +292,7 @@ export const mapStream = base.extend({
289
292
  cinematic: mapCinematic.optional().describe("cinematic camera behavior"),
290
293
  streetView: mapStreetView.optional().describe("immersive street view config"),
291
294
  googleMapsApiKey: z.string().optional().describe("injected by compiler from GOOGLE_MAPS_API_KEY env var"),
295
+ children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []).describe("overlay children rendered on top of the map; use at:\"Label\" to anchor at a waypoint"),
292
296
  });
293
297
  export type MapStream = z.infer<typeof mapStream>;
294
298
 
@@ -17,9 +17,12 @@ import type { Effect as EffectStream } from "../schema/index";
17
17
  export function EffectWrapper({
18
18
  stream,
19
19
  children,
20
+ contained,
20
21
  }: {
21
22
  stream: EffectStream;
22
23
  children: React.ReactNode;
24
+ /** Fill the parent box instead of the whole canvas (map overlay children). */
25
+ contained?: boolean;
23
26
  }) {
24
27
  const frame = useCurrentFrame();
25
28
  const { fps, width, height } = useVideoConfig();
@@ -78,7 +81,15 @@ export function EffectWrapper({
78
81
 
79
82
  return (
80
83
  <div
81
- style={Object.assign({ width, height, position: "absolute" as const, inset: 0 }, ...styles)}
84
+ style={Object.assign(
85
+ {
86
+ width: contained ? "100%" : width,
87
+ height: contained ? "100%" : height,
88
+ position: "absolute" as const,
89
+ inset: 0,
90
+ },
91
+ ...styles,
92
+ )}
82
93
  className="effect"
83
94
  >
84
95
  {children}