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/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 isDuplicate = this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
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 rawFrame = { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex, isScrolling: this.isScrolling };
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 buildCursorSvg(size, color) {
1492
- const s = size;
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 sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
1627
+ return { input: Buffer.from(cursorSvg), left, top };
1521
1628
  }
1522
- async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
1523
- if (!config.enabled || !config.clickEffect) return frameBuffer;
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 sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
1639
+ return { input: Buffer.from(rippleSvg), left, top };
1534
1640
  }
1535
- async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
1536
- if (!config.enabled || !config.highlight) return frameBuffer;
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 sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
1661
+ return { input: Buffer.from(highlightSvg), left, top };
1556
1662
  }
1557
- async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
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 sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
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 withFrameOffset = (pos) => ({
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
- buffer = await renderCursorHighlight(
1912
- buffer,
1913
- withFrameOffset(frame.cursorPosition),
2071
+ const overlay = buildHighlightOverlay(
2072
+ withExtFrameOffset(frame.cursorPosition),
1914
2073
  effects.cursor,
1915
- width,
1916
- height,
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
- buffer = await renderCursorTrail(
1922
- buffer,
1923
- ctx.cursorTrail.map(withFrameOffset),
2081
+ const overlay = buildTrailOverlay(
2082
+ ctx.cursorTrail.map(withExtFrameOffset),
1924
2083
  effects.cursor,
1925
- width,
1926
- height,
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
- buffer = await renderCursor(
1932
- buffer,
1933
- withFrameOffset(frame.cursorPosition),
2091
+ const overlay = buildCursorOverlay(
2092
+ withExtFrameOffset(frame.cursorPosition),
1934
2093
  effects.cursor,
1935
- width,
1936
- height,
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
- buffer = await renderClickEffect(
1943
- buffer,
1944
- withFrameOffset(frame.clickPosition),
2102
+ const overlay = buildClickRippleOverlay(
2103
+ withExtFrameOffset(frame.clickPosition),
1945
2104
  effects.cursor,
1946
2105
  progress,
1947
- width,
1948
- height,
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 followCursor = effects.zoom.autoZoom.followCursor;
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(frames);
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 = calculateAdaptiveZoomFromLookup(
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
- contexts.push({ zoomScale, clickProgress, cursorTrail: trail });
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(source, workerCount);
2734
+ yield* this.streamOnlineWithWorkers(filteredSource, workerCount);
2424
2735
  return;
2425
2736
  }
2426
2737
  const collected = [];
2427
- for await (const frame of source) {
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(frames);
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 encoderDetectionPromise = null;
2834
- function detectVideoEncoder() {
2835
- if (!encoderDetectionPromise) {
2836
- encoderDetectionPromise = new Promise((resolve2) => {
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
- if (out.includes("hevc_videotoolbox")) resolve2("hevc_videotoolbox");
2844
- else if (out.includes("h264_videotoolbox")) resolve2("h264_videotoolbox");
2845
- else resolve2("libx264");
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("libx264"));
3199
+ proc.on("error", () => resolve2({ hevcHw: false, h264Hw: false, av1: false }));
2848
3200
  });
2849
3201
  }
2850
- return encoderDetectionPromise;
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
- "yuv420p",
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
- "medium",
3280
+ params.x264Preset,
2907
3281
  "-tune",
2908
- "stillimage",
3282
+ "animation",
2909
3283
  "-profile:v",
2910
3284
  "high",
2911
3285
  "-level",