clipwise 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,13 +7,11 @@ import sharp7 from "sharp";
7
7
 
8
8
  // src/effects/frame.ts
9
9
  import sharp from "sharp";
10
- var TITLE_BAR_HEIGHT = 40;
11
- var TRAFFIC_LIGHT_Y = 14;
12
- var TRAFFIC_LIGHT_RADIUS = 6;
13
- var TRAFFIC_LIGHTS_START_X = 16;
14
- var TRAFFIC_LIGHT_GAP = 22;
15
- var ADDRESS_BAR_HEIGHT = 24;
16
- var ADDRESS_BAR_MARGIN = 70;
10
+ var TITLE_BAR_HEIGHT = 48;
11
+ var TRAFFIC_LIGHT_RADIUS = 6.5;
12
+ var TRAFFIC_LIGHTS_START_X = 22;
13
+ var TRAFFIC_LIGHT_GAP = 20;
14
+ var URL_PILL_HEIGHT = 30;
17
15
  var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
18
16
  var IPHONE_OUTER_RADIUS = 47;
19
17
  var IPHONE_INNER_RADIUS = 39;
@@ -26,38 +24,89 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
26
24
  var ANDROID_OUTER_RADIUS = 35;
27
25
  var ANDROID_INNER_RADIUS = 30;
28
26
  var ANDROID_CAMERA_RADIUS = 6;
