clipwise 0.7.0 → 0.7.2

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/index.js CHANGED
@@ -141,9 +141,16 @@ var ClipwiseRecorder = class {
141
141
  this.cursorSpeed = scenario.effects.cursor.speed;
142
142
  this.loaderDetectionEnabled = scenario.effects.smartSpeed?.enabled ?? false;
143
143
  this.browser = await chromium.launch({ headless: true });
144
- this.context = await this.browser.newContext({
144
+ const contextOptions = {
145
145
  viewport: this.viewport
146
- });
146
+ };
147
+ if (scenario.auth?.storageState) {
148
+ contextOptions.storageState = scenario.auth.storageState;
149
+ }
150
+ this.context = await this.browser.newContext(contextOptions);
151
+ if (scenario.auth?.cookies?.length) {
152
+ await this.context.addCookies(scenario.auth.cookies);
153
+ }
147
154
  this.page = await this.context.newPage();
148
155
  this.rawFrames = [];
149
156
  this.cursorTimeline = [];
@@ -466,6 +473,31 @@ var ClipwiseRecorder = class {
466
473
  }
467
474
  }
468
475
  }
476
+ /**
477
+ * 조건 대기 중 프레임을 연속 캡처하는 헬퍼.
478
+ * smartWait과 동일한 패턴: isWaitingPhase 플래그 + forceRepaint 루프를 조건 promise와 병렬 실행.
479
+ */
480
+ async waitForConditionWithCapture(conditionPromise, displaySpeed) {
481
+ this.isWaitingPhase = true;
482
+ this.currentDisplaySpeed = displaySpeed;
483
+ try {
484
+ let waitDone = false;
485
+ const repaintLoop = (async () => {
486
+ let toggle = false;
487
+ while (!waitDone && this.isCapturing && this.page) {
488
+ await this.forceRepaint(toggle);
489
+ toggle = !toggle;
490
+ await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
491
+ }
492
+ })();
493
+ await conditionPromise;
494
+ waitDone = true;
495
+ await repaintLoop;
496
+ } finally {
497
+ this.isWaitingPhase = false;
498
+ this.currentDisplaySpeed = void 0;
499
+ }
500
+ }
469
501
  /**
470
502
  * Pre-register waitForResponse listeners at the start of each step.
471
503
  * This ensures the listener is active before any preceding action
@@ -556,6 +588,17 @@ var ClipwiseRecorder = class {
556
588
  lastClickRefresh = now;
557
589
  }
558
590
  }
591
+ await this.page.evaluate((sel) => {
592
+ const el = document.querySelector(sel);
593
+ if (!el) return;
594
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
595
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
596
+ if (setter) {
597
+ setter.call(el, el.value);
598
+ el.dispatchEvent(new Event("input", { bubbles: true }));
599
+ el.dispatchEvent(new Event("change", { bubbles: true }));
600
+ }
601
+ }, action.selector);
559
602
  this.clickTimeline.push({
560
603
  position: { ...inputTarget },
561
604
  timestamp: Date.now()
@@ -629,83 +672,89 @@ var ClipwiseRecorder = class {
629
672
  }
630
673
  case "waitForSelector": {
631
674
  const locator = this.page.locator(action.selector).first();
632
- await locator.waitFor({ state: action.state, timeout: action.timeout });
675
+ const selectorPromise = locator.waitFor({ state: action.state, timeout: action.timeout });
676
+ if (action.captureWhileWaiting) {
677
+ await this.waitForConditionWithCapture(selectorPromise, action.displaySpeed);
678
+ } else {
679
+ await selectorPromise;
680
+ }
633
681
  break;
634
682
  }
635
683
  case "waitForNavigation": {
636
- await this.page.waitForLoadState(action.waitUntil, { timeout: action.timeout });
684
+ const navPromise = this.page.waitForLoadState(action.waitUntil, { timeout: action.timeout });
685
+ if (action.captureWhileWaiting) {
686
+ await this.waitForConditionWithCapture(navPromise, action.displaySpeed);
687
+ } else {
688
+ await navPromise;
689
+ }
637
690
  break;
638
691
  }
639
692
  case "waitForURL": {
640
- await this.page.waitForURL(action.url, { timeout: action.timeout });
693
+ const urlPromise = this.page.waitForURL(action.url, { timeout: action.timeout });
694
+ if (action.captureWhileWaiting) {
695
+ await this.waitForConditionWithCapture(urlPromise, action.displaySpeed);
696
+ } else {
697
+ await urlPromise;
698
+ }
641
699
  break;
642
700
  }
643
701
  case "waitForFunction": {
644
- await this.page.waitForFunction(action.expression, void 0, {
702
+ const fnPromise = this.page.waitForFunction(action.expression, void 0, {
645
703
  polling: action.polling,
646
704
  timeout: action.timeout
647
705
  });
706
+ if (action.captureWhileWaiting) {
707
+ await this.waitForConditionWithCapture(fnPromise, action.displaySpeed);
708
+ } else {
709
+ await fnPromise;
710
+ }
648
711
  break;
649
712
  }
650
713
  case "waitForResponse": {
651
714
  const pending = this.pendingResponsePromises.get(actionIndex);
652
715
  if (pending) {
653
- await pending;
716
+ if (action.captureWhileWaiting) {
717
+ await this.waitForConditionWithCapture(pending, action.displaySpeed);
718
+ } else {
719
+ await pending;
720
+ }
654
721
  }
655
722
  break;
656
723
  }
657
724
  case "smartWait": {
658
- this.isWaitingPhase = true;
659
- this.currentDisplaySpeed = action.displaySpeed;
660
- try {
661
- let conditionPromise;
662
- switch (action.until) {
663
- case "networkIdle":
664
- conditionPromise = this.page.waitForLoadState("networkidle", { timeout: action.timeout });
665
- break;
666
- case "selector":
667
- conditionPromise = action.selector ? this.page.locator(action.selector).first().waitFor({ state: "visible", timeout: action.timeout }) : Promise.resolve();
668
- break;
669
- case "domStable":
670
- conditionPromise = this.page.waitForFunction(
671
- () => new Promise((resolve) => {
672
- let timer;
673
- const observer = new MutationObserver(() => {
674
- clearTimeout(timer);
675
- timer = setTimeout(() => {
676
- observer.disconnect();
677
- resolve(true);
678
- }, 500);
679
- });
680
- observer.observe(document.body, { childList: true, subtree: true, attributes: true });
725
+ let conditionPromise;
726
+ switch (action.until) {
727
+ case "networkIdle":
728
+ conditionPromise = this.page.waitForLoadState("networkidle", { timeout: action.timeout });
729
+ break;
730
+ case "selector":
731
+ conditionPromise = action.selector ? this.page.locator(action.selector).first().waitFor({ state: "visible", timeout: action.timeout }) : Promise.resolve();
732
+ break;
733
+ case "domStable":
734
+ conditionPromise = this.page.waitForFunction(
735
+ () => new Promise((resolve) => {
736
+ let timer;
737
+ const observer = new MutationObserver(() => {
738
+ clearTimeout(timer);
681
739
  timer = setTimeout(() => {
682
740
  observer.disconnect();
683
741
  resolve(true);
684
742
  }, 500);
685
- }),
686
- void 0,
687
- { timeout: action.timeout }
688
- );
689
- break;
690
- default:
691
- conditionPromise = Promise.resolve();
692
- }
693
- let waitDone = false;
694
- const repaintLoop = (async () => {
695
- let toggle = false;
696
- while (!waitDone && this.isCapturing && this.page) {
697
- await this.forceRepaint(toggle);
698
- toggle = !toggle;
699
- await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
700
- }
701
- })();
702
- await conditionPromise;
703
- waitDone = true;
704
- await repaintLoop;
705
- } finally {
706
- this.isWaitingPhase = false;
707
- this.currentDisplaySpeed = void 0;
743
+ });
744
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true });
745
+ timer = setTimeout(() => {
746
+ observer.disconnect();
747
+ resolve(true);
748
+ }, 500);
749
+ }),
750
+ void 0,
751
+ { timeout: action.timeout }
752
+ );
753
+ break;
754
+ default:
755
+ conditionPromise = Promise.resolve();
708
756
  }
757
+ await this.waitForConditionWithCapture(conditionPromise, action.displaySpeed);
709
758
  break;
710
759
  }
711
760
  }
@@ -1551,6 +1600,44 @@ import sharp5 from "sharp";
1551
1600
  function escapeXml(s) {
1552
1601
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1553
1602
  }
1603
+ function isCJK(ch) {
1604
+ const code = ch.codePointAt(0) ?? 0;
1605
+ return code >= 4352 && code <= 4607 || // 한글 자모
1606
+ code >= 11904 && code <= 40959 || // CJK 부수, 한자
1607
+ code >= 44032 && code <= 55215 || // 한글 음절
1608
+ code >= 63744 && code <= 64255 || // CJK 호환 한자
1609
+ code >= 65072 && code <= 65103 || // CJK 호환 형태
1610
+ code >= 65280 && code <= 65519 || // Full-width 문자
1611
+ code >= 12288 && code <= 12543 || // CJK 기호, 히라가나, 가타카나
1612
+ code >= 12784 && code <= 12799 || // 가타카나 확장
1613
+ code >= 131072 && code <= 195103;
1614
+ }
1615
+ function displayWidth(text) {
1616
+ let w = 0;
1617
+ for (const ch of text) {
1618
+ w += isCJK(ch) ? 1.7 : 1;
1619
+ }
1620
+ return w;
1621
+ }
1622
+ function wrapText(text, maxWidth) {
1623
+ if (displayWidth(text) <= maxWidth) return [text];
1624
+ const lines = [];
1625
+ let current = "";
1626
+ let currentWidth = 0;
1627
+ for (const ch of text) {
1628
+ const chWidth = isCJK(ch) ? 1.7 : 1;
1629
+ if (currentWidth + chWidth > maxWidth && current.length > 0) {
1630
+ lines.push(current);
1631
+ current = ch;
1632
+ currentWidth = chWidth;
1633
+ } else {
1634
+ current += ch;
1635
+ currentWidth += chWidth;
1636
+ }
1637
+ }
1638
+ if (current.length > 0) lines.push(current);
1639
+ return lines;
1640
+ }
1554
1641
  function buildSessions(keystrokes) {
1555
1642
  const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
1556
1643
  if (!hasSessionIds) {
@@ -1584,16 +1671,22 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1584
1671
  const lineGap = Math.round(fontSize * 0.45);
1585
1672
  const charWidth = fontSize * 0.615;
1586
1673
  const maxHudWidth = frameWidth - 60 * dpr;
1587
- const maxCharsPerLine = Math.max(10, Math.floor((maxHudWidth - hudPadH * 2) / charWidth));
1588
- const lines = sessions.map(
1589
- (text) => text.length > maxCharsPerLine ? text.slice(-maxCharsPerLine) : text
1590
- );
1591
- const maxLineLen = Math.max(...lines.map((l) => l.length));
1674
+ const maxDisplayWidth = Math.max(10, (maxHudWidth - hudPadH * 2) / charWidth);
1675
+ const wrappedLines = [];
1676
+ sessions.forEach((text, sIdx) => {
1677
+ const wrapped = wrapText(text, maxDisplayWidth);
1678
+ for (const line of wrapped) {
1679
+ wrappedLines.push({ text: line, sessionIdx: sIdx });
1680
+ }
1681
+ });
1682
+ const lines = wrappedLines.map((l) => l.text);
1683
+ const totalLineCount = lines.length;
1684
+ const maxLineDisplayWidth = Math.max(...lines.map((l) => displayWidth(l)));
1592
1685
  const hudWidth = Math.min(
1593
- Math.ceil(maxLineLen * charWidth) + hudPadH * 2,
1686
+ Math.ceil(maxLineDisplayWidth * charWidth) + hudPadH * 2,
1594
1687
  maxHudWidth
1595
1688
  );
1596
- const hudHeight = Math.ceil(fontSize * lineCount + lineGap * (lineCount - 1) + hudPadV * 2);
1689
+ const hudHeight = Math.ceil(fontSize * totalLineCount + lineGap * (totalLineCount - 1) + hudPadV * 2);
1597
1690
  const margin = 30 * dpr;
1598
1691
  const hudY = frameHeight - hudHeight - margin;
1599
1692
  let hudX;
@@ -1608,18 +1701,20 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1608
1701
  default:
1609
1702
  hudX = Math.round((frameWidth - hudWidth) / 2);
1610
1703
  }
1611
- const LINE_OPACITY_FACTORS = [0.45, 0.7, 1];
1612
- const opacityFactors = LINE_OPACITY_FACTORS.slice(-lineCount);
1704
+ const SESSION_OPACITY_FACTORS = [0.45, 0.7, 1];
1705
+ const sessionOpacities = SESSION_OPACITY_FACTORS.slice(-lineCount);
1613
1706
  const rx = (8 * dpr).toFixed(1);
1614
1707
  const boxOp = (globalOpacity * 0.92).toFixed(3);
1615
1708
  const textX = hudX + hudPadH;
1616
1709
  const baselineY = hudY + hudPadV + fontSize * 0.82;
1617
- const textElements = lines.map((line, i) => {
1618
- const op = (globalOpacity * opacityFactors[i]).toFixed(3);
1710
+ const textElements = wrappedLines.map(({ text, sessionIdx }, i) => {
1711
+ const sessionPos = sessions.length <= 3 ? sessionIdx : sessionIdx - (sessions.length - 3);
1712
+ const opFactor = sessionOpacities[Math.max(0, sessionPos)] ?? 1;
1713
+ const op = (globalOpacity * opFactor).toFixed(3);
1619
1714
  const lineY = baselineY + i * (fontSize + lineGap);
1620
1715
  return `<text x="${textX}" y="${lineY}"
1621
1716
  font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1622
- fill="${config.textColor}" opacity="${op}">${escapeXml(line)}</text>`;
1717
+ fill="${config.textColor}" opacity="${op}">${escapeXml(text)}</text>`;
1623
1718
  }).join("\n ");
1624
1719
  const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1625
1720
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
@@ -3308,29 +3403,41 @@ var WaitForSelectorActionSchema = z.object({
3308
3403
  action: z.literal("waitForSelector"),
3309
3404
  selector: SafeSelectorSchema,
3310
3405
  state: z.enum(["visible", "attached", "hidden"]).default("visible"),
3311
- timeout: z.number().min(0).default(15e3)
3406
+ timeout: z.number().min(0).default(15e3),
3407
+ /** 대기 중 프레임 연속 캡처 (로딩 애니메이션 보존). */
3408
+ captureWhileWaiting: z.boolean().default(false),
3409
+ /** captureWhileWaiting 사용 시 출력 영상 속도 배율 (1-32). */
3410
+ displaySpeed: z.number().min(1).max(32).default(8)
3312
3411
  });
