@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.
- package/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- 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
|
+
|