clipwise 0.7.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,10 +248,12 @@ 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
254
  const contextOptions = {
145
- viewport: this.viewport
255
+ viewport: this.viewport,
256
+ deviceScaleFactor: this.deviceScaleFactor
146
257
  };
147
258
  if (scenario.auth?.storageState) {
148
259
  contextOptions.storageState = scenario.auth.storageState;
@@ -151,6 +262,9 @@ var ClipwiseRecorder = class {
151
262
  if (scenario.auth?.cookies?.length) {
152
263
  await this.context.addCookies(scenario.auth.cookies);
153
264
  }
265
+ if (scenario.prepare) {
266
+ await applyPrepare(this.context, scenario.prepare);
267
+ }
154
268
  this.page = await this.context.newPage();
155
269
  this.rawFrames = [];
156
270
  this.cursorTimeline = [];
@@ -250,7 +364,7 @@ var ClipwiseRecorder = class {
250
364
  });
251
365
  this.cdpClient = null;
252
366
  }
253
- await new Promise((resolve) => setTimeout(resolve, 200));
367
+ await new Promise((resolve3) => setTimeout(resolve3, 200));
254
368
  }
255
369
  /**
256
370
  * Execute the full scenario with continuous capture and return a RecordingSession.
@@ -468,7 +582,7 @@ var ClipwiseRecorder = class {
468
582
  const remaining = endTime - Date.now();
469
583
  if (remaining > 0) {
470
584
  await new Promise(
471
- (resolve) => setTimeout(resolve, Math.min(REPAINT_INTERVAL_MS, remaining))
585
+ (resolve3) => setTimeout(resolve3, Math.min(REPAINT_INTERVAL_MS, remaining))
472
586
  );
473
587
  }
474
588
  }
@@ -578,7 +692,7 @@ var ClipwiseRecorder = class {
578
692
  timestamp: Date.now(),
579
693
  sessionId: currentSessionId
580
694
  });
581
- await new Promise((resolve) => setTimeout(resolve, action.delay));
695
+ await new Promise((resolve3) => setTimeout(resolve3, action.delay));
582
696
  const now = Date.now();
583
697
  if (now - lastClickRefresh >= 400) {
584
698
  this.clickTimeline.push({
@@ -624,7 +738,7 @@ var ClipwiseRecorder = class {
624
738
  },
625
739
  { dy: yStep, dx: xStep, sel: action.selector ?? null }
626
740
  );
627
- await new Promise((resolve) => setTimeout(resolve, 30));
741
+ await new Promise((resolve3) => setTimeout(resolve3, 30));
628
742
  }
629
743
  await this.waitWithRepaints(150);
630
744
  } else {
@@ -732,19 +846,19 @@ var ClipwiseRecorder = class {
732
846
  break;
733
847
  case "domStable":
734
848
  conditionPromise = this.page.waitForFunction(
735
- () => new Promise((resolve) => {
849
+ () => new Promise((resolve3) => {
736
850
  let timer;
737
851
  const observer = new MutationObserver(() => {
738
852
  clearTimeout(timer);
739
853
  timer = setTimeout(() => {
740
854
  observer.disconnect();
741
- resolve(true);
855
+ resolve3(true);
742
856
  }, 500);
743
857
  });
744
858
  observer.observe(document.body, { childList: true, subtree: true, attributes: true });
745
859
  timer = setTimeout(() => {
746
860
  observer.disconnect();
747
- resolve(true);
861
+ resolve3(true);
748
862
  }, 500);
749
863
  }),
750
864
  void 0,
@@ -834,7 +948,7 @@ var ClipwiseRecorder = class {
834
948
  position: { x: point.x, y: point.y },
835
949
  timestamp: Date.now()
836
950
  });
837
- await new Promise((resolve) => setTimeout(resolve, preset.stepDelayMs));
951
+ await new Promise((resolve3) => setTimeout(resolve3, preset.stepDelayMs));
838
952
  }
839
953
  } finally {
840
954
  await this.restoreTransitions();
@@ -1010,13 +1124,11 @@ import sharp7 from "sharp";
1010
1124
 
1011
1125
  // src/effects/frame.ts
1012
1126
  import sharp from "sharp";
1013
- var TITLE_BAR_HEIGHT = 40;
1014
- var TRAFFIC_LIGHT_Y = 14;
1015
- var TRAFFIC_LIGHT_RADIUS = 6;
1016
- var TRAFFIC_LIGHTS_START_X = 16;
1017
- var TRAFFIC_LIGHT_GAP = 22;
1018
- var ADDRESS_BAR_HEIGHT = 24;
1019
- 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;
1020
1132
  var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
1021
1133
  var IPHONE_OUTER_RADIUS = 47;
1022
1134
  var IPHONE_INNER_RADIUS = 39;
@@ -1029,38 +1141,89 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
1029
1141
  var ANDROID_OUTER_RADIUS = 35;
1030
1142
  var ANDROID_INNER_RADIUS = 30;
1031
1143
  var ANDROID_CAMERA_RADIUS = 6;
1032
- function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
1033
- const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
1034
- const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
1035
- const addressBorder = darkMode ? "#444444" : "#d0d0d0";
1036
- const textColor = darkMode ? "#999999" : "#666666";
1037
- const tbarH = TITLE_BAR_HEIGHT * dpr;
1038
- 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;
1039
1166
  const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
1040
1167
  const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
1041
1168
  const tlGap = TRAFFIC_LIGHT_GAP * dpr;
1042
- const aBarH = ADDRESS_BAR_HEIGHT * dpr;
1043
- const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
1044
- const fontSize = 12 * dpr;
1169
+ const s = dpr;
1045
1170
  const trafficLights = [
1046
- { cx: tlStartX, fill: "#ff5f57" },
1047
- { cx: tlStartX + tlGap, fill: "#febc2e" },
1048
- { cx: tlStartX + tlGap * 2, fill: "#28c840" }
1049
- ].map(
1050
- (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
1051
- ).join("\n ");
1052
- const addressBarWidth = width - aBarMargin * 2;
1053
- const addressBarX = aBarMargin;
1054
- const addressBarY = (tbarH - aBarH) / 2;
1055
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
1056
- <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}"/>
1057
1216
  ${trafficLights}
1058
- <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
1059
- rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
1060
- <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
1061
- font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
1062
- localhost
1063
- </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}
1064
1227
  </svg>`;
1065
1228
  }
1066
1229
  function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
@@ -1212,7 +1375,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
1212
1375
  case "browser": {
1213
1376
  const tbarH = TITLE_BAR_HEIGHT * dpr;
1214
1377
  const totalHeight = frameHeight + tbarH;
1215
- const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
1378
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr, config.url);
1216
1379
  const chromeBuffer = Buffer.from(chromeSvg);
1217
1380
  const canvas = await sharp({
1218
1381
  create: {
@@ -2188,7 +2351,7 @@ var CanvasRenderer = class {
2188
2351
  * Workers process frames concurrently; results are collected in order.
2189
2352
  */
