@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,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
|
+
};
|