@navipartner/demo-video 0.0.0-stage → 0.4.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/README.md +109 -3
- package/bin/args.d.mts +30 -0
- package/bin/args.mjs +153 -0
- package/bin/demo-video.mjs +272 -0
- package/bin/encode.mjs +187 -0
- package/bin/init.d.mts +24 -0
- package/bin/init.mjs +447 -0
- package/bin/pick.d.mts +18 -0
- package/bin/pick.mjs +252 -0
- package/bin/runner.d.mts +5 -0
- package/bin/runner.mjs +47 -0
- package/brands/navipartner/logo.svg +19 -0
- package/brands/navipartner.json +22 -0
- package/dist/adapter-CWqMoNdw.d.ts +157 -0
- package/dist/chunk-BYAFDWPA.js +768 -0
- package/dist/index.d.ts +255 -0
- package/dist/index.js +56 -0
- package/dist/runner/index.d.ts +23 -0
- package/dist/runner/index.js +1258 -0
- package/package.json +67 -3
- package/skills/np-demo-video/SKILL.md +348 -0
- package/skills/np-demo-video/look.md +158 -0
- package/skills/np-demo-video/reference.md +82 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
// src/storyboard.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// src/timing.ts
|
|
7
|
+
var ZOOM_MS = 1200;
|
|
8
|
+
var CAMERA_EASE = "cubic-bezier(0.65, 0, 0.35, 1)";
|
|
9
|
+
var CARD_IN_MS = 800;
|
|
10
|
+
var CARD_OUT_MS = 900;
|
|
11
|
+
var CURSOR_MIN_MS = 550;
|
|
12
|
+
var CURSOR_MAX_MS = 1100;
|
|
13
|
+
var CURSOR_MS_PER_PX = 0.9;
|
|
14
|
+
var SETTLE_MS = 300;
|
|
15
|
+
var PRE_CLICK_MS = 220;
|
|
16
|
+
var PRESS_MS = 110;
|
|
17
|
+
var POST_CLICK_MS = 650;
|
|
18
|
+
var POST_KEY_MS = 300;
|
|
19
|
+
var POST_ACTION_MS = 650;
|
|
20
|
+
var LINGER_MS = 1400;
|
|
21
|
+
var POLL_MS = 100;
|
|
22
|
+
var wordCount = (text) => text ? text.replace(/\*/g, "").split(/\s+/).filter(Boolean).length : 0;
|
|
23
|
+
var cardSeconds = (spec) => {
|
|
24
|
+
const read = 1 + 0.32 * (wordCount(spec.eyebrow) + wordCount(spec.heading) + wordCount(spec.sub));
|
|
25
|
+
return Math.max(spec.seconds, Math.round(read * 10) / 10);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/stage.ts
|
|
29
|
+
var STAGE_PATH = "/__demo/stage";
|
|
30
|
+
var STAGE_WIDTH = 1920;
|
|
31
|
+
var STAGE_HEIGHT = 1080;
|
|
32
|
+
var FRAME_WIDTH = 1728;
|
|
33
|
+
var FRAME_HEIGHT = 972;
|
|
34
|
+
var DEFAULT_VIEWPORT = { width: FRAME_WIDTH, height: FRAME_HEIGHT };
|
|
35
|
+
var PARK = { x: STAGE_WIDTH * 0.6, y: STAGE_HEIGHT * 0.66 };
|
|
36
|
+
var stageLayout = (viewport = DEFAULT_VIEWPORT) => {
|
|
37
|
+
const fit = Math.min(
|
|
38
|
+
FRAME_WIDTH / viewport.width,
|
|
39
|
+
FRAME_HEIGHT / viewport.height
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
viewport,
|
|
43
|
+
fit,
|
|
44
|
+
left: (STAGE_WIDTH - viewport.width * fit) / 2,
|
|
45
|
+
top: (STAGE_HEIGHT - viewport.height * fit) / 2
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
var GRAIN = `data:image/svg+xml,${encodeURIComponent(
|
|
49
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="160" height="160" filter="url(#g)"/></svg>'
|
|
50
|
+
)}`;
|
|
51
|
+
var stageScript = (layout, look) => String.raw`
|
|
52
|
+
(() => {
|
|
53
|
+
const camera = document.getElementById("camera");
|
|
54
|
+
const cursor = document.getElementById("cursor");
|
|
55
|
+
const LOGO_URL = ${JSON.stringify(look.logoUrl ?? null)};
|
|
56
|
+
|
|
57
|
+
const F = ${layout.fit};
|
|
58
|
+
const PARK_X = ${PARK.x}, PARK_Y = ${PARK.y};
|
|
59
|
+
const CARD_IN_MS = ${CARD_IN_MS}, CARD_OUT_MS = ${CARD_OUT_MS};
|
|
60
|
+
const cursorMs = (dist) =>
|
|
61
|
+
Math.round(Math.min(${CURSOR_MAX_MS}, Math.max(${CURSOR_MIN_MS}, dist * ${CURSOR_MS_PER_PX})));
|
|
62
|
+
|
|
63
|
+
let cursorX = PARK_X, cursorY = PARK_Y;
|
|
64
|
+
|
|
65
|
+
// Nothing here waits: time is virtual (frames.ts), so each call starts a
|
|
66
|
+
// move and returns at once, and the director films its duration.
|
|
67
|
+
|
|
68
|
+
// A shot from camera.ts: the app's translation and total scale.
|
|
69
|
+
const setShot = (shot) => {
|
|
70
|
+
camera.style.transform = "translate(" + shot.tx + "px, " + shot.ty + "px) scale(" + shot.k + ")";
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Behind an opaque card: back to wide with no move, so every chapter opens
|
|
74
|
+
// on the whole window.
|
|
75
|
+
const resetCamera = () => {
|
|
76
|
+
camera.classList.add("instant");
|
|
77
|
+
camera.style.transform = "translate(0px, 0px) scale(" + F + ")";
|
|
78
|
+
void camera.offsetWidth;
|
|
79
|
+
camera.classList.remove("instant");
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// A quadratic Bezier with a slight bow, eased in and out, so the cursor
|
|
83
|
+
// moves like a hand rather than a robot. The bow is kept small: a visible
|
|
84
|
+
// swoop reads as showing off. Returns the move's duration.
|
|
85
|
+
const cursorTo = (x, y) => {
|
|
86
|
+
const dx = x - cursorX, dy = y - cursorY;
|
|
87
|
+
const dist = Math.hypot(dx, dy);
|
|
88
|
+
if (dist < 1) { return 0; }
|
|
89
|
+
const ms = cursorMs(dist);
|
|
90
|
+
const bow = Math.min(40, dist * 0.06);
|
|
91
|
+
const mx = cursorX + dx / 2 - dy / dist * bow || cursorX;
|
|
92
|
+
const my = cursorY + dy / 2 + dx / dist * bow || cursorY;
|
|
93
|
+
const steps = 24;
|
|
94
|
+
const frames = [];
|
|
95
|
+
for (let i = 0; i <= steps; i++) {
|
|
96
|
+
const t = i / steps;
|
|
97
|
+
const px = (1 - t) * (1 - t) * cursorX + 2 * (1 - t) * t * mx + t * t * x;
|
|
98
|
+
const py = (1 - t) * (1 - t) * cursorY + 2 * (1 - t) * t * my + t * t * y;
|
|
99
|
+
frames.push({ transform: "translate(" + px + "px, " + py + "px)" });
|
|
100
|
+
}
|
|
101
|
+
cursorX = x; cursorY = y;
|
|
102
|
+
// The resting transform is set first; the animation covers it until it
|
|
103
|
+
// ends, so the hand-off is seamless.
|
|
104
|
+
cursor.style.transform = "translate(" + x + "px, " + y + "px)";
|
|
105
|
+
cursor.animate(frames, { duration: ms, easing: "cubic-bezier(0.45, 0, 0.2, 1)" });
|
|
106
|
+
return ms;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Behind an opaque card: the cursor rests in empty space for the next chapter.
|
|
110
|
+
const parkNow = () => {
|
|
111
|
+
cursorX = PARK_X; cursorY = PARK_Y;
|
|
112
|
+
cursor.getAnimations().forEach((a) => a.cancel());
|
|
113
|
+
cursor.style.transform = "translate(" + cursorX + "px, " + cursorY + "px)";
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const escapeHtml = (text) =>
|
|
117
|
+
text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
118
|
+
// "Pay *their way*." becomes Pay <em>their way</em>.
|
|
119
|
+
const emphasise = (text) => escapeHtml(text).replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
120
|
+
|
|
121
|
+
// Each card is its own layer, so one card dissolves straight into the next
|
|
122
|
+
// (payoff into end card) without the app showing in between.
|
|
123
|
+
let current = null;
|
|
124
|
+
|
|
125
|
+
const buildCard = (spec) => {
|
|
126
|
+
const el = document.createElement("div");
|
|
127
|
+
el.className = "card " + (spec.style === "brand" ? "brand" : "pale");
|
|
128
|
+
const add = (tag, cls, html) => {
|
|
129
|
+
const child = document.createElement(tag);
|
|
130
|
+
child.className = cls;
|
|
131
|
+
child.innerHTML = html;
|
|
132
|
+
el.appendChild(child);
|
|
133
|
+
return child;
|
|
134
|
+
};
|
|
135
|
+
if (spec.eyebrow) { add("p", "eyebrow", escapeHtml(spec.eyebrow)); }
|
|
136
|
+
if (spec.logo && LOGO_URL) {
|
|
137
|
+
const logo = add("img", "logo", "");
|
|
138
|
+
logo.alt = "";
|
|
139
|
+
// A wordmark is sized by width, a square mark by height.
|
|
140
|
+
logo.onload = () => logo.classList.add(logo.naturalWidth > logo.naturalHeight * 3 ? "wide" : "square");
|
|
141
|
+
logo.src = LOGO_URL;
|
|
142
|
+
}
|
|
143
|
+
if (spec.heading) { add("h1", "heading", emphasise(spec.heading)); }
|
|
144
|
+
if (spec.sub) { add("p", "sub", escapeHtml(spec.sub)); }
|
|
145
|
+
return el;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// setTimeout runs on the virtual clock, so a removal lands on the frame
|
|
149
|
+
// its dissolve ends.
|
|
150
|
+
const showCard = (spec, instant) => {
|
|
151
|
+
const el = buildCard(spec);
|
|
152
|
+
const previous = current;
|
|
153
|
+
current = el;
|
|
154
|
+
if (instant) { el.classList.add("instant"); camera.classList.add("instant"); }
|
|
155
|
+
// Card to card: the old words dissolve first, then the new ones come in,
|
|
156
|
+
// so two headings never show at once.
|
|
157
|
+
if (previous && !instant) {
|
|
158
|
+
previous.classList.add("leaving");
|
|
159
|
+
el.classList.add("after");
|
|
160
|
+
}
|
|
161
|
+
document.body.appendChild(el);
|
|
162
|
+
void el.offsetWidth;
|
|
163
|
+
camera.classList.add("behind");
|
|
164
|
+
el.classList.add("show");
|
|
165
|
+
if (instant) {
|
|
166
|
+
void el.offsetWidth;
|
|
167
|
+
el.classList.remove("instant");
|
|
168
|
+
camera.classList.remove("instant");
|
|
169
|
+
if (previous) { previous.remove(); }
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (previous) { setTimeout(() => previous.remove(), CARD_IN_MS); }
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const hideCard = () => {
|
|
176
|
+
const el = current;
|
|
177
|
+
current = null;
|
|
178
|
+
if (!el) { return; }
|
|
179
|
+
el.classList.remove("show");
|
|
180
|
+
camera.classList.remove("behind");
|
|
181
|
+
setTimeout(() => el.remove(), CARD_OUT_MS);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
window.__demo = { setShot, resetCamera, cursorTo, parkNow, showCard, hideCard };
|
|
185
|
+
cursor.style.transform = "translate(" + cursorX + "px, " + cursorY + "px)";
|
|
186
|
+
})();
|
|
187
|
+
`;
|
|
188
|
+
var stageHtml = (background, appUrl, look, options = {}) => {
|
|
189
|
+
const layout = stageLayout(options.viewport);
|
|
190
|
+
const f = layout.fit;
|
|
191
|
+
const font = (name) => `"${name.replace(/"/g, "")}"`;
|
|
192
|
+
return `<!doctype html>
|
|
193
|
+
<html lang="en">
|
|
194
|
+
<head>
|
|
195
|
+
<meta charset="utf-8" />
|
|
196
|
+
<title>Demo stage</title>
|
|
197
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
198
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
199
|
+
<link href="${look.fontsUrl.replace(/"/g, """)}" rel="stylesheet" />
|
|
200
|
+
<style>
|
|
201
|
+
html, body { margin: 0; width: ${STAGE_WIDTH}px; height: ${STAGE_HEIGHT}px; overflow: hidden; }
|
|
202
|
+
body {
|
|
203
|
+
position: relative;
|
|
204
|
+
background: ${background};
|
|
205
|
+
font-family: ${font(look.bodyFont)}, system-ui, sans-serif;
|
|
206
|
+
color: ${look.text};
|
|
207
|
+
}
|
|
208
|
+
#camera {
|
|
209
|
+
position: absolute;
|
|
210
|
+
left: ${layout.left}px; top: ${layout.top}px;
|
|
211
|
+
width: ${layout.viewport.width}px; height: ${layout.viewport.height}px;
|
|
212
|
+
transform-origin: 0 0;
|
|
213
|
+
transform: translate(0px, 0px) scale(${f});
|
|
214
|
+
transition: transform ${ZOOM_MS}ms ${CAMERA_EASE}, filter ${CARD_OUT_MS}ms ease;
|
|
215
|
+
border-radius: ${14 / f}px;
|
|
216
|
+
box-shadow: 0 ${28 / f}px ${90 / f}px rgba(15, 23, 42, 0.16), 0 ${2 / f}px ${8 / f}px rgba(15, 23, 42, 0.08);
|
|
217
|
+
overflow: hidden;
|
|
218
|
+
background: #fff;
|
|
219
|
+
/* No will-change: it would rasterise the app once and scale the bitmap,
|
|
220
|
+
so zoomed-in text would blur. */
|
|
221
|
+
}
|
|
222
|
+
#camera.behind {
|
|
223
|
+
filter: blur(${16 / f}px);
|
|
224
|
+
transition: transform ${ZOOM_MS}ms ${CAMERA_EASE}, filter ${CARD_IN_MS}ms ease;
|
|
225
|
+
}
|
|
226
|
+
#camera.instant { transition: none; }
|
|
227
|
+
#app {
|
|
228
|
+
display: block; border: 0;
|
|
229
|
+
width: ${layout.viewport.width}px; height: ${layout.viewport.height}px;
|
|
230
|
+
}
|
|
231
|
+
#cursor {
|
|
232
|
+
position: absolute; left: 0; top: 0;
|
|
233
|
+
width: 30px; height: 40px;
|
|
234
|
+
pointer-events: none; z-index: 30;
|
|
235
|
+
filter: drop-shadow(0 3px 6px rgba(0,0,0,0.35));
|
|
236
|
+
}
|
|
237
|
+
/* Full-frame text cards (look.md). Opaque, so the app is never read
|
|
238
|
+
through them; the dissolve is the card fading while the app blurs. */
|
|
239
|
+
.card {
|
|
240
|
+
position: absolute; inset: 0; z-index: 40; isolation: isolate;
|
|
241
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 22px;
|
|
242
|
+
padding: 0 240px; text-align: center;
|
|
243
|
+
opacity: 0; transition: opacity ${CARD_OUT_MS}ms ease;
|
|
244
|
+
pointer-events: none;
|
|
245
|
+
}
|
|
246
|
+
.card.show { opacity: 1; transition: opacity ${CARD_IN_MS}ms ease; }
|
|
247
|
+
.card.pale { background: ${background}; }
|
|
248
|
+
.card.brand {
|
|
249
|
+
background: radial-gradient(1200px 800px at 20% 10%, rgba(255,255,255,0.10), transparent 60%), ${look.primary};
|
|
250
|
+
}
|
|
251
|
+
.card.instant, .card.instant * { transition: none !important; }
|
|
252
|
+
.card > * {
|
|
253
|
+
margin: 0; opacity: 0; filter: blur(12px);
|
|
254
|
+
transition: opacity 400ms ease, filter 400ms ease;
|
|
255
|
+
}
|
|
256
|
+
.card.show > * { opacity: 1; filter: blur(0); transition: opacity 600ms ease, filter 600ms ease; }
|
|
257
|
+
.card.show > .eyebrow, .card.show > .logo { transition-delay: 150ms; }
|
|
258
|
+
.card.show > .heading { transition-delay: 260ms; }
|
|
259
|
+
.card.show > .sub { transition-delay: 700ms; }
|
|
260
|
+
/* Beats every .card.show rule above (same specificity, declared later). */
|
|
261
|
+
.card.show.leaving > *, .card.show.leaving > .heading, .card.show.leaving > .sub,
|
|
262
|
+
.card.show.leaving > .eyebrow, .card.show.leaving > .logo {
|
|
263
|
+
opacity: 0; filter: blur(12px);
|
|
264
|
+
transition: opacity 280ms ease 0ms, filter 280ms ease 0ms;
|
|
265
|
+
}
|
|
266
|
+
.card.show.after > .eyebrow, .card.show.after > .logo { transition-delay: 420ms; }
|
|
267
|
+
.card.show.after > .heading { transition-delay: 520ms; }
|
|
268
|
+
.card.show.after > .sub { transition-delay: 900ms; }
|
|
269
|
+
/* Faint grain over the background and cards: it dithers their soft
|
|
270
|
+
gradients, which in 8-bit video would band and block. */
|
|
271
|
+
body::before, .card::before {
|
|
272
|
+
content: ""; position: absolute; inset: 0; pointer-events: none;
|
|
273
|
+
background-image: url("${GRAIN}");
|
|
274
|
+
background-size: 160px 160px;
|
|
275
|
+
opacity: 0.045;
|
|
276
|
+
}
|
|
277
|
+
.card::before { z-index: -1; }
|
|
278
|
+
.card .eyebrow { font-size: 24px; font-weight: 600; letter-spacing: 0.02em; color: #707070; }
|
|
279
|
+
.card .logo.wide { width: 720px; height: auto; }
|
|
280
|
+
.card .logo.square { height: 240px; width: auto; }
|
|
281
|
+
.card .heading {
|
|
282
|
+
font-family: ${font(look.headingFont)}, Georgia, serif;
|
|
283
|
+
font-size: 76px; font-weight: 400; line-height: 1.15; letter-spacing: -0.01em;
|
|
284
|
+
color: ${look.ink};
|
|
285
|
+
}
|
|
286
|
+
.card .heading em { font-style: italic; color: ${look.primary}; }
|
|
287
|
+
.card .sub { font-size: 28px; font-weight: 400; color: ${look.text}; opacity: 0.8; }
|
|
288
|
+
.card.brand .heading, .card.brand .heading em { color: #fff; }
|
|
289
|
+
.card.brand .sub, .card.brand .eyebrow { color: rgba(255,255,255,0.82); }
|
|
290
|
+
.card.brand .logo { filter: brightness(0) invert(1); }
|
|
291
|
+
</style>
|
|
292
|
+
</head>
|
|
293
|
+
<body>
|
|
294
|
+
<div id="camera"><iframe id="app" name="app" src="${appUrl}" title="App"></iframe></div>
|
|
295
|
+
<svg id="cursor" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg">
|
|
296
|
+
<path d="M3 2 L3 30 L10.5 23.5 L16 36 L21 33.5 L15.5 21 L26 21 Z" fill="#fff" stroke="#0f172a" stroke-width="2.2" stroke-linejoin="round"/>
|
|
297
|
+
</svg>
|
|
298
|
+
<script>${stageScript(layout, look)}</script>
|
|
299
|
+
</body>
|
|
300
|
+
</html>`;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// src/camera.ts
|
|
304
|
+
var EDGE = 40;
|
|
305
|
+
var MIN_SCALE = 1.3;
|
|
306
|
+
var MAX_SCALE = 2;
|
|
307
|
+
var FRAME_PADDING = 96;
|
|
308
|
+
var clamp = (t, size, full, offset) => {
|
|
309
|
+
if (size >= full) {
|
|
310
|
+
return Math.max(Math.min(t, -offset), full - offset - size);
|
|
311
|
+
}
|
|
312
|
+
const edge = Math.min(EDGE, (full - size) / 2);
|
|
313
|
+
return Math.min(Math.max(t, edge - offset), full - edge - offset - size);
|
|
314
|
+
};
|
|
315
|
+
var shotFor = (layout, rect, scale2) => {
|
|
316
|
+
const k = scale2 * layout.fit;
|
|
317
|
+
const cx = rect.x + rect.width / 2;
|
|
318
|
+
const cy = rect.y + rect.height / 2;
|
|
319
|
+
return {
|
|
320
|
+
k,
|
|
321
|
+
tx: clamp(
|
|
322
|
+
STAGE_WIDTH / 2 - layout.left - k * cx,
|
|
323
|
+
layout.viewport.width * k,
|
|
324
|
+
STAGE_WIDTH,
|
|
325
|
+
layout.left
|
|
326
|
+
),
|
|
327
|
+
ty: clamp(
|
|
328
|
+
STAGE_HEIGHT / 2 - layout.top - k * cy,
|
|
329
|
+
layout.viewport.height * k,
|
|
330
|
+
STAGE_HEIGHT,
|
|
331
|
+
layout.top
|
|
332
|
+
)
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
var wideShot = (layout) => ({
|
|
336
|
+
tx: 0,
|
|
337
|
+
ty: 0,
|
|
338
|
+
k: layout.fit
|
|
339
|
+
});
|
|
340
|
+
var visibleRect = (layout, shot) => {
|
|
341
|
+
const x0 = Math.max(0, (0 - layout.left - shot.tx) / shot.k);
|
|
342
|
+
const y0 = Math.max(0, (0 - layout.top - shot.ty) / shot.k);
|
|
343
|
+
const x1 = Math.min(
|
|
344
|
+
layout.viewport.width,
|
|
345
|
+
(STAGE_WIDTH - layout.left - shot.tx) / shot.k
|
|
346
|
+
);
|
|
347
|
+
const y1 = Math.min(
|
|
348
|
+
layout.viewport.height,
|
|
349
|
+
(STAGE_HEIGHT - layout.top - shot.ty) / shot.k
|
|
350
|
+
);
|
|
351
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
352
|
+
};
|
|
353
|
+
var shows = (layout, shot, rect) => {
|
|
354
|
+
const v = visibleRect(layout, shot);
|
|
355
|
+
const slack = 4;
|
|
356
|
+
return rect.x >= v.x - slack && rect.y >= v.y - slack && rect.x + rect.width <= v.x + v.width + slack && rect.y + rect.height <= v.y + v.height + slack;
|
|
357
|
+
};
|
|
358
|
+
var coverage = (rect, area) => {
|
|
359
|
+
const ix = Math.max(
|
|
360
|
+
0,
|
|
361
|
+
Math.min(rect.x + rect.width, area.x + area.width) - Math.max(rect.x, area.x)
|
|
362
|
+
);
|
|
363
|
+
const iy = Math.max(
|
|
364
|
+
0,
|
|
365
|
+
Math.min(rect.y + rect.height, area.y + area.height) - Math.max(rect.y, area.y)
|
|
366
|
+
);
|
|
367
|
+
return ix * iy / (rect.width * rect.height);
|
|
368
|
+
};
|
|
369
|
+
var unionRect = (rects) => {
|
|
370
|
+
const x0 = Math.min(...rects.map((r) => r.x));
|
|
371
|
+
const y0 = Math.min(...rects.map((r) => r.y));
|
|
372
|
+
const x1 = Math.max(...rects.map((r) => r.x + r.width));
|
|
373
|
+
const y1 = Math.max(...rects.map((r) => r.y + r.height));
|
|
374
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
375
|
+
};
|
|
376
|
+
var fittingScale = (layout, rect) => Math.min(
|
|
377
|
+
(STAGE_WIDTH - 2 * FRAME_PADDING) / rect.width,
|
|
378
|
+
(STAGE_HEIGHT - 2 * FRAME_PADDING) / rect.height
|
|
379
|
+
) / layout.fit;
|
|
380
|
+
|
|
381
|
+
// src/storyboard.ts
|
|
382
|
+
var target = {
|
|
383
|
+
target: z.string().min(1),
|
|
384
|
+
nth: z.number().int().optional()
|
|
385
|
+
};
|
|
386
|
+
var scale = z.number().min(1).max(3);
|
|
387
|
+
var seconds = z.number().min(0.5).max(60);
|
|
388
|
+
var cardFields = {
|
|
389
|
+
eyebrow: z.string().min(1).max(40).optional(),
|
|
390
|
+
heading: z.string().min(1).max(80).optional(),
|
|
391
|
+
sub: z.string().min(1).max(140).optional(),
|
|
392
|
+
/** Show the brand's logo (open and end cards). */
|
|
393
|
+
logo: z.boolean().optional(),
|
|
394
|
+
/** "pale" = brand-tinted white; "brand" = full-bleed primary colour. */
|
|
395
|
+
style: z.enum(["pale", "brand"]).default("pale"),
|
|
396
|
+
seconds: z.number().min(0.5).max(10).default(2.5)
|
|
397
|
+
};
|
|
398
|
+
var hasContent = (c) => Boolean(c.heading || c.logo);
|
|
399
|
+
var CARD_EMPTY = "a card needs a heading or the logo";
|
|
400
|
+
var coreStepSchemas = [
|
|
401
|
+
z.object({
|
|
402
|
+
do: z.literal("click"),
|
|
403
|
+
...target,
|
|
404
|
+
zoom: scale.optional()
|
|
405
|
+
}).strict(),
|
|
406
|
+
z.object({
|
|
407
|
+
do: z.literal("type"),
|
|
408
|
+
...target,
|
|
409
|
+
text: z.string(),
|
|
410
|
+
perCharMs: z.number().int().min(0).max(500).optional()
|
|
411
|
+
}).strict(),
|
|
412
|
+
z.object({ do: z.literal("press"), key: z.string().min(1) }).strict(),
|
|
413
|
+
z.object({
|
|
414
|
+
do: z.literal("zoom"),
|
|
415
|
+
...target,
|
|
416
|
+
scale: scale.default(1.4)
|
|
417
|
+
}).strict(),
|
|
418
|
+
z.object({
|
|
419
|
+
do: z.literal("frame"),
|
|
420
|
+
// A button and what it changes: 2–4 targets in one shot.
|
|
421
|
+
targets: z.array(z.union([z.string().min(1), z.object(target).strict()])).min(2).max(4),
|
|
422
|
+
maxScale: scale.optional()
|
|
423
|
+
}).strict(),
|
|
424
|
+
z.object({ do: z.literal("zoomOut"), park: z.boolean().optional() }).strict(),
|
|
425
|
+
z.object({ do: z.literal("park") }).strict(),
|
|
426
|
+
z.object({
|
|
427
|
+
do: z.literal("waitFor"),
|
|
428
|
+
...target,
|
|
429
|
+
hidden: z.boolean().optional(),
|
|
430
|
+
timeout: seconds.default(15)
|
|
431
|
+
}).strict(),
|
|
432
|
+
z.object({
|
|
433
|
+
do: z.literal("expectText"),
|
|
434
|
+
...target,
|
|
435
|
+
text: z.string(),
|
|
436
|
+
contains: z.boolean().optional(),
|
|
437
|
+
timeout: seconds.default(10)
|
|
438
|
+
}).strict(),
|
|
439
|
+
z.object({ do: z.literal("card"), ...cardFields }).strict(),
|
|
440
|
+
z.object({
|
|
441
|
+
do: z.literal("hold"),
|
|
442
|
+
seconds: z.number().min(0).max(10)
|
|
443
|
+
}).strict()
|
|
444
|
+
];
|
|
445
|
+
var CORE_STEP_NAMES = coreStepSchemas.map(
|
|
446
|
+
(s) => s.shape.do.value
|
|
447
|
+
);
|
|
448
|
+
var RESERVED_FIELDS = ["do"];
|
|
449
|
+
var cardSchema = z.object(cardFields).strict().refine(hasContent, CARD_EMPTY);
|
|
450
|
+
var isCoreStep = (step) => CORE_STEP_NAMES.includes(step.do);
|
|
451
|
+
var buildStoryboardSchema = (adapter) => {
|
|
452
|
+
const actionSchemas = Object.entries(adapter.actions ?? {}).map(
|
|
453
|
+
([name, action]) => {
|
|
454
|
+
if (CORE_STEP_NAMES.includes(name)) {
|
|
455
|
+
throw new Error(`Adapter action "${name}" collides with a core step`);
|
|
456
|
+
}
|
|
457
|
+
for (const field of RESERVED_FIELDS) {
|
|
458
|
+
if (field in action.params.shape) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`Adapter action "${name}" declares a "${field}" param, which collides with a core field`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return z.object({ do: z.literal(name) }).merge(action.params).strict();
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
const stepSchema = z.discriminatedUnion("do", [
|
|
468
|
+
...coreStepSchemas,
|
|
469
|
+
...actionSchemas
|
|
470
|
+
]);
|
|
471
|
+
return z.object({
|
|
472
|
+
app: z.literal(adapter.app),
|
|
473
|
+
description: z.string().min(1).max(280).optional(),
|
|
474
|
+
primary: z.boolean().optional(),
|
|
475
|
+
title: cardSchema.optional(),
|
|
476
|
+
closing: cardSchema.optional(),
|
|
477
|
+
steps: z.array(stepSchema).min(1)
|
|
478
|
+
}).strict().superRefine((story, ctx) => {
|
|
479
|
+
story.steps.forEach((step, index) => {
|
|
480
|
+
const s = step;
|
|
481
|
+
if (s.do === "card" && !hasContent(s)) {
|
|
482
|
+
ctx.addIssue({
|
|
483
|
+
code: "custom",
|
|
484
|
+
path: ["steps", index],
|
|
485
|
+
message: CARD_EMPTY
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
var StoryboardError = class extends Error {
|
|
492
|
+
};
|
|
493
|
+
var parseStoryboard = (raw, adapter, source = "storyboard") => {
|
|
494
|
+
const result = buildStoryboardSchema(adapter).safeParse(raw);
|
|
495
|
+
if (result.success) {
|
|
496
|
+
return result.data;
|
|
497
|
+
}
|
|
498
|
+
const lines = result.error.issues.map((issue) => {
|
|
499
|
+
const where = issue.path.length ? issue.path.join(".") : "(root)";
|
|
500
|
+
return ` ${where}: ${issue.message}`;
|
|
501
|
+
});
|
|
502
|
+
throw new StoryboardError(`Invalid ${source}:
|
|
503
|
+
${lines.join("\n")}`);
|
|
504
|
+
};
|
|
505
|
+
var cardChange = z.object({
|
|
506
|
+
eyebrow: cardFields.eyebrow,
|
|
507
|
+
heading: cardFields.heading,
|
|
508
|
+
sub: cardFields.sub,
|
|
509
|
+
logo: cardFields.logo,
|
|
510
|
+
style: z.enum(["pale", "brand"]).optional(),
|
|
511
|
+
seconds: z.number().min(0.5).max(10).optional()
|
|
512
|
+
}).strict();
|
|
513
|
+
var variantSchema = z.object({
|
|
514
|
+
extends: z.string().regex(/^[\w-]+$/, "the base story's name"),
|
|
515
|
+
brand: z.string().regex(/^[\w-]+$/, "a brand name"),
|
|
516
|
+
description: z.string().min(1).max(280).optional(),
|
|
517
|
+
title: cardChange.optional(),
|
|
518
|
+
closing: cardChange.optional(),
|
|
519
|
+
cards: z.array(cardChange).optional()
|
|
520
|
+
}).strict();
|
|
521
|
+
var readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
522
|
+
var isVariant = (raw) => typeof raw === "object" && raw !== null && "extends" in raw;
|
|
523
|
+
var loadVariant = (raw, file, adapter) => {
|
|
524
|
+
const name = path.basename(file);
|
|
525
|
+
const result = variantSchema.safeParse(raw);
|
|
526
|
+
if (!result.success) {
|
|
527
|
+
const lines = result.error.issues.map(
|
|
528
|
+
(i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`
|
|
529
|
+
);
|
|
530
|
+
throw new StoryboardError(`Invalid ${name}:
|
|
531
|
+
${lines.join("\n")}`);
|
|
532
|
+
}
|
|
533
|
+
const variant = result.data;
|
|
534
|
+
const baseFile = path.join(path.dirname(file), `${variant.extends}.json`);
|
|
535
|
+
if (!fs.existsSync(baseFile)) {
|
|
536
|
+
throw new StoryboardError(
|
|
537
|
+
`${name} extends "${variant.extends}", but there is no ${variant.extends}.json beside it`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const base = readJson(baseFile);
|
|
541
|
+
if (isVariant(base)) {
|
|
542
|
+
throw new StoryboardError(
|
|
543
|
+
`${name} extends ${variant.extends}.json, which is a variant itself; extend the story it extends`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const steps = Array.isArray(base.steps) ? base.steps : [];
|
|
547
|
+
const cardAt = steps.flatMap((s, i) => s.do === "card" ? [i] : []);
|
|
548
|
+
if (variant.cards && variant.cards.length !== cardAt.length) {
|
|
549
|
+
throw new StoryboardError(
|
|
550
|
+
`${name} gives ${variant.cards.length} card${variant.cards.length === 1 ? "" : "s"}; ${variant.extends}.json has ${cardAt.length}. A variant gives every card of its story, in order.`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const change = (card, over) => over ? { ...card, ...over } : card;
|
|
554
|
+
const merged = steps.map(
|
|
555
|
+
(step, i) => variant.cards ? change(step, variant.cards[cardAt.indexOf(i)]) : step
|
|
556
|
+
);
|
|
557
|
+
const rest = { ...base };
|
|
558
|
+
delete rest.primary;
|
|
559
|
+
const story = parseStoryboard(
|
|
560
|
+
{
|
|
561
|
+
...rest,
|
|
562
|
+
description: variant.description ?? base.description,
|
|
563
|
+
title: change(base.title, variant.title),
|
|
564
|
+
closing: change(base.closing, variant.closing),
|
|
565
|
+
steps: merged
|
|
566
|
+
},
|
|
567
|
+
adapter,
|
|
568
|
+
`${name} (on ${variant.extends}.json)`
|
|
569
|
+
);
|
|
570
|
+
return { ...story, brand: variant.brand, variantOf: variant.extends };
|
|
571
|
+
};
|
|
572
|
+
var loadStoryboard = (file, adapter) => {
|
|
573
|
+
const raw = readJson(file);
|
|
574
|
+
return isVariant(raw) ? loadVariant(raw, file, adapter) : parseStoryboard(raw, adapter, path.basename(file));
|
|
575
|
+
};
|
|
576
|
+
var MIN_ZOOM = MIN_SCALE;
|
|
577
|
+
var MAX_ZOOM = MAX_SCALE;
|
|
578
|
+
var MAX_PUSHES_PER_CHAPTER = 2;
|
|
579
|
+
var MAX_HOLD_SECONDS = 6;
|
|
580
|
+
var MIN_PER_CHAR_MS = 40;
|
|
581
|
+
var MAX_WORDS = { eyebrow: 4, heading: 8, sub: 16 };
|
|
582
|
+
var CODE_ON_CARD = [
|
|
583
|
+
// Hyphenated with a digit (quick-add-978…) or ending in a UI part
|
|
584
|
+
// (welcome-begin-button): prose like "made-to-order" passes.
|
|
585
|
+
[
|
|
586
|
+
/\b[a-z][a-z0-9]*(?:-[a-z0-9]+)*-(?:[a-z]*\d[a-z0-9]*|button|btn|input|row|modal|dialog|view|page|list|item|tile|container|wrapper|field|label|icon|link|menu|panel|section|header|footer)\b/i,
|
|
587
|
+
"a testid-like name"
|
|
588
|
+
],
|
|
589
|
+
[/\d{5,}/, "a barcode or id"],
|
|
590
|
+
[/\b[a-z]+_[a-z0-9_]+\b/i, "a snake_case name"],
|
|
591
|
+
[/\b(?![ie][A-Z])[a-z]+[A-Z][A-Za-z]+\b/, "a camelCase name"],
|
|
592
|
+
[
|
|
593
|
+
/data-testid|\bundefined\b|\bnull\b|\bNaN\b|TODO|\{\{|\}\}|<\/?\w+>/,
|
|
594
|
+
"code"
|
|
595
|
+
]
|
|
596
|
+
];
|
|
597
|
+
var INTERACTIONS = /* @__PURE__ */ new Set(["click", "type", "press"]);
|
|
598
|
+
var isInteraction = (step) => INTERACTIONS.has(step.do) || !isCoreStep(step);
|
|
599
|
+
var lintStoryboard = (story) => {
|
|
600
|
+
const issues = [];
|
|
601
|
+
const steps = story.steps;
|
|
602
|
+
if (!story.title) {
|
|
603
|
+
issues.push("title: a video opens on a title card (the logo, a tagline)");
|
|
604
|
+
}
|
|
605
|
+
if (!story.closing) {
|
|
606
|
+
issues.push(
|
|
607
|
+
"closing: a video ends on a closing card (the logo, held still)"
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
const numbersIn = (text) => text.match(/\d+(?:[.,:]\d+)*/g) ?? [];
|
|
611
|
+
const shown = new Set(
|
|
612
|
+
steps.filter((s) => s.do === "expectText").flatMap(
|
|
613
|
+
(s) => numbersIn(normaliseText(String(s.text)))
|
|
614
|
+
)
|
|
615
|
+
);
|
|
616
|
+
const targets = steps.map((s) => s.target).filter(
|
|
617
|
+
(t) => typeof t === "string" && /[-_]|[a-z][A-Z]|[=[\]()/]/.test(t)
|
|
618
|
+
);
|
|
619
|
+
const escape = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
620
|
+
const lintCard = (where, card) => {
|
|
621
|
+
for (const field of ["eyebrow", "heading", "sub"]) {
|
|
622
|
+
const text = card[field];
|
|
623
|
+
if (!text) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (wordCount(text) > MAX_WORDS[field]) {
|
|
627
|
+
issues.push(
|
|
628
|
+
`${where}.${field}: ${wordCount(text)} words; at most ${MAX_WORDS[field]} read at a glance`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
for (const [pattern, what] of CODE_ON_CARD) {
|
|
632
|
+
const hit = pattern.exec(text);
|
|
633
|
+
if (hit) {
|
|
634
|
+
issues.push(
|
|
635
|
+
`${where}.${field}: "${hit[0]}" looks like ${what}; cards are for people`
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (const t of targets) {
|
|
640
|
+
if (new RegExp(`(^|[^\\w-])${escape(t)}($|[^\\w-])`, "i").test(text)) {
|
|
641
|
+
issues.push(`${where}.${field}: shows the selector "${t}"`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const number of numbersIn(text)) {
|
|
645
|
+
const year = /^(19|20)\d\d$/.test(number);
|
|
646
|
+
if (!year && !shown.has(number)) {
|
|
647
|
+
issues.push(
|
|
648
|
+
`${where}.${field}: "${number}" isn't shown by an expectText in the story; a card only states what the video shows`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const heading = card.heading ?? "";
|
|
654
|
+
const stars = (heading.match(/\*/g) ?? []).length;
|
|
655
|
+
if (stars % 2 !== 0) {
|
|
656
|
+
issues.push(`${where}.heading: an unclosed *emphasis*`);
|
|
657
|
+
} else if (stars > 2) {
|
|
658
|
+
issues.push(`${where}.heading: one *italic* word or phrase per card`);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
if (story.title) {
|
|
662
|
+
lintCard("title", story.title);
|
|
663
|
+
}
|
|
664
|
+
if (story.closing) {
|
|
665
|
+
lintCard("closing", story.closing);
|
|
666
|
+
}
|
|
667
|
+
let chapterStart = 0;
|
|
668
|
+
let pushes = [];
|
|
669
|
+
const endChapter = (end) => {
|
|
670
|
+
if (pushes.length > MAX_PUSHES_PER_CHAPTER) {
|
|
671
|
+
issues.push(
|
|
672
|
+
`steps.${chapterStart}-${end - 1}: ${pushes.length} camera push-ins in one chapter (${pushes.join(", ")}); push in once on the area the chapter is about and hold steady through its clicks`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
pushes = [];
|
|
676
|
+
};
|
|
677
|
+
steps.forEach((step, index) => {
|
|
678
|
+
const where = `steps.${index}`;
|
|
679
|
+
const s = step;
|
|
680
|
+
if (step.do === "card") {
|
|
681
|
+
endChapter(index);
|
|
682
|
+
chapterStart = index + 1;
|
|
683
|
+
lintCard(where, s);
|
|
684
|
+
const rest = steps.slice(index + 1);
|
|
685
|
+
const nextCard = rest.findIndex((x) => x.do === "card");
|
|
686
|
+
const chapter = nextCard === -1 ? rest : rest.slice(0, nextCard);
|
|
687
|
+
if (chapter.length > 0 && !chapter.some(isInteraction)) {
|
|
688
|
+
issues.push(
|
|
689
|
+
`${where}: the card announces a chapter in which nothing is done; follow it with the steps it describes`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const zoom = step.do === "zoom" ? Number(s.scale) : step.do === "click" && s.zoom !== void 0 ? Number(s.zoom) : step.do === "frame" && s.maxScale !== void 0 ? Number(s.maxScale) : void 0;
|
|
695
|
+
if (zoom !== void 0 && (zoom < MIN_ZOOM || zoom > MAX_ZOOM)) {
|
|
696
|
+
issues.push(
|
|
697
|
+
`${where}: zoom ${zoom}; use ${MIN_ZOOM}\u2013${MAX_ZOOM} (tighter loses the context and softens the picture)`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
const shot = step.do === "frame" ? `frame ${s.targets.map((t) => typeof t === "string" ? t : t.target).join("+")}` : zoom !== void 0 ? `${String(s.target)}@${zoom}` : void 0;
|
|
701
|
+
if (shot && pushes.at(-1) !== shot) {
|
|
702
|
+
pushes.push(shot);
|
|
703
|
+
}
|
|
704
|
+
if (step.do === "zoom" || step.do === "frame") {
|
|
705
|
+
const rest = steps.slice(index + 1);
|
|
706
|
+
const end = rest.findIndex(
|
|
707
|
+
(x) => x.do === "zoom" || x.do === "frame" || x.do === "zoomOut" || x.do === "card"
|
|
708
|
+
);
|
|
709
|
+
const inShot = end === -1 ? rest : rest.slice(0, end);
|
|
710
|
+
const purposeful = inShot.some(
|
|
711
|
+
(x) => isInteraction(x) || x.do === "expectText" || x.do === "waitFor" || x.do === "hold" && Number(x.seconds) >= 1
|
|
712
|
+
);
|
|
713
|
+
if (!purposeful) {
|
|
714
|
+
issues.push(
|
|
715
|
+
`${where}: a zoom with nothing in its shot; push in only where something then happens or is read`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (step.do === "hold" && Number(s.seconds) > MAX_HOLD_SECONDS) {
|
|
720
|
+
issues.push(
|
|
721
|
+
`${where}: a ${String(s.seconds)} s hold is dead air; at most ${MAX_HOLD_SECONDS} s`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
if (step.do === "type" && s.perCharMs !== void 0 && Number(s.perCharMs) < MIN_PER_CHAR_MS) {
|
|
725
|
+
issues.push(
|
|
726
|
+
`${where}: typing faster than ${MIN_PER_CHAR_MS} ms a character can't be read`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
endChapter(steps.length);
|
|
731
|
+
return issues;
|
|
732
|
+
};
|
|
733
|
+
var normaliseText = (text) => text.replace(/\s+/g, " ").trim();
|
|
734
|
+
|
|
735
|
+
export {
|
|
736
|
+
ZOOM_MS,
|
|
737
|
+
CARD_IN_MS,
|
|
738
|
+
CARD_OUT_MS,
|
|
739
|
+
SETTLE_MS,
|
|
740
|
+
PRE_CLICK_MS,
|
|
741
|
+
PRESS_MS,
|
|
742
|
+
POST_CLICK_MS,
|
|
743
|
+
POST_KEY_MS,
|
|
744
|
+
POST_ACTION_MS,
|
|
745
|
+
LINGER_MS,
|
|
746
|
+
POLL_MS,
|
|
747
|
+
cardSeconds,
|
|
748
|
+
STAGE_PATH,
|
|
749
|
+
STAGE_WIDTH,
|
|
750
|
+
STAGE_HEIGHT,
|
|
751
|
+
DEFAULT_VIEWPORT,
|
|
752
|
+
PARK,
|
|
753
|
+
stageLayout,
|
|
754
|
+
stageHtml,
|
|
755
|
+
MIN_SCALE,
|
|
756
|
+
shotFor,
|
|
757
|
+
wideShot,
|
|
758
|
+
visibleRect,
|
|
759
|
+
shows,
|
|
760
|
+
coverage,
|
|
761
|
+
unionRect,
|
|
762
|
+
fittingScale,
|
|
763
|
+
isCoreStep,
|
|
764
|
+
StoryboardError,
|
|
765
|
+
parseStoryboard,
|
|
766
|
+
loadStoryboard,
|
|
767
|
+
lintStoryboard
|
|
768
|
+
};
|