3313
3412
  var WaitForNavigationActionSchema = z.object({
3314
3413
  action: z.literal("waitForNavigation"),
3315
3414
  waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).default("networkidle"),
3316
- timeout: z.number().min(0).default(15e3)
3415
+ timeout: z.number().min(0).default(15e3),
3416
+ captureWhileWaiting: z.boolean().default(false),
3417
+ displaySpeed: z.number().min(1).max(32).default(8)
3317
3418
  });
3318
3419
  var WaitForURLActionSchema = z.object({
3319
3420
  action: z.literal("waitForURL"),
3320
3421
  url: z.string().min(1),
3321
- timeout: z.number().min(0).default(15e3)
3422
+ timeout: z.number().min(0).default(15e3),
3423
+ captureWhileWaiting: z.boolean().default(false),
3424
+ displaySpeed: z.number().min(1).max(32).default(8)
3322
3425
  });
3323
3426
  var WaitForFunctionActionSchema = z.object({
3324
3427
  action: z.literal("waitForFunction"),
3325
3428
  expression: z.string().min(1),
3326
3429
  polling: z.union([z.literal("raf"), z.number().min(0)]).default("raf"),
3327
- timeout: z.number().min(0).default(3e4)
3430
+ timeout: z.number().min(0).default(3e4),
3431
+ captureWhileWaiting: z.boolean().default(false),
3432
+ displaySpeed: z.number().min(1).max(32).default(8)
3328
3433
  });
