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.
package/dist/index.js CHANGED
@@ -54,6 +54,115 @@ async function getElementCenter(page, selector, timeout) {
54
54
  };
55
55
  }
56
56
 
57
+ // src/core/prepare.ts
58
+ import { readFile } from "fs/promises";
59
+ function buildHideCss(selectors) {
60
+ return `${selectors.join(",\n")} {
61
+ display: none !important;
62
+ visibility: hidden !important;
63
+ }`;
64
+ }
65
+ function buildCssInjectionScript(css) {
66
+ return `(() => {
67
+ const apply = () => {
68
+ const style = document.createElement("style");
69
+ style.setAttribute("data-clipwise", "prepare");
70
+ style.textContent = ${JSON.stringify(css)};
71
+ (document.head || document.documentElement).appendChild(style);
72
+ };
73
+ if (document.readyState === "loading") {
74
+ document.addEventListener("DOMContentLoaded", apply);
75
+ } else {
76
+ apply();
77
+ }
78
+ })();`;
79
+ }
80
+ function buildFreezeTimeScript(epochMs) {
81
+ return `(() => {
82
+ const frozen = ${epochMs};
83
+ const OrigDate = Date;
84
+ class FrozenDate extends OrigDate {
85
+ constructor(...args) {
86
+ if (args.length === 0) { super(frozen); } else { super(...args); }
87
+ }
88
+ static now() { return frozen; }
89
+ }
90
+ FrozenDate.parse = OrigDate.parse;
91
+ FrozenDate.UTC = OrigDate.UTC;
92
+ Object.defineProperty(globalThis, "Date", { value: FrozenDate, writable: true, configurable: true });
93
+ })();`;
94
+ }
95
+ function buildSeedRandomScript(seed) {
96
+ return `(() => {
97
+ let s = (${seed}) >>> 0;
98
+ Math.random = () => {
99
+ s = (s + 0x6D2B79F5) | 0;
100
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
101
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
102
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
103
+ };
104
+ })();`;
105
+ }
106
+ function buildStorageScript(storage) {
107
+ return `(() => {
108
+ try {
109
+ const seed = ${JSON.stringify(storage)};
110
+ for (const [k, v] of Object.entries(seed.localStorage)) localStorage.setItem(k, v);
111
+ for (const [k, v] of Object.entries(seed.sessionStorage)) sessionStorage.setItem(k, v);
112
+ } catch { /* opaque origin \u2014 storage unavailable */ }
113
+ })();`;
114
+ }
115
+ async function resolveMockBody(route) {
116
+ if (route.fixture) {
117
+ return readFile(route.fixture, "utf-8");
118
+ }
119
+ if (route.body !== void 0) {
120
+ return typeof route.body === "string" ? route.body : JSON.stringify(route.body);
121
+ }
122
+ throw new Error(`prepare.mock "${route.url}": either "fixture" or "body" is required`);
123
+ }
124
+ async function applyPrepare(context, prepare) {
125
+ if (prepare.freezeTime) {
126
+ const epochMs = Date.parse(prepare.freezeTime);
127
+ if (Number.isNaN(epochMs)) {
128
+ throw new Error(`prepare.freezeTime: invalid date "${prepare.freezeTime}" (use ISO 8601, e.g. "2026-06-10T09:00:00Z")`);
129
+ }
130
+ await context.addInitScript(buildFreezeTimeScript(epochMs));
131
+ }
132
+ if (prepare.seedRandom !== void 0) {
133
+ await context.addInitScript(buildSeedRandomScript(prepare.seedRandom));
134
+ }
135
+ if (prepare.storage) {
136
+ await context.addInitScript(buildStorageScript(prepare.storage));
137
+ }
138
+ const cssChunks = [];
139
+ if (prepare.hide.length > 0) {
140
+ cssChunks.push(buildHideCss(prepare.hide));
141
+ }
142
+ if (prepare.inject?.css) {
143
+ const cssFiles = Array.isArray(prepare.inject.css) ? prepare.inject.css : [prepare.inject.css];
144
+ for (const file of cssFiles) {
145
+ cssChunks.push(await readFile(file, "utf-8"));
146
+ }
147
+ }
148
+ if (cssChunks.length > 0) {
149
+ await context.addInitScript(buildCssInjectionScript(cssChunks.join("\n\n")));
150
+ }
151
+ if (prepare.inject?.js) {
152
+ const jsFiles = Array.isArray(prepare.inject.js) ? prepare.inject.js : [prepare.inject.js];
153
+ for (const file of jsFiles) {
154
+ await context.addInitScript(await readFile(file, "utf-8"));
155
+ }
156
+ }
157
+ for (const mock of prepare.mock) {
158
+ const body = await resolveMockBody(mock);
159
+ await context.route(
160
+ (url) => url.href.includes(mock.url),
161
+ (route) => route.fulfill({ status: mock.status, contentType: mock.contentType, body })
162
+ );
163
+ }
164
+ }
165
+
57
166
  // src/core/recorder.ts
58
167
  var CLICK_EFFECT_DURATION_MS = 500;
59
168
  var REPAINT_INTERVAL_MS = 25;
@@ -139,11 +248,23 @@ var ClipwiseRecorder = class {
139
248
  };
140
249
  this.targetFps = scenario.output.fps;
141
250
  this.cursorSpeed = scenario.effects.cursor.speed;
251
+ this.deviceScaleFactor = scenario.viewport.deviceScaleFactor ?? 1;
142
252
  this.loaderDetectionEnabled = scenario.effects.smartSpeed?.enabled ?? false;
143
253
  this.browser = await chromium.launch({ headless: true });
144
- this.context = await this.browser.newContext({
145
- viewport: this.viewport
146
- });
254
+ const contextOptions = {
255
+ viewport: this.viewport,
256
+ deviceScaleFactor: this.deviceScaleFactor
257
+ };
258
+ if (scenario.auth?.storageState) {
259
+ contextOptions.storageState = scenario.auth.storageState;
260
+ }
261
+ this.context = await this.browser.newContext(contextOptions);
262
+ if (scenario.auth?.cookies?.length) {
263
+ await this.context.addCookies(scenario.auth.cookies);
264
+ }
265
+ if (scenario.prepare) {
266
+ await applyPrepare(this.context, scenario.prepare);
267
+ }
147
268
  this.page = await this.context.newPage();
148
269
  this.rawFrames = [];
149
270
  this.cursorTimeline = [];
@@ -243,7 +364,7 @@ var ClipwiseRecorder = class {
243
364
  });
244
365
  this.cdpClient = null;
245
366
  }
246
- await new Promise((resolve) => setTimeout(resolve, 200));
367
+ await new Promise((resolve3) => setTimeout(resolve3, 200));
247
368
  }
