@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
|
@@ -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.
|
|
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[] {
|
package/tests/render.test.ts
CHANGED
|
@@ -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
|
// ───────────────────────────────────────────────────────────────────────────
|