clipwise 0.1.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 +21 -0
- package/README.md +359 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +2150 -0
- package/dist/index.d.ts +1631 -0
- package/dist/index.js +1704 -0
- package/package.json +69 -0
|
@@ -0,0 +1,2150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/script/types.ts
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, StepActionSchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepSchema, ScenarioSchema;
|
|
15
|
+
var init_types = __esm({
|
|
16
|
+
"src/script/types.ts"() {
|
|
17
|
+
"use strict";
|
|
18
|
+
SafeSelectorSchema = z.string().regex(
|
|
19
|
+
/^[a-zA-Z0-9\-_#.\[\]="':\s~^$|*,>+()@]+$/,
|
|
20
|
+
"Selector contains invalid characters"
|
|
21
|
+
);
|
|
22
|
+
NavigateActionSchema = z.object({
|
|
23
|
+
action: z.literal("navigate"),
|
|
24
|
+
url: z.string().min(1),
|
|
25
|
+
waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).default("networkidle")
|
|
26
|
+
});
|
|
27
|
+
ClickActionSchema = z.object({
|
|
28
|
+
action: z.literal("click"),
|
|
29
|
+
selector: SafeSelectorSchema,
|
|
30
|
+
delay: z.number().optional()
|
|
31
|
+
});
|
|
32
|
+
TypeActionSchema = z.object({
|
|
33
|
+
action: z.literal("type"),
|
|
34
|
+
selector: SafeSelectorSchema,
|
|
35
|
+
text: z.string(),
|
|
36
|
+
delay: z.number().default(50)
|
|
37
|
+
});
|
|
38
|
+
ScrollActionSchema = z.object({
|
|
39
|
+
action: z.literal("scroll"),
|
|
40
|
+
selector: SafeSelectorSchema.optional(),
|
|
41
|
+
y: z.number().default(0),
|
|
42
|
+
x: z.number().default(0),
|
|
43
|
+
smooth: z.boolean().default(true)
|
|
44
|
+
});
|
|
45
|
+
WaitActionSchema = z.object({
|
|
46
|
+
action: z.literal("wait"),
|
|
47
|
+
duration: z.number().describe("Wait duration in milliseconds")
|
|
48
|
+
});
|
|
49
|
+
HoverActionSchema = z.object({
|
|
50
|
+
action: z.literal("hover"),
|
|
51
|
+
selector: SafeSelectorSchema
|
|
52
|
+
});
|
|
53
|
+
ScreenshotActionSchema = z.object({
|
|
54
|
+
action: z.literal("screenshot"),
|
|
55
|
+
name: z.string().optional(),
|
|
56
|
+
fullPage: z.boolean().default(false)
|
|
57
|
+
});
|
|
58
|
+
StepActionSchema = z.discriminatedUnion("action", [
|
|
59
|
+
NavigateActionSchema,
|
|
60
|
+
ClickActionSchema,
|
|
61
|
+
TypeActionSchema,
|
|
62
|
+
ScrollActionSchema,
|
|
63
|
+
WaitActionSchema,
|
|
64
|
+
HoverActionSchema,
|
|
65
|
+
ScreenshotActionSchema
|
|
66
|
+
]);
|
|
67
|
+
AutoZoomConfigSchema = z.object({
|
|
68
|
+
followCursor: z.boolean().default(true),
|
|
69
|
+
maxScale: z.number().min(1).max(5).default(2),
|
|
70
|
+
transitionDuration: z.number().default(400),
|
|
71
|
+
padding: z.number().default(200)
|
|
72
|
+
});
|
|
73
|
+
ZoomEffectSchema = z.object({
|
|
74
|
+
enabled: z.boolean().default(true),
|
|
75
|
+
scale: z.number().min(1).max(5).default(1.8),
|
|
76
|
+
duration: z.number().default(600),
|
|
77
|
+
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
|
|
78
|
+
autoZoom: AutoZoomConfigSchema.default({})
|
|
79
|
+
});
|
|
80
|
+
CursorEffectSchema = z.object({
|
|
81
|
+
enabled: z.boolean().default(true),
|
|
82
|
+
size: z.number().default(20),
|
|
83
|
+
color: z.string().default("#000000"),
|
|
84
|
+
speed: z.enum(["fast", "normal", "slow"]).default("fast"),
|
|
85
|
+
smoothing: z.boolean().default(true),
|
|
86
|
+
clickEffect: z.boolean().default(true),
|
|
87
|
+
clickColor: z.string().default("rgba(59, 130, 246, 0.3)"),
|
|
88
|
+
clickRadius: z.number().default(30),
|
|
89
|
+
trail: z.boolean().default(false),
|
|
90
|
+
trailLength: z.number().default(8),
|
|
91
|
+
trailColor: z.string().default("rgba(59, 130, 246, 0.2)"),
|
|
92
|
+
highlight: z.boolean().default(false),
|
|
93
|
+
highlightRadius: z.number().default(40),
|
|
94
|
+
highlightColor: z.string().default("rgba(255, 215, 0, 0.18)")
|
|
95
|
+
});
|
|
96
|
+
BackgroundSchema = z.object({
|
|
97
|
+
type: z.enum(["gradient", "solid", "image"]).default("gradient"),
|
|
98
|
+
value: z.string().default("linear-gradient(135deg, #667eea 0%, #764ba2 100%)"),
|
|
99
|
+
padding: z.number().default(60),
|
|
100
|
+
borderRadius: z.number().default(12),
|
|
101
|
+
shadow: z.boolean().default(true)
|
|
102
|
+
});
|
|
103
|
+
DeviceFrameSchema = z.object({
|
|
104
|
+
enabled: z.boolean().default(false),
|
|
105
|
+
type: z.enum(["browser", "macbook", "iphone", "ipad", "android", "none"]).default("browser"),
|
|
106
|
+
darkMode: z.boolean().default(false)
|
|
107
|
+
});
|
|
108
|
+
SpeedRampConfigSchema = z.object({
|
|
109
|
+
enabled: z.boolean().default(false),
|
|
110
|
+
idleSpeed: z.number().min(0.5).max(8).default(3),
|
|
111
|
+
actionSpeed: z.number().min(0.25).max(2).default(0.8),
|
|
112
|
+
transitionFrames: z.number().default(15)
|
|
113
|
+
});
|
|
114
|
+
KeystrokeConfigSchema = z.object({
|
|
115
|
+
enabled: z.boolean().default(false),
|
|
116
|
+
position: z.enum(["bottom-center", "bottom-left", "bottom-right"]).default("bottom-center"),
|
|
117
|
+
fontSize: z.number().default(18),
|
|
118
|
+
backgroundColor: z.string().default("rgba(0, 0, 0, 0.75)"),
|
|
119
|
+
textColor: z.string().default("#ffffff"),
|
|
120
|
+
padding: z.number().default(8),
|
|
121
|
+
fadeAfter: z.number().default(1500)
|
|
122
|
+
});
|
|
123
|
+
WatermarkConfigSchema = z.object({
|
|
124
|
+
enabled: z.boolean().default(false),
|
|
125
|
+
text: z.string().default(""),
|
|
126
|
+
position: z.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("bottom-right"),
|
|
127
|
+
opacity: z.number().min(0).max(1).default(0.5),
|
|
128
|
+
fontSize: z.number().default(14),
|
|
129
|
+
color: z.string().default("#ffffff")
|
|
130
|
+
});
|
|
131
|
+
EffectsConfigSchema = z.object({
|
|
132
|
+
zoom: ZoomEffectSchema.default({}),
|
|
133
|
+
cursor: CursorEffectSchema.default({}),
|
|
134
|
+
background: BackgroundSchema.default({}),
|
|
135
|
+
deviceFrame: DeviceFrameSchema.default({}),
|
|
136
|
+
speedRamp: SpeedRampConfigSchema.default({}),
|
|
137
|
+
keystroke: KeystrokeConfigSchema.default({}),
|
|
138
|
+
watermark: WatermarkConfigSchema.default({})
|
|
139
|
+
});
|
|
140
|
+
OutputConfigSchema = z.object({
|
|
141
|
+
format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
|
|
142
|
+
width: z.number().default(1280),
|
|
143
|
+
height: z.number().default(800),
|
|
144
|
+
fps: z.number().min(1).max(60).default(15),
|
|
145
|
+
quality: z.number().min(1).max(100).default(80),
|
|
146
|
+
outputDir: z.string().default("./output"),
|
|
147
|
+
filename: z.string().default("clipwise-recording")
|
|
148
|
+
});
|
|
149
|
+
StepSchema = z.object({
|
|
150
|
+
name: z.string().optional(),
|
|
151
|
+
actions: z.array(StepActionSchema),
|
|
152
|
+
captureDelay: z.number().default(300),
|
|
153
|
+
holdDuration: z.number().default(1500),
|
|
154
|
+
transition: z.enum(["fade", "none"]).default("none")
|
|
155
|
+
});
|
|
156
|
+
ScenarioSchema = z.object({
|
|
157
|
+
name: z.string(),
|
|
158
|
+
description: z.string().optional(),
|
|
159
|
+
viewport: z.object({
|
|
160
|
+
width: z.number().default(1280),
|
|
161
|
+
height: z.number().default(800)
|
|
162
|
+
}).default({}),
|
|
163
|
+
effects: EffectsConfigSchema.default({}),
|
|
164
|
+
output: OutputConfigSchema.default({}),
|
|
165
|
+
steps: z.array(StepSchema).min(1)
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// src/script/parser.ts
|
|
171
|
+
var parser_exports = {};
|
|
172
|
+
__export(parser_exports, {
|
|
173
|
+
loadScenario: () => loadScenario,
|
|
174
|
+
parseScenario: () => parseScenario
|
|
175
|
+
});
|
|
176
|
+
import { parse as parseYaml } from "yaml";
|
|
177
|
+
import { readFile } from "fs/promises";
|
|
178
|
+
import { ZodError } from "zod";
|
|
179
|
+
function parseScenario(yamlContent) {
|
|
180
|
+
let raw;
|
|
181
|
+
try {
|
|
182
|
+
raw = parseYaml(yamlContent);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const message = error instanceof Error ? error.message : "Unknown parse error";
|
|
185
|
+
throw new Error(`YAML parse error: ${message}`);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return ScenarioSchema.parse(raw);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof ZodError) {
|
|
191
|
+
const details = error.issues.map((issue) => {
|
|
192
|
+
const path = issue.path.join(".");
|
|
193
|
+
return ` - ${path ? `${path}: ` : ""}${issue.message}`;
|
|
194
|
+
}).join("\n");
|
|
195
|
+
throw new Error(`Scenario validation failed:
|
|
196
|
+
${details}`);
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function loadScenario(filePath) {
|
|
202
|
+
let content;
|
|
203
|
+
try {
|
|
204
|
+
content = await readFile(filePath, "utf-8");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : "Unknown file error";
|
|
207
|
+
throw new Error(`Failed to read scenario file "${filePath}": ${message}`);
|
|
208
|
+
}
|
|
209
|
+
return parseScenario(content);
|
|
210
|
+
}
|
|
211
|
+
var init_parser = __esm({
|
|
212
|
+
"src/script/parser.ts"() {
|
|
213
|
+
"use strict";
|
|
214
|
+
init_types();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// src/cli/index.ts
|
|
219
|
+
init_parser();
|
|
220
|
+
import { Command } from "commander";
|
|
221
|
+
import ora from "ora";
|
|
222
|
+
import chalk from "chalk";
|
|
223
|
+
|
|
224
|
+
// src/script/validator.ts
|
|
225
|
+
function validateScenario(scenario) {
|
|
226
|
+
const errors = [];
|
|
227
|
+
const warnings = [];
|
|
228
|
+
if (scenario.steps.length > 0) {
|
|
229
|
+
const firstStep = scenario.steps[0];
|
|
230
|
+
const hasNavigate = firstStep.actions.some(
|
|
231
|
+
(a) => a.action === "navigate"
|
|
232
|
+
);
|
|
233
|
+
if (!hasNavigate) {
|
|
234
|
+
errors.push(
|
|
235
|
+
'First step must contain a "navigate" action to open a page'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (let i = 0; i < scenario.steps.length; i++) {
|
|
240
|
+
const step = scenario.steps[i];
|
|
241
|
+
const stepLabel = step.name ? `"${step.name}"` : `#${i + 1}`;
|
|
242
|
+
for (let j = 0; j < step.actions.length; j++) {
|
|
243
|
+
const action = step.actions[j];
|
|
244
|
+
if ("selector" in action && action.selector !== void 0) {
|
|
245
|
+
const selector = action.selector;
|
|
246
|
+
if (selector.trim() === "") {
|
|
247
|
+
errors.push(
|
|
248
|
+
`Step ${stepLabel}, action #${j + 1} (${action.action}): selector must not be empty`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const { width, height } = scenario.viewport;
|
|
255
|
+
if (width < 100 || width > 3840) {
|
|
256
|
+
errors.push(
|
|
257
|
+
`Viewport width ${width} is out of range (must be 100-3840)`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (height < 100 || height > 3840) {
|
|
261
|
+
errors.push(
|
|
262
|
+
`Viewport height ${height} is out of range (must be 100-3840)`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const output = scenario.output;
|
|
266
|
+
if (output.width < 100 || output.width > 3840) {
|
|
267
|
+
errors.push(
|
|
268
|
+
`Output width ${output.width} is out of range (must be 100-3840)`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (output.height < 100 || output.height > 3840) {
|
|
272
|
+
errors.push(
|
|
273
|
+
`Output height ${output.height} is out of range (must be 100-3840)`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (output.fps > 30) {
|
|
277
|
+
warnings.push(
|
|
278
|
+
`FPS is set to ${output.fps}. High FPS may produce very large files.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (output.format === "gif" && output.quality > 90) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
"GIF quality above 90 has diminishing returns and increases file size significantly."
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (scenario.viewport.width !== output.width || scenario.viewport.height !== output.height) {
|
|
287
|
+
warnings.push(
|
|
288
|
+
"Viewport dimensions differ from output dimensions. Output will be scaled."
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
valid: errors.length === 0,
|
|
293
|
+
errors,
|
|
294
|
+
warnings
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/core/recorder.ts
|
|
299
|
+
import { chromium } from "playwright";
|
|
300
|
+
|
|
301
|
+
// src/core/cursor-tracker.ts
|
|
302
|
+
function easeInOutCubic(t) {
|
|
303
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
304
|
+
}
|
|
305
|
+
function interpolatePath(from, to, steps) {
|
|
306
|
+
if (steps <= 0) return [to];
|
|
307
|
+
if (steps === 1) return [from, to];
|
|
308
|
+
const dx = to.x - from.x;
|
|
309
|
+
const dy = to.y - from.y;
|
|
310
|
+
const cp1 = {
|
|
311
|
+
x: from.x + dx * 0.25 + dy * 0.1,
|
|
312
|
+
y: from.y + dy * 0.25 - dx * 0.1
|
|
313
|
+
};
|
|
314
|
+
const cp2 = {
|
|
315
|
+
x: from.x + dx * 0.75 - dy * 0.1,
|
|
316
|
+
y: from.y + dy * 0.75 + dx * 0.1
|
|
317
|
+
};
|
|
318
|
+
const points = [];
|
|
319
|
+
for (let i = 0; i <= steps; i++) {
|
|
320
|
+
const rawT = i / steps;
|
|
321
|
+
const t = easeInOutCubic(rawT);
|
|
322
|
+
const oneMinusT = 1 - t;
|
|
323
|
+
const x = oneMinusT * oneMinusT * oneMinusT * from.x + 3 * oneMinusT * oneMinusT * t * cp1.x + 3 * oneMinusT * t * t * cp2.x + t * t * t * to.x;
|
|
324
|
+
const y = oneMinusT * oneMinusT * oneMinusT * from.y + 3 * oneMinusT * oneMinusT * t * cp1.y + 3 * oneMinusT * t * t * cp2.y + t * t * t * to.y;
|
|
325
|
+
points.push({ x: Math.round(x), y: Math.round(y) });
|
|
326
|
+
}
|
|
327
|
+
return points;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/core/screenshot.ts
|
|
331
|
+
async function getElementCenter(page, selector) {
|
|
332
|
+
if (!/^[\w\-#.\[\]="':\s,>+~*()@^$|]+$/.test(selector)) {
|
|
333
|
+
throw new Error(`Invalid selector: ${selector}`);
|
|
334
|
+
}
|
|
335
|
+
const element = page.locator(selector).first();
|
|
336
|
+
await element.waitFor({ state: "visible", timeout: 5e3 });
|
|
337
|
+
const box = await element.boundingBox();
|
|
338
|
+
if (!box) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Element "${selector}" not found or has no bounding box`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
x: Math.round(box.x + box.width / 2),
|
|
345
|
+
y: Math.round(box.y + box.height / 2)
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/core/recorder.ts
|
|
350
|
+
var CLICK_EFFECT_DURATION_MS = 500;
|
|
351
|
+
var REPAINT_INTERVAL_MS = 50;
|
|
352
|
+
var ACTION_GAP_MS = 30;
|
|
353
|
+
var CURSOR_SPEED_PRESETS = {
|
|
354
|
+
fast: { steps: 12, delay: 6 },
|
|
355
|
+
// ~72ms total
|
|
356
|
+
normal: { steps: 18, delay: 8 },
|
|
357
|
+
// ~144ms total
|
|
358
|
+
slow: { steps: 24, delay: 12 }
|
|
359
|
+
// ~288ms total
|
|
360
|
+
};
|
|
361
|
+
var ClipwiseRecorder = class {
|
|
362
|
+
browser = null;
|
|
363
|
+
context = null;
|
|
364
|
+
page = null;
|
|
365
|
+
cdpClient = null;
|
|
366
|
+
rawFrames = [];
|
|
367
|
+
cursorTimeline = [];
|
|
368
|
+
clickTimeline = [];
|
|
369
|
+
keystrokeTimeline = [];
|
|
370
|
+
currentStepIndex = 0;
|
|
371
|
+
cursorPosition = { x: 0, y: 0 };
|
|
372
|
+
viewport = { width: 1280, height: 800 };
|
|
373
|
+
isCapturing = false;
|
|
374
|
+
targetFps = 30;
|
|
375
|
+
cursorSpeed = "fast";
|
|
376
|
+
/**
|
|
377
|
+
* Launch the browser and create a page with the scenario viewport.
|
|
378
|
+
*/
|
|
379
|
+
async init(scenario) {
|
|
380
|
+
this.viewport = {
|
|
381
|
+
width: scenario.viewport.width,
|
|
382
|
+
height: scenario.viewport.height
|
|
383
|
+
};
|
|
384
|
+
this.targetFps = scenario.output.fps;
|
|
385
|
+
this.cursorSpeed = scenario.effects.cursor.speed;
|
|
386
|
+
this.browser = await chromium.launch({ headless: true });
|
|
387
|
+
this.context = await this.browser.newContext({
|
|
388
|
+
viewport: this.viewport
|
|
389
|
+
});
|
|
390
|
+
this.page = await this.context.newPage();
|
|
391
|
+
this.rawFrames = [];
|
|
392
|
+
this.cursorTimeline = [];
|
|
393
|
+
this.clickTimeline = [];
|
|
394
|
+
this.keystrokeTimeline = [];
|
|
395
|
+
this.currentStepIndex = 0;
|
|
396
|
+
this.cursorPosition = { x: 0, y: 0 };
|
|
397
|
+
this.isCapturing = false;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Start CDP screencast for continuous frame capture.
|
|
401
|
+
* Frames are received asynchronously and stored in rawFrames.
|
|
402
|
+
*/
|
|
403
|
+
async startCapture() {
|
|
404
|
+
if (!this.page) throw new Error("Page not initialized");
|
|
405
|
+
this.cdpClient = await this.page.context().newCDPSession(this.page);
|
|
406
|
+
this.isCapturing = true;
|
|
407
|
+
this.cdpClient.on(
|
|
408
|
+
"Page.screencastFrame",
|
|
409
|
+
async (event) => {
|
|
410
|
+
if (!this.isCapturing || !this.cdpClient) return;
|
|
411
|
+
const buffer = Buffer.from(event.data, "base64");
|
|
412
|
+
this.rawFrames.push({
|
|
413
|
+
buffer,
|
|
414
|
+
timestamp: Date.now()
|
|
415
|
+
});
|
|
416
|
+
await this.cdpClient.send("Page.screencastFrameAck", {
|
|
417
|
+
sessionId: event.sessionId
|
|
418
|
+
}).catch(() => {
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
await this.cdpClient.send("Page.startScreencast", {
|
|
423
|
+
format: "jpeg",
|
|
424
|
+
quality: 95,
|
|
425
|
+
maxWidth: this.viewport.width,
|
|
426
|
+
maxHeight: this.viewport.height,
|
|
427
|
+
everyNthFrame: 1
|
|
428
|
+
});
|
|
429
|
+
this.cursorTimeline.push({
|
|
430
|
+
position: { ...this.cursorPosition },
|
|
431
|
+
timestamp: Date.now()
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Stop CDP screencast and flush remaining frames.
|
|
436
|
+
*/
|
|
437
|
+
async stopCapture() {
|
|
438
|
+
this.isCapturing = false;
|
|
439
|
+
if (this.cdpClient) {
|
|
440
|
+
await this.cdpClient.send("Page.stopScreencast").catch(() => {
|
|
441
|
+
});
|
|
442
|
+
await this.cdpClient.detach().catch(() => {
|
|
443
|
+
});
|
|
444
|
+
this.cdpClient = null;
|
|
445
|
+
}
|
|
446
|
+
await new Promise((resolve2) => setTimeout(resolve2, 200));
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Execute the full scenario with continuous capture and return a RecordingSession.
|
|
450
|
+
*/
|
|
451
|
+
async record(scenario) {
|
|
452
|
+
await this.init(scenario);
|
|
453
|
+
const startTime = Date.now();
|
|
454
|
+
try {
|
|
455
|
+
await this.startCapture();
|
|
456
|
+
for (let si = 0; si < scenario.steps.length; si++) {
|
|
457
|
+
const step = scenario.steps[si];
|
|
458
|
+
this.currentStepIndex = si;
|
|
459
|
+
for (const action of step.actions) {
|
|
460
|
+
await this.executeAction(action);
|
|
461
|
+
}
|
|
462
|
+
if (step.captureDelay > 0) {
|
|
463
|
+
await this.waitWithRepaints(step.captureDelay);
|
|
464
|
+
}
|
|
465
|
+
const holdMs = step.holdDuration;
|
|
466
|
+
if (holdMs > 0) {
|
|
467
|
+
await this.waitWithRepaints(holdMs);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
await this.stopCapture();
|
|
471
|
+
const rawFrames = this.buildCapturedFrames();
|
|
472
|
+
const recordingDurationMs = Date.now() - startTime;
|
|
473
|
+
const frames = this.resampleToTargetFps(
|
|
474
|
+
rawFrames,
|
|
475
|
+
recordingDurationMs
|
|
476
|
+
);
|
|
477
|
+
return {
|
|
478
|
+
scenario,
|
|
479
|
+
frames,
|
|
480
|
+
startTime,
|
|
481
|
+
endTime: Date.now()
|
|
482
|
+
};
|
|
483
|
+
} catch (error) {
|
|
484
|
+
await this.stopCapture().catch(() => {
|
|
485
|
+
});
|
|
486
|
+
const rawFrames = this.buildCapturedFrames();
|
|
487
|
+
const recordingDurationMs = Date.now() - startTime;
|
|
488
|
+
const frames = this.resampleToTargetFps(
|
|
489
|
+
rawFrames,
|
|
490
|
+
recordingDurationMs
|
|
491
|
+
);
|
|
492
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
493
|
+
err.partialSession = {
|
|
494
|
+
scenario,
|
|
495
|
+
frames,
|
|
496
|
+
startTime,
|
|
497
|
+
endTime: Date.now()
|
|
498
|
+
};
|
|
499
|
+
throw err;
|
|
500
|
+
} finally {
|
|
501
|
+
await this.cleanup();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Wait for a given duration while forcing periodic repaints
|
|
506
|
+
* so CDP screencast keeps sending frames even on static pages.
|
|
507
|
+
*/
|
|
508
|
+
async waitWithRepaints(durationMs) {
|
|
509
|
+
if (!this.page || durationMs <= 0) return;
|
|
510
|
+
const endTime = Date.now() + durationMs;
|
|
511
|
+
let toggle = false;
|
|
512
|
+
while (Date.now() < endTime && this.isCapturing) {
|
|
513
|
+
await this.page.evaluate((t) => {
|
|
514
|
+
document.documentElement.style.outline = t ? "0.001px solid transparent" : "none";
|
|
515
|
+
}, toggle).catch(() => {
|
|
516
|
+
});
|
|
517
|
+
toggle = !toggle;
|
|
518
|
+
const remaining = endTime - Date.now();
|
|
519
|
+
if (remaining > 0) {
|
|
520
|
+
await new Promise(
|
|
521
|
+
(resolve2) => setTimeout(resolve2, Math.min(REPAINT_INTERVAL_MS, remaining))
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Execute a single action. CDP screencast captures frames continuously
|
|
528
|
+
* in the background while actions are performed.
|
|
529
|
+
*/
|
|
530
|
+
async executeAction(action) {
|
|
531
|
+
if (!this.page) {
|
|
532
|
+
throw new Error("Page not initialized. Call init() first.");
|
|
533
|
+
}
|
|
534
|
+
switch (action.action) {
|
|
535
|
+
case "navigate": {
|
|
536
|
+
await this.page.goto(action.url, { waitUntil: action.waitUntil });
|
|
537
|
+
await this.waitWithRepaints(300);
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case "click": {
|
|
541
|
+
const target = await getElementCenter(this.page, action.selector);
|
|
542
|
+
await this.moveCursorSmooth(target);
|
|
543
|
+
this.clickTimeline.push({
|
|
544
|
+
position: { ...target },
|
|
545
|
+
timestamp: Date.now()
|
|
546
|
+
});
|
|
547
|
+
await this.page.click(action.selector, {
|
|
548
|
+
delay: action.delay
|
|
549
|
+
});
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
case "type": {
|
|
553
|
+
const inputTarget = await getElementCenter(
|
|
554
|
+
this.page,
|
|
555
|
+
action.selector
|
|
556
|
+
);
|
|
557
|
+
await this.moveCursorSmooth(inputTarget);
|
|
558
|
+
this.clickTimeline.push({
|
|
559
|
+
position: { ...inputTarget },
|
|
560
|
+
timestamp: Date.now()
|
|
561
|
+
});
|
|
562
|
+
await this.page.click(action.selector);
|
|
563
|
+
for (const char of action.text) {
|
|
564
|
+
await this.page.keyboard.type(char, { delay: action.delay });
|
|
565
|
+
this.keystrokeTimeline.push({
|
|
566
|
+
key: char,
|
|
567
|
+
timestamp: Date.now()
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case "scroll": {
|
|
573
|
+
const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector) : null;
|
|
574
|
+
await this.page.evaluate(
|
|
575
|
+
({ x, y, smooth, selector }) => {
|
|
576
|
+
const target = selector ? document.querySelector(selector) : window;
|
|
577
|
+
if (target) {
|
|
578
|
+
const options = {
|
|
579
|
+
left: x,
|
|
580
|
+
top: y,
|
|
581
|
+
behavior: smooth ? "smooth" : "instant"
|
|
582
|
+
};
|
|
583
|
+
if (target === window) {
|
|
584
|
+
window.scrollBy(options);
|
|
585
|
+
} else {
|
|
586
|
+
target.scrollBy(options);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
x: action.x,
|
|
592
|
+
y: action.y,
|
|
593
|
+
smooth: action.smooth,
|
|
594
|
+
selector: action.selector ?? null
|
|
595
|
+
}
|
|
596
|
+
);
|
|
597
|
+
if (scrollTarget) {
|
|
598
|
+
this.cursorPosition = scrollTarget;
|
|
599
|
+
this.cursorTimeline.push({
|
|
600
|
+
position: { ...scrollTarget },
|
|
601
|
+
timestamp: Date.now()
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
|
|
605
|
+
const scrollWait = action.smooth ? Math.max(600, Math.round(scrollDistance * 0.8)) : 100;
|
|
606
|
+
await this.waitWithRepaints(scrollWait);
|
|
607
|
+
await this.waitWithRepaints(150);
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
case "wait": {
|
|
611
|
+
await this.waitWithRepaints(action.duration);
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
case "hover": {
|
|
615
|
+
const hoverTarget = await getElementCenter(
|
|
616
|
+
this.page,
|
|
617
|
+
action.selector
|
|
618
|
+
);
|
|
619
|
+
await this.moveCursorSmooth(hoverTarget);
|
|
620
|
+
await this.page.hover(action.selector);
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
case "screenshot": {
|
|
624
|
+
await this.waitWithRepaints(100);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
await this.waitWithRepaints(ACTION_GAP_MS);
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Move cursor smoothly from current position to target using
|
|
632
|
+
* manual step-by-step movement with delays between each step.
|
|
633
|
+
* Speed is controlled by the cursor.speed preset (fast/normal/slow).
|
|
634
|
+
*/
|
|
635
|
+
async moveCursorSmooth(target) {
|
|
636
|
+
if (!this.page) return;
|
|
637
|
+
const { steps, delay } = CURSOR_SPEED_PRESETS[this.cursorSpeed];
|
|
638
|
+
const from = { ...this.cursorPosition };
|
|
639
|
+
const path = interpolatePath(from, target, steps);
|
|
640
|
+
for (const point of path) {
|
|
641
|
+
await this.page.mouse.move(point.x, point.y);
|
|
642
|
+
this.cursorTimeline.push({
|
|
643
|
+
position: { x: point.x, y: point.y },
|
|
644
|
+
timestamp: Date.now()
|
|
645
|
+
});
|
|
646
|
+
await new Promise((resolve2) => setTimeout(resolve2, delay));
|
|
647
|
+
}
|
|
648
|
+
this.cursorPosition = { ...target };
|
|
649
|
+
await this.waitWithRepaints(100);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Build CapturedFrame array from raw screencast frames,
|
|
653
|
+
* interpolating cursor positions and mapping click events.
|
|
654
|
+
*/
|
|
655
|
+
buildCapturedFrames() {
|
|
656
|
+
if (this.rawFrames.length === 0) return [];
|
|
657
|
+
return this.rawFrames.map((raw, index) => {
|
|
658
|
+
const cursorPos = this.interpolateCursorAt(raw.timestamp);
|
|
659
|
+
const clickEvent = this.clickTimeline.find(
|
|
660
|
+
(click) => raw.timestamp >= click.timestamp && raw.timestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
661
|
+
);
|
|
662
|
+
let clickProgress;
|
|
663
|
+
if (clickEvent) {
|
|
664
|
+
const elapsed = raw.timestamp - clickEvent.timestamp;
|
|
665
|
+
clickProgress = Math.min(1, elapsed / CLICK_EFFECT_DURATION_MS);
|
|
666
|
+
}
|
|
667
|
+
const frameKeystrokes = this.keystrokeTimeline.filter(
|
|
668
|
+
(k) => k.timestamp <= raw.timestamp
|
|
669
|
+
);
|
|
670
|
+
return {
|
|
671
|
+
index,
|
|
672
|
+
screenshot: raw.buffer,
|
|
673
|
+
timestamp: raw.timestamp,
|
|
674
|
+
cursorPosition: cursorPos,
|
|
675
|
+
clickPosition: clickEvent?.position ?? null,
|
|
676
|
+
clickProgress,
|
|
677
|
+
viewport: { ...this.viewport },
|
|
678
|
+
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
679
|
+
stepIndex: this.currentStepIndex
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Resample captured frames to the target FPS.
|
|
685
|
+
*
|
|
686
|
+
* Even if CDP only sent a few unique screenshots, we generate enough
|
|
687
|
+
* output frames for smooth playback. Each output frame:
|
|
688
|
+
* - Uses the nearest raw screenshot (may be duplicated)
|
|
689
|
+
* - Gets a uniquely interpolated cursor position
|
|
690
|
+
* - Gets properly mapped click effects
|
|
691
|
+
*/
|
|
692
|
+
resampleToTargetFps(frames, recordingDurationMs) {
|
|
693
|
+
if (frames.length === 0) return [];
|
|
694
|
+
const targetFrameCount = Math.max(
|
|
695
|
+
frames.length,
|
|
696
|
+
Math.round(recordingDurationMs / 1e3 * this.targetFps)
|
|
697
|
+
);
|
|
698
|
+
if (targetFrameCount <= frames.length) return frames;
|
|
699
|
+
const startTime = frames[0].timestamp;
|
|
700
|
+
const endTime = frames[frames.length - 1].timestamp;
|
|
701
|
+
const duration = Math.max(1, endTime - startTime);
|
|
702
|
+
const resampled = [];
|
|
703
|
+
for (let i = 0; i < targetFrameCount; i++) {
|
|
704
|
+
const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
|
|
705
|
+
const targetTimestamp = startTime + t * duration;
|
|
706
|
+
let nearestIdx = 0;
|
|
707
|
+
let minDist = Infinity;
|
|
708
|
+
for (let j = 0; j < frames.length; j++) {
|
|
709
|
+
const dist = Math.abs(frames[j].timestamp - targetTimestamp);
|
|
710
|
+
if (dist < minDist) {
|
|
711
|
+
minDist = dist;
|
|
712
|
+
nearestIdx = j;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const cursorPos = this.interpolateCursorAt(targetTimestamp);
|
|
716
|
+
const clickEvent = this.clickTimeline.find(
|
|
717
|
+
(click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
718
|
+
);
|
|
719
|
+
let clickProgress;
|
|
720
|
+
if (clickEvent) {
|
|
721
|
+
const elapsed = targetTimestamp - clickEvent.timestamp;
|
|
722
|
+
clickProgress = Math.min(1, elapsed / CLICK_EFFECT_DURATION_MS);
|
|
723
|
+
}
|
|
724
|
+
const frameKeystrokes = this.keystrokeTimeline.filter(
|
|
725
|
+
(k) => k.timestamp <= targetTimestamp
|
|
726
|
+
);
|
|
727
|
+
resampled.push({
|
|
728
|
+
index: i,
|
|
729
|
+
screenshot: frames[nearestIdx].screenshot,
|
|
730
|
+
timestamp: targetTimestamp,
|
|
731
|
+
cursorPosition: cursorPos,
|
|
732
|
+
clickPosition: clickEvent?.position ?? null,
|
|
733
|
+
clickProgress,
|
|
734
|
+
viewport: { ...this.viewport },
|
|
735
|
+
stepName: frames[nearestIdx].stepName,
|
|
736
|
+
stepIndex: frames[nearestIdx].stepIndex,
|
|
737
|
+
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
return resampled;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Interpolate cursor position at a given timestamp using the cursor timeline.
|
|
744
|
+
*/
|
|
745
|
+
interpolateCursorAt(timestamp) {
|
|
746
|
+
if (this.cursorTimeline.length === 0) return { x: 0, y: 0 };
|
|
747
|
+
if (this.cursorTimeline.length === 1) {
|
|
748
|
+
return { ...this.cursorTimeline[0].position };
|
|
749
|
+
}
|
|
750
|
+
let before = this.cursorTimeline[0];
|
|
751
|
+
let after = this.cursorTimeline[this.cursorTimeline.length - 1];
|
|
752
|
+
for (let i = 0; i < this.cursorTimeline.length - 1; i++) {
|
|
753
|
+
if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
|
|
754
|
+
before = this.cursorTimeline[i];
|
|
755
|
+
after = this.cursorTimeline[i + 1];
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (timestamp <= before.timestamp) return { ...before.position };
|
|
760
|
+
if (timestamp >= after.timestamp) return { ...after.position };
|
|
761
|
+
const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
|
|
762
|
+
return {
|
|
763
|
+
x: Math.round(
|
|
764
|
+
before.position.x + (after.position.x - before.position.x) * t
|
|
765
|
+
),
|
|
766
|
+
y: Math.round(
|
|
767
|
+
before.position.y + (after.position.y - before.position.y) * t
|
|
768
|
+
)
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Clean up browser resources. Always called after recording.
|
|
773
|
+
*/
|
|
774
|
+
async cleanup() {
|
|
775
|
+
if (this.cdpClient) {
|
|
776
|
+
await this.cdpClient.detach().catch(() => {
|
|
777
|
+
});
|
|
778
|
+
this.cdpClient = null;
|
|
779
|
+
}
|
|
780
|
+
if (this.context) {
|
|
781
|
+
await this.context.close().catch(() => {
|
|
782
|
+
});
|
|
783
|
+
this.context = null;
|
|
784
|
+
}
|
|
785
|
+
if (this.browser) {
|
|
786
|
+
await this.browser.close().catch(() => {
|
|
787
|
+
});
|
|
788
|
+
this.browser = null;
|
|
789
|
+
}
|
|
790
|
+
this.page = null;
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// src/compose/canvas-renderer.ts
|
|
795
|
+
import sharp8 from "sharp";
|
|
796
|
+
|
|
797
|
+
// src/effects/frame.ts
|
|
798
|
+
import sharp from "sharp";
|
|
799
|
+
var TITLE_BAR_HEIGHT = 40;
|
|
800
|
+
var TRAFFIC_LIGHT_Y = 14;
|
|
801
|
+
var TRAFFIC_LIGHT_RADIUS = 6;
|
|
802
|
+
var TRAFFIC_LIGHTS_START_X = 16;
|
|
803
|
+
var TRAFFIC_LIGHT_GAP = 22;
|
|
804
|
+
var ADDRESS_BAR_HEIGHT = 24;
|
|
805
|
+
var ADDRESS_BAR_MARGIN = 70;
|
|
806
|
+
var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
|
|
807
|
+
var IPHONE_OUTER_RADIUS = 47;
|
|
808
|
+
var IPHONE_INNER_RADIUS = 39;
|
|
809
|
+
var IPHONE_ISLAND = { width: 120, height: 36 };
|
|
810
|
+
var IPHONE_HOME_BAR = { width: 134, height: 5 };
|
|
811
|
+
var IPAD_BEZEL = { sides: 20, top: 24, bottom: 24 };
|
|
812
|
+
var IPAD_OUTER_RADIUS = 18;
|
|
813
|
+
var IPAD_INNER_RADIUS = 12;
|
|
814
|
+
var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
|
|
815
|
+
var ANDROID_OUTER_RADIUS = 35;
|
|
816
|
+
var ANDROID_INNER_RADIUS = 30;
|
|
817
|
+
var ANDROID_CAMERA_RADIUS = 6;
|
|
818
|
+
function buildBrowserChromeSvg(width, darkMode) {
|
|
819
|
+
const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
|
|
820
|
+
const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
|
|
821
|
+
const addressBorder = darkMode ? "#444444" : "#d0d0d0";
|
|
822
|
+
const textColor = darkMode ? "#999999" : "#666666";
|
|
823
|
+
const trafficLights = [
|
|
824
|
+
{ cx: TRAFFIC_LIGHTS_START_X, fill: "#ff5f57" },
|
|
825
|
+
{ cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP, fill: "#febc2e" },
|
|
826
|
+
{ cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP * 2, fill: "#28c840" }
|
|
827
|
+
].map(
|
|
828
|
+
(light) => `<circle cx="${light.cx}" cy="${TRAFFIC_LIGHT_Y}" r="${TRAFFIC_LIGHT_RADIUS}" fill="${light.fill}"/>`
|
|
829
|
+
).join("\n ");
|
|
830
|
+
const addressBarWidth = width - ADDRESS_BAR_MARGIN * 2;
|
|
831
|
+
const addressBarX = ADDRESS_BAR_MARGIN;
|
|
832
|
+
const addressBarY = (TITLE_BAR_HEIGHT - ADDRESS_BAR_HEIGHT) / 2;
|
|
833
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${TITLE_BAR_HEIGHT}">
|
|
834
|
+
<rect width="${width}" height="${TITLE_BAR_HEIGHT}" fill="${bg}"/>
|
|
835
|
+
${trafficLights}
|
|
836
|
+
<rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${ADDRESS_BAR_HEIGHT}"
|
|
837
|
+
rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="1"/>
|
|
838
|
+
<text x="${width / 2}" y="${TRAFFIC_LIGHT_Y + 4}" text-anchor="middle"
|
|
839
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="12" fill="${textColor}">
|
|
840
|
+
localhost
|
|
841
|
+
</text>
|
|
842
|
+
</svg>`;
|
|
843
|
+
}
|
|
844
|
+
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
845
|
+
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
846
|
+
const islandColor = darkMode ? "#000000" : "#1a1a1a";
|
|
847
|
+
const homeBarColor = darkMode ? "#555555" : "#333333";
|
|
848
|
+
const islandX = (totalWidth - IPHONE_ISLAND.width) / 2;
|
|
849
|
+
const islandY = (IPHONE_BEZEL.top - IPHONE_ISLAND.height) / 2 + 4;
|
|
850
|
+
const homeBarX = (totalWidth - IPHONE_HOME_BAR.width) / 2;
|
|
851
|
+
const homeBarY = totalHeight - IPHONE_BEZEL.bottom / 2 - IPHONE_HOME_BAR.height / 2;
|
|
852
|
+
const screenX = IPHONE_BEZEL.sides;
|
|
853
|
+
const screenY = IPHONE_BEZEL.top;
|
|
854
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
855
|
+
<!-- Device body -->
|
|
856
|
+
<rect width="${totalWidth}" height="${totalHeight}"
|
|
857
|
+
rx="${IPHONE_OUTER_RADIUS}" ry="${IPHONE_OUTER_RADIUS}" fill="${bezelColor}"/>
|
|
858
|
+
<!-- Screen cutout (transparent) -->
|
|
859
|
+
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
860
|
+
rx="${IPHONE_INNER_RADIUS}" ry="${IPHONE_INNER_RADIUS}" fill="black"/>
|
|
861
|
+
<!-- Dynamic Island pill -->
|
|
862
|
+
<rect x="${islandX}" y="${islandY}" width="${IPHONE_ISLAND.width}" height="${IPHONE_ISLAND.height}"
|
|
863
|
+
rx="${IPHONE_ISLAND.height / 2}" ry="${IPHONE_ISLAND.height / 2}" fill="${islandColor}"/>
|
|
864
|
+
<!-- Home indicator bar -->
|
|
865
|
+
<rect x="${homeBarX}" y="${homeBarY}" width="${IPHONE_HOME_BAR.width}" height="${IPHONE_HOME_BAR.height}"
|
|
866
|
+
rx="${IPHONE_HOME_BAR.height / 2}" ry="${IPHONE_HOME_BAR.height / 2}" fill="${homeBarColor}"/>
|
|
867
|
+
</svg>`;
|
|
868
|
+
}
|
|
869
|
+
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
870
|
+
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
871
|
+
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
872
|
+
const screenX = IPAD_BEZEL.sides;
|
|
873
|
+
const screenY = IPAD_BEZEL.top;
|
|
874
|
+
const cameraCx = totalWidth / 2;
|
|
875
|
+
const cameraCy = IPAD_BEZEL.top / 2;
|
|
876
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
877
|
+
<!-- Device body -->
|
|
878
|
+
<rect width="${totalWidth}" height="${totalHeight}"
|
|
879
|
+
rx="${IPAD_OUTER_RADIUS}" ry="${IPAD_OUTER_RADIUS}" fill="${bezelColor}"/>
|
|
880
|
+
<!-- Screen cutout -->
|
|
881
|
+
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
882
|
+
rx="${IPAD_INNER_RADIUS}" ry="${IPAD_INNER_RADIUS}" fill="black"/>
|
|
883
|
+
<!-- Front camera dot -->
|
|
884
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
|
|
885
|
+
</svg>`;
|
|
886
|
+
}
|
|
887
|
+
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
888
|
+
const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
|
|
889
|
+
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
890
|
+
const screenX = ANDROID_BEZEL.sides;
|
|
891
|
+
const screenY = ANDROID_BEZEL.top;
|
|
892
|
+
const cameraCx = totalWidth / 2;
|
|
893
|
+
const cameraCy = ANDROID_BEZEL.top / 2;
|
|
894
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
895
|
+
<!-- Device body -->
|
|
896
|
+
<rect width="${totalWidth}" height="${totalHeight}"
|
|
897
|
+
rx="${ANDROID_OUTER_RADIUS}" ry="${ANDROID_OUTER_RADIUS}" fill="${bezelColor}"/>
|
|
898
|
+
<!-- Screen cutout -->
|
|
899
|
+
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
900
|
+
rx="${ANDROID_INNER_RADIUS}" ry="${ANDROID_INNER_RADIUS}" fill="black"/>
|
|
901
|
+
<!-- Punch-hole camera -->
|
|
902
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${ANDROID_CAMERA_RADIUS}" fill="${cameraColor}"/>
|
|
903
|
+
</svg>`;
|
|
904
|
+
}
|
|
905
|
+
function buildScreenMaskSvg(width, height, radius) {
|
|
906
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
907
|
+
<rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
|
|
908
|
+
</svg>`;
|
|
909
|
+
}
|
|
910
|
+
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
|
|
911
|
+
let bezel;
|
|
912
|
+
let innerRadius;
|
|
913
|
+
switch (deviceType) {
|
|
914
|
+
case "iphone":
|
|
915
|
+
bezel = IPHONE_BEZEL;
|
|
916
|
+
innerRadius = IPHONE_INNER_RADIUS;
|
|
917
|
+
break;
|
|
918
|
+
case "ipad":
|
|
919
|
+
bezel = IPAD_BEZEL;
|
|
920
|
+
innerRadius = IPAD_INNER_RADIUS;
|
|
921
|
+
break;
|
|
922
|
+
case "android":
|
|
923
|
+
bezel = ANDROID_BEZEL;
|
|
924
|
+
innerRadius = ANDROID_INNER_RADIUS;
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
const totalWidth = frameWidth + bezel.sides * 2;
|
|
928
|
+
const totalHeight = frameHeight + bezel.top + bezel.bottom;
|
|
929
|
+
let frameSvg;
|
|
930
|
+
switch (deviceType) {
|
|
931
|
+
case "iphone":
|
|
932
|
+
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
933
|
+
break;
|
|
934
|
+
case "ipad":
|
|
935
|
+
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
936
|
+
break;
|
|
937
|
+
case "android":
|
|
938
|
+
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
|
|
942
|
+
const maskedScreen = await sharp(frameBuffer).resize(frameWidth, frameHeight, { fit: "fill" }).composite([
|
|
943
|
+
{
|
|
944
|
+
input: Buffer.from(maskSvg),
|
|
945
|
+
blend: "dest-in"
|
|
946
|
+
}
|
|
947
|
+
]).png().toBuffer();
|
|
948
|
+
const canvas = await sharp({
|
|
949
|
+
create: {
|
|
950
|
+
width: totalWidth,
|
|
951
|
+
height: totalHeight,
|
|
952
|
+
channels: 4,
|
|
953
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
954
|
+
}
|
|
955
|
+
}).png().toBuffer();
|
|
956
|
+
return sharp(canvas).composite([
|
|
957
|
+
{ input: Buffer.from(frameSvg), left: 0, top: 0 },
|
|
958
|
+
{ input: maskedScreen, left: bezel.sides, top: bezel.top }
|
|
959
|
+
]).png().toBuffer();
|
|
960
|
+
}
|
|
961
|
+
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
962
|
+
if (!config.enabled || config.type === "none") return frameBuffer;
|
|
963
|
+
switch (config.type) {
|
|
964
|
+
case "browser": {
|
|
965
|
+
const totalHeight = frameHeight + TITLE_BAR_HEIGHT;
|
|
966
|
+
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode);
|
|
967
|
+
const chromeBuffer = Buffer.from(chromeSvg);
|
|
968
|
+
const canvas = await sharp({
|
|
969
|
+
create: {
|
|
970
|
+
width: frameWidth,
|
|
971
|
+
height: totalHeight,
|
|
972
|
+
channels: 4,
|
|
973
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
974
|
+
}
|
|
975
|
+
}).png().toBuffer();
|
|
976
|
+
return sharp(canvas).composite([
|
|
977
|
+
{ input: chromeBuffer, left: 0, top: 0 },
|
|
978
|
+
{ input: frameBuffer, left: 0, top: TITLE_BAR_HEIGHT }
|
|
979
|
+
]).png().toBuffer();
|
|
980
|
+
}
|
|
981
|
+
case "iphone":
|
|
982
|
+
case "ipad":
|
|
983
|
+
case "android":
|
|
984
|
+
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
|
|
985
|
+
default:
|
|
986
|
+
return frameBuffer;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// src/effects/cursor.ts
|
|
991
|
+
import sharp2 from "sharp";
|
|
992
|
+
function buildCursorSvg(size, color) {
|
|
993
|
+
const s = size;
|
|
994
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
|
|
995
|
+
<path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
|
|
996
|
+
fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
|
|
997
|
+
</svg>`;
|
|
998
|
+
}
|
|
999
|
+
function buildClickRippleSvg(radius, color, progress) {
|
|
1000
|
+
const currentRadius = radius * progress;
|
|
1001
|
+
const opacity = Math.max(0, 1 - progress);
|
|
1002
|
+
const size = Math.ceil(radius * 2 + 4);
|
|
1003
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
1004
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
|
|
1005
|
+
fill="none" stroke="${color}" stroke-width="2"
|
|
1006
|
+
opacity="${opacity.toFixed(3)}"/>
|
|
1007
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius * 0.6}"
|
|
1008
|
+
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
1009
|
+
</svg>`;
|
|
1010
|
+
}
|
|
1011
|
+
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
1012
|
+
if (!config.enabled) return frameBuffer;
|
|
1013
|
+
const cursorSvg = buildCursorSvg(config.size, config.color);
|
|
1014
|
+
const cursorBuffer = Buffer.from(cursorSvg);
|
|
1015
|
+
const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
|
|
1016
|
+
const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
|
|
1017
|
+
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
1018
|
+
}
|
|
1019
|
+
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
|
|
1020
|
+
if (!config.enabled || !config.clickEffect) return frameBuffer;
|
|
1021
|
+
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
1022
|
+
const rippleSvg = buildClickRippleSvg(
|
|
1023
|
+
config.clickRadius,
|
|
1024
|
+
config.clickColor,
|
|
1025
|
+
clampedProgress
|
|
1026
|
+
);
|
|
1027
|
+
const rippleBuffer = Buffer.from(rippleSvg);
|
|
1028
|
+
const rippleSize = Math.ceil(config.clickRadius * 2 + 4);
|
|
1029
|
+
const left = Math.max(
|
|
1030
|
+
0,
|
|
1031
|
+
Math.min(
|
|
1032
|
+
Math.round(position.x - rippleSize / 2),
|
|
1033
|
+
frameWidth - rippleSize
|
|
1034
|
+
)
|
|
1035
|
+
);
|
|
1036
|
+
const top = Math.max(
|
|
1037
|
+
0,
|
|
1038
|
+
Math.min(
|
|
1039
|
+
Math.round(position.y - rippleSize / 2),
|
|
1040
|
+
frameHeight - rippleSize
|
|
1041
|
+
)
|
|
1042
|
+
);
|
|
1043
|
+
return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
|
|
1044
|
+
}
|
|
1045
|
+
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
1046
|
+
if (!config.enabled || !config.highlight) return frameBuffer;
|
|
1047
|
+
const r = config.highlightRadius;
|
|
1048
|
+
const size = Math.ceil(r * 2 + 4);
|
|
1049
|
+
const cx = size / 2;
|
|
1050
|
+
const cy = size / 2;
|
|
1051
|
+
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
1052
|
+
<defs>
|
|
1053
|
+
<radialGradient id="glow">
|
|
1054
|
+
<stop offset="0%" stop-color="${config.highlightColor}" />
|
|
1055
|
+
<stop offset="70%" stop-color="${config.highlightColor}" />
|
|
1056
|
+
<stop offset="100%" stop-color="transparent" />
|
|
1057
|
+
</radialGradient>
|
|
1058
|
+
</defs>
|
|
1059
|
+
<circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
|
|
1060
|
+
</svg>`;
|
|
1061
|
+
const left = Math.max(0, Math.min(Math.round(position.x - cx), frameWidth - size));
|
|
1062
|
+
const top = Math.max(0, Math.min(Math.round(position.y - cy), frameHeight - size));
|
|
1063
|
+
return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
|
|
1064
|
+
}
|
|
1065
|
+
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
|
|
1066
|
+
if (!config.enabled || !config.trail || positions.length < 2) {
|
|
1067
|
+
return frameBuffer;
|
|
1068
|
+
}
|
|
1069
|
+
const segments = [];
|
|
1070
|
+
for (let i = 1; i < positions.length; i++) {
|
|
1071
|
+
const opacity = i / positions.length * 0.6;
|
|
1072
|
+
const strokeWidth = 1 + i / positions.length * 2;
|
|
1073
|
+
const p1 = positions[i - 1];
|
|
1074
|
+
const p2 = positions[i];
|
|
1075
|
+
segments.push(
|
|
1076
|
+
`<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
|
|
1077
|
+
stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
|
|
1078
|
+
stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1082
|
+
${segments.join("\n ")}
|
|
1083
|
+
</svg>`;
|
|
1084
|
+
return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/effects/zoom.ts
|
|
1088
|
+
import sharp3 from "sharp";
|
|
1089
|
+
async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight) {
|
|
1090
|
+
if (scale <= 1) return frameBuffer;
|
|
1091
|
+
const cropWidth = Math.round(frameWidth / scale);
|
|
1092
|
+
const cropHeight = Math.round(frameHeight / scale);
|
|
1093
|
+
let left = Math.round(focusPoint.x - cropWidth / 2);
|
|
1094
|
+
let top = Math.round(focusPoint.y - cropHeight / 2);
|
|
1095
|
+
left = Math.max(0, Math.min(left, frameWidth - cropWidth));
|
|
1096
|
+
top = Math.max(0, Math.min(top, frameHeight - cropHeight));
|
|
1097
|
+
return sharp3(frameBuffer).extract({ left, top, width: cropWidth, height: cropHeight }).resize(frameWidth, frameHeight, { kernel: sharp3.kernel.lanczos3 }).png().toBuffer();
|
|
1098
|
+
}
|
|
1099
|
+
function calculateAdaptiveZoom(frames, currentIndex, maxScale, transitionFrames) {
|
|
1100
|
+
if (maxScale <= 1) return 1;
|
|
1101
|
+
let minDistance = Infinity;
|
|
1102
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1103
|
+
if (frames[i].clickPosition) {
|
|
1104
|
+
const distance = Math.abs(i - currentIndex);
|
|
1105
|
+
minDistance = Math.min(minDistance, distance);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (minDistance === Infinity) return 1;
|
|
1109
|
+
if (minDistance <= transitionFrames) {
|
|
1110
|
+
const t = 1 - minDistance / transitionFrames;
|
|
1111
|
+
const eased = easeInOutCubic2(t);
|
|
1112
|
+
return 1 + (maxScale - 1) * eased;
|
|
1113
|
+
}
|
|
1114
|
+
return 1;
|
|
1115
|
+
}
|
|
1116
|
+
function easeInOutCubic2(t) {
|
|
1117
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// src/effects/background.ts
|
|
1121
|
+
import sharp4 from "sharp";
|
|
1122
|
+
function parseGradient(value) {
|
|
1123
|
+
const match = value.match(
|
|
1124
|
+
/linear-gradient\(\s*([\d.]+)deg\s*,\s*(.+)\s*\)/
|
|
1125
|
+
);
|
|
1126
|
+
if (!match) {
|
|
1127
|
+
return { angle: 135, stops: [{ color: value, offset: "100%" }] };
|
|
1128
|
+
}
|
|
1129
|
+
const angle = parseFloat(match[1]);
|
|
1130
|
+
const stopsRaw = match[2].split(",").map((s) => s.trim());
|
|
1131
|
+
const stops = stopsRaw.map((stop) => {
|
|
1132
|
+
const parts = stop.trim().split(/\s+/);
|
|
1133
|
+
return {
|
|
1134
|
+
color: parts[0],
|
|
1135
|
+
offset: parts[1] ?? "0%"
|
|
1136
|
+
};
|
|
1137
|
+
});
|
|
1138
|
+
return { angle, stops };
|
|
1139
|
+
}
|
|
1140
|
+
function angleToGradientCoords(angle) {
|
|
1141
|
+
const rad = (angle - 90) * Math.PI / 180;
|
|
1142
|
+
const x1 = 50 - Math.cos(rad) * 50;
|
|
1143
|
+
const y1 = 50 - Math.sin(rad) * 50;
|
|
1144
|
+
const x2 = 50 + Math.cos(rad) * 50;
|
|
1145
|
+
const y2 = 50 + Math.sin(rad) * 50;
|
|
1146
|
+
return {
|
|
1147
|
+
x1: `${x1.toFixed(1)}%`,
|
|
1148
|
+
y1: `${y1.toFixed(1)}%`,
|
|
1149
|
+
x2: `${x2.toFixed(1)}%`,
|
|
1150
|
+
y2: `${y2.toFixed(1)}%`
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
function buildBackgroundSvg(config, width, height) {
|
|
1154
|
+
if (config.type === "solid") {
|
|
1155
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
1156
|
+
<rect width="${width}" height="${height}" fill="${config.value}"/>
|
|
1157
|
+
</svg>`;
|
|
1158
|
+
}
|
|
1159
|
+
const { angle, stops } = parseGradient(config.value);
|
|
1160
|
+
const coords = angleToGradientCoords(angle);
|
|
1161
|
+
const stopElements = stops.map((s) => `<stop offset="${s.offset}" stop-color="${s.color}"/>`).join("\n ");
|
|
1162
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
1163
|
+
<defs>
|
|
1164
|
+
<linearGradient id="bg" x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}">
|
|
1165
|
+
${stopElements}
|
|
1166
|
+
</linearGradient>
|
|
1167
|
+
</defs>
|
|
1168
|
+
<rect width="${width}" height="${height}" fill="url(#bg)"/>
|
|
1169
|
+
</svg>`;
|
|
1170
|
+
}
|
|
1171
|
+
async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
|
|
1172
|
+
const padding = config.padding;
|
|
1173
|
+
const contentWidth = outputWidth - padding * 2;
|
|
1174
|
+
const contentHeight = outputHeight - padding * 2;
|
|
1175
|
+
if (contentWidth <= 0 || contentHeight <= 0) {
|
|
1176
|
+
return frameBuffer;
|
|
1177
|
+
}
|
|
1178
|
+
const resizedFrame = await sharp4(frameBuffer).resize(contentWidth, contentHeight, { fit: "fill" }).png().toBuffer();
|
|
1179
|
+
const bgSvg = buildBackgroundSvg(config, outputWidth, outputHeight);
|
|
1180
|
+
const bgBuffer = Buffer.from(bgSvg);
|
|
1181
|
+
const radius = config.borderRadius;
|
|
1182
|
+
const roundedMask = Buffer.from(
|
|
1183
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${contentWidth}" height="${contentHeight}">
|
|
1184
|
+
<rect width="${contentWidth}" height="${contentHeight}" rx="${radius}" ry="${radius}" fill="#ffffff"/>
|
|
1185
|
+
</svg>`
|
|
1186
|
+
);
|
|
1187
|
+
const maskedFrame = await sharp4(resizedFrame).composite([
|
|
1188
|
+
{
|
|
1189
|
+
input: roundedMask,
|
|
1190
|
+
blend: "dest-in"
|
|
1191
|
+
}
|
|
1192
|
+
]).png().toBuffer();
|
|
1193
|
+
const composites = [];
|
|
1194
|
+
if (config.shadow) {
|
|
1195
|
+
const shadowSvg = Buffer.from(
|
|
1196
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${outputWidth}" height="${outputHeight}">
|
|
1197
|
+
<defs>
|
|
1198
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
1199
|
+
<feDropShadow dx="0" dy="4" stdDeviation="16" flood-color="rgba(0,0,0,0.3)"/>
|
|
1200
|
+
</filter>
|
|
1201
|
+
</defs>
|
|
1202
|
+
<rect x="${padding}" y="${padding}" width="${contentWidth}" height="${contentHeight}"
|
|
1203
|
+
rx="${radius}" ry="${radius}" fill="rgba(0,0,0,0.15)" filter="url(#shadow)"/>
|
|
1204
|
+
</svg>`
|
|
1205
|
+
);
|
|
1206
|
+
composites.push({ input: shadowSvg, left: 0, top: 0 });
|
|
1207
|
+
}
|
|
1208
|
+
composites.push({ input: maskedFrame, left: padding, top: padding });
|
|
1209
|
+
return sharp4(bgBuffer).resize(outputWidth, outputHeight).composite(composites).png().toBuffer();
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/effects/keystroke.ts
|
|
1213
|
+
import sharp5 from "sharp";
|
|
1214
|
+
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
|
|
1215
|
+
if (!config.enabled || keystrokes.length === 0) return frameBuffer;
|
|
1216
|
+
const recentKeys = keystrokes.filter(
|
|
1217
|
+
(k) => frameTimestamp - k.timestamp < config.fadeAfter
|
|
1218
|
+
);
|
|
1219
|
+
if (recentKeys.length === 0) return frameBuffer;
|
|
1220
|
+
const displayText = recentKeys.map((k) => k.key).join("");
|
|
1221
|
+
if (displayText.length === 0) return frameBuffer;
|
|
1222
|
+
const charWidth = config.fontSize * 0.62;
|
|
1223
|
+
const textWidth = Math.ceil(displayText.length * charWidth);
|
|
1224
|
+
const hudPadH = config.padding * 2;
|
|
1225
|
+
const hudPadV = config.padding * 1.5;
|
|
1226
|
+
const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
|
|
1227
|
+
const hudHeight = Math.ceil(config.fontSize + hudPadV * 2);
|
|
1228
|
+
const newest = recentKeys[recentKeys.length - 1];
|
|
1229
|
+
const age = frameTimestamp - newest.timestamp;
|
|
1230
|
+
const fadeStart = config.fadeAfter * 0.6;
|
|
1231
|
+
const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
|
|
1232
|
+
if (opacity <= 0) return frameBuffer;
|
|
1233
|
+
let hudX;
|
|
1234
|
+
const hudY = frameHeight - hudHeight - 30;
|
|
1235
|
+
switch (config.position) {
|
|
1236
|
+
case "bottom-left":
|
|
1237
|
+
hudX = 30;
|
|
1238
|
+
break;
|
|
1239
|
+
case "bottom-right":
|
|
1240
|
+
hudX = frameWidth - hudWidth - 30;
|
|
1241
|
+
break;
|
|
1242
|
+
case "bottom-center":
|
|
1243
|
+
default:
|
|
1244
|
+
hudX = Math.round((frameWidth - hudWidth) / 2);
|
|
1245
|
+
break;
|
|
1246
|
+
}
|
|
1247
|
+
const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
|
|
1248
|
+
const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
|
|
1249
|
+
const escaped = truncated.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1250
|
+
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1251
|
+
<rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
|
|
1252
|
+
rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
|
|
1253
|
+
<text x="${hudX + hudPadH}" y="${hudY + hudPadV + config.fontSize * 0.75}"
|
|
1254
|
+
font-family="monospace, Menlo, Consolas" font-size="${config.fontSize}"
|
|
1255
|
+
fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
|
|
1256
|
+
</svg>`;
|
|
1257
|
+
return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/effects/transition.ts
|
|
1261
|
+
import sharp6 from "sharp";
|
|
1262
|
+
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
|
|
1263
|
+
const t = Math.max(0, Math.min(1, progress));
|
|
1264
|
+
if (t <= 0) return fromBuffer;
|
|
1265
|
+
if (t >= 1) return toBuffer;
|
|
1266
|
+
const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1267
|
+
const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1268
|
+
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
1269
|
+
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
1270
|
+
pixels[i] = Math.round(
|
|
1271
|
+
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
return sharp6(pixels, {
|
|
1275
|
+
raw: {
|
|
1276
|
+
width: fromRaw.info.width,
|
|
1277
|
+
height: fromRaw.info.height,
|
|
1278
|
+
channels: 4
|
|
1279
|
+
}
|
|
1280
|
+
}).png().toBuffer();
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/effects/watermark.ts
|
|
1284
|
+
import sharp7 from "sharp";
|
|
1285
|
+
async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
1286
|
+
if (!config.enabled || !config.text) return frameBuffer;
|
|
1287
|
+
const charWidth = config.fontSize * 0.62;
|
|
1288
|
+
const textWidth = Math.ceil(config.text.length * charWidth);
|
|
1289
|
+
const margin = 16;
|
|
1290
|
+
let x;
|
|
1291
|
+
let y;
|
|
1292
|
+
switch (config.position) {
|
|
1293
|
+
case "top-left":
|
|
1294
|
+
x = margin;
|
|
1295
|
+
y = margin + config.fontSize;
|
|
1296
|
+
break;
|
|
1297
|
+
case "top-right":
|
|
1298
|
+
x = frameWidth - textWidth - margin;
|
|
1299
|
+
y = margin + config.fontSize;
|
|
1300
|
+
break;
|
|
1301
|
+
case "bottom-left":
|
|
1302
|
+
x = margin;
|
|
1303
|
+
y = frameHeight - margin;
|
|
1304
|
+
break;
|
|
1305
|
+
case "bottom-right":
|
|
1306
|
+
default:
|
|
1307
|
+
x = frameWidth - textWidth - margin;
|
|
1308
|
+
y = frameHeight - margin;
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
const escaped = config.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1312
|
+
const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1313
|
+
<text x="${x}" y="${y}"
|
|
1314
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
|
|
1315
|
+
font-weight="600" fill="${config.color}"
|
|
1316
|
+
opacity="${config.opacity.toFixed(3)}">${escaped}</text>
|
|
1317
|
+
</svg>`;
|
|
1318
|
+
return sharp7(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// src/compose/canvas-renderer.ts
|
|
1322
|
+
function getFrameOffset(config) {
|
|
1323
|
+
if (!config.enabled) return { left: 0, top: 0 };
|
|
1324
|
+
switch (config.type) {
|
|
1325
|
+
case "browser":
|
|
1326
|
+
return { left: 0, top: 40 };
|
|
1327
|
+
case "iphone":
|
|
1328
|
+
return { left: 12, top: 50 };
|
|
1329
|
+
case "ipad":
|
|
1330
|
+
return { left: 20, top: 24 };
|
|
1331
|
+
case "android":
|
|
1332
|
+
return { left: 8, top: 32 };
|
|
1333
|
+
default:
|
|
1334
|
+
return { left: 0, top: 0 };
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
var CanvasRenderer = class {
|
|
1338
|
+
constructor(effects, output, steps) {
|
|
1339
|
+
this.effects = effects;
|
|
1340
|
+
this.output = output;
|
|
1341
|
+
this.steps = steps ?? [];
|
|
1342
|
+
}
|
|
1343
|
+
steps;
|
|
1344
|
+
/**
|
|
1345
|
+
* Apply the full effects pipeline to a single captured frame.
|
|
1346
|
+
*
|
|
1347
|
+
* Pipeline order:
|
|
1348
|
+
* 1. Device frame (browser chrome / mobile mockup)
|
|
1349
|
+
* 2. Cursor highlight (Screen Studio glow)
|
|
1350
|
+
* 3. Cursor trail
|
|
1351
|
+
* 4. Cursor rendering
|
|
1352
|
+
* 5. Click ripple effect (animated progress)
|
|
1353
|
+
* 6. Keystroke HUD
|
|
1354
|
+
* 7. Zoom (adaptive, cursor-following)
|
|
1355
|
+
* 8. Background (padding, gradient, rounded corners)
|
|
1356
|
+
* 9. Watermark overlay
|
|
1357
|
+
* 10. Final resize
|
|
1358
|
+
*/
|
|
1359
|
+
async composeFrame(frame, context) {
|
|
1360
|
+
let buffer = frame.screenshot;
|
|
1361
|
+
let width = frame.viewport.width;
|
|
1362
|
+
let height = frame.viewport.height;
|
|
1363
|
+
const ctx = {
|
|
1364
|
+
zoomScale: context?.zoomScale ?? 1,
|
|
1365
|
+
clickProgress: context?.clickProgress ?? null,
|
|
1366
|
+
cursorTrail: context?.cursorTrail ?? []
|
|
1367
|
+
};
|
|
1368
|
+
if (this.effects.deviceFrame.enabled) {
|
|
1369
|
+
buffer = await applyDeviceFrame(
|
|
1370
|
+
buffer,
|
|
1371
|
+
this.effects.deviceFrame,
|
|
1372
|
+
width,
|
|
1373
|
+
height
|
|
1374
|
+
);
|
|
1375
|
+
const meta = await sharp8(buffer).metadata();
|
|
1376
|
+
width = meta.width ?? width;
|
|
1377
|
+
height = meta.height ?? height;
|
|
1378
|
+
}
|
|
1379
|
+
if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
|
|
1380
|
+
buffer = await renderCursorHighlight(
|
|
1381
|
+
buffer,
|
|
1382
|
+
frame.cursorPosition,
|
|
1383
|
+
this.effects.cursor,
|
|
1384
|
+
width,
|
|
1385
|
+
height
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1389
|
+
buffer = await renderCursorTrail(
|
|
1390
|
+
buffer,
|
|
1391
|
+
ctx.cursorTrail,
|
|
1392
|
+
this.effects.cursor,
|
|
1393
|
+
width,
|
|
1394
|
+
height
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
if (this.effects.cursor.enabled && frame.cursorPosition) {
|
|
1398
|
+
buffer = await renderCursor(
|
|
1399
|
+
buffer,
|
|
1400
|
+
frame.cursorPosition,
|
|
1401
|
+
this.effects.cursor,
|
|
1402
|
+
width,
|
|
1403
|
+
height
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
|
|
1407
|
+
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1408
|
+
buffer = await renderClickEffect(
|
|
1409
|
+
buffer,
|
|
1410
|
+
frame.clickPosition,
|
|
1411
|
+
this.effects.cursor,
|
|
1412
|
+
progress,
|
|
1413
|
+
width,
|
|
1414
|
+
height
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
if (this.effects.keystroke.enabled && frame.keystrokes) {
|
|
1418
|
+
buffer = await renderKeystrokeHud(
|
|
1419
|
+
buffer,
|
|
1420
|
+
frame.keystrokes,
|
|
1421
|
+
frame.timestamp,
|
|
1422
|
+
this.effects.keystroke,
|
|
1423
|
+
width,
|
|
1424
|
+
height
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
const scale = ctx.zoomScale;
|
|
1428
|
+
if (this.effects.zoom.enabled && scale > 1) {
|
|
1429
|
+
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
|
|
1430
|
+
const offset = getFrameOffset(this.effects.deviceFrame);
|
|
1431
|
+
const focusPoint = {
|
|
1432
|
+
x: rawFocus.x + offset.left,
|
|
1433
|
+
y: rawFocus.y + offset.top
|
|
1434
|
+
};
|
|
1435
|
+
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1436
|
+
}
|
|
1437
|
+
buffer = await applyBackground(
|
|
1438
|
+
buffer,
|
|
1439
|
+
this.effects.background,
|
|
1440
|
+
this.output.width,
|
|
1441
|
+
this.output.height
|
|
1442
|
+
);
|
|
1443
|
+
if (this.effects.watermark.enabled) {
|
|
1444
|
+
buffer = await renderWatermark(
|
|
1445
|
+
buffer,
|
|
1446
|
+
this.effects.watermark,
|
|
1447
|
+
this.output.width,
|
|
1448
|
+
this.output.height
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
|
|
1452
|
+
return {
|
|
1453
|
+
index: frame.index,
|
|
1454
|
+
buffer,
|
|
1455
|
+
timestamp: frame.timestamp
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Process an entire sequence of captured frames through the effects pipeline.
|
|
1460
|
+
*
|
|
1461
|
+
* Multi-pass approach:
|
|
1462
|
+
* Pass 1: Speed ramping (adjust frame set).
|
|
1463
|
+
* Pass 2: Calculate per-frame contexts (zoom, click, trail).
|
|
1464
|
+
* Pass 3: Render each frame with effects.
|
|
1465
|
+
* Pass 4: Apply scene transitions at step boundaries.
|
|
1466
|
+
*/
|
|
1467
|
+
async composeAll(frames) {
|
|
1468
|
+
if (frames.length === 0) return [];
|
|
1469
|
+
let processFrames = frames;
|
|
1470
|
+
if (this.effects.speedRamp.enabled) {
|
|
1471
|
+
processFrames = this.applySpeedRamp(frames);
|
|
1472
|
+
}
|
|
1473
|
+
const contexts = this.calculateFrameContexts(processFrames);
|
|
1474
|
+
const composed = [];
|
|
1475
|
+
for (let i = 0; i < processFrames.length; i++) {
|
|
1476
|
+
const result = await this.composeFrame(processFrames[i], contexts[i]);
|
|
1477
|
+
composed.push(result);
|
|
1478
|
+
}
|
|
1479
|
+
if (this.steps.length > 0) {
|
|
1480
|
+
await this.applyTransitions(composed, processFrames);
|
|
1481
|
+
}
|
|
1482
|
+
return composed;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Calculate per-frame rendering context (zoom, click progress, cursor trail, tilt).
|
|
1486
|
+
*/
|
|
1487
|
+
calculateFrameContexts(frames) {
|
|
1488
|
+
const contexts = [];
|
|
1489
|
+
const transitionFrames = Math.round(
|
|
1490
|
+
this.output.fps * (this.effects.zoom.duration / 1e3)
|
|
1491
|
+
);
|
|
1492
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1493
|
+
const frame = frames[i];
|
|
1494
|
+
let zoomScale = 1;
|
|
1495
|
+
if (this.effects.zoom.enabled) {
|
|
1496
|
+
zoomScale = calculateAdaptiveZoom(
|
|
1497
|
+
frames,
|
|
1498
|
+
i,
|
|
1499
|
+
this.effects.zoom.scale,
|
|
1500
|
+
transitionFrames
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
|
|
1504
|
+
const trailLength = this.effects.cursor.trailLength;
|
|
1505
|
+
const trail = [];
|
|
1506
|
+
for (let j = Math.max(0, i - trailLength); j <= i; j++) {
|
|
1507
|
+
if (frames[j].cursorPosition) {
|
|
1508
|
+
trail.push(frames[j].cursorPosition);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
contexts.push({ zoomScale, clickProgress, cursorTrail: trail });
|
|
1512
|
+
}
|
|
1513
|
+
return contexts;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Apply speed ramping: slow down near actions, speed up during idle.
|
|
1517
|
+
* Returns a new frame array with frames duplicated or skipped.
|
|
1518
|
+
*/
|
|
1519
|
+
applySpeedRamp(frames) {
|
|
1520
|
+
const config = this.effects.speedRamp;
|
|
1521
|
+
if (!config.enabled) return frames;
|
|
1522
|
+
const proximityRadius = Math.round(this.output.fps * 1);
|
|
1523
|
+
const actionIndices = /* @__PURE__ */ new Set();
|
|
1524
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1525
|
+
if (frames[i].clickPosition) {
|
|
1526
|
+
for (let j = Math.max(0, i - proximityRadius); j <= Math.min(frames.length - 1, i + proximityRadius); j++) {
|
|
1527
|
+
actionIndices.add(j);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
const result = [];
|
|
1532
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1533
|
+
const isAction = actionIndices.has(i);
|
|
1534
|
+
if (isAction) {
|
|
1535
|
+
const copies = Math.max(1, Math.round(1 / config.actionSpeed));
|
|
1536
|
+
for (let c = 0; c < copies; c++) {
|
|
1537
|
+
result.push({ ...frames[i], index: result.length });
|
|
1538
|
+
}
|
|
1539
|
+
} else {
|
|
1540
|
+
const skipRate = Math.max(1, Math.round(config.idleSpeed));
|
|
1541
|
+
if (i % skipRate === 0) {
|
|
1542
|
+
result.push({ ...frames[i], index: result.length });
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return result;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Apply crossfade transitions at step boundaries where configured.
|
|
1550
|
+
* Modifies the composed array in-place.
|
|
1551
|
+
*/
|
|
1552
|
+
async applyTransitions(composed, frames) {
|
|
1553
|
+
const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
|
|
1554
|
+
const boundaries = [];
|
|
1555
|
+
for (let i = 1; i < frames.length; i++) {
|
|
1556
|
+
if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
|
|
1557
|
+
const stepIdx = frames[i].stepIndex;
|
|
1558
|
+
const step = this.steps[stepIdx];
|
|
1559
|
+
if (step && step.transition === "fade") {
|
|
1560
|
+
boundaries.push({ index: i, stepIndex: stepIdx });
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
for (const boundary of boundaries) {
|
|
1565
|
+
const startIdx = Math.max(0, boundary.index - Math.floor(transitionFrames / 2));
|
|
1566
|
+
const endIdx = Math.min(composed.length - 1, boundary.index + Math.ceil(transitionFrames / 2));
|
|
1567
|
+
const range = endIdx - startIdx;
|
|
1568
|
+
if (range < 2) continue;
|
|
1569
|
+
const fromBuffer = composed[startIdx].buffer;
|
|
1570
|
+
const toBuffer = composed[endIdx].buffer;
|
|
1571
|
+
const width = this.output.width;
|
|
1572
|
+
const height = this.output.height;
|
|
1573
|
+
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
1574
|
+
const progress = (i - startIdx) / range;
|
|
1575
|
+
composed[i].buffer = await applyCrossfade(
|
|
1576
|
+
fromBuffer,
|
|
1577
|
+
toBuffer,
|
|
1578
|
+
progress,
|
|
1579
|
+
width,
|
|
1580
|
+
height
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
// src/compose/video-encoder.ts
|
|
1588
|
+
import gifenc from "gifenc";
|
|
1589
|
+
import sharp9 from "sharp";
|
|
1590
|
+
import { writeFile, mkdir, readFile as readFile2, rm, mkdtemp } from "fs/promises";
|
|
1591
|
+
import { join } from "path";
|
|
1592
|
+
import { tmpdir } from "os";
|
|
1593
|
+
import { spawn } from "child_process";
|
|
1594
|
+
var { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
1595
|
+
async function encodeGif(frames, config) {
|
|
1596
|
+
if (frames.length === 0) {
|
|
1597
|
+
throw new Error("Cannot encode GIF: no frames provided");
|
|
1598
|
+
}
|
|
1599
|
+
const width = config.width;
|
|
1600
|
+
const height = config.height;
|
|
1601
|
+
const gif = GIFEncoder();
|
|
1602
|
+
const delay = Math.round(1e3 / config.fps);
|
|
1603
|
+
for (const frame of frames) {
|
|
1604
|
+
const { data, info } = await sharp9(frame.buffer).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1605
|
+
const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1606
|
+
const palette = quantize(rgba, 256);
|
|
1607
|
+
const indexed = applyPalette(rgba, palette);
|
|
1608
|
+
gif.writeFrame(indexed, width, height, {
|
|
1609
|
+
palette,
|
|
1610
|
+
delay
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
gif.finish();
|
|
1614
|
+
return Buffer.from(gif.bytes());
|
|
1615
|
+
}
|
|
1616
|
+
async function encodeMp4(frames, config) {
|
|
1617
|
+
if (frames.length === 0) {
|
|
1618
|
+
throw new Error("Cannot encode MP4: no frames provided");
|
|
1619
|
+
}
|
|
1620
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "clipwise-"));
|
|
1621
|
+
try {
|
|
1622
|
+
const padLength = String(frames.length).length;
|
|
1623
|
+
for (const frame of frames) {
|
|
1624
|
+
const paddedIndex = String(frame.index).padStart(padLength, "0");
|
|
1625
|
+
const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
|
|
1626
|
+
await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
|
|
1627
|
+
}
|
|
1628
|
+
const outputPath = join(tmpDir, "output.mp4");
|
|
1629
|
+
const crf = Math.round(51 - config.quality / 100 * 51);
|
|
1630
|
+
await runFfmpeg([
|
|
1631
|
+
"-y",
|
|
1632
|
+
"-framerate",
|
|
1633
|
+
String(config.fps),
|
|
1634
|
+
"-i",
|
|
1635
|
+
join(tmpDir, `frame-%0${padLength}d.png`),
|
|
1636
|
+
"-c:v",
|
|
1637
|
+
"libx264",
|
|
1638
|
+
"-pix_fmt",
|
|
1639
|
+
"yuv420p",
|
|
1640
|
+
"-crf",
|
|
1641
|
+
String(crf),
|
|
1642
|
+
"-preset",
|
|
1643
|
+
"slow",
|
|
1644
|
+
"-tune",
|
|
1645
|
+
"animation",
|
|
1646
|
+
"-movflags",
|
|
1647
|
+
"+faststart",
|
|
1648
|
+
outputPath
|
|
1649
|
+
]);
|
|
1650
|
+
return await readFile2(outputPath);
|
|
1651
|
+
} finally {
|
|
1652
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
function runFfmpeg(args) {
|
|
1657
|
+
return new Promise((resolve2, reject) => {
|
|
1658
|
+
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1659
|
+
let stderr = "";
|
|
1660
|
+
proc.stderr.on("data", (data) => {
|
|
1661
|
+
stderr += data.toString();
|
|
1662
|
+
});
|
|
1663
|
+
proc.on("close", (code) => {
|
|
1664
|
+
if (code === 0) {
|
|
1665
|
+
resolve2();
|
|
1666
|
+
} else {
|
|
1667
|
+
reject(
|
|
1668
|
+
new Error(
|
|
1669
|
+
`FFmpeg encoding failed (exit code ${code}). Make sure ffmpeg is installed: brew install ffmpeg
|
|
1670
|
+
` + stderr.slice(-500)
|
|
1671
|
+
)
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
proc.on("error", (err) => {
|
|
1676
|
+
if (err.code === "ENOENT") {
|
|
1677
|
+
reject(
|
|
1678
|
+
new Error(
|
|
1679
|
+
"ffmpeg not found. Install it to encode MP4:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: choco install ffmpeg"
|
|
1680
|
+
)
|
|
1681
|
+
);
|
|
1682
|
+
} else {
|
|
1683
|
+
reject(err);
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
async function savePngSequence(frames, config) {
|
|
1689
|
+
if (frames.length === 0) {
|
|
1690
|
+
throw new Error("Cannot save PNG sequence: no frames provided");
|
|
1691
|
+
}
|
|
1692
|
+
const outputDir = join(config.outputDir, config.filename);
|
|
1693
|
+
await mkdir(outputDir, { recursive: true });
|
|
1694
|
+
const paths = [];
|
|
1695
|
+
const padLength = String(frames.length).length;
|
|
1696
|
+
for (const frame of frames) {
|
|
1697
|
+
const paddedIndex = String(frame.index).padStart(padLength, "0");
|
|
1698
|
+
const filename = `frame-${paddedIndex}.png`;
|
|
1699
|
+
const filePath = join(outputDir, filename);
|
|
1700
|
+
const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
|
|
1701
|
+
await writeFile(filePath, pngBuffer);
|
|
1702
|
+
paths.push(filePath);
|
|
1703
|
+
}
|
|
1704
|
+
return paths;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// src/cli/index.ts
|
|
1708
|
+
import { writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
|
|
1709
|
+
import { join as join2, resolve, dirname } from "path";
|
|
1710
|
+
import { pathToFileURL } from "url";
|
|
1711
|
+
var program = new Command();
|
|
1712
|
+
program.name("clipwise").description(
|
|
1713
|
+
"Playwright-based cinematic screen recorder for product demos"
|
|
1714
|
+
).version("0.1.0");
|
|
1715
|
+
program.command("record").description("Record a demo from a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").option("-o, --output <dir>", "Output directory", "./output").option(
|
|
1716
|
+
"-f, --format <format>",
|
|
1717
|
+
"Output format (gif|mp4|png-sequence)",
|
|
1718
|
+
"gif"
|
|
1719
|
+
).option("--no-effects", "Disable all effects").action(async (scenarioPath, options) => {
|
|
1720
|
+
const spinner = ora();
|
|
1721
|
+
try {
|
|
1722
|
+
spinner.start("Loading scenario...");
|
|
1723
|
+
const scenario = await loadScenario(scenarioPath);
|
|
1724
|
+
const scenarioDir = dirname(resolve(scenarioPath));
|
|
1725
|
+
for (const step of scenario.steps) {
|
|
1726
|
+
for (const action of step.actions) {
|
|
1727
|
+
if (action.action === "navigate") {
|
|
1728
|
+
const url = action.url;
|
|
1729
|
+
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("file://")) {
|
|
1730
|
+
action.url = pathToFileURL(resolve(scenarioDir, url)).href;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
scenario.output.outputDir = options.output;
|
|
1736
|
+
if (options.format) {
|
|
1737
|
+
scenario.output.format = options.format;
|
|
1738
|
+
}
|
|
1739
|
+
spinner.succeed(`Scenario loaded: ${chalk.bold(scenario.name)}`);
|
|
1740
|
+
spinner.start("Validating scenario...");
|
|
1741
|
+
const validation = validateScenario(scenario);
|
|
1742
|
+
if (!validation.valid) {
|
|
1743
|
+
spinner.fail("Scenario validation failed:");
|
|
1744
|
+
for (const error of validation.errors) {
|
|
1745
|
+
console.error(chalk.red(` \u2717 ${error}`));
|
|
1746
|
+
}
|
|
1747
|
+
process.exit(1);
|
|
1748
|
+
}
|
|
1749
|
+
for (const warning of validation.warnings) {
|
|
1750
|
+
console.warn(chalk.yellow(` \u26A0 ${warning}`));
|
|
1751
|
+
}
|
|
1752
|
+
spinner.succeed("Scenario is valid");
|
|
1753
|
+
spinner.start("Checking browser...");
|
|
1754
|
+
try {
|
|
1755
|
+
const { chromium: chromium2 } = await import("playwright");
|
|
1756
|
+
const testBrowser = await chromium2.launch({ headless: true });
|
|
1757
|
+
await testBrowser.close();
|
|
1758
|
+
spinner.succeed("Browser ready");
|
|
1759
|
+
} catch {
|
|
1760
|
+
spinner.fail("Chromium not found");
|
|
1761
|
+
console.log(chalk.yellow("\nInstalling Chromium (one-time setup)...\n"));
|
|
1762
|
+
const { execSync } = await import("child_process");
|
|
1763
|
+
try {
|
|
1764
|
+
execSync("npx playwright install chromium", { stdio: "inherit" });
|
|
1765
|
+
console.log(chalk.green("\nChromium installed successfully!\n"));
|
|
1766
|
+
} catch {
|
|
1767
|
+
console.error(chalk.red("\nFailed to install Chromium. Run manually:\n npx playwright install chromium\n"));
|
|
1768
|
+
process.exit(1);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
spinner.start(
|
|
1772
|
+
`Recording ${scenario.steps.length} steps...`
|
|
1773
|
+
);
|
|
1774
|
+
const recorder = new ClipwiseRecorder();
|
|
1775
|
+
const session = await recorder.record(scenario);
|
|
1776
|
+
spinner.succeed(
|
|
1777
|
+
`Recorded ${session.frames.length} frames`
|
|
1778
|
+
);
|
|
1779
|
+
let composedFrames;
|
|
1780
|
+
if (options.effects !== false) {
|
|
1781
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
1782
|
+
const renderer = new CanvasRenderer(
|
|
1783
|
+
scenario.effects,
|
|
1784
|
+
scenario.output,
|
|
1785
|
+
scenario.steps
|
|
1786
|
+
);
|
|
1787
|
+
composedFrames = await renderer.composeAll(session.frames);
|
|
1788
|
+
spinner.succeed("Effects applied");
|
|
1789
|
+
} else {
|
|
1790
|
+
composedFrames = session.frames.map((f) => ({
|
|
1791
|
+
index: f.index,
|
|
1792
|
+
buffer: f.screenshot,
|
|
1793
|
+
timestamp: f.timestamp
|
|
1794
|
+
}));
|
|
1795
|
+
spinner.info("Effects disabled, using raw frames");
|
|
1796
|
+
}
|
|
1797
|
+
await mkdir2(options.output, { recursive: true });
|
|
1798
|
+
if (scenario.output.format === "png-sequence") {
|
|
1799
|
+
spinner.start("Saving PNG sequence...");
|
|
1800
|
+
const paths = await savePngSequence(
|
|
1801
|
+
composedFrames,
|
|
1802
|
+
scenario.output
|
|
1803
|
+
);
|
|
1804
|
+
spinner.succeed(
|
|
1805
|
+
`Saved ${paths.length} frames to ${chalk.bold(options.output)}`
|
|
1806
|
+
);
|
|
1807
|
+
} else if (scenario.output.format === "mp4") {
|
|
1808
|
+
spinner.start("Encoding MP4...");
|
|
1809
|
+
const mp4Buffer = await encodeMp4(
|
|
1810
|
+
composedFrames,
|
|
1811
|
+
scenario.output
|
|
1812
|
+
);
|
|
1813
|
+
const outputPath = join2(
|
|
1814
|
+
options.output,
|
|
1815
|
+
`${scenario.output.filename}.mp4`
|
|
1816
|
+
);
|
|
1817
|
+
await writeFile2(outputPath, mp4Buffer);
|
|
1818
|
+
const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
|
|
1819
|
+
spinner.succeed(
|
|
1820
|
+
`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
|
|
1821
|
+
);
|
|
1822
|
+
} else {
|
|
1823
|
+
spinner.start("Encoding GIF...");
|
|
1824
|
+
const gifBuffer = await encodeGif(
|
|
1825
|
+
composedFrames,
|
|
1826
|
+
scenario.output
|
|
1827
|
+
);
|
|
1828
|
+
const outputPath = join2(
|
|
1829
|
+
options.output,
|
|
1830
|
+
`${scenario.output.filename}.gif`
|
|
1831
|
+
);
|
|
1832
|
+
await writeFile2(outputPath, gifBuffer);
|
|
1833
|
+
const sizeMB = (gifBuffer.length / (1024 * 1024)).toFixed(2);
|
|
1834
|
+
spinner.succeed(
|
|
1835
|
+
`GIF saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
console.log(chalk.green("\nDone! \u{1F3AC}"));
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
spinner.fail("Recording failed");
|
|
1841
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1842
|
+
console.error(chalk.red(`
|
|
1843
|
+
Error: ${message}`));
|
|
1844
|
+
process.exit(1);
|
|
1845
|
+
}
|
|
1846
|
+
});
|
|
1847
|
+
program.command("validate").description("Validate a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").action(async (scenarioPath) => {
|
|
1848
|
+
const spinner = ora();
|
|
1849
|
+
try {
|
|
1850
|
+
spinner.start("Loading scenario...");
|
|
1851
|
+
const scenario = await loadScenario(scenarioPath);
|
|
1852
|
+
spinner.succeed(`Loaded: ${chalk.bold(scenario.name)}`);
|
|
1853
|
+
const result = validateScenario(scenario);
|
|
1854
|
+
if (result.errors.length > 0) {
|
|
1855
|
+
console.log(chalk.red("\nErrors:"));
|
|
1856
|
+
for (const error of result.errors) {
|
|
1857
|
+
console.log(chalk.red(` \u2717 ${error}`));
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (result.warnings.length > 0) {
|
|
1861
|
+
console.log(chalk.yellow("\nWarnings:"));
|
|
1862
|
+
for (const warning of result.warnings) {
|
|
1863
|
+
console.log(chalk.yellow(` \u26A0 ${warning}`));
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
if (result.valid) {
|
|
1867
|
+
console.log(
|
|
1868
|
+
chalk.green("\nScenario is valid and ready to record.")
|
|
1869
|
+
);
|
|
1870
|
+
} else {
|
|
1871
|
+
console.log(
|
|
1872
|
+
chalk.red("\nScenario has errors. Fix them before recording.")
|
|
1873
|
+
);
|
|
1874
|
+
process.exit(1);
|
|
1875
|
+
}
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
spinner.fail("Validation failed");
|
|
1878
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1879
|
+
console.error(chalk.red(`
|
|
1880
|
+
Error: ${message}`));
|
|
1881
|
+
process.exit(1);
|
|
1882
|
+
}
|
|
1883
|
+
});
|
|
1884
|
+
program.command("init").description("Create a template clipwise.yaml in the current directory").action(async () => {
|
|
1885
|
+
const targetPath = resolve("clipwise.yaml");
|
|
1886
|
+
try {
|
|
1887
|
+
await access(targetPath);
|
|
1888
|
+
console.log(chalk.yellow("Warning: clipwise.yaml already exists in this directory."));
|
|
1889
|
+
console.log(chalk.yellow("Remove it first if you want to generate a fresh template.\n"));
|
|
1890
|
+
process.exit(1);
|
|
1891
|
+
} catch {
|
|
1892
|
+
}
|
|
1893
|
+
const template = `name: "My Demo"
|
|
1894
|
+
viewport:
|
|
1895
|
+
width: 1280
|
|
1896
|
+
height: 800
|
|
1897
|
+
|
|
1898
|
+
effects:
|
|
1899
|
+
deviceFrame:
|
|
1900
|
+
enabled: true
|
|
1901
|
+
type: browser
|
|
1902
|
+
cursor:
|
|
1903
|
+
enabled: true
|
|
1904
|
+
clickEffect: true
|
|
1905
|
+
highlight: true
|
|
1906
|
+
background:
|
|
1907
|
+
type: gradient
|
|
1908
|
+
value: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
|
1909
|
+
padding: 48
|
|
1910
|
+
borderRadius: 14
|
|
1911
|
+
shadow: true
|
|
1912
|
+
|
|
1913
|
+
output:
|
|
1914
|
+
format: mp4
|
|
1915
|
+
fps: 30
|
|
1916
|
+
quality: 80
|
|
1917
|
+
|
|
1918
|
+
steps:
|
|
1919
|
+
- name: "Open app"
|
|
1920
|
+
captureDelay: 100
|
|
1921
|
+
holdDuration: 1000
|
|
1922
|
+
actions:
|
|
1923
|
+
- action: navigate
|
|
1924
|
+
url: "http://localhost:3000"
|
|
1925
|
+
waitUntil: load
|
|
1926
|
+
|
|
1927
|
+
- name: "Click button"
|
|
1928
|
+
captureDelay: 50
|
|
1929
|
+
holdDuration: 800
|
|
1930
|
+
actions:
|
|
1931
|
+
- action: click
|
|
1932
|
+
selector: "#my-button"
|
|
1933
|
+
`;
|
|
1934
|
+
await writeFile2(targetPath, template, "utf-8");
|
|
1935
|
+
console.log(chalk.green("Created clipwise.yaml\n"));
|
|
1936
|
+
console.log("Next steps:");
|
|
1937
|
+
console.log(` 1. Edit ${chalk.bold("clipwise.yaml")} \u2014 change the URL to your site`);
|
|
1938
|
+
console.log(` 2. Run ${chalk.bold("clipwise record clipwise.yaml -f mp4")} to record`);
|
|
1939
|
+
console.log(` 3. Find your output in ${chalk.bold("./output/")}`);
|
|
1940
|
+
console.log(`
|
|
1941
|
+
Or try the built-in demo: ${chalk.bold("clipwise demo")}
|
|
1942
|
+
`);
|
|
1943
|
+
});
|
|
1944
|
+
program.command("demo").description("Record a demo video of the Clipwise showcase dashboard").option("-o, --output <dir>", "Output directory", "./output").option(
|
|
1945
|
+
"-f, --format <format>",
|
|
1946
|
+
"Output format (gif|mp4)",
|
|
1947
|
+
"mp4"
|
|
1948
|
+
).option(
|
|
1949
|
+
"--url <url>",
|
|
1950
|
+
"Custom URL to record (default: Clipwise demo dashboard)"
|
|
1951
|
+
).option(
|
|
1952
|
+
"--device <device>",
|
|
1953
|
+
"Device frame (browser|iphone|ipad|android)",
|
|
1954
|
+
"browser"
|
|
1955
|
+
).action(async (options) => {
|
|
1956
|
+
const spinner = ora();
|
|
1957
|
+
try {
|
|
1958
|
+
const demoUrl = options.url ?? "https://kwakseongjae.github.io/clipwise/";
|
|
1959
|
+
const device = options.device;
|
|
1960
|
+
const isMobile = device === "iphone" || device === "android";
|
|
1961
|
+
const isTablet = device === "ipad";
|
|
1962
|
+
const vpWidth = isMobile ? 390 : isTablet ? 1024 : 1280;
|
|
1963
|
+
const vpHeight = isMobile ? 844 : isTablet ? 768 : 800;
|
|
1964
|
+
const outWidth = isMobile ? 540 : 1280;
|
|
1965
|
+
const outHeight = isMobile ? 1080 : isTablet ? 960 : 800;
|
|
1966
|
+
const { parseScenario: parseScenario2 } = await Promise.resolve().then(() => (init_parser(), parser_exports));
|
|
1967
|
+
const yaml = await import("yaml");
|
|
1968
|
+
const steps = [
|
|
1969
|
+
{
|
|
1970
|
+
name: "Load dashboard",
|
|
1971
|
+
captureDelay: 100,
|
|
1972
|
+
holdDuration: 1e3,
|
|
1973
|
+
actions: [{ action: "navigate", url: demoUrl, waitUntil: "load" }]
|
|
1974
|
+
},
|
|
1975
|
+
{
|
|
1976
|
+
name: "Hover Users stat",
|
|
1977
|
+
captureDelay: 50,
|
|
1978
|
+
holdDuration: 700,
|
|
1979
|
+
actions: [{ action: "hover", selector: "#stat-users" }]
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
name: "Hover Revenue",
|
|
1983
|
+
captureDelay: 50,
|
|
1984
|
+
holdDuration: 700,
|
|
1985
|
+
actions: [{ action: "hover", selector: "#stat-revenue" }]
|
|
1986
|
+
},
|
|
1987
|
+
{
|
|
1988
|
+
name: "Switch chart",
|
|
1989
|
+
captureDelay: 50,
|
|
1990
|
+
holdDuration: 800,
|
|
1991
|
+
actions: [{ action: "click", selector: "#tab-monthly" }]
|
|
1992
|
+
},
|
|
1993
|
+
{
|
|
1994
|
+
name: "Search",
|
|
1995
|
+
captureDelay: 50,
|
|
1996
|
+
holdDuration: 800,
|
|
1997
|
+
actions: [
|
|
1998
|
+
{ action: "click", selector: "#search-input" },
|
|
1999
|
+
{ action: "type", selector: "#search-input", text: "conversion", delay: 18 }
|
|
2000
|
+
]
|
|
2001
|
+
},
|
|
2002
|
+
...!isMobile ? [{
|
|
2003
|
+
name: "Scroll to projects",
|
|
2004
|
+
captureDelay: 100,
|
|
2005
|
+
holdDuration: 600,
|
|
2006
|
+
actions: [{ action: "scroll", y: 420, smooth: true }]
|
|
2007
|
+
}] : [{
|
|
2008
|
+
name: "Scroll to chart",
|
|
2009
|
+
captureDelay: 100,
|
|
2010
|
+
holdDuration: 600,
|
|
2011
|
+
actions: [{ action: "scroll", y: 250, smooth: true }]
|
|
2012
|
+
}],
|
|
2013
|
+
{
|
|
2014
|
+
name: "Hover row",
|
|
2015
|
+
captureDelay: 50,
|
|
2016
|
+
holdDuration: 600,
|
|
2017
|
+
actions: [{ action: "hover", selector: "#row-1" }]
|
|
2018
|
+
},
|
|
2019
|
+
{
|
|
2020
|
+
name: "Open modal",
|
|
2021
|
+
captureDelay: 100,
|
|
2022
|
+
holdDuration: 800,
|
|
2023
|
+
actions: [{ action: "click", selector: "#btn-new-project" }]
|
|
2024
|
+
},
|
|
2025
|
+
{
|
|
2026
|
+
name: "Type name",
|
|
2027
|
+
captureDelay: 50,
|
|
2028
|
+
holdDuration: 600,
|
|
2029
|
+
actions: [
|
|
2030
|
+
{ action: "click", selector: "#project-name" },
|
|
2031
|
+
{ action: "type", selector: "#project-name", text: "Clipwise Demo", delay: 20 }
|
|
2032
|
+
]
|
|
2033
|
+
},
|
|
2034
|
+
{
|
|
2035
|
+
name: "Type desc",
|
|
2036
|
+
captureDelay: 50,
|
|
2037
|
+
holdDuration: 600,
|
|
2038
|
+
actions: [
|
|
2039
|
+
{ action: "click", selector: "#project-desc" },
|
|
2040
|
+
{ action: "type", selector: "#project-desc", text: "Automated screen recording", delay: 16 }
|
|
2041
|
+
]
|
|
2042
|
+
},
|
|
2043
|
+
{
|
|
2044
|
+
name: "Create",
|
|
2045
|
+
captureDelay: 100,
|
|
2046
|
+
holdDuration: 1e3,
|
|
2047
|
+
actions: [{ action: "click", selector: "#btn-create" }]
|
|
2048
|
+
}
|
|
2049
|
+
];
|
|
2050
|
+
const scenarioObj = {
|
|
2051
|
+
name: `Clipwise Demo (${device})`,
|
|
2052
|
+
viewport: { width: vpWidth, height: vpHeight },
|
|
2053
|
+
effects: {
|
|
2054
|
+
zoom: {
|
|
2055
|
+
enabled: true,
|
|
2056
|
+
scale: 1.8,
|
|
2057
|
+
duration: 500,
|
|
2058
|
+
autoZoom: { followCursor: true, maxScale: 2 }
|
|
2059
|
+
},
|
|
2060
|
+
cursor: {
|
|
2061
|
+
enabled: true,
|
|
2062
|
+
size: isMobile ? 16 : 20,
|
|
2063
|
+
clickEffect: true,
|
|
2064
|
+
highlight: true,
|
|
2065
|
+
trail: !isMobile,
|
|
2066
|
+
trailLength: 6
|
|
2067
|
+
},
|
|
2068
|
+
background: {
|
|
2069
|
+
type: "gradient",
|
|
2070
|
+
value: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
|
|
2071
|
+
padding: isMobile ? 60 : 48,
|
|
2072
|
+
borderRadius: 14,
|
|
2073
|
+
shadow: true
|
|
2074
|
+
},
|
|
2075
|
+
deviceFrame: { enabled: true, type: device, darkMode: true },
|
|
2076
|
+
keystroke: {
|
|
2077
|
+
enabled: true,
|
|
2078
|
+
position: "bottom-center",
|
|
2079
|
+
fontSize: isMobile ? 14 : 16
|
|
2080
|
+
},
|
|
2081
|
+
watermark: {
|
|
2082
|
+
enabled: true,
|
|
2083
|
+
text: "Clipwise",
|
|
2084
|
+
position: "bottom-right",
|
|
2085
|
+
opacity: 0.35,
|
|
2086
|
+
fontSize: 13
|
|
2087
|
+
}
|
|
2088
|
+
},
|
|
2089
|
+
output: {
|
|
2090
|
+
format: options.format,
|
|
2091
|
+
width: outWidth,
|
|
2092
|
+
height: outHeight,
|
|
2093
|
+
fps: 30,
|
|
2094
|
+
quality: 80,
|
|
2095
|
+
outputDir: options.output,
|
|
2096
|
+
filename: `clipwise-demo-${device}`
|
|
2097
|
+
},
|
|
2098
|
+
steps
|
|
2099
|
+
};
|
|
2100
|
+
const scenario = parseScenario2(yaml.stringify(scenarioObj));
|
|
2101
|
+
spinner.succeed(`Demo scenario ready: ${chalk.bold(scenario.name)}`);
|
|
2102
|
+
spinner.start("Checking browser...");
|
|
2103
|
+
try {
|
|
2104
|
+
const { chromium: chromium2 } = await import("playwright");
|
|
2105
|
+
const testBrowser = await chromium2.launch({ headless: true });
|
|
2106
|
+
await testBrowser.close();
|
|
2107
|
+
spinner.succeed("Browser ready");
|
|
2108
|
+
} catch {
|
|
2109
|
+
spinner.fail("Chromium not found");
|
|
2110
|
+
console.log(chalk.yellow("\nInstalling Chromium (one-time setup)...\n"));
|
|
2111
|
+
const { execSync } = await import("child_process");
|
|
2112
|
+
try {
|
|
2113
|
+
execSync("npx playwright install chromium", { stdio: "inherit" });
|
|
2114
|
+
} catch {
|
|
2115
|
+
console.error(chalk.red("\nFailed to install Chromium. Run: npx playwright install chromium\n"));
|
|
2116
|
+
process.exit(1);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
spinner.start(`Recording ${scenario.steps.length} steps...`);
|
|
2120
|
+
const recorder = new ClipwiseRecorder();
|
|
2121
|
+
const session = await recorder.record(scenario);
|
|
2122
|
+
spinner.succeed(`Recorded ${session.frames.length} frames`);
|
|
2123
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
2124
|
+
const renderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
|
|
2125
|
+
const composedFrames = await renderer.composeAll(session.frames);
|
|
2126
|
+
spinner.succeed("Effects applied");
|
|
2127
|
+
await mkdir2(options.output, { recursive: true });
|
|
2128
|
+
const ext = scenario.output.format === "gif" ? "gif" : "mp4";
|
|
2129
|
+
const outputPath = join2(options.output, `clipwise-demo-${device}.${ext}`);
|
|
2130
|
+
if (ext === "gif") {
|
|
2131
|
+
spinner.start("Encoding GIF...");
|
|
2132
|
+
const buf = await encodeGif(composedFrames, scenario.output);
|
|
2133
|
+
await writeFile2(outputPath, buf);
|
|
2134
|
+
spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
2135
|
+
} else {
|
|
2136
|
+
spinner.start("Encoding MP4...");
|
|
2137
|
+
const buf = await encodeMp4(composedFrames, scenario.output);
|
|
2138
|
+
await writeFile2(outputPath, buf);
|
|
2139
|
+
spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
2140
|
+
}
|
|
2141
|
+
console.log(chalk.green("\nDemo complete! \u{1F3AC}"));
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
spinner.fail("Demo recording failed");
|
|
2144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2145
|
+
console.error(chalk.red(`
|
|
2146
|
+
Error: ${message}`));
|
|
2147
|
+
process.exit(1);
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
program.parse();
|