clipwise 0.6.1 → 0.7.1
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/README.ko.md +1 -1
- package/README.md +42 -8
- package/dist/cli/index.js +480 -106
- package/dist/compose/frame-worker.js +85 -76
- package/dist/index.d.ts +477 -34
- package/dist/index.js +582 -129
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ var __export = (target, all) => {
|
|
|
11
11
|
|
|
12
12
|
// src/script/types.ts
|
|
13
13
|
import { z } from "zod";
|
|
14
|
-
var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepEffectsOverrideSchema, TransitionTypeSchema, StepSchema, AudioConfigSchema, ScenarioSchema;
|
|
14
|
+
var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, SmartWaitActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, SmartSpeedConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepEffectsOverrideSchema, TransitionTypeSchema, StepSchema, AudioConfigSchema, ScenarioSchema;
|
|
15
15
|
var init_types = __esm({
|
|
16
16
|
"src/script/types.ts"() {
|
|
17
17
|
"use strict";
|
|
@@ -87,6 +87,17 @@ var init_types = __esm({
|
|
|
87
87
|
status: z.number().min(100).max(599).optional(),
|
|
88
88
|
timeout: z.number().min(0).default(3e4)
|
|
89
89
|
});
|
|
90
|
+
SmartWaitActionSchema = z.object({
|
|
91
|
+
action: z.literal("smartWait"),
|
|
92
|
+
/** Condition to wait for */
|
|
93
|
+
until: z.enum(["networkIdle", "selector", "domStable"]).default("networkIdle"),
|
|
94
|
+
/** CSS selector (required when until="selector") */
|
|
95
|
+
selector: SafeSelectorSchema.optional(),
|
|
96
|
+
/** Maximum wait in ms */
|
|
97
|
+
timeout: z.number().min(0).default(3e4),
|
|
98
|
+
/** Speed multiplier for the wait period in the output video (default: 8×) */
|
|
99
|
+
displaySpeed: z.number().min(1).max(32).default(8)
|
|
100
|
+
});
|
|
90
101
|
StepActionSchema = z.discriminatedUnion("action", [
|
|
91
102
|
NavigateActionSchema,
|
|
92
103
|
ClickActionSchema,
|
|
@@ -99,7 +110,8 @@ var init_types = __esm({
|
|
|
99
110
|
WaitForNavigationActionSchema,
|
|
100
111
|
WaitForURLActionSchema,
|
|
101
112
|
WaitForFunctionActionSchema,
|
|
102
|
-
WaitForResponseActionSchema
|
|
113
|
+
WaitForResponseActionSchema,
|
|
114
|
+
SmartWaitActionSchema
|
|
103
115
|
]);
|
|
104
116
|
ZoomIntensitySchema = z.enum([
|
|
105
117
|
"subtle",
|
|
@@ -129,7 +141,7 @@ var init_types = __esm({
|
|
|
129
141
|
*/
|
|
130
142
|
intensity: ZoomIntensitySchema.default("light"),
|
|
131
143
|
duration: z.number().default(800),
|
|
132
|
-
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
|
|
144
|
+
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear", "spring"]).default("ease-in-out"),
|
|
133
145
|
autoZoom: AutoZoomConfigSchema.default({})
|
|
134
146
|
});
|
|
135
147
|
CursorEffectSchema = z.object({
|
|
@@ -166,6 +178,17 @@ var init_types = __esm({
|
|
|
166
178
|
actionSpeed: z.number().min(0.25).max(2).default(0.8),
|
|
167
179
|
transitionFrames: z.number().default(15)
|
|
168
180
|
});
|
|
181
|
+
SmartSpeedConfigSchema = z.object({
|
|
182
|
+
enabled: z.boolean().default(false),
|
|
183
|
+
/** Speed multiplier for frames during smartWait (overridden by per-action displaySpeed) */
|
|
184
|
+
waitSpeed: z.number().min(1).max(32).default(8),
|
|
185
|
+
/** Speed multiplier for idle frames (no DOM/network changes) */
|
|
186
|
+
idleSpeed: z.number().min(1).max(16).default(4),
|
|
187
|
+
/** Duration (ms) to ease between speed changes (prevents jarring jumps) */
|
|
188
|
+
transitionDuration: z.number().default(300),
|
|
189
|
+
/** Minimum segment duration (ms) — don't speed up very short segments */
|
|
190
|
+
minSegmentDuration: z.number().default(500)
|
|
191
|
+
});
|
|
169
192
|
KeystrokeConfigSchema = z.object({
|
|
170
193
|
enabled: z.boolean().default(false),
|
|
171
194
|
/**
|
|
@@ -200,6 +223,7 @@ var init_types = __esm({
|
|
|
200
223
|
background: BackgroundSchema.default({}),
|
|
201
224
|
deviceFrame: DeviceFrameSchema.default({}),
|
|
202
225
|
speedRamp: SpeedRampConfigSchema.default({}),
|
|
226
|
+
smartSpeed: SmartSpeedConfigSchema.default({}),
|
|
203
227
|
keystroke: KeystrokeConfigSchema.default({}),
|
|
204
228
|
watermark: WatermarkConfigSchema.default({})
|
|
205
229
|
});
|
|
@@ -215,6 +239,8 @@ var init_types = __esm({
|
|
|
215
239
|
// balanced — general-purpose, good quality/size trade-off (CRF 20)
|
|
216
240
|
// archive — high-fidelity storage, larger file (CRF 15)
|
|
217
241
|
preset: z.enum(["social", "balanced", "archive"]).optional(),
|
|
242
|
+
/** Codec override: h264 (default), hevc (10-bit), av1 (smallest files, slow encode) */
|
|
243
|
+
codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
|
|
218
244
|
outputDir: z.string().default("./output"),
|
|
219
245
|
filename: z.string().default("clipwise-recording")
|
|
220
246
|
});
|
|
@@ -224,6 +250,7 @@ var init_types = __esm({
|
|
|
224
250
|
background: BackgroundSchema.partial().optional(),
|
|
225
251
|
deviceFrame: DeviceFrameSchema.partial().optional(),
|
|
226
252
|
speedRamp: SpeedRampConfigSchema.partial().optional(),
|
|
253
|
+
smartSpeed: SmartSpeedConfigSchema.partial().optional(),
|
|
227
254
|
keystroke: KeystrokeConfigSchema.partial().optional(),
|
|
228
255
|
watermark: WatermarkConfigSchema.partial().optional()
|
|
229
256
|
}).optional();
|
|
@@ -505,6 +532,12 @@ var ClipwiseRecorder = class {
|
|
|
505
532
|
keystrokeSessionId = 0;
|
|
506
533
|
currentStepIndex = 0;
|
|
507
534
|
isScrolling = false;
|
|
535
|
+
isWaitingPhase = false;
|
|
536
|
+
currentDisplaySpeed;
|
|
537
|
+
/** Tracks active infinite CSS animations (spinners/loaders). Count > 0 → loading state. */
|
|
538
|
+
activeLoaderAnimations = /* @__PURE__ */ new Set();
|
|
539
|
+
/** Whether auto-loader detection is active (derived from smartSpeed.enabled). */
|
|
540
|
+
loaderDetectionEnabled = false;
|
|
508
541
|
cursorPosition = { x: 0, y: 0 };
|
|
509
542
|
viewport = { width: 1280, height: 800 };
|
|
510
543
|
deviceScaleFactor = 1;
|
|
@@ -532,6 +565,7 @@ var ClipwiseRecorder = class {
|
|
|
532
565
|
};
|
|
533
566
|
this.targetFps = scenario.output.fps;
|
|
534
567
|
this.cursorSpeed = scenario.effects.cursor.speed;
|
|
568
|
+
this.loaderDetectionEnabled = scenario.effects.smartSpeed?.enabled ?? false;
|
|
535
569
|
this.browser = await chromium.launch({ headless: true });
|
|
536
570
|
this.context = await this.browser.newContext({
|
|
537
571
|
viewport: this.viewport
|
|
@@ -544,6 +578,9 @@ var ClipwiseRecorder = class {
|
|
|
544
578
|
this.keystrokeSessionId = 0;
|
|
545
579
|
this.currentStepIndex = 0;
|
|
546
580
|
this.isScrolling = false;
|
|
581
|
+
this.isWaitingPhase = false;
|
|
582
|
+
this.currentDisplaySpeed = void 0;
|
|
583
|
+
this.activeLoaderAnimations.clear();
|
|
547
584
|
this.cursorPosition = { x: 0, y: 0 };
|
|
548
585
|
this.isCapturing = false;
|
|
549
586
|
this.firstContentTimestamp = 0;
|
|
@@ -567,13 +604,15 @@ var ClipwiseRecorder = class {
|
|
|
567
604
|
const buffer = Buffer.from(event.data, "base64");
|
|
568
605
|
this.dedupStats.received++;
|
|
569
606
|
const signature = buffer.subarray(0, DEDUP_SIGNATURE_BYTES);
|
|
570
|
-
const
|
|
607
|
+
const isInLoadingState = this.isWaitingPhase || this.loaderDetectionEnabled && this.activeLoaderAnimations.size > 0;
|
|
608
|
+
const isDuplicate = !isInLoadingState && this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
|
|
571
609
|
if (isDuplicate) {
|
|
572
610
|
this.dedupStats.skipped++;
|
|
573
611
|
} else {
|
|
574
612
|
this.lastFrameSignature = Buffer.from(signature);
|
|
575
613
|
const captureTime = Date.now();
|
|
576
|
-
const
|
|
614
|
+
const isLoading = this.isWaitingPhase || this.loaderDetectionEnabled && this.activeLoaderAnimations.size > 0;
|
|
615
|
+
const rawFrame = { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex, isScrolling: this.isScrolling, isWaitingPhase: isLoading, displaySpeed: this.currentDisplaySpeed };
|
|
577
616
|
this.rawFrames.push(rawFrame);
|
|
578
617
|
this.dedupStats.stored++;
|
|
579
618
|
if (this.frameChannel && this.firstContentTimestamp > 0) {
|
|
@@ -590,6 +629,23 @@ var ClipwiseRecorder = class {
|
|
|
590
629
|
});
|
|
591
630
|
}
|
|
592
631
|
);
|
|
632
|
+
if (this.loaderDetectionEnabled) {
|
|
633
|
+
this.cdpClient.on("Animation.animationStarted", (event) => {
|
|
634
|
+
const anim = event.animation;
|
|
635
|
+
const iterations = anim?.source?.iterations ?? 0;
|
|
636
|
+
const isInfinite = iterations === -1 || iterations > 100;
|
|
637
|
+
const animName = anim?.name || "";
|
|
638
|
+
const isLoaderPattern = /spin|rotate|pulse|bounce|loading|skeleton|shimmer/i.test(animName);
|
|
639
|
+
if (anim?.type === "CSSAnimation" && isInfinite && isLoaderPattern) {
|
|
640
|
+
this.activeLoaderAnimations.add(anim.id);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
this.cdpClient.on("Animation.animationCanceled", (event) => {
|
|
644
|
+
this.activeLoaderAnimations.delete(event.id);
|
|
645
|
+
});
|
|
646
|
+
await this.cdpClient.send("Animation.enable").catch(() => {
|
|
647
|
+
});
|
|
648
|
+
}
|
|
593
649
|
await this.cdpClient.send("Page.startScreencast", {
|
|
594
650
|
format: "png",
|
|
595
651
|
maxWidth: this.viewport.width * this.deviceScaleFactor,
|
|
@@ -784,7 +840,9 @@ var ClipwiseRecorder = class {
|
|
|
784
840
|
deviceScaleFactor: this.deviceScaleFactor,
|
|
785
841
|
stepIndex: raw.stepIndex,
|
|
786
842
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
787
|
-
isScrolling: raw.isScrolling || void 0
|
|
843
|
+
isScrolling: raw.isScrolling || void 0,
|
|
844
|
+
isWaitingPhase: raw.isWaitingPhase || void 0,
|
|
845
|
+
displaySpeed: raw.displaySpeed
|
|
788
846
|
};
|
|
789
847
|
}
|
|
790
848
|
/**
|
|
@@ -903,6 +961,7 @@ var ClipwiseRecorder = class {
|
|
|
903
961
|
await this.page.click(action.selector);
|
|
904
962
|
this.keystrokeSessionId++;
|
|
905
963
|
const currentSessionId = this.keystrokeSessionId;
|
|
964
|
+
let lastClickRefresh = Date.now();
|
|
906
965
|
let typeRepaintToggle = false;
|
|
907
966
|
for (const char of action.text) {
|
|
908
967
|
await this.page.keyboard.type(char);
|
|
@@ -914,7 +973,19 @@ var ClipwiseRecorder = class {
|
|
|
914
973
|
sessionId: currentSessionId
|
|
915
974
|
});
|
|
916
975
|
await new Promise((resolve2) => setTimeout(resolve2, action.delay));
|
|
976
|
+
const now = Date.now();
|
|
977
|
+
if (now - lastClickRefresh >= 400) {
|
|
978
|
+
this.clickTimeline.push({
|
|
979
|
+
position: { ...inputTarget },
|
|
980
|
+
timestamp: now
|
|
981
|
+
});
|
|
982
|
+
lastClickRefresh = now;
|
|
983
|
+
}
|
|
917
984
|
}
|
|
985
|
+
this.clickTimeline.push({
|
|
986
|
+
position: { ...inputTarget },
|
|
987
|
+
timestamp: Date.now()
|
|
988
|
+
});
|
|
918
989
|
break;
|
|
919
990
|
}
|
|
920
991
|
case "scroll": {
|
|
@@ -1009,6 +1080,60 @@ var ClipwiseRecorder = class {
|
|
|
1009
1080
|
}
|
|
1010
1081
|
break;
|
|
1011
1082
|
}
|
|
1083
|
+
case "smartWait": {
|
|
1084
|
+
this.isWaitingPhase = true;
|
|
1085
|
+
this.currentDisplaySpeed = action.displaySpeed;
|
|
1086
|
+
try {
|
|
1087
|
+
let conditionPromise;
|
|
1088
|
+
switch (action.until) {
|
|
1089
|
+
case "networkIdle":
|
|
1090
|
+
conditionPromise = this.page.waitForLoadState("networkidle", { timeout: action.timeout });
|
|
1091
|
+
break;
|
|
1092
|
+
case "selector":
|
|
1093
|
+
conditionPromise = action.selector ? this.page.locator(action.selector).first().waitFor({ state: "visible", timeout: action.timeout }) : Promise.resolve();
|
|
1094
|
+
break;
|
|
1095
|
+
case "domStable":
|
|
1096
|
+
conditionPromise = this.page.waitForFunction(
|
|
1097
|
+
() => new Promise((resolve2) => {
|
|
1098
|
+
let timer;
|
|
1099
|
+
const observer = new MutationObserver(() => {
|
|
1100
|
+
clearTimeout(timer);
|
|
1101
|
+
timer = setTimeout(() => {
|
|
1102
|
+
observer.disconnect();
|
|
1103
|
+
resolve2(true);
|
|
1104
|
+
}, 500);
|
|
1105
|
+
});
|
|
1106
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
1107
|
+
timer = setTimeout(() => {
|
|
1108
|
+
observer.disconnect();
|
|
1109
|
+
resolve2(true);
|
|
1110
|
+
}, 500);
|
|
1111
|
+
}),
|
|
1112
|
+
void 0,
|
|
1113
|
+
{ timeout: action.timeout }
|
|
1114
|
+
);
|
|
1115
|
+
break;
|
|
1116
|
+
default:
|
|
1117
|
+
conditionPromise = Promise.resolve();
|
|
1118
|
+
}
|
|
1119
|
+
let waitDone = false;
|
|
1120
|
+
const repaintLoop = (async () => {
|
|
1121
|
+
let toggle = false;
|
|
1122
|
+
while (!waitDone && this.isCapturing && this.page) {
|
|
1123
|
+
await this.forceRepaint(toggle);
|
|
1124
|
+
toggle = !toggle;
|
|
1125
|
+
await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
|
|
1126
|
+
}
|
|
1127
|
+
})();
|
|
1128
|
+
await conditionPromise;
|
|
1129
|
+
waitDone = true;
|
|
1130
|
+
await repaintLoop;
|
|
1131
|
+
} finally {
|
|
1132
|
+
this.isWaitingPhase = false;
|
|
1133
|
+
this.currentDisplaySpeed = void 0;
|
|
1134
|
+
}
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1012
1137
|
}
|
|
1013
1138
|
await this.waitWithRepaints(ACTION_GAP_MS);
|
|
1014
1139
|
}
|
|
@@ -1128,7 +1253,9 @@ var ClipwiseRecorder = class {
|
|
|
1128
1253
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
1129
1254
|
stepIndex: raw.stepIndex,
|
|
1130
1255
|
// use per-frame step index captured at event time
|
|
1131
|
-
isScrolling: raw.isScrolling || void 0
|
|
1256
|
+
isScrolling: raw.isScrolling || void 0,
|
|
1257
|
+
isWaitingPhase: raw.isWaitingPhase || void 0,
|
|
1258
|
+
displaySpeed: raw.displaySpeed
|
|
1132
1259
|
};
|
|
1133
1260
|
});
|
|
1134
1261
|
}
|
|
@@ -1488,52 +1615,31 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
|
|
|
1488
1615
|
|
|
1489
1616
|
// src/effects/cursor.ts
|
|
1490
1617
|
import sharp2 from "sharp";
|
|
1491
|
-
function
|
|
1492
|
-
|
|
1493
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
|
|
1494
|
-
<path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
|
|
1495
|
-
fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
|
|
1496
|
-
</svg>`;
|
|
1497
|
-
}
|
|
1498
|
-
function buildClickRippleSvg(radius, color, progress) {
|
|
1499
|
-
const currentRadius = radius * progress;
|
|
1500
|
-
const opacity = Math.max(0, 1 - progress);
|
|
1501
|
-
const size = Math.ceil(radius * 2 + 4);
|
|
1502
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
1503
|
-
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
|
|
1504
|
-
fill="none" stroke="${color}" stroke-width="2"
|
|
1505
|
-
opacity="${opacity.toFixed(3)}"/>
|
|
1506
|
-
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius * 0.6}"
|
|
1507
|
-
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
1508
|
-
</svg>`;
|
|
1509
|
-
}
|
|
1510
|
-
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1511
|
-
if (!config.enabled) return frameBuffer;
|
|
1618
|
+
function buildCursorOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1619
|
+
if (!config.enabled) return null;
|
|
1512
1620
|
const size = Math.round(config.size * dpr);
|
|
1513
1621
|
const cursorSvg = buildCursorSvg(size, config.color);
|
|
1514
|
-
const cursorBuffer = Buffer.from(cursorSvg);
|
|
1515
1622
|
const tipOffsetX = Math.round(4 / 24 * size);
|
|
1516
1623
|
const px = Math.round(position.x * dpr);
|
|
1517
1624
|
const py = Math.round(position.y * dpr);
|
|
1518
1625
|
const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
|
|
1519
1626
|
const top = Math.max(0, Math.min(py, frameHeight - size));
|
|
1520
|
-
return
|
|
1627
|
+
return { input: Buffer.from(cursorSvg), left, top };
|
|
1521
1628
|
}
|
|
1522
|
-
|
|
1523
|
-
if (!config.enabled || !config.clickEffect) return
|
|
1629
|
+
function buildClickRippleOverlay(position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
1630
|
+
if (!config.enabled || !config.clickEffect) return null;
|
|
1524
1631
|
const radius = config.clickRadius * dpr;
|
|
1525
1632
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
1526
1633
|
const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
|
|
1527
|
-
const rippleBuffer = Buffer.from(rippleSvg);
|
|
1528
1634
|
const rippleSize = Math.ceil(radius * 2 + 4);
|
|
1529
1635
|
const px = Math.round(position.x * dpr);
|
|
1530
1636
|
const py = Math.round(position.y * dpr);
|
|
1531
1637
|
const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
|
|
1532
1638
|
const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
|
|
1533
|
-
return
|
|
1639
|
+
return { input: Buffer.from(rippleSvg), left, top };
|
|
1534
1640
|
}
|
|
1535
|
-
|
|
1536
|
-
if (!config.enabled || !config.highlight) return
|
|
1641
|
+
function buildHighlightOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1642
|
+
if (!config.enabled || !config.highlight) return null;
|
|
1537
1643
|
const r = config.highlightRadius * dpr;
|
|
1538
1644
|
const size = Math.ceil(r * 2 + 4);
|
|
1539
1645
|
const cx = size / 2;
|
|
@@ -1552,12 +1658,10 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
|
|
|
1552
1658
|
const py = Math.round(position.y * dpr);
|
|
1553
1659
|
const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
|
|
1554
1660
|
const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
|
|
1555
|
-
return
|
|
1661
|
+
return { input: Buffer.from(highlightSvg), left, top };
|
|
1556
1662
|
}
|
|
1557
|
-
|
|
1558
|
-
if (!config.enabled || !config.trail || positions.length < 2)
|
|
1559
|
-
return frameBuffer;
|
|
1560
|
-
}
|
|
1663
|
+
function buildTrailOverlay(positions, config, frameWidth, frameHeight, dpr = 1) {
|
|
1664
|
+
if (!config.enabled || !config.trail || positions.length < 2) return null;
|
|
1561
1665
|
const segments = [];
|
|
1562
1666
|
for (let i = 1; i < positions.length; i++) {
|
|
1563
1667
|
const opacity = i / positions.length * 0.6;
|
|
@@ -1573,7 +1677,26 @@ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, fra
|
|
|
1573
1677
|
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
|
|
1574
1678
|
${segments.join("\n ")}
|
|
1575
1679
|
</svg>`;
|
|
1576
|
-
return
|
|
1680
|
+
return { input: Buffer.from(trailSvg), left: 0, top: 0 };
|
|
1681
|
+
}
|
|
1682
|
+
function buildCursorSvg(size, color) {
|
|
1683
|
+
const s = size;
|
|
1684
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
|
|
1685
|
+
<path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
|
|
1686
|
+
fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
|
|
1687
|
+
</svg>`;
|
|
1688
|
+
}
|
|
1689
|
+
function buildClickRippleSvg(radius, color, progress) {
|
|
1690
|
+
const currentRadius = radius * progress;
|
|
1691
|
+
const opacity = Math.max(0, 1 - progress);
|
|
1692
|
+
const size = Math.ceil(radius * 2 + 4);
|
|
1693
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
1694
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
|
|
1695
|
+
fill="none" stroke="${color}" stroke-width="2"
|
|
1696
|
+
opacity="${opacity.toFixed(3)}"/>
|
|
1697
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius * 0.6}"
|
|
1698
|
+
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
1699
|
+
</svg>`;
|
|
1577
1700
|
}
|
|
1578
1701
|
|
|
1579
1702
|
// src/effects/zoom.ts
|
|
@@ -1640,6 +1763,51 @@ function calculateAdaptiveZoomInWindow(windowFrames, windowStart, currentIndex,
|
|
|
1640
1763
|
function easeInOutCubic2(t) {
|
|
1641
1764
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
1642
1765
|
}
|
|
1766
|
+
function springEasing(t) {
|
|
1767
|
+
const omega = 6.5;
|
|
1768
|
+
const raw = 1 - (1 + omega * t) * Math.exp(-omega * t);
|
|
1769
|
+
const endVal = 1 - (1 + omega) * Math.exp(-omega);
|
|
1770
|
+
return Math.max(0, Math.min(1, raw / endVal));
|
|
1771
|
+
}
|
|
1772
|
+
function applyZoomEasing(t, easing = "cubic") {
|
|
1773
|
+
return easing === "spring" ? springEasing(t) : easeInOutCubic2(t);
|
|
1774
|
+
}
|
|
1775
|
+
function mergeClickZones(clickLookup, mergeGap) {
|
|
1776
|
+
if (clickLookup.length === 0) return [];
|
|
1777
|
+
const zones = [];
|
|
1778
|
+
let start = clickLookup[0];
|
|
1779
|
+
let end = clickLookup[0];
|
|
1780
|
+
for (let i = 1; i < clickLookup.length; i++) {
|
|
1781
|
+
if (clickLookup[i] - end <= mergeGap) {
|
|
1782
|
+
end = clickLookup[i];
|
|
1783
|
+
} else {
|
|
1784
|
+
zones.push({ start, end });
|
|
1785
|
+
start = clickLookup[i];
|
|
1786
|
+
end = clickLookup[i];
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
zones.push({ start, end });
|
|
1790
|
+
return zones;
|
|
1791
|
+
}
|
|
1792
|
+
function calculateAdaptiveZoomFromZones(zones, currentIndex, maxScale, transitionFrames, easing = "spring") {
|
|
1793
|
+
if (maxScale <= 1 || zones.length === 0) return 1;
|
|
1794
|
+
let lo = 0;
|
|
1795
|
+
let hi = zones.length;
|
|
1796
|
+
while (lo < hi) {
|
|
1797
|
+
const mid = lo + hi >>> 1;
|
|
1798
|
+
if (zones[mid].end < currentIndex) lo = mid + 1;
|
|
1799
|
+
else hi = mid;
|
|
1800
|
+
}
|
|
1801
|
+
if (lo < zones.length && currentIndex >= zones[lo].start && currentIndex <= zones[lo].end) {
|
|
1802
|
+
return maxScale;
|
|
1803
|
+
}
|
|
1804
|
+
const distBefore = lo > 0 ? currentIndex - zones[lo - 1].end : Infinity;
|
|
1805
|
+
const distAfter = lo < zones.length ? zones[lo].start - currentIndex : Infinity;
|
|
1806
|
+
const minDistance = Math.min(distBefore, distAfter);
|
|
1807
|
+
if (minDistance > transitionFrames) return 1;
|
|
1808
|
+
const t = 1 - minDistance / transitionFrames;
|
|
1809
|
+
return 1 + (maxScale - 1) * applyZoomEasing(t, easing);
|
|
1810
|
+
}
|
|
1643
1811
|
|
|
1644
1812
|
// src/effects/background.ts
|
|
1645
1813
|
import sharp4 from "sharp";
|
|
@@ -1886,73 +2054,87 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1886
2054
|
cursorTrail: context?.cursorTrail ?? []
|
|
1887
2055
|
};
|
|
1888
2056
|
const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1889
|
-
const
|
|
2057
|
+
const sl = context?.staticLayers;
|
|
2058
|
+
const preZoomOverlays = [];
|
|
2059
|
+
const hasBrowserChrome = effects.deviceFrame.enabled && effects.deviceFrame.type === "browser" && sl?.browserChromePng;
|
|
2060
|
+
const extTop = hasBrowserChrome ? sl.browserChromeHeight : 0;
|
|
2061
|
+
const extWidth = width;
|
|
2062
|
+
const extHeight = height + extTop;
|
|
2063
|
+
if (hasBrowserChrome) {
|
|
2064
|
+
preZoomOverlays.push({ input: sl.browserChromePng, left: 0, top: 0 });
|
|
2065
|
+
}
|
|
2066
|
+
const withExtFrameOffset = (pos) => ({
|
|
1890
2067
|
x: pos.x + frameOffset.left / Math.max(1, dpr),
|
|
1891
2068
|
y: pos.y + frameOffset.top / Math.max(1, dpr)
|
|
1892
2069
|
});
|
|
1893
|
-
if (effects.deviceFrame.enabled) {
|
|
1894
|
-
const sl2 = ctx.staticLayers;
|
|
1895
|
-
if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
|
|
1896
|
-
buffer = await sharp7(buffer).extend({
|
|
1897
|
-
top: sl2.browserChromeHeight,
|
|
1898
|
-
bottom: 0,
|
|
1899
|
-
left: 0,
|
|
1900
|
-
right: 0,
|
|
1901
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
1902
|
-
}).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
|
|
1903
|
-
} else {
|
|
1904
|
-
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
1905
|
-
}
|
|
1906
|
-
const meta2 = await sharp7(buffer).metadata();
|
|
1907
|
-
width = meta2.width ?? width;
|
|
1908
|
-
height = meta2.height ?? height;
|
|
1909
|
-
}
|
|
1910
2070
|
if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
withFrameOffset(frame.cursorPosition),
|
|
2071
|
+
const overlay = buildHighlightOverlay(
|
|
2072
|
+
withExtFrameOffset(frame.cursorPosition),
|
|
1914
2073
|
effects.cursor,
|
|
1915
|
-
|
|
1916
|
-
|
|
2074
|
+
extWidth,
|
|
2075
|
+
extHeight,
|
|
1917
2076
|
dpr
|
|
1918
2077
|
);
|
|
2078
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
1919
2079
|
}
|
|
1920
2080
|
if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
ctx.cursorTrail.map(withFrameOffset),
|
|
2081
|
+
const overlay = buildTrailOverlay(
|
|
2082
|
+
ctx.cursorTrail.map(withExtFrameOffset),
|
|
1924
2083
|
effects.cursor,
|
|
1925
|
-
|
|
1926
|
-
|
|
2084
|
+
extWidth,
|
|
2085
|
+
extHeight,
|
|
1927
2086
|
dpr
|
|
1928
2087
|
);
|
|
2088
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
1929
2089
|
}
|
|
1930
2090
|
if (effects.cursor.enabled && frame.cursorPosition) {
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
withFrameOffset(frame.cursorPosition),
|
|
2091
|
+
const overlay = buildCursorOverlay(
|
|
2092
|
+
withExtFrameOffset(frame.cursorPosition),
|
|
1934
2093
|
effects.cursor,
|
|
1935
|
-
|
|
1936
|
-
|
|
2094
|
+
extWidth,
|
|
2095
|
+
extHeight,
|
|
1937
2096
|
dpr
|
|
1938
2097
|
);
|
|
2098
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
1939
2099
|
}
|
|
1940
2100
|
if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
|
|
1941
2101
|
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
withFrameOffset(frame.clickPosition),
|
|
2102
|
+
const overlay = buildClickRippleOverlay(
|
|
2103
|
+
withExtFrameOffset(frame.clickPosition),
|
|
1945
2104
|
effects.cursor,
|
|
1946
2105
|
progress,
|
|
1947
|
-
|
|
1948
|
-
|
|
2106
|
+
extWidth,
|
|
2107
|
+
extHeight,
|
|
1949
2108
|
dpr
|
|
1950
2109
|
);
|
|
2110
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
2111
|
+
}
|
|
2112
|
+
if (hasBrowserChrome || preZoomOverlays.length > 0) {
|
|
2113
|
+
let pipeline = sharp7(buffer);
|
|
2114
|
+
if (hasBrowserChrome) {
|
|
2115
|
+
pipeline = pipeline.extend({
|
|
2116
|
+
top: extTop,
|
|
2117
|
+
bottom: 0,
|
|
2118
|
+
left: 0,
|
|
2119
|
+
right: 0,
|
|
2120
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
if (preZoomOverlays.length > 0) {
|
|
2124
|
+
pipeline = pipeline.composite(preZoomOverlays);
|
|
2125
|
+
}
|
|
2126
|
+
buffer = await pipeline.png().toBuffer();
|
|
2127
|
+
width = extWidth;
|
|
2128
|
+
height = extHeight;
|
|
2129
|
+
} else if (effects.deviceFrame.enabled) {
|
|
2130
|
+
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
2131
|
+
const devMeta = await sharp7(buffer).metadata();
|
|
2132
|
+
width = devMeta.width ?? width;
|
|
2133
|
+
height = devMeta.height ?? height;
|
|
1951
2134
|
}
|
|
1952
2135
|
const scale = ctx.zoomScale;
|
|
1953
2136
|
if (effects.zoom.enabled && scale > 1) {
|
|
1954
|
-
const
|
|
1955
|
-
const rawFocus = followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
2137
|
+
const rawFocus = ctx.focusOverride ?? (effects.zoom.autoZoom.followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 });
|
|
1956
2138
|
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1957
2139
|
const focusPoint = {
|
|
1958
2140
|
x: rawFocus.x * dpr + offset.left,
|
|
@@ -1971,7 +2153,6 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1971
2153
|
dpr
|
|
1972
2154
|
);
|
|
1973
2155
|
}
|
|
1974
|
-
const sl = ctx.staticLayers;
|
|
1975
2156
|
if (sl) {
|
|
1976
2157
|
const padding = effects.background.padding;
|
|
1977
2158
|
const contentWidth = output.width - padding * 2;
|
|
@@ -2180,6 +2361,7 @@ function mergeStepEffects(global, stepIndex, steps) {
|
|
|
2180
2361
|
background: override.background ? { ...global.background, ...override.background } : global.background,
|
|
2181
2362
|
deviceFrame: override.deviceFrame ? { ...global.deviceFrame, ...override.deviceFrame } : global.deviceFrame,
|
|
2182
2363
|
speedRamp: override.speedRamp ? { ...global.speedRamp, ...override.speedRamp } : global.speedRamp,
|
|
2364
|
+
smartSpeed: override.smartSpeed ? { ...global.smartSpeed, ...override.smartSpeed } : global.smartSpeed,
|
|
2183
2365
|
keystroke: override.keystroke ? { ...global.keystroke, ...override.keystroke } : global.keystroke,
|
|
2184
2366
|
watermark: override.watermark ? { ...global.watermark, ...override.watermark } : global.watermark
|
|
2185
2367
|
};
|
|
@@ -2233,7 +2415,10 @@ var CanvasRenderer = class {
|
|
|
2233
2415
|
if (frames.length === 0) return [];
|
|
2234
2416
|
let processFrames = frames;
|
|
2235
2417
|
if (this.effects.speedRamp.enabled) {
|
|
2236
|
-
processFrames = this.applySpeedRamp(
|
|
2418
|
+
processFrames = this.applySpeedRamp(processFrames);
|
|
2419
|
+
}
|
|
2420
|
+
if (this.effects.smartSpeed.enabled) {
|
|
2421
|
+
processFrames = this.applySmartSpeed(processFrames);
|
|
2237
2422
|
}
|
|
2238
2423
|
const contexts = this.calculateFrameContexts(processFrames);
|
|
2239
2424
|
const cpuCount = os.cpus().length;
|
|
@@ -2329,11 +2514,27 @@ var CanvasRenderer = class {
|
|
|
2329
2514
|
this.effects.zoom.scale,
|
|
2330
2515
|
this.effects.zoom.intensity
|
|
2331
2516
|
);
|
|
2517
|
+
const mergeGap = Math.round(transitionFrames * 1.5);
|
|
2518
|
+
const zoomZones = this.effects.zoom.enabled ? mergeClickZones(clickLookup, mergeGap) : [];
|
|
2519
|
+
const zoomEasing = this.effects.zoom.easing === "spring" ? "spring" : "cubic";
|
|
2520
|
+
const clickPositions = /* @__PURE__ */ new Map();
|
|
2521
|
+
if (this.effects.zoom.enabled) {
|
|
2522
|
+
for (const ci of clickLookup) {
|
|
2523
|
+
const pos = frames[ci].clickPosition;
|
|
2524
|
+
if (pos) clickPositions.set(ci, pos);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2332
2527
|
for (let i = 0; i < frames.length; i++) {
|
|
2333
2528
|
const frame = frames[i];
|
|
2334
2529
|
let zoomScale = 1;
|
|
2335
2530
|
if (this.effects.zoom.enabled) {
|
|
2336
|
-
zoomScale =
|
|
2531
|
+
zoomScale = zoomZones.length > 0 ? calculateAdaptiveZoomFromZones(
|
|
2532
|
+
zoomZones,
|
|
2533
|
+
i,
|
|
2534
|
+
effectiveScale,
|
|
2535
|
+
transitionFrames,
|
|
2536
|
+
zoomEasing
|
|
2537
|
+
) : calculateAdaptiveZoomFromLookup(
|
|
2337
2538
|
clickLookup,
|
|
2338
2539
|
i,
|
|
2339
2540
|
effectiveScale,
|
|
@@ -2351,10 +2552,56 @@ var CanvasRenderer = class {
|
|
|
2351
2552
|
trail.push(frames[j].cursorPosition);
|
|
2352
2553
|
}
|
|
2353
2554
|
}
|
|
2354
|
-
|
|
2555
|
+
let focusOverride;
|
|
2556
|
+
if (zoomScale > 1 && zoomZones.length > 0 && clickLookup.length > 1) {
|
|
2557
|
+
focusOverride = this.interpolateFocusInZone(
|
|
2558
|
+
i,
|
|
2559
|
+
clickLookup,
|
|
2560
|
+
clickPositions,
|
|
2561
|
+
frames
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
contexts.push({ zoomScale, clickProgress, cursorTrail: trail, focusOverride });
|
|
2355
2565
|
}
|
|
2356
2566
|
return contexts;
|
|
2357
2567
|
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Interpolate the zoom focus point between adjacent clicks within a zone.
|
|
2570
|
+
*
|
|
2571
|
+
* Without this, the camera jumps instantly from one click position to the
|
|
2572
|
+
* next when a merged zone contains multiple clicks. This method produces
|
|
2573
|
+
* a smooth pan by linearly interpolating between the previous and next
|
|
2574
|
+
* click positions relative to the current frame index.
|
|
2575
|
+
*
|
|
2576
|
+
* Falls back to the nearest click position when the frame is before the
|
|
2577
|
+
* first click or after the last click in the lookup.
|
|
2578
|
+
*/
|
|
2579
|
+
interpolateFocusInZone(frameIndex, clickLookup, clickPositions, frames) {
|
|
2580
|
+
let lo = 0;
|
|
2581
|
+
let hi = clickLookup.length;
|
|
2582
|
+
while (lo < hi) {
|
|
2583
|
+
const mid = lo + hi >>> 1;
|
|
2584
|
+
if (clickLookup[mid] < frameIndex) lo = mid + 1;
|
|
2585
|
+
else hi = mid;
|
|
2586
|
+
}
|
|
2587
|
+
const prevIdx = lo > 0 ? clickLookup[lo - 1] : -1;
|
|
2588
|
+
const nextIdx = lo < clickLookup.length ? clickLookup[lo] : -1;
|
|
2589
|
+
const prevPos = prevIdx >= 0 ? clickPositions.get(prevIdx) : void 0;
|
|
2590
|
+
const nextPos = nextIdx >= 0 ? clickPositions.get(nextIdx) : void 0;
|
|
2591
|
+
if (nextIdx === frameIndex && nextPos) return nextPos;
|
|
2592
|
+
if (prevPos && nextPos && prevIdx < frameIndex && nextIdx > frameIndex) {
|
|
2593
|
+
const span = nextIdx - prevIdx;
|
|
2594
|
+
if (span <= 0) return prevPos;
|
|
2595
|
+
const t = (frameIndex - prevIdx) / span;
|
|
2596
|
+
return {
|
|
2597
|
+
x: prevPos.x + (nextPos.x - prevPos.x) * t,
|
|
2598
|
+
y: prevPos.y + (nextPos.y - prevPos.y) * t
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
if (prevPos) return prevPos;
|
|
2602
|
+
if (nextPos) return nextPos;
|
|
2603
|
+
return void 0;
|
|
2604
|
+
}
|
|
2358
2605
|
/**
|
|
2359
2606
|
* Apply speed ramping: slow down near actions, speed up during idle.
|
|
2360
2607
|
*/
|
|
@@ -2387,6 +2634,69 @@ var CanvasRenderer = class {
|
|
|
2387
2634
|
}
|
|
2388
2635
|
return result;
|
|
2389
2636
|
}
|
|
2637
|
+
/**
|
|
2638
|
+
* Apply smart speed: compress smartWait periods based on per-frame metadata.
|
|
2639
|
+
*
|
|
2640
|
+
* Unlike applySpeedRamp (which uses click proximity heuristics), smartSpeed
|
|
2641
|
+
* reads the `isWaitingPhase` flag set by the recorder during smartWait actions.
|
|
2642
|
+
* Frames in a waiting phase are downsampled by their `displaySpeed` multiplier.
|
|
2643
|
+
*
|
|
2644
|
+
* Streaming-compatible: each frame is independently decidable (no lookahead
|
|
2645
|
+
* needed), so this can run inline during streaming composition.
|
|
2646
|
+
*/
|
|
2647
|
+
applySmartSpeed(frames) {
|
|
2648
|
+
const config = this.effects.smartSpeed;
|
|
2649
|
+
if (!config.enabled) return frames;
|
|
2650
|
+
const transitionMargin = Math.round(
|
|
2651
|
+
this.output.fps * (config.transitionDuration / 1e3)
|
|
2652
|
+
);
|
|
2653
|
+
const segments = [];
|
|
2654
|
+
let segStart = -1;
|
|
2655
|
+
let segSpeed = config.waitSpeed;
|
|
2656
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2657
|
+
if (frames[i].isWaitingPhase) {
|
|
2658
|
+
if (segStart < 0) {
|
|
2659
|
+
segStart = i;
|
|
2660
|
+
segSpeed = frames[i].displaySpeed ?? config.waitSpeed;
|
|
2661
|
+
}
|
|
2662
|
+
} else if (segStart >= 0) {
|
|
2663
|
+
segments.push({ start: segStart, end: i - 1, speed: segSpeed });
|
|
2664
|
+
segStart = -1;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
if (segStart >= 0) segments.push({ start: segStart, end: frames.length - 1, speed: segSpeed });
|
|
2668
|
+
const skipRates = new Array(frames.length).fill(1);
|
|
2669
|
+
for (const seg of segments) {
|
|
2670
|
+
const segLen = seg.end - seg.start + 1;
|
|
2671
|
+
const skipRate = Math.max(1, Math.round(seg.speed));
|
|
2672
|
+
for (let i = seg.start; i <= seg.end; i++) {
|
|
2673
|
+
const fromStart = i - seg.start;
|
|
2674
|
+
const fromEnd = seg.end - i;
|
|
2675
|
+
if (fromStart < transitionMargin || fromEnd < transitionMargin) {
|
|
2676
|
+
skipRates[i] = 1;
|
|
2677
|
+
} else if (segLen < transitionMargin * 3) {
|
|
2678
|
+
skipRates[i] = Math.max(1, Math.round(skipRate / 2));
|
|
2679
|
+
} else {
|
|
2680
|
+
skipRates[i] = skipRate;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
const result = [];
|
|
2685
|
+
let skipCounter = 0;
|
|
2686
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2687
|
+
const rate = skipRates[i];
|
|
2688
|
+
if (rate <= 1) {
|
|
2689
|
+
skipCounter = 0;
|
|
2690
|
+
result.push({ ...frames[i], index: result.length });
|
|
2691
|
+
} else {
|
|
2692
|
+
skipCounter++;
|
|
2693
|
+
if (skipCounter % rate === 1) {
|
|
2694
|
+
result.push({ ...frames[i], index: result.length });
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
return result;
|
|
2699
|
+
}
|
|
2390
2700
|
// ─── Online streaming pipeline (Phase 3-B) ─────────────────────────────────
|
|
2391
2701
|
/**
|
|
2392
2702
|
* Returns true when no effect requires the full frame array upfront.
|
|
@@ -2416,19 +2726,56 @@ var CanvasRenderer = class {
|
|
|
2416
2726
|
* using the same applyTransitionsToStream() logic as composeStream().
|
|
2417
2727
|
*/
|
|
2418
2728
|
async *composeStreamOnline(source) {
|
|
2729
|
+
const filteredSource = this.effects.smartSpeed.enabled ? this.filterSmartSpeedInline(source) : source;
|
|
2419
2730
|
const hasFadeTransitions = this.steps.some((s) => s.transition !== "none");
|
|
2420
2731
|
if (!hasFadeTransitions) {
|
|
2421
2732
|
const cpuCount = os.cpus().length;
|
|
2422
2733
|
const workerCount = Math.min(cpuCount, 8);
|
|
2423
|
-
yield* this.streamOnlineWithWorkers(
|
|
2734
|
+
yield* this.streamOnlineWithWorkers(filteredSource, workerCount);
|
|
2424
2735
|
return;
|
|
2425
2736
|
}
|
|
2426
2737
|
const collected = [];
|
|
2427
|
-
for await (const frame of
|
|
2738
|
+
for await (const frame of filteredSource) {
|
|
2428
2739
|
collected.push(frame);
|
|
2429
2740
|
}
|
|
2430
2741
|
yield* this.composeStream(collected);
|
|
2431
2742
|
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Inline async filter for smartSpeed in streaming pipelines.
|
|
2745
|
+
*
|
|
2746
|
+
* Applies ease-in at the start of a waiting phase: the first
|
|
2747
|
+
* `transitionMargin` frames are kept at normal speed so the loader
|
|
2748
|
+
* is visible, then frames are skipped at displaySpeed rate.
|
|
2749
|
+
* When waiting ends, frames immediately return to normal speed.
|
|
2750
|
+
*/
|
|
2751
|
+
async *filterSmartSpeedInline(source) {
|
|
2752
|
+
const config = this.effects.smartSpeed;
|
|
2753
|
+
const transitionMargin = Math.round(
|
|
2754
|
+
this.output.fps * (config.transitionDuration / 1e3)
|
|
2755
|
+
);
|
|
2756
|
+
let waitFrameCounter = 0;
|
|
2757
|
+
let skipCounter = 0;
|
|
2758
|
+
let outputIndex = 0;
|
|
2759
|
+
for await (const frame of source) {
|
|
2760
|
+
if (frame.isWaitingPhase) {
|
|
2761
|
+
waitFrameCounter++;
|
|
2762
|
+
if (waitFrameCounter <= transitionMargin) {
|
|
2763
|
+
yield { ...frame, index: outputIndex++ };
|
|
2764
|
+
} else {
|
|
2765
|
+
const speed = frame.displaySpeed ?? config.waitSpeed;
|
|
2766
|
+
const skipRate = Math.max(1, Math.round(speed));
|
|
2767
|
+
skipCounter++;
|
|
2768
|
+
if (skipCounter % skipRate === 1) {
|
|
2769
|
+
yield { ...frame, index: outputIndex++ };
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
} else {
|
|
2773
|
+
waitFrameCounter = 0;
|
|
2774
|
+
skipCounter = 0;
|
|
2775
|
+
yield { ...frame, index: outputIndex++ };
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2432
2779
|
/**
|
|
2433
2780
|
* Worker-pool online streaming: dispatches frame i to a worker as soon as
|
|
2434
2781
|
* frame i + transitionFrames has arrived from the source.
|
|
@@ -2573,7 +2920,10 @@ var CanvasRenderer = class {
|
|
|
2573
2920
|
if (frames.length === 0) return;
|
|
2574
2921
|
let processFrames = frames;
|
|
2575
2922
|
if (this.effects.speedRamp.enabled) {
|
|
2576
|
-
processFrames = this.applySpeedRamp(
|
|
2923
|
+
processFrames = this.applySpeedRamp(processFrames);
|
|
2924
|
+
}
|
|
2925
|
+
if (this.effects.smartSpeed.enabled) {
|
|
2926
|
+
processFrames = this.applySmartSpeed(processFrames);
|
|
2577
2927
|
}
|
|
2578
2928
|
const contexts = this.calculateFrameContexts(processFrames);
|
|
2579
2929
|
const windows = this.getTransitionWindows(processFrames);
|
|
@@ -2816,9 +3166,9 @@ import { tmpdir } from "os";
|
|
|
2816
3166
|
import { spawn } from "child_process";
|
|
2817
3167
|
var { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
2818
3168
|
var ENCODING_PRESETS = {
|
|
2819
|
-
social: { crf: 22, vtQuality: 75 },
|
|
2820
|
-
balanced: { crf: 18, vtQuality: 85 },
|
|
2821
|
-
archive: { crf: 13, vtQuality: 92 }
|
|
3169
|
+
social: { crf: 22, vtQuality: 75, x264Preset: "medium" },
|
|
3170
|
+
balanced: { crf: 18, vtQuality: 85, x264Preset: "slow" },
|
|
3171
|
+
archive: { crf: 13, vtQuality: 92, x264Preset: "veryslow" }
|
|
2822
3172
|
};
|
|
2823
3173
|
function resolveEncodingParams(config) {
|
|
2824
3174
|
if (config.preset) return ENCODING_PRESETS[config.preset];
|
|
@@ -2830,24 +3180,42 @@ function resolveEncodingParams(config) {
|
|
|
2830
3180
|
if (config.quality >= 45) return ENCODING_PRESETS.balanced;
|
|
2831
3181
|
return ENCODING_PRESETS.archive;
|
|
2832
3182
|
}
|
|
2833
|
-
var
|
|
2834
|
-
function
|
|
2835
|
-
if (!
|
|
2836
|
-
|
|
3183
|
+
var encoderScanPromise = null;
|
|
3184
|
+
function scanAvailableEncoders() {
|
|
3185
|
+
if (!encoderScanPromise) {
|
|
3186
|
+
encoderScanPromise = new Promise((resolve2) => {
|
|
2837
3187
|
const proc = spawn("ffmpeg", ["-encoders"], {
|
|
2838
3188
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2839
3189
|
});
|
|
2840
3190
|
let out = "";
|
|
2841
3191
|
proc.stdout.on("data", (d) => out += d.toString());
|
|
2842
3192
|
proc.on("close", () => {
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
3193
|
+
resolve2({
|
|
3194
|
+
hevcHw: out.includes("hevc_videotoolbox"),
|
|
3195
|
+
h264Hw: out.includes("h264_videotoolbox"),
|
|
3196
|
+
av1: out.includes("libsvtav1")
|
|
3197
|
+
});
|
|
2846
3198
|
});
|
|
2847
|
-
proc.on("error", () => resolve2(
|
|
3199
|
+
proc.on("error", () => resolve2({ hevcHw: false, h264Hw: false, av1: false }));
|
|
2848
3200
|
});
|
|
2849
3201
|
}
|
|
2850
|
-
return
|
|
3202
|
+
return encoderScanPromise;
|
|
3203
|
+
}
|
|
3204
|
+
async function detectVideoEncoder(codec = "auto") {
|
|
3205
|
+
const avail = await scanAvailableEncoders();
|
|
3206
|
+
switch (codec) {
|
|
3207
|
+
case "av1":
|
|
3208
|
+
return avail.av1 ? "libsvtav1" : "libx264";
|
|
3209
|
+
case "hevc":
|
|
3210
|
+
return avail.hevcHw ? "hevc_videotoolbox" : "libx264";
|
|
3211
|
+
case "h264":
|
|
3212
|
+
return avail.h264Hw ? "h264_videotoolbox" : "libx264";
|
|
3213
|
+
case "auto":
|
|
3214
|
+
default:
|
|
3215
|
+
if (avail.hevcHw) return "hevc_videotoolbox";
|
|
3216
|
+
if (avail.h264Hw) return "h264_videotoolbox";
|
|
3217
|
+
return "libx264";
|
|
3218
|
+
}
|
|
2851
3219
|
}
|
|
2852
3220
|
async function encodeGif(frames, config) {
|
|
2853
3221
|
if (frames.length === 0) {
|
|
@@ -2871,7 +3239,7 @@ async function encodeGif(frames, config) {
|
|
|
2871
3239
|
async function encodeMp4Stream(frames, config, audio) {
|
|
2872
3240
|
const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
2873
3241
|
try {
|
|
2874
|
-
const encoder = await detectVideoEncoder();
|
|
3242
|
+
const encoder = await detectVideoEncoder(config.codec);
|
|
2875
3243
|
const params = resolveEncodingParams(config);
|
|
2876
3244
|
await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
|
|
2877
3245
|
return await readFile2(outputPath);
|
|
@@ -2887,9 +3255,15 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
2887
3255
|
"-q:v",
|
|
2888
3256
|
String(params.vtQuality),
|
|
2889
3257
|
"-pix_fmt",
|
|
2890
|
-
"
|
|
3258
|
+
"p010le",
|
|
2891
3259
|
"-tag:v",
|
|
2892
|
-
"hvc1"
|
|
3260
|
+
"hvc1",
|
|
3261
|
+
"-color_primaries",
|
|
3262
|
+
"bt709",
|
|
3263
|
+
"-color_trc",
|
|
3264
|
+
"bt709",
|
|
3265
|
+
"-colorspace",
|
|
3266
|
+
"bt709"
|
|
2893
3267
|
] : encoder === "h264_videotoolbox" ? [
|
|
2894
3268
|
"-c:v",
|
|
2895
3269
|
"h264_videotoolbox",
|
|
@@ -2903,9 +3277,9 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
2903
3277
|
"-crf",
|
|
2904
3278
|
String(params.crf),
|
|
2905
3279
|
"-preset",
|
|
2906
|
-
|
|
3280
|
+
params.x264Preset,
|
|
2907
3281
|
"-tune",
|
|
2908
|
-
"
|
|
3282
|
+
"animation",
|
|
2909
3283
|
"-profile:v",
|
|
2910
3284
|
"high",
|
|
2911
3285
|
"-level",
|