@reshotdev/screenshot 0.0.1-beta.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 (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,343 @@
1
+ // record-clip.js - Clip recording flow with subtitle editor
2
+ const chalk = require('chalk');
3
+ const inquirer = require('inquirer');
4
+ const path = require('path');
5
+ const fs = require('fs-extra');
6
+ const express = require('express');
7
+ const { chromium } = require('playwright');
8
+ const { updateBrowserMode } = require('./record-browser-injection');
9
+ const { runPolishedClip } = require('./polished-clip');
10
+ const { saveScenarioProgress } = require('./record-config');
11
+
12
+ /**
13
+ * Start clip recording flow
14
+ * @param {Object} sessionState - Recording session state
15
+ * @param {Page} page - Playwright page object
16
+ */
17
+ async function startClipRecording(sessionState, page) {
18
+ // Ask about container element
19
+ const { useContainer } = await inquirer.prompt([
20
+ {
21
+ type: 'confirm',
22
+ name: 'useContainer',
23
+ message: 'Do you want to record within a specific container element?',
24
+ default: true
25
+ }
26
+ ]);
27
+
28
+ let selector = null;
29
+
30
+ if (useContainer) {
31
+ // If the user has already interacted with the page, offer to reuse the
32
+ // last captured selector as the clip container to avoid forcing them to
33
+ // "record" the same area twice.
34
+ const lastStepWithSelector = Array.isArray(sessionState.capturedSteps)
35
+ ? [...sessionState.capturedSteps].reverse().find((step) => step && step.selector)
36
+ : null;
37
+
38
+ if (lastStepWithSelector && lastStepWithSelector.selector) {
39
+ const { reuseLastSelector } = await inquirer.prompt([
40
+ {
41
+ type: 'confirm',
42
+ name: 'reuseLastSelector',
43
+ message: `Use the last captured element as the container for this clip? (${lastStepWithSelector.selector})`,
44
+ default: true
45
+ }
46
+ ]);
47
+
48
+ if (reuseLastSelector) {
49
+ selector = lastStepWithSelector.selector;
50
+ }
51
+ }
52
+
53
+ // If we still don't have a selector (no prior steps or user declined),
54
+ // fall back to interactive selection in the browser.
55
+ if (!selector) {
56
+ sessionState.mode = 'select-element-for-clip';
57
+ sessionState.pendingCapture = { selector: null };
58
+
59
+ await updateBrowserMode(page, 'select-element-for-clip');
60
+
61
+ console.log(
62
+ chalk.cyan(
63
+ '\n Click once on the container element you want this clip anchored to, then return to the terminal.\n'
64
+ )
65
+ );
66
+
67
+ // Wait for element to be selected
68
+ await new Promise((resolve) => {
69
+ sessionState.onElementSelected = resolve;
70
+ });
71
+
72
+ selector = sessionState.pendingCapture.selector;
73
+ await updateBrowserMode(page, 'normal');
74
+ }
75
+ }
76
+
77
+ // Get output filename
78
+ const { outputFilename } = await inquirer.prompt([
79
+ {
80
+ type: 'input',
81
+ name: 'outputFilename',
82
+ message: 'Output clip filename:',
83
+ default: `${sessionState.visualKey}-clip.mp4`
84
+ }
85
+ ]);
86
+
87
+ // Initialize recording state
88
+ if (sessionState.phase !== 'capturing') {
89
+ sessionState.phase = 'capturing';
90
+ }
91
+ sessionState.mode = 'recording-clip';
92
+ sessionState.clipEvents = [];
93
+ sessionState.recordingStart = Date.now();
94
+ sessionState.clipSelector = selector;
95
+ sessionState.clipFilename = outputFilename;
96
+
97
+ await updateBrowserMode(page, 'recording-clip');
98
+
99
+ // Explicitly resume stdin and set to raw mode immediately so 'C' hotkey is caught.
100
+ if (process.stdin && process.stdin.isTTY) {
101
+ process.stdin.resume();
102
+ try {
103
+ process.stdin.setRawMode(true);
104
+ } catch (error) {
105
+ console.warn(chalk.yellow('⚠ Unable to re-enable terminal raw mode for clip controls'));
106
+ }
107
+ }
108
+
109
+ console.log(chalk.green(`\n 🎥 Recording started... Perform your actions. Press 'C' again in terminal to stop. (Hotkeys active)\n`));
110
+
111
+ // Start video recording in a new context
112
+ const tempDir = path.join(process.cwd(), '.reshot', 'tmp');
113
+ fs.ensureDirSync(tempDir);
114
+
115
+ const browser = page.context().browser();
116
+ const recordingContext = await browser.newContext({
117
+ viewport: { width: 1280, height: 720 },
118
+ recordVideo: { dir: tempDir, size: { width: 1280, height: 720 } }
119
+ });
120
+
121
+ const recordingPage = await recordingContext.newPage();
122
+ await recordingPage.goto(page.url());
123
+
124
+ // Store recording context in session state
125
+ sessionState.recordingContext = recordingContext;
126
+ sessionState.recordingPage = recordingPage;
127
+
128
+ // Set up action replay handler to sync video with real user actions
129
+ sessionState.replayActionToRecording = async (action, selector, text) => {
130
+ try {
131
+ if (action === 'click') {
132
+ await recordingPage.click(selector);
133
+ } else if (action === 'type') {
134
+ await recordingPage.fill(selector, text);
135
+ }
136
+ } catch (error) {
137
+ // Silently fail replay - video may not match exactly but timeline will be correct
138
+ console.warn(chalk.yellow(` ⚠ Could not replay ${action} to recording context: ${error.message}`));
139
+ }
140
+ };
141
+
142
+ // Wait for user to press 'C' again to stop
143
+ // (handled by terminal hotkey listener which sets sessionState.stopClipRecording)
144
+ await new Promise((resolve) => {
145
+ const checkStop = setInterval(() => {
146
+ if (sessionState.stopClipRecording) {
147
+ clearInterval(checkStop);
148
+ resolve();
149
+ }
150
+ }, 100);
151
+ });
152
+
153
+ // Stop recording
154
+ await recordingContext.close();
155
+ await updateBrowserMode(page, 'normal');
156
+
157
+ console.log(chalk.green(`\n ✔ Recording stopped. Processing clip...\n`));
158
+
159
+ // Wait for video file
160
+ await new Promise(resolve => setTimeout(resolve, 1000));
161
+
162
+ const videoFiles = fs.readdirSync(tempDir).filter(f => f.endsWith('.webm'));
163
+ if (videoFiles.length === 0) {
164
+ throw new Error('No video file was created');
165
+ }
166
+ const rawVideoPath = path.join(tempDir, videoFiles[videoFiles.length - 1]);
167
+
168
+ // Add element bounding boxes to clip events
169
+ for (const event of sessionState.clipEvents) {
170
+ try {
171
+ const element = await page.locator(event.selector).first();
172
+ const boundingBox = await element.boundingBox();
173
+ if (boundingBox) {
174
+ event.elementBox = boundingBox;
175
+ }
176
+ } catch (error) {
177
+ console.warn(chalk.yellow(` ⚠ Could not get bounding box for ${event.selector}`));
178
+ }
179
+ }
180
+
181
+ // Open subtitle editor
182
+ const finalEvents = await runSubtitleEditor(sessionState.clipEvents, rawVideoPath, page);
183
+
184
+ console.log(chalk.green(`\n ✔ Subtitles edited. Creating polished clip...\n`));
185
+
186
+ // Enhance events with enhancements flag
187
+ const { enableEnhancements } = await inquirer.prompt([
188
+ {
189
+ type: 'confirm',
190
+ name: 'enableEnhancements',
191
+ message: 'Enable click highlights and subtitles in the final clip?',
192
+ default: true
193
+ }
194
+ ]);
195
+
196
+ // Get device scale factor from page
197
+ let deviceScaleFactor = null;
198
+ try {
199
+ deviceScaleFactor = await page.evaluate(() => window.devicePixelRatio || 1);
200
+ } catch (error) {
201
+ // Fallback to 1 if evaluation fails
202
+ deviceScaleFactor = 1;
203
+ }
204
+
205
+ // Generate stable step ID
206
+ const stepId = `${sessionState.visualKey}-clip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
207
+
208
+ // Create clip step for config with full metadata
209
+ const clipStep = {
210
+ action: 'clip',
211
+ key: buildClipKey(sessionState.visualKey, outputFilename),
212
+ selector: selector || 'body',
213
+ path: outputFilename,
214
+ id: stepId,
215
+ deviceScaleFactor: deviceScaleFactor !== 1 ? deviceScaleFactor : undefined,
216
+ selectorPadding: selector ? {
217
+ top: 10,
218
+ right: 10,
219
+ bottom: 10,
220
+ left: 10,
221
+ } : undefined,
222
+ enhancements: {
223
+ clickHighlight: enableEnhancements,
224
+ subtitles: enableEnhancements
225
+ },
226
+ steps: finalEvents.map(e => ({
227
+ action: e.action,
228
+ selector: e.selector,
229
+ text: e.text,
230
+ subtitle: e.subtitle
231
+ }))
232
+ };
233
+
234
+ sessionState.capturedSteps.push(clipStep);
235
+ await saveScenarioProgress(sessionState, page, { finalize: false });
236
+
237
+ // Clean up temp video
238
+ fs.removeSync(rawVideoPath);
239
+
240
+ // Reset recording state
241
+ sessionState.mode = 'normal';
242
+ sessionState.clipEvents = null;
243
+ sessionState.recordingStart = null;
244
+ sessionState.stopClipRecording = false;
245
+
246
+ console.log(
247
+ chalk.green(
248
+ ` ✔ Clip step added: ${sessionState.visualKey}/${clipStep.key} → ${clipStep.path}\n`
249
+ )
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Run subtitle editor mini-server
255
+ * @param {Array} events - Clip events with timestamps
256
+ * @param {string} videoPath - Path to recorded video
257
+ * @param {Page} page - Playwright page object
258
+ * @returns {Promise<Array>} Edited events
259
+ */
260
+ async function runSubtitleEditor(events, videoPath, page) {
261
+ return new Promise((resolve, reject) => {
262
+ const app = express();
263
+ app.use(express.json());
264
+
265
+ let editedEvents = null;
266
+ let editorPage = null;
267
+
268
+ // Serve subtitle editor HTML
269
+ app.get('/', (req, res) => {
270
+ const htmlPath = path.join(__dirname, '../../web/subtitle-editor/index.html');
271
+ res.sendFile(htmlPath);
272
+ });
273
+
274
+ // Serve video
275
+ app.get('/video', (req, res) => {
276
+ res.sendFile(videoPath);
277
+ });
278
+
279
+ // Get timeline
280
+ app.get('/timeline', (req, res) => {
281
+ res.json(events);
282
+ });
283
+
284
+ // Save edited timeline
285
+ app.post('/timeline', (req, res) => {
286
+ editedEvents = req.body;
287
+ res.json({ ok: true });
288
+
289
+ // Close editor and resolve
290
+ setTimeout(async () => {
291
+ if (editorPage) {
292
+ await editorPage.close();
293
+ }
294
+ server.close();
295
+ resolve(editedEvents);
296
+ }, 500);
297
+ });
298
+
299
+ // Start server
300
+ const server = app.listen(0, async () => {
301
+ const port = server.address().port;
302
+ const url = `http://localhost:${port}`;
303
+
304
+ console.log(chalk.cyan(`\n Opening subtitle editor at ${url}...\n`));
305
+
306
+ try {
307
+ const context = page.context();
308
+ editorPage = await context.newPage();
309
+ await editorPage.goto(url);
310
+ } catch (error) {
311
+ server.close();
312
+ reject(error);
313
+ }
314
+ });
315
+
316
+ // Timeout after 10 minutes
317
+ setTimeout(() => {
318
+ if (!editedEvents) {
319
+ server.close();
320
+ if (editorPage) {
321
+ editorPage.close();
322
+ }
323
+ reject(new Error('Subtitle editor timeout'));
324
+ }
325
+ }, 10 * 60 * 1000);
326
+ });
327
+ }
328
+
329
+ function buildClipKey(visualKey, filename) {
330
+ const base = filename
331
+ .replace(path.extname(filename), '')
332
+ .replace(/[^a-zA-Z0-9-]/g, '-')
333
+ .toLowerCase()
334
+ .replace(/-+/g, '-')
335
+ .replace(/^-|-$/g, '');
336
+ const sanitized = base || `clip-${Date.now()}`;
337
+ return `${visualKey}-${sanitized}`.replace(/-+/g, '-');
338
+ }
339
+
340
+ module.exports = {
341
+ startClipRecording
342
+ };
343
+