@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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Google Directions REST helper.
|
|
3
|
+
*
|
|
4
|
+
* Used at resolve time (Node) to compute per-leg travel durations so map
|
|
5
|
+
* overlay children (`at:"Label"`) can be auto-timed (arrival + dwell). This
|
|
6
|
+
* is the same Directions REST pattern the spots CLI uses — one request per
|
|
7
|
+
* consecutive waypoint pair so mixed travel modes (FLIGHT/BOAT synthetic)
|
|
8
|
+
* can be interspersed with road legs.
|
|
9
|
+
*
|
|
10
|
+
* Requires GOOGLE_MAPS_API_KEY (Directions API enabled).
|
|
11
|
+
*/
|
|
12
|
+
import { makeSyntheticLeg, haversineKm } from "./route-legs";
|
|
13
|
+
|
|
14
|
+
export interface RouteLegTiming {
|
|
15
|
+
mode: string;
|
|
16
|
+
from: { lat: number; lng: number };
|
|
17
|
+
to: { lat: number; lng: number };
|
|
18
|
+
/** Estimated travel time for this leg, in seconds. */
|
|
19
|
+
durationSec: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function apiKey(): string {
|
|
23
|
+
return (
|
|
24
|
+
(typeof process !== "undefined" && process.env.GOOGLE_MAPS_API_KEY) || ""
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Directions travel modes (non-synthetic). */
|
|
29
|
+
const ROAD_MODES = new Set(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute the travel time (seconds) for one leg. Road modes hit the
|
|
33
|
+
* Directions REST API; synthetic modes (FLIGHT/BOAT) are estimated from
|
|
34
|
+
* haversine distance at cruise speed — no API call, deterministic.
|
|
35
|
+
*
|
|
36
|
+
* Returns null when the Directions call fails (caller falls back to a
|
|
37
|
+
* straight-line estimate so timing still works offline).
|
|
38
|
+
*/
|
|
39
|
+
export async function legDurationSec(
|
|
40
|
+
from: { lat: number; lng: number },
|
|
41
|
+
to: { lat: number; lng: number },
|
|
42
|
+
mode: string,
|
|
43
|
+
key = apiKey(),
|
|
44
|
+
): Promise<number | null> {
|
|
45
|
+
const m = (mode || "").toUpperCase();
|
|
46
|
+
if (!ROAD_MODES.has(m)) {
|
|
47
|
+
// Synthetic: distance at cruise speed (same math as the renderer).
|
|
48
|
+
return makeSyntheticLeg(from, to, m).durationSec;
|
|
49
|
+
}
|
|
50
|
+
if (!key) {
|
|
51
|
+
// No key → straight-line estimate (consistent, deterministic).
|
|
52
|
+
return straightLineSec(from, to);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const url = new URL("https://maps.googleapis.com/maps/api/directions/json");
|
|
56
|
+
url.searchParams.set("origin", `${from.lat},${from.lng}`);
|
|
57
|
+
url.searchParams.set("destination", `${to.lat},${to.lng}`);
|
|
58
|
+
url.searchParams.set("mode", m.toLowerCase());
|
|
59
|
+
url.searchParams.set("key", key);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(url);
|
|
63
|
+
const data = await res.json();
|
|
64
|
+
const dur = data?.routes?.[0]?.legs?.[0]?.duration?.value;
|
|
65
|
+
if (typeof dur === "number" && dur > 0) return dur;
|
|
66
|
+
return straightLineSec(from, to);
|
|
67
|
+
} catch {
|
|
68
|
+
return straightLineSec(from, to);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Fallback straight-line estimate (~50 km/h), so timing works without the API. */
|
|
73
|
+
function straightLineSec(
|
|
74
|
+
from: { lat: number; lng: number },
|
|
75
|
+
to: { lat: number; lng: number },
|
|
76
|
+
): number {
|
|
77
|
+
return (haversineKm(from, to) / 50) * 3600;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute per-leg timing for a full route.
|
|
82
|
+
*
|
|
83
|
+
* `modes[i]` is the travel mode of the leg leaving waypoints[i]
|
|
84
|
+
* (defaults to `defaultMode`). Returns one timing per consecutive pair.
|
|
85
|
+
*/
|
|
86
|
+
export async function routeLegTimings(
|
|
87
|
+
waypoints: { lat: number; lng: number; mode?: string }[],
|
|
88
|
+
defaultMode = "DRIVING",
|
|
89
|
+
key = apiKey(),
|
|
90
|
+
): Promise<RouteLegTiming[]> {
|
|
91
|
+
if (waypoints.length < 2) return [];
|
|
92
|
+
const out: RouteLegTiming[] = [];
|
|
93
|
+
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
94
|
+
const from = waypoints[i]!;
|
|
95
|
+
const to = waypoints[i + 1]!;
|
|
96
|
+
const mode = (from.mode ?? defaultMode).toUpperCase();
|
|
97
|
+
const durationSec = (await legDurationSec(from, to, mode, key)) ?? 0;
|
|
98
|
+
out.push({ mode, from, to, durationSec });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -104,6 +104,17 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
|
|
|
104
104
|
return stream.durationInSeconds ?? 0;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// Map is a leaf whose overlay children are positioned INSIDE it — its own
|
|
108
|
+
// base timing (end) defines the duration, not max(child durations).
|
|
109
|
+
if (stream.type === "map") {
|
|
110
|
+
const d = leafEnd(stream);
|
|
111
|
+
if (update) {
|
|
112
|
+
stream.durationInSeconds = d;
|
|
113
|
+
for (const child of stream.children ?? []) getDurationInSeconds(child, update);
|
|
114
|
+
}
|
|
115
|
+
return d;
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
// include: if src is set, treat as leaf (duration from base end).
|
|
108
119
|
// Otherwise fall back to inline children (legacy).
|
|
109
120
|
if (stream.type === "include") {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-leg route math for the `map` stream (view:"route").
|
|
3
|
+
*
|
|
4
|
+
* A route is a sequence of legs — one per consecutive waypoint pair. Each
|
|
5
|
+
* waypoint may tag the leg that LEAVES it with its own travel mode
|
|
6
|
+
* (`waypoint.mode ?? map.travelMode`). Supported modes:
|
|
7
|
+
*
|
|
8
|
+
* DRIVING | WALKING | BICYCLING | TRANSIT → Google Directions API
|
|
9
|
+
* FLIGHT | BOAT → synthetic great-circle arc
|
|
10
|
+
* (Directions has no air/water routes; we draw a curved arc and time it
|
|
11
|
+
* from haversine distance at a cruise speed)
|
|
12
|
+
*
|
|
13
|
+
* Pure functions only (deterministic per second) so the animated marker and
|
|
14
|
+
* any future camera can share the same route-time math.
|
|
15
|
+
*/
|
|
16
|
+
export interface RouteLegStep {
|
|
17
|
+
path: { lat: number; lng: number }[];
|
|
18
|
+
durationSec: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RouteLeg {
|
|
22
|
+
mode: string;
|
|
23
|
+
from: { lat: number; lng: number };
|
|
24
|
+
to: { lat: number; lng: number };
|
|
25
|
+
durationSec: number;
|
|
26
|
+
steps: RouteLegStep[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cruise speeds used to estimate synthetic leg durations (km/h). */
|
|
30
|
+
export const FLIGHT_SPEED_KMH = 850;
|
|
31
|
+
export const BOAT_SPEED_KMH = 40;
|
|
32
|
+
|
|
33
|
+
/** Modes with no Directions route — rendered as synthetic great-circle arcs. */
|
|
34
|
+
export function isSyntheticMode(mode?: string): boolean {
|
|
35
|
+
const m = (mode ?? "").toUpperCase();
|
|
36
|
+
return m === "FLIGHT" || m === "BOAT";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const EARTH_RADIUS_KM = 6371;
|
|
40
|
+
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
41
|
+
const toDeg = (d: number) => (d * 180) / Math.PI;
|
|
42
|
+
|
|
43
|
+
/** Great-circle (haversine) distance between two lat/lng points, in km. */
|
|
44
|
+
export function haversineKm(
|
|
45
|
+
a: { lat: number; lng: number },
|
|
46
|
+
b: { lat: number; lng: number },
|
|
47
|
+
): number {
|
|
48
|
+
const dLat = toRad(b.lat - a.lat);
|
|
49
|
+
const dLng = toRad(b.lng - a.lng);
|
|
50
|
+
const h =
|
|
51
|
+
Math.sin(dLat / 2) ** 2 +
|
|
52
|
+
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
|
53
|
+
return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(h));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Interpolate the great-circle arc between two lat/lng points (the "as the
|
|
58
|
+
* crow flies" flight path), returning `points + 1` points including both ends.
|
|
59
|
+
*/
|
|
60
|
+
export function greatCirclePath(
|
|
61
|
+
a: { lat: number; lng: number },
|
|
62
|
+
b: { lat: number; lng: number },
|
|
63
|
+
points = 64,
|
|
64
|
+
): { lat: number; lng: number }[] {
|
|
65
|
+
const φ1 = toRad(a.lat);
|
|
66
|
+
const λ1 = toRad(a.lng);
|
|
67
|
+
const φ2 = toRad(b.lat);
|
|
68
|
+
const λ2 = toRad(b.lng);
|
|
69
|
+
const d = haversineKm(a, b) / EARTH_RADIUS_KM; // angular distance (radians)
|
|
70
|
+
const out: { lat: number; lng: number }[] = [];
|
|
71
|
+
for (let i = 0; i <= points; i++) {
|
|
72
|
+
if (d === 0) {
|
|
73
|
+
out.push({ lat: a.lat, lng: a.lng });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const f = i / points;
|
|
77
|
+
const A = Math.sin((1 - f) * d) / Math.sin(d);
|
|
78
|
+
const B = Math.sin(f * d) / Math.sin(d);
|
|
79
|
+
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
|
80
|
+
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
|
81
|
+
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
|
82
|
+
out.push({ lat: toDeg(Math.atan2(z, Math.sqrt(x * x + y * y))), lng: toDeg(Math.atan2(y, x)) });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build a synthetic leg (FLIGHT ✈️ / BOAT 🚢) between two waypoints. */
|
|
88
|
+
export function makeSyntheticLeg(
|
|
89
|
+
from: { lat: number; lng: number },
|
|
90
|
+
to: { lat: number; lng: number },
|
|
91
|
+
mode: string,
|
|
92
|
+
): RouteLeg {
|
|
93
|
+
const speedKmh = (mode || "").toUpperCase() === "BOAT" ? BOAT_SPEED_KMH : FLIGHT_SPEED_KMH;
|
|
94
|
+
const durationSec = (haversineKm(from, to) / speedKmh) * 3600;
|
|
95
|
+
return {
|
|
96
|
+
mode: mode.toUpperCase(),
|
|
97
|
+
from,
|
|
98
|
+
to,
|
|
99
|
+
durationSec,
|
|
100
|
+
steps: [{ path: greatCirclePath(from, to), durationSec }],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Emoji glyph for a leg's travel mode (used for the traveling marker). */
|
|
105
|
+
export function modeEmoji(mode?: string): string {
|
|
106
|
+
switch ((mode ?? "").toUpperCase()) {
|
|
107
|
+
case "FLIGHT": return "✈️";
|
|
108
|
+
case "BOAT": return "🚢";
|
|
109
|
+
case "WALKING": return "🚶";
|
|
110
|
+
case "BICYCLING": return "🚲";
|
|
111
|
+
case "TRANSIT": return "🚌";
|
|
112
|
+
case "DRIVING": return "🚗";
|
|
113
|
+
default: return "📍";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Position within one leg at normalized progress t ∈ [0,1] (step-timed). */
|
|
118
|
+
export function positionAlongLeg(
|
|
119
|
+
leg: RouteLeg,
|
|
120
|
+
t: number,
|
|
121
|
+
): { lat: number; lng: number } | null {
|
|
122
|
+
if (leg.steps.length === 0) return null;
|
|
123
|
+
const currentInSecond = t * leg.durationSec;
|
|
124
|
+
let acc = 0;
|
|
125
|
+
for (const step of leg.steps) {
|
|
126
|
+
if (currentInSecond <= acc + step.durationSec) {
|
|
127
|
+
const stepElapsed = currentInSecond - acc;
|
|
128
|
+
const stepProgress = step.durationSec > 0 ? stepElapsed / step.durationSec : 0;
|
|
129
|
+
const idx = Math.min(
|
|
130
|
+
Math.max(0, Math.floor(stepProgress * step.path.length)),
|
|
131
|
+
step.path.length - 1,
|
|
132
|
+
);
|
|
133
|
+
return step.path[idx] ?? null;
|
|
134
|
+
}
|
|
135
|
+
acc += step.durationSec;
|
|
136
|
+
}
|
|
137
|
+
const last = leg.steps[leg.steps.length - 1]!;
|
|
138
|
+
return last.path[last.path.length - 1] ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A dwell window where the pin holds still at a waypoint (a map overlay
|
|
143
|
+
* child is showing). `fromSec`/`toSec` are in the same timeline as
|
|
144
|
+
* `routePositionAtLegs`'s `seconds`.
|
|
145
|
+
*/
|
|
146
|
+
export interface RouteStopWindow {
|
|
147
|
+
label: string;
|
|
148
|
+
at: { lat: number; lng: number };
|
|
149
|
+
mode: string;
|
|
150
|
+
fromSec: number;
|
|
151
|
+
toSec: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Position of the traveling marker at a given timeline second, plus the mode
|
|
156
|
+
* of the leg it is currently on. Time is split across legs proportionally to
|
|
157
|
+
* each leg's duration. When `stops` is provided, the marker holds still at a
|
|
158
|
+
* waypoint during its dwell window (drive time is compressed around the
|
|
159
|
+
* dwells, matching the resolver's arrival math). Returns null when there is
|
|
160
|
+
* no route.
|
|
161
|
+
*/
|
|
162
|
+
export function routePositionAtLegs(
|
|
163
|
+
legs: RouteLeg[],
|
|
164
|
+
actionDuration: number,
|
|
165
|
+
seconds: number,
|
|
166
|
+
stops: RouteStopWindow[] = [],
|
|
167
|
+
): { lat: number; lng: number; mode: string } | null {
|
|
168
|
+
if (legs.length === 0) return null;
|
|
169
|
+
|
|
170
|
+
// Inside a dwell window → hold at that waypoint.
|
|
171
|
+
const inStop = stops.find((s) => seconds >= s.fromSec && seconds < s.toSec);
|
|
172
|
+
if (inStop) {
|
|
173
|
+
return { lat: inStop.at.lat, lng: inStop.at.lng, mode: inStop.mode };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Drive time = timeline seconds with completed/overlapping dwells removed.
|
|
177
|
+
let dwellBefore = 0;
|
|
178
|
+
for (const s of stops) {
|
|
179
|
+
if (seconds >= s.toSec) dwellBefore += s.toSec - s.fromSec;
|
|
180
|
+
else if (seconds > s.fromSec) dwellBefore += seconds - s.fromSec;
|
|
181
|
+
}
|
|
182
|
+
const driveTime = Math.max(0, seconds - dwellBefore);
|
|
183
|
+
const totalDwell = stops.reduce((s, st) => s + (st.toSec - st.fromSec), 0);
|
|
184
|
+
const driveBudget = Math.max(0.1, actionDuration - totalDwell);
|
|
185
|
+
const total = legs.reduce((s, l) => s + l.durationSec, 0);
|
|
186
|
+
const currentInSecond = driveTime * (total / driveBudget);
|
|
187
|
+
let acc = 0;
|
|
188
|
+
for (const leg of legs) {
|
|
189
|
+
if (currentInSecond <= acc + leg.durationSec) {
|
|
190
|
+
const t = leg.durationSec > 0 ? Math.min(Math.max((currentInSecond - acc) / leg.durationSec, 0), 1) : 0;
|
|
191
|
+
const pos = positionAlongLeg(leg, t);
|
|
192
|
+
return pos ? { ...pos, mode: leg.mode } : null;
|
|
193
|
+
}
|
|
194
|
+
acc += leg.durationSec;
|
|
195
|
+
}
|
|
196
|
+
const last = legs[legs.length - 1]!;
|
|
197
|
+
const pos = positionAlongLeg(last, 1);
|
|
198
|
+
return pos ? { ...pos, mode: last.mode } : null;
|
|
199
|
+
}
|
package/tests/dsl.test.ts
CHANGED
|
@@ -113,6 +113,41 @@ describe("dsl — parseWaypoints", () => {
|
|
|
113
113
|
]);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
it("parses a bare travel mode without an empty media slot (smart)", () => {
|
|
117
|
+
expect(parseWaypoints('[40.7,-74.0,"NYC",FLIGHT; 34.05,-118.25,"LA","photo.jpg",BOAT]')).toEqual([
|
|
118
|
+
{ lat: 40.7, lng: -74.0, label: "NYC", media: undefined, mode: "FLIGHT" },
|
|
119
|
+
{ lat: 34.05, lng: -118.25, label: "LA", media: "photo.jpg", mode: "BOAT" },
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("parses a bare travel mode as the only trailing field", () => {
|
|
124
|
+
expect(parseWaypoints('[40.7,-74.0,FLIGHT]')).toEqual([
|
|
125
|
+
{ lat: 40.7, lng: -74.0, label: undefined, media: undefined, mode: "FLIGHT" },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("parses a bare mode even before the label", () => {
|
|
130
|
+
expect(parseWaypoints('[40.7,-74.0,BOAT,"Pier"]')).toEqual([
|
|
131
|
+
{ lat: 40.7, lng: -74.0, label: "Pier", media: undefined, mode: "BOAT" },
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("uppercases a lowercase bare mode", () => {
|
|
136
|
+
expect(parseWaypoints('[40.7,-74.0,"NYC",flight]')[0]!.mode).toBe("FLIGHT");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("still accepts empty quoted media slots (backward compat)", () => {
|
|
140
|
+
expect(parseWaypoints('[40.7,-74.0,"NYC","",BOAT]')).toEqual([
|
|
141
|
+
{ lat: 40.7, lng: -74.0, label: "NYC", media: undefined, mode: "BOAT" },
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("treats a quoted mode word as a literal label/media (escape hatch)", () => {
|
|
146
|
+
expect(parseWaypoints('[40.7,-74.0,"NYC","FLIGHT"]')).toEqual([
|
|
147
|
+
{ lat: 40.7, lng: -74.0, label: "NYC", media: "FLIGHT", mode: undefined },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
116
151
|
it("returns empty array for non-bracket input", () => {
|
|
117
152
|
expect(parseWaypoints("not a list")).toEqual([]);
|
|
118
153
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# markcut storyboard evals
|
|
2
|
+
|
|
3
|
+
DeepEval suite that scores an agent authoring markcut storyboards from a
|
|
4
|
+
video brief, using the markcut skill.
|
|
5
|
+
|
|
6
|
+
## Components
|
|
7
|
+
|
|
8
|
+
| File | Purpose |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `openrouter_model.py` | Token-capped OpenRouter LLM wrapper (free-tier friendly, with model fallback rotation) |
|
|
11
|
+
| `gen_dataset.py` | Generates goldens from scratch via the Synthesizer |
|
|
12
|
+
| `dataset.json` | The golden dataset (committed, editable) |
|
|
13
|
+
| `storyboard_app.py` | Traced app: runs `pi` CLI with the markcut skill to author a storyboard |
|
|
14
|
+
| `metrics.py` | Judge metrics: TaskCompletion, Storyboard Format GEval, Story Narrative GEval |
|
|
15
|
+
| `test_storyboard.py` | pytest traced single-turn evals |
|
|
16
|
+
|
|
17
|
+
## Regenerate the dataset
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
.venv-evals/bin/python tests/evals/gen_dataset.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Run the evals
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cd tests/evals && ../../.venv-evals/bin/deepeval test run test_storyboard.py \
|
|
27
|
+
--identifier "iterating-on-storyboard-authoring-round-1"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Each test runs the pi agent on the golden's video brief and asserts
|
|
31
|
+
trace-level metrics. Failures typically indicate the agent violated a
|
|
32
|
+
skill rule (e.g. `duration:` on scripted scenes, missing
|
|
33
|
+
`isBackground:true`) or produced weak narrative structure.
|
|
34
|
+
|
|
35
|
+
## Notes
|
|
36
|
+
|
|
37
|
+
- Evaluation model runs on OpenRouter free models; the wrapper caps
|
|
38
|
+
max_tokens at 16000 to fit the workspace key's daily budget and
|
|
39
|
+
rotates through fallback models on rate limits.
|
|
40
|
+
- Traces are local (Confident AI not enabled); view latest results via
|
|
41
|
+
`deepeval view --latest` offline report in `.deepeval/`.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"input": "30s cinematic coastal drive vlog w/ voiceover detailing scenic landmarks & geography",
|
|
4
|
+
"actual_output": null,
|
|
5
|
+
"expected_output": null,
|
|
6
|
+
"context": null,
|
|
7
|
+
"source_file": null
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"input": "Detail how teaser reveals specs, competitive edge, and launch strategy.",
|
|
11
|
+
"actual_output": null,
|
|
12
|
+
"expected_output": null,
|
|
13
|
+
"context": null,
|
|
14
|
+
"source_file": null
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"input": "Draft a 2‑speaker dialogue explaining quantum computing, with visual analogy and real‑world app.",
|
|
18
|
+
"actual_output": null,
|
|
19
|
+
"expected_output": null,
|
|
20
|
+
"context": null,
|
|
21
|
+
"source_file": null
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"input": "Develop a 5‑min pasta recipe paired with lo‑fi background music for enhanced cooking experience",
|
|
25
|
+
"actual_output": null,
|
|
26
|
+
"expected_output": null,
|
|
27
|
+
"context": null,
|
|
28
|
+
"source_file": null
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"input": "How did 1960s Ford integrate sustainability into its corporate brand story?",
|
|
32
|
+
"actual_output": null,
|
|
33
|
+
"expected_output": null,
|
|
34
|
+
"context": null,
|
|
35
|
+
"source_file": null
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"input": "Create a cinematic sci-fi thriller trailer, <2 min, featuring dystopian cityscape and AI antagonist",
|
|
39
|
+
"actual_output": null,
|
|
40
|
+
"expected_output": null,
|
|
41
|
+
"context": null,
|
|
42
|
+
"source_file": null
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"input": "Compare walkthrough of modern luxury villa vs historic estate: design, amenities, layout.",
|
|
46
|
+
"actual_output": null,
|
|
47
|
+
"expected_output": null,
|
|
48
|
+
"context": null,
|
|
49
|
+
"source_file": null
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"input": "Compare high-energy cuts vs. slow cinematic pacing for fitness motivation shorts.",
|
|
53
|
+
"actual_output": null,
|
|
54
|
+
"expected_output": null,
|
|
55
|
+
"context": null,
|
|
56
|
+
"source_file": null
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"input": "Produce a short historical documentary snippet on the Indus. Rev., focusing on inventions",
|
|
60
|
+
"actual_output": null,
|
|
61
|
+
"expected_output": null,
|
|
62
|
+
"context": null,
|
|
63
|
+
"source_file": null
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"input": "Compare dashboard walkthroughs in SW A and B tutorials, noting UI similarities and differences.",
|
|
67
|
+
"actual_output": null,
|
|
68
|
+
"expected_output": null,
|
|
69
|
+
"context": null,
|
|
70
|
+
"source_file": null
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"input": "Compare a 15‑s skincare ad with strong CTA vs a 30‑s ad: effectiveness, engagement, conversion.",
|
|
74
|
+
"actual_output": null,
|
|
75
|
+
"expected_output": null,
|
|
76
|
+
"context": null,
|
|
77
|
+
"source_file": null
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"input": "Describe how pressure adaptations let deep-sea creatures survive extreme conditions in the clip.",
|
|
81
|
+
"actual_output": null,
|
|
82
|
+
"expected_output": null,
|
|
83
|
+
"context": null,
|
|
84
|
+
"source_file": null
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"input": "30s cinematic coastal drive vlog w/ voiceover detailing scenic landmarks & geography",
|
|
88
|
+
"actual_output": null,
|
|
89
|
+
"expected_output": null,
|
|
90
|
+
"context": null,
|
|
91
|
+
"source_file": null
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"input": "Detail how teaser reveals specs, competitive edge, and launch strategy.",
|
|
95
|
+
"actual_output": null,
|
|
96
|
+
"expected_output": null,
|
|
97
|
+
"context": null,
|
|
98
|
+
"source_file": null
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"input": "Draft a 2‑speaker dialogue explaining quantum computing, with visual analogy and real‑world app.",
|
|
102
|
+
"actual_output": null,
|
|
103
|
+
"expected_output": null,
|
|
104
|
+
"context": null,
|
|
105
|
+
"source_file": null
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"input": "Develop a 5‑min pasta recipe paired with lo‑fi background music for enhanced cooking experience",
|
|
109
|
+
"actual_output": null,
|
|
110
|
+
"expected_output": null,
|
|
111
|
+
"context": null,
|
|
112
|
+
"source_file": null
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"input": "How did 1960s Ford integrate sustainability into its corporate brand story?",
|
|
116
|
+
"actual_output": null,
|
|
117
|
+
"expected_output": null,
|
|
118
|
+
"context": null,
|
|
119
|
+
"source_file": null
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"input": "Create a cinematic sci-fi thriller trailer, <2 min, featuring dystopian cityscape and AI antagonist",
|
|
123
|
+
"actual_output": null,
|
|
124
|
+
"expected_output": null,
|
|
125
|
+
"context": null,
|
|
126
|
+
"source_file": null
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"input": "Compare walkthrough of modern luxury villa vs historic estate: design, amenities, layout.",
|
|
130
|
+
"actual_output": null,
|
|
131
|
+
"expected_output": null,
|
|
132
|
+
"context": null,
|
|
133
|
+
"source_file": null
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"input": "Compare high-energy cuts vs. slow cinematic pacing for fitness motivation shorts.",
|
|
137
|
+
"actual_output": null,
|
|
138
|
+
"expected_output": null,
|
|
139
|
+
"context": null,
|
|
140
|
+
"source_file": null
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"input": "Produce a short historical documentary snippet on the Indus. Rev., focusing on inventions",
|
|
144
|
+
"actual_output": null,
|
|
145
|
+
"expected_output": null,
|
|
146
|
+
"context": null,
|
|
147
|
+
"source_file": null
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"input": "Compare dashboard walkthroughs in SW A and B tutorials, noting UI similarities and differences.",
|
|
151
|
+
"actual_output": null,
|
|
152
|
+
"expected_output": null,
|
|
153
|
+
"context": null,
|
|
154
|
+
"source_file": null
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"input": "Compare a 15‑s skincare ad with strong CTA vs a 30‑s ad: effectiveness, engagement, conversion.",
|
|
158
|
+
"actual_output": null,
|
|
159
|
+
"expected_output": null,
|
|
160
|
+
"context": null,
|
|
161
|
+
"source_file": null
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"input": "Describe how pressure adaptations let deep-sea creatures survive extreme conditions in the clip.",
|
|
165
|
+
"actual_output": null,
|
|
166
|
+
"expected_output": null,
|
|
167
|
+
"context": null,
|
|
168
|
+
"source_file": null
|
|
169
|
+
}
|
|
170
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Generate the markcut storyboard goldens with a token-capped OpenRouter model.
|
|
2
|
+
|
|
3
|
+
Run:
|
|
4
|
+
OPENROUTER_API_KEY=... .venv-evals/bin/python tests/evals/gen_dataset.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from deepeval.synthesizer import Synthesizer
|
|
10
|
+
from deepeval.synthesizer.config import StylingConfig
|
|
11
|
+
|
|
12
|
+
from openrouter_model import OpenRouterLLM
|
|
13
|
+
|
|
14
|
+
SCENARIO = (
|
|
15
|
+
"Video creators and AI agents using the markcut markdown-to-video skill "
|
|
16
|
+
"to author video storyboards from a brief. markcut uses a markdown "
|
|
17
|
+
"descriptive format: '## scene' headings, '- image prompt:...' bullets, "
|
|
18
|
+
"script \"...\" narration, isBackground:true for visuals under scripted "
|
|
19
|
+
"scenes, map stream type with view/tween camera moves for route vlogs, "
|
|
20
|
+
"built-in components, TTS/TTI/STT media pipeline, and golden rules such "
|
|
21
|
+
"as never set duration on scripted scenes, keep manual assets in the "
|
|
22
|
+
"assets/ folder, and review md then compiled json then rendered video."
|
|
23
|
+
)
|
|
24
|
+
TASK = (
|
|
25
|
+
"Act as a storyboard author: given a video brief, produce a markcut "
|
|
26
|
+
"storyboard in markdown descriptive format that follows the skill's "
|
|
27
|
+
"scene/isBackground/duration rules and narrative structure (hook, "
|
|
28
|
+
"conflict, resolution, emotion, call to action)."
|
|
29
|
+
)
|
|
30
|
+
INPUT_FORMAT = (
|
|
31
|
+
"A short video brief, e.g. 'a 30s travel vlog of a coastal route with "
|
|
32
|
+
"narration', 'a product launch teaser with stats', 'a two-speaker "
|
|
33
|
+
"dialogue explainer', 'a recipe short with background music'."
|
|
34
|
+
)
|
|
35
|
+
EXPECTED_OUTPUT_FORMAT = (
|
|
36
|
+
"A complete markcut storyboard markdown using ## scene headings, "
|
|
37
|
+
"- image/script bullets, correct isBackground usage, and no manual "
|
|
38
|
+
"durations on scripted scenes."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
model = OpenRouterLLM()
|
|
44
|
+
synthesizer = Synthesizer(
|
|
45
|
+
model=model,
|
|
46
|
+
styling_config=StylingConfig(
|
|
47
|
+
scenario=SCENARIO,
|
|
48
|
+
task=TASK,
|
|
49
|
+
input_format=INPUT_FORMAT,
|
|
50
|
+
expected_output_format=EXPECTED_OUTPUT_FORMAT,
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
goldens = synthesizer.generate_goldens_from_scratch(num_goldens=12)
|
|
54
|
+
synthesizer.save_as(
|
|
55
|
+
file_type="json",
|
|
56
|
+
directory="tests/evals",
|
|
57
|
+
file_name="dataset",
|
|
58
|
+
)
|
|
59
|
+
print(f"Saved {len(goldens)} goldens to tests/evals/dataset.json")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|