29
- function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
30
- const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
31
- const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
32
- const addressBorder = darkMode ? "#444444" : "#d0d0d0";
33
- const textColor = darkMode ? "#999999" : "#666666";
34
- const tbarH = TITLE_BAR_HEIGHT * dpr;
35
- const tlY = TRAFFIC_LIGHT_Y * dpr;
27
+ function buildBrowserChromeSvg(width, darkMode, dpr = 1, url = "localhost") {
28
+ const c = darkMode ? {
29
+ bgTop: "#3c3c3e",
30
+ bgBottom: "#343436",
31
+ border: "#232325",
32
+ pillBg: "#28282a",
33
+ pillBorder: "#48484b",
34
+ text: "#d8d8da",
35
+ icon: "#a8a8ac",
36
+ iconDim: "#5f5f63"
37
+ } : {
38
+ bgTop: "#f8f7f6",
39
+ bgBottom: "#eeedeb",
40
+ border: "#d8d6d3",
41
+ pillBg: "#ffffff",
42
+ pillBorder: "#dedcd9",
43
+ text: "#3a3a3c",
44
+ icon: "#6f6f72",
45
+ iconDim: "#bdbdc0"
46
+ };
47
+ const h = TITLE_BAR_HEIGHT * dpr;
48
+ const midY = h / 2;
36
49
  const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
37
50
  const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
38
51
  const tlGap = TRAFFIC_LIGHT_GAP * dpr;
39
- const aBarH = ADDRESS_BAR_HEIGHT * dpr;
40
- const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
41
- const fontSize = 12 * dpr;
52
+ const s = dpr;
42
53
  const trafficLights = [
43
- { cx: tlStartX, fill: "#ff5f57" },
44
- { cx: tlStartX + tlGap, fill: "#febc2e" },
45
- { cx: tlStartX + tlGap * 2, fill: "#28c840" }
46
- ].map(
47
- (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
48
- ).join("\n ");
49
- const addressBarWidth = width - aBarMargin * 2;
50
- const addressBarX = aBarMargin;
51
- const addressBarY = (tbarH - aBarH) / 2;
52
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
53
- <rect width="${width}" height="${tbarH}" fill="${bg}"/>
54
+ { cx: tlStartX, fill: "#ff5f57", stroke: "#e0443e" },
55
+ { cx: tlStartX + tlGap, fill: "#febc2e", stroke: "#d89e24" },
56
+ { cx: tlStartX + tlGap * 2, fill: "#28c840", stroke: "#1ea133" }
57
+ ].map((l) => `<circle cx="${l.cx}" cy="${midY}" r="${tlR}" fill="${l.fill}" stroke="${l.stroke}" stroke-width="${0.5 * s}"/>`).join("\n ");
58
+ const navX = tlStartX + tlGap * 2 + 34 * s;
59
+ const back = `<path d="M ${navX + 4 * s} ${midY - 6 * s} l ${-6 * s} ${6 * s} l ${6 * s} ${6 * s}"
60
+ fill="none" stroke="${c.icon}" stroke-width="${1.8 * s}" stroke-linecap="round" stroke-linejoin="round"/>`;
61
+ const fwdX = navX + 28 * s;
62
+ const forward = `<path d="M ${fwdX - 4 * s} ${midY - 6 * s} l ${6 * s} ${6 * s} l ${-6 * s} ${6 * s}"
63
+ fill="none" stroke="${c.iconDim}" stroke-width="${1.8 * s}" stroke-linecap="round" stroke-linejoin="round"/>`;
64
+ const relX = fwdX + 28 * s;
65
+ const reload = `<path d="M ${relX + 6 * s} ${midY - 3.5 * s} a ${6 * s} ${6 * s} 0 1 0 ${1.2 * s} ${5.5 * s}"
66
+ fill="none" stroke="${c.icon}" stroke-width="${1.7 * s}" stroke-linecap="round"/>
67
+ <path d="M ${relX + 6.4 * s} ${midY - 7.5 * s} l ${0.4 * s} ${4.6 * s} l ${-4.6 * s} ${-0.4 * s} z" fill="${c.icon}"/>`;
68
+ const fontSize = 12.5 * dpr;
69
+ const pillH = URL_PILL_HEIGHT * dpr;
70
+ const pillW = Math.max(200 * s, Math.min(width * 0.42, 520 * s));
71
+ const pillX = (width - pillW) / 2;
72
+ const pillY = midY - pillH / 2;
73
+ const textW = url.length * fontSize * 0.56;
74
+ const lockX = width / 2 - textW / 2 - 16 * s;
75
+ const lockY = midY - 5 * s;
76
+ const padlock = `
77
+ <rect x="${lockX}" y="${lockY + 4 * s}" width="${9 * s}" height="${7 * s}" rx="${1.5 * s}" fill="${c.icon}"/>
78
+ <path d="M ${lockX + 2 * s} ${lockY + 4 * s} v ${-2 * s} a ${2.5 * s} ${2.5 * s} 0 0 1 ${5 * s} 0 v ${2 * s}"
79
+ fill="none" stroke="${c.icon}" stroke-width="${1.4 * s}"/>`;
80
+ const dotsX = width - 26 * s;
81
+ const dots = [-4.5, 0, 4.5].map((dy) => `<circle cx="${dotsX}" cy="${midY + dy * s}" r="${1.6 * s}" fill="${c.icon}"/>`).join("");
82
+ const avatar = `
83
+ <circle cx="${width - 56 * s}" cy="${midY}" r="${11 * s}" fill="url(#cwAvatar)"/>
84
+ <text x="${width - 56 * s}" y="${midY + 4 * s}" text-anchor="middle"
85
+ font-family="system-ui, -apple-system, sans-serif" font-size="${10.5 * dpr}" font-weight="600" fill="#ffffff">S</text>`;
86
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
87
+ <defs>
88
+ <linearGradient id="cwChromeBg" x1="0" y1="0" x2="0" y2="1">
89
+ <stop offset="0" stop-color="${c.bgTop}"/>
90
+ <stop offset="1" stop-color="${c.bgBottom}"/>
91
+ </linearGradient>
92
+ <linearGradient id="cwAvatar" x1="0" y1="0" x2="1" y2="1">
93
+ <stop offset="0" stop-color="#818cf8"/>
94
+ <stop offset="1" stop-color="#6366f1"/>
95
+ </linearGradient>
96
+ </defs>
97
+ <rect width="${width}" height="${h}" fill="url(#cwChromeBg)"/>
98
+ <rect y="${h - s}" width="${width}" height="${s}" fill="${c.border}"/>
54
99
  ${trafficLights}
55
- <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
56
- rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
57
- <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
58
- font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
59
- localhost
60
- </text>
100
+ ${back}
101
+ ${forward}
102
+ ${reload}
103
+ <rect x="${pillX}" y="${pillY}" width="${pillW}" height="${pillH}"
104
+ rx="${pillH / 2}" ry="${pillH / 2}" fill="${c.pillBg}" stroke="${c.pillBorder}" stroke-width="${s}"/>
105
+ ${padlock}
106
+ <text x="${width / 2 + 7 * s}" y="${midY + fontSize * 0.35}" text-anchor="middle"
107
+ font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${c.text}">${url}</text>
108
+ ${dots}
109
+ ${avatar}
61
110
  </svg>`;
62
111
  }
63
112
  function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
@@ -203,9 +252,9 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
203
252
  { input: maskedScreen, left: bezel.sides, top: bezel.top }
204
253
  ]).png().toBuffer();
205
254
  }
206
- async function buildBrowserChromeBuffer(viewportWidth, darkMode, dpr = 1) {
255
+ async function buildBrowserChromeBuffer(viewportWidth, darkMode, dpr = 1, url = "localhost") {
207
256
  const tbarH = TITLE_BAR_HEIGHT * dpr;
208
- const chromeSvg = buildBrowserChromeSvg(viewportWidth, darkMode, dpr);
257
+ const chromeSvg = buildBrowserChromeSvg(viewportWidth, darkMode, dpr, url);
209
258
  return sharp(Buffer.from(chromeSvg)).resize(viewportWidth, tbarH).png().toBuffer();
210
259
  }
211
260
  async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
@@ -214,7 +263,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
214
263
  case "browser": {
215
264
  const tbarH = TITLE_BAR_HEIGHT * dpr;
216
265
  const totalHeight = frameHeight + tbarH;
217
- const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
266
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr, config.url);
218
267
  const chromeBuffer = Buffer.from(chromeSvg);
219
268
  const canvas = await sharp({
220
269
  create: {
@@ -460,6 +509,44 @@ import sharp5 from "sharp";
460
509
  function escapeXml(s) {
461
510
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
462
511
  }
512
+ function isCJK(ch) {
513
+ const code = ch.codePointAt(0) ?? 0;
514
+ return code >= 4352 && code <= 4607 || // 한글 자모
515
+ code >= 11904 && code <= 40959 || // CJK 부수, 한자
516
+ code >= 44032 && code <= 55215 || // 한글 음절
517
+ code >= 63744 && code <= 64255 || // CJK 호환 한자
518
+ code >= 65072 && code <= 65103 || // CJK 호환 형태
519
+ code >= 65280 && code <= 65519 || // Full-width 문자
520
+ code >= 12288 && code <= 12543 || // CJK 기호, 히라가나, 가타카나
521
+ code >= 12784 && code <= 12799 || // 가타카나 확장
522
+ code >= 131072 && code <= 195103;
523
+ }
524
+ function displayWidth(text) {
525
+ let w = 0;
526
+ for (const ch of text) {
527
+ w += isCJK(ch) ? 1.7 : 1;
528
+ }
529
+ return w;
530
+ }
531
+ function wrapText(text, maxWidth) {
532
+ if (displayWidth(text) <= maxWidth) return [text];
533
+ const lines = [];
534
+ let current = "";
535
+ let currentWidth = 0;
536
+ for (const ch of text) {
537
+ const chWidth = isCJK(ch) ? 1.7 : 1;
538
+ if (currentWidth + chWidth > maxWidth && current.length > 0) {
539
+ lines.push(current);
540
+ current = ch;
541
+ currentWidth = chWidth;
542
+ } else {
543
+ current += ch;
544
+ currentWidth += chWidth;
545
+ }
546
+ }
547
+ if (current.length > 0) lines.push(current);
548
+ return lines;
549
+ }
463
550
  function buildSessions(keystrokes) {
464
551
  const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
465
552
  if (!hasSessionIds) {
@@ -493,16 +580,22 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
493
580
  const lineGap = Math.round(fontSize * 0.45);
494
581
  const charWidth = fontSize * 0.615;
495
582
  const maxHudWidth = frameWidth - 60 * dpr;
496
- const maxCharsPerLine = Math.max(10, Math.floor((maxHudWidth - hudPadH * 2) / charWidth));
497
- const lines = sessions.map(
498
- (text) => text.length > maxCharsPerLine ? text.slice(-maxCharsPerLine) : text
499
- );
500
- const maxLineLen = Math.max(...lines.map((l) => l.length));
583
+ const maxDisplayWidth = Math.max(10, (maxHudWidth - hudPadH * 2) / charWidth);
584
+ const wrappedLines = [];
585
+ sessions.forEach((text, sIdx) => {
586
+ const wrapped = wrapText(text, maxDisplayWidth);
587
+ for (const line of wrapped) {
588
+ wrappedLines.push({ text: line, sessionIdx: sIdx });
589
+ }
590
+ });
591
+ const lines = wrappedLines.map((l) => l.text);
592
+ const totalLineCount = lines.length;
593
+ const maxLineDisplayWidth = Math.max(...lines.map((l) => displayWidth(l)));
501
594
  const hudWidth = Math.min(
502
- Math.ceil(maxLineLen * charWidth) + hudPadH * 2,
595
+ Math.ceil(maxLineDisplayWidth * charWidth) + hudPadH * 2,
503
596
  maxHudWidth
504
597
  );
505
- const hudHeight = Math.ceil(fontSize * lineCount + lineGap * (lineCount - 1) + hudPadV * 2);
598
+ const hudHeight = Math.ceil(fontSize * totalLineCount + lineGap * (totalLineCount - 1) + hudPadV * 2);
506
599
  const margin = 30 * dpr;
507
600
  const hudY = frameHeight - hudHeight - margin;
508
601
  let hudX;
@@ -517,18 +610,20 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
517
610
  default:
518
611
  hudX = Math.round((frameWidth - hudWidth) / 2);
519
612
  }
520
- const LINE_OPACITY_FACTORS = [0.45, 0.7, 1];
521
- const opacityFactors = LINE_OPACITY_FACTORS.slice(-lineCount);
613
+ const SESSION_OPACITY_FACTORS = [0.45, 0.7, 1];
614
+ const sessionOpacities = SESSION_OPACITY_FACTORS.slice(-lineCount);
522
615
  const rx = (8 * dpr).toFixed(1);
523
616
  const boxOp = (globalOpacity * 0.92).toFixed(3);
524
617
  const textX = hudX + hudPadH;
525
618
  const baselineY = hudY + hudPadV + fontSize * 0.82;
526
- const textElements = lines.map((line, i) => {
527
- const op = (globalOpacity * opacityFactors[i]).toFixed(3);
619
+ const textElements = wrappedLines.map(({ text, sessionIdx }, i) => {
620
+ const sessionPos = sessions.length <= 3 ? sessionIdx : sessionIdx - (sessions.length - 3);
621
+ const opFactor = sessionOpacities[Math.max(0, sessionPos)] ?? 1;
622
+ const op = (globalOpacity * opFactor).toFixed(3);
528
623
  const lineY = baselineY + i * (fontSize + lineGap);
529
624
  return `<text x="${textX}" y="${lineY}"
530
625
  font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
531
- fill="${config.textColor}" opacity="${op}">${escapeXml(line)}</text>`;
626
+ fill="${config.textColor}" opacity="${op}">${escapeXml(text)}</text>`;
532
627
  }).join("\n ");
533
628
  const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
534
629
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
@@ -597,7 +692,8 @@ async function buildStaticLayers(effects, output, viewportWidth, dpr) {
597
692
  browserChromePng = await buildBrowserChromeBuffer(
598
693
  viewportWidth,
599
694
  effects.deviceFrame.darkMode,
600
- dpr
695
+ dpr,
696
+ effects.deviceFrame.url
601
697
  );
602
698
  }
603
699
  return {