clipwise 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -25,6 +25,20 @@ npx clipwise@latest record .clipwise/scenarios/demo.yaml
25
25
  **Zero footprint**: Clipwise가 남기는 모든 것(시나리오, 픽스처, 인증 상태, 출력물)은
26
26
  `.clipwise/` 디렉토리 하나에 담깁니다. `rm -rf .clipwise` 한 줄로 모든 흔적이 사라집니다.
27
27
 
28
+ ### 처음 5분 가이드
29
+
30
+ 1. `npx clipwise@latest init` — 바로 쓸 수 있는 시나리오 2개와 함께 `.clipwise/` 생성
31
+ 2. `npx clipwise@latest record .clipwise/scenarios/keynote.yaml` — **수정 없이** 키노트
32
+ 런치 영상이 렌더됩니다 (호스팅 데모 대시보드를 녹화)
33
+ 3. `.clipwise/output/keynote.mp4` 열기 — 이것이 기본으로 제공되는 퀄리티 기준입니다
34
+ 4. `keynote.yaml` 수정: `url:`과 셀렉터를 **내 앱**으로 교체, 캡션 문구 조정
35
+ 5. `brand.yaml` 수정: accent 컬러·폰트 프리셋·캐치프레이즈 — 영상 전체가 따라옵니다
36
+
37
+ 자연어가 편하시면 `npx clipwise install-skill` 후 Claude Code에서 `/clipwise`로
38
+ 요청하세요 — 키노트 레시피가 내장된 스킬이 YAML을 대신 작성합니다. 런치 영상이
39
+ 아니라 단순 화면 녹화가 필요하면 `scenarios/demo.yaml`과 아래
40
+ [YAML 시나리오 형식](#yaml-시나리오-형식)에서 시작하세요.
41
+
28
42
  ## 요구사항
29
43
 
30
44
  - **Node.js** >= 18
package/README.md CHANGED
@@ -25,6 +25,20 @@ npx clipwise@latest record .clipwise/scenarios/demo.yaml
25
25
  **Zero footprint**: everything Clipwise touches lives in one `.clipwise/` directory —
26
26
  scenarios, fixtures, auth state, output. Remove every trace with `rm -rf .clipwise`.
27
27
 
28
+ ### Your first 5 minutes
29
+
30
+ 1. `npx clipwise@latest init` — scaffolds `.clipwise/` with two ready scenarios
31
+ 2. `npx clipwise@latest record .clipwise/scenarios/keynote.yaml` — renders a full
32
+ keynote-style launch video **with zero edits** (it records the hosted demo dashboard)
33
+ 3. Open `.clipwise/output/keynote.mp4` — this is the quality bar you get out of the box
34
+ 4. Edit `keynote.yaml`: swap the `url:` and selectors for **your** app, tweak the captions
35
+ 5. Edit `brand.yaml`: your accent color, font preset, catchphrases — the whole video follows
36
+
37
+ Prefer natural language? `npx clipwise install-skill`, then ask `/clipwise` in
38
+ Claude Code — it writes these YAMLs for you (the skill ships with the full
39
+ keynote recipe). For a plain screen recording instead of a launch video, start
40
+ from `scenarios/demo.yaml` and the [YAML Scenario Format](#yaml-scenario-format) below.
41
+
28
42
  ## Requirements
29
43
 
30
44
  - **Node.js** >= 18
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) {
@@ -4144,7 +4163,7 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
4144
4163
  await browser.close();
4145
4164
  return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
4146
4165
  }
4147
- function vignetteProps(scene, take, serverBase, scenario, brand) {
4166
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4148
4167
  const W = scenario.viewport.width;
4149
4168
  const H = scenario.viewport.height;
4150
4169
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -4168,7 +4187,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
4168
4187
  const props = {
4169
4188
  accent: brand.accent,
4170
4189
  font: brand.font,
4171
- dur: scene.duration / 1e3,
4190
+ dur: durMs / 1e3,
4172
4191
  layout: scene.layout,
4173
4192
  num: scene.num ?? "",
4174
4193
  label: scene.label ?? "",
@@ -4187,13 +4206,19 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
4187
4206
  pushTo: scene.push?.to ?? 1
4188
4207
  };
4189
4208
  if (scene.code?.length) props.code = scene.code.join("||");
4209
+ if (scene.push?.origin) {
4210
+ const box = take.boxes.get(scene.push.origin);
4211
+ if (!box) throw new Error(`vignette push.origin selector "${scene.push.origin}" not found in footage "${scene.footage}"`);
4212
+ props.pushOx = Math.max(0, Math.min(100, (box.x + box.width / 2 - crop.x) / crop.w * 100)).toFixed(1);
4213
+ props.pushOy = Math.max(0, Math.min(100, (box.y + box.height / 2 - crop.y) / crop.h * 100)).toFixed(1);
4214
+ }
4190
4215
  if (brand.annotations && scene.fx.length > 0) {
4191
4216
  props.fx = scene.fx.map((fx) => {
4192
4217
  let coords = fx.coords;
4193
4218
  if (fx.selector) {
4194
4219
  const box = take.boxes.get(fx.selector);
4195
4220
  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];
4221
+ 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
4222
  }
4198
4223
  return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
4199
4224
  }).join(";");
@@ -4210,6 +4235,7 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4210
4235
  if (scene.type !== "vignette") continue;
4211
4236
  const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
4212
4237
  if (scene.crop?.selector) set.add(scene.crop.selector);
4238
+ if (scene.push?.origin) set.add(scene.push.origin);
4213
4239
  for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
4214
4240
  selectorsByFootage.set(scene.footage, set);
4215
4241
  }
@@ -4235,27 +4261,32 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4235
4261
  });