3329
3434
  var WaitForResponseActionSchema = z.object({
3330
3435
  action: z.literal("waitForResponse"),
3331
3436
  url: z.string().min(1),
3332
3437
  status: z.number().min(100).max(599).optional(),
3333
- timeout: z.number().min(0).default(3e4)
3438
+ timeout: z.number().min(0).default(3e4),
3439
+ captureWhileWaiting: z.boolean().default(false),
3440
+ displaySpeed: z.number().min(1).max(32).default(8)
3334
3441
  });
3335
3442
  var SmartWaitActionSchema = z.object({
3336
3443
  action: z.literal("smartWait"),
@@ -3525,6 +3632,22 @@ var AudioConfigSchema = z.object({
3525
3632
  /** Fade-out duration in milliseconds. */
3526
3633
  fadeOut: z.number().min(0).default(0)
3527
3634
  });
3635
+ var AuthConfigSchema = z.object({
3636
+ /** Path to a Playwright storageState JSON file (cookies + localStorage). */
3637
+ storageState: z.string().optional(),
3638
+ /** Inline cookie definitions (applied after storageState if both specified). */
3639
+ cookies: z.array(
3640
+ z.object({
3641
+ name: z.string(),
3642
+ value: z.string(),
3643
+ domain: z.string(),
3644
+ path: z.string().default("/"),
3645
+ httpOnly: z.boolean().default(false),
3646
+ secure: z.boolean().default(false),
3647
+ sameSite: z.enum(["Strict", "Lax", "None"]).default("Lax")
3648
+ })
3649
+ ).optional()
3650
+ });
3528
3651
  var ScenarioSchema = z.object({
3529
3652
  name: z.string(),
3530
3653
  description: z.string().optional(),
@@ -3532,6 +3655,8 @@ var ScenarioSchema = z.object({
3532
3655
  width: z.number().default(1280),
3533
3656
  height: z.number().default(800)
3534
3657
  }).default({}),
3658
+ /** Optional authentication — restores browser session for logged-in pages. */
3659
+ auth: AuthConfigSchema.optional(),
3535
3660
  effects: EffectsConfigSchema.default({}),
3536
3661
  output: OutputConfigSchema.default({}),
3537
3662
  /** Optional audio narration — muxed into MP4 output. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Scriptable cinematic screen recorder for product demos — YAML in, polished MP4 out",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,6 +52,7 @@ output:
52
52
  height: 800 # Output height
53
53
  fps: 30 # 1-60
54
54
  preset: balanced # social | balanced | archive
55
+ codec: auto # auto | h264 | hevc | av1
55
56
  outputDir: "./output"
56
57
  filename: "my-recording"
57
58
 
@@ -71,7 +72,7 @@ steps: [] # Array of steps (min 1, first must have navigate)
71
72
  actions: [] # Array of actions
72
73
  ```
73
74
 
74
- ### Actions (12 types)
75
+ ### Actions (13 types)
75
76
 
76
77
  #### Basic Actions
77
78
 
@@ -169,6 +170,16 @@ steps: [] # Array of steps (min 1, first must have navigate)
169
170
  timeout: 30000
170
171
  ```
171
172
 
173
+ 13. **smartWait** — Record real wait time, then auto-compress in output
174
+ ```yaml
175
+ - action: smartWait
176
+ until: networkIdle # networkIdle | selector | domStable
177
+ selector: ".results" # Required when until=selector
178
+ timeout: 30000 # Max wait ms (default: 30000)
179
+ displaySpeed: 8 # Speed multiplier for output (1-32, default: 8)
180
+ ```
181
+ > Unlike fixed `wait`, `smartWait` captures frames during the wait period (with forced repaints to bypass dedup), then compresses them at `displaySpeed` in the final video. Use this for API calls, loading states, streaming responses.
182
+
172
183
  ### Effects Configuration
173
184
 
174
185
  #### Zoom — Adaptive zoom follows cursor on clicks (smart camera: auto-suppressed during scroll)
@@ -178,6 +189,7 @@ zoom:
178
189
  intensity: light # subtle(1.15x) | light(1.25x) | moderate(1.35x) | strong(1.5x) | dramatic(1.8x)
179
190
  # scale: 1.25 # Or use numeric value (overridden by intensity)
180
191
  duration: 800 # Zoom animation ms
192
+ easing: ease-in-out # ease-in-out | ease-in | ease-out | linear | spring
181
193
  autoZoom:
182
194
  followCursor: true # Viewport pans to follow cursor position
183
195
  transitionDuration: 300
@@ -261,6 +273,17 @@ speedRamp:
261
273
  transitionFrames: 15
262
274
  ```
263
275
 
276
+ #### Smart Speed — Content-aware speed control (auto-compresses wait/loading periods)
277
+ ```yaml
278
+ smartSpeed:
279
+ enabled: true
280
+ waitSpeed: 8 # Speed multiplier for smartWait frames (1-32, default: 8)
281
+ idleSpeed: 4 # Speed multiplier for idle frames (1-16, default: 4)
282
+ transitionDuration: 300 # Ease duration between speed changes (ms)
283
+ minSegmentDuration: 500 # Don't speed up segments shorter than this (ms)
284
+ ```
285
+ > Unlike `speedRamp` (click-based), `smartSpeed` uses semantic metadata from `smartWait` actions and per-frame change scoring. Pairs naturally with `smartWait` for loading/API-call compression.
286
+
264
287
  #### Audio Narration — Attach audio to MP4 output
265
288
  ```yaml
266
289
  audio:
@@ -315,6 +338,11 @@ steps:
315
338
  9. **Smart camera**: zoom is automatically suppressed during scroll actions; `followCursor` pans to cursor position
316
339
  10. **Transitions**: use `fade` or `blur` for cinematic cuts between major sections; `slide-left`/`slide-up` for sequential flows
317
340
  11. **Audio**: audio file must exist at the specified path; only works with MP4 output format
341
+ 12. **Spring zoom**: use `easing: spring` for Screen Studio-like natural camera motion with fast initial response and smooth deceleration; nearby clicks are auto-merged into continuous zoom zones
342
+ 13. **Zoom sustain during typing**: zoom automatically maintains throughout `type` actions — no need to add extra click events
343
+ 14. **Auto loader detection**: CSS spinners (`@keyframes spin/rotate/pulse/bounce`) are passively detected via CDP and auto-marked for smartSpeed compression
344
+ 15. **Codec choice**: `av1` gives 40-60% smaller files but slower encode; `hevc` provides 10-bit color (no gradient banding); `auto` picks h264 for compatibility
345
+ 16. **smartWait over wait**: prefer `smartWait` over fixed `wait` for API calls and loading states — it captures real frames and auto-compresses them
318
346
 
319
347
  ## Timing Presets
320
348