@t3lnet/sceneforge 1.0.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 +5 -0
- package/dist/index.cjs +2137 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +706 -0
- package/dist/index.d.ts +706 -0
- package/dist/index.js +2064 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2064 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
4
|
+
|
|
5
|
+
// ../shared/src/yaml-parser.ts
|
|
6
|
+
import { parse, stringify } from "yaml";
|
|
7
|
+
|
|
8
|
+
// ../shared/src/schema.ts
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
var DEMO_SCHEMA_VERSION = 1;
|
|
11
|
+
var stepTargetSchema = z.object({
|
|
12
|
+
type: z.enum(["button", "link", "input", "text", "selector"]),
|
|
13
|
+
text: z.string().optional(),
|
|
14
|
+
selector: z.string().optional(),
|
|
15
|
+
name: z.string().optional()
|
|
16
|
+
}).strict().superRefine((target, ctx) => {
|
|
17
|
+
switch (target.type) {
|
|
18
|
+
case "selector":
|
|
19
|
+
if (!target.selector) {
|
|
20
|
+
ctx.addIssue({
|
|
21
|
+
code: z.ZodIssueCode.custom,
|
|
22
|
+
message: "selector target requires selector"
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
case "text":
|
|
27
|
+
if (!target.text) {
|
|
28
|
+
ctx.addIssue({
|
|
29
|
+
code: z.ZodIssueCode.custom,
|
|
30
|
+
message: "text target requires text"
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
break;
|
|
34
|
+
case "button":
|
|
35
|
+
case "link":
|
|
36
|
+
case "input":
|
|
37
|
+
if (!target.text && !target.name && !target.selector) {
|
|
38
|
+
ctx.addIssue({
|
|
39
|
+
code: z.ZodIssueCode.custom,
|
|
40
|
+
message: "target requires text, name, or selector"
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
var waitConditionSchema = z.object({
|
|
47
|
+
type: z.enum(["text", "selector", "navigation", "idle", "selectorHidden", "textHidden"]),
|
|
48
|
+
value: z.string().optional(),
|
|
49
|
+
timeout: z.coerce.number().int().positive().optional()
|
|
50
|
+
}).strict().superRefine((condition, ctx) => {
|
|
51
|
+
if (["text", "selector", "selectorHidden", "textHidden"].includes(condition.type)) {
|
|
52
|
+
if (!condition.value) {
|
|
53
|
+
ctx.addIssue({
|
|
54
|
+
code: z.ZodIssueCode.custom,
|
|
55
|
+
message: `wait condition '${condition.type}' requires value`
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
var dragConfigSchema = z.object({
|
|
61
|
+
deltaX: z.coerce.number(),
|
|
62
|
+
deltaY: z.coerce.number(),
|
|
63
|
+
steps: z.coerce.number().int().positive().optional()
|
|
64
|
+
}).strict();
|
|
65
|
+
var waitActionSchema = z.object({
|
|
66
|
+
action: z.literal("wait"),
|
|
67
|
+
duration: z.coerce.number().nonnegative().optional(),
|
|
68
|
+
waitFor: waitConditionSchema.optional()
|
|
69
|
+
}).strict().superRefine((action, ctx) => {
|
|
70
|
+
if (action.duration === void 0 && action.waitFor === void 0) {
|
|
71
|
+
ctx.addIssue({
|
|
72
|
+
code: z.ZodIssueCode.custom,
|
|
73
|
+
message: "wait action requires duration or waitFor"
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
var navigateActionSchema = z.object({
|
|
78
|
+
action: z.literal("navigate"),
|
|
79
|
+
path: z.string().min(1),
|
|
80
|
+
waitFor: waitConditionSchema.optional()
|
|
81
|
+
}).strict();
|
|
82
|
+
var clickActionSchema = z.object({
|
|
83
|
+
action: z.literal("click"),
|
|
84
|
+
target: stepTargetSchema,
|
|
85
|
+
highlight: z.boolean().optional(),
|
|
86
|
+
waitFor: waitConditionSchema.optional()
|
|
87
|
+
}).strict();
|
|
88
|
+
var typeActionSchema = z.object({
|
|
89
|
+
action: z.literal("type"),
|
|
90
|
+
target: stepTargetSchema,
|
|
91
|
+
text: z.string(),
|
|
92
|
+
waitFor: waitConditionSchema.optional()
|
|
93
|
+
}).strict();
|
|
94
|
+
var uploadActionSchema = z.object({
|
|
95
|
+
action: z.literal("upload"),
|
|
96
|
+
file: z.string().min(1),
|
|
97
|
+
target: stepTargetSchema.optional(),
|
|
98
|
+
waitFor: waitConditionSchema.optional()
|
|
99
|
+
}).strict();
|
|
100
|
+
var hoverActionSchema = z.object({
|
|
101
|
+
action: z.literal("hover"),
|
|
102
|
+
target: stepTargetSchema,
|
|
103
|
+
waitFor: waitConditionSchema.optional()
|
|
104
|
+
}).strict();
|
|
105
|
+
var scrollActionSchema = z.object({
|
|
106
|
+
action: z.literal("scroll"),
|
|
107
|
+
duration: z.coerce.number().optional(),
|
|
108
|
+
waitFor: waitConditionSchema.optional()
|
|
109
|
+
}).strict();
|
|
110
|
+
var scrollToActionSchema = z.object({
|
|
111
|
+
action: z.literal("scrollTo"),
|
|
112
|
+
target: stepTargetSchema,
|
|
113
|
+
waitFor: waitConditionSchema.optional()
|
|
114
|
+
}).strict();
|
|
115
|
+
var dragActionSchema = z.object({
|
|
116
|
+
action: z.literal("drag"),
|
|
117
|
+
target: stepTargetSchema,
|
|
118
|
+
drag: dragConfigSchema,
|
|
119
|
+
waitFor: waitConditionSchema.optional()
|
|
120
|
+
}).strict();
|
|
121
|
+
var actionSchema = z.union([
|
|
122
|
+
navigateActionSchema,
|
|
123
|
+
clickActionSchema,
|
|
124
|
+
typeActionSchema,
|
|
125
|
+
uploadActionSchema,
|
|
126
|
+
waitActionSchema,
|
|
127
|
+
hoverActionSchema,
|
|
128
|
+
scrollActionSchema,
|
|
129
|
+
scrollToActionSchema,
|
|
130
|
+
dragActionSchema
|
|
131
|
+
]);
|
|
132
|
+
var demoStepSchema = z.object({
|
|
133
|
+
id: z.string().min(1),
|
|
134
|
+
script: z.string(),
|
|
135
|
+
actions: z.array(actionSchema)
|
|
136
|
+
}).strict();
|
|
137
|
+
var videoSegmentSchema = z.object({
|
|
138
|
+
file: z.string().min(1),
|
|
139
|
+
duration: z.coerce.number().positive().optional(),
|
|
140
|
+
fade: z.boolean().optional(),
|
|
141
|
+
fadeDuration: z.coerce.number().positive().optional()
|
|
142
|
+
}).strict();
|
|
143
|
+
var musicStartPointSchema = z.union([
|
|
144
|
+
z.object({ type: z.literal("beginning") }).strict(),
|
|
145
|
+
z.object({ type: z.literal("afterIntro") }).strict(),
|
|
146
|
+
z.object({ type: z.literal("step"), stepId: z.string().min(1) }).strict(),
|
|
147
|
+
z.object({ type: z.literal("time"), seconds: z.coerce.number().nonnegative() }).strict()
|
|
148
|
+
]);
|
|
149
|
+
var musicEndPointSchema = z.union([
|
|
150
|
+
z.object({ type: z.literal("end") }).strict(),
|
|
151
|
+
z.object({ type: z.literal("beforeOutro") }).strict(),
|
|
152
|
+
z.object({ type: z.literal("step"), stepId: z.string().min(1) }).strict(),
|
|
153
|
+
z.object({ type: z.literal("time"), seconds: z.coerce.number().nonnegative() }).strict()
|
|
154
|
+
]);
|
|
155
|
+
var backgroundMusicSchema = z.object({
|
|
156
|
+
file: z.string().min(1),
|
|
157
|
+
volume: z.coerce.number().min(0).max(1).optional(),
|
|
158
|
+
startAt: musicStartPointSchema.optional(),
|
|
159
|
+
endAt: musicEndPointSchema.optional(),
|
|
160
|
+
loop: z.boolean().optional(),
|
|
161
|
+
fadeIn: z.coerce.number().nonnegative().optional(),
|
|
162
|
+
fadeOut: z.coerce.number().nonnegative().optional()
|
|
163
|
+
}).strict();
|
|
164
|
+
var mediaConfigSchema = z.object({
|
|
165
|
+
intro: videoSegmentSchema.optional(),
|
|
166
|
+
outro: videoSegmentSchema.optional(),
|
|
167
|
+
backgroundMusic: backgroundMusicSchema.optional()
|
|
168
|
+
}).strict();
|
|
169
|
+
var demoDefinitionSchema = z.object({
|
|
170
|
+
version: z.coerce.number().int().positive().default(DEMO_SCHEMA_VERSION),
|
|
171
|
+
name: z.string().min(1),
|
|
172
|
+
title: z.string().min(1),
|
|
173
|
+
description: z.string().optional(),
|
|
174
|
+
steps: z.array(demoStepSchema),
|
|
175
|
+
media: mediaConfigSchema.optional()
|
|
176
|
+
}).strict();
|
|
177
|
+
function formatValidationError(error) {
|
|
178
|
+
if (!error || typeof error !== "object" || !("issues" in error)) {
|
|
179
|
+
return "Invalid demo definition";
|
|
180
|
+
}
|
|
181
|
+
const issues = error.issues;
|
|
182
|
+
return issues.map((issue) => {
|
|
183
|
+
const path4 = issue.path.length ? issue.path.join(".") : "root";
|
|
184
|
+
return `${path4}: ${issue.message}`;
|
|
185
|
+
}).join("; ");
|
|
186
|
+
}
|
|
187
|
+
function parseDemoDefinition(input) {
|
|
188
|
+
return demoDefinitionSchema.parse(input);
|
|
189
|
+
}
|
|
190
|
+
function safeParseDemoDefinition(input) {
|
|
191
|
+
return demoDefinitionSchema.safeParse(input);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ../shared/src/yaml-parser.ts
|
|
195
|
+
function parseFromYAML(yamlString, options) {
|
|
196
|
+
const parsed = parse(yamlString);
|
|
197
|
+
if (!parsed || typeof parsed !== "object") {
|
|
198
|
+
throw new Error("Invalid YAML: expected an object");
|
|
199
|
+
}
|
|
200
|
+
const resolved = options?.resolveSecrets ? resolveSecrets(parsed, options.resolveSecrets) : parsed;
|
|
201
|
+
try {
|
|
202
|
+
return parseDemoDefinition(resolved);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new Error(formatValidationError(error));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function serializeToYAML(demo) {
|
|
208
|
+
const cleanDemo = {
|
|
209
|
+
version: demo.version ?? DEMO_SCHEMA_VERSION,
|
|
210
|
+
name: demo.name,
|
|
211
|
+
title: demo.title,
|
|
212
|
+
...demo.description && { description: demo.description },
|
|
213
|
+
steps: demo.steps.map(serializeStep)
|
|
214
|
+
};
|
|
215
|
+
return stringify(cleanDemo, {
|
|
216
|
+
indent: 2,
|
|
217
|
+
lineWidth: 100,
|
|
218
|
+
defaultStringType: "QUOTE_DOUBLE",
|
|
219
|
+
defaultKeyType: "PLAIN"
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function serializeStep(step) {
|
|
223
|
+
return {
|
|
224
|
+
id: step.id,
|
|
225
|
+
script: step.script,
|
|
226
|
+
actions: step.actions.map(serializeAction)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function serializeAction(action) {
|
|
230
|
+
const result = {
|
|
231
|
+
action: action.action
|
|
232
|
+
};
|
|
233
|
+
if (action.path !== void 0) {
|
|
234
|
+
result.path = action.path;
|
|
235
|
+
}
|
|
236
|
+
if (action.target !== void 0) {
|
|
237
|
+
result.target = serializeTarget(action.target);
|
|
238
|
+
}
|
|
239
|
+
if (action.text !== void 0) {
|
|
240
|
+
result.text = action.text;
|
|
241
|
+
}
|
|
242
|
+
if (action.file !== void 0) {
|
|
243
|
+
result.file = action.file;
|
|
244
|
+
}
|
|
245
|
+
if (action.duration !== void 0) {
|
|
246
|
+
result.duration = action.duration;
|
|
247
|
+
}
|
|
248
|
+
if (action.highlight === true) {
|
|
249
|
+
result.highlight = true;
|
|
250
|
+
}
|
|
251
|
+
if (action.waitFor !== void 0) {
|
|
252
|
+
result.waitFor = serializeWaitCondition(action.waitFor);
|
|
253
|
+
}
|
|
254
|
+
if (action.drag !== void 0) {
|
|
255
|
+
result.drag = {
|
|
256
|
+
deltaX: action.drag.deltaX,
|
|
257
|
+
deltaY: action.drag.deltaY,
|
|
258
|
+
...action.drag.steps !== void 0 && { steps: action.drag.steps }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
function serializeTarget(target) {
|
|
264
|
+
const result = {
|
|
265
|
+
type: target.type
|
|
266
|
+
};
|
|
267
|
+
if (target.text !== void 0) {
|
|
268
|
+
result.text = target.text;
|
|
269
|
+
}
|
|
270
|
+
if (target.selector !== void 0) {
|
|
271
|
+
result.selector = target.selector;
|
|
272
|
+
}
|
|
273
|
+
if (target.name !== void 0) {
|
|
274
|
+
result.name = target.name;
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
function serializeWaitCondition(waitFor) {
|
|
279
|
+
const result = {
|
|
280
|
+
type: waitFor.type
|
|
281
|
+
};
|
|
282
|
+
if (waitFor.value !== void 0) {
|
|
283
|
+
result.value = waitFor.value;
|
|
284
|
+
}
|
|
285
|
+
if (waitFor.timeout !== void 0) {
|
|
286
|
+
result.timeout = waitFor.timeout;
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
function validateDemoDefinition(definition) {
|
|
291
|
+
const result = safeParseDemoDefinition(definition);
|
|
292
|
+
if (!result.success) {
|
|
293
|
+
throw new Error(formatValidationError(result.error));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function createEmptyDemo() {
|
|
297
|
+
return {
|
|
298
|
+
version: DEMO_SCHEMA_VERSION,
|
|
299
|
+
name: "new-demo",
|
|
300
|
+
title: "New Demo",
|
|
301
|
+
description: "",
|
|
302
|
+
steps: []
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function createEmptyStep(id) {
|
|
306
|
+
const stepId = id || `step-${Date.now()}`;
|
|
307
|
+
return {
|
|
308
|
+
id: stepId,
|
|
309
|
+
script: "",
|
|
310
|
+
actions: []
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
var SECRET_PATTERN = /\$\{(ENV|SECRET):([A-Za-z0-9_]+)\}/g;
|
|
314
|
+
function resolveSecrets(value, resolver, path4 = "root") {
|
|
315
|
+
if (typeof value === "string") {
|
|
316
|
+
if (!SECRET_PATTERN.test(value)) {
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
SECRET_PATTERN.lastIndex = 0;
|
|
320
|
+
return value.replace(SECRET_PATTERN, (_match, _type, key) => {
|
|
321
|
+
const resolved = resolver(key);
|
|
322
|
+
if (resolved === void 0) {
|
|
323
|
+
throw new Error(`Missing secret for ${key} at ${path4}`);
|
|
324
|
+
}
|
|
325
|
+
return resolved;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(value)) {
|
|
329
|
+
return value.map(
|
|
330
|
+
(entry, index) => resolveSecrets(entry, resolver, `${path4}[${index}]`)
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (value && typeof value === "object") {
|
|
334
|
+
const result = {};
|
|
335
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
336
|
+
result[key] = resolveSecrets(entry, resolver, `${path4}.${key}`);
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
return value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ../shared/src/target-resolver.ts
|
|
344
|
+
function resolveTarget(target) {
|
|
345
|
+
if (target.selector) {
|
|
346
|
+
return target.selector;
|
|
347
|
+
}
|
|
348
|
+
switch (target.type) {
|
|
349
|
+
case "button":
|
|
350
|
+
if (target.text) {
|
|
351
|
+
return `button:has-text("${target.text}"), [role="button"]:has-text("${target.text}")`;
|
|
352
|
+
}
|
|
353
|
+
if (target.name) {
|
|
354
|
+
return `button[name="${target.name}"], [role="button"][name="${target.name}"]`;
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
case "link":
|
|
358
|
+
if (target.text) {
|
|
359
|
+
return `a:has-text("${target.text}")`;
|
|
360
|
+
}
|
|
361
|
+
if (target.name) {
|
|
362
|
+
return `a[name="${target.name}"]`;
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
case "input":
|
|
366
|
+
if (target.name) {
|
|
367
|
+
return `input[name="${target.name}"], textarea[name="${target.name}"]`;
|
|
368
|
+
}
|
|
369
|
+
if (target.text) {
|
|
370
|
+
return `label:has-text("${target.text}") + input, label:has-text("${target.text}") input`;
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
case "text":
|
|
374
|
+
if (target.text) {
|
|
375
|
+
return `text="${target.text}"`;
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
case "selector":
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
throw new Error(`Unable to resolve target: ${JSON.stringify(target)}`);
|
|
382
|
+
}
|
|
383
|
+
function resolvePath(pathTemplate, variables) {
|
|
384
|
+
let result = pathTemplate;
|
|
385
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
386
|
+
if (value === void 0) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value);
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ../shared/src/action-helpers.ts
|
|
395
|
+
function createClickAction(selector, highlight = false) {
|
|
396
|
+
return {
|
|
397
|
+
action: "click",
|
|
398
|
+
target: {
|
|
399
|
+
type: "selector",
|
|
400
|
+
selector
|
|
401
|
+
},
|
|
402
|
+
highlight
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function createTypeAction(selector, text) {
|
|
406
|
+
return {
|
|
407
|
+
action: "type",
|
|
408
|
+
target: {
|
|
409
|
+
type: "selector",
|
|
410
|
+
selector
|
|
411
|
+
},
|
|
412
|
+
text
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function createNavigateAction(path4) {
|
|
416
|
+
return {
|
|
417
|
+
action: "navigate",
|
|
418
|
+
path: path4
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function createWaitAction(duration) {
|
|
422
|
+
return {
|
|
423
|
+
action: "wait",
|
|
424
|
+
duration
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function createWaitForAction(type, value, timeout = 15e3) {
|
|
428
|
+
return {
|
|
429
|
+
action: "wait",
|
|
430
|
+
waitFor: {
|
|
431
|
+
type,
|
|
432
|
+
value,
|
|
433
|
+
timeout
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function createUploadAction(file, selector) {
|
|
438
|
+
const action = {
|
|
439
|
+
action: "upload",
|
|
440
|
+
file
|
|
441
|
+
};
|
|
442
|
+
if (selector) {
|
|
443
|
+
action.target = {
|
|
444
|
+
type: "selector",
|
|
445
|
+
selector
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
return action;
|
|
449
|
+
}
|
|
450
|
+
function createHoverAction(selector) {
|
|
451
|
+
return {
|
|
452
|
+
action: "hover",
|
|
453
|
+
target: {
|
|
454
|
+
type: "selector",
|
|
455
|
+
selector
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function createScrollAction(duration = 500) {
|
|
460
|
+
return {
|
|
461
|
+
action: "scroll",
|
|
462
|
+
duration
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function createScrollToAction(selector) {
|
|
466
|
+
return {
|
|
467
|
+
action: "scrollTo",
|
|
468
|
+
target: {
|
|
469
|
+
type: "selector",
|
|
470
|
+
selector
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function createDragAction(selector, deltaX, deltaY, steps = 20) {
|
|
475
|
+
return {
|
|
476
|
+
action: "drag",
|
|
477
|
+
target: {
|
|
478
|
+
type: "selector",
|
|
479
|
+
selector
|
|
480
|
+
},
|
|
481
|
+
drag: {
|
|
482
|
+
deltaX,
|
|
483
|
+
deltaY,
|
|
484
|
+
steps
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ../playwright/src/demo-runner.ts
|
|
490
|
+
import { expect } from "@playwright/test";
|
|
491
|
+
import * as fs3 from "fs/promises";
|
|
492
|
+
import * as path3 from "path";
|
|
493
|
+
|
|
494
|
+
// ../generation/src/voice-synthesis.ts
|
|
495
|
+
import { spawn } from "child_process";
|
|
496
|
+
import { ElevenLabsClient } from "elevenlabs";
|
|
497
|
+
import * as fs from "fs/promises";
|
|
498
|
+
import * as path from "path";
|
|
499
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024;
|
|
500
|
+
var DEFAULT_MAX_CONCURRENCY = 2;
|
|
501
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
502
|
+
retries: 3,
|
|
503
|
+
minDelayMs: 500,
|
|
504
|
+
maxDelayMs: 8e3
|
|
505
|
+
};
|
|
506
|
+
function sanitizeFileSegment(value, fallback = "segment", maxLength = 80) {
|
|
507
|
+
const raw = String(value ?? "").trim();
|
|
508
|
+
if (!raw) {
|
|
509
|
+
return fallback;
|
|
510
|
+
}
|
|
511
|
+
const cleaned = raw.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
512
|
+
const collapsed = cleaned.replace(/_{2,}/g, "_").replace(/^_+|_+$/g, "");
|
|
513
|
+
const safe = collapsed || fallback;
|
|
514
|
+
const trimmed = safe.length > maxLength ? safe.slice(0, maxLength) : safe;
|
|
515
|
+
if (trimmed === "." || trimmed === "..") {
|
|
516
|
+
return fallback;
|
|
517
|
+
}
|
|
518
|
+
return trimmed;
|
|
519
|
+
}
|
|
520
|
+
function appendWithLimit(buffer, chunk, limit) {
|
|
521
|
+
const next = buffer + chunk;
|
|
522
|
+
if (next.length <= limit) {
|
|
523
|
+
return next;
|
|
524
|
+
}
|
|
525
|
+
return next.slice(next.length - limit);
|
|
526
|
+
}
|
|
527
|
+
function runCommand(command, args, options = {}) {
|
|
528
|
+
const {
|
|
529
|
+
stdio = ["ignore", "pipe", "pipe"],
|
|
530
|
+
cwd,
|
|
531
|
+
maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES
|
|
532
|
+
} = options;
|
|
533
|
+
return new Promise((resolve2, reject) => {
|
|
534
|
+
const child = spawn(command, args, { stdio, cwd });
|
|
535
|
+
let stdout = "";
|
|
536
|
+
let stderr = "";
|
|
537
|
+
if (child.stdout) {
|
|
538
|
+
child.stdout.on("data", (chunk) => {
|
|
539
|
+
stdout = appendWithLimit(stdout, chunk.toString(), maxOutputBytes);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (child.stderr) {
|
|
543
|
+
child.stderr.on("data", (chunk) => {
|
|
544
|
+
stderr = appendWithLimit(stderr, chunk.toString(), maxOutputBytes);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
child.on("error", (error) => {
|
|
548
|
+
reject(error);
|
|
549
|
+
});
|
|
550
|
+
child.on("close", (code) => {
|
|
551
|
+
if (code === 0) {
|
|
552
|
+
resolve2({ stdout, stderr });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const error = new Error(`${command} exited with code ${code}`);
|
|
556
|
+
error.code = code ?? void 0;
|
|
557
|
+
error.stderr = stderr;
|
|
558
|
+
reject(error);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
async function probeMediaDurationMs(filePath) {
|
|
563
|
+
try {
|
|
564
|
+
const { stdout } = await runCommand("ffprobe", [
|
|
565
|
+
"-v",
|
|
566
|
+
"error",
|
|
567
|
+
"-show_entries",
|
|
568
|
+
"format=duration",
|
|
569
|
+
"-of",
|
|
570
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
571
|
+
filePath
|
|
572
|
+
]);
|
|
573
|
+
const durationSec = parseFloat(stdout.trim());
|
|
574
|
+
if (Number.isFinite(durationSec)) {
|
|
575
|
+
return Math.round(durationSec * 1e3);
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
function sleep(ms) {
|
|
583
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
584
|
+
}
|
|
585
|
+
function getStatusCode(error) {
|
|
586
|
+
if (!error || typeof error !== "object") return void 0;
|
|
587
|
+
const maybeError = error;
|
|
588
|
+
return maybeError.status ?? maybeError.statusCode ?? maybeError.response?.status;
|
|
589
|
+
}
|
|
590
|
+
function getHeaderValue(headers, name) {
|
|
591
|
+
if (!headers || typeof headers !== "object") {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
const getter = headers.get;
|
|
595
|
+
if (typeof getter === "function") {
|
|
596
|
+
return getter.call(headers, name);
|
|
597
|
+
}
|
|
598
|
+
const record = headers;
|
|
599
|
+
const direct = record[name];
|
|
600
|
+
if (typeof direct === "string") {
|
|
601
|
+
return direct;
|
|
602
|
+
}
|
|
603
|
+
const lower = record[name.toLowerCase()];
|
|
604
|
+
if (typeof lower === "string") {
|
|
605
|
+
return lower;
|
|
606
|
+
}
|
|
607
|
+
const upper = record[name.toUpperCase()];
|
|
608
|
+
if (typeof upper === "string") {
|
|
609
|
+
return upper;
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
function getRetryAfterMs(error) {
|
|
614
|
+
if (!error || typeof error !== "object") return null;
|
|
615
|
+
const maybeError = error;
|
|
616
|
+
const headerValue = getHeaderValue(maybeError.response?.headers, "retry-after");
|
|
617
|
+
const retryAfter = headerValue ?? maybeError.retryAfter;
|
|
618
|
+
if (retryAfter === void 0 || retryAfter === null) {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
const seconds = Number(retryAfter);
|
|
622
|
+
if (Number.isFinite(seconds)) {
|
|
623
|
+
return Math.max(0, Math.round(seconds * 1e3));
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
function isRetryableError(error) {
|
|
628
|
+
const status = getStatusCode(error);
|
|
629
|
+
if (status && [408, 429, 500, 502, 503, 504].includes(status)) {
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
const message = String(error?.message ?? "");
|
|
633
|
+
return /(rate limit|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up)/i.test(message);
|
|
634
|
+
}
|
|
635
|
+
function getRetryDelayMs(attempt, options, error) {
|
|
636
|
+
const minDelayMs = options.minDelayMs ?? DEFAULT_RETRY_OPTIONS.minDelayMs;
|
|
637
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs;
|
|
638
|
+
const baseDelay = Math.min(maxDelayMs, minDelayMs * 2 ** attempt);
|
|
639
|
+
const jitter = 0.5 + Math.random();
|
|
640
|
+
const retryAfterMs = getRetryAfterMs(error) ?? 0;
|
|
641
|
+
return Math.max(retryAfterMs, Math.round(baseDelay * jitter));
|
|
642
|
+
}
|
|
643
|
+
async function withRetry(fn, options = {}) {
|
|
644
|
+
const retries = options.retries ?? DEFAULT_RETRY_OPTIONS.retries;
|
|
645
|
+
let attempt = 0;
|
|
646
|
+
while (true) {
|
|
647
|
+
try {
|
|
648
|
+
return await fn();
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (!isRetryableError(error) || attempt >= retries) {
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
const delayMs = getRetryDelayMs(attempt, options, error);
|
|
654
|
+
await sleep(delayMs);
|
|
655
|
+
attempt += 1;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
var VoiceSynthesizer = class {
|
|
660
|
+
constructor(config) {
|
|
661
|
+
__publicField(this, "client");
|
|
662
|
+
__publicField(this, "config");
|
|
663
|
+
this.config = config;
|
|
664
|
+
this.client = new ElevenLabsClient({
|
|
665
|
+
apiKey: config.apiKey
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* List available voices (useful for finding voice IDs)
|
|
670
|
+
*/
|
|
671
|
+
async listVoices() {
|
|
672
|
+
const response = await this.client.voices.getAll();
|
|
673
|
+
return response.voices.map((voice) => ({
|
|
674
|
+
voiceId: voice.voice_id,
|
|
675
|
+
name: voice.name || "Unnamed",
|
|
676
|
+
category: voice.category || "unknown"
|
|
677
|
+
}));
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Synthesize a single text segment to audio
|
|
681
|
+
*/
|
|
682
|
+
async synthesizeSegment(text, outputPath, options) {
|
|
683
|
+
const audio = await this.client.textToSpeech.convert(this.config.voiceId, {
|
|
684
|
+
text,
|
|
685
|
+
model_id: this.config.modelId || "eleven_multilingual_v2",
|
|
686
|
+
voice_settings: {
|
|
687
|
+
stability: options?.stability ?? 0.5,
|
|
688
|
+
similarity_boost: options?.similarityBoost ?? 0.75,
|
|
689
|
+
style: options?.style ?? 0,
|
|
690
|
+
use_speaker_boost: options?.useSpeakerBoost ?? true
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
const chunks = [];
|
|
694
|
+
for await (const chunk of audio) {
|
|
695
|
+
chunks.push(Buffer.from(chunk));
|
|
696
|
+
}
|
|
697
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
698
|
+
await fs.writeFile(outputPath, audioBuffer);
|
|
699
|
+
const probedDurationMs = await probeMediaDurationMs(outputPath);
|
|
700
|
+
if (probedDurationMs !== null) {
|
|
701
|
+
return { durationMs: probedDurationMs };
|
|
702
|
+
}
|
|
703
|
+
const fileSizeBytes = audioBuffer.length;
|
|
704
|
+
const estimatedDurationMs = Math.round(fileSizeBytes * 8 / 128);
|
|
705
|
+
console.warn(`[voice] ffprobe unavailable, using estimated duration for ${outputPath}`);
|
|
706
|
+
return { durationMs: estimatedDurationMs };
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Generate a sound effect from text description
|
|
710
|
+
*/
|
|
711
|
+
async generateSoundEffect(description, outputPath, options) {
|
|
712
|
+
const audio = await withRetry(
|
|
713
|
+
() => this.client.textToSoundEffects.convert({
|
|
714
|
+
text: description,
|
|
715
|
+
duration_seconds: options?.durationSeconds ?? 1,
|
|
716
|
+
prompt_influence: options?.promptInfluence ?? 0.3
|
|
717
|
+
}),
|
|
718
|
+
options?.retry
|
|
719
|
+
);
|
|
720
|
+
const chunks = [];
|
|
721
|
+
for await (const chunk of audio) {
|
|
722
|
+
chunks.push(Buffer.from(chunk));
|
|
723
|
+
}
|
|
724
|
+
await fs.writeFile(outputPath, Buffer.concat(chunks));
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Synthesize all segments from a script JSON file
|
|
728
|
+
*/
|
|
729
|
+
async synthesizeScript(scriptPath, outputDir, options) {
|
|
730
|
+
const scriptContent = await fs.readFile(scriptPath, "utf-8");
|
|
731
|
+
const script = JSON.parse(scriptContent);
|
|
732
|
+
const audioDir = path.join(outputDir, "audio", script.demoName);
|
|
733
|
+
await fs.mkdir(audioDir, { recursive: true });
|
|
734
|
+
const synthesizedSegments = new Array(script.segments.length);
|
|
735
|
+
const maxConcurrency = Math.max(
|
|
736
|
+
1,
|
|
737
|
+
options?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY
|
|
738
|
+
);
|
|
739
|
+
let progressCount = 0;
|
|
740
|
+
let nextIndex = 0;
|
|
741
|
+
const workers = Array.from({ length: Math.min(maxConcurrency, script.segments.length) }, async () => {
|
|
742
|
+
while (true) {
|
|
743
|
+
const index = nextIndex;
|
|
744
|
+
nextIndex += 1;
|
|
745
|
+
if (index >= script.segments.length) {
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
const segment = script.segments[index];
|
|
749
|
+
const safeStepId = sanitizeFileSegment(segment.stepId, `step-${index + 1}`);
|
|
750
|
+
const audioFileName = `${String(index + 1).padStart(2, "0")}_${safeStepId}.mp3`;
|
|
751
|
+
const audioPath = path.join(audioDir, audioFileName);
|
|
752
|
+
progressCount += 1;
|
|
753
|
+
options?.onProgress?.(progressCount, script.segments.length, segment.stepId);
|
|
754
|
+
try {
|
|
755
|
+
const result = await withRetry(
|
|
756
|
+
() => this.synthesizeSegment(segment.text, audioPath, options?.voiceSettings),
|
|
757
|
+
options?.retry
|
|
758
|
+
);
|
|
759
|
+
synthesizedSegments[index] = {
|
|
760
|
+
stepId: segment.stepId,
|
|
761
|
+
audioPath,
|
|
762
|
+
durationMs: result.durationMs,
|
|
763
|
+
text: segment.text
|
|
764
|
+
};
|
|
765
|
+
console.log(`[voice] Synthesized: ${segment.stepId} (${result.durationMs.toFixed(0)}ms)`);
|
|
766
|
+
} catch (error) {
|
|
767
|
+
console.error(`[voice] Failed to synthesize ${segment.stepId}:`, error);
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
await Promise.all(workers);
|
|
773
|
+
let soundEffectsPath;
|
|
774
|
+
if (options?.generateClickSounds) {
|
|
775
|
+
soundEffectsPath = path.join(audioDir, "click.mp3");
|
|
776
|
+
await this.generateClickSound(soundEffectsPath);
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
demoName: script.demoName,
|
|
780
|
+
segments: synthesizedSegments,
|
|
781
|
+
soundEffectsPath
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Generate a UI click sound effect
|
|
786
|
+
*/
|
|
787
|
+
async generateClickSound(outputPath) {
|
|
788
|
+
await this.generateSoundEffect(
|
|
789
|
+
"soft UI button click, subtle, digital, clean interface sound",
|
|
790
|
+
outputPath,
|
|
791
|
+
{ durationSeconds: 0.5, promptInfluence: 0.5 }
|
|
792
|
+
);
|
|
793
|
+
console.log("[voice] Generated click sound effect");
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Generate ambient background music
|
|
797
|
+
*/
|
|
798
|
+
async generateBackgroundMusic(outputPath, options) {
|
|
799
|
+
const styleDescriptions = {
|
|
800
|
+
corporate: "soft corporate background music, professional, minimal, ambient technology sounds",
|
|
801
|
+
tech: "modern technology background music, subtle electronic, innovative, clean",
|
|
802
|
+
calm: "calm ambient background music, peaceful, soft piano, gentle",
|
|
803
|
+
upbeat: "light upbeat background music, positive, motivational, subtle energy"
|
|
804
|
+
};
|
|
805
|
+
const description = styleDescriptions[options?.style || "tech"];
|
|
806
|
+
const duration = options?.durationSeconds ?? 30;
|
|
807
|
+
await this.generateSoundEffect(description, outputPath, {
|
|
808
|
+
durationSeconds: Math.min(duration, 30),
|
|
809
|
+
promptInfluence: 0.4
|
|
810
|
+
});
|
|
811
|
+
console.log(`[voice] Generated background music (${options?.style || "tech"} style)`);
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
async function generateTimingManifest(scriptPath, synthesisResult, outputPath) {
|
|
815
|
+
const scriptContent = await fs.readFile(scriptPath, "utf-8");
|
|
816
|
+
const script = JSON.parse(scriptContent);
|
|
817
|
+
const manifest = {
|
|
818
|
+
demoName: script.demoName,
|
|
819
|
+
segments: synthesisResult.segments.map((synth) => {
|
|
820
|
+
const originalSegment = script.segments.find((s) => s.stepId === synth.stepId);
|
|
821
|
+
return {
|
|
822
|
+
stepId: synth.stepId,
|
|
823
|
+
audioFile: synth.audioPath,
|
|
824
|
+
videoStartTimeMs: originalSegment?.startTimeMs ?? 0,
|
|
825
|
+
audioDurationMs: synth.durationMs,
|
|
826
|
+
text: synth.text
|
|
827
|
+
};
|
|
828
|
+
}),
|
|
829
|
+
soundEffects: synthesisResult.soundEffectsPath ? { clickSound: synthesisResult.soundEffectsPath } : void 0,
|
|
830
|
+
backgroundMusic: synthesisResult.backgroundMusicPath
|
|
831
|
+
};
|
|
832
|
+
await fs.writeFile(outputPath, JSON.stringify(manifest, null, 2));
|
|
833
|
+
return manifest;
|
|
834
|
+
}
|
|
835
|
+
function createVoiceSynthesizer(config) {
|
|
836
|
+
return new VoiceSynthesizer(config);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ../generation/src/script-generator.ts
|
|
840
|
+
import * as fs2 from "fs/promises";
|
|
841
|
+
import * as path2 from "path";
|
|
842
|
+
var ScriptGenerator = class {
|
|
843
|
+
constructor(demoName, title, startTimeMs) {
|
|
844
|
+
__publicField(this, "segments", []);
|
|
845
|
+
__publicField(this, "stepBoundaries", []);
|
|
846
|
+
__publicField(this, "currentStepId", null);
|
|
847
|
+
__publicField(this, "currentStepStartMs", null);
|
|
848
|
+
__publicField(this, "demoName");
|
|
849
|
+
__publicField(this, "title");
|
|
850
|
+
__publicField(this, "startTime");
|
|
851
|
+
this.demoName = demoName;
|
|
852
|
+
this.title = title;
|
|
853
|
+
this.startTime = startTimeMs ?? Date.now();
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Marks the start of a step (for video splitting).
|
|
857
|
+
*/
|
|
858
|
+
startStep(stepId) {
|
|
859
|
+
if (this.currentStepId && this.currentStepStartMs !== null) {
|
|
860
|
+
this.stepBoundaries.push({
|
|
861
|
+
stepId: this.currentStepId,
|
|
862
|
+
stepIndex: this.stepBoundaries.length,
|
|
863
|
+
videoStartMs: this.currentStepStartMs,
|
|
864
|
+
videoEndMs: Date.now() - this.startTime
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
this.currentStepId = stepId;
|
|
868
|
+
this.currentStepStartMs = Date.now() - this.startTime;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Marks the end of all steps (call at demo completion).
|
|
872
|
+
*/
|
|
873
|
+
finishAllSteps() {
|
|
874
|
+
if (this.currentStepId && this.currentStepStartMs !== null) {
|
|
875
|
+
this.stepBoundaries.push({
|
|
876
|
+
stepId: this.currentStepId,
|
|
877
|
+
stepIndex: this.stepBoundaries.length,
|
|
878
|
+
videoStartMs: this.currentStepStartMs,
|
|
879
|
+
videoEndMs: Date.now() - this.startTime
|
|
880
|
+
});
|
|
881
|
+
this.currentStepId = null;
|
|
882
|
+
this.currentStepStartMs = null;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Estimates reading duration for text.
|
|
887
|
+
* Based on ~150 words per minute average speaking rate.
|
|
888
|
+
*/
|
|
889
|
+
estimateReadingDuration(text) {
|
|
890
|
+
const words = text.split(/\s+/).length;
|
|
891
|
+
const wpm = 150;
|
|
892
|
+
return Math.round(words / wpm * 60 * 1e3);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Adds a narration segment with automatic timing.
|
|
896
|
+
*/
|
|
897
|
+
addSegment(stepId, text, options) {
|
|
898
|
+
const now = Date.now();
|
|
899
|
+
const startTimeMs = now - this.startTime;
|
|
900
|
+
const estimatedDurationMs = this.estimateReadingDuration(text);
|
|
901
|
+
this.segments.push({
|
|
902
|
+
stepId,
|
|
903
|
+
text,
|
|
904
|
+
startTimeMs,
|
|
905
|
+
endTimeMs: startTimeMs + estimatedDurationMs,
|
|
906
|
+
estimatedDurationMs,
|
|
907
|
+
pauseBeforeMs: options?.pauseBeforeMs,
|
|
908
|
+
pauseAfterMs: options?.pauseAfterMs
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Updates the end time of the last segment.
|
|
913
|
+
* Call this when a step completes to capture actual duration.
|
|
914
|
+
*/
|
|
915
|
+
completeLastSegment() {
|
|
916
|
+
if (this.segments.length > 0) {
|
|
917
|
+
const lastSegment = this.segments[this.segments.length - 1];
|
|
918
|
+
lastSegment.endTimeMs = Date.now() - this.startTime;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Generates the complete script output.
|
|
923
|
+
*/
|
|
924
|
+
getOutput() {
|
|
925
|
+
const now = Date.now();
|
|
926
|
+
return {
|
|
927
|
+
demoName: this.demoName,
|
|
928
|
+
title: this.title,
|
|
929
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
930
|
+
totalDurationMs: now - this.startTime,
|
|
931
|
+
segments: this.segments,
|
|
932
|
+
stepBoundaries: this.stepBoundaries
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Exports script as JSON.
|
|
937
|
+
*/
|
|
938
|
+
async exportJSON(outputPath) {
|
|
939
|
+
const output = this.getOutput();
|
|
940
|
+
await fs2.mkdir(path2.dirname(outputPath), { recursive: true });
|
|
941
|
+
await fs2.writeFile(outputPath, JSON.stringify(output, null, 2));
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Exports script as SRT subtitle format.
|
|
945
|
+
*/
|
|
946
|
+
async exportSRT(outputPath) {
|
|
947
|
+
const output = this.getOutput();
|
|
948
|
+
let srt = "";
|
|
949
|
+
output.segments.forEach((segment, index) => {
|
|
950
|
+
const startTime = formatSRTTime(segment.startTimeMs);
|
|
951
|
+
const endTime = formatSRTTime(segment.endTimeMs);
|
|
952
|
+
srt += `${index + 1}
|
|
953
|
+
`;
|
|
954
|
+
srt += `${startTime} --> ${endTime}
|
|
955
|
+
`;
|
|
956
|
+
srt += `${segment.text}
|
|
957
|
+
|
|
958
|
+
`;
|
|
959
|
+
});
|
|
960
|
+
await fs2.mkdir(path2.dirname(outputPath), { recursive: true });
|
|
961
|
+
await fs2.writeFile(outputPath, srt.trim());
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Exports script in a format suitable for AI voice generation services.
|
|
965
|
+
* Includes SSML-like pause markers.
|
|
966
|
+
*/
|
|
967
|
+
async exportAIVoiceScript(outputPath) {
|
|
968
|
+
const output = this.getOutput();
|
|
969
|
+
const voiceScript = {
|
|
970
|
+
demoName: output.demoName,
|
|
971
|
+
title: output.title,
|
|
972
|
+
totalDuration: formatReadableTime(output.totalDurationMs),
|
|
973
|
+
segments: output.segments.map((segment, index) => ({
|
|
974
|
+
index: index + 1,
|
|
975
|
+
stepId: segment.stepId,
|
|
976
|
+
text: segment.text,
|
|
977
|
+
ssmlHints: generateSSMLHints(segment),
|
|
978
|
+
pauseBeforeMs: segment.pauseBeforeMs || 0,
|
|
979
|
+
pauseAfterMs: segment.pauseAfterMs || 500,
|
|
980
|
+
syncPointMs: segment.startTimeMs
|
|
981
|
+
}))
|
|
982
|
+
};
|
|
983
|
+
await fs2.mkdir(path2.dirname(outputPath), { recursive: true });
|
|
984
|
+
await fs2.writeFile(outputPath, JSON.stringify(voiceScript, null, 2));
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Exports script as human-readable markdown for voice actors.
|
|
988
|
+
*/
|
|
989
|
+
async exportMarkdown(outputPath) {
|
|
990
|
+
const output = this.getOutput();
|
|
991
|
+
let markdown = `# ${output.title}
|
|
992
|
+
|
|
993
|
+
`;
|
|
994
|
+
markdown += `**Demo:** ${output.demoName}
|
|
995
|
+
`;
|
|
996
|
+
markdown += `**Total Duration:** ${formatReadableTime(output.totalDurationMs)}
|
|
997
|
+
`;
|
|
998
|
+
markdown += `**Generated:** ${output.generatedAt}
|
|
999
|
+
|
|
1000
|
+
`;
|
|
1001
|
+
markdown += `---
|
|
1002
|
+
|
|
1003
|
+
`;
|
|
1004
|
+
markdown += `## Script
|
|
1005
|
+
|
|
1006
|
+
`;
|
|
1007
|
+
output.segments.forEach((segment, index) => {
|
|
1008
|
+
markdown += `### ${index + 1}. ${segment.stepId}
|
|
1009
|
+
|
|
1010
|
+
`;
|
|
1011
|
+
markdown += `**Timing:** ${formatReadableTime(segment.startTimeMs)} - ${formatReadableTime(segment.endTimeMs)}
|
|
1012
|
+
|
|
1013
|
+
`;
|
|
1014
|
+
if (segment.pauseBeforeMs) {
|
|
1015
|
+
markdown += `*[Pause ${segment.pauseBeforeMs}ms before]*
|
|
1016
|
+
|
|
1017
|
+
`;
|
|
1018
|
+
}
|
|
1019
|
+
markdown += `> ${segment.text}
|
|
1020
|
+
|
|
1021
|
+
`;
|
|
1022
|
+
if (segment.pauseAfterMs) {
|
|
1023
|
+
markdown += `*[Pause ${segment.pauseAfterMs}ms after]*
|
|
1024
|
+
|
|
1025
|
+
`;
|
|
1026
|
+
}
|
|
1027
|
+
markdown += `---
|
|
1028
|
+
|
|
1029
|
+
`;
|
|
1030
|
+
});
|
|
1031
|
+
await fs2.mkdir(path2.dirname(outputPath), { recursive: true });
|
|
1032
|
+
await fs2.writeFile(outputPath, markdown);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
function formatSRTTime(ms) {
|
|
1036
|
+
const hours = Math.floor(ms / 36e5);
|
|
1037
|
+
const minutes = Math.floor(ms % 36e5 / 6e4);
|
|
1038
|
+
const seconds = Math.floor(ms % 6e4 / 1e3);
|
|
1039
|
+
const milliseconds = ms % 1e3;
|
|
1040
|
+
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(milliseconds, 3)}`;
|
|
1041
|
+
}
|
|
1042
|
+
function formatReadableTime(ms) {
|
|
1043
|
+
const totalSeconds = Math.floor(ms / 1e3);
|
|
1044
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1045
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
1046
|
+
const seconds = totalSeconds % 60;
|
|
1047
|
+
if (hours > 0) {
|
|
1048
|
+
return `${hours}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
|
|
1049
|
+
}
|
|
1050
|
+
return `${minutes}:${pad(seconds, 2)}`;
|
|
1051
|
+
}
|
|
1052
|
+
function pad(num, size) {
|
|
1053
|
+
let s = num.toString();
|
|
1054
|
+
while (s.length < size) s = "0" + s;
|
|
1055
|
+
return s;
|
|
1056
|
+
}
|
|
1057
|
+
function generateSSMLHints(segment) {
|
|
1058
|
+
let ssml = segment.text;
|
|
1059
|
+
if (segment.pauseBeforeMs) {
|
|
1060
|
+
ssml = `<break time="${segment.pauseBeforeMs}ms"/> ${ssml}`;
|
|
1061
|
+
}
|
|
1062
|
+
if (segment.pauseAfterMs) {
|
|
1063
|
+
ssml = `${ssml} <break time="${segment.pauseAfterMs}ms"/>`;
|
|
1064
|
+
}
|
|
1065
|
+
return ssml;
|
|
1066
|
+
}
|
|
1067
|
+
function createScriptGenerator(demoName, title, startTimeMs) {
|
|
1068
|
+
return new ScriptGenerator(demoName, title, startTimeMs);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ../playwright/src/cursor-overlay.ts
|
|
1072
|
+
var CURSOR_STYLES = `
|
|
1073
|
+
/* Hide the real cursor when demo cursor is active */
|
|
1074
|
+
html.demo-cursor-active, html.demo-cursor-active * {
|
|
1075
|
+
cursor: none !important;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* Demo cursor container */
|
|
1079
|
+
#demo-cursor {
|
|
1080
|
+
position: fixed;
|
|
1081
|
+
pointer-events: none;
|
|
1082
|
+
z-index: 999999;
|
|
1083
|
+
will-change: left, top;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/* Cursor arrow SVG - larger for visibility */
|
|
1087
|
+
#demo-cursor-arrow {
|
|
1088
|
+
width: 32px;
|
|
1089
|
+
height: 32px;
|
|
1090
|
+
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 2px rgba(0, 0, 0, 0.4));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/* Click ripple effect - larger and more visible */
|
|
1094
|
+
.demo-click-ripple {
|
|
1095
|
+
position: absolute;
|
|
1096
|
+
top: 4px;
|
|
1097
|
+
left: 4px;
|
|
1098
|
+
width: 60px;
|
|
1099
|
+
height: 60px;
|
|
1100
|
+
border-radius: 50%;
|
|
1101
|
+
background: radial-gradient(circle, rgba(239, 68, 68, 0.7) 0%, rgba(239, 68, 68, 0.3) 40%, rgba(239, 68, 68, 0) 70%);
|
|
1102
|
+
transform: translate(-50%, -50%) scale(0);
|
|
1103
|
+
animation: demo-ripple 0.5s ease-out forwards;
|
|
1104
|
+
pointer-events: none;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
@keyframes demo-ripple {
|
|
1108
|
+
0% {
|
|
1109
|
+
transform: translate(-50%, -50%) scale(0);
|
|
1110
|
+
opacity: 1;
|
|
1111
|
+
}
|
|
1112
|
+
100% {
|
|
1113
|
+
transform: translate(-50%, -50%) scale(2.5);
|
|
1114
|
+
opacity: 0;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/* Highlight ring for elements being clicked - thicker and more visible */
|
|
1119
|
+
.demo-highlight-ring {
|
|
1120
|
+
position: fixed;
|
|
1121
|
+
border: 3px solid rgba(239, 68, 68, 0.9);
|
|
1122
|
+
border-radius: 6px;
|
|
1123
|
+
pointer-events: none;
|
|
1124
|
+
z-index: 999998;
|
|
1125
|
+
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3), inset 0 0 0 1px rgba(239, 68, 68, 0.2);
|
|
1126
|
+
animation: demo-highlight-pulse 0.8s ease-out forwards;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
@keyframes demo-highlight-pulse {
|
|
1130
|
+
0% {
|
|
1131
|
+
opacity: 1;
|
|
1132
|
+
transform: scale(1);
|
|
1133
|
+
}
|
|
1134
|
+
50% {
|
|
1135
|
+
opacity: 0.9;
|
|
1136
|
+
transform: scale(1.03);
|
|
1137
|
+
}
|
|
1138
|
+
100% {
|
|
1139
|
+
opacity: 0;
|
|
1140
|
+
transform: scale(1.08);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/* Trail effect for cursor movement */
|
|
1145
|
+
.demo-cursor-trail {
|
|
1146
|
+
position: fixed;
|
|
1147
|
+
width: 8px;
|
|
1148
|
+
height: 8px;
|
|
1149
|
+
border-radius: 50%;
|
|
1150
|
+
background: rgba(239, 68, 68, 0.4);
|
|
1151
|
+
pointer-events: none;
|
|
1152
|
+
z-index: 999998;
|
|
1153
|
+
animation: demo-trail-fade 0.3s ease-out forwards;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
@keyframes demo-trail-fade {
|
|
1157
|
+
0% {
|
|
1158
|
+
opacity: 0.6;
|
|
1159
|
+
transform: scale(1);
|
|
1160
|
+
}
|
|
1161
|
+
100% {
|
|
1162
|
+
opacity: 0;
|
|
1163
|
+
transform: scale(0.5);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
`;
|
|
1167
|
+
var CURSOR_SVG = `
|
|
1168
|
+
<svg id="demo-cursor-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1169
|
+
<path d="M3 3L10.5 21L13 13L21 10.5L3 3Z" fill="#ef4444" stroke="white" stroke-width="2" stroke-linejoin="round"/>
|
|
1170
|
+
</svg>
|
|
1171
|
+
`;
|
|
1172
|
+
var NAME_HELPER_SCRIPT = `
|
|
1173
|
+
(() => {
|
|
1174
|
+
if (typeof window.__name !== "function") {
|
|
1175
|
+
window.__name = (target, value) => {
|
|
1176
|
+
try {
|
|
1177
|
+
Object.defineProperty(target, "name", { value, configurable: true });
|
|
1178
|
+
} catch {
|
|
1179
|
+
// Ignore failures setting function names.
|
|
1180
|
+
}
|
|
1181
|
+
return target;
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
})();
|
|
1185
|
+
`;
|
|
1186
|
+
var nameHelperInstalled = /* @__PURE__ */ new WeakSet();
|
|
1187
|
+
async function ensureNameHelper(page) {
|
|
1188
|
+
if (!nameHelperInstalled.has(page)) {
|
|
1189
|
+
await page.addInitScript({ content: NAME_HELPER_SCRIPT });
|
|
1190
|
+
nameHelperInstalled.add(page);
|
|
1191
|
+
}
|
|
1192
|
+
await page.addScriptTag({ content: NAME_HELPER_SCRIPT });
|
|
1193
|
+
}
|
|
1194
|
+
async function injectCursorOverlay(page) {
|
|
1195
|
+
await ensureNameHelper(page);
|
|
1196
|
+
await page.addStyleTag({ content: CURSOR_STYLES });
|
|
1197
|
+
await page.evaluate((cursorSvg) => {
|
|
1198
|
+
const cursor = document.createElement("div");
|
|
1199
|
+
cursor.id = "demo-cursor";
|
|
1200
|
+
cursor.innerHTML = cursorSvg;
|
|
1201
|
+
cursor.style.left = "-100px";
|
|
1202
|
+
cursor.style.top = "-100px";
|
|
1203
|
+
document.body.appendChild(cursor);
|
|
1204
|
+
document.documentElement.classList.add("demo-cursor-active");
|
|
1205
|
+
window.__demoCursor = {
|
|
1206
|
+
setPosition: (x, y) => {
|
|
1207
|
+
cursor.style.left = `${x}px`;
|
|
1208
|
+
cursor.style.top = `${y}px`;
|
|
1209
|
+
},
|
|
1210
|
+
click: () => {
|
|
1211
|
+
const ripple = document.createElement("div");
|
|
1212
|
+
ripple.className = "demo-click-ripple";
|
|
1213
|
+
cursor.appendChild(ripple);
|
|
1214
|
+
setTimeout(() => ripple.remove(), 500);
|
|
1215
|
+
},
|
|
1216
|
+
highlight: (rect) => {
|
|
1217
|
+
const ring = document.createElement("div");
|
|
1218
|
+
ring.className = "demo-highlight-ring";
|
|
1219
|
+
ring.style.left = `${rect.x}px`;
|
|
1220
|
+
ring.style.top = `${rect.y}px`;
|
|
1221
|
+
ring.style.width = `${rect.width}px`;
|
|
1222
|
+
ring.style.height = `${rect.height}px`;
|
|
1223
|
+
document.body.appendChild(ring);
|
|
1224
|
+
setTimeout(() => ring.remove(), 800);
|
|
1225
|
+
},
|
|
1226
|
+
addTrail: (x, y) => {
|
|
1227
|
+
const trail = document.createElement("div");
|
|
1228
|
+
trail.className = "demo-cursor-trail";
|
|
1229
|
+
trail.style.left = `${x}px`;
|
|
1230
|
+
trail.style.top = `${y}px`;
|
|
1231
|
+
document.body.appendChild(trail);
|
|
1232
|
+
setTimeout(() => trail.remove(), 300);
|
|
1233
|
+
},
|
|
1234
|
+
destroy: () => {
|
|
1235
|
+
cursor.remove();
|
|
1236
|
+
document.documentElement.classList.remove("demo-cursor-active");
|
|
1237
|
+
document.querySelectorAll(".demo-cursor-trail, .demo-highlight-ring, .demo-click-ripple").forEach((el) => el.remove());
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
}, CURSOR_SVG);
|
|
1241
|
+
}
|
|
1242
|
+
async function moveCursorTo(page, x, y, options) {
|
|
1243
|
+
const steps = options?.steps ?? 15;
|
|
1244
|
+
const trailEnabled = options?.trailEnabled ?? true;
|
|
1245
|
+
const currentPos = await page.evaluate(() => {
|
|
1246
|
+
const cursor = document.getElementById("demo-cursor");
|
|
1247
|
+
if (!cursor) return { x: 0, y: 0 };
|
|
1248
|
+
return {
|
|
1249
|
+
x: parseFloat(cursor.style.left) || 0,
|
|
1250
|
+
y: parseFloat(cursor.style.top) || 0
|
|
1251
|
+
};
|
|
1252
|
+
});
|
|
1253
|
+
if (currentPos.x < 0 || currentPos.y < 0) {
|
|
1254
|
+
currentPos.x = x > 100 ? x - 100 : 50;
|
|
1255
|
+
currentPos.y = y > 100 ? y - 100 : 50;
|
|
1256
|
+
await page.evaluate(
|
|
1257
|
+
([startX, startY]) => {
|
|
1258
|
+
const api = window.__demoCursor;
|
|
1259
|
+
if (api) api.setPosition(startX, startY);
|
|
1260
|
+
},
|
|
1261
|
+
[currentPos.x, currentPos.y]
|
|
1262
|
+
);
|
|
1263
|
+
await page.waitForTimeout(50);
|
|
1264
|
+
}
|
|
1265
|
+
for (let i = 1; i <= steps; i++) {
|
|
1266
|
+
const t = i / steps;
|
|
1267
|
+
const easeT = 1 - Math.pow(1 - t, 3);
|
|
1268
|
+
const newX = currentPos.x + (x - currentPos.x) * easeT;
|
|
1269
|
+
const newY = currentPos.y + (y - currentPos.y) * easeT;
|
|
1270
|
+
await page.evaluate(
|
|
1271
|
+
({ posX, posY, addTrail }) => {
|
|
1272
|
+
const api = window.__demoCursor;
|
|
1273
|
+
if (api) {
|
|
1274
|
+
api.setPosition(posX, posY);
|
|
1275
|
+
if (addTrail) {
|
|
1276
|
+
api.addTrail(posX + 4, posY + 4);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
{ posX: newX, posY: newY, addTrail: trailEnabled && i % 3 === 0 }
|
|
1281
|
+
);
|
|
1282
|
+
await page.waitForTimeout(20);
|
|
1283
|
+
}
|
|
1284
|
+
await page.evaluate(
|
|
1285
|
+
([finalX, finalY]) => {
|
|
1286
|
+
const api = window.__demoCursor;
|
|
1287
|
+
if (api) api.setPosition(finalX, finalY);
|
|
1288
|
+
},
|
|
1289
|
+
[x, y]
|
|
1290
|
+
);
|
|
1291
|
+
await page.mouse.move(x, y);
|
|
1292
|
+
await page.waitForTimeout(100);
|
|
1293
|
+
}
|
|
1294
|
+
async function triggerClickRipple(page) {
|
|
1295
|
+
await page.evaluate(() => {
|
|
1296
|
+
const api = window.__demoCursor;
|
|
1297
|
+
if (api) {
|
|
1298
|
+
api.click();
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
await page.waitForTimeout(150);
|
|
1302
|
+
}
|
|
1303
|
+
async function highlightElement(page, selector) {
|
|
1304
|
+
const boundingBox = await page.locator(selector).first().boundingBox();
|
|
1305
|
+
if (boundingBox) {
|
|
1306
|
+
await page.evaluate(
|
|
1307
|
+
(rect) => {
|
|
1308
|
+
const api = window.__demoCursor;
|
|
1309
|
+
if (api) {
|
|
1310
|
+
api.highlight(rect);
|
|
1311
|
+
}
|
|
1312
|
+
},
|
|
1313
|
+
{
|
|
1314
|
+
x: boundingBox.x - 6,
|
|
1315
|
+
y: boundingBox.y - 6,
|
|
1316
|
+
width: boundingBox.width + 12,
|
|
1317
|
+
height: boundingBox.height + 12
|
|
1318
|
+
}
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
async function removeCursorOverlay(page) {
|
|
1323
|
+
await page.evaluate(() => {
|
|
1324
|
+
const api = window.__demoCursor;
|
|
1325
|
+
if (api) {
|
|
1326
|
+
api.destroy();
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
async function demoClick(page, selector, options) {
|
|
1331
|
+
const element = page.locator(selector).first();
|
|
1332
|
+
const timeout = options?.timeoutMs ?? 1e4;
|
|
1333
|
+
await element.scrollIntoViewIfNeeded();
|
|
1334
|
+
await element.waitFor({ state: "visible", timeout });
|
|
1335
|
+
const boundingBox = await element.boundingBox();
|
|
1336
|
+
if (!boundingBox) {
|
|
1337
|
+
throw new Error(`Element not found or not visible: ${selector}`);
|
|
1338
|
+
}
|
|
1339
|
+
const centerX = boundingBox.x + boundingBox.width / 2;
|
|
1340
|
+
const centerY = boundingBox.y + boundingBox.height / 2;
|
|
1341
|
+
if (options?.delayBefore) {
|
|
1342
|
+
await page.waitForTimeout(options.delayBefore);
|
|
1343
|
+
}
|
|
1344
|
+
await moveCursorTo(page, centerX, centerY, { steps: 20 });
|
|
1345
|
+
if (options?.highlight) {
|
|
1346
|
+
await highlightElement(page, selector);
|
|
1347
|
+
await page.waitForTimeout(200);
|
|
1348
|
+
}
|
|
1349
|
+
await triggerClickRipple(page);
|
|
1350
|
+
await element.click({ timeout });
|
|
1351
|
+
await page.waitForTimeout(200);
|
|
1352
|
+
if (options?.delayAfter) {
|
|
1353
|
+
await page.waitForTimeout(options.delayAfter);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
async function demoHover(page, selector, options) {
|
|
1357
|
+
const element = page.locator(selector).first();
|
|
1358
|
+
const timeout = options?.timeoutMs ?? 1e4;
|
|
1359
|
+
await element.scrollIntoViewIfNeeded();
|
|
1360
|
+
await element.waitFor({ state: "visible", timeout });
|
|
1361
|
+
const boundingBox = await element.boundingBox();
|
|
1362
|
+
if (!boundingBox) {
|
|
1363
|
+
throw new Error(`Element not found or not visible: ${selector}`);
|
|
1364
|
+
}
|
|
1365
|
+
const centerX = boundingBox.x + boundingBox.width / 2;
|
|
1366
|
+
const centerY = boundingBox.y + boundingBox.height / 2;
|
|
1367
|
+
await moveCursorTo(page, centerX, centerY, { steps: 15 });
|
|
1368
|
+
if (options?.highlight) {
|
|
1369
|
+
await highlightElement(page, selector);
|
|
1370
|
+
}
|
|
1371
|
+
await element.hover({ timeout });
|
|
1372
|
+
await page.waitForTimeout(300);
|
|
1373
|
+
}
|
|
1374
|
+
async function demoType(page, selector, text, options) {
|
|
1375
|
+
const element = page.locator(selector).first();
|
|
1376
|
+
await element.scrollIntoViewIfNeeded();
|
|
1377
|
+
await element.waitFor({ state: "visible", timeout: 1e4 });
|
|
1378
|
+
const boundingBox = await element.boundingBox();
|
|
1379
|
+
if (!boundingBox) {
|
|
1380
|
+
throw new Error(`Element not found or not visible: ${selector}`);
|
|
1381
|
+
}
|
|
1382
|
+
const centerX = boundingBox.x + boundingBox.width / 2;
|
|
1383
|
+
const centerY = boundingBox.y + boundingBox.height / 2;
|
|
1384
|
+
await moveCursorTo(page, centerX, centerY);
|
|
1385
|
+
if (options?.highlight) {
|
|
1386
|
+
await highlightElement(page, selector);
|
|
1387
|
+
await page.waitForTimeout(150);
|
|
1388
|
+
}
|
|
1389
|
+
await triggerClickRipple(page);
|
|
1390
|
+
await element.click();
|
|
1391
|
+
await page.waitForTimeout(100);
|
|
1392
|
+
await element.fill(text);
|
|
1393
|
+
await page.waitForTimeout(200);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// ../playwright/src/demo-runner.ts
|
|
1397
|
+
var DEFAULT_ACTION_TIMEOUT_MS = 15e3;
|
|
1398
|
+
var NAVIGATION_TIMEOUT_MS = 45e3;
|
|
1399
|
+
var UPLOAD_TIMEOUT_MS = 3e4;
|
|
1400
|
+
var ACTION_TIMEOUT_BUFFER_MS = 2e3;
|
|
1401
|
+
var DEFAULT_WAIT_TIMEOUT_MS = Number(process.env.DEMO_WAIT_TIMEOUT_MS ?? 15e3);
|
|
1402
|
+
var WAIT_RETRY_ATTEMPTS = Math.max(
|
|
1403
|
+
1,
|
|
1404
|
+
Number(process.env.DEMO_WAIT_RETRY_ATTEMPTS ?? 2)
|
|
1405
|
+
);
|
|
1406
|
+
var WAIT_RETRY_BASE_DELAY_MS = Number(process.env.DEMO_WAIT_RETRY_DELAY_MS ?? 500);
|
|
1407
|
+
function sanitizeDiagnosticsSegment(value, fallback) {
|
|
1408
|
+
const raw = String(value ?? "").trim();
|
|
1409
|
+
if (!raw) {
|
|
1410
|
+
return fallback;
|
|
1411
|
+
}
|
|
1412
|
+
const cleaned = raw.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
1413
|
+
const collapsed = cleaned.replace(/_{2,}/g, "_").replace(/^_+|_+$/g, "");
|
|
1414
|
+
const safe = collapsed || fallback;
|
|
1415
|
+
const trimmed = safe.length > 80 ? safe.slice(0, 80) : safe;
|
|
1416
|
+
return trimmed === "." || trimmed === ".." ? fallback : trimmed;
|
|
1417
|
+
}
|
|
1418
|
+
function logEvent(level, event, data) {
|
|
1419
|
+
const payload = {
|
|
1420
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1421
|
+
level,
|
|
1422
|
+
event,
|
|
1423
|
+
...data
|
|
1424
|
+
};
|
|
1425
|
+
const line = JSON.stringify(payload);
|
|
1426
|
+
if (level === "error") {
|
|
1427
|
+
console.error(line);
|
|
1428
|
+
} else if (level === "warn") {
|
|
1429
|
+
console.warn(line);
|
|
1430
|
+
} else {
|
|
1431
|
+
console.log(line);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
function getActionTimeoutMs(action) {
|
|
1435
|
+
if (action.action === "navigate") {
|
|
1436
|
+
return NAVIGATION_TIMEOUT_MS;
|
|
1437
|
+
}
|
|
1438
|
+
if (action.action === "upload") {
|
|
1439
|
+
return UPLOAD_TIMEOUT_MS;
|
|
1440
|
+
}
|
|
1441
|
+
if (action.action === "wait") {
|
|
1442
|
+
if (action.duration !== void 0) {
|
|
1443
|
+
return Math.max(DEFAULT_ACTION_TIMEOUT_MS, action.duration + ACTION_TIMEOUT_BUFFER_MS);
|
|
1444
|
+
}
|
|
1445
|
+
if (action.waitFor?.timeout) {
|
|
1446
|
+
return action.waitFor.timeout + ACTION_TIMEOUT_BUFFER_MS;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
if (action.waitFor?.timeout) {
|
|
1450
|
+
return action.waitFor.timeout + ACTION_TIMEOUT_BUFFER_MS;
|
|
1451
|
+
}
|
|
1452
|
+
return DEFAULT_ACTION_TIMEOUT_MS;
|
|
1453
|
+
}
|
|
1454
|
+
async function withActionTimeout(timeoutMs, label, fn) {
|
|
1455
|
+
let timeoutId = null;
|
|
1456
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1457
|
+
timeoutId = setTimeout(() => {
|
|
1458
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
1459
|
+
}, timeoutMs);
|
|
1460
|
+
});
|
|
1461
|
+
try {
|
|
1462
|
+
return await Promise.race([fn(), timeoutPromise]);
|
|
1463
|
+
} finally {
|
|
1464
|
+
if (timeoutId) {
|
|
1465
|
+
clearTimeout(timeoutId);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
async function captureDiagnostics(params) {
|
|
1470
|
+
const { context, stepId, stepIndex, actionIndex, action, error, runId } = params;
|
|
1471
|
+
const diagnosticsDir = path3.join(context.outputDir, "diagnostics");
|
|
1472
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1473
|
+
const stepLabel = sanitizeDiagnosticsSegment(stepId ?? "unknown-step", "unknown-step");
|
|
1474
|
+
const actionLabel = actionIndex !== void 0 ? `action-${actionIndex + 1}` : "action";
|
|
1475
|
+
const baseName = `${timestamp}-${runId}-${stepLabel}-${actionLabel}`;
|
|
1476
|
+
try {
|
|
1477
|
+
await fs3.mkdir(diagnosticsDir, { recursive: true });
|
|
1478
|
+
} catch {
|
|
1479
|
+
}
|
|
1480
|
+
const metadata = {
|
|
1481
|
+
runId,
|
|
1482
|
+
stepId,
|
|
1483
|
+
stepIndex,
|
|
1484
|
+
actionIndex,
|
|
1485
|
+
action,
|
|
1486
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1487
|
+
url: context.page.url(),
|
|
1488
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1489
|
+
};
|
|
1490
|
+
try {
|
|
1491
|
+
const screenshotPath = path3.join(diagnosticsDir, `${baseName}.png`);
|
|
1492
|
+
await context.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
1493
|
+
} catch {
|
|
1494
|
+
}
|
|
1495
|
+
try {
|
|
1496
|
+
const domContent = await context.page.content();
|
|
1497
|
+
const maxChars = 2e4;
|
|
1498
|
+
const snippet = domContent.length > maxChars ? `${domContent.slice(0, maxChars)}
|
|
1499
|
+
<!-- truncated -->` : domContent;
|
|
1500
|
+
const domPath = path3.join(diagnosticsDir, `${baseName}.dom.html`);
|
|
1501
|
+
await fs3.writeFile(domPath, snippet, "utf-8");
|
|
1502
|
+
} catch {
|
|
1503
|
+
}
|
|
1504
|
+
try {
|
|
1505
|
+
const metaPath = path3.join(diagnosticsDir, `${baseName}.json`);
|
|
1506
|
+
await fs3.writeFile(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
1507
|
+
} catch {
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
async function loadDemoDefinition(filePath, options) {
|
|
1511
|
+
const content = await fs3.readFile(filePath, "utf-8");
|
|
1512
|
+
const definition = parseFromYAML(content, options);
|
|
1513
|
+
validateDemoDefinition(definition);
|
|
1514
|
+
return definition;
|
|
1515
|
+
}
|
|
1516
|
+
function resolveFilePath(filePath, baseDir) {
|
|
1517
|
+
if (path3.isAbsolute(filePath)) {
|
|
1518
|
+
return filePath;
|
|
1519
|
+
}
|
|
1520
|
+
const root = baseDir ?? process.cwd();
|
|
1521
|
+
return path3.resolve(root, filePath);
|
|
1522
|
+
}
|
|
1523
|
+
function resolveNavigatePath(actionPath, context) {
|
|
1524
|
+
const resolvedPath = resolvePath(actionPath, { baseURL: context.baseURL });
|
|
1525
|
+
if (/\{[^}]+\}/.test(resolvedPath)) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
`Navigate action contains unresolved template variables: ${resolvedPath}`
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
return resolvedPath;
|
|
1531
|
+
}
|
|
1532
|
+
async function executeWaitCondition(page, condition, actionContext) {
|
|
1533
|
+
const timeout = condition.timeout || DEFAULT_WAIT_TIMEOUT_MS;
|
|
1534
|
+
let navigated = false;
|
|
1535
|
+
const getSelectorContext = async () => {
|
|
1536
|
+
if (!condition.value) return {};
|
|
1537
|
+
try {
|
|
1538
|
+
if (condition.type === "selector" || condition.type === "selectorHidden") {
|
|
1539
|
+
const count = await page.locator(condition.value).count();
|
|
1540
|
+
return { selectorCount: count };
|
|
1541
|
+
}
|
|
1542
|
+
if (condition.type === "text" || condition.type === "textHidden") {
|
|
1543
|
+
const count = await page.getByText(condition.value, { exact: false }).count();
|
|
1544
|
+
return { textMatchCount: count };
|
|
1545
|
+
}
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
return { selectorContextError: error instanceof Error ? error.message : String(error) };
|
|
1548
|
+
}
|
|
1549
|
+
return {};
|
|
1550
|
+
};
|
|
1551
|
+
for (let attempt = 0; attempt < WAIT_RETRY_ATTEMPTS; attempt += 1) {
|
|
1552
|
+
try {
|
|
1553
|
+
switch (condition.type) {
|
|
1554
|
+
case "text":
|
|
1555
|
+
if (!condition.value) {
|
|
1556
|
+
throw new Error("Wait for text requires 'value'");
|
|
1557
|
+
}
|
|
1558
|
+
await expect(page.getByText(condition.value, { exact: false }).first()).toBeVisible({
|
|
1559
|
+
timeout
|
|
1560
|
+
});
|
|
1561
|
+
break;
|
|
1562
|
+
case "selector":
|
|
1563
|
+
if (!condition.value) {
|
|
1564
|
+
throw new Error("Wait for selector requires 'value'");
|
|
1565
|
+
}
|
|
1566
|
+
await page.locator(condition.value).first().waitFor({ state: "visible", timeout });
|
|
1567
|
+
break;
|
|
1568
|
+
case "navigation":
|
|
1569
|
+
await page.waitForURL(/.*/, { waitUntil: "networkidle", timeout });
|
|
1570
|
+
navigated = true;
|
|
1571
|
+
break;
|
|
1572
|
+
case "idle":
|
|
1573
|
+
await page.waitForLoadState("networkidle", { timeout });
|
|
1574
|
+
break;
|
|
1575
|
+
case "selectorHidden":
|
|
1576
|
+
if (!condition.value) {
|
|
1577
|
+
throw new Error("Wait for selectorHidden requires 'value'");
|
|
1578
|
+
}
|
|
1579
|
+
await page.locator(condition.value).first().waitFor({ state: "hidden", timeout });
|
|
1580
|
+
break;
|
|
1581
|
+
case "textHidden":
|
|
1582
|
+
if (!condition.value) {
|
|
1583
|
+
throw new Error("Wait for textHidden requires 'value'");
|
|
1584
|
+
}
|
|
1585
|
+
await expect(page.getByText(condition.value, { exact: false }).first()).toBeHidden({
|
|
1586
|
+
timeout
|
|
1587
|
+
});
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
if (attempt > 0 && actionContext) {
|
|
1591
|
+
logEvent("info", "wait_retry_success", {
|
|
1592
|
+
runId: actionContext.runId,
|
|
1593
|
+
demoName: actionContext.demoName,
|
|
1594
|
+
stepId: actionContext.stepId,
|
|
1595
|
+
actionIndex: actionContext.actionIndex,
|
|
1596
|
+
waitType: condition.type,
|
|
1597
|
+
attempt: attempt + 1
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
return navigated;
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
const selectorContext = await getSelectorContext();
|
|
1603
|
+
logEvent("warn", "wait_retry", {
|
|
1604
|
+
runId: actionContext?.runId,
|
|
1605
|
+
demoName: actionContext?.demoName,
|
|
1606
|
+
stepId: actionContext?.stepId,
|
|
1607
|
+
actionIndex: actionContext?.actionIndex,
|
|
1608
|
+
waitType: condition.type,
|
|
1609
|
+
waitValue: condition.value,
|
|
1610
|
+
timeout,
|
|
1611
|
+
attempt: attempt + 1,
|
|
1612
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1613
|
+
...selectorContext
|
|
1614
|
+
});
|
|
1615
|
+
if (attempt >= WAIT_RETRY_ATTEMPTS - 1) {
|
|
1616
|
+
throw error;
|
|
1617
|
+
}
|
|
1618
|
+
const delayMs = WAIT_RETRY_BASE_DELAY_MS * (attempt + 1);
|
|
1619
|
+
await page.waitForTimeout(delayMs);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return navigated;
|
|
1623
|
+
}
|
|
1624
|
+
async function executeAction(action, context, actionContext) {
|
|
1625
|
+
const { page } = context;
|
|
1626
|
+
let navigated = false;
|
|
1627
|
+
const timeoutMs = getActionTimeoutMs(action);
|
|
1628
|
+
const startedAt = Date.now();
|
|
1629
|
+
logEvent("info", "action_start", {
|
|
1630
|
+
runId: actionContext.runId,
|
|
1631
|
+
demoName: actionContext.demoName,
|
|
1632
|
+
stepId: actionContext.stepId,
|
|
1633
|
+
stepIndex: actionContext.stepIndex,
|
|
1634
|
+
actionIndex: actionContext.actionIndex,
|
|
1635
|
+
actionType: action.action,
|
|
1636
|
+
timeoutMs
|
|
1637
|
+
});
|
|
1638
|
+
try {
|
|
1639
|
+
await withActionTimeout(timeoutMs, `${action.action} action`, async () => {
|
|
1640
|
+
switch (action.action) {
|
|
1641
|
+
case "navigate": {
|
|
1642
|
+
if (!action.path) {
|
|
1643
|
+
throw new Error("Navigate action missing 'path'");
|
|
1644
|
+
}
|
|
1645
|
+
const resolvedPath = resolveNavigatePath(action.path, context);
|
|
1646
|
+
const fullURL = resolvedPath.startsWith("http") ? resolvedPath : `${context.baseURL}${resolvedPath}`;
|
|
1647
|
+
await page.goto(fullURL, { waitUntil: "networkidle", timeout: timeoutMs });
|
|
1648
|
+
navigated = true;
|
|
1649
|
+
await injectCursorOverlay(page);
|
|
1650
|
+
break;
|
|
1651
|
+
}
|
|
1652
|
+
case "click": {
|
|
1653
|
+
if (!action.target) {
|
|
1654
|
+
throw new Error("Click action missing 'target'");
|
|
1655
|
+
}
|
|
1656
|
+
const selector = resolveTarget(action.target);
|
|
1657
|
+
await demoClick(page, selector, {
|
|
1658
|
+
highlight: action.highlight,
|
|
1659
|
+
delayAfter: 500,
|
|
1660
|
+
timeoutMs
|
|
1661
|
+
});
|
|
1662
|
+
break;
|
|
1663
|
+
}
|
|
1664
|
+
case "type": {
|
|
1665
|
+
if (!action.target) {
|
|
1666
|
+
throw new Error("Type action missing 'target'");
|
|
1667
|
+
}
|
|
1668
|
+
if (!action.text) {
|
|
1669
|
+
throw new Error("Type action missing 'text'");
|
|
1670
|
+
}
|
|
1671
|
+
const selector = resolveTarget(action.target);
|
|
1672
|
+
const element = page.locator(selector).first();
|
|
1673
|
+
await element.waitFor({ state: "visible", timeout: timeoutMs });
|
|
1674
|
+
await element.scrollIntoViewIfNeeded();
|
|
1675
|
+
const box = await element.boundingBox();
|
|
1676
|
+
if (box) {
|
|
1677
|
+
await moveCursorTo(page, box.x + box.width / 2, box.y + box.height / 2);
|
|
1678
|
+
}
|
|
1679
|
+
await element.click({ timeout: timeoutMs });
|
|
1680
|
+
await element.fill(action.text, { timeout: timeoutMs });
|
|
1681
|
+
break;
|
|
1682
|
+
}
|
|
1683
|
+
case "upload": {
|
|
1684
|
+
if (!action.file) {
|
|
1685
|
+
throw new Error("Upload action missing 'file'");
|
|
1686
|
+
}
|
|
1687
|
+
const filePath = resolveFilePath(action.file, context.assetBaseDir);
|
|
1688
|
+
const fileSelector = action.target?.selector || 'input[type="file"]';
|
|
1689
|
+
const fileInput = page.locator(fileSelector).first();
|
|
1690
|
+
await fileInput.setInputFiles(filePath, { timeout: timeoutMs });
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
case "wait": {
|
|
1694
|
+
if (action.duration) {
|
|
1695
|
+
await page.waitForTimeout(action.duration);
|
|
1696
|
+
} else if (action.waitFor) {
|
|
1697
|
+
navigated = await executeWaitCondition(page, action.waitFor, actionContext);
|
|
1698
|
+
if (navigated) {
|
|
1699
|
+
await injectCursorOverlay(page);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
break;
|
|
1703
|
+
}
|
|
1704
|
+
case "hover": {
|
|
1705
|
+
if (!action.target) {
|
|
1706
|
+
throw new Error("Hover action missing 'target'");
|
|
1707
|
+
}
|
|
1708
|
+
const selector = resolveTarget(action.target);
|
|
1709
|
+
const element = page.locator(selector).first();
|
|
1710
|
+
await element.waitFor({ state: "visible", timeout: timeoutMs });
|
|
1711
|
+
await element.scrollIntoViewIfNeeded();
|
|
1712
|
+
const box = await element.boundingBox();
|
|
1713
|
+
if (box) {
|
|
1714
|
+
await moveCursorTo(page, box.x + box.width / 2, box.y + box.height / 2);
|
|
1715
|
+
}
|
|
1716
|
+
await element.hover({ timeout: timeoutMs });
|
|
1717
|
+
break;
|
|
1718
|
+
}
|
|
1719
|
+
case "scroll": {
|
|
1720
|
+
const scrollAmount = action.duration || 500;
|
|
1721
|
+
await page.mouse.wheel(0, scrollAmount);
|
|
1722
|
+
await page.waitForTimeout(300);
|
|
1723
|
+
break;
|
|
1724
|
+
}
|
|
1725
|
+
case "scrollTo": {
|
|
1726
|
+
if (!action.target) {
|
|
1727
|
+
throw new Error("ScrollTo action missing 'target'");
|
|
1728
|
+
}
|
|
1729
|
+
const selector = resolveTarget(action.target);
|
|
1730
|
+
await page.locator(selector).first().scrollIntoViewIfNeeded({ timeout: timeoutMs });
|
|
1731
|
+
await page.waitForTimeout(300);
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
case "drag": {
|
|
1735
|
+
if (!action.target) {
|
|
1736
|
+
throw new Error("Drag action missing 'target'");
|
|
1737
|
+
}
|
|
1738
|
+
if (!action.drag) {
|
|
1739
|
+
throw new Error("Drag action missing 'drag' configuration");
|
|
1740
|
+
}
|
|
1741
|
+
const selector = resolveTarget(action.target);
|
|
1742
|
+
const element = page.locator(selector).first();
|
|
1743
|
+
await element.waitFor({ state: "visible", timeout: timeoutMs });
|
|
1744
|
+
await element.scrollIntoViewIfNeeded();
|
|
1745
|
+
const box = await element.boundingBox();
|
|
1746
|
+
if (!box) {
|
|
1747
|
+
throw new Error(`Drag target not found or not visible: ${selector}`);
|
|
1748
|
+
}
|
|
1749
|
+
const startX = box.x + box.width / 2;
|
|
1750
|
+
const startY = box.y + box.height / 2;
|
|
1751
|
+
const endX = startX + action.drag.deltaX;
|
|
1752
|
+
const endY = startY + action.drag.deltaY;
|
|
1753
|
+
const dragSteps = action.drag.steps || 20;
|
|
1754
|
+
await moveCursorTo(page, startX, startY, { steps: 15 });
|
|
1755
|
+
await page.waitForTimeout(100);
|
|
1756
|
+
await page.mouse.move(startX, startY);
|
|
1757
|
+
await page.mouse.down();
|
|
1758
|
+
await page.waitForTimeout(50);
|
|
1759
|
+
for (let i = 1; i <= dragSteps; i++) {
|
|
1760
|
+
const t = i / dragSteps;
|
|
1761
|
+
const easeT = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
1762
|
+
const currentX = startX + (endX - startX) * easeT;
|
|
1763
|
+
const currentY = startY + (endY - startY) * easeT;
|
|
1764
|
+
await page.mouse.move(currentX, currentY);
|
|
1765
|
+
await page.evaluate(
|
|
1766
|
+
([x, y, shouldAddTrail]) => {
|
|
1767
|
+
const api = window.__demoCursor;
|
|
1768
|
+
if (api) {
|
|
1769
|
+
api.setPosition(x, y);
|
|
1770
|
+
if (shouldAddTrail) {
|
|
1771
|
+
api.addTrail(x + 4, y + 4);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
},
|
|
1775
|
+
[currentX, currentY, i % 3 === 0]
|
|
1776
|
+
);
|
|
1777
|
+
await page.waitForTimeout(25);
|
|
1778
|
+
}
|
|
1779
|
+
await page.mouse.up();
|
|
1780
|
+
await page.waitForTimeout(200);
|
|
1781
|
+
break;
|
|
1782
|
+
}
|
|
1783
|
+
default:
|
|
1784
|
+
throw new Error(`Unknown action type: ${action.action}`);
|
|
1785
|
+
}
|
|
1786
|
+
if (action.waitFor && action.action !== "wait") {
|
|
1787
|
+
const waitNavigated = await executeWaitCondition(page, action.waitFor, actionContext);
|
|
1788
|
+
if (waitNavigated) {
|
|
1789
|
+
await injectCursorOverlay(page);
|
|
1790
|
+
navigated = true;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
await page.waitForTimeout(200);
|
|
1794
|
+
});
|
|
1795
|
+
logEvent("info", "action_complete", {
|
|
1796
|
+
runId: actionContext.runId,
|
|
1797
|
+
demoName: actionContext.demoName,
|
|
1798
|
+
stepId: actionContext.stepId,
|
|
1799
|
+
stepIndex: actionContext.stepIndex,
|
|
1800
|
+
actionIndex: actionContext.actionIndex,
|
|
1801
|
+
actionType: action.action,
|
|
1802
|
+
durationMs: Date.now() - startedAt,
|
|
1803
|
+
navigated
|
|
1804
|
+
});
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
logEvent("error", "action_failed", {
|
|
1807
|
+
runId: actionContext.runId,
|
|
1808
|
+
demoName: actionContext.demoName,
|
|
1809
|
+
stepId: actionContext.stepId,
|
|
1810
|
+
stepIndex: actionContext.stepIndex,
|
|
1811
|
+
actionIndex: actionContext.actionIndex,
|
|
1812
|
+
actionType: action.action,
|
|
1813
|
+
durationMs: Date.now() - startedAt,
|
|
1814
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1815
|
+
});
|
|
1816
|
+
await captureDiagnostics({
|
|
1817
|
+
context,
|
|
1818
|
+
stepId: actionContext.stepId,
|
|
1819
|
+
stepIndex: actionContext.stepIndex,
|
|
1820
|
+
actionIndex: actionContext.actionIndex,
|
|
1821
|
+
action,
|
|
1822
|
+
error,
|
|
1823
|
+
runId: actionContext.runId
|
|
1824
|
+
});
|
|
1825
|
+
throw error;
|
|
1826
|
+
}
|
|
1827
|
+
return navigated;
|
|
1828
|
+
}
|
|
1829
|
+
async function executeStep(step, context, scriptGenerator, runId, demoName, stepIndex) {
|
|
1830
|
+
console.log(`[demo] Executing step: ${step.id} (${step.actions.length} actions)`);
|
|
1831
|
+
logEvent("info", "step_start", {
|
|
1832
|
+
runId,
|
|
1833
|
+
demoName,
|
|
1834
|
+
stepId: step.id,
|
|
1835
|
+
stepIndex,
|
|
1836
|
+
actionCount: step.actions.length
|
|
1837
|
+
});
|
|
1838
|
+
if (scriptGenerator) {
|
|
1839
|
+
scriptGenerator.addSegment(step.id, step.script);
|
|
1840
|
+
}
|
|
1841
|
+
for (let i = 0; i < step.actions.length; i++) {
|
|
1842
|
+
const action = step.actions[i];
|
|
1843
|
+
console.log(`[demo] Action ${i + 1}/${step.actions.length}: ${action.action}`);
|
|
1844
|
+
await executeAction(action, context, {
|
|
1845
|
+
stepId: step.id,
|
|
1846
|
+
stepIndex,
|
|
1847
|
+
actionIndex: i,
|
|
1848
|
+
runId,
|
|
1849
|
+
demoName
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
logEvent("info", "step_complete", {
|
|
1853
|
+
runId,
|
|
1854
|
+
demoName,
|
|
1855
|
+
stepId: step.id,
|
|
1856
|
+
stepIndex,
|
|
1857
|
+
actionCount: step.actions.length
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
function getStepStartTarget(step) {
|
|
1861
|
+
for (const action of step.actions) {
|
|
1862
|
+
if (action.action === "wait" || action.action === "navigate") {
|
|
1863
|
+
continue;
|
|
1864
|
+
}
|
|
1865
|
+
if (!action.target) {
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
try {
|
|
1869
|
+
return {
|
|
1870
|
+
selector: resolveTarget(action.target),
|
|
1871
|
+
waitForState: action.action === "upload" ? "attached" : "visible"
|
|
1872
|
+
};
|
|
1873
|
+
} catch {
|
|
1874
|
+
return null;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
return null;
|
|
1878
|
+
}
|
|
1879
|
+
async function waitForStepReady(page, step) {
|
|
1880
|
+
const target = getStepStartTarget(step);
|
|
1881
|
+
if (!target) {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
try {
|
|
1885
|
+
await page.locator(target.selector).first().waitFor({ state: target.waitForState, timeout: DEFAULT_WAIT_TIMEOUT_MS });
|
|
1886
|
+
} catch {
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
function getStepEndSelector(step) {
|
|
1890
|
+
for (let index = step.actions.length - 1; index >= 0; index -= 1) {
|
|
1891
|
+
const action = step.actions[index];
|
|
1892
|
+
if (action.action === "wait" || action.action === "navigate") {
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
if (!action.target) {
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
try {
|
|
1899
|
+
return resolveTarget(action.target);
|
|
1900
|
+
} catch {
|
|
1901
|
+
return null;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
return null;
|
|
1905
|
+
}
|
|
1906
|
+
async function waitForStepExit(page, step) {
|
|
1907
|
+
const selector = getStepEndSelector(step);
|
|
1908
|
+
if (!selector) {
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
try {
|
|
1912
|
+
await page.locator(selector).first().waitFor({ state: "hidden", timeout: 1500 });
|
|
1913
|
+
} catch {
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
async function runDemo(definition, context, options = {}) {
|
|
1917
|
+
const startTime = Date.now();
|
|
1918
|
+
const transitionDelayMs = 500;
|
|
1919
|
+
const runId = `${definition.name}-${startTime}`;
|
|
1920
|
+
let scriptGenerator = null;
|
|
1921
|
+
logEvent("info", "demo_start", {
|
|
1922
|
+
runId,
|
|
1923
|
+
demoName: definition.name,
|
|
1924
|
+
stepCount: definition.steps.length,
|
|
1925
|
+
baseURL: context.baseURL
|
|
1926
|
+
});
|
|
1927
|
+
try {
|
|
1928
|
+
await injectCursorOverlay(context.page);
|
|
1929
|
+
const hadVideoStartTime = context.videoRecordingStartTime !== void 0;
|
|
1930
|
+
const syncedVideoStartTimeMs = context.videoRecordingStartTime ?? Date.now();
|
|
1931
|
+
if (!hadVideoStartTime) {
|
|
1932
|
+
context.videoRecordingStartTime = syncedVideoStartTimeMs;
|
|
1933
|
+
}
|
|
1934
|
+
logEvent("info", "video_sync", {
|
|
1935
|
+
runId,
|
|
1936
|
+
demoName: definition.name,
|
|
1937
|
+
videoStartTimeMs: syncedVideoStartTimeMs,
|
|
1938
|
+
providedByCaller: hadVideoStartTime
|
|
1939
|
+
});
|
|
1940
|
+
if (options.generateScripts) {
|
|
1941
|
+
scriptGenerator = createScriptGenerator(
|
|
1942
|
+
definition.name,
|
|
1943
|
+
definition.title,
|
|
1944
|
+
syncedVideoStartTimeMs
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
for (let stepIndex = 0; stepIndex < definition.steps.length; stepIndex += 1) {
|
|
1948
|
+
const step = definition.steps[stepIndex];
|
|
1949
|
+
if (scriptGenerator && stepIndex > 0) {
|
|
1950
|
+
await waitForStepReady(context.page, step);
|
|
1951
|
+
}
|
|
1952
|
+
if (scriptGenerator) {
|
|
1953
|
+
scriptGenerator.startStep(step.id);
|
|
1954
|
+
}
|
|
1955
|
+
await executeStep(step, context, scriptGenerator, runId, definition.name, stepIndex);
|
|
1956
|
+
if (scriptGenerator && stepIndex < definition.steps.length - 1) {
|
|
1957
|
+
await waitForStepExit(context.page, step);
|
|
1958
|
+
await context.page.waitForTimeout(transitionDelayMs);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
if (scriptGenerator) {
|
|
1962
|
+
scriptGenerator.finishAllSteps();
|
|
1963
|
+
const scriptOutputDir = options.scriptOutputDir ?? path3.join(context.outputDir, "scripts");
|
|
1964
|
+
const scriptBasePath = path3.join(scriptOutputDir, definition.name);
|
|
1965
|
+
await scriptGenerator.exportJSON(`${scriptBasePath}.json`);
|
|
1966
|
+
await scriptGenerator.exportSRT(`${scriptBasePath}.srt`);
|
|
1967
|
+
await scriptGenerator.exportMarkdown(`${scriptBasePath}.md`);
|
|
1968
|
+
await scriptGenerator.exportAIVoiceScript(`${scriptBasePath}.voice.json`);
|
|
1969
|
+
await removeCursorOverlay(context.page);
|
|
1970
|
+
logEvent("info", "demo_complete", {
|
|
1971
|
+
runId,
|
|
1972
|
+
demoName: definition.name,
|
|
1973
|
+
durationMs: Date.now() - startTime
|
|
1974
|
+
});
|
|
1975
|
+
return {
|
|
1976
|
+
success: true,
|
|
1977
|
+
demoName: definition.name,
|
|
1978
|
+
videoPath: context.videoPath,
|
|
1979
|
+
scriptPath: `${scriptBasePath}.json`,
|
|
1980
|
+
duration: Date.now() - startTime
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
await removeCursorOverlay(context.page);
|
|
1984
|
+
logEvent("info", "demo_complete", {
|
|
1985
|
+
runId,
|
|
1986
|
+
demoName: definition.name,
|
|
1987
|
+
durationMs: Date.now() - startTime
|
|
1988
|
+
});
|
|
1989
|
+
return {
|
|
1990
|
+
success: true,
|
|
1991
|
+
demoName: definition.name,
|
|
1992
|
+
videoPath: context.videoPath,
|
|
1993
|
+
duration: Date.now() - startTime
|
|
1994
|
+
};
|
|
1995
|
+
} catch (error) {
|
|
1996
|
+
logEvent("error", "demo_failed", {
|
|
1997
|
+
runId,
|
|
1998
|
+
demoName: definition.name,
|
|
1999
|
+
durationMs: Date.now() - startTime,
|
|
2000
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2001
|
+
});
|
|
2002
|
+
await captureDiagnostics({
|
|
2003
|
+
context,
|
|
2004
|
+
error,
|
|
2005
|
+
runId
|
|
2006
|
+
});
|
|
2007
|
+
return {
|
|
2008
|
+
success: false,
|
|
2009
|
+
demoName: definition.name,
|
|
2010
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
2011
|
+
duration: Date.now() - startTime
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
async function runDemoFromFile(definitionPath, context, options) {
|
|
2016
|
+
const definition = await loadDemoDefinition(definitionPath);
|
|
2017
|
+
return runDemo(definition, context, options);
|
|
2018
|
+
}
|
|
2019
|
+
async function discoverDemos(demoDir) {
|
|
2020
|
+
const files = await fs3.readdir(demoDir);
|
|
2021
|
+
return files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).map((f) => path3.join(demoDir, f));
|
|
2022
|
+
}
|
|
2023
|
+
export {
|
|
2024
|
+
DEMO_SCHEMA_VERSION,
|
|
2025
|
+
ScriptGenerator,
|
|
2026
|
+
VoiceSynthesizer,
|
|
2027
|
+
createClickAction,
|
|
2028
|
+
createDragAction,
|
|
2029
|
+
createEmptyDemo,
|
|
2030
|
+
createEmptyStep,
|
|
2031
|
+
createHoverAction,
|
|
2032
|
+
createNavigateAction,
|
|
2033
|
+
createScriptGenerator,
|
|
2034
|
+
createScrollAction,
|
|
2035
|
+
createScrollToAction,
|
|
2036
|
+
createTypeAction,
|
|
2037
|
+
createUploadAction,
|
|
2038
|
+
createVoiceSynthesizer,
|
|
2039
|
+
createWaitAction,
|
|
2040
|
+
createWaitForAction,
|
|
2041
|
+
demoClick,
|
|
2042
|
+
demoDefinitionSchema,
|
|
2043
|
+
demoHover,
|
|
2044
|
+
demoType,
|
|
2045
|
+
discoverDemos,
|
|
2046
|
+
formatValidationError,
|
|
2047
|
+
generateTimingManifest,
|
|
2048
|
+
highlightElement,
|
|
2049
|
+
injectCursorOverlay,
|
|
2050
|
+
loadDemoDefinition,
|
|
2051
|
+
moveCursorTo,
|
|
2052
|
+
parseDemoDefinition,
|
|
2053
|
+
parseFromYAML,
|
|
2054
|
+
removeCursorOverlay,
|
|
2055
|
+
resolvePath,
|
|
2056
|
+
resolveTarget,
|
|
2057
|
+
runDemo,
|
|
2058
|
+
runDemoFromFile,
|
|
2059
|
+
safeParseDemoDefinition,
|
|
2060
|
+
serializeToYAML,
|
|
2061
|
+
triggerClickRipple,
|
|
2062
|
+
validateDemoDefinition
|
|
2063
|
+
};
|
|
2064
|
+
//# sourceMappingURL=index.js.map
|