@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,553 @@
1
+ // polished-clip.js - Three-stage HTML overlay pipeline for polished video clips
2
+ // Based on test/412/polished_clip_runner.js
3
+ const { chromium } = require("playwright");
4
+ const fs = require("fs-extra");
5
+ const path = require("path");
6
+ const { spawn } = require("child_process");
7
+ const { buildLaunchOptions } = require("./ci-detect");
8
+ const { resolveSecretsInString } = require("./secrets");
9
+
10
+ // Debug mode - set RESHOT_DEBUG=1 or RESHOT_DEBUG=video to enable verbose logging
11
+ const DEBUG =
12
+ process.env.RESHOT_DEBUG === "1" || process.env.RESHOT_DEBUG === "video";
13
+
14
+ function debug(...args) {
15
+ if (DEBUG) {
16
+ console.log(" [DEBUG]", ...args);
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Check if ffmpeg is installed
22
+ */
23
+ function checkFFmpeg() {
24
+ try {
25
+ const ffmpegProcess = spawn("ffmpeg", ["-version"], { stdio: "ignore" });
26
+ return new Promise((resolve) => {
27
+ ffmpegProcess.on("close", (code) => {
28
+ resolve(code === 0);
29
+ });
30
+ });
31
+ } catch (e) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Run ffmpeg command
38
+ */
39
+ function runFFmpeg(args, description) {
40
+ return new Promise((resolve, reject) => {
41
+ console.log(` ${description}`);
42
+ const ffmpegProcess = spawn("ffmpeg", args, {
43
+ stdio: ["ignore", "pipe", "pipe"],
44
+ });
45
+
46
+ let stderr = "";
47
+ ffmpegProcess.stderr.on("data", (data) => {
48
+ const output = data.toString();
49
+ stderr += output;
50
+ if (output.includes("time=")) {
51
+ const match = output.match(/time=([^\s]+)/);
52
+ if (match) {
53
+ process.stdout.write(`\r Progress: ${match[1]}`);
54
+ }
55
+ }
56
+ });
57
+
58
+ let resolved = false;
59
+ const timeout = setTimeout(() => {
60
+ if (!resolved) {
61
+ resolved = true;
62
+ ffmpegProcess.kill();
63
+ reject(new Error(`FFmpeg timeout: ${description}`));
64
+ }
65
+ }, 2 * 60 * 1000); // 2 minute timeout
66
+
67
+ ffmpegProcess.on("close", (code) => {
68
+ if (resolved) return;
69
+ resolved = true;
70
+ clearTimeout(timeout);
71
+ if (code === 0) {
72
+ console.log("\n ✔ Complete");
73
+ resolve();
74
+ } else {
75
+ console.error("\n ❌ FFmpeg failed with code:", code);
76
+ console.error(" Last 500 chars:", stderr.slice(-500));
77
+ reject(new Error(`FFmpeg failed: ${description}`));
78
+ }
79
+ });
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Generate overlay HTML for animations
85
+ */
86
+ function generateOverlayHtml(events, mainBoundingBox, enhancements) {
87
+ const width = Math.round(mainBoundingBox.width);
88
+ const height = Math.round(mainBoundingBox.height);
89
+
90
+ return `<!DOCTYPE html>
91
+ <html>
92
+ <head>
93
+ <meta charset="UTF-8">
94
+ <style>
95
+ * {
96
+ margin: 0;
97
+ padding: 0;
98
+ box-sizing: border-box;
99
+ }
100
+ body {
101
+ width: ${width}px;
102
+ height: ${height}px;
103
+ background: #00ff00; /* Green screen for chroma key */
104
+ overflow: hidden;
105
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
106
+ }
107
+ .click-highlight {
108
+ position: absolute;
109
+ background: rgba(255, 255, 0, 0.5);
110
+ border: 2px solid rgba(255, 255, 0, 0.8);
111
+ border-radius: 4px;
112
+ pointer-events: none;
113
+ opacity: 0;
114
+ transition: opacity 0.1s ease-in-out;
115
+ }
116
+ .click-highlight.visible {
117
+ opacity: 1;
118
+ }
119
+ .subtitle-container {
120
+ position: absolute;
121
+ bottom: 0;
122
+ left: 0;
123
+ right: 0;
124
+ height: 60px;
125
+ background: rgba(0, 0, 0, 0.6);
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ opacity: 0;
130
+ transition: opacity 0.3s ease-in-out;
131
+ }
132
+ .subtitle-container.visible {
133
+ opacity: 1;
134
+ }
135
+ .subtitle-text {
136
+ color: white;
137
+ font-size: 24px;
138
+ text-align: center;
139
+ padding: 0 20px;
140
+ }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ ${
145
+ enhancements.clickHighlight
146
+ ? events
147
+ .filter((e) => e.action === "click")
148
+ .map((event, i) => {
149
+ return ` <div class="click-highlight" id="highlight-${i}"
150
+ style="left: ${event.elementBox.x}px; top: ${event.elementBox.y}px;
151
+ width: ${event.elementBox.width}px; height: ${event.elementBox.height}px;"></div>
152
+ `;
153
+ })
154
+ .join("")
155
+ : ""
156
+ }
157
+
158
+ ${
159
+ enhancements.subtitles
160
+ ? `
161
+ <div class="subtitle-container" id="subtitle-container">
162
+ <div class="subtitle-text" id="subtitle-text"></div>
163
+ </div>
164
+ `
165
+ : ""
166
+ }
167
+
168
+ <script>
169
+ const events = ${JSON.stringify(events)};
170
+ const enhancements = ${JSON.stringify(enhancements)};
171
+
172
+ // Schedule click highlights
173
+ ${
174
+ enhancements.clickHighlight
175
+ ? `
176
+ events.filter(e => e.action === 'click').forEach((event, idx) => {
177
+ const highlight = document.getElementById('highlight-' + idx);
178
+ if (!highlight) return;
179
+
180
+ setTimeout(() => {
181
+ highlight.classList.add('visible');
182
+ setTimeout(() => {
183
+ highlight.classList.remove('visible');
184
+ }, 500);
185
+ }, event.timestamp * 1000);
186
+ });
187
+ `
188
+ : ""
189
+ }
190
+
191
+ // Schedule subtitles
192
+ ${
193
+ enhancements.subtitles
194
+ ? `
195
+ const subtitleContainer = document.getElementById('subtitle-container');
196
+ const subtitleText = document.getElementById('subtitle-text');
197
+
198
+ if (subtitleContainer && subtitleText) {
199
+ events.forEach((event, idx) => {
200
+ const startTime = event.timestamp * 1000;
201
+ const nextEvent = events[idx + 1];
202
+ const endTime = nextEvent ? nextEvent.timestamp * 1000 : (event.timestamp + 2) * 1000;
203
+ const duration = endTime - startTime;
204
+
205
+ setTimeout(() => {
206
+ subtitleText.textContent = event.subtitle;
207
+ subtitleContainer.classList.add('visible');
208
+
209
+ setTimeout(() => {
210
+ subtitleContainer.classList.remove('visible');
211
+ }, duration);
212
+ }, startTime);
213
+ });
214
+ }
215
+ `
216
+ : ""
217
+ }
218
+ </script>
219
+ </body>
220
+ </html>`;
221
+ }
222
+
223
+ /**
224
+ * Run polished clip pipeline
225
+ * @param {Object} options - Run options
226
+ * @param {string} options.url - URL to navigate to
227
+ * @param {Object} options.clipStep - Clip step configuration
228
+ * @param {string} options.outputDir - Directory to save output file
229
+ */
230
+ async function runPolishedClip({ url, clipStep, outputDir }) {
231
+ debug("Starting polished clip recording");
232
+ debug(`URL: ${url}`);
233
+ debug(`Output directory: ${outputDir}`);
234
+ debug(`Clip selector: ${clipStep.selector}`);
235
+
236
+ // Check ffmpeg
237
+ const hasFFmpeg = await checkFFmpeg();
238
+ if (!hasFFmpeg) {
239
+ throw new Error(
240
+ "ffmpeg is not installed. Please install it to create video clips."
241
+ );
242
+ }
243
+ debug("ffmpeg check passed");
244
+
245
+ const tempDir = path.join(process.cwd(), ".reshot", "tmp");
246
+ debug(`Temp directory: ${tempDir}`);
247
+ fs.ensureDirSync(tempDir);
248
+
249
+ const finalVideoPath = path.join(outputDir, clipStep.path);
250
+ const rawVideoPath = path.join(tempDir, "temp_raw_video.webm");
251
+ const timelinePath = path.join(tempDir, "timeline.json");
252
+ const overlayHtmlPath = path.join(tempDir, "overlay.html");
253
+ const croppedVideoPath = path.join(tempDir, "cropped_video.mp4");
254
+ debug(`Final video path: ${finalVideoPath}`);
255
+
256
+ // ============================================
257
+ // STAGE 1: CAPTURE PHASE
258
+ // ============================================
259
+ console.log(" === Stage 1: Capturing video and timeline ===");
260
+
261
+ debug("Launching browser...");
262
+ const browser = await chromium.launch(buildLaunchOptions({ headless: true }));
263
+ debug("Creating context with video recording...");
264
+ const context = await browser.newContext({
265
+ viewport: { width: 1280, height: 720 },
266
+ recordVideo: { dir: tempDir, size: { width: 1280, height: 720 } },
267
+ });
268
+ const page = await context.newPage();
269
+ debug(`Navigating to ${url}...`);
270
+ await page.goto(url);
271
+ await page.waitForTimeout(500);
272
+
273
+ const mainElement = await page.locator(clipStep.selector).first();
274
+ const mainBoundingBox = await mainElement.boundingBox();
275
+ debug(`Main element bounding box: ${JSON.stringify(mainBoundingBox)}`);
276
+
277
+ if (!mainBoundingBox) {
278
+ throw new Error(
279
+ `Could not find element with selector: ${clipStep.selector}`
280
+ );
281
+ }
282
+
283
+ const events = [];
284
+ const startTime = Date.now();
285
+
286
+ // ============================================
287
+ // SENTINEL CAPTURE SETUP
288
+ // ============================================
289
+ const sentinelDir = path.join(
290
+ outputDir,
291
+ path.dirname(clipStep.path),
292
+ "sentinels"
293
+ );
294
+ fs.ensureDirSync(sentinelDir);
295
+ const sentinelPaths = [];
296
+
297
+ /**
298
+ * Capture a sentinel frame of the main element
299
+ * @param {number} index - Step index
300
+ * @returns {Promise<string>} Path to saved sentinel
301
+ */
302
+ async function captureSentinel(index) {
303
+ const sentinelPath = path.join(sentinelDir, `step-${index}.png`);
304
+ await mainElement.screenshot({ path: sentinelPath });
305
+ sentinelPaths.push({ index, path: sentinelPath });
306
+ return sentinelPath;
307
+ }
308
+
309
+ // Capture initial state BEFORE any actions
310
+ await captureSentinel(0);
311
+ console.log(" ✔ Captured initial sentinel frame");
312
+
313
+ // Execute steps and capture timeline
314
+ for (let stepIdx = 0; stepIdx < clipStep.steps.length; stepIdx++) {
315
+ const subStep = clipStep.steps[stepIdx];
316
+ const element = await page.locator(subStep.selector).first();
317
+ const boundingBox = await element.boundingBox();
318
+ if (!boundingBox) {
319
+ throw new Error(
320
+ `Could not find element with selector: ${subStep.selector}`
321
+ );
322
+ }
323
+ const timestamp = (Date.now() - startTime) / 1000;
324
+
325
+ // Store event with relative coordinates
326
+ events.push({
327
+ action: subStep.action,
328
+ timestamp,
329
+ subtitle:
330
+ subStep.subtitle ||
331
+ (subStep.action === "type"
332
+ ? `Entering text into ${subStep.selector}`
333
+ : `Clicking ${subStep.selector}`),
334
+ elementBox: {
335
+ x: boundingBox.x - mainBoundingBox.x,
336
+ y: boundingBox.y - mainBoundingBox.y,
337
+ width: boundingBox.width,
338
+ height: boundingBox.height,
339
+ },
340
+ });
341
+
342
+ if (subStep.action === "type") {
343
+ const text = resolveSecretsInString(subStep.text);
344
+ debug(`Typing into ${subStep.selector}: "${text.substring(0, 20)}..."`);
345
+ await page.type(subStep.selector, text, { delay: 100 });
346
+ await page.waitForTimeout(500);
347
+ } else if (subStep.action === "click") {
348
+ debug(`Clicking on ${subStep.selector}`);
349
+ await page.click(subStep.selector);
350
+ await page.waitForTimeout(500);
351
+ }
352
+
353
+ // Capture sentinel frame AFTER action
354
+ await captureSentinel(stepIdx + 1);
355
+ }
356
+
357
+ console.log(` ✔ Captured ${sentinelPaths.length} sentinel frames`);
358
+
359
+ debug("Waiting before closing context...");
360
+ await page.waitForTimeout(2000);
361
+ debug("Closing context to finalize video...");
362
+ await context.close();
363
+ console.log(" ✔ Video recorded and timeline captured");
364
+
365
+ // Wait for video file
366
+ debug("Waiting for video file to be written...");
367
+ await new Promise((resolve) => setTimeout(resolve, 1000));
368
+ const videoFiles = fs.readdirSync(tempDir).filter((f) => f.endsWith(".webm"));
369
+ debug(`Found ${videoFiles.length} video files: ${videoFiles.join(", ")}`);
370
+ if (videoFiles.length === 0) {
371
+ const allFiles = fs.readdirSync(tempDir);
372
+ debug(`All files in temp dir: ${allFiles.join(", ")}`);
373
+ throw new Error("No video file was created");
374
+ }
375
+ const recordedVideoPath = path.join(
376
+ tempDir,
377
+ videoFiles[videoFiles.length - 1]
378
+ );
379
+ const recordedVideoFilename = videoFiles[videoFiles.length - 1];
380
+ const videoSize = fs.statSync(recordedVideoPath).size;
381
+ debug(
382
+ `Using video file: ${recordedVideoPath} (${(videoSize / 1024).toFixed(
383
+ 1
384
+ )} KB)`
385
+ );
386
+
387
+ // Save timeline.json
388
+ fs.writeFileSync(timelinePath, JSON.stringify(events, null, 2));
389
+ console.log(` ✔ Timeline saved`);
390
+
391
+ // Crop the raw video to the main element
392
+ console.log(" --- Cropping video to element bounds ---");
393
+ await runFFmpeg(
394
+ [
395
+ "-i",
396
+ recordedVideoPath,
397
+ "-vf",
398
+ `crop=${Math.round(mainBoundingBox.width)}:${Math.round(
399
+ mainBoundingBox.height
400
+ )}:${Math.round(mainBoundingBox.x)}:${Math.round(mainBoundingBox.y)}`,
401
+ "-c:v",
402
+ "libx264",
403
+ "-preset",
404
+ "ultrafast",
405
+ "-pix_fmt",
406
+ "yuv420p",
407
+ "-y",
408
+ croppedVideoPath,
409
+ ],
410
+ "Cropping..."
411
+ );
412
+
413
+ // ============================================
414
+ // STAGE 2: OVERLAY GENERATION PHASE
415
+ // ============================================
416
+ let finalOverlayPath = null;
417
+ const enhancements = clipStep.enhancements || {};
418
+
419
+ if (enhancements.clickHighlight || enhancements.subtitles) {
420
+ console.log(" === Stage 2: Generating HTML overlay ===");
421
+
422
+ // Generate overlay.html
423
+ const overlayHtml = generateOverlayHtml(
424
+ events,
425
+ mainBoundingBox,
426
+ enhancements
427
+ );
428
+ fs.writeFileSync(overlayHtmlPath, overlayHtml);
429
+ console.log(` ✔ Overlay HTML generated`);
430
+
431
+ // Record the overlay HTML as a video
432
+ console.log(" --- Recording overlay animations ---");
433
+ const overlayContext = await browser.newContext({
434
+ viewport: {
435
+ width: Math.round(mainBoundingBox.width),
436
+ height: Math.round(mainBoundingBox.height),
437
+ },
438
+ recordVideo: {
439
+ dir: tempDir,
440
+ size: {
441
+ width: Math.round(mainBoundingBox.width),
442
+ height: Math.round(mainBoundingBox.height),
443
+ },
444
+ },
445
+ });
446
+ const overlayPage = await overlayContext.newPage();
447
+
448
+ // Load the HTML file and wait for it to be ready
449
+ await overlayPage.goto(`file://${path.resolve(overlayHtmlPath)}`, {
450
+ waitUntil: "networkidle",
451
+ });
452
+ await overlayPage.waitForTimeout(500);
453
+
454
+ // Wait for animations to complete
455
+ const maxTimestamp = Math.max(
456
+ ...events.map((e, idx) => {
457
+ const nextEvent = events[idx + 1];
458
+ return nextEvent ? nextEvent.timestamp : e.timestamp + 2;
459
+ })
460
+ );
461
+ const videoDuration = Math.max(maxTimestamp + 2, 5);
462
+
463
+ console.log(
464
+ ` Waiting ${videoDuration.toFixed(1)}s for overlay animations...`
465
+ );
466
+ await overlayPage.waitForTimeout(videoDuration * 1000);
467
+
468
+ await overlayContext.close();
469
+ console.log(" ✔ Overlay video recorded");
470
+
471
+ // Wait and find overlay video
472
+ await new Promise((resolve) => setTimeout(resolve, 1000));
473
+ const allVideoFiles = fs
474
+ .readdirSync(tempDir)
475
+ .filter((f) => f.endsWith(".webm"));
476
+ const overlayVideoFiles = allVideoFiles.filter(
477
+ (f) => f !== recordedVideoFilename
478
+ );
479
+ if (overlayVideoFiles.length === 0) {
480
+ throw new Error("No overlay video file was created");
481
+ }
482
+ finalOverlayPath = path.join(
483
+ tempDir,
484
+ overlayVideoFiles[overlayVideoFiles.length - 1]
485
+ );
486
+ console.log(` ✔ Overlay video found`);
487
+ }
488
+
489
+ // ============================================
490
+ // STAGE 3: COMPOSITING PHASE
491
+ // ============================================
492
+ if (enhancements.clickHighlight || enhancements.subtitles) {
493
+ console.log(" === Stage 3: Compositing videos ===");
494
+
495
+ // Use chroma key to make green background transparent, then overlay
496
+ await runFFmpeg(
497
+ [
498
+ "-i",
499
+ croppedVideoPath,
500
+ "-i",
501
+ finalOverlayPath,
502
+ "-filter_complex",
503
+ "[1:v]chromakey=0x00ff00:0.1:0.2[ckout];[0:v][ckout]overlay=0:0:shortest=1",
504
+ "-c:v",
505
+ "libx264",
506
+ "-preset",
507
+ "ultrafast",
508
+ "-pix_fmt",
509
+ "yuv420p",
510
+ "-movflags",
511
+ "+faststart",
512
+ "-y",
513
+ finalVideoPath,
514
+ ],
515
+ "Compositing with chroma key..."
516
+ );
517
+ } else {
518
+ // No enhancements, just copy the cropped video
519
+ fs.copyFileSync(croppedVideoPath, finalVideoPath);
520
+ }
521
+
522
+ // Clean up
523
+ console.log(" --- Cleaning up temporary files ---");
524
+ try {
525
+ if (fs.existsSync(recordedVideoPath)) fs.unlinkSync(recordedVideoPath);
526
+ if (fs.existsSync(croppedVideoPath)) fs.unlinkSync(croppedVideoPath);
527
+ if (fs.existsSync(timelinePath)) fs.unlinkSync(timelinePath);
528
+ if (fs.existsSync(overlayHtmlPath)) fs.unlinkSync(overlayHtmlPath);
529
+ const overlayFiles = fs
530
+ .readdirSync(tempDir)
531
+ .filter((f) => f.includes("overlay") || f.endsWith(".webm"));
532
+ overlayFiles.forEach((f) => {
533
+ try {
534
+ fs.unlinkSync(path.join(tempDir, f));
535
+ } catch (e) {}
536
+ });
537
+ if (fs.readdirSync(tempDir).length === 0) {
538
+ fs.rmdirSync(tempDir);
539
+ }
540
+ } catch (e) {
541
+ console.warn(
542
+ " Warning: Some temp files could not be deleted:",
543
+ e.message
544
+ );
545
+ }
546
+
547
+ console.log(` ✔ Polished video clip saved to ${finalVideoPath}`);
548
+ await browser.close();
549
+ }
550
+
551
+ module.exports = {
552
+ runPolishedClip,
553
+ };