playwright-director 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +860 -0
  2. package/dist/Tutorial.d.ts +108 -0
  3. package/dist/Tutorial.d.ts.map +1 -0
  4. package/dist/Tutorial.js +767 -0
  5. package/dist/Tutorial.js.map +1 -0
  6. package/dist/bin/export-transcript.d.ts +3 -0
  7. package/dist/bin/export-transcript.d.ts.map +1 -0
  8. package/dist/bin/export-transcript.js +111 -0
  9. package/dist/bin/export-transcript.js.map +1 -0
  10. package/dist/cursor.d.ts +24 -0
  11. package/dist/cursor.d.ts.map +1 -0
  12. package/dist/cursor.js +96 -0
  13. package/dist/cursor.js.map +1 -0
  14. package/dist/index.d.ts +16 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +12 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/init.d.ts +3 -0
  19. package/dist/init.d.ts.map +1 -0
  20. package/dist/init.js +183 -0
  21. package/dist/init.js.map +1 -0
  22. package/dist/merge.d.ts +26 -0
  23. package/dist/merge.d.ts.map +1 -0
  24. package/dist/merge.js +65 -0
  25. package/dist/merge.js.map +1 -0
  26. package/dist/music.d.ts +29 -0
  27. package/dist/music.d.ts.map +1 -0
  28. package/dist/music.js +107 -0
  29. package/dist/music.js.map +1 -0
  30. package/dist/overlay-html.d.ts +44 -0
  31. package/dist/overlay-html.d.ts.map +1 -0
  32. package/dist/overlay-html.js +150 -0
  33. package/dist/overlay-html.js.map +1 -0
  34. package/dist/overlay.d.ts +39 -0
  35. package/dist/overlay.d.ts.map +1 -0
  36. package/dist/overlay.js +134 -0
  37. package/dist/overlay.js.map +1 -0
  38. package/dist/postinstall.d.ts +3 -0
  39. package/dist/postinstall.d.ts.map +1 -0
  40. package/dist/postinstall.js +38 -0
  41. package/dist/postinstall.js.map +1 -0
  42. package/dist/reporter.d.ts +22 -0
  43. package/dist/reporter.d.ts.map +1 -0
  44. package/dist/reporter.js +166 -0
  45. package/dist/reporter.js.map +1 -0
  46. package/dist/site/build-site.d.ts +2 -0
  47. package/dist/site/build-site.d.ts.map +1 -0
  48. package/dist/site/build-site.js +82 -0
  49. package/dist/site/build-site.js.map +1 -0
  50. package/dist/site/embed.d.ts +48 -0
  51. package/dist/site/embed.d.ts.map +1 -0
  52. package/dist/site/embed.js +45 -0
  53. package/dist/site/embed.js.map +1 -0
  54. package/dist/site/generate-manifest.d.ts +15 -0
  55. package/dist/site/generate-manifest.d.ts.map +1 -0
  56. package/dist/site/generate-manifest.js +106 -0
  57. package/dist/site/generate-manifest.js.map +1 -0
  58. package/dist/site/scaffold.d.ts +3 -0
  59. package/dist/site/scaffold.d.ts.map +1 -0
  60. package/dist/site/scaffold.js +70 -0
  61. package/dist/site/scaffold.js.map +1 -0
  62. package/dist/site/scan-tutorials.d.ts +12 -0
  63. package/dist/site/scan-tutorials.d.ts.map +1 -0
  64. package/dist/site/scan-tutorials.js +54 -0
  65. package/dist/site/scan-tutorials.js.map +1 -0
  66. package/dist/site/types.d.ts +75 -0
  67. package/dist/site/types.d.ts.map +1 -0
  68. package/dist/site/types.js +2 -0
  69. package/dist/site/types.js.map +1 -0
  70. package/dist/skill-stamp.d.ts +21 -0
  71. package/dist/skill-stamp.d.ts.map +1 -0
  72. package/dist/skill-stamp.js +39 -0
  73. package/dist/skill-stamp.js.map +1 -0
  74. package/dist/slugify.d.ts +19 -0
  75. package/dist/slugify.d.ts.map +1 -0
  76. package/dist/slugify.js +29 -0
  77. package/dist/slugify.js.map +1 -0
  78. package/dist/stage-presets.d.ts +51 -0
  79. package/dist/stage-presets.d.ts.map +1 -0
  80. package/dist/stage-presets.js +45 -0
  81. package/dist/stage-presets.js.map +1 -0
  82. package/dist/styles.css +637 -0
  83. package/dist/timeline.d.ts +87 -0
  84. package/dist/timeline.d.ts.map +1 -0
  85. package/dist/timeline.js +108 -0
  86. package/dist/timeline.js.map +1 -0
  87. package/dist/transcript.d.ts +87 -0
  88. package/dist/transcript.d.ts.map +1 -0
  89. package/dist/transcript.js +291 -0
  90. package/dist/transcript.js.map +1 -0
  91. package/dist/tts-provider.d.ts +69 -0
  92. package/dist/tts-provider.d.ts.map +1 -0
  93. package/dist/tts-provider.js +182 -0
  94. package/dist/tts-provider.js.map +1 -0
  95. package/dist/types.d.ts +109 -0
  96. package/dist/types.d.ts.map +1 -0
  97. package/dist/types.js +2 -0
  98. package/dist/types.js.map +1 -0
  99. package/dist/voice-prerender.d.ts +41 -0
  100. package/dist/voice-prerender.d.ts.map +1 -0
  101. package/dist/voice-prerender.js +122 -0
  102. package/dist/voice-prerender.js.map +1 -0
  103. package/dist/voice.d.ts +76 -0
  104. package/dist/voice.d.ts.map +1 -0
  105. package/dist/voice.js +244 -0
  106. package/dist/voice.js.map +1 -0
  107. package/package.json +87 -0
  108. package/skills/tutorialize/SKILL.md +54 -0
  109. package/skills/tutorialize/agent.md +58 -0
  110. package/skills/tutorialize/references/api.md +489 -0
  111. package/skills/tutorialize/references/storytelling.md +193 -0
  112. package/src/styles.css +637 -0
  113. package/templates/site/public/.gitkeep +0 -0
  114. package/templates/site/public/widget.js +273 -0
  115. package/templates/site/src/components/TutorialsHome.astro +141 -0
  116. package/templates/site/src/components/VideoCard.astro +192 -0
  117. package/templates/site/src/components/VideoModal.astro +170 -0
  118. package/templates/site/src/data/.gitkeep +0 -0
  119. package/templates/site/src/layouts/Base.astro +56 -0
  120. package/templates/site/src/pages/[slug].astro +745 -0
  121. package/templates/site/src/pages/index.astro +5 -0
  122. package/templates/site/src/styles/tutorials.css +303 -0
