clipwise 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -293,7 +293,9 @@ var init_types = __esm({
293
293
  /** Fade-in duration in milliseconds. */
294
294
  fadeIn: z.number().min(0).default(0),
295
295
  /** Fade-out duration in milliseconds. */
296
- fadeOut: z.number().min(0).default(0)
296
+ fadeOut: z.number().min(0).default(0),
297
+ /** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
298
+ bpm: z.number().min(40).max(220).optional()
297
299
  });
298
300
  AuthConfigSchema = z.object({
299
301
  /** Path to a Playwright storageState JSON file (cookies + localStorage). */
@@ -324,6 +326,8 @@ var init_types = __esm({
324
326
  PrepareConfigSchema = z.object({
325
327
  /** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
326
328
  hide: z.array(z.string().min(1)).default([]),
329
+ /** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
330
+ mask: z.array(z.string().min(1)).default([]),
327
331
  /** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
328
332
  freezeTime: z.string().optional(),
329
333
  /** Math.random을 이 시드의 결정론적 PRNG로 대체. */
@@ -357,7 +361,7 @@ var init_types = __esm({
357
361
  steps: z.array(StepSchema).min(1)
358
362
  });
359
363
  SceneFxSchema = z.object({
360
- kind: z.enum(["circle", "arrow"]),
364
+ kind: z.enum(["circle", "arrow", "spotlight"]),
361
365
  /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
362
366
  selector: z.string().optional(),
363
367
  /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
@@ -387,8 +391,14 @@ var init_types = __esm({
387
391
  /** 크롭 높이 상한 (px, 원본 기준) — 와이드 스트립 연출용. */
388
392
  maxH: z.number().optional()
389
393
  }).optional(),
390
- /** 푸시인 카메라 (스케일 from→to). */
391
- push: z.object({ from: z.number().default(1), to: z.number().default(1) }).optional(),
394
+ /** 푸시인 카메라 (스케일 from→to).
395
+ * origin: 푸시 중심점 — 셀렉터(실측) 지정 그 요소를 향해 밀어 들어가
396
+ * 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
397
+ push: z.object({
398
+ from: z.number().default(1),
399
+ to: z.number().default(1),
400
+ origin: z.string().optional()
401
+ }).optional(),
392
402
  /** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
393
403
  start: z.union([z.number(), z.object({ step: z.number().int().min(0), offset: z.number().default(0) })]).default(0),
394
404
  /** 푸티지 재생 배속. */
@@ -571,6 +581,12 @@ function buildHideCss(selectors) {
571
581
  visibility: hidden !important;
572
582
  }`;
573
583
  }
584
+ function buildMaskCss(selectors) {
585
+ return `${selectors.join(",\n")} {
586
+ filter: blur(10px) !important;
587
+ border-radius: 4px;
588
+ }`;
589
+ }
574
590
  function buildCssInjectionScript(css) {
575
591
  return `(() => {
576
592
  const apply = () => {
@@ -648,6 +664,9 @@ async function applyPrepare(context, prepare) {
648
664
  if (prepare.hide.length > 0) {
649
665
  cssChunks.push(buildHideCss(prepare.hide));
650
666
  }
667
+ if (prepare.mask.length > 0) {
668
+ cssChunks.push(buildMaskCss(prepare.mask));
669
+ }
651
670
  if (prepare.inject?.css) {
652
671
  const cssFiles = Array.isArray(prepare.inject.css) ? prepare.inject.css : [prepare.inject.css];
653
672
  for (const file of cssFiles) {
@@ -3959,6 +3978,7 @@ __export(runner_exports, {
3959
3978
  });
3960
3979
  import { chromium as chromium2 } from "playwright";
3961
3980
  import { createServer } from "http";
3981
+ import { createHash } from "crypto";
3962
3982
  import { execSync } from "child_process";
3963
3983
  import { readFile as readFile4, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
3964
3984
  import { existsSync as existsSync2 } from "fs";
@@ -4100,21 +4120,21 @@ async function recordFootageTake(scenario, scene, selectors) {
4100
4120
  const recorder = new ClipwiseRecorder();
4101
4121
  const session = await recorder.record(takeScenario);
4102
4122
  const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
4103
- const composed = [];
4104
- for await (const f of renderer.composeStream(session.frames)) composed.push(f);
4105
- const frames = await Promise.all(
4106
- composed.map(
4107
- (f) => f.rawInfo ? sharp10(f.buffer, {
4108
- raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
4109
- }).png().toBuffer() : Promise.resolve(f.buffer)
4110
- )
4111
- );
4123
+ const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
4124
+ let count = 0;
4125
+ for await (const f of renderer.composeStream(session.frames)) {
4126
+ const png = f.rawInfo ? await sharp10(f.buffer, {
4127
+ raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
4128
+ }).png().toBuffer() : f.buffer;
4129
+ await writeFile2(join2(framesDir, `${count}.png`), png);
4130
+ count++;
4131
+ }
4112
4132
  const anchors = [];
4113
4133
  for (let k = 0; k < scene.steps.length; k++) {
4114
4134
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
4115
4135
  anchors.push(Math.max(0, idx) / scenario.output.fps);
4116
4136
  }
4117
- return { frames, anchors, boxes };
4137
+ return { framesDir, count, anchors, boxes };
4118
4138
  }
4119
4139
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
4120
4140
  return Math.round(Math.min(maxW, maxH * cw / ch));
@@ -4144,7 +4164,7 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
4144
4164
  await browser.close();
4145
4165
  return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
4146
4166
  }
4147
- function vignetteProps(scene, take, serverBase, scenario, brand) {
4167
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4148
4168
  const W = scenario.viewport.width;
4149
4169
  const H = scenario.viewport.height;
4150
4170
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -4168,13 +4188,13 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
4168
4188
  const props = {
4169
4189
  accent: brand.accent,
4170
4190
  font: brand.font,
4171
- dur: scene.duration / 1e3,
4191
+ dur: durMs / 1e3,
4172
4192
  layout: scene.layout,
4173
4193
  num: scene.num ?? "",
4174
4194
  label: scene.label ?? "",
4175
4195
  caption: scene.caption ?? "",
4176
4196
  base: serverBase,
4177
- count: take.frames.length,
4197
+ count: take.count,
4178
4198
  fps: scenario.output.fps,
4179
4199
  start,
4180
4200
  rate: scene.rate,
@@ -4187,13 +4207,19 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
4187
4207
  pushTo: scene.push?.to ?? 1
4188
4208
  };
4189
4209
  if (scene.code?.length) props.code = scene.code.join("||");
4210
+ if (scene.push?.origin) {
4211
+ const box = take.boxes.get(scene.push.origin);
4212
+ if (!box) throw new Error(`vignette push.origin selector "${scene.push.origin}" not found in footage "${scene.footage}"`);
4213
+ props.pushOx = Math.max(0, Math.min(100, (box.x + box.width / 2 - crop.x) / crop.w * 100)).toFixed(1);
4214
+ props.pushOy = Math.max(0, Math.min(100, (box.y + box.height / 2 - crop.y) / crop.h * 100)).toFixed(1);
4215
+ }
4190
4216
  if (brand.annotations && scene.fx.length > 0) {
4191
4217
  props.fx = scene.fx.map((fx) => {
4192
4218
  let coords = fx.coords;
4193
4219
  if (fx.selector) {
4194
4220
  const box = take.boxes.get(fx.selector);
4195
4221
  if (!box) throw new Error(`vignette fx selector "${fx.selector}" not found in footage "${scene.footage}"`);
4196
- 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];
4222
+ coords = fx.kind === "arrow" ? [box.x - 160, box.y + 120, box.x - 12, box.y + box.height / 2] : [box.x, box.y, box.width, box.height];
4197
4223
  }
4198
4224
  return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
4199
4225
  }).join(";");
@@ -4210,6 +4236,7 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4210
4236
  if (scene.type !== "vignette") continue;
4211
4237
  const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
4212
4238
  if (scene.crop?.selector) set.add(scene.crop.selector);
4239
+ if (scene.push?.origin) set.add(scene.push.origin);
4213
4240
  for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
4214
4241
  selectorsByFootage.set(scene.footage, set);
4215
4242
  }
@@ -4225,9 +4252,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4225
4252
  const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
4226
4253
  const take = m ? takes.get(m[1]) : void 0;
4227
4254
  if (take) {
4228
- const idx = Math.min(take.frames.length - 1, parseInt(m[2], 10));
4229
- res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
4230
- res.end(take.frames[idx]);
4255
+ const idx = Math.min(take.count - 1, parseInt(m[2], 10));
4256
+ readFile4(join2(take.framesDir, `${idx}.png`)).then((png) => {
4257
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
4258
+ res.end(png);
4259
+ }).catch(() => {
4260
+ res.writeHead(404);
4261
+ res.end();
4262
+ });
4231
4263
  } else {
4232
4264
  res.writeHead(404);
4233
4265
  res.end();
@@ -4235,27 +4267,32 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4235
4267
  });
4236
4268
  await new Promise((r) => server.listen(0, r));
4237
4269
  const port = server.address().port;
4238
- const totalMs = timeline.reduce((s, sc) => s + sc.duration, 0);
4270
+ const beatMs = scenario.audio?.bpm ? 6e4 / scenario.audio.bpm : 0;
4271
+ const durations = timeline.map(
4272
+ (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
4273
+ );
4274
+ const totalMs = durations.reduce((s, d) => s + d, 0);
4239
4275
  let elapsedMs = 0;
4240
4276
  const segments = [];
4241
4277
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
4242
4278
  try {
4243
4279
  for (let i = 0; i < timeline.length; i++) {
4244
4280
  const scene = timeline[i];
4281
+ const dur = durations[i];
4245
4282
  const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
4246
4283
  onProgress?.({ scene: i + 1, total: timeline.length, label });
4247
4284
  const thread = brand.annotations ? {
4248
4285
  threadFrom: (elapsedMs / totalMs).toFixed(4),
4249
- threadTo: ((elapsedMs + scene.duration) / totalMs).toFixed(4)
4286
+ threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
4250
4287
  } : {};
4251
- elapsedMs += scene.duration;
4288
+ elapsedMs += dur;
4252
4289
  let segment;
4253
4290
  if (scene.type === "motion") {
4254
4291
  const url = resolveMotionTemplate(scene.template, scenarioDir);
4255
4292
  segment = await captureMotionSegment(
4256
4293
  url,
4257
- { accent: brand.accent, font: brand.font, dur: scene.duration / 1e3, ...scene.props, ...thread },
4258
- scene.duration,
4294
+ { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
4295
+ dur,
4259
4296
  scenario
4260
4297
  );
4261
4298
  } else {
@@ -4263,8 +4300,8 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4263
4300
  const url = resolveMotionTemplate("vignette", scenarioDir);
4264
4301
  segment = await captureMotionSegment(
4265
4302
  url,
4266
- { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
4267
- scene.duration,
4303
+ { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
4304
+ dur,
4268
4305
  scenario
4269
4306
  );
4270
4307
  }
@@ -4278,13 +4315,39 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4278
4315
  const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
4279
4316
  const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
4280
4317
  const outPath = join2(tmp, "timeline.mp4");
4318
+ let audioInput = "";
4319
+ let audioMap = "";
4320
+ const totalSec = totalMs / 1e3;
4321
+ if (scenario.audio) {
4322
+ const a = scenario.audio;
4323
+ let audioPath;
4324
+ if (/^https?:\/\//.test(a.file)) {
4325
+ const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
4326
+ audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
4327
+ if (!existsSync2(audioPath)) {
4328
+ execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
4329
+ }
4330
+ } else {
4331
+ audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
4332
+ }
4333
+ const af = [];
4334
+ if (a.volume !== 1) af.push(`volume=${a.volume}`);
4335
+ if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
4336
+ if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
4337
+ audioInput = `-stream_loop -1 -i "${audioPath}" `;
4338
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
4339
+ }
4281
4340
  execSync(
4282
- `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}"`,
4341
+ `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
4283
4342
  { stdio: ["ignore", "ignore", "pipe"] }
4284
4343
  );
4285
4344
  const buffer = await readFile4(outPath);
4286
4345
  await rm2(tmp, { recursive: true, force: true }).catch(() => {
4287
4346
  });
4347
+ for (const take of takes.values()) {
4348
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
4349
+ });
4350
+ }
4288
4351
  return buffer;
4289
4352
  }
4290
4353
  var init_runner = __esm({
@@ -4522,7 +4585,7 @@ import { homedir } from "os";
4522
4585
  var program = new Command();
4523
4586
  program.name("clipwise").description(
4524
4587
  "Playwright-based cinematic screen recorder for product demos"
4525
- ).version("0.9.1");
4588
+ ).version("0.10.1");
4526
4589
  program.command("record").description("Record a demo from a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").option("-o, --output <dir>", "Output directory (default: scenario outputDir or .clipwise/output)").option(
4527
4590
  "-f, --format <format>",
4528
4591
  "Output format (gif|mp4|png-sequence)"
package/dist/index.d.ts CHANGED
@@ -1934,16 +1934,20 @@ declare const AudioConfigSchema: z.ZodObject<{
1934
1934
  fadeIn: z.ZodDefault<z.ZodNumber>;
1935
1935
  /** Fade-out duration in milliseconds. */
1936
1936
  fadeOut: z.ZodDefault<z.ZodNumber>;
1937
+ /** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
1938
+ bpm: z.ZodOptional<z.ZodNumber>;
1937
1939
  }, "strip", z.ZodTypeAny, {
1938
1940
  file: string;
1939
1941
  volume: number;
1940
1942
  fadeIn: number;
1941
1943
  fadeOut: number;
1944
+ bpm?: number | undefined;
1942
1945
  }, {
1943
1946
  file: string;
1944
1947
  volume?: number | undefined;
1945
1948
  fadeIn?: number | undefined;
1946
1949
  fadeOut?: number | undefined;
1950
+ bpm?: number | undefined;
1947
1951
  }>;
1948
1952
  type AudioConfig = z.infer<typeof AudioConfigSchema>;
1949
1953
  /**
@@ -2045,6 +2049,8 @@ type MockRoute = z.infer<typeof MockRouteSchema>;
2045
2049
  declare const PrepareConfigSchema: z.ZodObject<{
2046
2050
  /** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
2047
2051
  hide: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
2052
+ /** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
2053
+ mask: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
2048
2054
  /** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
2049
2055
  freezeTime: z.ZodOptional<z.ZodString>;
2050
2056
  /** Math.random을 이 시드의 결정론적 PRNG로 대체. */
@@ -2096,6 +2102,7 @@ declare const PrepareConfigSchema: z.ZodObject<{
2096
2102
  }>>;
2097
2103
  }, "strip", z.ZodTypeAny, {
2098
2104
  hide: string[];
2105
+ mask: string[];
2099
2106
  mock: {
2100
2107
  status: number;
2101
2108
  url: string;
@@ -2115,6 +2122,7 @@ declare const PrepareConfigSchema: z.ZodObject<{
2115
2122
  } | undefined;
2116
2123
  }, {
2117
2124
  hide?: string[] | undefined;
2125
+ mask?: string[] | undefined;
2118
2126
  freezeTime?: string | undefined;
2119
2127
  seedRandom?: number | undefined;
2120
2128
  storage?: {
@@ -3374,9 +3382,10 @@ declare const ScreenSceneSchema: z.ZodObject<{
3374
3382
  } | undefined;
3375
3383
  }[];
3376
3384
  }>;
3377
- /** 드로잉 주석 — 좌표는 셀렉터 실측(권장) 또는 푸티지 원본 px. */
3385
+ /** 푸티지 주석 — circle/arrow(선 드로잉), spotlight(주변 디밍).
3386
+ * 좌표는 셀렉터 실측(권장) 또는 푸티지 원본 px. */
3378
3387
  declare const SceneFxSchema: z.ZodObject<{
3379
- kind: z.ZodEnum<["circle", "arrow"]>;
3388
+ kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
3380
3389
  /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
3381
3390
  selector: z.ZodOptional<z.ZodString>;
3382
3391
  /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
@@ -3385,11 +3394,11 @@ declare const SceneFxSchema: z.ZodObject<{
3385
3394
  delay: z.ZodDefault<z.ZodNumber>;
3386
3395
  }, "strip", z.ZodTypeAny, {
3387
3396
  delay: number;
3388
- kind: "circle" | "arrow";
3397
+ kind: "circle" | "arrow" | "spotlight";
3389
3398
  selector?: string | undefined;
3390
3399
  coords?: number[] | undefined;
3391
3400
  }, {
3392
- kind: "circle" | "arrow";
3401
+ kind: "circle" | "arrow" | "spotlight";
3393
3402
  selector?: string | undefined;
3394
3403
  delay?: number | undefined;
3395
3404
  coords?: number[] | undefined;
@@ -3433,16 +3442,21 @@ declare const VignetteSceneSchema: z.ZodObject<{
3433
3442
  h?: number | undefined;
3434
3443
  maxH?: number | undefined;
3435
3444
  }>>;
3436
- /** 푸시인 카메라 (스케일 from→to). */
3445
+ /** 푸시인 카메라 (스케일 from→to).
3446
+ * origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
3447
+ * 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
3437
3448
  push: z.ZodOptional<z.ZodObject<{
3438
3449
  from: z.ZodDefault<z.ZodNumber>;
3439
3450
  to: z.ZodDefault<z.ZodNumber>;
3451
+ origin: z.ZodOptional<z.ZodString>;
3440
3452
  }, "strip", z.ZodTypeAny, {
3441
3453
  from: number;
3442
3454
  to: number;
3455
+ origin?: string | undefined;
3443
3456
  }, {
3444
3457
  from?: number | undefined;
3445
3458
  to?: number | undefined;
3459
+ origin?: string | undefined;
3446
3460
  }>>;
3447
3461
  /** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
3448
3462
  start: z.ZodDefault<z.ZodUnion<[z.ZodNumber, z.ZodObject<{
@@ -3458,7 +3472,7 @@ declare const VignetteSceneSchema: z.ZodObject<{
3458
3472
  /** 푸티지 재생 배속. */
3459
3473
  rate: z.ZodDefault<z.ZodNumber>;
3460
3474
  fx: z.ZodDefault<z.ZodArray<z.ZodObject<{
3461
- kind: z.ZodEnum<["circle", "arrow"]>;
3475
+ kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
3462
3476
  /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
3463
3477
  selector: z.ZodOptional<z.ZodString>;
3464
3478
  /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
@@ -3467,11 +3481,11 @@ declare const VignetteSceneSchema: z.ZodObject<{
3467
3481
  delay: z.ZodDefault<z.ZodNumber>;
3468
3482
  }, "strip", z.ZodTypeAny, {
3469
3483
  delay: number;
3470
- kind: "circle" | "arrow";
3484
+ kind: "circle" | "arrow" | "spotlight";
3471
3485
  selector?: string | undefined;
3472
3486
  coords?: number[] | undefined;
3473
3487
  }, {
3474
- kind: "circle" | "arrow";
3488
+ kind: "circle" | "arrow" | "spotlight";
3475
3489
  selector?: string | undefined;
3476
3490
  delay?: number | undefined;
3477
3491
  coords?: number[] | undefined;
@@ -3488,13 +3502,14 @@ declare const VignetteSceneSchema: z.ZodObject<{
3488
3502
  rate: number;
3489
3503
  fx: {
3490
3504
  delay: number;
3491
- kind: "circle" | "arrow";
3505
+ kind: "circle" | "arrow" | "spotlight";
3492
3506
  selector?: string | undefined;
3493
3507
  coords?: number[] | undefined;
3494
3508
  }[];
3495
3509
  push?: {
3496
3510
  from: number;
3497
3511
  to: number;
3512
+ origin?: string | undefined;
3498
3513
  } | undefined;
3499
3514
  code?: string[] | undefined;
3500
3515
  crop?: {
@@ -3516,6 +3531,7 @@ declare const VignetteSceneSchema: z.ZodObject<{
3516
3531
  push?: {
3517
3532
  from?: number | undefined;
3518
3533
  to?: number | undefined;
3534
+ origin?: string | undefined;
3519
3535
  } | undefined;
3520
3536
  code?: string[] | undefined;
3521
3537
  crop?: {
@@ -3537,7 +3553,7 @@ declare const VignetteSceneSchema: z.ZodObject<{
3537
3553
  } | undefined;
3538
3554
  rate?: number | undefined;
3539
3555
  fx?: {
3540
- kind: "circle" | "arrow";
3556
+ kind: "circle" | "arrow" | "spotlight";
3541
3557
  selector?: string | undefined;
3542
3558
  delay?: number | undefined;
3543
3559
  coords?: number[] | undefined;
@@ -4817,16 +4833,21 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
4817
4833
  h?: number | undefined;
4818
4834
  maxH?: number | undefined;
4819
4835
  }>>;
4820
- /** 푸시인 카메라 (스케일 from→to). */
4836
+ /** 푸시인 카메라 (스케일 from→to).
4837
+ * origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
4838
+ * 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
4821
4839
  push: z.ZodOptional<z.ZodObject<{
4822
4840
  from: z.ZodDefault<z.ZodNumber>;
4823
4841
  to: z.ZodDefault<z.ZodNumber>;
4842
+ origin: z.ZodOptional<z.ZodString>;
4824
4843
  }, "strip", z.ZodTypeAny, {
4825
4844
  from: number;
4826
4845
  to: number;
4846
+ origin?: string | undefined;
4827
4847
  }, {
4828
4848
  from?: number | undefined;
4829
4849
  to?: number | undefined;
4850
+ origin?: string | undefined;
4830
4851
  }>>;
4831
4852
  /** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
4832
4853
  start: z.ZodDefault<z.ZodUnion<[z.ZodNumber, z.ZodObject<{
@@ -4842,7 +4863,7 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
4842
4863
  /** 푸티지 재생 배속. */
4843
4864
  rate: z.ZodDefault<z.ZodNumber>;
4844
4865
  fx: z.ZodDefault<z.ZodArray<z.ZodObject<{
4845
- kind: z.ZodEnum<["circle", "arrow"]>;
4866
+ kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
4846
4867
  /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
4847
4868
  selector: z.ZodOptional<z.ZodString>;
4848
4869
  /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
@@ -4851,11 +4872,11 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
4851
4872
  delay: z.ZodDefault<z.ZodNumber>;
4852
4873
  }, "strip", z.ZodTypeAny, {
4853
4874
  delay: number;
4854
- kind: "circle" | "arrow";
4875
+ kind: "circle" | "arrow" | "spotlight";
4855
4876
  selector?: string | undefined;
4856
4877
  coords?: number[] | undefined;
4857
4878
  }, {
4858
- kind: "circle" | "arrow";
4879
+ kind: "circle" | "arrow" | "spotlight";
4859
4880
  selector?: string | undefined;
4860
4881
  delay?: number | undefined;
4861
4882
  coords?: number[] | undefined;
@@ -4872,13 +4893,14 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
4872
4893
  rate: number;
4873
4894
  fx: {
4874
4895
  delay: number;
4875
- kind: "circle" | "arrow";
4896
+ kind: "circle" | "arrow" | "spotlight";
4876
4897
  selector?: string | undefined;
4877
4898
  coords?: number[] | undefined;
4878
4899
  }[];
4879
4900
  push?: {
4880
4901
  from: number;
4881
4902
  to: number;
4903
+ origin?: string | undefined;
4882
4904
  } | undefined;
4883
4905
  code?: string[] | undefined;
4884
4906
  crop?: {
@@ -4900,6 +4922,7 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
4900
4922
  push?: {
4901
4923
  from?: number | undefined;
4902
4924
  to?: number | undefined;
4925
+ origin?: string | undefined;
4903
4926
  } | undefined;
4904
4927
  code?: string[] | undefined;
4905
4928
  crop?: {
@@ -4921,7 +4944,7 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
4921
4944
  } | undefined;
4922
4945
  rate?: number | undefined;
4923
4946
  fx?: {
4924
- kind: "circle" | "arrow";
4947
+ kind: "circle" | "arrow" | "spotlight";
4925
4948
  selector?: string | undefined;
4926
4949
  delay?: number | undefined;
4927
4950
  coords?: number[] | undefined;
@@ -5006,6 +5029,8 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
5006
5029
  prepare: z.ZodOptional<z.ZodObject<{
5007
5030
  /** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
5008
5031
  hide: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
5032
+ /** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
5033
+ mask: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
5009
5034
  /** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
5010
5035
  freezeTime: z.ZodOptional<z.ZodString>;
5011
5036
  /** Math.random을 이 시드의 결정론적 PRNG로 대체. */
@@ -5057,6 +5082,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
5057
5082
  }>>;
5058
5083
  }, "strip", z.ZodTypeAny, {
5059
5084
  hide: string[];
5085
+ mask: string[];
5060
5086
  mock: {
5061
5087
  status: number;
5062
5088
  url: string;
@@ -5076,6 +5102,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
5076
5102
  } | undefined;
5077
5103
  }, {
5078
5104
  hide?: string[] | undefined;
5105
+ mask?: string[] | undefined;
5079
5106
  freezeTime?: string | undefined;
5080
5107
  seedRandom?: number | undefined;
5081
5108
  storage?: {
@@ -5525,16 +5552,20 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
5525
5552
  fadeIn: z.ZodDefault<z.ZodNumber>;
5526
5553
  /** Fade-out duration in milliseconds. */
5527
5554
  fadeOut: z.ZodDefault<z.ZodNumber>;
5555
+ /** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
5556
+ bpm: z.ZodOptional<z.ZodNumber>;
5528
5557
  }, "strip", z.ZodTypeAny, {
5529
5558
  file: string;
5530
5559
  volume: number;
5531
5560
  fadeIn: number;
5532
5561
  fadeOut: number;
5562
+ bpm?: number | undefined;
5533
5563
  }, {
5534
5564
  file: string;
5535
5565
  volume?: number | undefined;
5536
5566
  fadeIn?: number | undefined;
5537
5567
  fadeOut?: number | undefined;
5568
+ bpm?: number | undefined;
5538
5569
  }>>;
5539
5570
  /** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
5540
5571
  steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -7712,16 +7743,21 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
7712
7743
  h?: number | undefined;
7713
7744
  maxH?: number | undefined;
7714
7745
  }>>;
7715
- /** 푸시인 카메라 (스케일 from→to). */
7746
+ /** 푸시인 카메라 (스케일 from→to).
7747
+ * origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
7748
+ * 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
7716
7749
  push: z.ZodOptional<z.ZodObject<{
7717
7750
  from: z.ZodDefault<z.ZodNumber>;
7718
7751
  to: z.ZodDefault<z.ZodNumber>;
7752
+ origin: z.ZodOptional<z.ZodString>;
7719
7753
  }, "strip", z.ZodTypeAny, {
7720
7754
  from: number;
7721
7755
  to: number;
7756
+ origin?: string | undefined;
7722
7757
  }, {
7723
7758
  from?: number | undefined;
7724
7759
  to?: number | undefined;
7760
+ origin?: string | undefined;
7725
7761
  }>>;
7726
7762
  /** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
7727
7763
  start: z.ZodDefault<z.ZodUnion<[z.ZodNumber, z.ZodObject<{
@@ -7737,7 +7773,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
7737
7773
  /** 푸티지 재생 배속. */
7738
7774
  rate: z.ZodDefault<z.ZodNumber>;
7739
7775
  fx: z.ZodDefault<z.ZodArray<z.ZodObject<{
7740
- kind: z.ZodEnum<["circle", "arrow"]>;
7776
+ kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
7741
7777
  /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
7742
7778
  selector: z.ZodOptional<z.ZodString>;
7743
7779
  /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
@@ -7746,11 +7782,11 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
7746
7782
  delay: z.ZodDefault<z.ZodNumber>;
7747
7783
  }, "strip", z.ZodTypeAny, {
7748
7784
  delay: number;
7749
- kind: "circle" | "arrow";
7785
+ kind: "circle" | "arrow" | "spotlight";
7750
7786
  selector?: string | undefined;
7751
7787
  coords?: number[] | undefined;
7752
7788
  }, {
7753
- kind: "circle" | "arrow";
7789
+ kind: "circle" | "arrow" | "spotlight";
7754
7790
  selector?: string | undefined;
7755
7791
  delay?: number | undefined;
7756
7792
  coords?: number[] | undefined;
@@ -7767,13 +7803,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
7767
7803
  rate: number;
7768
7804
  fx: {
7769
7805
  delay: number;
7770
- kind: "circle" | "arrow";
7806
+ kind: "circle" | "arrow" | "spotlight";
7771
7807
  selector?: string | undefined;
7772
7808
  coords?: number[] | undefined;
7773
7809
  }[];
7774
7810
  push?: {
7775
7811
  from: number;
7776
7812
  to: number;
7813
+ origin?: string | undefined;
7777
7814
  } | undefined;
7778
7815
  code?: string[] | undefined;
7779
7816
  crop?: {
@@ -7795,6 +7832,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
7795
7832
  push?: {
7796
7833
  from?: number | undefined;
7797
7834
  to?: number | undefined;
7835
+ origin?: string | undefined;
7798
7836
  } | undefined;
7799
7837
  code?: string[] | undefined;
7800
7838
  crop?: {
@@ -7816,7 +7854,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
7816
7854
  } | undefined;
7817
7855
  rate?: number | undefined;
7818
7856
  fx?: {
7819
- kind: "circle" | "arrow";
7857
+ kind: "circle" | "arrow" | "spotlight";
7820
7858
  selector?: string | undefined;
7821
7859
  delay?: number | undefined;
7822
7860
  coords?: number[] | undefined;
@@ -8084,6 +8122,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8084
8122
  } | undefined;
8085
8123
  prepare?: {
8086
8124
  hide: string[];
8125
+ mask: string[];
8087
8126
  mock: {
8088
8127
  status: number;
8089
8128
  url: string;
@@ -8107,6 +8146,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8107
8146
  volume: number;
8108
8147
  fadeIn: number;
8109
8148
  fadeOut: number;
8149
+ bpm?: number | undefined;
8110
8150
  } | undefined;
8111
8151
  scenes?: ({
8112
8152
  type: "motion";
@@ -8282,13 +8322,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8282
8322
  rate: number;
8283
8323
  fx: {
8284
8324
  delay: number;
8285
- kind: "circle" | "arrow";
8325
+ kind: "circle" | "arrow" | "spotlight";
8286
8326
  selector?: string | undefined;
8287
8327
  coords?: number[] | undefined;
8288
8328
  }[];
8289
8329
  push?: {
8290
8330
  from: number;
8291
8331
  to: number;
8332
+ origin?: string | undefined;
8292
8333
  } | undefined;
8293
8334
  code?: string[] | undefined;
8294
8335
  crop?: {
@@ -8555,6 +8596,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8555
8596
  } | undefined;
8556
8597
  prepare?: {
8557
8598
  hide?: string[] | undefined;
8599
+ mask?: string[] | undefined;
8558
8600
  freezeTime?: string | undefined;
8559
8601
  seedRandom?: number | undefined;
8560
8602
  storage?: {
@@ -8589,6 +8631,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8589
8631
  volume?: number | undefined;
8590
8632
  fadeIn?: number | undefined;
8591
8633
  fadeOut?: number | undefined;
8634
+ bpm?: number | undefined;
8592
8635
  } | undefined;
8593
8636
  scenes?: ({
8594
8637
  type: "motion";
@@ -8759,6 +8802,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8759
8802
  push?: {
8760
8803
  from?: number | undefined;
8761
8804
  to?: number | undefined;
8805
+ origin?: string | undefined;
8762
8806
  } | undefined;
8763
8807
  code?: string[] | undefined;
8764
8808
  crop?: {
@@ -8780,7 +8824,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8780
8824
  } | undefined;
8781
8825
  rate?: number | undefined;
8782
8826
  fx?: {
8783
- kind: "circle" | "arrow";
8827
+ kind: "circle" | "arrow" | "spotlight";
8784
8828
  selector?: string | undefined;
8785
8829
  delay?: number | undefined;
8786
8830
  coords?: number[] | undefined;
@@ -9048,6 +9092,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9048
9092
  } | undefined;
9049
9093
  prepare?: {
9050
9094
  hide: string[];
9095
+ mask: string[];
9051
9096
  mock: {
9052
9097
  status: number;
9053
9098
  url: string;
@@ -9071,6 +9116,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9071
9116
  volume: number;
9072
9117
  fadeIn: number;
9073
9118
  fadeOut: number;
9119
+ bpm?: number | undefined;
9074
9120
  } | undefined;
9075
9121
  scenes?: ({
9076
9122
  type: "motion";
@@ -9246,13 +9292,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9246
9292
  rate: number;
9247
9293
  fx: {
9248
9294
  delay: number;
9249
- kind: "circle" | "arrow";
9295
+ kind: "circle" | "arrow" | "spotlight";
9250
9296
  selector?: string | undefined;
9251
9297
  coords?: number[] | undefined;
9252
9298
  }[];
9253
9299
  push?: {
9254
9300
  from: number;
9255
9301
  to: number;
9302
+ origin?: string | undefined;
9256
9303
  } | undefined;
9257
9304
  code?: string[] | undefined;
9258
9305
  crop?: {
@@ -9519,6 +9566,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9519
9566
  } | undefined;
9520
9567
  prepare?: {
9521
9568
  hide?: string[] | undefined;
9569
+ mask?: string[] | undefined;
9522
9570
  freezeTime?: string | undefined;
9523
9571
  seedRandom?: number | undefined;
9524
9572
  storage?: {
@@ -9553,6 +9601,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9553
9601
  volume?: number | undefined;
9554
9602
  fadeIn?: number | undefined;
9555
9603
  fadeOut?: number | undefined;
9604
+ bpm?: number | undefined;
9556
9605
  } | undefined;
9557
9606
  scenes?: ({
9558
9607
  type: "motion";
@@ -9723,6 +9772,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9723
9772
  push?: {
9724
9773
  from?: number | undefined;
9725
9774
  to?: number | undefined;
9775
+ origin?: string | undefined;
9726
9776
  } | undefined;
9727
9777
  code?: string[] | undefined;
9728
9778
  crop?: {
@@ -9744,7 +9794,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9744
9794
  } | undefined;
9745
9795
  rate?: number | undefined;
9746
9796
  fx?: {
9747
- kind: "circle" | "arrow";
9797
+ kind: "circle" | "arrow" | "spotlight";
9748
9798
  selector?: string | undefined;
9749
9799
  delay?: number | undefined;
9750
9800
  coords?: number[] | undefined;
package/dist/index.js CHANGED
@@ -62,6 +62,12 @@ function buildHideCss(selectors) {
62
62
  visibility: hidden !important;
63
63
  }`;
64
64
  }
65
+ function buildMaskCss(selectors) {
66
+ return `${selectors.join(",\n")} {
67
+ filter: blur(10px) !important;
68
+ border-radius: 4px;
69
+ }`;
70
+ }
65
71
  function buildCssInjectionScript(css) {
66
72
  return `(() => {
67
73
  const apply = () => {
@@ -139,6 +145,9 @@ async function applyPrepare(context, prepare) {
139
145
  if (prepare.hide.length > 0) {
140
146
  cssChunks.push(buildHideCss(prepare.hide));
141
147
  }
148
+ if (prepare.mask.length > 0) {
149
+ cssChunks.push(buildMaskCss(prepare.mask));
150
+ }
142
151
  if (prepare.inject?.css) {
143
152
  const cssFiles = Array.isArray(prepare.inject.css) ? prepare.inject.css : [prepare.inject.css];
144
153
  for (const file of cssFiles) {
@@ -3515,6 +3524,7 @@ var StreamingSession = class extends EventEmitter {
3515
3524
  // src/scenes/runner.ts
3516
3525
  import { chromium as chromium2 } from "playwright";
3517
3526
  import { createServer } from "http";
3527
+ import { createHash } from "crypto";
3518
3528
  import { execSync } from "child_process";
3519
3529
  import { readFile as readFile3, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
3520
3530
  import { existsSync as existsSync2 } from "fs";
@@ -3656,21 +3666,21 @@ async function recordFootageTake(scenario, scene, selectors) {
3656
3666
  const recorder = new ClipwiseRecorder();
3657
3667
  const session = await recorder.record(takeScenario);
3658
3668
  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
- );
3669
+ const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
3670
+ let count = 0;
3671
+ for await (const f of renderer.composeStream(session.frames)) {
3672
+ const png = f.rawInfo ? await sharp10(f.buffer, {
3673
+ raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
3674
+ }).png().toBuffer() : f.buffer;
3675
+ await writeFile2(join2(framesDir, `${count}.png`), png);
3676
+ count++;
3677
+ }
3668
3678
  const anchors = [];
3669
3679
  for (let k = 0; k < scene.steps.length; k++) {
3670
3680
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
3671
3681
  anchors.push(Math.max(0, idx) / scenario.output.fps);
3672
3682
  }
3673
- return { frames, anchors, boxes };
3683
+ return { framesDir, count, anchors, boxes };
3674
3684
  }
3675
3685
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
3676
3686
  return Math.round(Math.min(maxW, maxH * cw / ch));
@@ -3700,7 +3710,7 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
3700
3710
  await browser.close();
3701
3711
  return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
3702
3712
  }
3703
- function vignetteProps(scene, take, serverBase, scenario, brand) {
3713
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3704
3714
  const W = scenario.viewport.width;
3705
3715
  const H = scenario.viewport.height;
3706
3716
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -3724,13 +3734,13 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
3724
3734
  const props = {
3725
3735
  accent: brand.accent,
3726
3736
  font: brand.font,
3727
- dur: scene.duration / 1e3,
3737
+ dur: durMs / 1e3,
3728
3738
  layout: scene.layout,
3729
3739
  num: scene.num ?? "",
3730
3740
  label: scene.label ?? "",
3731
3741
  caption: scene.caption ?? "",
3732
3742
  base: serverBase,
3733
- count: take.frames.length,
3743
+ count: take.count,
3734
3744
  fps: scenario.output.fps,
3735
3745
  start,
3736
3746
  rate: scene.rate,
@@ -3743,13 +3753,19 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
3743
3753
  pushTo: scene.push?.to ?? 1
3744
3754
  };
3745
3755
  if (scene.code?.length) props.code = scene.code.join("||");
3756
+ if (scene.push?.origin) {
3757
+ const box = take.boxes.get(scene.push.origin);
3758
+ if (!box) throw new Error(`vignette push.origin selector "${scene.push.origin}" not found in footage "${scene.footage}"`);
3759
+ props.pushOx = Math.max(0, Math.min(100, (box.x + box.width / 2 - crop.x) / crop.w * 100)).toFixed(1);
3760
+ props.pushOy = Math.max(0, Math.min(100, (box.y + box.height / 2 - crop.y) / crop.h * 100)).toFixed(1);
3761
+ }
3746
3762
  if (brand.annotations && scene.fx.length > 0) {
3747
3763
  props.fx = scene.fx.map((fx) => {
3748
3764
  let coords = fx.coords;
3749
3765
  if (fx.selector) {
3750
3766
  const box = take.boxes.get(fx.selector);
3751
3767
  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];
3768
+ coords = fx.kind === "arrow" ? [box.x - 160, box.y + 120, box.x - 12, box.y + box.height / 2] : [box.x, box.y, box.width, box.height];
3753
3769
  }
3754
3770
  return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
3755
3771
  }).join(";");
@@ -3766,6 +3782,7 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3766
3782
  if (scene.type !== "vignette") continue;
3767
3783
  const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
3768
3784
  if (scene.crop?.selector) set.add(scene.crop.selector);
3785
+ if (scene.push?.origin) set.add(scene.push.origin);
3769
3786
  for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
3770
3787
  selectorsByFootage.set(scene.footage, set);
3771
3788
  }
@@ -3781,9 +3798,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3781
3798
  const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
3782
3799
  const take = m ? takes.get(m[1]) : void 0;
3783
3800
  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]);
3801
+ const idx = Math.min(take.count - 1, parseInt(m[2], 10));
3802
+ readFile3(join2(take.framesDir, `${idx}.png`)).then((png) => {
3803
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
3804
+ res.end(png);
3805
+ }).catch(() => {
3806
+ res.writeHead(404);
3807
+ res.end();
3808
+ });
3787
3809
  } else {
3788
3810
  res.writeHead(404);
3789
3811
  res.end();
@@ -3791,27 +3813,32 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3791
3813
  });
3792
3814
  await new Promise((r) => server.listen(0, r));
3793
3815
  const port = server.address().port;
3794
- const totalMs = timeline.reduce((s, sc) => s + sc.duration, 0);
3816
+ const beatMs = scenario.audio?.bpm ? 6e4 / scenario.audio.bpm : 0;
3817
+ const durations = timeline.map(
3818
+ (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
3819
+ );
3820
+ const totalMs = durations.reduce((s, d) => s + d, 0);
3795
3821
  let elapsedMs = 0;
3796
3822
  const segments = [];
3797
3823
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
3798
3824
  try {
3799
3825
  for (let i = 0; i < timeline.length; i++) {
3800
3826
  const scene = timeline[i];
3827
+ const dur = durations[i];
3801
3828
  const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
3802
3829
  onProgress?.({ scene: i + 1, total: timeline.length, label });
3803
3830
  const thread = brand.annotations ? {
3804
3831
  threadFrom: (elapsedMs / totalMs).toFixed(4),
3805
- threadTo: ((elapsedMs + scene.duration) / totalMs).toFixed(4)
3832
+ threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
3806
3833
  } : {};
3807
- elapsedMs += scene.duration;
3834
+ elapsedMs += dur;
3808
3835
  let segment;
3809
3836
  if (scene.type === "motion") {
3810
3837
  const url = resolveMotionTemplate(scene.template, scenarioDir);
3811
3838
  segment = await captureMotionSegment(
3812
3839
  url,
3813
- { accent: brand.accent, font: brand.font, dur: scene.duration / 1e3, ...scene.props, ...thread },
3814
- scene.duration,
3840
+ { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
3841
+ dur,
3815
3842
  scenario
3816
3843
  );
3817
3844
  } else {
@@ -3819,8 +3846,8 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3819
3846
  const url = resolveMotionTemplate("vignette", scenarioDir);
3820
3847
  segment = await captureMotionSegment(
3821
3848
  url,
3822
- { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
3823
- scene.duration,
3849
+ { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
3850
+ dur,
3824
3851
  scenario
3825
3852
  );
3826
3853
  }
@@ -3834,13 +3861,39 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3834
3861
  const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
3835
3862
  const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
3836
3863
  const outPath = join2(tmp, "timeline.mp4");
3864
+ let audioInput = "";
3865
+ let audioMap = "";
3866
+ const totalSec = totalMs / 1e3;
3867
+ if (scenario.audio) {
3868
+ const a = scenario.audio;
3869
+ let audioPath;
3870
+ if (/^https?:\/\//.test(a.file)) {
3871
+ const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
3872
+ audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
3873
+ if (!existsSync2(audioPath)) {
3874
+ execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
3875
+ }
3876
+ } else {
3877
+ audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
3878
+ }
3879
+ const af = [];
3880
+ if (a.volume !== 1) af.push(`volume=${a.volume}`);
3881
+ if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
3882
+ if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
3883
+ audioInput = `-stream_loop -1 -i "${audioPath}" `;
3884
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
3885
+ }
3837
3886
  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}"`,
3887
+ `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
3839
3888
  { stdio: ["ignore", "ignore", "pipe"] }
3840
3889
  );
3841
3890
  const buffer = await readFile3(outPath);
3842
3891
  await rm2(tmp, { recursive: true, force: true }).catch(() => {
3843
3892
  });
3893
+ for (const take of takes.values()) {
3894
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
3895
+ });
3896
+ }
3844
3897
  return buffer;
3845
3898
  }
3846
3899
 
@@ -4129,7 +4182,9 @@ var AudioConfigSchema = z.object({
4129
4182
  /** Fade-in duration in milliseconds. */
4130
4183
  fadeIn: z.number().min(0).default(0),
4131
4184
  /** Fade-out duration in milliseconds. */
4132
- fadeOut: z.number().min(0).default(0)
4185
+ fadeOut: z.number().min(0).default(0),
4186
+ /** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
4187
+ bpm: z.number().min(40).max(220).optional()
4133
4188
  });
4134
4189
  var AuthConfigSchema = z.object({
4135
4190
  /** Path to a Playwright storageState JSON file (cookies + localStorage). */
@@ -4160,6 +4215,8 @@ var MockRouteSchema = z.object({
4160
4215
  var PrepareConfigSchema = z.object({
4161
4216
  /** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
4162
4217
  hide: z.array(z.string().min(1)).default([]),
4218
+ /** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
4219
+ mask: z.array(z.string().min(1)).default([]),
4163
4220
  /** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
4164
4221
  freezeTime: z.string().optional(),
4165
4222
  /** Math.random을 이 시드의 결정론적 PRNG로 대체. */
@@ -4193,7 +4250,7 @@ var ScreenSceneSchema = z.object({
4193
4250
  steps: z.array(StepSchema).min(1)
4194
4251
  });
4195
4252
  var SceneFxSchema = z.object({
4196
- kind: z.enum(["circle", "arrow"]),
4253
+ kind: z.enum(["circle", "arrow", "spotlight"]),
4197
4254
  /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
4198
4255
  selector: z.string().optional(),
4199
4256
  /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
@@ -4223,8 +4280,14 @@ var VignetteSceneSchema = z.object({
4223
4280
  /** 크롭 높이 상한 (px, 원본 기준) — 와이드 스트립 연출용. */
4224
4281
  maxH: z.number().optional()
4225
4282
  }).optional(),
4226
- /** 푸시인 카메라 (스케일 from→to). */
4227
- push: z.object({ from: z.number().default(1), to: z.number().default(1) }).optional(),
4283
+ /** 푸시인 카메라 (스케일 from→to).
4284
+ * origin: 푸시 중심점 — 셀렉터(실측) 지정 그 요소를 향해 밀어 들어가
4285
+ * 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
4286
+ push: z.object({
4287
+ from: z.number().default(1),
4288
+ to: z.number().default(1),
4289
+ origin: z.string().optional()
4290
+ }).optional(),
4228
4291
  /** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
4229
4292
  start: z.union([z.number(), z.object({ step: z.number().int().min(0), offset: z.number().default(0) })]).default(0),
4230
4293
  /** 푸티지 재생 배속. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "Scriptable cinematic screen recorder for product demos — YAML in, polished MP4 out",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -375,12 +375,14 @@ scenes:
375
375
  label: "Smart Speed"
376
376
  caption: "Loading compressed, *results crisp*"
377
377
  crop: { selector: ".panel", pad: 14, maxH: 250 } # selector-measured, never guess pixels
378
- push: { from: 1.05, to: 1 } # push-in/out camera
378
+ push: { from: 1.05, to: 1, origin: ".panel" } # origin: match-cut — push toward
379
+ # a selector so the NEXT scene's crop continues the move
379
380
  start: { step: 3, offset: 0 } # quote footage from a step boundary (or seconds)
380
381
  rate: 1.15 # playback speed of the quoted footage
381
- fx: # line-draw annotations on the footage
382
- - { kind: circle, selector: "#revenue", delay: 2500 }
383
- - { kind: arrow, selector: ".panel", delay: 2900 }
382
+ fx: # annotations on the footage
383
+ - { kind: circle, selector: "#revenue", delay: 2500 } # hand-drawn circle
384
+ - { kind: arrow, selector: ".panel", delay: 2900 } # drawn arrow
385
+ - { kind: spotlight, selector: "#revenue", delay: 2400 } # dim everything else
384
386
  # code: ["prepare:", " hide: [...]"] # split layout left code card
385
387
  ```
386
388
 
@@ -395,6 +397,13 @@ scenes:
395
397
  5. **Footage effects**: in scenes mode set only `cursor:` (highlight: false) — zoom/frame/background
396
398
  are handled by the vignette compositor, not the recorder
397
399
  6. Keep one screen take (~12-15s) and let vignettes quote segments via `start: { step: N }`
400
+ 7. **Sensitive data**: `prepare.mask: [".email", ".amount"]` blurs elements at record time
401
+ (follows scrolling — never ask the user to fake their data)
402
+ 8. **Music**: `audio: { file: bgm.mp3, bpm: 122, fadeOut: 1500 }` muxes BGM into the final
403
+ video AND snaps every scene cut onto the beat grid (beat-synced cuts).
404
+ `file:` also accepts a URL (downloaded+cached on the user's machine — use license-free
405
+ sources like Mixkit, e.g. `https://assets.mixkit.co/music/132/132.mp3`, ~120bpm).
406
+ Track shorter than the video loops automatically; video length is always authoritative
398
407
 
399
408
  ## Critical Rules
400
409
 
@@ -113,6 +113,15 @@
113
113
  /* ── 선 드로잉 주석 레이어 (fx=circle@…;arrow@…) — 카드 위에 그려짐 ── */
114
114
  .annotations { position: absolute; inset: 0; z-index: 5; pointer-events: none;
115
115
  overflow: visible; }
116
+ /* 스포트라이트 — 대상만 남기고 주변을 디밍 (fx kind: spotlight) */
117
+ .spotlight { position: absolute; z-index: 4; pointer-events: none;
118
+ /* spread가 너무 크면 Chromium이 그림자를 클램프하므로 카드 크기 수준으로 */
119
+ box-shadow: 0 0 0 1400px rgba(12, 12, 16, 0.52); border-radius: 10px;
120
+ /* 다크 UI에서도 컷아웃이 인지되도록 은은한 악센트 림 */
121
+ outline: 1.5px solid color-mix(in srgb, var(--accent) 55%, transparent);
122
+ outline-offset: 3px;
123
+ opacity: 0; animation: spot-in 550ms cubic-bezier(0.4, 0, 0.2, 1) both; }
124
+ @keyframes spot-in { to { opacity: 1; } }
116
125
  .annotations .stroke { stroke: var(--accent); fill: none; stroke-width: 3.5;
117
126
  stroke-linecap: round; stroke-linejoin: round;
118
127
  stroke-dasharray: 1; stroke-dashoffset: 1;
@@ -178,9 +187,12 @@
178
187
  card.style.setProperty("--push-from", P.get("pushFrom") || "1");
179
188
  card.style.setProperty("--push-to", P.get("pushTo") || "1");
180
189
  const fullCrop = cw >= SRC_W;
190
+ // push origin — 매치컷: 다음 신의 크롭 중심을 향해 밀어 들어가도록 중심점 지정
191
+ const pushOx = num("pushOx", 50), pushOy = num("pushOy", 42);
181
192
  card.innerHTML = `
182
193
  ${fullCrop ? '<div class="titlebar"><i></i><i></i><i></i></div>' : ""}
183
- <div class="viewport"><div class="pusher" style="animation-duration:${num("dur", 4)}s">
194
+ <div class="viewport"><div class="pusher"
195
+ style="animation-duration:${num("dur", 4)}s; transform-origin:${pushOx}% ${pushOy}%">
184
196
  <img class="footage" id="footage"
185
197
  style="width:${SRC_W * scale}px;height:${SRC_H * scale}px;left:${-cx * scale}px;top:${-cy * scale}px" />
186
198
  </div></div>`;
@@ -224,6 +236,15 @@
224
236
  for (const spec of fxSpecs) {
225
237
  const [kind, coords, delay = "0"] = spec.split("@");
226
238
  const n = coords.split(",").map(Number);
239
+ if (kind === "spotlight") {
240
+ // 푸시 레이어 안에 디밍 컷아웃 — 푸티지에 앵커링되어 카메라를 따라간다
241
+ const [sx, sy] = toCard(n[0], n[1]);
242
+ const spot = document.createElement("div");
243
+ spot.className = "spotlight";
244
+ spot.style.cssText = `left:${sx - 6}px;top:${sy - 6}px;width:${n[2] * effScale + 12}px;height:${n[3] * effScale + 12}px;animation-delay:${delay}ms`;
245
+ card.querySelector(".pusher").appendChild(spot);
246
+ continue;
247
+ }
227
248
  if (kind === "circle") {
228
249
  // 손그림 느낌: 살짝 큰 타원 + 미세 회전
229
250
  const [x0, y0] = toCard(n[0], n[1]);