248
369
  /**
249
370
  * Execute the full scenario with continuous capture and return a RecordingSession.
@@ -461,11 +582,36 @@ var ClipwiseRecorder = class {
461
582
  const remaining = endTime - Date.now();
462
583
  if (remaining > 0) {
463
584
  await new Promise(
464
- (resolve) => setTimeout(resolve, Math.min(REPAINT_INTERVAL_MS, remaining))
585
+ (resolve3) => setTimeout(resolve3, Math.min(REPAINT_INTERVAL_MS, remaining))
465
586
  );
466
587
  }
467
588
  }
468
589
  }
590
+ /**
591
+ * 조건 대기 중 프레임을 연속 캡처하는 헬퍼.
592
+ * smartWait과 동일한 패턴: isWaitingPhase 플래그 + forceRepaint 루프를 조건 promise와 병렬 실행.
593
+ */
594
+ async waitForConditionWithCapture(conditionPromise, displaySpeed) {
595
+ this.isWaitingPhase = true;
596
+ this.currentDisplaySpeed = displaySpeed;
597
+ try {
598
+ let waitDone = false;
599
+ const repaintLoop = (async () => {
600
+ let toggle = false;
601
+ while (!waitDone && this.isCapturing && this.page) {
602
+ await this.forceRepaint(toggle);
603
+ toggle = !toggle;
604
+ await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
605
+ }
606
+ })();
607
+ await conditionPromise;
608
+ waitDone = true;
609
+ await repaintLoop;
610
+ } finally {
611
+ this.isWaitingPhase = false;
612
+ this.currentDisplaySpeed = void 0;
613
+ }
614
+ }
469
615
  /**
470
616
  * Pre-register waitForResponse listeners at the start of each step.
471
617
  * This ensures the listener is active before any preceding action
@@ -546,7 +692,7 @@ var ClipwiseRecorder = class {
546
692
  timestamp: Date.now(),
547
693
  sessionId: currentSessionId
548
694
  });
549
- await new Promise((resolve) => setTimeout(resolve, action.delay));
695
+ await new Promise((resolve3) => setTimeout(resolve3, action.delay));
550
696
  const now = Date.now();
551
697
  if (now - lastClickRefresh >= 400) {
552
698
  this.clickTimeline.push({
@@ -556,6 +702,17 @@ var ClipwiseRecorder = class {
556
702
  lastClickRefresh = now;
557
703
  }
558
704
  }
705
+ await this.page.evaluate((sel) => {
706
+ const el = document.querySelector(sel);
707
+ if (!el) return;
708
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
709
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
710
+ if (setter) {
711
+ setter.call(el, el.value);
712
+ el.dispatchEvent(new Event("input", { bubbles: true }));
713
+ el.dispatchEvent(new Event("change", { bubbles: true }));
714
+ }
715
+ }, action.selector);
559
716
  this.clickTimeline.push({
560
717
  position: { ...inputTarget },
561
718
  timestamp: Date.now()
@@ -581,7 +738,7 @@ var ClipwiseRecorder = class {
581
738
  },
582
739
  { dy: yStep, dx: xStep, sel: action.selector ?? null }
583
740
  );
584
- await new Promise((resolve) => setTimeout(resolve, 30));
741
+ await new Promise((resolve3) => setTimeout(resolve3, 30));
585
742
  }
586
743
  await this.waitWithRepaints(150);
587
744
  } else {
@@ -629,83 +786,89 @@ var ClipwiseRecorder = class {
629
786
  }
630
787
  case "waitForSelector": {
631
788
  const locator = this.page.locator(action.selector).first();
632
- await locator.waitFor({ state: action.state, timeout: action.timeout });
789
+ const selectorPromise = locator.waitFor({ state: action.state, timeout: action.timeout });
790
+ if (action.captureWhileWaiting) {
791
+ await this.waitForConditionWithCapture(selectorPromise, action.displaySpeed);
792
+ } else {
793
+ await selectorPromise;
794
+ }
633
795
  break;
634
796
  }
635
797
  case "waitForNavigation": {
636
- await this.page.waitForLoadState(action.waitUntil, { timeout: action.timeout });
798
+ const navPromise = this.page.waitForLoadState(action.waitUntil, { timeout: action.timeout });
799
+ if (action.captureWhileWaiting) {
800
+ await this.waitForConditionWithCapture(navPromise, action.displaySpeed);
801
+ } else {
802
+ await navPromise;
803
+ }
637
804
  break;
638
805
  }
639
806
  case "waitForURL": {
640
- await this.page.waitForURL(action.url, { timeout: action.timeout });
807
+ const urlPromise = this.page.waitForURL(action.url, { timeout: action.timeout });
808
+ if (action.captureWhileWaiting) {
809
+ await this.waitForConditionWithCapture(urlPromise, action.displaySpeed);
810
+ } else {
811
+ await urlPromise;
812
+ }
641
813
  break;
642
814
  }
643
815
  case "waitForFunction": {
644
- await this.page.waitForFunction(action.expression, void 0, {
816
+ const fnPromise = this.page.waitForFunction(action.expression, void 0, {
645
817
  polling: action.polling,
646
818
  timeout: action.timeout
647
819
  });
820
+ if (action.captureWhileWaiting) {
821
+ await this.waitForConditionWithCapture(fnPromise, action.displaySpeed);
822
+ } else {
823
+ await fnPromise;
824
+ }
648
825
  break;
649
826
  }
650
827
  case "waitForResponse": {
651
828
  const pending = this.pendingResponsePromises.get(actionIndex);
652
829
  if (pending) {
653
- await pending;
830
+ if (action.captureWhileWaiting) {
831
+ await this.waitForConditionWithCapture(pending, action.displaySpeed);
832
+ } else {
833
+ await pending;
834
+ }
654
835
  }
655
836
  break;
656
837
  }
657
838
  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 });
839
+ let conditionPromise;
840
+ switch (action.until) {
841
+ case "networkIdle":
842
+ conditionPromise = this.page.waitForLoadState("networkidle", { timeout: action.timeout });
843
+ break;
844
+ case "selector":
845
+ conditionPromise = action.selector ? this.page.locator(action.selector).first().waitFor({ state: "visible", timeout: action.timeout }) : Promise.resolve();
846
+ break;
847
+ case "domStable":
848
+ conditionPromise = this.page.waitForFunction(
849
+ () => new Promise((resolve3) => {
850
+ let timer;
851
+ const observer = new MutationObserver(() => {
852
+ clearTimeout(timer);
681
853
  timer = setTimeout(() => {
682
854
  observer.disconnect();
683
- resolve(true);
855
+ resolve3(true);
684
856
  }, 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;
857
+ });
858
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true });
859
+ timer = setTimeout(() => {
860
+ observer.disconnect();
861
+ resolve3(true);
862
+ }, 500);
863
+ }),
864
+ void 0,
865
+ { timeout: action.timeout }
866
+ );
867
+ break;
868
+ default:
869
+ conditionPromise = Promise.resolve();
708
870
  }
871
+ await this.waitForConditionWithCapture(conditionPromise, action.displaySpeed);
709
872
  break;
710
873
  }
711
874
  }
@@ -785,7 +948,7 @@ var ClipwiseRecorder = class {
785
948
  position: { x: point.x, y: point.y },
786
949
  timestamp: Date.now()
787
950
  });
788
- await new Promise((resolve) => setTimeout(resolve, preset.stepDelayMs));
951
+ await new Promise((resolve3) => setTimeout(resolve3, preset.stepDelayMs));
789
952
  }
790
953
  } finally {
791
954
  await this.restoreTransitions();
@@ -961,13 +1124,11 @@ import sharp7 from "sharp";
961
1124
 
962
1125
  // src/effects/frame.ts
963
1126
  import sharp from "sharp";
964
- var TITLE_BAR_HEIGHT = 40;
965
- var TRAFFIC_LIGHT_Y = 14;
966
- var TRAFFIC_LIGHT_RADIUS = 6;
967
- var TRAFFIC_LIGHTS_START_X = 16;
968
- var TRAFFIC_LIGHT_GAP = 22;
969
- var ADDRESS_BAR_HEIGHT = 24;
970
- var ADDRESS_BAR_MARGIN = 70;
1127
+ var TITLE_BAR_HEIGHT = 48;
1128
+ var TRAFFIC_LIGHT_RADIUS = 6.5;
1129
+ var TRAFFIC_LIGHTS_START_X = 22;
1130
+ var TRAFFIC_LIGHT_GAP = 20;
1131
+ var URL_PILL_HEIGHT = 30;
971
1132
  var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
972
1133
  var IPHONE_OUTER_RADIUS = 47;
973
1134
  var IPHONE_INNER_RADIUS = 39;
@@ -980,38 +1141,89 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
980
1141
  var ANDROID_OUTER_RADIUS = 35;
981
1142
  var ANDROID_INNER_RADIUS = 30;
982
1143
  var ANDROID_CAMERA_RADIUS = 6;
983
- function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
984
- const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
985
- const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
986
- const addressBorder = darkMode ? "#444444" : "#d0d0d0";
987
- const textColor = darkMode ? "#999999" : "#666666";
988
- const tbarH = TITLE_BAR_HEIGHT * dpr;
989
- const tlY = TRAFFIC_LIGHT_Y * dpr;
1144
+ function buildBrowserChromeSvg(width, darkMode, dpr = 1, url = "localhost") {
1145
+ const c = darkMode ? {
1146
+ bgTop: "#3c3c3e",
1147
+ bgBottom: "#343436",
1148
+ border: "#232325",
1149
+ pillBg: "#28282a",
1150
+ pillBorder: "#48484b",
1151
+ text: "#d8d8da",
1152
+ icon: "#a8a8ac",
1153
+ iconDim: "#5f5f63"
1154
+ } : {
1155
+ bgTop: "#f8f7f6",
1156
+ bgBottom: "#eeedeb",
1157
+ border: "#d8d6d3",
1158
+ pillBg: "#ffffff",
1159
+ pillBorder: "#dedcd9",
1160
+ text: "#3a3a3c",
1161
+ icon: "#6f6f72",
1162
+ iconDim: "#bdbdc0"
1163
+ };
1164
+ const h = TITLE_BAR_HEIGHT * dpr;
1165
+ const midY = h / 2;
990
1166
  const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
991
1167
  const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
992
1168
  const tlGap = TRAFFIC_LIGHT_GAP * dpr;
993
- const aBarH = ADDRESS_BAR_HEIGHT * dpr;
994
- const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
995
- const fontSize = 12 * dpr;
1169
+ const s = dpr;
996
1170
  const trafficLights = [
997
- { cx: tlStartX, fill: "#ff5f57" },
998
- { cx: tlStartX + tlGap, fill: "#febc2e" },
999
- { cx: tlStartX + tlGap * 2, fill: "#28c840" }
1000
- ].map(
1001
- (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
1002
- ).join("\n ");
1003
- const addressBarWidth = width - aBarMargin * 2;
1004
- const addressBarX = aBarMargin;
1005
- const addressBarY = (tbarH - aBarH) / 2;
1006
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
1007
- <rect width="${width}" height="${tbarH}" fill="${bg}"/>
1171
+ { cx: tlStartX, fill: "#ff5f57", stroke: "#e0443e" },
1172
+ { cx: tlStartX + tlGap, fill: "#febc2e", stroke: "#d89e24" },
1173
+ { cx: tlStartX + tlGap * 2, fill: "#28c840", stroke: "#1ea133" }
1174
+ ].map((l) => `<circle cx="${l.cx}" cy="${midY}" r="${tlR}" fill="${l.fill}" stroke="${l.stroke}" stroke-width="${0.5 * s}"/>`).join("\n ");
1175
+ const navX = tlStartX + tlGap * 2 + 34 * s;
1176
+ const back = `<path d="M ${navX + 4 * s} ${midY - 6 * s} l ${-6 * s} ${6 * s} l ${6 * s} ${6 * s}"
1177
+ fill="none" stroke="${c.icon}" stroke-width="${1.8 * s}" stroke-linecap="round" stroke-linejoin="round"/>`;
1178
+ const fwdX = navX + 28 * s;
1179
+ const forward = `<path d="M ${fwdX - 4 * s} ${midY - 6 * s} l ${6 * s} ${6 * s} l ${-6 * s} ${6 * s}"
1180
+ fill="none" stroke="${c.iconDim}" stroke-width="${1.8 * s}" stroke-linecap="round" stroke-linejoin="round"/>`;
1181
+ const relX = fwdX + 28 * s;
1182
+ 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}"
1183
+ fill="none" stroke="${c.icon}" stroke-width="${1.7 * s}" stroke-linecap="round"/>
1184
+ <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}"/>`;
1185
+ const fontSize = 12.5 * dpr;
1186
+ const pillH = URL_PILL_HEIGHT * dpr;
1187
+ const pillW = Math.max(200 * s, Math.min(width * 0.42, 520 * s));
1188
+ const pillX = (width - pillW) / 2;
1189
+ const pillY = midY - pillH / 2;
1190
+ const textW = url.length * fontSize * 0.56;
1191
+ const lockX = width / 2 - textW / 2 - 16 * s;
1192
+ const lockY = midY - 5 * s;
1193
+ const padlock = `
1194
+ <rect x="${lockX}" y="${lockY + 4 * s}" width="${9 * s}" height="${7 * s}" rx="${1.5 * s}" fill="${c.icon}"/>
1195
+ <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}"
1196
+ fill="none" stroke="${c.icon}" stroke-width="${1.4 * s}"/>`;
1197
+ const dotsX = width - 26 * s;
1198
+ const dots = [-4.5, 0, 4.5].map((dy) => `<circle cx="${dotsX}" cy="${midY + dy * s}" r="${1.6 * s}" fill="${c.icon}"/>`).join("");
1199
+ const avatar = `
1200
+ <circle cx="${width - 56 * s}" cy="${midY}" r="${11 * s}" fill="url(#cwAvatar)"/>
1201
+ <text x="${width - 56 * s}" y="${midY + 4 * s}" text-anchor="middle"
1202
+ font-family="system-ui, -apple-system, sans-serif" font-size="${10.5 * dpr}" font-weight="600" fill="#ffffff">S</text>`;
1203
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
1204
+ <defs>
1205
+ <linearGradient id="cwChromeBg" x1="0" y1="0" x2="0" y2="1">
1206
+ <stop offset="0" stop-color="${c.bgTop}"/>
1207
+ <stop offset="1" stop-color="${c.bgBottom}"/>
1208
+ </linearGradient>
1209
+ <linearGradient id="cwAvatar" x1="0" y1="0" x2="1" y2="1">
1210
+ <stop offset="0" stop-color="#818cf8"/>
1211
+ <stop offset="1" stop-color="#6366f1"/>
1212
+ </linearGradient>
1213
+ </defs>
1214
+ <rect width="${width}" height="${h}" fill="url(#cwChromeBg)"/>
1215
+ <rect y="${h - s}" width="${width}" height="${s}" fill="${c.border}"/>
1008
1216
  ${trafficLights}
1009
- <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
1010
- rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
1011
- <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
1012
- font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
1013
- localhost
1014
- </text>
1217
+ ${back}
1218
+ ${forward}
1219
+ ${reload}
1220
+ <rect x="${pillX}" y="${pillY}" width="${pillW}" height="${pillH}"
1221
+ rx="${pillH / 2}" ry="${pillH / 2}" fill="${c.pillBg}" stroke="${c.pillBorder}" stroke-width="${s}"/>
1222
+ ${padlock}
1223
+ <text x="${width / 2 + 7 * s}" y="${midY + fontSize * 0.35}" text-anchor="middle"
1224
+ font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${c.text}">${url}</text>
1225
+ ${dots}
1226
+ ${avatar}
1015
1227
  </svg>`;
1016
1228
  }
1017
1229
  function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
@@ -1163,7 +1375,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
1163
1375
  case "browser": {
1164
1376
  const tbarH = TITLE_BAR_HEIGHT * dpr;
1165
1377
  const totalHeight = frameHeight + tbarH;
1166
- const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
1378
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr, config.url);
1167
1379
  const chromeBuffer = Buffer.from(chromeSvg);
1168
1380
  const canvas = await sharp({
1169
1381
  create: {
@@ -1551,6 +1763,44 @@ import sharp5 from "sharp";
1551
1763
  function escapeXml(s) {
1552
1764
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1553
1765
  }
1766
+ function isCJK(ch) {
1767
+ const code = ch.codePointAt(0) ?? 0;
1768
+ return code >= 4352 && code <= 4607 || // 한글 자모
1769
+ code >= 11904 && code <= 40959 || // CJK 부수, 한자
1770
+ code >= 44032 && code <= 55215 || // 한글 음절
1771
+ code >= 63744 && code <= 64255 || // CJK 호환 한자
1772
+ code >= 65072 && code <= 65103 || // CJK 호환 형태
1773
+ code >= 65280 && code <= 65519 || // Full-width 문자
1774
+ code >= 12288 && code <= 12543 || // CJK 기호, 히라가나, 가타카나
1775
+ code >= 12784 && code <= 12799 || // 가타카나 확장
1776
+ code >= 131072 && code <= 195103;
1777
+ }
1778
+ function displayWidth(text) {
1779
+ let w = 0;
1780
+ for (const ch of text) {
1781
+ w += isCJK(ch) ? 1.7 : 1;
1782
+ }
1783
+ return w;
1784
+ }
1785
+ function wrapText(text, maxWidth) {
1786
+ if (displayWidth(text) <= maxWidth) return [text];
1787
+ const lines = [];
1788
+ let current = "";
1789
+ let currentWidth = 0;
1790
+ for (const ch of text) {
1791
+ const chWidth = isCJK(ch) ? 1.7 : 1;
1792
+ if (currentWidth + chWidth > maxWidth && current.length > 0) {
1793
+ lines.push(current);
1794
+ current = ch;
1795
+ currentWidth = chWidth;
1796
+ } else {
1797
+ current += ch;
1798
+ currentWidth += chWidth;
1799
+ }
1800
+ }
1801
+ if (current.length > 0) lines.push(current);
1802
+ return lines;
1803
+ }
1554
1804
  function buildSessions(keystrokes) {
1555
1805
  const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
1556
1806
  if (!hasSessionIds) {
@@ -1584,16 +1834,22 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1584
1834
  const lineGap = Math.round(fontSize * 0.45);
1585
1835
  const charWidth = fontSize * 0.615;
1586
1836
  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));
1837
+ const maxDisplayWidth = Math.max(10, (maxHudWidth - hudPadH * 2) / charWidth);
1838
+ const wrappedLines = [];
1839
+ sessions.forEach((text, sIdx) => {
1840
+ const wrapped = wrapText(text, maxDisplayWidth);
1841
+ for (const line of wrapped) {
1842
+ wrappedLines.push({ text: line, sessionIdx: sIdx });
1843
+ }
1844
+ });
1845
+ const lines = wrappedLines.map((l) => l.text);
1846
+ const totalLineCount = lines.length;
1847
+ const maxLineDisplayWidth = Math.max(...lines.map((l) => displayWidth(l)));
1592
1848
  const hudWidth = Math.min(
1593
- Math.ceil(maxLineLen * charWidth) + hudPadH * 2,
1849
+ Math.ceil(maxLineDisplayWidth * charWidth) + hudPadH * 2,
1594
1850
  maxHudWidth
1595
1851
  );
1596
- const hudHeight = Math.ceil(fontSize * lineCount + lineGap * (lineCount - 1) + hudPadV * 2);
1852
+ const hudHeight = Math.ceil(fontSize * totalLineCount + lineGap * (totalLineCount - 1) + hudPadV * 2);
1597
1853
  const margin = 30 * dpr;
1598
1854
  const hudY = frameHeight - hudHeight - margin;
1599
1855
  let hudX;
@@ -1608,18 +1864,20 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1608
1864
  default:
1609
1865
  hudX = Math.round((frameWidth - hudWidth) / 2);
1610
1866
  }
1611
- const LINE_OPACITY_FACTORS = [0.45, 0.7, 1];
1612
- const opacityFactors = LINE_OPACITY_FACTORS.slice(-lineCount);
1867
+ const SESSION_OPACITY_FACTORS = [0.45, 0.7, 1];
1868
+ const sessionOpacities = SESSION_OPACITY_FACTORS.slice(-lineCount);
1613
1869
  const rx = (8 * dpr).toFixed(1);
1614
1870
  const boxOp = (globalOpacity * 0.92).toFixed(3);
1615
1871
  const textX = hudX + hudPadH;
1616
1872
  const baselineY = hudY + hudPadV + fontSize * 0.82;
1617
- const textElements = lines.map((line, i) => {
1618
- const op = (globalOpacity * opacityFactors[i]).toFixed(3);
1873
+ const textElements = wrappedLines.map(({ text, sessionIdx }, i) => {
1874
+ const sessionPos = sessions.length <= 3 ? sessionIdx : sessionIdx - (sessions.length - 3);
1875
+ const opFactor = sessionOpacities[Math.max(0, sessionPos)] ?? 1;
1876
+ const op = (globalOpacity * opFactor).toFixed(3);
1619
1877
  const lineY = baselineY + i * (fontSize + lineGap);
1620
1878
  return `<text x="${textX}" y="${lineY}"
1621
1879
  font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1622
- fill="${config.textColor}" opacity="${op}">${escapeXml(line)}</text>`;
1880
+ fill="${config.textColor}" opacity="${op}">${escapeXml(text)}</text>`;
1623
1881
  }).join("\n ");
1624
1882
  const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1625
1883
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
@@ -2093,7 +2351,7 @@ var CanvasRenderer = class {
2093
2351
  * Workers process frames concurrently; results are collected in order.
2094
2352
  */