@@ -0,0 +1,767 @@
1
+ import { readFileSync, unlinkSync, existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { renderStage } from './overlay-html.js';
5
+ import { TutorialVoice } from './voice.js';
6
+ import { TutorialMusic } from './music.js';
7
+ import { TutorialCursor } from './cursor.js';
8
+ import { TutorialOverlay } from './overlay.js';
9
+ import { TutorialTimeline } from './timeline.js';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const TUTORIAL_MODE = process.env.TUTORIAL_MODE === 'true';
13
+ const TUTORIAL_VOICE = process.env.TUTORIAL_VOICE !== 'false';
14
+ const TUTORIAL_VOICE_NAME = process.env.TUTORIAL_VOICE_NAME;
15
+ const TUTORIAL_OUTPUT_DIR = process.env.TUTORIAL_OUTPUT_DIR || 'tutorials/output';
16
+ const DEFAULT_AUDIO_BASE_URL = 'http://localhost:5173';
17
+ const DEFAULT_MUSIC_URL_PATH = '/audio/tutorial-background.mp3';
18
+ const TUTORIAL_MUSIC = process.env.TUTORIAL_MUSIC;
19
+ const TUTORIAL_MUSIC_VOLUME = parseFloat(process.env.TUTORIAL_MUSIC_VOLUME || '0.15');
20
+ const TUTORIAL_VOICE_VOLUME = parseFloat(process.env.TUTORIAL_VOICE_VOLUME || '2.5');
21
+ const DEFAULT_STYLES = readFileSync(join(__dirname, 'styles.css'), 'utf-8');
22
+ let _sharp = null;
23
+ async function getSharp() {
24
+ if (_sharp !== null)
25
+ return _sharp;
26
+ try {
27
+ _sharp = (await import('sharp')).default;
28
+ return _sharp;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * When to start a step's action inside its narration clip (option B of
36
+ * docs/narration-action-overlap.md — one merged clip, computed offset).
37
+ *
38
+ * Two-phase (`doText` known): character-share estimate of where the "do"
39
+ * sentence ends — `duration × (len(do) + 2) / len(full)` (the +2 is the
40
+ * ". " separator). Single-phase: fixed 25% of the clip.
41
+ */
42
+ export function narrationActionOffset(durationMs, fullText, doText) {
43
+ if (!doText || !fullText || doText.length >= fullText.length) {
44
+ return Math.round(durationMs * 0.25);
45
+ }
46
+ const fraction = Math.min(1, (doText.length + 2) / fullText.length);
47
+ return Math.round(durationMs * fraction);
48
+ }
49
+ const asList = (focus) => (Array.isArray(focus) ? focus : [focus]);
50
+ export class Tutorial {
51
+ page;
52
+ // Scene and variant options are kept in their own fields below, not in this bag.
53
+ options;
54
+ initialized = false;
55
+ testName;
56
+ translateFn;
57
+ voice;
58
+ music;
59
+ cursor;
60
+ overlay;
61
+ timeline;
62
+ pendingItems = [];
63
+ stepCounter = 0;
64
+ videoStartTime = 0;
65
+ scenes;
66
+ activeScenes = [];
67
+ sceneTransitionMs;
68
+ variant;
69
+ /** Mobile multi-scene: every scene stays on stage, focus() only marks the active one. */
70
+ pinnedSplit = false;
71
+ constructor(page, options) {
72
+ this.page = page;
73
+ // Env read here, not at module load, so tests can stub it per-instance.
74
+ this.variant = options.variant ?? (process.env.TUTORIAL_VARIANT || undefined);
75
+ const baseName = options.testName ?? `tutorial_${Date.now()}`;
76
+ this.testName = this.variant && options.testName ? `${baseName}-${this.variant}` : baseName;
77
+ this.translateFn = options.translate ?? ((k) => k);
78
+ const audioBaseUrl = options.audioBaseUrl ?? DEFAULT_AUDIO_BASE_URL;
79
+ const defaultMusicUrl = TUTORIAL_MUSIC ?? '';
80
+ this.options = {
81
+ title: options.title,
82
+ testFile: options.testFile ?? '',
83
+ projectName: options.projectName ?? '',
84
+ testName: this.testName,
85
+ testTitle: options.testTitle,
86
+ feature: options.feature,
87
+ lang: options.lang ?? 'en',
88
+ audioBaseUrl,
89
+ stepDelay: options.stepDelay ?? 500,
90
+ highlightDuration: options.highlightDuration ?? 800,
91
+ mouseSteps: options.mouseSteps ?? 25,
92
+ pauseBeforeClick: options.pauseBeforeClick ?? 300,
93
+ enableVoice: options.enableVoice ?? TUTORIAL_VOICE,
94
+ voiceName: options.voiceName ?? TUTORIAL_VOICE_NAME ?? '',
95
+ voiceRate: options.voiceRate ?? 1.0,
96
+ voicePitch: options.voicePitch ?? 1,
97
+ backgroundMusic: options.backgroundMusic ?? defaultMusicUrl,
98
+ musicVolume: options.musicVolume ?? TUTORIAL_MUSIC_VOLUME,
99
+ voiceVolume: options.voiceVolume ?? TUTORIAL_VOICE_VOLUME,
100
+ customStyles: options.customStyles ?? '',
101
+ playAudioInBrowser: options.playAudioInBrowser ?? true,
102
+ overlayPosition: options.overlayPosition ?? 'TR'
103
+ };
104
+ this.voice = new TutorialVoice(page, {
105
+ lang: this.options.lang,
106
+ voiceName: this.options.voiceName,
107
+ voiceRate: this.options.voiceRate,
108
+ voicePitch: this.options.voicePitch,
109
+ playInBrowser: this.options.playAudioInBrowser,
110
+ audioBaseUrl
111
+ });
112
+ this.music = new TutorialMusic(page, {
113
+ backgroundMusic: this.options.backgroundMusic,
114
+ musicVolume: this.options.musicVolume
115
+ });
116
+ this.cursor = new TutorialCursor(page, {
117
+ mouseSteps: this.options.mouseSteps,
118
+ pauseBeforeClick: this.options.pauseBeforeClick
119
+ });
120
+ this.overlay = new TutorialOverlay(page, {
121
+ title: this.options.title,
122
+ lang: this.options.lang,
123
+ highlightDuration: this.options.highlightDuration,
124
+ position: this.options.overlayPosition ?? 'TR'
125
+ });
126
+ this.timeline = new TutorialTimeline(this.testName, this.options.testFile, this.options.projectName, this.options.lang, options.testTitle ?? '', options.feature ?? '', {
127
+ musicFile: this.options.backgroundMusic?.startsWith('http')
128
+ ? 'static/audio/tutorial-background.mp3'
129
+ : this.options.backgroundMusic,
130
+ musicVolume: this.options.musicVolume,
131
+ voiceVolume: this.options.voiceVolume
132
+ }, this.options.title, this.variant ?? '');
133
+ this.scenes = options.scenes ?? {};
134
+ this.sceneTransitionMs = options.sceneTransition?.duration ?? 600;
135
+ const sceneNames = Object.keys(this.scenes);
136
+ if (sceneNames.length > 0) {
137
+ this.activeScenes = asList(options.focus ?? sceneNames[0]);
138
+ this.activeScenes.forEach((name) => this.requireScene(name));
139
+ }
140
+ this.pinnedSplit = this.variant === 'mobile' && sceneNames.length > 1;
141
+ this.videoStartTime = Date.now();
142
+ }
143
+ // ── Scenes ──────────────────────────────────────────────────────────
144
+ // Scene plumbing runs in both modes: it is the stage, not the decoration.
145
+ requireScene(name) {
146
+ const scene = this.scenes[name];
147
+ if (!scene) {
148
+ const known = Object.keys(this.scenes).join(', ') || 'none declared';
149
+ throw new Error(`[Tutorial] Unknown scene "${name}" (known: ${known})`);
150
+ }
151
+ return scene;
152
+ }
153
+ /**
154
+ * Mount the stage: a tab bar plus one iframe per scene.
155
+ *
156
+ * The parent page is loaded from `audioBaseUrl` first, because setContent
157
+ * keeps the current origin — that is what lets narration audio load without
158
+ * cross-origin friction, with no stage file to deploy.
159
+ */
160
+ async stage() {
161
+ if (Object.keys(this.scenes).length === 0) {
162
+ throw new Error('[Tutorial] stage() requires `scenes` in the constructor options');
163
+ }
164
+ await this.page.goto(this.options.audioBaseUrl);
165
+ await this.page.setContent(renderStage(this.scenes, this.activeScenes, this.pinnedSplit));
166
+ await this.ensureStyles();
167
+ await this.initialize();
168
+ }
169
+ /** A scene is a plain FrameLocator — the whole Playwright locator API. */
170
+ scene(name) {
171
+ this.requireScene(name);
172
+ return this.page.frameLocator(`[data-tutorial-frame="${name}"]`);
173
+ }
174
+ /** Navigate a scene. Relative paths resolve against its baseUrl; an absolute
175
+ * URL is honoured as-is, so a scene may change origin mid-tutorial. */
176
+ async goto(name, url) {
177
+ const scene = this.requireScene(name);
178
+ const target = scene.baseUrl ? new URL(url, scene.baseUrl).toString() : url;
179
+ await this.page.evaluate(({ frame, href }) => {
180
+ const el = document.querySelector(`[data-tutorial-frame="${frame}"]`);
181
+ el.src = href;
182
+ }, { frame: name, href: target });
183
+ // Cross-origin: contentDocument is unreadable, so wait through the frame.
184
+ await this.scene(name).locator('body').waitFor({ state: 'attached' });
185
+ }
186
+ /**
187
+ * Bring scene(s) to the stage. One fills it; two share it side by side.
188
+ * The cursor is hidden across the switch so it never streaks between panes.
189
+ */
190
+ async focus(target, options) {
191
+ const names = asList(target);
192
+ names.forEach((name) => this.requireScene(name));
193
+ if (TUTORIAL_MODE && this.initialized)
194
+ await this.cursor.hide();
195
+ const ratios = options?.ratio ?? names.map(() => 1);
196
+ await this.page.evaluate(({ active, ratios, pinned }) => {
197
+ const stage = document.getElementById('tutorial-stage');
198
+ if (stage)
199
+ stage.setAttribute('data-split', String(pinned || active.length > 1));
200
+ document.querySelectorAll('[data-tutorial-tab]').forEach((el) => {
201
+ const name = el.getAttribute('data-tutorial-tab');
202
+ el.setAttribute('data-active', String(active.includes(name)));
203
+ });
204
+ document.querySelectorAll('[data-tutorial-scene]').forEach((el) => {
205
+ const name = el.getAttribute('data-tutorial-scene');
206
+ const idx = active.indexOf(name);
207
+ const isActive = idx !== -1;
208
+ el.setAttribute('data-active', String(isActive));
209
+ // Pinned split (mobile): every phone keeps its equal width — no inline flex.
210
+ if (!pinned)
211
+ el.style.flex = isActive ? `${ratios[idx]} 1 0` : '';
212
+ });
213
+ }, { active: names, ratios, pinned: this.pinnedSplit });
214
+ this.activeScenes = names;
215
+ await this.page.waitForTimeout(TUTORIAL_MODE ? this.sceneTransitionMs : 0);
216
+ if (TUTORIAL_MODE && this.initialized)
217
+ await this.cursor.ensureVisible();
218
+ }
219
+ /** What to stamp on a timeline entry: a bare name, or a pair when two
220
+ * scenes shared the stage. Undefined for single-scene tutorials. */
221
+ get stagedScene() {
222
+ if (this.activeScenes.length === 0)
223
+ return undefined;
224
+ return this.activeScenes.length === 1 ? this.activeScenes[0] : [...this.activeScenes];
225
+ }
226
+ sameFocus(target) {
227
+ const names = asList(target);
228
+ return (names.length === this.activeScenes.length &&
229
+ names.every((name, i) => name === this.activeScenes[i]));
230
+ }
231
+ static get isEnabled() {
232
+ return TUTORIAL_MODE;
233
+ }
234
+ translate(key) {
235
+ return this.translateFn(key);
236
+ }
237
+ clearFields() {
238
+ if (!TUTORIAL_MODE)
239
+ return;
240
+ const clearOnce = () => {
241
+ this.page.evaluate(() => {
242
+ document.querySelectorAll('input:not([type=hidden]):not([type=submit]):not([type=button]):not([type=checkbox]):not([type=radio]), textarea').forEach(el => { el.value = ''; });
243
+ }).catch(() => { });
244
+ this.page.off('load', clearOnce);
245
+ };
246
+ this.page.on('load', clearOnce);
247
+ }
248
+ get hasSteps() {
249
+ return this.stepCounter > 0;
250
+ }
251
+ get stepCount() {
252
+ return this.stepCounter;
253
+ }
254
+ switchPage(page) {
255
+ if (!TUTORIAL_MODE)
256
+ return;
257
+ this.page = page;
258
+ this.voice.switchPage(page);
259
+ this.music.switchPage(page);
260
+ this.cursor.switchPage(page);
261
+ this.overlay.switchPage(page);
262
+ }
263
+ /** @deprecated Total steps are now calculated automatically. */
264
+ setTotalSteps(total) {
265
+ console.warn('[Tutorial] setTotalSteps is deprecated - total steps are calculated automatically');
266
+ this.overlay.setTotalSteps(total);
267
+ }
268
+ /** @deprecated Voice preloading is now automatic when steps/contexts are added. */
269
+ async preloadVoice(texts) {
270
+ console.warn('[Tutorial] preloadVoice is deprecated - voice preloading is now automatic');
271
+ if (!TUTORIAL_MODE || !this.options.enableVoice)
272
+ return;
273
+ await this.voice.preload(texts);
274
+ }
275
+ async initialize() {
276
+ if (this.initialized || !TUTORIAL_MODE)
277
+ return;
278
+ await this.ensureStyles();
279
+ // Debug overlay: paints wall-clock epoch ms into the recording so any
280
+ // audio/video desync can be measured frame by frame on the final file.
281
+ if (process.env.TUTORIAL_DEBUG_CLOCK === 'true') {
282
+ await this.page.evaluate(() => {
283
+ if (document.getElementById('tutorial-debug-clock'))
284
+ return;
285
+ const el = document.createElement('div');
286
+ el.id = 'tutorial-debug-clock';
287
+ el.style.cssText =
288
+ 'position:fixed;bottom:4px;left:4px;z-index:2147483647;font:bold 24px monospace;background:#000;color:#0f0;padding:2px 8px;pointer-events:none';
289
+ document.body.appendChild(el);
290
+ const tick = () => {
291
+ el.textContent = String(Date.now() % 1000000);
292
+ requestAnimationFrame(tick);
293
+ };
294
+ tick();
295
+ }).catch(() => { });
296
+ }
297
+ await this.cursor.initialize();
298
+ if (this.options.backgroundMusic && this.options.playAudioInBrowser) {
299
+ await this.music.start();
300
+ }
301
+ this.initialized = true;
302
+ }
303
+ async ensureStyles() {
304
+ const styles = this.options.customStyles || DEFAULT_STYLES;
305
+ await this.page.evaluate(({ css, variant }) => {
306
+ // The attribute scopes variant CSS (e.g. the compact mobile overlay) and
307
+ // survives overlay re-injections, which only replace #tutorial-overlay.
308
+ if (variant)
309
+ document.documentElement.setAttribute('data-tutorial-variant', variant);
310
+ if (document.getElementById('tutorial-styles'))
311
+ return;
312
+ const style = document.createElement('style');
313
+ style.id = 'tutorial-styles';
314
+ style.textContent = css;
315
+ document.head.appendChild(style);
316
+ }, { css: styles, variant: this.variant ?? '' });
317
+ }
318
+ context(key, options) {
319
+ if (!TUTORIAL_MODE)
320
+ return;
321
+ const title = this.translate(key);
322
+ const style = options?.style ?? 'goal';
323
+ const text = options?.text;
324
+ const voiceText = options?.voiceText ?? (text ? `${title}. ${text}` : title);
325
+ const voicePreload = this.options.enableVoice
326
+ ? this.voice.preloadSingle(voiceText)
327
+ : Promise.resolve();
328
+ this.pendingItems.push({
329
+ type: 'context',
330
+ title,
331
+ text,
332
+ style,
333
+ voiceText,
334
+ voicePreload,
335
+ key
336
+ });
337
+ }
338
+ step(key, action, options) {
339
+ const title = this.translate(key);
340
+ if (!TUTORIAL_MODE) {
341
+ this.pendingItems.push({
342
+ type: 'step',
343
+ title,
344
+ action,
345
+ overlayText: title,
346
+ voiceText: title,
347
+ voicePreload: Promise.resolve(),
348
+ scene: options?.scene
349
+ });
350
+ return;
351
+ }
352
+ this.stepCounter++;
353
+ let voiceText;
354
+ let voiceDoText;
355
+ if (options?.voiceText) {
356
+ voiceText = options.voiceText;
357
+ // Same sentence-boundary rule as `tutorial-transcript apply`.
358
+ const boundary = options.voiceText.indexOf('. ');
359
+ if (boundary > 0)
360
+ voiceDoText = options.voiceText.slice(0, boundary);
361
+ }
362
+ else if (options?.do && options?.explain) {
363
+ voiceText = `${options.do}. ${options.explain}`;
364
+ voiceDoText = options.do;
365
+ }
366
+ else if (options?.do) {
367
+ voiceText = options.do;
368
+ }
369
+ else if (options?.description) {
370
+ voiceText = `${title}. ${options.description}`;
371
+ voiceDoText = title;
372
+ }
373
+ else {
374
+ voiceText = title;
375
+ }
376
+ const voicePreload = this.options.enableVoice && !options?.skipVoice
377
+ ? this.voice.preloadSingle(voiceText)
378
+ : Promise.resolve();
379
+ this.pendingItems.push({
380
+ type: 'step',
381
+ title,
382
+ action,
383
+ overlayText: options?.do ?? title,
384
+ overlayDescription: options?.explain ?? options?.description,
385
+ voiceText,
386
+ voiceDoText,
387
+ skipVoice: options?.skipVoice,
388
+ delay: options?.delay,
389
+ voicePreload,
390
+ key,
391
+ scene: options?.scene,
392
+ overlayPosition: options?.overlayPosition
393
+ });
394
+ }
395
+ async highlight(selector, duration) {
396
+ if (!TUTORIAL_MODE)
397
+ return;
398
+ const locator = typeof selector === 'string' ? this.page.locator(selector) : selector;
399
+ await locator.scrollIntoViewIfNeeded();
400
+ await this.cursor.moveToElement(locator);
401
+ await this.overlay.highlight(locator, duration);
402
+ }
403
+ async unhighlight(selector) {
404
+ if (!TUTORIAL_MODE)
405
+ return;
406
+ await this.overlay.unhighlight(selector);
407
+ }
408
+ async moveMouseToElement(locator) {
409
+ if (!TUTORIAL_MODE)
410
+ return;
411
+ await locator.scrollIntoViewIfNeeded();
412
+ await this.cursor.moveToElement(locator);
413
+ }
414
+ async moveMouse(targetX, targetY) {
415
+ if (!TUTORIAL_MODE)
416
+ return;
417
+ await this.cursor.moveTo(targetX, targetY);
418
+ }
419
+ async animateClick() {
420
+ if (!TUTORIAL_MODE)
421
+ return;
422
+ await this.cursor.animateClick();
423
+ }
424
+ async click(selector) {
425
+ const locator = typeof selector === 'string' ? this.page.locator(selector) : selector;
426
+ if (TUTORIAL_MODE) {
427
+ await locator.scrollIntoViewIfNeeded();
428
+ await this.highlight(locator);
429
+ await this.animateClick();
430
+ }
431
+ await locator.click();
432
+ if (TUTORIAL_MODE)
433
+ await this.unhighlight(locator);
434
+ }
435
+ async fill(selector, value) {
436
+ const locator = typeof selector === 'string' ? this.page.locator(selector) : selector;
437
+ if (TUTORIAL_MODE) {
438
+ await locator.scrollIntoViewIfNeeded();
439
+ await this.highlight(locator);
440
+ await this.animateClick();
441
+ }
442
+ await locator.fill(value);
443
+ if (TUTORIAL_MODE)
444
+ await this.unhighlight(locator);
445
+ }
446
+ async typeSlowly(selector, value, delay = 50) {
447
+ const locator = typeof selector === 'string' ? this.page.locator(selector) : selector;
448
+ if (TUTORIAL_MODE) {
449
+ await locator.scrollIntoViewIfNeeded();
450
+ await this.highlight(locator);
451
+ await this.animateClick();
452
+ await locator.click({ clickCount: 3 });
453
+ await locator.pressSequentially(value, { delay });
454
+ await this.unhighlight(locator);
455
+ }
456
+ else {
457
+ await locator.fill(value);
458
+ }
459
+ }
460
+ /**
461
+ * Type a secret into a field that stays blurred. The blur is applied before
462
+ * the first keystroke and is NOT removed afterwards — the finished value must
463
+ * never become readable, or the whole point is lost. Call `unblur()` if a
464
+ * later step really needs the field legible again.
465
+ */
466
+ async typeBlurred(selector, value, options = {}) {
467
+ const opts = typeof options === 'number' ? { delay: options } : options;
468
+ const { delay = 50, blur = 4, reveal = false } = opts;
469
+ const locator = typeof selector === 'string' ? this.page.locator(selector) : selector;
470
+ if (TUTORIAL_MODE) {
471
+ await locator.scrollIntoViewIfNeeded();
472
+ await this.highlight(locator);
473
+ await this.animateClick();
474
+ await locator.click({ clickCount: 3 });
475
+ await locator.evaluate((el, px) => {
476
+ el.style.filter = `blur(${px}px)`;
477
+ }, blur);
478
+ await locator.pressSequentially(value, { delay });
479
+ if (reveal)
480
+ await this.unblur(locator);
481
+ await this.unhighlight(locator);
482
+ }
483
+ else {
484
+ await locator.fill(value);
485
+ }
486
+ }
487
+ /** Remove the blur left by `typeBlurred`. */
488
+ async unblur(selector) {
489
+ if (!TUTORIAL_MODE)
490
+ return;
491
+ const locator = typeof selector === 'string' ? this.page.locator(selector) : selector;
492
+ await locator.evaluate((el) => {
493
+ el.style.filter = '';
494
+ });
495
+ }
496
+ async selectOption(selector, value) {
497
+ const locator = typeof selector === 'string' ? this.page.locator(selector) : selector;
498
+ if (TUTORIAL_MODE) {
499
+ await locator.scrollIntoViewIfNeeded();
500
+ await this.highlight(locator);
501
+ await this.animateClick();
502
+ }
503
+ await locator.selectOption(value);
504
+ if (TUTORIAL_MODE)
505
+ await this.unhighlight(locator);
506
+ }
507
+ async hideOverlay() {
508
+ if (!TUTORIAL_MODE)
509
+ return;
510
+ await this.overlay.hide();
511
+ await this.cursor.hide();
512
+ }
513
+ async showEmailPreview(options) {
514
+ if (!TUTORIAL_MODE)
515
+ return;
516
+ await this.overlay.showEmailPreview(options);
517
+ const duration = options.duration ?? 3000;
518
+ await this.page.evaluate((durationMs) => {
519
+ const iframe = document.querySelector('.tutorial-email-iframe');
520
+ const doc = iframe?.contentDocument;
521
+ const scroller = doc?.scrollingElement;
522
+ if (!iframe || !doc || !scroller)
523
+ return;
524
+ const run = () => {
525
+ const viewportH = iframe.clientHeight;
526
+ const docH = scroller.scrollHeight;
527
+ const maxScroll = Math.max(0, docH - viewportH);
528
+ if (maxScroll <= 0)
529
+ return;
530
+ const anchors = Array.from(doc.querySelectorAll('a[href]'));
531
+ const cta = anchors.find((a) => a.offsetHeight > 0 && a.offsetWidth > 0) ?? null;
532
+ let target = maxScroll;
533
+ if (cta) {
534
+ const rect = cta.getBoundingClientRect();
535
+ const ctaTopInDoc = scroller.scrollTop + rect.top;
536
+ target = Math.max(0, Math.min(maxScroll, ctaTopInDoc - viewportH / 3));
537
+ }
538
+ const startDelay = Math.min(700, durationMs * 0.15);
539
+ const scrollWindow = Math.max(durationMs - startDelay * 2, 500);
540
+ const start = performance.now() + startDelay;
541
+ const step = (now) => {
542
+ const t = Math.max(0, Math.min(1, (now - start) / scrollWindow));
543
+ const eased = 1 - Math.pow(1 - t, 2);
544
+ scroller.scrollTop = target * eased;
545
+ if (t < 1)
546
+ requestAnimationFrame(step);
547
+ };
548
+ requestAnimationFrame(step);
549
+ };
550
+ if (doc.readyState === 'complete')
551
+ run();
552
+ else
553
+ iframe.addEventListener('load', run, { once: true });
554
+ }, duration);
555
+ await this.page.waitForTimeout(duration);
556
+ }
557
+ async hideEmailPreview() {
558
+ if (!TUTORIAL_MODE)
559
+ return;
560
+ await this.overlay.hideEmailPreview();
561
+ }
562
+ async complete(message) {
563
+ if (!TUTORIAL_MODE) {
564
+ for (const item of this.pendingItems) {
565
+ if (item.type === 'step') {
566
+ // Even without narration, a hidden scene is not interactive.
567
+ if (item.scene && !this.sameFocus(item.scene))
568
+ await this.focus(item.scene);
569
+ await item.action();
570
+ }
571
+ }
572
+ return;
573
+ }
574
+ const defaultMessage = this.options.lang === 'fr' ? 'Tutoriel terminé!' : 'Tutorial complete!';
575
+ const completionMessage = message || defaultMessage;
576
+ const completionVoicePreload = this.options.enableVoice
577
+ ? this.voice.preloadSingle(completionMessage)
578
+ : Promise.resolve();
579
+ const preloads = this.pendingItems.map(item => item.voicePreload);
580
+ preloads.push(completionVoicePreload);
581
+ await Promise.all(preloads);
582
+ // Sync marker: recording starts at page creation and the recorder may
583
+ // buffer several seconds before the file even exists, so no wall-clock
584
+ // anchor (constructor time, file birthtime) reliably maps to the video's
585
+ // t=0. Instead, flash a full-screen black frame whose END is timeline
586
+ // zero: the reporter finds it on tape with ffmpeg blackdetect and sets
587
+ // the exact trim. The wall-clock trim below stays as the fallback.
588
+ const syncMarker = await this.page.evaluate(() => {
589
+ const el = document.createElement('div');
590
+ el.id = 'tutorial-sync-marker';
591
+ el.style.cssText = 'position:fixed;inset:0;background:#000;z-index:2147483647';
592
+ (document.body ?? document.documentElement).appendChild(el);
593
+ }).then(() => true).catch(() => false);
594
+ if (syncMarker) {
595
+ await this.page.waitForTimeout(500);
596
+ await this.page.evaluate(() => {
597
+ document.getElementById('tutorial-sync-marker')?.remove();
598
+ }).catch(() => { });
599
+ }
600
+ let videoTrimMs = Date.now() - this.videoStartTime;
601
+ try {
602
+ const video = this.page.video();
603
+ if (video) {
604
+ const birthMs = statSync(await video.path()).birthtimeMs;
605
+ if (birthMs > 0)
606
+ videoTrimMs = Date.now() - birthMs;
607
+ }
608
+ }
609
+ catch {
610
+ // No video or no birthtime on this platform — keep the constructor anchor.
611
+ }
612
+ this.timeline.start(videoTrimMs, syncMarker);
613
+ await this.initialize();
614
+ this.overlay.setTotalSteps(this.stepCounter);
615
+ let currentStep = 0;
616
+ for (const item of this.pendingItems) {
617
+ await this.ensureStyles();
618
+ if (item.type === 'context') {
619
+ await this.overlay.showContext(item.title, item.text, item.style);
620
+ if (this.options.enableVoice) {
621
+ // Wall clock comes from the clip's metadata duration, not from the
622
+ // browser "ended" event — an early/failed playback must not let the
623
+ // next step's narration start over this one in the mix.
624
+ const audioFilename = this.voice.getFilename(item.voiceText);
625
+ const voiceStartTime = Date.now();
626
+ const duration = await this.voice.startPlayback(item.voiceText);
627
+ this.timeline.addStep(0, 'Context', audioFilename, duration, voiceStartTime, item.voiceText, item.key, this.stagedScene);
628
+ const remaining = duration - (Date.now() - voiceStartTime);
629
+ if (remaining > 0)
630
+ await this.page.waitForTimeout(remaining);
631
+ }
632
+ else {
633
+ // Unvoiced (empty audioFile, zero duration): recorded for the site's
634
+ // step guide, ignored by the merge/transcript/prerender pipeline.
635
+ this.timeline.addStep(0, 'Context', '', 0, Date.now(), item.voiceText, item.key, this.stagedScene);
636
+ }
637
+ await this.page.waitForTimeout(this.options.stepDelay);
638
+ }
639
+ else if (item.type === 'step') {
640
+ currentStep++;
641
+ this.overlay.setCurrentStep(currentStep);
642
+ // Switch before narrating: the viewer should already be looking at
643
+ // the right tab when the sentence about it starts.
644
+ if (item.scene && !this.sameFocus(item.scene))
645
+ await this.focus(item.scene);
646
+ await this.cursor.ensureVisible();
647
+ if (this.options.backgroundMusic && this.options.playAudioInBrowser) {
648
+ await this.music.ensurePlaying();
649
+ }
650
+ await this.overlay.showStep(item.overlayText, item.overlayDescription, item.overlayPosition);
651
+ if (this.options.enableVoice && !item.skipVoice) {
652
+ // Narration/action overlap (option B): start the merged clip,
653
+ // launch the action at the estimated end of its "do" half, then
654
+ // clamp wall clock to the clip duration so the next clip never
655
+ // overlaps in the ffmpeg mix.
656
+ const audioFilename = this.voice.getFilename(item.voiceText);
657
+ const voiceStartTime = Date.now();
658
+ const duration = await this.voice.startPlayback(item.voiceText);
659
+ this.timeline.addStep(currentStep, item.title, audioFilename, duration, voiceStartTime, item.voiceText, item.key, this.stagedScene);
660
+ const offset = narrationActionOffset(duration, item.voiceText, item.voiceDoText);
661
+ if (offset > 0)
662
+ await this.page.waitForTimeout(offset);
663
+ await item.action();
664
+ const remaining = duration - (Date.now() - voiceStartTime);
665
+ if (remaining > 0)
666
+ await this.page.waitForTimeout(remaining);
667
+ }
668
+ else {
669
+ this.timeline.addStep(currentStep, item.title, '', 0, Date.now(), item.voiceText, item.key, this.stagedScene);
670
+ await this.page.waitForTimeout(this.options.stepDelay);
671
+ await item.action();
672
+ }
673
+ await this.page.waitForTimeout(item.delay ?? 300);
674
+ await this.captureStepScreenshot(currentStep);
675
+ }
676
+ }
677
+ await this.overlay.showComplete(completionMessage);
678
+ if (this.options.enableVoice) {
679
+ const audioFilename = this.voice.getFilename(completionMessage);
680
+ const voiceStartTime = Date.now();
681
+ const duration = await this.voice.startPlayback(completionMessage);
682
+ this.timeline.addStep(this.stepCounter + 1, 'Complete', audioFilename, duration, voiceStartTime, completionMessage, undefined, this.stagedScene);
683
+ const remaining = duration - (Date.now() - voiceStartTime);
684
+ if (remaining > 0)
685
+ await this.page.waitForTimeout(remaining);
686
+ }
687
+ else {
688
+ this.timeline.addStep(this.stepCounter + 1, 'Complete', '', 0, Date.now(), completionMessage, undefined, this.stagedScene);
689
+ }
690
+ if (this.music.isInitialized) {
691
+ await this.music.stop(true);
692
+ }
693
+ await this.page.waitForTimeout(2000);
694
+ const video = this.page.video();
695
+ if (video) {
696
+ const videoPath = await video.path();
697
+ this.timeline.setVideoPath(videoPath);
698
+ }
699
+ const outputDir = join(process.cwd(), TUTORIAL_OUTPUT_DIR);
700
+ if (!existsSync(outputDir)) {
701
+ mkdirSync(outputDir, { recursive: true });
702
+ }
703
+ this.timeline.save(join(outputDir, `${this.testName}_timeline.json`));
704
+ await this.hideOverlay();
705
+ }
706
+ getTimeline() {
707
+ return this.timeline.getData();
708
+ }
709
+ async captureStepScreenshot(stepNumber) {
710
+ try {
711
+ const screenshotDir = join(process.cwd(), 'tutorials/videos');
712
+ if (!existsSync(screenshotDir)) {
713
+ mkdirSync(screenshotDir, { recursive: true });
714
+ }
715
+ const sharp = await getSharp();
716
+ if (sharp) {
717
+ const screenshotPath = join(screenshotDir, `${this.testName}-step-${stepNumber}.webp`);
718
+ const raw = await this.page.screenshot({ type: 'png', fullPage: false });
719
+ const buffer = await sharp(raw).resize({ width: 500, withoutEnlargement: true }).webp({ quality: 80 }).toBuffer();
720
+ writeFileSync(screenshotPath, buffer);
721
+ if (stepNumber === 1) {
722
+ const posterPath = join(screenshotDir, `${this.testName}-poster.webp`);
723
+ const posterBuffer = await sharp(raw).resize({ width: 800, withoutEnlargement: true }).webp({ quality: 85 }).toBuffer();
724
+ writeFileSync(posterPath, posterBuffer);
725
+ }
726
+ console.log(`[Tutorial] Screenshot: ${this.testName}-step-${stepNumber}.webp`);
727
+ }
728
+ else {
729
+ const screenshotPath = join(screenshotDir, `${this.testName}-step-${stepNumber}.png`);
730
+ const raw = await this.page.screenshot({ type: 'png', fullPage: false });
731
+ writeFileSync(screenshotPath, raw);
732
+ if (stepNumber === 1) {
733
+ const posterPath = join(screenshotDir, `${this.testName}-poster.png`);
734
+ writeFileSync(posterPath, raw);
735
+ }
736
+ console.log(`[Tutorial] Screenshot (PNG, install sharp for WebP): ${this.testName}-step-${stepNumber}.png`);
737
+ }
738
+ }
739
+ catch (err) {
740
+ console.warn(`[Tutorial] Screenshot failed for step ${stepNumber}: ${err.message}`);
741
+ }
742
+ }
743
+ async deleteVideoIfEmpty() {
744
+ if (!TUTORIAL_MODE)
745
+ return false;
746
+ if (!this.hasSteps) {
747
+ const video = this.page.video();
748
+ if (video) {
749
+ try {
750
+ const videoPath = await video.path();
751
+ if (videoPath && existsSync(videoPath)) {
752
+ unlinkSync(videoPath);
753
+ console.log(`[Tutorial] Deleted empty video: ${videoPath}`);
754
+ return true;
755
+ }
756
+ }
757
+ catch {
758
+ // Video might not be saved yet
759
+ }
760
+ }
761
+ console.log('[Tutorial] No steps recorded, video will be deleted');
762
+ return true;
763
+ }
764
+ return false;
765
+ }
766
+ }
767
+ //# sourceMappingURL=Tutorial.js.map