2190
2353
  processWithWorkers(frames, contexts, workerCount, perFrameEffects) {
2191
- return new Promise((resolve, reject) => {
2354
+ return new Promise((resolve3, reject) => {
2192
2355
  const results = new Array(frames.length);
2193
2356
  let completed = 0;
2194
2357
  let nextIndex = 0;
@@ -2226,7 +2389,7 @@ var CanvasRenderer = class {
2226
2389
  completed++;
2227
2390
  if (completed === frames.length) {
2228
2391
  workers.forEach((wk) => wk.terminate());
2229
- resolve(results);
2392
+ resolve3(results);
2230
2393
  } else {
2231
2394
  dispatch(worker);
2232
2395
  }
@@ -2900,7 +3063,7 @@ var CanvasRenderer = class {
2900
3063
  // src/compose/video-encoder.ts
2901
3064
  import gifenc from "gifenc";
2902
3065
  import sharp9 from "sharp";
2903
- import { writeFile, mkdir, readFile, rm } from "fs/promises";
3066
+ import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
2904
3067
  import { join } from "path";
2905
3068
  import { tmpdir } from "os";
2906
3069
  import { spawn } from "child_process";
@@ -2923,20 +3086,20 @@ function resolveEncodingParams(config) {
2923
3086
  var encoderScanPromise = null;
2924
3087
  function scanAvailableEncoders() {
2925
3088
  if (!encoderScanPromise) {
2926
- encoderScanPromise = new Promise((resolve) => {
3089
+ encoderScanPromise = new Promise((resolve3) => {
2927
3090
  const proc = spawn("ffmpeg", ["-encoders"], {
2928
3091
  stdio: ["ignore", "pipe", "ignore"]
2929
3092
  });
2930
3093
  let out = "";
2931
3094
  proc.stdout.on("data", (d) => out += d.toString());
2932
3095
  proc.on("close", () => {
2933
- resolve({
3096
+ resolve3({
2934
3097
  hevcHw: out.includes("hevc_videotoolbox"),
2935
3098
  h264Hw: out.includes("h264_videotoolbox"),
2936
3099
  av1: out.includes("libsvtav1")
2937
3100
  });
2938
3101
  });
2939
- proc.on("error", () => resolve({ hevcHw: false, h264Hw: false, av1: false }));
3102
+ proc.on("error", () => resolve3({ hevcHw: false, h264Hw: false, av1: false }));
2940
3103
  });
2941
3104
  }
2942
3105
  return encoderScanPromise;
@@ -3049,7 +3212,7 @@ async function encodeMp4(frames, config, audio) {
3049
3212
  const encoder = await detectVideoEncoder(config.codec);
3050
3213
  const params = resolveEncodingParams(config);
3051
3214
  await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio);
3052
- return await readFile(outputPath);
3215
+ return await readFile2(outputPath);
3053
3216
  } finally {
3054
3217
  await rm(outputPath, { force: true }).catch(() => {
3055
3218
  });
@@ -3065,7 +3228,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
3065
3228
  if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
3066
3229
  }
3067
3230
  const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
3068
- return new Promise((resolve, reject) => {
3231
+ return new Promise((resolve3, reject) => {
3069
3232
  const ffmpeg = spawn(
3070
3233
  "ffmpeg",
3071
3234
  [
@@ -3100,7 +3263,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
3100
3263
  ffmpeg.stderr.on("data", (d) => stderr += d.toString());
3101
3264
  ffmpeg.on("close", (code) => {
3102
3265
  if (code === 0) {
3103
- resolve();
3266
+ resolve3();
3104
3267
  } else {
3105
3268
  reject(
3106
3269
  new Error(
@@ -3139,7 +3302,7 @@ async function encodeMp4Stream(frames, config, audio) {
3139
3302
  const encoder = await detectVideoEncoder(config.codec);
3140
3303
  const params = resolveEncodingParams(config);
3141
3304
  await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
3142
- return await readFile(outputPath);
3305
+ return await readFile2(outputPath);
3143
3306
  } finally {
3144
3307
  await rm(outputPath, { force: true }).catch(() => {
3145
3308
  });
@@ -3192,7 +3355,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
3192
3355
  if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
3193
3356
  }
3194
3357
  const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
3195
- return new Promise((resolve, reject) => {
3358
+ return new Promise((resolve3, reject) => {
3196
3359
  const ffmpeg = spawn(
3197
3360
  "ffmpeg",
3198
3361
  [
@@ -3225,7 +3388,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
3225
3388
  ffmpeg.stderr.on("data", (d) => stderr += d.toString());
3226
3389
  ffmpeg.on("close", (code) => {
3227
3390
  if (code === 0) {
3228
- resolve();
3391
+ resolve3();
3229
3392
  } else {
3230
3393
  reject(
3231
3394
  new Error(
@@ -3349,9 +3512,342 @@ var StreamingSession = class extends EventEmitter {
3349
3512
  }
3350
3513
  };
3351
3514
 
3352
- // 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";
3353
3524
  import { parse as parseYaml } from "yaml";
3354
- 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";
3355
3851
 
3356
3852
  // src/script/types.ts
3357
3853
  import { z } from "zod";
@@ -3522,7 +4018,9 @@ var BackgroundSchema = z.object({
3522
4018
  var DeviceFrameSchema = z.object({
3523
4019
  enabled: z.boolean().default(false),
3524
4020
  type: z.enum(["browser", "macbook", "iphone", "ipad", "android", "none"]).default("browser"),
3525
- darkMode: z.boolean().default(false)
4021
+ darkMode: z.boolean().default(false),
4022
+ /** browser 타입의 주소창에 표시할 URL (실제 녹화 URL과 무관한 표시용). */
4023
+ url: z.string().default("localhost")
3526
4024
  });
3527
4025
  var SpeedRampConfigSchema = z.object({
3528
4026
  enabled: z.boolean().default(false),
@@ -3593,7 +4091,8 @@ var OutputConfigSchema = z.object({
3593
4091
  preset: z.enum(["social", "balanced", "archive"]).optional(),
3594
4092
  /** Codec override: h264 (default), hevc (10-bit), av1 (smallest files, slow encode) */
3595
4093
  codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
3596
- outputDir: z.string().default("./output"),
4094
+ // Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
4095
+ outputDir: z.string().default(".clipwise/output"),
3597
4096
  filename: z.string().default("clipwise-recording")
3598
4097
  });
3599
4098
  var StepEffectsOverrideSchema = z.object({
@@ -3648,20 +4147,124 @@ var AuthConfigSchema = z.object({
3648
4147
  })
3649
4148
  ).optional()
3650
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
+ ]);
3651
4239
  var ScenarioSchema = z.object({
3652
4240
  name: z.string(),
3653
4241
  description: z.string().optional(),
3654
4242
  viewport: z.object({
3655
4243
  width: z.number().default(1280),
3656
- 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)
3657
4247
  }).default({}),
3658
4248
  /** Optional authentication — restores browser session for logged-in pages. */
3659
4249
  auth: AuthConfigSchema.optional(),
4250
+ /** Optional recording-time runtime injection (hide/mock/freezeTime/...). */
4251
+ prepare: PrepareConfigSchema.optional(),
3660
4252
  effects: EffectsConfigSchema.default({}),
3661
4253
  output: OutputConfigSchema.default({}),
3662
4254
  /** Optional audio narration — muxed into MP4 output. */
3663
4255
  audio: AudioConfigSchema.optional(),
3664
- 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
+ }
3665
4268
  });
3666
4269
 
3667
4270
  // src/script/parser.ts
@@ -3669,7 +4272,7 @@ import { ZodError } from "zod";
3669
4272
  function parseScenario(yamlContent) {
3670
4273
  let raw;
3671
4274
  try {
3672
- raw = parseYaml(yamlContent);
4275
+ raw = parseYaml2(yamlContent);
3673
4276
  } catch (error) {
3674
4277
  const message = error instanceof Error ? error.message : "Unknown parse error";
3675
4278
  throw new Error(`YAML parse error: ${message}`);
@@ -3688,21 +4291,69 @@ ${details}`);
3688
4291
  throw error;
3689
4292
  }
3690
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
+ }
3691
4308
  async function loadScenario(filePath) {
3692
4309
  let content;
3693
4310
  try {
3694
- content = await readFile2(filePath, "utf-8");
4311
+ content = await readFile4(filePath, "utf-8");
3695
4312
  } catch (error) {
3696
4313
  const message = error instanceof Error ? error.message : "Unknown file error";
3697
4314
  throw new Error(`Failed to read scenario file "${filePath}": ${message}`);
3698
4315
  }
3699
- return parseScenario(content);
4316
+ const scenario = parseScenario(content);
4317
+ resolvePreparePaths(scenario, dirname2(resolve2(filePath)));
4318
+ return scenario;
3700
4319
  }
3701
4320
 
3702
4321
  // src/script/validator.ts
3703
4322
  function validateScenario(scenario) {
3704
4323
  const errors = [];
3705
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
+ }
3706
4357
  if (scenario.steps.length > 0) {
3707
4358
  const firstStep = scenario.steps[0];
3708
4359
  const hasNavigate = firstStep.actions.some(
@@ -3751,6 +4402,32 @@ function validateScenario(scenario) {
3751
4402
  `Output height ${output.height} is out of range (must be 100-3840)`
3752
4403
  );
3753
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
+ }
3754
4431
  if (output.fps > 30) {
3755
4432
  warnings.push(
3756
4433
  `FPS is set to ${output.fps}. High FPS may produce very large files.`
@@ -3780,9 +4457,15 @@ export {
3780
4457
  ZOOM_INTENSITY_SCALES,
3781
4458
  applyBlur,
3782
4459
  applyCrossfade,
4460
+ applyPrepare,
3783
4461
  applySlide,
3784
4462
  applyTransition,
3785
4463
  applyZoomEasing,
4464
+ buildCssInjectionScript,
4465
+ buildFreezeTimeScript,
4466
+ buildHideCss,
4467
+ buildSeedRandomScript,
4468
+ buildStorageScript,
3786
4469
  buildZoomClickLookup,
3787
4470
  calculateAdaptiveZoom,
3788
4471
  calculateAdaptiveZoomFromLookup,
@@ -3799,7 +4482,9 @@ export {
3799
4482
  renderCursorHighlight,
3800
4483
  renderCursorTrail,
3801
4484
  renderKeystrokeHud,
4485
+ renderScenesTimeline,
3802
4486
  renderWatermark,
4487
+ resolvePreparePaths,
3803
4488
  resolveZoomScale,
3804
4489
  savePngSequence,
3805
4490
  springEasing,