2095
2353
  processWithWorkers(frames, contexts, workerCount, perFrameEffects) {
2096
- return new Promise((resolve, reject) => {
2354
+ return new Promise((resolve3, reject) => {
2097
2355
  const results = new Array(frames.length);
2098
2356
  let completed = 0;
2099
2357
  let nextIndex = 0;
@@ -2131,7 +2389,7 @@ var CanvasRenderer = class {
2131
2389
  completed++;
2132
2390
  if (completed === frames.length) {
2133
2391
  workers.forEach((wk) => wk.terminate());
2134
- resolve(results);
2392
+ resolve3(results);
2135
2393
  } else {
2136
2394
  dispatch(worker);
2137
2395
  }
@@ -2805,7 +3063,7 @@ var CanvasRenderer = class {
2805
3063
  // src/compose/video-encoder.ts
2806
3064
  import gifenc from "gifenc";
2807
3065
  import sharp9 from "sharp";
2808
- import { writeFile, mkdir, readFile, rm } from "fs/promises";
3066
+ import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
2809
3067
  import { join } from "path";
2810
3068
  import { tmpdir } from "os";
2811
3069
  import { spawn } from "child_process";
@@ -2828,20 +3086,20 @@ function resolveEncodingParams(config) {
2828
3086
  var encoderScanPromise = null;
2829
3087
  function scanAvailableEncoders() {
2830
3088
  if (!encoderScanPromise) {
2831
- encoderScanPromise = new Promise((resolve) => {
3089
+ encoderScanPromise = new Promise((resolve3) => {
2832
3090
  const proc = spawn("ffmpeg", ["-encoders"], {
2833
3091
  stdio: ["ignore", "pipe", "ignore"]
2834
3092
  });
2835
3093
  let out = "";
2836
3094
  proc.stdout.on("data", (d) => out += d.toString());
2837
3095
  proc.on("close", () => {
2838
- resolve({
3096
+ resolve3({
2839
3097
  hevcHw: out.includes("hevc_videotoolbox"),
2840
3098
  h264Hw: out.includes("h264_videotoolbox"),
2841
3099
  av1: out.includes("libsvtav1")
2842
3100
  });
2843
3101
  });
2844
- proc.on("error", () => resolve({ hevcHw: false, h264Hw: false, av1: false }));
3102
+ proc.on("error", () => resolve3({ hevcHw: false, h264Hw: false, av1: false }));
2845
3103
  });
2846
3104
  }
2847
3105
  return encoderScanPromise;
@@ -2954,7 +3212,7 @@ async function encodeMp4(frames, config, audio) {
2954
3212
  const encoder = await detectVideoEncoder(config.codec);
2955
3213
  const params = resolveEncodingParams(config);
2956
3214
  await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio);
2957
- return await readFile(outputPath);
3215
+ return await readFile2(outputPath);
2958
3216
  } finally {
2959
3217
  await rm(outputPath, { force: true }).catch(() => {
2960
3218
  });
@@ -2970,7 +3228,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
2970
3228
  if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
2971
3229
  }
2972
3230
  const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
2973
- return new Promise((resolve, reject) => {
3231
+ return new Promise((resolve3, reject) => {
2974
3232
  const ffmpeg = spawn(
2975
3233
  "ffmpeg",
2976
3234
  [
@@ -3005,7 +3263,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
3005
3263
  ffmpeg.stderr.on("data", (d) => stderr += d.toString());
3006
3264
  ffmpeg.on("close", (code) => {
3007
3265
  if (code === 0) {
3008
- resolve();
3266
+ resolve3();
3009
3267
  } else {
3010
3268
  reject(
3011
3269
  new Error(
@@ -3044,7 +3302,7 @@ async function encodeMp4Stream(frames, config, audio) {
3044
3302
  const encoder = await detectVideoEncoder(config.codec);
3045
3303
  const params = resolveEncodingParams(config);
3046
3304
  await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
3047
- return await readFile(outputPath);
3305
+ return await readFile2(outputPath);
3048
3306
  } finally {
3049
3307
  await rm(outputPath, { force: true }).catch(() => {
3050
3308
  });
@@ -3097,7 +3355,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
3097
3355
  if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
3098
3356
  }
3099
3357
  const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
3100
- return new Promise((resolve, reject) => {
3358
+ return new Promise((resolve3, reject) => {
3101
3359
  const ffmpeg = spawn(
3102
3360
  "ffmpeg",
3103
3361
  [
@@ -3130,7 +3388,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
3130
3388
  ffmpeg.stderr.on("data", (d) => stderr += d.toString());
3131
3389
  ffmpeg.on("close", (code) => {
3132
3390
  if (code === 0) {
3133
- resolve();
3391
+ resolve3();
3134
3392
  } else {
3135
3393
  reject(
3136
3394
  new Error(
@@ -3254,9 +3512,342 @@ var StreamingSession = class extends EventEmitter {
3254
3512
  }
3255
3513
  };
3256
3514
 
3257
- // src/script/parser.ts
3515
+ // src/scenes/runner.ts
3516
+ import { chromium as chromium2 } from "playwright";
3517
+ import { createServer } from "http";
3518
+ import { execSync } from "child_process";
3519
+ import { readFile as readFile3, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
3520
+ import { existsSync as existsSync2 } from "fs";
3521
+ import { tmpdir as tmpdir2 } from "os";
3522
+ import { join as join2, resolve, dirname, isAbsolute } from "path";
3523
+ import { pathToFileURL, fileURLToPath as fileURLToPath2 } from "url";
3258
3524
  import { parse as parseYaml } from "yaml";
3259
- import { readFile as readFile2 } from "fs/promises";
3525
+ import sharp10 from "sharp";
3526
+ async function loadBrandMotion(scenarioDir) {
3527
+ const defaults = { accent: "#6366f1", font: "editorial", annotations: true };
3528
+ for (const candidate of [
3529
+ resolve(scenarioDir, "..", "brand.yaml"),
3530
+ // .clipwise/scenarios/x.yaml → .clipwise/brand.yaml
3531
+ resolve(scenarioDir, "brand.yaml"),
3532
+ resolve(process.cwd(), ".clipwise", "brand.yaml")
3533
+ ]) {
3534
+ try {
3535
+ const raw = parseYaml(await readFile3(candidate, "utf-8"));
3536
+ return {
3537
+ accent: raw.accent ?? defaults.accent,
3538
+ font: raw.font ?? defaults.font,
3539
+ annotations: raw.annotations ?? defaults.annotations
3540
+ };
3541
+ } catch {
3542
+ }
3543
+ }
3544
+ return defaults;
3545
+ }
3546
+ function resolveMotionTemplate(template, scenarioDir) {
3547
+ if (template.endsWith(".html")) {
3548
+ const p = isAbsolute(template) ? template : resolve(scenarioDir, template);
3549
+ if (!existsSync2(p)) throw new Error(`Motion template not found: ${p}`);
3550
+ return pathToFileURL(p).href;
3551
+ }
3552
+ const here = dirname(fileURLToPath2(import.meta.url));
3553
+ for (const base of [
3554
+ resolve(here, "..", "templates", "motion"),
3555
+ // dist/ 기준 → 패키지 루트
3556
+ resolve(here, "..", "..", "templates", "motion")
3557
+ // src/scenes/ 기준
3558
+ ]) {
3559
+ const p = join2(base, `${template}.html`);
3560
+ if (existsSync2(p)) return pathToFileURL(p).href;
3561
+ }
3562
+ throw new Error(
3563
+ `Unknown built-in motion template "${template}" (built-ins: intro-title, feature-callout, kinetic-type, vignette)`
3564
+ );
3565
+ }
3566
+ function footageEffects(effects) {
3567
+ return {
3568
+ ...effects,
3569
+ zoom: { ...effects.zoom, enabled: false },
3570
+ deviceFrame: { ...effects.deviceFrame, enabled: false },
3571
+ keystroke: { ...effects.keystroke, enabled: false },
3572
+ speedRamp: { ...effects.speedRamp, enabled: false },
3573
+ smartSpeed: effects.smartSpeed ? { ...effects.smartSpeed, enabled: false } : effects.smartSpeed,
3574
+ background: {
3575
+ ...effects.background,
3576
+ type: "solid",
3577
+ value: "#000000",
3578
+ padding: 0,
3579
+ borderRadius: 0,
3580
+ shadow: false
3581
+ }
3582
+ };
3583
+ }
3584
+ async function executeStepsForProbe(page, scene) {
3585
+ for (const step of scene.steps) {
3586
+ for (const action of step.actions) {
3587
+ switch (action.action) {
3588
+ case "navigate":
3589
+ await page.goto(action.url, { waitUntil: action.waitUntil ?? "networkidle" });
3590
+ break;
3591
+ case "click":
3592
+ await page.click(action.selector, { timeout: action.timeout ?? 15e3 });
3593
+ break;
3594
+ case "type":
3595
+ await page.fill(action.selector, action.text, { timeout: action.timeout ?? 15e3 });
3596
+ break;
3597
+ case "hover":
3598
+ await page.hover(action.selector, { timeout: action.timeout ?? 15e3 });
3599
+ break;
3600
+ case "scroll":
3601
+ await page.evaluate(
3602
+ ({ x, y }) => window.scrollBy(x, y),
3603
+ { x: action.x ?? 0, y: action.y ?? 0 }
3604
+ );
3605
+ break;
3606
+ case "wait":
3607
+ await page.waitForTimeout(Math.min(action.duration, 3e3));
3608
+ break;
3609
+ case "waitForSelector":
3610
+ await page.waitForSelector(action.selector, {
3611
+ state: action.state ?? "visible",
3612
+ timeout: action.timeout ?? 15e3
3613
+ });
3614
+ break;
3615
+ case "waitForFunction":
3616
+ await page.waitForFunction(action.expression, void 0, { timeout: action.timeout ?? 3e4 });
3617
+ break;
3618
+ default:
3619
+ break;
3620
+ }
3621
+ }
3622
+ }
3623
+ }
3624
+ function segmentOutput(scenario) {
3625
+ const dpr = scenario.viewport.deviceScaleFactor ?? 1;
3626
+ return {
3627
+ ...scenario.output,
3628
+ width: scenario.output.width * dpr,
3629
+ height: scenario.output.height * dpr,
3630
+ preset: "archive"
3631
+ };
3632
+ }
3633
+ async function recordFootageTake(scenario, scene, selectors) {
3634
+ const boxes = /* @__PURE__ */ new Map();
3635
+ if (selectors.length > 0) {
3636
+ const browser = await chromium2.launch();
3637
+ const context = await browser.newContext({
3638
+ viewport: { width: scenario.viewport.width, height: scenario.viewport.height }
3639
+ });
3640
+ if (scenario.prepare) await applyPrepare(context, scenario.prepare);
3641
+ const page = await context.newPage();
3642
+ await executeStepsForProbe(page, scene);
3643
+ await page.evaluate(() => window.scrollTo(0, 0));
3644
+ for (const selector of selectors) {
3645
+ const box = await page.locator(selector).first().boundingBox();
3646
+ if (box) boxes.set(selector, box);
3647
+ }
3648
+ await browser.close();
3649
+ }
3650
+ const takeScenario = {
3651
+ ...scenario,
3652
+ steps: scene.steps,
3653
+ scenes: void 0,
3654
+ effects: footageEffects(scenario.effects)
3655
+ };
3656
+ const recorder = new ClipwiseRecorder();
3657
+ const session = await recorder.record(takeScenario);
3658
+ const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
3659
+ const composed = [];
3660
+ for await (const f of renderer.composeStream(session.frames)) composed.push(f);
3661
+ const frames = await Promise.all(
3662
+ composed.map(
3663
+ (f) => f.rawInfo ? sharp10(f.buffer, {
3664
+ raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
3665
+ }).png().toBuffer() : Promise.resolve(f.buffer)
3666
+ )
3667
+ );
3668
+ const anchors = [];
3669
+ for (let k = 0; k < scene.steps.length; k++) {
3670
+ const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
3671
+ anchors.push(Math.max(0, idx) / scenario.output.fps);
3672
+ }
3673
+ return { frames, anchors, boxes };
3674
+ }
3675
+ function fitCardW(cw, ch, maxW = 940, maxH = 540) {
3676
+ return Math.round(Math.min(maxW, maxH * cw / ch));
3677
+ }
3678
+ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
3679
+ const fps = scenario.output.fps;
3680
+ const totalFrames = Math.round(durationMs / 1e3 * fps);
3681
+ const browser = await chromium2.launch();
3682
+ const page = await browser.newPage({
3683
+ viewport: { width: scenario.output.width, height: scenario.output.height },
3684
+ deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
3685
+ });
3686
+ const params = new URLSearchParams(
3687
+ Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
3688
+ );
3689
+ await page.goto(`${templateUrl}?${params}`, { waitUntil: "load" });
3690
+ await page.evaluate(() => document.fonts.ready);
3691
+ const frames = [];
3692
+ for (let i = 0; i < totalFrames; i++) {
3693
+ const t = i / fps * 1e3;
3694
+ await page.evaluate(
3695
+ (time) => window.__clipwiseSeek(time),
3696
+ t
3697
+ );
3698
+ frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
3699
+ }
3700
+ await browser.close();
3701
+ return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
3702
+ }
3703
+ function vignetteProps(scene, take, serverBase, scenario, brand) {
3704
+ const W = scenario.viewport.width;
3705
+ const H = scenario.viewport.height;
3706
+ let crop = { x: 0, y: 0, w: W, h: H };
3707
+ if (scene.crop) {
3708
+ const c = scene.crop;
3709
+ if (c.selector) {
3710
+ const box = take.boxes.get(c.selector);
3711
+ if (!box) throw new Error(`vignette crop selector "${c.selector}" not found in footage "${scene.footage}"`);
3712
+ crop = {
3713
+ x: Math.max(0, box.x - c.pad),
3714
+ y: Math.max(0, box.y - c.pad),
3715
+ w: box.width + c.pad * 2,
3716
+ h: box.height + c.pad * 2
3717
+ };
3718
+ } else if (c.w !== void 0) {
3719
+ crop = { x: c.x ?? 0, y: c.y ?? 0, w: c.w, h: c.h ?? H };
3720
+ }
3721
+ if (c.maxH) crop.h = Math.min(crop.h, c.maxH);
3722
+ }
3723
+ const start = typeof scene.start === "number" ? scene.start : (take.anchors[scene.start.step] ?? 0) + scene.start.offset;
3724
+ const props = {
3725
+ accent: brand.accent,
3726
+ font: brand.font,
3727
+ dur: scene.duration / 1e3,
3728
+ layout: scene.layout,
3729
+ num: scene.num ?? "",
3730
+ label: scene.label ?? "",
3731
+ caption: scene.caption ?? "",
3732
+ base: serverBase,
3733
+ count: take.frames.length,
3734
+ fps: scenario.output.fps,
3735
+ start,
3736
+ rate: scene.rate,
3737
+ cropX: crop.x,
3738
+ cropY: crop.y,
3739
+ cropW: crop.w,
3740
+ cropH: crop.h,
3741
+ cardW: fitCardW(crop.w, crop.h),
3742
+ pushFrom: scene.push?.from ?? 1,
3743
+ pushTo: scene.push?.to ?? 1
3744
+ };
3745
+ if (scene.code?.length) props.code = scene.code.join("||");
3746
+ if (brand.annotations && scene.fx.length > 0) {
3747
+ props.fx = scene.fx.map((fx) => {
3748
+ let coords = fx.coords;
3749
+ if (fx.selector) {
3750
+ const box = take.boxes.get(fx.selector);
3751
+ if (!box) throw new Error(`vignette fx selector "${fx.selector}" not found in footage "${scene.footage}"`);
3752
+ coords = fx.kind === "circle" ? [box.x, box.y, box.width, box.height] : [box.x - 160, box.y + 120, box.x - 12, box.y + box.height / 2];
3753
+ }
3754
+ return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
3755
+ }).join(";");
3756
+ }
3757
+ return props;
3758
+ }
3759
+ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3760
+ const scenes = scenario.scenes ?? [];
3761
+ const screens = scenes.filter((s) => s.type === "screen");
3762
+ const timeline = scenes.filter((s) => s.type !== "screen");
3763
+ const brand = await loadBrandMotion(scenarioDir);
3764
+ const selectorsByFootage = /* @__PURE__ */ new Map();
3765
+ for (const scene of timeline) {
3766
+ if (scene.type !== "vignette") continue;
3767
+ const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
3768
+ if (scene.crop?.selector) set.add(scene.crop.selector);
3769
+ for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
3770
+ selectorsByFootage.set(scene.footage, set);
3771
+ }
3772
+ const takes = /* @__PURE__ */ new Map();
3773
+ for (const screen of screens) {
3774
+ onProgress?.({ scene: 0, total: timeline.length, label: `footage "${screen.id}"` });
3775
+ takes.set(
3776
+ screen.id,
3777
+ await recordFootageTake(scenario, screen, [...selectorsByFootage.get(screen.id) ?? []])
3778
+ );
3779
+ }
3780
+ const server = createServer((req, res) => {
3781
+ const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
3782
+ const take = m ? takes.get(m[1]) : void 0;
3783
+ if (take) {
3784
+ const idx = Math.min(take.frames.length - 1, parseInt(m[2], 10));
3785
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
3786
+ res.end(take.frames[idx]);
3787
+ } else {
3788
+ res.writeHead(404);
3789
+ res.end();
3790
+ }
3791
+ });
3792
+ await new Promise((r) => server.listen(0, r));
3793
+ const port = server.address().port;
3794
+ const totalMs = timeline.reduce((s, sc) => s + sc.duration, 0);
3795
+ let elapsedMs = 0;
3796
+ const segments = [];
3797
+ const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
3798
+ try {
3799
+ for (let i = 0; i < timeline.length; i++) {
3800
+ const scene = timeline[i];
3801
+ const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
3802
+ onProgress?.({ scene: i + 1, total: timeline.length, label });
3803
+ const thread = brand.annotations ? {
3804
+ threadFrom: (elapsedMs / totalMs).toFixed(4),
3805
+ threadTo: ((elapsedMs + scene.duration) / totalMs).toFixed(4)
3806
+ } : {};
3807
+ elapsedMs += scene.duration;
3808
+ let segment;
3809
+ if (scene.type === "motion") {
3810
+ const url = resolveMotionTemplate(scene.template, scenarioDir);
3811
+ segment = await captureMotionSegment(
3812
+ url,
3813
+ { accent: brand.accent, font: brand.font, dur: scene.duration / 1e3, ...scene.props, ...thread },
3814
+ scene.duration,
3815
+ scenario
3816
+ );
3817
+ } else {
3818
+ const take = takes.get(scene.footage);
3819
+ const url = resolveMotionTemplate("vignette", scenarioDir);
3820
+ segment = await captureMotionSegment(
3821
+ url,
3822
+ { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
3823
+ scene.duration,
3824
+ scenario
3825
+ );
3826
+ }
3827
+ const segPath = join2(tmp, `s${i}.mp4`);
3828
+ await writeFile2(segPath, segment.buffer);
3829
+ segments.push({ path: segPath, seconds: segment.seconds });
3830
+ }
3831
+ } finally {
3832
+ server.close();
3833
+ }
3834
+ const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
3835
+ const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
3836
+ const outPath = join2(tmp, "timeline.mp4");
3837
+ execSync(
3838
+ `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} -filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
3839
+ { stdio: ["ignore", "ignore", "pipe"] }
3840
+ );
3841
+ const buffer = await readFile3(outPath);
3842
+ await rm2(tmp, { recursive: true, force: true }).catch(() => {
3843
+ });
3844
+ return buffer;
3845
+ }
3846
+
3847
+ // src/script/parser.ts
3848
+ import { parse as parseYaml2 } from "yaml";
3849
+ import { readFile as readFile4 } from "fs/promises";
3850
+ import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
3260
3851
 
3261
3852
  // src/script/types.ts
3262
3853
  import { z } from "zod";
@@ -3308,29 +3899,41 @@ var WaitForSelectorActionSchema = z.object({
3308
3899
  action: z.literal("waitForSelector"),
3309
3900
  selector: SafeSelectorSchema,
3310
3901
  state: z.enum(["visible", "attached", "hidden"]).default("visible"),
3311
- timeout: z.number().min(0).default(15e3)
3902
+ timeout: z.number().min(0).default(15e3),
3903
+ /** 대기 중 프레임 연속 캡처 (로딩 애니메이션 보존). */
3904
+ captureWhileWaiting: z.boolean().default(false),
3905
+ /** captureWhileWaiting 사용 시 출력 영상 속도 배율 (1-32). */
3906
+ displaySpeed: z.number().min(1).max(32).default(8)
3312
3907
  });
3313
3908
  var WaitForNavigationActionSchema = z.object({
3314
3909
  action: z.literal("waitForNavigation"),
3315
3910
  waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).default("networkidle"),
3316
- timeout: z.number().min(0).default(15e3)
3911
+ timeout: z.number().min(0).default(15e3),
3912
+ captureWhileWaiting: z.boolean().default(false),
3913
+ displaySpeed: z.number().min(1).max(32).default(8)
3317
3914
  });
3318
3915
  var WaitForURLActionSchema = z.object({
3319
3916
  action: z.literal("waitForURL"),
3320
3917
  url: z.string().min(1),
3321
- timeout: z.number().min(0).default(15e3)
3918
+ timeout: z.number().min(0).default(15e3),
3919
+ captureWhileWaiting: z.boolean().default(false),
3920
+ displaySpeed: z.number().min(1).max(32).default(8)
3322
3921
  });
3323
3922
  var WaitForFunctionActionSchema = z.object({
3324
3923
  action: z.literal("waitForFunction"),
3325
3924
  expression: z.string().min(1),
3326
3925
  polling: z.union([z.literal("raf"), z.number().min(0)]).default("raf"),
3327
- timeout: z.number().min(0).default(3e4)
3926
+ timeout: z.number().min(0).default(3e4),
3927
+ captureWhileWaiting: z.boolean().default(false),
3928
+ displaySpeed: z.number().min(1).max(32).default(8)
3328
3929
  });
3329
3930
  var WaitForResponseActionSchema = z.object({
3330
3931
  action: z.literal("waitForResponse"),
3331
3932
  url: z.string().min(1),
3332
3933
  status: z.number().min(100).max(599).optional(),
3333
- timeout: z.number().min(0).default(3e4)
3934
+ timeout: z.number().min(0).default(3e4),
3935
+ captureWhileWaiting: z.boolean().default(false),
3936
+ displaySpeed: z.number().min(1).max(32).default(8)
3334
3937
  });
3335
3938
  var SmartWaitActionSchema = z.object({
3336
3939
  action: z.literal("smartWait"),
@@ -3415,7 +4018,9 @@ var BackgroundSchema = z.object({
3415
4018
  var DeviceFrameSchema = z.object({
3416
4019
  enabled: z.boolean().default(false),
3417
4020
  type: z.enum(["browser", "macbook", "iphone", "ipad", "android", "none"]).default("browser"),
3418
- darkMode: z.boolean().default(false)
4021
+ darkMode: z.boolean().default(false),
4022
+ /** browser 타입의 주소창에 표시할 URL (실제 녹화 URL과 무관한 표시용). */
4023
+ url: z.string().default("localhost")
3419
4024
  });
3420
4025
  var SpeedRampConfigSchema = z.object({
3421
4026
  enabled: z.boolean().default(false),
@@ -3486,7 +4091,8 @@ var OutputConfigSchema = z.object({
3486
4091
  preset: z.enum(["social", "balanced", "archive"]).optional(),
3487
4092
  /** Codec override: h264 (default), hevc (10-bit), av1 (smallest files, slow encode) */
3488
4093
  codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
3489
- outputDir: z.string().default("./output"),
4094
+ // Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
4095
+ outputDir: z.string().default(".clipwise/output"),
3490
4096
  filename: z.string().default("clipwise-recording")
3491
4097
  });
3492
4098
  var StepEffectsOverrideSchema = z.object({
@@ -3525,18 +4131,140 @@ var AudioConfigSchema = z.object({
3525
4131
  /** Fade-out duration in milliseconds. */
3526
4132
  fadeOut: z.number().min(0).default(0)
3527
4133
  });
4134
+ var AuthConfigSchema = z.object({
4135
+ /** Path to a Playwright storageState JSON file (cookies + localStorage). */
4136
+ storageState: z.string().optional(),
4137
+ /** Inline cookie definitions (applied after storageState if both specified). */
4138
+ cookies: z.array(
4139
+ z.object({
4140
+ name: z.string(),
4141
+ value: z.string(),
4142
+ domain: z.string(),
4143
+ path: z.string().default("/"),
4144
+ httpOnly: z.boolean().default(false),
4145
+ secure: z.boolean().default(false),
4146
+ sameSite: z.enum(["Strict", "Lax", "None"]).default("Lax")
4147
+ })
4148
+ ).optional()
4149
+ });
4150
+ var MockRouteSchema = z.object({
4151
+ /** 매칭할 URL 부분 문자열 (예: "/api/dashboard/stats"). */
4152
+ url: z.string().min(1),
4153
+ /** 응답 본문 JSON 파일 경로 (시나리오 파일 기준 상대 경로). */
4154
+ fixture: z.string().optional(),
4155
+ /** 인라인 응답 본문 — fixture 대신 YAML에 직접 작성. */
4156
+ body: z.unknown().optional(),
4157
+ status: z.number().int().min(100).max(599).default(200),
4158
+ contentType: z.string().default("application/json")
4159
+ });
4160
+ var PrepareConfigSchema = z.object({
4161
+ /** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
4162
+ hide: z.array(z.string().min(1)).default([]),
4163
+ /** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
4164
+ freezeTime: z.string().optional(),
4165
+ /** Math.random을 이 시드의 결정론적 PRNG로 대체. */
4166
+ seedRandom: z.number().int().optional(),
4167
+ /** 페이지 로드 전 시드할 웹 스토리지 항목. */
4168
+ storage: z.object({
4169
+ localStorage: z.record(z.string()).default({}),
4170
+ sessionStorage: z.record(z.string()).default({})
4171
+ }).optional(),
4172
+ /** 네트워크 응답 목(mock) — 사용자 DB를 시드하지 않고 데모 데이터 제공. */
4173
+ mock: z.array(MockRouteSchema).default([]),
4174
+ /** 임의 CSS/JS 파일 주입 (시나리오 파일 기준 상대 경로). */
4175
+ inject: z.object({
4176
+ css: z.union([z.string(), z.array(z.string())]).optional(),
4177
+ js: z.union([z.string(), z.array(z.string())]).optional()
4178
+ }).optional()
4179
+ });
4180
+ var MotionSceneSchema = z.object({
4181
+ type: z.literal("motion"),
4182
+ /** 내장 템플릿(intro-title|feature-callout|kinetic-type|vignette) 또는 .html 경로. */
4183
+ template: z.string().min(1),
4184
+ /** 신 길이 (ms). */
4185
+ duration: z.number().min(200).max(6e4),
4186
+ /** 템플릿에 query param으로 주입할 props. */
4187
+ props: z.record(z.union([z.string(), z.number(), z.boolean()])).default({})
4188
+ });
4189
+ var ScreenSceneSchema = z.object({
4190
+ type: z.literal("screen"),
4191
+ /** vignette가 참조할 푸티지 ID. */
4192
+ id: z.string().min(1),
4193
+ steps: z.array(StepSchema).min(1)
4194
+ });
4195
+ var SceneFxSchema = z.object({
4196
+ kind: z.enum(["circle", "arrow"]),
4197
+ /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
4198
+ selector: z.string().optional(),
4199
+ /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
4200
+ coords: z.array(z.number()).length(4).optional(),
4201
+ /** 신 내 드로잉 시작 시각 (ms). */
4202
+ delay: z.number().min(0).default(0)
4203
+ });
4204
+ var VignetteSceneSchema = z.object({
4205
+ type: z.literal("vignette"),
4206
+ /** 인용할 screen 신의 id. */
4207
+ footage: z.string().min(1),
4208
+ duration: z.number().min(500).max(6e4),
4209
+ layout: z.enum(["hero", "crop", "split"]).default("hero"),
4210
+ num: z.string().optional(),
4211
+ label: z.string().optional(),
4212
+ caption: z.string().optional(),
4213
+ /** split 레이아웃의 코드 카드 라인들. */
4214
+ code: z.array(z.string()).optional(),
4215
+ /** 크롭 영역 — selector 실측(+pad) 또는 명시 좌표. 생략 시 전체 화면. */
4216
+ crop: z.object({
4217
+ selector: z.string().optional(),
4218
+ pad: z.number().default(14),
4219
+ x: z.number().optional(),
4220
+ y: z.number().optional(),
4221
+ w: z.number().optional(),
4222
+ h: z.number().optional(),
4223
+ /** 크롭 높이 상한 (px, 원본 기준) — 와이드 스트립 연출용. */
4224
+ maxH: z.number().optional()
4225
+ }).optional(),
4226
+ /** 푸시인 카메라 (스케일 from→to). */
4227
+ push: z.object({ from: z.number().default(1), to: z.number().default(1) }).optional(),
4228
+ /** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
4229
+ start: z.union([z.number(), z.object({ step: z.number().int().min(0), offset: z.number().default(0) })]).default(0),
4230
+ /** 푸티지 재생 배속. */
4231
+ rate: z.number().min(0.1).max(8).default(1),
4232
+ fx: z.array(SceneFxSchema).default([])
4233
+ });
4234
+ var SceneSchema = z.discriminatedUnion("type", [
4235
+ MotionSceneSchema,
4236
+ ScreenSceneSchema,
4237
+ VignetteSceneSchema
4238
+ ]);
3528
4239
  var ScenarioSchema = z.object({
3529
4240
  name: z.string(),
3530
4241
  description: z.string().optional(),
3531
4242
  viewport: z.object({
3532
4243
  width: z.number().default(1280),
3533
- height: z.number().default(800)
4244
+ height: z.number().default(800),
4245
+ /** HiDPI 캡처 배율 — 2면 물리 픽셀 2배(레티나급)로 녹화·합성한다. */
4246
+ deviceScaleFactor: z.number().min(1).max(3).default(1)
3534
4247
  }).default({}),
4248
+ /** Optional authentication — restores browser session for logged-in pages. */
4249
+ auth: AuthConfigSchema.optional(),
4250
+ /** Optional recording-time runtime injection (hide/mock/freezeTime/...). */
4251
+ prepare: PrepareConfigSchema.optional(),
3535
4252
  effects: EffectsConfigSchema.default({}),
3536
4253
  output: OutputConfigSchema.default({}),
3537
4254
  /** Optional audio narration — muxed into MP4 output. */
3538
4255
  audio: AudioConfigSchema.optional(),
3539
- steps: z.array(StepSchema).min(1)
4256
+ /** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
4257
+ steps: z.array(StepSchema).default([]),
4258
+ /** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
4259
+ scenes: z.array(SceneSchema).optional()
4260
+ }).superRefine((s, ctx) => {
4261
+ if (s.steps.length === 0 && !s.scenes?.length) {
4262
+ ctx.addIssue({
4263
+ code: z.ZodIssueCode.custom,
4264
+ path: ["steps"],
4265
+ message: "Array must contain at least 1 element(s) \u2014 provide steps or scenes"
4266
+ });
4267
+ }
3540
4268
  });
3541
4269
 
3542
4270
  // src/script/parser.ts
@@ -3544,7 +4272,7 @@ import { ZodError } from "zod";
3544
4272
  function parseScenario(yamlContent) {
3545
4273
  let raw;
3546
4274
  try {
3547
- raw = parseYaml(yamlContent);
4275
+ raw = parseYaml2(yamlContent);
3548
4276
  } catch (error) {
3549
4277
  const message = error instanceof Error ? error.message : "Unknown parse error";
3550
4278
  throw new Error(`YAML parse error: ${message}`);
@@ -3563,21 +4291,69 @@ ${details}`);
3563
4291
  throw error;
3564
4292
  }
3565
4293
  }
4294
+ function resolvePreparePaths(scenario, scenarioDir) {
4295
+ const prepare = scenario.prepare;
4296
+ if (!prepare) return;
4297
+ const abs = (p) => isAbsolute2(p) ? p : resolve2(scenarioDir, p);
4298
+ for (const mock of prepare.mock) {
4299
+ if (mock.fixture) mock.fixture = abs(mock.fixture);
4300
+ }
4301
+ if (prepare.inject?.css) {
4302
+ prepare.inject.css = Array.isArray(prepare.inject.css) ? prepare.inject.css.map(abs) : abs(prepare.inject.css);
4303
+ }
4304
+ if (prepare.inject?.js) {
4305
+ prepare.inject.js = Array.isArray(prepare.inject.js) ? prepare.inject.js.map(abs) : abs(prepare.inject.js);
4306
+ }
4307
+ }
3566
4308
  async function loadScenario(filePath) {
3567
4309
  let content;
3568
4310
  try {
3569
- content = await readFile2(filePath, "utf-8");
4311
+ content = await readFile4(filePath, "utf-8");
3570
4312
  } catch (error) {
3571
4313
  const message = error instanceof Error ? error.message : "Unknown file error";
3572
4314
  throw new Error(`Failed to read scenario file "${filePath}": ${message}`);
3573
4315
  }
3574
- return parseScenario(content);
4316
+ const scenario = parseScenario(content);
4317
+ resolvePreparePaths(scenario, dirname2(resolve2(filePath)));
4318
+ return scenario;
3575
4319
  }
3576
4320
 
3577
4321
  // src/script/validator.ts
3578
4322
  function validateScenario(scenario) {
3579
4323
  const errors = [];
3580
4324
  const warnings = [];
4325
+ if (scenario.scenes?.length) {
4326
+ const screenIds = new Set(
4327
+ scenario.scenes.filter((s) => s.type === "screen").map((s) => s.id)
4328
+ );
4329
+ const timeline = scenario.scenes.filter((s) => s.type !== "screen");
4330
+ if (timeline.length === 0) {
4331
+ errors.push("scenes: at least one motion or vignette scene is required (screen scenes are footage sources only)");
4332
+ }
4333
+ if (scenario.output.format !== "mp4") {
4334
+ errors.push(`scenes timeline requires output.format mp4 (got "${scenario.output.format}")`);
4335
+ }
4336
+ for (const scene of scenario.scenes) {
4337
+ if (scene.type === "screen") {
4338
+ const hasNavigate = scene.steps[0]?.actions.some((a) => a.action === "navigate");
4339
+ if (!hasNavigate) {
4340
+ errors.push(`scenes: screen "${scene.id}" must start with a navigate action`);
4341
+ }
4342
+ } else if (scene.type === "vignette") {
4343
+ if (!screenIds.has(scene.footage)) {
4344
+ errors.push(`scenes: vignette references unknown footage "${scene.footage}"`);
4345
+ }
4346
+ for (const fx of scene.fx) {
4347
+ if (!fx.selector && !fx.coords) {
4348
+ errors.push(`scenes: vignette fx (${fx.kind}) needs "selector" or "coords"`);
4349
+ }
4350
+ }
4351
+ if (scene.crop && !scene.crop.selector && scene.crop.w === void 0) {
4352
+ warnings.push("scenes: vignette crop without selector/coords falls back to full frame");
4353
+ }
4354
+ }
4355
+ }
4356
+ }
3581
4357
  if (scenario.steps.length > 0) {
3582
4358
  const firstStep = scenario.steps[0];
3583
4359
  const hasNavigate = firstStep.actions.some(
@@ -3626,6 +4402,32 @@ function validateScenario(scenario) {
3626
4402
  `Output height ${output.height} is out of range (must be 100-3840)`
3627
4403
  );
3628
4404
  }
4405
+ if (scenario.prepare) {
4406
+ const prepare = scenario.prepare;
4407
+ if (prepare.freezeTime && Number.isNaN(Date.parse(prepare.freezeTime))) {
4408
+ errors.push(
4409
+ `prepare.freezeTime "${prepare.freezeTime}" is not a valid date (use ISO 8601, e.g. "2026-06-10T09:00:00Z")`
4410
+ );
4411
+ }
4412
+ for (let i = 0; i < prepare.mock.length; i++) {
4413
+ const mock = prepare.mock[i];
4414
+ if (!mock.fixture && mock.body === void 0) {
4415
+ errors.push(
4416
+ `prepare.mock #${i + 1} ("${mock.url}"): either "fixture" or "body" is required`
4417
+ );
4418
+ }
4419
+ if (mock.fixture && mock.body !== void 0) {
4420
+ warnings.push(
4421
+ `prepare.mock #${i + 1} ("${mock.url}"): both "fixture" and "body" set \u2014 fixture takes precedence`
4422
+ );
4423
+ }
4424
+ }
4425
+ for (const selector of prepare.hide) {
4426
+ if (selector.trim() === "") {
4427
+ errors.push("prepare.hide: selector must not be empty");
4428
+ }
4429
+ }
4430
+ }
3629
4431
  if (output.fps > 30) {
3630
4432
  warnings.push(
3631
4433
  `FPS is set to ${output.fps}. High FPS may produce very large files.`
@@ -3655,9 +4457,15 @@ export {
3655
4457
  ZOOM_INTENSITY_SCALES,
3656
4458
  applyBlur,
3657
4459
  applyCrossfade,
4460
+ applyPrepare,
3658
4461
  applySlide,
3659
4462
  applyTransition,
3660
4463
  applyZoomEasing,
4464
+ buildCssInjectionScript,
4465
+ buildFreezeTimeScript,
4466
+ buildHideCss,
4467
+ buildSeedRandomScript,
4468
+ buildStorageScript,
3661
4469
  buildZoomClickLookup,
3662
4470
  calculateAdaptiveZoom,
3663
4471
  calculateAdaptiveZoomFromLookup,
@@ -3674,7 +4482,9 @@ export {
3674
4482
  renderCursorHighlight,
3675
4483
  renderCursorTrail,
3676
4484
  renderKeystrokeHud,
4485
+ renderScenesTimeline,
3677
4486
  renderWatermark,
4487
+ resolvePreparePaths,
3678
4488
  resolveZoomScale,
3679
4489
  savePngSequence,
3680
4490
  springEasing,