@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
@@ -0,0 +1,70 @@
1
+ """DeepEval metrics for the markcut storyboard eval suite.
2
+
3
+ Judge model is the token-capped OpenRouter wrapper so the whole suite
4
+ runs on the workspace's free OpenRouter key.
5
+ """
6
+
7
+ import sys
8
+ import os
9
+
10
+ sys.path.insert(0, os.path.dirname(__file__))
11
+
12
+ from deepeval.metrics import GEval, TaskCompletionMetric
13
+ from deepeval.test_case import LLMTestCaseParams
14
+
15
+ from openrouter_model import OpenRouterLLM
16
+
17
+
18
+ def _judge():
19
+ return OpenRouterLLM(temperature=0.0)
20
+
21
+
22
+ # Trace-level: did the agent finish the storyboard task at all?
23
+ TASK_COMPLETION_METRICS = [
24
+ TaskCompletionMetric(model=_judge(), threshold=0.7),
25
+ ]
26
+
27
+ # Is the storyboard valid markcut markdown following the skill's rules?
28
+ STORYBOARD_FORMAT_METRIC = GEval(
29
+ name="Storyboard Format",
30
+ criteria=(
31
+ "Evaluate whether the actual output is a valid markcut storyboard "
32
+ "in markdown descriptive format. It must: use '## scene' headings "
33
+ "for scenes; use '- image prompt:\"...\"' or similar bullet syntax "
34
+ "for visuals; include narration via script \"...\"; set "
35
+ "isBackground:true on the primary visual of any scene that has a "
36
+ "script or audio (otherwise the scene plays black); and NOT set "
37
+ "manual durations on scripted scenes since the resolver derives "
38
+ "duration from the audio script."
39
+ ),
40
+ evaluation_params=[
41
+ LLMTestCaseParams.INPUT,
42
+ LLMTestCaseParams.ACTUAL_OUTPUT,
43
+ ],
44
+ model=_judge(),
45
+ threshold=0.7,
46
+ )
47
+
48
+ # Does the storyboard tell a compelling viral story?
49
+ STORY_NARRATIVE_METRIC = GEval(
50
+ name="Story Narrative",
51
+ criteria=(
52
+ "Evaluate whether the storyboard tells a compelling short video "
53
+ "story. It should contain a hook (why watch), conflict or "
54
+ "challenge, resolution, emotional beats, a call to action, and "
55
+ "ideally an open ending where appropriate. Score low if it is a "
56
+ "flat list of scenes with no narrative structure."
57
+ ),
58
+ evaluation_params=[
59
+ LLMTestCaseParams.INPUT,
60
+ LLMTestCaseParams.ACTUAL_OUTPUT,
61
+ ],
62
+ model=_judge(),
63
+ threshold=0.6,
64
+ )
65
+
66
+ SINGLE_TURN_TRACE_METRICS = [
67
+ TASK_COMPLETION_METRICS[0],
68
+ STORYBOARD_FORMAT_METRIC,
69
+ STORY_NARRATIVE_METRIC,
70
+ ]
@@ -0,0 +1,143 @@
1
+ """OpenRouter-backed DeepEval LLM with a capped max_tokens budget.
2
+
3
+ The workspace OPENROUTER_API_KEY has a daily credit cap that rejects
4
+ requests asking for 65536 max_tokens, so we wrap the OpenAI-compatible
5
+ client ourselves and ask for far fewer output tokens.
6
+ """
7
+
8
+ import os
9
+ from typing import Optional, List
10
+
11
+ from deepeval.models import DeepEvalBaseLLM
12
+ from deepeval.synthesizer.schema import (
13
+ Response as _ResponseSchema,
14
+ SyntheticData,
15
+ SyntheticDataList,
16
+ )
17
+ from deepeval.metrics.utils import trimAndLoadJson
18
+
19
+ MAX_OUTPUT_TOKENS = 16000
20
+
21
+ # Free-tier models are flaky; rotate through candidates on rate limits.
22
+ FALLBACK_MODELS = [
23
+ "google/gemma-4-31b-it:free",
24
+ "nvidia/nemotron-3-super-120b-a12b:free",
25
+ "inclusionai/ling-3.0-flash-sante:free",
26
+ "minimax/minimax-m2.7:free",
27
+ ]
28
+
29
+
30
+ class OpenRouterLLM(DeepEvalBaseLLM):
31
+ def __init__(
32
+ self,
33
+ model: str = "google/gemma-4-31b-it:free",
34
+ temperature: float = 0.0,
35
+ **kwargs,
36
+ ):
37
+ self.model_id = model
38
+ self.temperature = temperature
39
+ super().__init__(model, **kwargs)
40
+
41
+ def load_model(self):
42
+ from openai import OpenAI
43
+
44
+ return OpenAI(
45
+ api_key=os.environ["OPENROUTER_API_KEY"],
46
+ base_url="https://openrouter.ai/api/v1",
47
+ )
48
+
49
+ def generate(
50
+ self,
51
+ prompt: str,
52
+ schema=None,
53
+ max_output_tokens: int = MAX_OUTPUT_TOKENS,
54
+ ) -> str:
55
+ client = self.load_model()
56
+ if schema is not None:
57
+ # Sending the raw JSON schema makes weak models echo it back;
58
+ # describe the shape in prose instead.
59
+ if schema is SyntheticDataList:
60
+ shape_desc = (
61
+ 'a JSON object {"data": [{"input": "<brief text>"}]} '
62
+ "with as many items as requested"
63
+ )
64
+ elif schema is _ResponseSchema:
65
+ shape_desc = 'a JSON object {"response": "<your answer>"}'
66
+ else:
67
+ shape_desc = (
68
+ "a single JSON object with exactly these keys: "
69
+ f"{list(schema.model_fields.keys())}"
70
+ )
71
+ prompt = (
72
+ "You are a JSON generator. Respond with ONLY one valid JSON "
73
+ "value - no prose, no markdown fences, no commentary, and "
74
+ "never repeat the schema itself. The JSON must be exactly "
75
+ f"{shape_desc}.\n\nTask:\n{prompt}"
76
+ )
77
+
78
+ import time
79
+
80
+ text = None
81
+ last_err = None
82
+ for model_id in [self.model_id] + [
83
+ m for m in FALLBACK_MODELS if m != self.model_id
84
+ ]:
85
+ for attempt in range(3):
86
+ try:
87
+ response = client.chat.completions.create(
88
+ model=model_id,
89
+ messages=[{"role": "user", "content": prompt}],
90
+ temperature=self.temperature,
91
+ max_tokens=min(max_output_tokens, MAX_OUTPUT_TOKENS),
92
+ )
93
+ text = response.choices[0].message.content or ""
94
+ break
95
+ except Exception as err: # rate limits, upstream 429s
96
+ last_err = err
97
+ time.sleep(5 * (attempt + 1))
98
+ if text:
99
+ break
100
+ if not text:
101
+ raise last_err
102
+ if schema is None:
103
+ return text
104
+ # Parse the JSON the prompt asked for into the requested schema
105
+ try:
106
+ data = trimAndLoadJson(text, self)
107
+ except ValueError:
108
+ with open("/tmp/deepeval_last_output.txt", "w") as f:
109
+ f.write(text)
110
+ raise
111
+ if schema is SyntheticDataList:
112
+ return SyntheticDataList(
113
+ data=[SyntheticData(**item) for item in data["data"]]
114
+ )
115
+ if schema is _ResponseSchema and isinstance(data, str):
116
+ return _ResponseSchema(response=data)
117
+ return schema(**data)
118
+
119
+ async def a_generate(
120
+ self,
121
+ prompt: str,
122
+ schema=None,
123
+ max_output_tokens: int = MAX_OUTPUT_TOKENS,
124
+ ) -> str:
125
+ return self.generate(prompt, schema, max_output_tokens)
126
+
127
+ def get_model_name(self) -> str:
128
+ return self.model_id
129
+
130
+ def supports_json_mode(self) -> bool:
131
+ return False
132
+
133
+ def supports_structured_outputs(self) -> bool:
134
+ return False
135
+
136
+ def supports_log_probs(self) -> Optional[bool]:
137
+ return False
138
+
139
+ def supports_multimodal(self) -> bool:
140
+ return False
141
+
142
+ def batch_generate(self, prompts: List[str]) -> List[str]:
143
+ return [self.generate(p) for p in prompts]
@@ -0,0 +1,55 @@
1
+ """Traced markcut storyboard authoring app.
2
+
3
+ Runs the pi agent CLI with the markcut skill against a video brief and
4
+ returns the produced storyboard markdown. The @observe decorator makes
5
+ the run a DeepEval agent trace so traced single-turn evals can score it.
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+
12
+ from deepeval.tracing import observe
13
+
14
+ MARKCUT_SKILL = os.path.abspath(
15
+ os.path.join(
16
+ os.path.dirname(__file__), "..", "..", "skills", "markcut", "SKILL.md"
17
+ )
18
+ )
19
+
20
+
21
+ def _read_skill() -> str:
22
+ with open(MARKCUT_SKILL) as f:
23
+ return f.read()
24
+
25
+
26
+ @observe(type="agent", name="markcut-storyboard-author")
27
+ def run_traced_storyboard(brief: str) -> str:
28
+ """Author a markcut storyboard markdown for the given brief."""
29
+ with tempfile.TemporaryDirectory() as workdir:
30
+ prompt = (
31
+ "You are a video storyboard author using the markcut skill.\n"
32
+ f"Video brief: {brief}\n\n"
33
+ "Write the storyboard as a markcut markdown file named "
34
+ "storyboard.md in the current directory. Follow the skill's "
35
+ "rules strictly. When done, print ONLY the final storyboard "
36
+ "markdown to stdout with no extra commentary."
37
+ )
38
+ result = subprocess.run(
39
+ [
40
+ "pi",
41
+ "-p",
42
+ "--no-session",
43
+ "--skill", MARKCUT_SKILL,
44
+ prompt,
45
+ ],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=600,
49
+ cwd=workdir,
50
+ )
51
+ storyboard_path = os.path.join(workdir, "storyboard.md")
52
+ if os.path.exists(storyboard_path):
53
+ with open(storyboard_path) as f:
54
+ return f.read()
55
+ return result.stdout
@@ -0,0 +1,32 @@
1
+ """Traced single-turn evals for the markcut storyboard authoring agent.
2
+
3
+ Each golden's input is a video brief; the app runs the pi agent CLI with
4
+ the markcut skill to author a storyboard markdown, traced with @observe.
5
+
6
+ Run:
7
+ cd tests/evals && ../../.venv-evals/bin/deepeval test run test_storyboard.py
8
+ """
9
+
10
+ import sys
11
+ import os
12
+
13
+ sys.path.insert(0, os.path.dirname(__file__))
14
+
15
+ import pytest
16
+ from deepeval import assert_test
17
+ from deepeval.dataset import EvaluationDataset, Golden
18
+
19
+ from metrics import SINGLE_TURN_TRACE_METRICS
20
+ from storyboard_app import run_traced_storyboard
21
+
22
+
23
+ dataset = EvaluationDataset()
24
+ dataset.add_goldens_from_json_file(
25
+ file_path=os.path.join(os.path.dirname(__file__), "dataset.json")
26
+ )
27
+
28
+
29
+ @pytest.mark.parametrize("golden", dataset.goldens)
30
+ def test_storyboard_authoring(golden: Golden):
31
+ run_traced_storyboard(golden.input)
32
+ assert_test(golden=golden, metrics=SINGLE_TURN_TRACE_METRICS)
@@ -0,0 +1,56 @@
1
+ {
2
+ "root": {
3
+ "type": "root",
4
+ "width": 640,
5
+ "height": 480,
6
+ "fps": 30,
7
+ "layout": "series",
8
+ "children": [
9
+ {
10
+ "type": "map",
11
+ "id": "map-overlay-test",
12
+ "view": "route",
13
+ "travelMode": "DRIVING",
14
+ "mapType": "roadmap",
15
+ "routeColor": "#4285F4",
16
+ "routeWeight": 5,
17
+ "waypoints": [
18
+ {
19
+ "lat": 37.8199,
20
+ "lng": -122.4783,
21
+ "label": "Golden Gate"
22
+ },
23
+ {
24
+ "lat": 37.6213,
25
+ "lng": -122.379,
26
+ "label": "SFO"
27
+ }
28
+ ],
29
+ "start": 0,
30
+ "end": 12,
31
+ "duration": 12,
32
+ "children": [
33
+ {
34
+ "type": "effect",
35
+ "animation": "zoomIn",
36
+ "animationIterationCount": 1,
37
+ "start": 0,
38
+ "end": 4,
39
+ "at": "Golden Gate",
40
+ "children": [
41
+ {
42
+ "type": "image",
43
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAACXBIWXMAAAAAAAAAAQCEeRdzAAAAYUlEQVR4nO3PwQkAIBDAMAX3X/kcwkcQmgnaPWvWz44OeNWA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaAdgFCjAL8Lb/n7gAAAABJRU5ErkJggg==",
44
+ "fit": "contain",
45
+ "start": 0,
46
+ "end": 4,
47
+ "at": "Golden Gate"
48
+ }
49
+ ]
50
+ }
51
+ ]
52
+ }
53
+ ],
54
+ "seed": 592821268
55
+ }
56
+ }
@@ -12,6 +12,13 @@ layout:parallel
12
12
  - script "The route winds from the Golden Gate to the airport, with photos at each stop."
13
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
14
 
15
+ ## Stops
16
+ layout:parallel
17
+ - script "The pin pauses at each landmark while a photo grows out of it."
18
+ - map view:route duration:16 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗" waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
19
+ - image src:https://picsum.photos/seed/gg-photo/200/200 at:"Golden Gate" duration:3 effects:[zoomIn]
20
+ - image src:https://picsum.photos/seed/civic-photo/200/200 at:"Civic Center" duration:3 effects:[zoomIn]
21
+
15
22
  ## Cinematic
16
23
  layout:parallel
17
24
  - script "The camera tilts and chases the road like a drone."
@@ -25,4 +32,4 @@ layout:parallel
25
32
  ## Street-View-Walk
26
33
  layout:parallel
27
34
  - 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}}
35
+ - map view:streetview duration:6 streetView:{route:[{lat:37.7785,lng:-122.4185}, {lat:37.7777,lng:-122.4178}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
@@ -0,0 +1,11 @@
1
+ # video
2
+ seed:42
3
+ width:640 height:480 fps:30 layout:series transition:fade transitionTime:0.5
4
+
5
+ ## Route-Tour
6
+ layout:parallel
7
+ - script "We tour from the Golden Gate to the airport, stopping at each landmark."
8
+ - map view:route travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5
9
+ waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
10
+ - image src:https://picsum.photos/seed/gg-photo/200/200 at:"Golden Gate" duration:3 effects:[zoomIn]
11
+ - image src:https://picsum.photos/seed/civic-photo/200/200 at:"Civic Center" duration:3 effects:[zoomIn]
@@ -0,0 +1,9 @@
1
+ # video
2
+ seed:1
3
+ width:1080 height:1920 fps:30 layout:series transition:fade transitionTime:0.5
4
+
5
+ ## Multi-Leg-Trip
6
+ layout:parallel
7
+ - script "We fly to the coast, take a boat across the bay, walk the promenade, then drive to the airport."
8
+ - map view:route duration:12 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5
9
+ waypoints:[37.8199,-122.4783,"SFO",FLIGHT; 33.94,-118.41,"LAX",BOAT; 33.75,-118.28,"Long Beach",WALKING; 33.77,-118.19,"Promenade",DRIVING; 33.94,-118.41,"LAX"]
@@ -0,0 +1,36 @@
1
+ {
2
+ "root": {
3
+ "type": "root",
4
+ "width": 640,
5
+ "height": 480,
6
+ "fps": 30,
7
+ "layout": "series",
8
+ "children": [
9
+ {
10
+ "type": "map",
11
+ "id": "sv-walk",
12
+ "view": "streetview",
13
+ "start": 0,
14
+ "end": 6,
15
+ "duration": 6,
16
+ "streetView": {
17
+ "route": [
18
+ {
19
+ "lat": 37.7785,
20
+ "lng": -122.4185
21
+ },
22
+ {
23
+ "lat": 37.7777,
24
+ "lng": -122.4178
25
+ }
26
+ ],
27
+ "radius": 50,
28
+ "pov": {
29
+ "heading": 15,
30
+ "pitch": -5
31
+ }
32
+ }
33
+ }
34
+ ]
35
+ }
36
+ }
@@ -955,6 +955,81 @@ layout:parallel
955
955
  });
956
956
  });
957
957
 
958
+ // ── Map Multi-Leg Travel Modes ─────────────────────────────────────────────
959
+
960
+ describe("map multi-leg travel modes (per-waypoint)", () => {
961
+ const md = `
962
+ # video
963
+ width:640 height:480 fps:30 layout:series
964
+
965
+ ## Trip
966
+ layout:parallel
967
+ - map view:route duration:12 travelMode:DRIVING
968
+ waypoints:[37.8199,-122.4783,"SFO",FLIGHT; 33.94,-118.41,"LAX",BOAT; 33.75,-118.28,"Long Beach",WALKING; 33.77,-118.19,"Promenade",DRIVING; 33.94,-118.41,"LAX"]
969
+ `;
970
+
971
+ it("parses a per-waypoint travel mode as the 5th field", () => {
972
+ const parsed = parseMarkdownDescriptive(md);
973
+ const map = (parsed.children as any[])[0].children.find((c: any) => c.type === "map");
974
+ expect(map.waypoints.map((w: any) => w.mode)).toEqual([
975
+ "FLIGHT", "BOAT", "WALKING", "DRIVING", undefined,
976
+ ]);
977
+ expect(map.waypoints[0].media).toBeUndefined();
978
+ expect(map.waypoints[0].label).toBe("SFO");
979
+ });
980
+
981
+ it("compiles waypoint modes into the stream tree", () => {
982
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
983
+ const map = (compiled.children as any[])[0].children.find((c: any) => c.type === "map");
984
+ expect(map.waypoints.map((w: any) => w.mode)).toEqual([
985
+ "FLIGHT", "BOAT", "WALKING", "DRIVING", undefined,
986
+ ]);
987
+ });
988
+ });
989
+
990
+ // ── Map Overlay Children (at:"Waypoint") ──────────────────────────────────
991
+
992
+ describe("map overlay children (at:\"Waypoint\")", () => {
993
+ const md = `
994
+ # video
995
+ width:640 height:480 fps:30 layout:series
996
+
997
+ ## Tour
998
+ layout:parallel
999
+ - map view:route duration:12 travelMode:DRIVING
1000
+ waypoints:[37.81,-122.48,"Golden Gate"; 37.77,-122.42,"Civic Center"]
1001
+ - image src:gg.jpg at:"Golden Gate" start:3 duration:3 effects:[zoomIn]
1002
+ - image src:civic.jpg at:"Civic Center" start:7 duration:3 effects:[zoomIn]
1003
+ `;
1004
+
1005
+ it("parses nested children with at onto the map node", () => {
1006
+ const parsed = parseMarkdownDescriptive(md);
1007
+ const map = (parsed.children as any[])[0].children.find((c: any) => c.type === "map");
1008
+ expect(map.children).toHaveLength(2);
1009
+ expect(map.children[0].type).toBe("image");
1010
+ expect(map.children[0].at).toBe("Golden Gate");
1011
+ expect(map.children[1].at).toBe("Civic Center");
1012
+ });
1013
+
1014
+ it("compiles children into the map stream and propagates at through effects", () => {
1015
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
1016
+ const map = (compiled.children as any[])[0].children.find((c: any) => c.type === "map");
1017
+ expect(map.children).toHaveLength(2);
1018
+ // effects:[zoomIn] wraps each child in an effect node that carries `at`
1019
+ expect(map.children[0].type).toBe("effect");
1020
+ expect(map.children[0].at).toBe("Golden Gate");
1021
+ expect(map.children[0].start).toBe(3);
1022
+ expect(map.children[1].at).toBe("Civic Center");
1023
+ });
1024
+
1025
+ it("keeps the map duration from its own end, not max(children)", () => {
1026
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
1027
+ const map = (compiled.children as any[])[0].children.find((c: any) => c.type === "map");
1028
+ expect(map.durationInSeconds).toBe(12);
1029
+ expect(map.end).toBe(12);
1030
+ });
1031
+ });
1032
+
958
1033
  // ── Helper ────────────────────────────────────────────────────────────────
959
1034
 
960
1035
  function findComponents(node: any): any[] {
@@ -21,6 +21,7 @@ import {
21
21
  FIXTURES_DIR,
22
22
  } from "./utils";
23
23
  import { existsSync, rmSync, mkdirSync } from "node:fs";
24
+ import { execSync } from "node:child_process";
24
25
  import { resolve } from "node:path";
25
26
 
26
27
  // Increase timeout for integration tests (rendering + STT can take a while)
@@ -258,6 +259,97 @@ describe("Map Rendering", () => {
258
259
  // Check file size as proxy for visual content.
259
260
  expect(getFrameFileSize(frame)).toBeGreaterThan(1000);
260
261
  });
262
+
263
+ it("renders anchored overlay children at waypoints (dwell vs drive)", async () => {
264
+ const output = renderFixture(fixturePath("map-overlay.json"), {
265
+ outputName: "map-overlay.mp4",
266
+ timeout: RENDER_TIMEOUT,
267
+ });
268
+
269
+ const info = getVideoInfo(output);
270
+ expect(info.width).toBe(640);
271
+
272
+ // The map has a solid-magenta photo anchored at "Golden Gate" (0–4s,
273
+ // zoomIn), then the pin drives to SFO. The photo must be visible during
274
+ // the dwell and gone during the drive — guards regressions where anchored
275
+ // overlays silently never render (getProjection() null / lazy effect path).
276
+ const dwell = outPath("frames/map-overlay-dwell.png");
277
+ const drive = outPath("frames/map-overlay-drive.png");
278
+ extractFrame(output, 2.5, dwell);
279
+ extractFrame(output, 6, drive);
280
+
281
+ const magentaFraction = (f: string): number => {
282
+ const p = execSync(
283
+ `ffmpeg -loglevel error -i "${f}" -vf scale=160:120 -f rawvideo -pix_fmt rgb24 pipe:1`,
284
+ );
285
+ let count = 0;
286
+ for (let i = 0; i < p.length; i += 3) {
287
+ if (p[i]! > 200 && p[i + 1]! < 80 && p[i + 2]! > 200) count++;
288
+ }
289
+ return count / (p.length / 3);
290
+ };
291
+
292
+ expect(magentaFraction(dwell)).toBeGreaterThan(0.02); // photo visible during dwell
293
+ expect(magentaFraction(drive)).toBeLessThan(0.001); // gone during drive
294
+ });
295
+
296
+ it("renders street view panoramas non-dark", async () => {
297
+ const output = renderFixture(fixturePath("map-dynamic.json"), {
298
+ outputName: "map-dynamic-streetview.mp4",
299
+ timeout: RENDER_TIMEOUT,
300
+ });
301
+
302
+ // map-dynamic.json is a series: dolly 0–3s, cinematic 3–11s, then the
303
+ // static streetview-pan scene plays ~11–17s. Its panorama must not be
304
+ // black — guards regressions where static Street View captures dark frames
305
+ // (pano_changed race + too-short fallback).
306
+ const frame = outPath("frames/map-dynamic-streetview.png");
307
+ extractFrame(output, 13, frame);
308
+
309
+ const darkFraction = (f: string): number => {
310
+ const p = execSync(
311
+ `ffmpeg -loglevel error -i "${f}" -vf scale=120:90 -f rawvideo -pix_fmt rgb24 pipe:1`,
312
+ );
313
+ let dark = 0;
314
+ for (let i = 0; i < p.length; i += 3) {
315
+ const v = (p[i]! + p[i + 1]! + p[i + 2]!) / 3;
316
+ if (v < 20) dark++;
317
+ }
318
+ return dark / (p.length / 3);
319
+ };
320
+
321
+ expect(darkFraction(frame)).toBeLessThan(0.5);
322
+ });
323
+
324
+ it("renders street view walk (route) non-dark at both waypoints", async () => {
325
+ const output = renderFixture(fixturePath("streetview-walk.json"), {
326
+ outputName: "streetview-walk.mp4",
327
+ timeout: RENDER_TIMEOUT,
328
+ });
329
+
330
+ // The snap-walk holds at route[0] (101 Grove St) for the first half and
331
+ // route[1] (95 Hayes St) for the second. Both have imagery; neither frame
332
+ // should be black. Guards regressions where the walk's per-frame pano
333
+ // requests get rate-limited (429) and render dark frames.
334
+ const darkFraction = (f: string): number => {
335
+ const p = execSync(
336
+ `ffmpeg -loglevel error -i "${f}" -vf scale=120:90 -f rawvideo -pix_fmt rgb24 pipe:1`,
337
+ );
338
+ let dark = 0;
339
+ for (let i = 0; i < p.length; i += 3) {
340
+ const v = (p[i]! + p[i + 1]! + p[i + 2]!) / 3;
341
+ if (v < 20) dark++;
342
+ }
343
+ return dark / (p.length / 3);
344
+ };
345
+
346
+ const wp0 = outPath("frames/streetview-walk-wp0.png");
347
+ const wp1 = outPath("frames/streetview-walk-wp1.png");
348
+ extractFrame(output, 2, wp0);
349
+ extractFrame(output, 4.5, wp1);
350
+ expect(darkFraction(wp0)).toBeLessThan(0.5);
351
+ expect(darkFraction(wp1)).toBeLessThan(0.5);
352
+ });
261
353
  });
262
354
 
263
355
  // ───────────────────────────────────────────────────────────────────────────