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.
- package/README.md +860 -0
- package/dist/Tutorial.d.ts +108 -0
- package/dist/Tutorial.d.ts.map +1 -0
- package/dist/Tutorial.js +767 -0
- package/dist/Tutorial.js.map +1 -0
- package/dist/bin/export-transcript.d.ts +3 -0
- package/dist/bin/export-transcript.d.ts.map +1 -0
- package/dist/bin/export-transcript.js +111 -0
- package/dist/bin/export-transcript.js.map +1 -0
- package/dist/cursor.d.ts +24 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +96 -0
- package/dist/cursor.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +3 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +183 -0
- package/dist/init.js.map +1 -0
- package/dist/merge.d.ts +26 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +65 -0
- package/dist/merge.js.map +1 -0
- package/dist/music.d.ts +29 -0
- package/dist/music.d.ts.map +1 -0
- package/dist/music.js +107 -0
- package/dist/music.js.map +1 -0
- package/dist/overlay-html.d.ts +44 -0
- package/dist/overlay-html.d.ts.map +1 -0
- package/dist/overlay-html.js +150 -0
- package/dist/overlay-html.js.map +1 -0
- package/dist/overlay.d.ts +39 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +134 -0
- package/dist/overlay.js.map +1 -0
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +38 -0
- package/dist/postinstall.js.map +1 -0
- package/dist/reporter.d.ts +22 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +166 -0
- package/dist/reporter.js.map +1 -0
- package/dist/site/build-site.d.ts +2 -0
- package/dist/site/build-site.d.ts.map +1 -0
- package/dist/site/build-site.js +82 -0
- package/dist/site/build-site.js.map +1 -0
- package/dist/site/embed.d.ts +48 -0
- package/dist/site/embed.d.ts.map +1 -0
- package/dist/site/embed.js +45 -0
- package/dist/site/embed.js.map +1 -0
- package/dist/site/generate-manifest.d.ts +15 -0
- package/dist/site/generate-manifest.d.ts.map +1 -0
- package/dist/site/generate-manifest.js +106 -0
- package/dist/site/generate-manifest.js.map +1 -0
- package/dist/site/scaffold.d.ts +3 -0
- package/dist/site/scaffold.d.ts.map +1 -0
- package/dist/site/scaffold.js +70 -0
- package/dist/site/scaffold.js.map +1 -0
- package/dist/site/scan-tutorials.d.ts +12 -0
- package/dist/site/scan-tutorials.d.ts.map +1 -0
- package/dist/site/scan-tutorials.js +54 -0
- package/dist/site/scan-tutorials.js.map +1 -0
- package/dist/site/types.d.ts +75 -0
- package/dist/site/types.d.ts.map +1 -0
- package/dist/site/types.js +2 -0
- package/dist/site/types.js.map +1 -0
- package/dist/skill-stamp.d.ts +21 -0
- package/dist/skill-stamp.d.ts.map +1 -0
- package/dist/skill-stamp.js +39 -0
- package/dist/skill-stamp.js.map +1 -0
- package/dist/slugify.d.ts +19 -0
- package/dist/slugify.d.ts.map +1 -0
- package/dist/slugify.js +29 -0
- package/dist/slugify.js.map +1 -0
- package/dist/stage-presets.d.ts +51 -0
- package/dist/stage-presets.d.ts.map +1 -0
- package/dist/stage-presets.js +45 -0
- package/dist/stage-presets.js.map +1 -0
- package/dist/styles.css +637 -0
- package/dist/timeline.d.ts +87 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +108 -0
- package/dist/timeline.js.map +1 -0
- package/dist/transcript.d.ts +87 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +291 -0
- package/dist/transcript.js.map +1 -0
- package/dist/tts-provider.d.ts +69 -0
- package/dist/tts-provider.d.ts.map +1 -0
- package/dist/tts-provider.js +182 -0
- package/dist/tts-provider.js.map +1 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/voice-prerender.d.ts +41 -0
- package/dist/voice-prerender.d.ts.map +1 -0
- package/dist/voice-prerender.js +122 -0
- package/dist/voice-prerender.js.map +1 -0
- package/dist/voice.d.ts +76 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +244 -0
- package/dist/voice.js.map +1 -0
- package/package.json +87 -0
- package/skills/tutorialize/SKILL.md +54 -0
- package/skills/tutorialize/agent.md +58 -0
- package/skills/tutorialize/references/api.md +489 -0
- package/skills/tutorialize/references/storytelling.md +193 -0
- package/src/styles.css +637 -0
- package/templates/site/public/.gitkeep +0 -0
- package/templates/site/public/widget.js +273 -0
- package/templates/site/src/components/TutorialsHome.astro +141 -0
- package/templates/site/src/components/VideoCard.astro +192 -0
- package/templates/site/src/components/VideoModal.astro +170 -0
- package/templates/site/src/data/.gitkeep +0 -0
- package/templates/site/src/layouts/Base.astro +56 -0
- package/templates/site/src/pages/[slug].astro +745 -0
- package/templates/site/src/pages/index.astro +5 -0
- package/templates/site/src/styles/tutorials.css +303 -0
package/dist/Tutorial.js
ADDED
|
@@ -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
|