4236
4262
  await new Promise((r) => server.listen(0, r));
4237
4263
  const port = server.address().port;
4238
- const totalMs = timeline.reduce((s, sc) => s + sc.duration, 0);
4264
+ const beatMs = scenario.audio?.bpm ? 6e4 / scenario.audio.bpm : 0;
4265
+ const durations = timeline.map(
4266
+ (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
4267
+ );
4268
+ const totalMs = durations.reduce((s, d) => s + d, 0);
4239
4269
  let elapsedMs = 0;
4240
4270
  const segments = [];
4241
4271
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
4242
4272
  try {
4243
4273
  for (let i = 0; i < timeline.length; i++) {
4244
4274
  const scene = timeline[i];
4275
+ const dur = durations[i];
4245
4276
  const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
4246
4277
  onProgress?.({ scene: i + 1, total: timeline.length, label });
4247
4278
  const thread = brand.annotations ? {
4248
4279
  threadFrom: (elapsedMs / totalMs).toFixed(4),
4249
- threadTo: ((elapsedMs + scene.duration) / totalMs).toFixed(4)
4280
+ threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
4250
4281
  } : {};
4251
- elapsedMs += scene.duration;
4282
+ elapsedMs += dur;
4252
4283
  let segment;
4253
4284
  if (scene.type === "motion") {
4254
4285
  const url = resolveMotionTemplate(scene.template, scenarioDir);
4255
4286
  segment = await captureMotionSegment(
4256
4287
  url,
4257
- { accent: brand.accent, font: brand.font, dur: scene.duration / 1e3, ...scene.props, ...thread },
4258
- scene.duration,
4288
+ { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
4289
+ dur,
4259
4290
  scenario
4260
4291
  );
4261
4292
  } else {
@@ -4263,8 +4294,8 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4263
4294
  const url = resolveMotionTemplate("vignette", scenarioDir);
4264
4295
  segment = await captureMotionSegment(
4265
4296
  url,
4266
- { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
4267
- scene.duration,
4297
+ { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
4298
+ dur,
4268
4299
  scenario
4269
4300
  );
4270
4301
  }
@@ -4278,8 +4309,21 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4278
4309
  const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
4279
4310
  const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
4280
4311
  const outPath = join2(tmp, "timeline.mp4");
4312
+ let audioInput = "";
4313
+ let audioMap = "";
4314
+ if (scenario.audio) {
4315
+ const a = scenario.audio;
4316
+ const audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
4317
+ const totalSec = totalMs / 1e3;
4318
+ const af = [];
4319
+ if (a.volume !== 1) af.push(`volume=${a.volume}`);
4320
+ if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
4321
+ if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
4322
+ audioInput = `-i "${audioPath}" `;
4323
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
4324
+ }
4281
4325
  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}"`,
4326
+ `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}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
4283
4327
  { stdio: ["ignore", "ignore", "pipe"] }
4284
4328
  );
4285
4329
  const buffer = await readFile4(outPath);
@@ -4522,7 +4566,7 @@ import { homedir } from "os";
4522
4566
  var program = new Command();
4523
4567
  program.name("clipwise").description(
4524
4568
  "Playwright-based cinematic screen recorder for product demos"
4525
- ).version("0.9.0");
4569
+ ).version("0.10.0");
4526
4570
  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
4571
  "-f, --format <format>",
4528
4572
  "Output format (gif|mp4|png-sequence)"
@@ -4793,6 +4837,97 @@ steps:
4793
4837
  actions:
4794
4838
  - action: click
4795
4839
  selector: "#my-button"
4840
+ `;
4841
+ const keynoteTemplate = `# Keynote-style launch video \u2014 runs AS-IS against the hosted demo.
4842
+ # Try it first: clipwise record .clipwise/scenarios/keynote.yaml
4843
+ # Then replace the url + selectors with your own app.
4844
+
4845
+ name: "My Launch Video"
4846
+ viewport: { width: 1280, height: 800, deviceScaleFactor: 2 } # 2 = retina quality
4847
+
4848
+ effects:
4849
+ cursor: { enabled: true, clickEffect: true, highlight: false, trail: false }
4850
+
4851
+ output:
4852
+ format: mp4
4853
+ fps: 30
4854
+ preset: balanced
4855
+ filename: keynote
4856
+
4857
+ scenes:
4858
+ # footage take \u2014 recorded once; vignettes below quote it by step
4859
+ - type: screen
4860
+ id: demo
4861
+ steps:
4862
+ - name: "Open"
4863
+ captureDelay: 120
4864
+ holdDuration: 1400
4865
+ actions:
4866
+ - action: navigate
4867
+ url: "https://kwakseongjae.github.io/clipwise/demo/" # \u2190 your app URL
4868
+ waitUntil: networkidle
4869
+ - name: "Stats"
4870
+ captureDelay: 50
4871
+ holdDuration: 700
4872
+ actions:
4873
+ - { action: hover, selector: "#stat-users" } # \u2190 your selectors
4874
+ - name: "Search"
4875
+ captureDelay: 50
4876
+ holdDuration: 500
4877
+ actions:
4878
+ - { action: type, selector: "#search-input", text: "growth report", delay: 28 }
4879
+ - name: "Switch tab"
4880
+ captureDelay: 50
4881
+ holdDuration: 1100
4882
+ actions:
4883
+ - { action: click, selector: "#tab-daily" }
4884
+ - name: "Table"
4885
+ captureDelay: 80
4886
+ holdDuration: 1300
4887
+ actions:
4888
+ - action: scroll
4889
+ y: 420
4890
+ smooth: true
4891
+
4892
+ # \u2500\u2500 timeline \u2500\u2500
4893
+ - type: motion
4894
+ template: kinetic-type
4895
+ duration: 2200
4896
+ props: { lines: "Ship *demos*,||not edits.", size: 86 }
4897
+
4898
+ - type: vignette
4899
+ footage: demo
4900
+ duration: 4200
4901
+ layout: hero
4902
+ num: "01"
4903
+ label: "Cinematic camera"
4904
+ caption: "Recorded from a real app \u2014 *zero code changes*"
4905
+ push: { from: 1.02, to: 1.1 }
4906
+ start: { step: 0, offset: 0.15 }
4907
+ fx:
4908
+ - { kind: circle, selector: "#stat-users", delay: 2700 }
4909
+
4910
+ - type: vignette
4911
+ footage: demo
4912
+ duration: 4000
4913
+ layout: crop
4914
+ num: "02"
4915
+ label: "Close-up"
4916
+ caption: "Selector-measured crop \u2014 *no pixel guessing*"
4917
+ crop: { selector: "#chart-area", pad: 14 }
4918
+ push: { from: 1.04, to: 1 }
4919
+ start: { step: 3 }
4920
+ rate: 1.1
4921
+
4922
+ - type: motion
4923
+ template: kinetic-type
4924
+ duration: 1900
4925
+ props: { lines: "Your code,||*untouched.*", size: 80, fx: marker }
4926
+
4927
+ - type: motion
4928
+ template: kinetic-type
4929
+ duration: 2600
4930
+ props: { lines: "*My Product*", size: 90, sub: "npx clipwise@latest init" }
4796
4931
  `;
4797
4932
  const gitignore = `# Clipwise local artifacts \u2014 safe to ignore
4798
4933
  auth/
@@ -4834,23 +4969,27 @@ catchphrases:
4834
4969
  await mkdir2(join3(baseDir, "fixtures"), { recursive: true });
4835
4970
  await mkdir2(join3(baseDir, "auth"), { recursive: true });
4836
4971
  await writeFile3(join3(baseDir, "scenarios", "demo.yaml"), template, "utf-8");
4972
+ await writeFile3(join3(baseDir, "scenarios", "keynote.yaml"), keynoteTemplate, "utf-8");
4837
4973
  await writeFile3(join3(baseDir, "brand.yaml"), brandTemplate, "utf-8");
4838
4974
  await writeFile3(join3(baseDir, ".gitignore"), gitignore, "utf-8");
4839
4975
  console.log(chalk.green("Created .clipwise/\n"));
4840
4976
  console.log(" .clipwise/");
4841
- console.log(" scenarios/demo.yaml \u2014 your first scenario (edit the URL)");
4842
- console.log(" brand.yaml \u2014 tone & manner + catchphrases (Brand Kit)");
4843
- console.log(" prepare/ \u2014 CSS/JS injected only while recording");
4844
- console.log(" fixtures/ \u2014 mocked API responses (JSON)");
4845
- console.log(" auth/ \u2014 storageState files (gitignored)");
4846
- console.log(" .gitignore \u2014 keeps auth/, output/, cache/ out of git");
4847
- console.log("\nNext steps:");
4848
- console.log(` 1. Edit ${chalk.bold(".clipwise/scenarios/demo.yaml")} \u2014 change the URL to your site`);
4849
- console.log(` 2. Run ${chalk.bold("clipwise record .clipwise/scenarios/demo.yaml")}`);
4850
- console.log(` 3. Find your output in ${chalk.bold(".clipwise/output/")}`);
4977
+ console.log(" scenarios/keynote.yaml \u2014 keynote launch video (runs as-is!)");
4978
+ console.log(" scenarios/demo.yaml \u2014 simple screen recording (edit the URL)");
4979
+ console.log(" brand.yaml \u2014 tone & font presets + catchphrases");
4980
+ console.log(" prepare/ \u2014 CSS/JS injected only while recording");
4981
+ console.log(" fixtures/ \u2014 mocked API responses (JSON)");
4982
+ console.log(" auth/ \u2014 storageState files (gitignored)");
4983
+ console.log(`
4984
+ ${chalk.bold("Try it right now")} (no edits needed \u2014 records the hosted demo):`);
4985
+ console.log(` ${chalk.bold("clipwise record .clipwise/scenarios/keynote.yaml")}`);
4986
+ console.log("\nThen make it yours:");
4987
+ console.log(` 1. Edit ${chalk.bold("keynote.yaml")} \u2014 swap the url + selectors for your app`);
4988
+ console.log(` 2. Edit ${chalk.bold("brand.yaml")} \u2014 your accent color & catchphrases`);
4989
+ console.log(` 3. Or let AI write scenarios: ${chalk.bold("clipwise install-skill")} \u2192 ask ${chalk.bold("/clipwise")} in Claude Code`);
4851
4990
  console.log(`
4852
- Remove every trace anytime: ${chalk.bold("rm -rf .clipwise")}`);
4853
- console.log(`Or try the built-in demo: ${chalk.bold("clipwise demo")}
4991
+ Output lands in ${chalk.bold(".clipwise/output/")} \xB7 remove every trace: ${chalk.bold("rm -rf .clipwise")}`);
4992
+ console.log(`Docs: ${chalk.bold("https://kwakseongjae.github.io/clipwise/")}
4854
4993
  `);
4855
4994
  });
4856
4995
  program.command("demo").description("Record a demo video of the Clipwise showcase dashboard").option("-o, --output <dir>", "Output directory", ".clipwise/output").option(
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) {
@@ -3700,7 +3709,7 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
3700
3709
  await browser.close();
3701
3710
  return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
3702
3711
  }
3703
- function vignetteProps(scene, take, serverBase, scenario, brand) {
3712
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3704
3713
  const W = scenario.viewport.width;
3705
3714
  const H = scenario.viewport.height;
3706
3715
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -3724,7 +3733,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
3724
3733
  const props = {
3725
3734
  accent: brand.accent,
3726
3735
  font: brand.font,
3727
- dur: scene.duration / 1e3,
3736
+ dur: durMs / 1e3,
3728
3737
  layout: scene.layout,
3729
3738
  num: scene.num ?? "",
3730
3739
  label: scene.label ?? "",
@@ -3743,13 +3752,19 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
3743
3752
  pushTo: scene.push?.to ?? 1
3744
3753
  };
3745
3754
  if (scene.code?.length) props.code = scene.code.join("||");
3755
+ if (scene.push?.origin) {
3756
+ const box = take.boxes.get(scene.push.origin);
3757
+ if (!box) throw new Error(`vignette push.origin selector "${scene.push.origin}" not found in footage "${scene.footage}"`);
3758
+ props.pushOx = Math.max(0, Math.min(100, (box.x + box.width / 2 - crop.x) / crop.w * 100)).toFixed(1);
3759
+ props.pushOy = Math.max(0, Math.min(100, (box.y + box.height / 2 - crop.y) / crop.h * 100)).toFixed(1);
3760
+ }
3746
3761
  if (brand.annotations && scene.fx.length > 0) {
3747
3762
  props.fx = scene.fx.map((fx) => {
3748
3763
  let coords = fx.coords;
3749
3764
  if (fx.selector) {
3750
3765
  const box = take.boxes.get(fx.selector);
3751
3766
  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];
3767
+ 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
3768
  }
3754
3769
  return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
3755
3770
  }).join(";");
@@ -3766,6 +3781,7 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3766
3781
  if (scene.type !== "vignette") continue;
3767
3782
  const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
3768
3783
  if (scene.crop?.selector) set.add(scene.crop.selector);
3784
+ if (scene.push?.origin) set.add(scene.push.origin);
3769
3785
  for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
3770
3786
  selectorsByFootage.set(scene.footage, set);
3771
3787
  }
@@ -3791,27 +3807,32 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3791
3807
  });
3792
3808
  await new Promise((r) => server.listen(0, r));
3793
3809
  const port = server.address().port;
3794
- const totalMs = timeline.reduce((s, sc) => s + sc.duration, 0);
3810
+ const beatMs = scenario.audio?.bpm ? 6e4 / scenario.audio.bpm : 0;
3811
+ const durations = timeline.map(
3812
+ (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
3813
+ );
3814
+ const totalMs = durations.reduce((s, d) => s + d, 0);
3795
3815
  let elapsedMs = 0;
3796
3816
  const segments = [];
3797
3817
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
3798
3818
  try {
3799
3819
  for (let i = 0; i < timeline.length; i++) {
3800
3820
  const scene = timeline[i];
3821
+ const dur = durations[i];
3801
3822
  const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
3802
3823
  onProgress?.({ scene: i + 1, total: timeline.length, label });
3803
3824
  const thread = brand.annotations ? {
3804
3825
  threadFrom: (elapsedMs / totalMs).toFixed(4),
3805
- threadTo: ((elapsedMs + scene.duration) / totalMs).toFixed(4)
3826
+ threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
3806
3827
  } : {};
3807
- elapsedMs += scene.duration;
3828
+ elapsedMs += dur;
3808
3829
  let segment;
3809
3830
  if (scene.type === "motion") {
3810
3831
  const url = resolveMotionTemplate(scene.template, scenarioDir);
3811
3832
  segment = await captureMotionSegment(
3812
3833
  url,
3813
- { accent: brand.accent, font: brand.font, dur: scene.duration / 1e3, ...scene.props, ...thread },
3814
- scene.duration,
3834
+ { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
3835
+ dur,
3815
3836
  scenario
3816
3837
  );
3817
3838
  } else {
@@ -3819,8 +3840,8 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3819
3840
  const url = resolveMotionTemplate("vignette", scenarioDir);
3820
3841
  segment = await captureMotionSegment(
3821
3842
  url,
3822
- { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
3823
- scene.duration,
3843
+ { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
3844
+ dur,
3824
3845
  scenario
3825
3846
  );
3826
3847
  }
@@ -3834,8 +3855,21 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3834
3855
  const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
3835
3856
  const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
3836
3857
  const outPath = join2(tmp, "timeline.mp4");
3858
+ let audioInput = "";
3859
+ let audioMap = "";
3860
+ if (scenario.audio) {
3861
+ const a = scenario.audio;
3862
+ const audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
3863
+ const totalSec = totalMs / 1e3;
3864
+ const af = [];
3865
+ if (a.volume !== 1) af.push(`volume=${a.volume}`);
3866
+ if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
3867
+ if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
3868
+ audioInput = `-i "${audioPath}" `;
3869
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
3870
+ }
3837
3871
  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}"`,
3872
+ `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}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
3839
3873
  { stdio: ["ignore", "ignore", "pipe"] }
3840
3874
  );
3841
3875
  const buffer = await readFile3(outPath);
@@ -4129,7 +4163,9 @@ var AudioConfigSchema = z.object({
4129
4163
  /** Fade-in duration in milliseconds. */
4130
4164
  fadeIn: z.number().min(0).default(0),
4131
4165
  /** Fade-out duration in milliseconds. */
4132
- fadeOut: z.number().min(0).default(0)
4166
+ fadeOut: z.number().min(0).default(0),
4167
+ /** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
4168
+ bpm: z.number().min(40).max(220).optional()
4133
4169
  });
4134
4170
  var AuthConfigSchema = z.object({
4135
4171
  /** Path to a Playwright storageState JSON file (cookies + localStorage). */
@@ -4160,6 +4196,8 @@ var MockRouteSchema = z.object({
4160
4196
  var PrepareConfigSchema = z.object({
4161
4197
  /** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
4162
4198
  hide: z.array(z.string().min(1)).default([]),
4199
+ /** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
4200
+ mask: z.array(z.string().min(1)).default([]),
4163
4201
  /** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
4164
4202
  freezeTime: z.string().optional(),
4165
4203
  /** Math.random을 이 시드의 결정론적 PRNG로 대체. */
@@ -4193,7 +4231,7 @@ var ScreenSceneSchema = z.object({
4193
4231
  steps: z.array(StepSchema).min(1)
4194
4232
  });
4195
4233
  var SceneFxSchema = z.object({
4196
- kind: z.enum(["circle", "arrow"]),
4234
+ kind: z.enum(["circle", "arrow", "spotlight"]),
4197
4235
  /** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
4198
4236
  selector: z.string().optional(),
4199
4237
  /** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
@@ -4223,8 +4261,14 @@ var VignetteSceneSchema = z.object({
4223
4261
  /** 크롭 높이 상한 (px, 원본 기준) — 와이드 스트립 연출용. */
4224
4262
  maxH: z.number().optional()
4225
4263
  }).optional(),
4226
- /** 푸시인 카메라 (스케일 from→to). */
4227
- push: z.object({ from: z.number().default(1), to: z.number().default(1) }).optional(),
4264
+ /** 푸시인 카메라 (스케일 from→to).
4265
+ * origin: 푸시 중심점 — 셀렉터(실측) 지정 그 요소를 향해 밀어 들어가
4266
+ * 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
4267
+ push: z.object({
4268
+ from: z.number().default(1),
4269
+ to: z.number().default(1),
4270
+ origin: z.string().optional()
4271
+ }).optional(),
4228
4272
  /** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
4229
4273
  start: z.union([z.number(), z.object({ step: z.number().int().min(0), offset: z.number().default(0) })]).default(0),
4230
4274
  /** 푸티지 재생 배속. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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,10 @@ 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)
398
404
 
399
405
  ## Critical Rules
400
406
 
@@ -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]);