clipwise 0.10.1 → 0.11.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/README.ko.md CHANGED
@@ -294,6 +294,7 @@ prepare:
294
294
  | 시드 데이터를 가진 "데모 모드" 구현 | `mock:` |
295
295
  | 일관된 데모를 위한 날짜/랜덤 스텁 | `freezeTime:` + `seedRandom:` |
296
296
  | 녹화용 온보딩 사전 완료 분기 | `storage:` |
297
+ | 민감 정보(이메일·금액) 블러 | `mask:` — 요소 단위, 스크롤 추적 |
297
298
 
298
299
  `freezeTime` + `seedRandom`을 함께 쓰면 녹화가 **결정론적**이 됩니다 —
299
300
  같은 시나리오는 몇 번을 돌려도 바이트 단위로 동일한 프레임을 만듭니다.
@@ -327,11 +328,22 @@ scenes:
327
328
  push: { from: 1.05, to: 1 }
328
329
  start: { step: 3 } # step 경계에서 인용 시작
329
330
  rate: 1.15
330
- fx: [{ kind: circle, selector: "#revenue", delay: 2500 }]
331
+ fx:
332
+ - { kind: circle, selector: "#revenue", delay: 2500 } # 손그림 동그라미
333
+ - { kind: spotlight, selector: "#revenue", delay: 2500 } # 주변 디밍
334
+ # push: { from: 1.0, to: 1.2, origin: ".panel" } # 셀렉터를 향한 매치컷
335
+
336
+ # 선택 — 무료 BGM을 URL로 (사용자 머신에서 다운로드) + 비트 싱크 컷
337
+ audio: { file: "https://assets.mixkit.co/music/132/132.mp3", bpm: 120, volume: 0.32, fadeOut: 2000 }
338
+
339
+ # 선택 — 캡션 필 (타임라인 절대 초), 모든 비율에 번인
340
+ captions:
341
+ - { text: "실제 앱을 그대로 녹화했습니다", start: 0.4, end: 2.4 }
331
342
  ```
332
343
 
333
344
  **고퀄리티 레시피** (쇼케이스 영상이 이렇게 나오는 이유):
334
345
  1. `viewport.deviceScaleFactor: 2` — 레티나 해상도 캡처 (푸티지·타이포 전부)
346
+ 0. `output.aspects: ["9:16", "1:1"]` — 같은 실행에서 릴스/피드 파일까지 (푸티지는 1회 녹화)
335
347
  2. `prepare:` — 배너 숨김, 시간 동결, 랜덤 시드, API 목킹
336
348
  3. `.clipwise/brand.yaml` — 톤 프리셋, accent, 폰트 프리셋(`editorial` = Inter + Fraunces),
337
349
  캐치프레이즈. 선 드로잉 주석 + 연결 스레드는 자동 적용
package/README.md CHANGED
@@ -296,6 +296,7 @@ prepare:
296
296
  | Build a "demo mode" with seeded data | `mock:` |
297
297
  | Stub dates and randomness for consistent demos | `freezeTime:` + `seedRandom:` |
298
298
  | Pre-complete onboarding for recordings | `storage:` |
299
+ | Blur sensitive data (emails, amounts) | `mask:` — element-level, follows scrolling |
299
300
 
300
301
  Combined with `freezeTime` + `seedRandom`, recordings become **deterministic** —
301
302
  the same scenario produces byte-identical frames run after run.
@@ -330,11 +331,22 @@ scenes:
330
331
  push: { from: 1.05, to: 1 }
331
332
  start: { step: 3 } # quote from a step boundary
332
333
  rate: 1.15
333
- fx: [{ kind: circle, selector: "#revenue", delay: 2500 }]
334
+ fx:
335
+ - { kind: circle, selector: "#revenue", delay: 2500 } # hand-drawn circle
336
+ - { kind: spotlight, selector: "#revenue", delay: 2500 } # dim everything else
337
+ # push: { from: 1.0, to: 1.2, origin: ".panel" } # match-cut toward a selector
338
+
339
+ # Optional — free BGM by URL (downloaded on your machine) + beat-synced cuts
340
+ audio: { file: "https://assets.mixkit.co/music/132/132.mp3", bpm: 120, volume: 0.32, fadeOut: 2000 }
341
+
342
+ # Optional — caption pills (timeline-absolute seconds), burned into every aspect
343
+ captions:
344
+ - { text: "Recorded from a real app", start: 0.4, end: 2.4 }
334
345
  ```
335
346
 
336
347
  **Quality recipe** (what makes the showcase videos look the way they do):
337
348
  1. `viewport.deviceScaleFactor: 2` — retina-resolution capture (footage, type, everything)
349
+ 0. `output.aspects: ["9:16", "1:1"]` — reels/feed files from the same run (footage recorded once)
338
350
  2. `prepare:` — hide banners, freeze time, seed randomness, mock APIs
339
351
  3. `.clipwise/brand.yaml` — tone preset, accent, font preset (`editorial` = Inter + Fraunces), catchphrases; line annotations + the connecting thread switch on automatically
340
352
  4. Structure: kinetic hook → hero push-in → close-up vignettes → interstitial → split (YAML × footage) → outro
package/dist/cli/index.js CHANGED
@@ -11,7 +11,7 @@ var __export = (target, all) => {
11
11
 
12
12
  // src/script/types.ts
13
13
  import { z } from "zod";
14
- var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, SmartWaitActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, SmartSpeedConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepEffectsOverrideSchema, TransitionTypeSchema, StepSchema, AudioConfigSchema, AuthConfigSchema, MockRouteSchema, PrepareConfigSchema, MotionSceneSchema, ScreenSceneSchema, SceneFxSchema, VignetteSceneSchema, SceneSchema, ScenarioSchema;
14
+ var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, SmartWaitActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, SmartSpeedConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepEffectsOverrideSchema, TransitionTypeSchema, StepSchema, AudioConfigSchema, AuthConfigSchema, MockRouteSchema, PrepareConfigSchema, MotionSceneSchema, ScreenSceneSchema, SceneFxSchema, VignetteSceneSchema, SceneSchema, CaptionSchema, ScenarioSchema;
15
15
  var init_types = __esm({
16
16
  "src/script/types.ts"() {
17
17
  "use strict";
@@ -257,7 +257,10 @@ var init_types = __esm({
257
257
  codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
258
258
  // Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
259
259
  outputDir: z.string().default(".clipwise/output"),
260
- filename: z.string().default("clipwise-recording")
260
+ filename: z.string().default("clipwise-recording"),
261
+ /** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
262
+ * 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
263
+ aspects: z.array(z.enum(["16:9", "9:16", "1:1"])).default([])
261
264
  });
262
265
  StepEffectsOverrideSchema = z.object({
263
266
  zoom: ZoomEffectSchema.partial().optional(),
@@ -410,6 +413,11 @@ var init_types = __esm({
410
413
  ScreenSceneSchema,
411
414
  VignetteSceneSchema
412
415
  ]);
416
+ CaptionSchema = z.object({
417
+ text: z.string().min(1),
418
+ start: z.number().min(0),
419
+ end: z.number().min(0)
420
+ });
413
421
  ScenarioSchema = z.object({
414
422
  name: z.string(),
415
423
  description: z.string().optional(),
@@ -430,7 +438,9 @@ var init_types = __esm({
430
438
  /** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
431
439
  steps: z.array(StepSchema).default([]),
432
440
  /** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
433
- scenes: z.array(SceneSchema).optional()
441
+ scenes: z.array(SceneSchema).optional(),
442
+ /** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
443
+ captions: z.array(CaptionSchema).default([])
434
444
  }).superRefine((s, ctx) => {
435
445
  if (s.steps.length === 0 && !s.scenes?.length) {
436
446
  ctx.addIssue({
@@ -4085,12 +4095,12 @@ async function executeStepsForProbe(page, scene) {
4085
4095
  }
4086
4096
  }
4087
4097
  }
4088
- function segmentOutput(scenario) {
4098
+ function segmentOutput(scenario, stageW, stageH) {
4089
4099
  const dpr = scenario.viewport.deviceScaleFactor ?? 1;
4090
4100
  return {
4091
4101
  ...scenario.output,
4092
- width: scenario.output.width * dpr,
4093
- height: scenario.output.height * dpr,
4102
+ width: stageW * dpr,
4103
+ height: stageH * dpr,
4094
4104
  preset: "archive"
4095
4105
  };
4096
4106
  }
@@ -4119,7 +4129,11 @@ async function recordFootageTake(scenario, scene, selectors) {
4119
4129
  };
4120
4130
  const recorder = new ClipwiseRecorder();
4121
4131
  const session = await recorder.record(takeScenario);
4122
- const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
4132
+ const renderer = new CanvasRenderer(
4133
+ takeScenario.effects,
4134
+ segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
4135
+ scene.steps
4136
+ );
4123
4137
  const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
4124
4138
  let count = 0;
4125
4139
  for await (const f of renderer.composeStream(session.frames)) {
@@ -4139,12 +4153,12 @@ async function recordFootageTake(scenario, scene, selectors) {
4139
4153
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
4140
4154
  return Math.round(Math.min(maxW, maxH * cw / ch));
4141
4155
  }
4142
- async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
4156
+ async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
4143
4157
  const fps = scenario.output.fps;
4144
4158
  const totalFrames = Math.round(durationMs / 1e3 * fps);
4145
4159
  const browser = await chromium2.launch();
4146
4160
  const page = await browser.newPage({
4147
- viewport: { width: scenario.output.width, height: scenario.output.height },
4161
+ viewport: { width: stageW, height: stageH },
4148
4162
  deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
4149
4163
  });
4150
4164
  const params = new URLSearchParams(
@@ -4162,9 +4176,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
4162
4176
  frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
4163
4177
  }
4164
4178
  await browser.close();
4165
- return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
4179
+ return {
4180
+ buffer: await encodeMp4(frames, segmentOutput(scenario, stageW, stageH)),
4181
+ seconds: totalFrames / fps
4182
+ };
4166
4183
  }
4167
- function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4184
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
4168
4185
  const W = scenario.viewport.width;
4169
4186
  const H = scenario.viewport.height;
4170
4187
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -4202,7 +4219,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4202
4219
  cropY: crop.y,
4203
4220
  cropW: crop.w,
4204
4221
  cropH: crop.h,
4205
- cardW: fitCardW(crop.w, crop.h),
4222
+ // 카드 크기 상한은 무대(stage) 크기 비례 — 어떤 출력 비율에서도 일관된 여백
4223
+ cardW: fitCardW(crop.w, crop.h, Math.round(stageW * 0.735), Math.round(stageH * 0.675)),
4206
4224
  pushFrom: scene.push?.from ?? 1,
4207
4225
  pushTo: scene.push?.to ?? 1
4208
4226
  };
@@ -4272,19 +4290,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4272
4290
  (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
4273
4291
  );
4274
4292
  const totalMs = durations.reduce((s, d) => s + d, 0);
4275
- let elapsedMs = 0;
4276
- const segments = [];
4293
+ const totalSec = totalMs / 1e3;
4294
+ let audioPath = null;
4295
+ if (scenario.audio) {
4296
+ const a = scenario.audio;
4297
+ if (/^https?:\/\//.test(a.file)) {
4298
+ const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
4299
+ audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
4300
+ if (!existsSync2(audioPath)) {
4301
+ execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
4302
+ }
4303
+ } else {
4304
+ audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
4305
+ }
4306
+ }
4277
4307
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
4278
- try {
4308
+ const renderPass = async (stageW, stageH, passLabel) => {
4309
+ let elapsedMs = 0;
4310
+ const segments = [];
4279
4311
  for (let i = 0; i < timeline.length; i++) {
4280
4312
  const scene = timeline[i];
4281
4313
  const dur = durations[i];
4282
- const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
4314
+ const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
4283
4315
  onProgress?.({ scene: i + 1, total: timeline.length, label });
4284
4316
  const thread = brand.annotations ? {
4285
4317
  threadFrom: (elapsedMs / totalMs).toFixed(4),
4286
4318
  threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
4287
4319
  } : {};
4320
+ if (scenario.captions.length > 0) {
4321
+ thread.capsJson = JSON.stringify(scenario.captions);
4322
+ thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
4323
+ }
4288
4324
  elapsedMs += dur;
4289
4325
  let segment;
4290
4326
  if (scene.type === "motion") {
@@ -4293,63 +4329,70 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4293
4329
  url,
4294
4330
  { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
4295
4331
  dur,
4296
- scenario
4332
+ scenario,
4333
+ stageW,
4334
+ stageH
4297
4335
  );
4298
4336
  } else {
4299
4337
  const take = takes.get(scene.footage);
4300
4338
  const url = resolveMotionTemplate("vignette", scenarioDir);
4301
4339
  segment = await captureMotionSegment(
4302
4340
  url,
4303
- { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
4341
+ {
4342
+ ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur, stageW, stageH),
4343
+ ...thread
4344
+ },
4304
4345
  dur,
4305
- scenario
4346
+ scenario,
4347
+ stageW,
4348
+ stageH
4306
4349
  );
4307
4350
  }
4308
- const segPath = join2(tmp, `s${i}.mp4`);
4351
+ const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
4309
4352
  await writeFile2(segPath, segment.buffer);
4310
4353
  segments.push({ path: segPath, seconds: segment.seconds });
4311
4354
  }
4355
+ const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
4356
+ const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
4357
+ const vChain = `${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]`;
4358
+ const finalLabel = "[v]";
4359
+ let audioInput = "";
4360
+ let audioMap = "";
4361
+ if (scenario.audio && audioPath) {
4362
+ const a = scenario.audio;
4363
+ const af = [];
4364
+ if (a.volume !== 1) af.push(`volume=${a.volume}`);
4365
+ if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
4366
+ if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
4367
+ audioInput = `-stream_loop -1 -i "${audioPath}" `;
4368
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
4369
+ }
4370
+ const outPath = join2(tmp, `${passLabel.replace(/\W/g, "")}timeline.mp4`);
4371
+ execSync(
4372
+ `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${vChain}" -map "${finalLabel}" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
4373
+ { stdio: ["ignore", "ignore", "pipe"] }
4374
+ );
4375
+ return readFile4(outPath);
4376
+ };
4377
+ try {
4378
+ const buffer = await renderPass(scenario.viewport.width, scenario.viewport.height, "");
4379
+ const extras = [];
4380
+ for (const aspect of scenario.output.aspects) {
4381
+ const [w, h] = ASPECT_DIMS[aspect];
4382
+ extras.push({ label: aspect.replace(":", "x"), buffer: await renderPass(w, h, `${aspect} \xB7 `) });
4383
+ }
4384
+ return { buffer, extras };
4312
4385
  } finally {
4313
4386
  server.close();
4314
- }
4315
- const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
4316
- const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
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
- }
4340
- execSync(
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}"`,
4342
- { stdio: ["ignore", "ignore", "pipe"] }
4343
- );
4344
- const buffer = await readFile4(outPath);
4345
- await rm2(tmp, { recursive: true, force: true }).catch(() => {
4346
- });
4347
- for (const take of takes.values()) {
4348
- await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
4387
+ await rm2(tmp, { recursive: true, force: true }).catch(() => {
4349
4388
  });
4389
+ for (const take of takes.values()) {
4390
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
4391
+ });
4392
+ }
4350
4393
  }
4351
- return buffer;
4352
4394
  }
4395
+ var ASPECT_DIMS;
4353
4396
  var init_runner = __esm({
4354
4397
  "src/scenes/runner.ts"() {
4355
4398
  "use strict";
@@ -4357,6 +4400,11 @@ var init_runner = __esm({
4357
4400
  init_prepare();
4358
4401
  init_canvas_renderer();
4359
4402
  init_video_encoder();
4403
+ ASPECT_DIMS = {
4404
+ "16:9": [1280, 720],
4405
+ "9:16": [720, 1280],
4406
+ "1:1": [960, 960]
4407
+ };
4360
4408
  }
4361
4409
  });
4362
4410
 
@@ -4402,6 +4450,15 @@ function validateScenario(scenario) {
4402
4450
  }
4403
4451
  }
4404
4452
  }
4453
+ for (let i = 0; i < scenario.captions.length; i++) {
4454
+ const c = scenario.captions[i];
4455
+ if (c.end <= c.start) {
4456
+ errors.push(`captions #${i + 1} ("${c.text.slice(0, 20)}"): end must be greater than start`);
4457
+ }
4458
+ }
4459
+ if (scenario.captions.length > 0 && !scenario.scenes?.length) {
4460
+ warnings.push("captions are only rendered in scenes timelines (ignored for classic steps recordings)");
4461
+ }
4405
4462
  if (scenario.steps.length > 0) {
4406
4463
  const firstStep = scenario.steps[0];
4407
4464
  const hasNavigate = firstStep.actions.some(
@@ -4585,7 +4642,7 @@ import { homedir } from "os";
4585
4642
  var program = new Command();
4586
4643
  program.name("clipwise").description(
4587
4644
  "Playwright-based cinematic screen recorder for product demos"
4588
- ).version("0.10.1");
4645
+ ).version("0.11.1");
4589
4646
  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(
4590
4647
  "-f, --format <format>",
4591
4648
  "Output format (gif|mp4|png-sequence)"
@@ -4649,12 +4706,17 @@ program.command("record").description("Record a demo from a YAML scenario file")
4649
4706
  const outDir2 = scenario.output.outputDir;
4650
4707
  await mkdir2(outDir2, { recursive: true });
4651
4708
  spinner.start(`Rendering ${scenario.scenes.length}-scene timeline...`);
4652
- const buf = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
4709
+ const { buffer: buf, extras } = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
4653
4710
  spinner.text = scene === 0 ? `Recording footage \u2014 ${label}...` : `Rendering scene ${scene}/${total} \u2014 ${label}...`;
4654
4711
  });
4655
4712
  const outputPath = join3(outDir2, `${scenario.output.filename}.mp4`);
4656
4713
  await writeFile3(outputPath, buf);
4657
4714
  spinner.succeed(`Timeline saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
4715
+ for (const extra of extras) {
4716
+ const extraPath = join3(outDir2, `${scenario.output.filename}-${extra.label}.mp4`);
4717
+ await writeFile3(extraPath, extra.buffer);
4718
+ spinner.succeed(` + ${chalk.bold(extraPath)} (${(extra.buffer.length / 1048576).toFixed(2)} MB)`);
4719
+ }
4658
4720
  console.log(chalk.green("\nDone! \u{1F3AC}"));
4659
4721
  return;
4660
4722
  }
@@ -4864,6 +4926,11 @@ steps:
4864
4926
  name: "My Launch Video"
4865
4927
  viewport: { width: 1280, height: 800, deviceScaleFactor: 2 } # 2 = retina quality
4866
4928
 
4929
+ # Optional extras (uncomment to use):
4930
+ # audio: { file: "https://assets.mixkit.co/music/132/132.mp3", bpm: 120, volume: 0.32, fadeOut: 2000 }
4931
+ # captions:
4932
+ # - { text: "Recorded from a real app", start: 0.4, end: 2.4 }
4933
+
4867
4934
  effects:
4868
4935
  cursor: { enabled: true, clickEffect: true, highlight: false, trail: false }
4869
4936
 
@@ -4872,6 +4939,7 @@ output:
4872
4939
  fps: 30
4873
4940
  preset: balanced
4874
4941
  filename: keynote
4942
+ # aspects: ["9:16"] # also render a reels-format file in the same run
4875
4943
 
4876
4944
  scenes:
4877
4945
  # footage take \u2014 recorded once; vignettes below quote it by step
package/dist/index.d.ts CHANGED
@@ -627,6 +627,9 @@ declare const OutputConfigSchema: z.ZodObject<{
627
627
  codec: z.ZodDefault<z.ZodEnum<["auto", "h264", "hevc", "av1"]>>;
628
628
  outputDir: z.ZodDefault<z.ZodString>;
629
629
  filename: z.ZodDefault<z.ZodString>;
630
+ /** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
631
+ * 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
632
+ aspects: z.ZodDefault<z.ZodArray<z.ZodEnum<["16:9", "9:16", "1:1"]>, "many">>;
630
633
  }, "strip", z.ZodTypeAny, {
631
634
  format: "gif" | "mp4" | "webm" | "png-sequence";
632
635
  width: number;
@@ -636,6 +639,7 @@ declare const OutputConfigSchema: z.ZodObject<{
636
639
  codec: "auto" | "h264" | "hevc" | "av1";
637
640
  outputDir: string;
638
641
  filename: string;
642
+ aspects: ("16:9" | "9:16" | "1:1")[];
639
643
  preset?: "social" | "balanced" | "archive" | undefined;
640
644
  }, {
641
645
  format?: "gif" | "mp4" | "webm" | "png-sequence" | undefined;
@@ -647,6 +651,7 @@ declare const OutputConfigSchema: z.ZodObject<{
647
651
  codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
648
652
  outputDir?: string | undefined;
649
653
  filename?: string | undefined;
654
+ aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
650
655
  }>;
651
656
  type OutputConfig = z.infer<typeof OutputConfigSchema>;
652
657
  /**
@@ -5521,6 +5526,9 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
5521
5526
  codec: z.ZodDefault<z.ZodEnum<["auto", "h264", "hevc", "av1"]>>;
5522
5527
  outputDir: z.ZodDefault<z.ZodString>;
5523
5528
  filename: z.ZodDefault<z.ZodString>;
5529
+ /** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
5530
+ * 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
5531
+ aspects: z.ZodDefault<z.ZodArray<z.ZodEnum<["16:9", "9:16", "1:1"]>, "many">>;
5524
5532
  }, "strip", z.ZodTypeAny, {
5525
5533
  format: "gif" | "mp4" | "webm" | "png-sequence";
5526
5534
  width: number;
@@ -5530,6 +5538,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
5530
5538
  codec: "auto" | "h264" | "hevc" | "av1";
5531
5539
  outputDir: string;
5532
5540
  filename: string;
5541
+ aspects: ("16:9" | "9:16" | "1:1")[];
5533
5542
  preset?: "social" | "balanced" | "archive" | undefined;
5534
5543
  }, {
5535
5544
  format?: "gif" | "mp4" | "webm" | "png-sequence" | undefined;
@@ -5541,6 +5550,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
5541
5550
  codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
5542
5551
  outputDir?: string | undefined;
5543
5552
  filename?: string | undefined;
5553
+ aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
5544
5554
  }>>;
5545
5555
  /** Optional audio narration — muxed into MP4 output. */
5546
5556
  audio: z.ZodOptional<z.ZodObject<{
@@ -7860,6 +7870,20 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
7860
7870
  coords?: number[] | undefined;
7861
7871
  }[] | undefined;
7862
7872
  }>]>, "many">>;
7873
+ /** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
7874
+ captions: z.ZodDefault<z.ZodArray<z.ZodObject<{
7875
+ text: z.ZodString;
7876
+ start: z.ZodNumber;
7877
+ end: z.ZodNumber;
7878
+ }, "strip", z.ZodTypeAny, {
7879
+ text: string;
7880
+ start: number;
7881
+ end: number;
7882
+ }, {
7883
+ text: string;
7884
+ start: number;
7885
+ end: number;
7886
+ }>, "many">>;
7863
7887
  }, "strip", z.ZodTypeAny, {
7864
7888
  name: string;
7865
7889
  effects: {
@@ -8105,8 +8129,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8105
8129
  codec: "auto" | "h264" | "hevc" | "av1";
8106
8130
  outputDir: string;
8107
8131
  filename: string;
8132
+ aspects: ("16:9" | "9:16" | "1:1")[];
8108
8133
  preset?: "social" | "balanced" | "archive" | undefined;
8109
8134
  };
8135
+ captions: {
8136
+ text: string;
8137
+ start: number;
8138
+ end: number;
8139
+ }[];
8110
8140
  description?: string | undefined;
8111
8141
  auth?: {
8112
8142
  storageState?: string | undefined;
@@ -8625,6 +8655,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8625
8655
  codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
8626
8656
  outputDir?: string | undefined;
8627
8657
  filename?: string | undefined;
8658
+ aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
8628
8659
  } | undefined;
8629
8660
  audio?: {
8630
8661
  file: string;
@@ -8830,6 +8861,11 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
8830
8861
  coords?: number[] | undefined;
8831
8862
  }[] | undefined;
8832
8863
  })[] | undefined;
8864
+ captions?: {
8865
+ text: string;
8866
+ start: number;
8867
+ end: number;
8868
+ }[] | undefined;
8833
8869
  }>, {
8834
8870
  name: string;
8835
8871
  effects: {
@@ -9075,8 +9111,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9075
9111
  codec: "auto" | "h264" | "hevc" | "av1";
9076
9112
  outputDir: string;
9077
9113
  filename: string;
9114
+ aspects: ("16:9" | "9:16" | "1:1")[];
9078
9115
  preset?: "social" | "balanced" | "archive" | undefined;
9079
9116
  };
9117
+ captions: {
9118
+ text: string;
9119
+ start: number;
9120
+ end: number;
9121
+ }[];
9080
9122
  description?: string | undefined;
9081
9123
  auth?: {
9082
9124
  storageState?: string | undefined;
@@ -9595,6 +9637,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9595
9637
  codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
9596
9638
  outputDir?: string | undefined;
9597
9639
  filename?: string | undefined;
9640
+ aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
9598
9641
  } | undefined;
9599
9642
  audio?: {
9600
9643
  file: string;
@@ -9800,6 +9843,11 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
9800
9843
  coords?: number[] | undefined;
9801
9844
  }[] | undefined;
9802
9845
  })[] | undefined;
9846
+ captions?: {
9847
+ text: string;
9848
+ start: number;
9849
+ end: number;
9850
+ }[] | undefined;
9803
9851
  }>;
9804
9852
  type Scenario = z.infer<typeof ScenarioSchema>;
9805
9853
  interface KeystrokeEvent {
@@ -10628,10 +10676,19 @@ interface SceneProgress {
10628
10676
  total: number;
10629
10677
  label: string;
10630
10678
  }
10679
+ interface ScenesRenderResult {
10680
+ /** 기본 비율(viewport 크기) 렌더. */
10681
+ buffer: Buffer;
10682
+ /** output.aspects로 요청된 추가 비율 렌더 (label 예: "9x16"). */
10683
+ extras: {
10684
+ label: string;
10685
+ buffer: Buffer;
10686
+ }[];
10687
+ }
10631
10688
  /**
10632
- * scenes 타임라인을 렌더해 MP4 버퍼를 반환한다.
10689
+ * scenes 타임라인을 렌더한다 기본 비율 + output.aspects 추가 비율.
10633
10690
  */
10634
- declare function renderScenesTimeline(scenario: Scenario, scenarioDir: string, onProgress?: (p: SceneProgress) => void): Promise<Buffer>;
10691
+ declare function renderScenesTimeline(scenario: Scenario, scenarioDir: string, onProgress?: (p: SceneProgress) => void): Promise<ScenesRenderResult>;
10635
10692
 
10636
10693
  /**
10637
10694
  * Parse a YAML string and return a validated Scenario object.
package/dist/index.js CHANGED
@@ -3631,15 +3631,20 @@ async function executeStepsForProbe(page, scene) {
3631
3631
  }
3632
3632
  }
3633
3633
  }
3634
- function segmentOutput(scenario) {
3634
+ function segmentOutput(scenario, stageW, stageH) {
3635
3635
  const dpr = scenario.viewport.deviceScaleFactor ?? 1;
3636
3636
  return {
3637
3637
  ...scenario.output,
3638
- width: scenario.output.width * dpr,
3639
- height: scenario.output.height * dpr,
3638
+ width: stageW * dpr,
3639
+ height: stageH * dpr,
3640
3640
  preset: "archive"
3641
3641
  };
3642
3642
  }
3643
+ var ASPECT_DIMS = {
3644
+ "16:9": [1280, 720],
3645
+ "9:16": [720, 1280],
3646
+ "1:1": [960, 960]
3647
+ };
3643
3648
  async function recordFootageTake(scenario, scene, selectors) {
3644
3649
  const boxes = /* @__PURE__ */ new Map();
3645
3650
  if (selectors.length > 0) {
@@ -3665,7 +3670,11 @@ async function recordFootageTake(scenario, scene, selectors) {
3665
3670
  };
3666
3671
  const recorder = new ClipwiseRecorder();
3667
3672
  const session = await recorder.record(takeScenario);
3668
- const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
3673
+ const renderer = new CanvasRenderer(
3674
+ takeScenario.effects,
3675
+ segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
3676
+ scene.steps
3677
+ );
3669
3678
  const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
3670
3679
  let count = 0;
3671
3680
  for await (const f of renderer.composeStream(session.frames)) {
@@ -3685,12 +3694,12 @@ async function recordFootageTake(scenario, scene, selectors) {
3685
3694
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
3686
3695
  return Math.round(Math.min(maxW, maxH * cw / ch));
3687
3696
  }
3688
- async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
3697
+ async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
3689
3698
  const fps = scenario.output.fps;
3690
3699
  const totalFrames = Math.round(durationMs / 1e3 * fps);
3691
3700
  const browser = await chromium2.launch();
3692
3701
  const page = await browser.newPage({
3693
- viewport: { width: scenario.output.width, height: scenario.output.height },
3702
+ viewport: { width: stageW, height: stageH },
3694
3703
  deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
3695
3704
  });
3696
3705
  const params = new URLSearchParams(
@@ -3708,9 +3717,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
3708
3717
  frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
3709
3718
  }
3710
3719
  await browser.close();
3711
- return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
3720
+ return {
3721
+ buffer: await encodeMp4(frames, segmentOutput(scenario, stageW, stageH)),
3722
+ seconds: totalFrames / fps
3723
+ };
3712
3724
  }
3713
- function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3725
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
3714
3726
  const W = scenario.viewport.width;
3715
3727
  const H = scenario.viewport.height;
3716
3728
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -3748,7 +3760,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3748
3760
  cropY: crop.y,
3749
3761
  cropW: crop.w,
3750
3762
  cropH: crop.h,
3751
- cardW: fitCardW(crop.w, crop.h),
3763
+ // 카드 크기 상한은 무대(stage) 크기 비례 — 어떤 출력 비율에서도 일관된 여백
3764
+ cardW: fitCardW(crop.w, crop.h, Math.round(stageW * 0.735), Math.round(stageH * 0.675)),
3752
3765
  pushFrom: scene.push?.from ?? 1,
3753
3766
  pushTo: scene.push?.to ?? 1
3754
3767
  };
@@ -3818,19 +3831,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3818
3831
  (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
3819
3832
  );
3820
3833
  const totalMs = durations.reduce((s, d) => s + d, 0);
3821
- let elapsedMs = 0;
3822
- const segments = [];
3834
+ const totalSec = totalMs / 1e3;
3835
+ let audioPath = null;
3836
+ if (scenario.audio) {
3837
+ const a = scenario.audio;
3838
+ if (/^https?:\/\//.test(a.file)) {
3839
+ const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
3840
+ audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
3841
+ if (!existsSync2(audioPath)) {
3842
+ execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
3843
+ }
3844
+ } else {
3845
+ audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
3846
+ }
3847
+ }
3823
3848
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
3824
- try {
3849
+ const renderPass = async (stageW, stageH, passLabel) => {
3850
+ let elapsedMs = 0;
3851
+ const segments = [];
3825
3852
  for (let i = 0; i < timeline.length; i++) {
3826
3853
  const scene = timeline[i];
3827
3854
  const dur = durations[i];
3828
- const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
3855
+ const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
3829
3856
  onProgress?.({ scene: i + 1, total: timeline.length, label });
3830
3857
  const thread = brand.annotations ? {
3831
3858
  threadFrom: (elapsedMs / totalMs).toFixed(4),
3832
3859
  threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
3833
3860
  } : {};
3861
+ if (scenario.captions.length > 0) {
3862
+ thread.capsJson = JSON.stringify(scenario.captions);
3863
+ thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
3864
+ }
3834
3865
  elapsedMs += dur;
3835
3866
  let segment;
3836
3867
  if (scene.type === "motion") {
@@ -3839,62 +3870,68 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3839
3870
  url,
3840
3871
  { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
3841
3872
  dur,
3842
- scenario
3873
+ scenario,
3874
+ stageW,
3875
+ stageH
3843
3876
  );
3844
3877
  } else {
3845
3878
  const take = takes.get(scene.footage);
3846
3879
  const url = resolveMotionTemplate("vignette", scenarioDir);
3847
3880
  segment = await captureMotionSegment(
3848
3881
  url,
3849
- { ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
3882
+ {
3883
+ ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur, stageW, stageH),
3884
+ ...thread
3885
+ },
3850
3886
  dur,
3851
- scenario
3887
+ scenario,
3888
+ stageW,
3889
+ stageH
3852
3890
  );
3853
3891
  }
3854
- const segPath = join2(tmp, `s${i}.mp4`);
3892
+ const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
3855
3893
  await writeFile2(segPath, segment.buffer);
3856
3894
  segments.push({ path: segPath, seconds: segment.seconds });
3857
3895
  }
3896
+ const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
3897
+ const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
3898
+ const vChain = `${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]`;
3899
+ const finalLabel = "[v]";
3900
+ let audioInput = "";
3901
+ let audioMap = "";
3902
+ if (scenario.audio && audioPath) {
3903
+ const a = scenario.audio;
3904
+ const af = [];
3905
+ if (a.volume !== 1) af.push(`volume=${a.volume}`);
3906
+ if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
3907
+ if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
3908
+ audioInput = `-stream_loop -1 -i "${audioPath}" `;
3909
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
3910
+ }
3911
+ const outPath = join2(tmp, `${passLabel.replace(/\W/g, "")}timeline.mp4`);
3912
+ execSync(
3913
+ `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${vChain}" -map "${finalLabel}" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
3914
+ { stdio: ["ignore", "ignore", "pipe"] }
3915
+ );
3916
+ return readFile3(outPath);
3917
+ };
3918
+ try {
3919
+ const buffer = await renderPass(scenario.viewport.width, scenario.viewport.height, "");
3920
+ const extras = [];
3921
+ for (const aspect of scenario.output.aspects) {
3922
+ const [w, h] = ASPECT_DIMS[aspect];
3923
+ extras.push({ label: aspect.replace(":", "x"), buffer: await renderPass(w, h, `${aspect} \xB7 `) });
3924
+ }
3925
+ return { buffer, extras };
3858
3926
  } finally {
3859
3927
  server.close();
3860
- }
3861
- const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
3862
- const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
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
- }
3886
- execSync(
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}"`,
3888
- { stdio: ["ignore", "ignore", "pipe"] }
3889
- );
3890
- const buffer = await readFile3(outPath);
3891
- await rm2(tmp, { recursive: true, force: true }).catch(() => {
3892
- });
3893
- for (const take of takes.values()) {
3894
- await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
3928
+ await rm2(tmp, { recursive: true, force: true }).catch(() => {
3895
3929
  });
3930
+ for (const take of takes.values()) {
3931
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
3932
+ });
3933
+ }
3896
3934
  }
3897
- return buffer;
3898
3935
  }
3899
3936
 
3900
3937
  // src/script/parser.ts
@@ -4146,7 +4183,10 @@ var OutputConfigSchema = z.object({
4146
4183
  codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
4147
4184
  // Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
4148
4185
  outputDir: z.string().default(".clipwise/output"),
4149
- filename: z.string().default("clipwise-recording")
4186
+ filename: z.string().default("clipwise-recording"),
4187
+ /** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
4188
+ * 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
4189
+ aspects: z.array(z.enum(["16:9", "9:16", "1:1"])).default([])
4150
4190
  });
4151
4191
  var StepEffectsOverrideSchema = z.object({
4152
4192
  zoom: ZoomEffectSchema.partial().optional(),
@@ -4299,6 +4339,11 @@ var SceneSchema = z.discriminatedUnion("type", [
4299
4339
  ScreenSceneSchema,
4300
4340
  VignetteSceneSchema
4301
4341
  ]);
4342
+ var CaptionSchema = z.object({
4343
+ text: z.string().min(1),
4344
+ start: z.number().min(0),
4345
+ end: z.number().min(0)
4346
+ });
4302
4347
  var ScenarioSchema = z.object({
4303
4348
  name: z.string(),
4304
4349
  description: z.string().optional(),
@@ -4319,7 +4364,9 @@ var ScenarioSchema = z.object({
4319
4364
  /** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
4320
4365
  steps: z.array(StepSchema).default([]),
4321
4366
  /** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
4322
- scenes: z.array(SceneSchema).optional()
4367
+ scenes: z.array(SceneSchema).optional(),
4368
+ /** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
4369
+ captions: z.array(CaptionSchema).default([])
4323
4370
  }).superRefine((s, ctx) => {
4324
4371
  if (s.steps.length === 0 && !s.scenes?.length) {
4325
4372
  ctx.addIssue({
@@ -4417,6 +4464,15 @@ function validateScenario(scenario) {
4417
4464
  }
4418
4465
  }
4419
4466
  }
4467
+ for (let i = 0; i < scenario.captions.length; i++) {
4468
+ const c = scenario.captions[i];
4469
+ if (c.end <= c.start) {
4470
+ errors.push(`captions #${i + 1} ("${c.text.slice(0, 20)}"): end must be greater than start`);
4471
+ }
4472
+ }
4473
+ if (scenario.captions.length > 0 && !scenario.scenes?.length) {
4474
+ warnings.push("captions are only rendered in scenes timelines (ignored for classic steps recordings)");
4475
+ }
4420
4476
  if (scenario.steps.length > 0) {
4421
4477
  const firstStep = scenario.steps[0];
4422
4478
  const hasNavigate = firstStep.actions.some(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.10.1",
3
+ "version": "0.11.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",
@@ -399,6 +399,10 @@ scenes:
399
399
  6. Keep one screen take (~12-15s) and let vignettes quote segments via `start: { step: N }`
400
400
  7. **Sensitive data**: `prepare.mask: [".email", ".amount"]` blurs elements at record time
401
401
  (follows scrolling — never ask the user to fake their data)
402
+ 9. **Social formats**: `output.aspects: ["9:16", "1:1"]` renders extra reels/feed files in the
403
+ same run — footage is recorded once, only the stage re-composes. Files: `name-9x16.mp4` etc.
404
+ 10. **Captions**: top-level `captions: [{ text, start, end }]` (timeline-absolute seconds)
405
+ burns styled caption pills into every aspect — great for sound-off social playback
402
406
  8. **Music**: `audio: { file: bgm.mp3, bpm: 122, fadeOut: 1500 }` muxes BGM into the final
403
407
  video AND snaps every scene cut onto the beat grid (beat-synced cuts).
404
408
  `file:` also accepts a URL (downloaded+cached on the user's machine — use license-free
@@ -91,6 +91,24 @@
91
91
  /* ── 스레드 — 영상 전체를 관통하는 한 줄의 잉크 선.
92
92
  threadFrom→threadTo 구간을 신 길이 동안 선형으로 전진시키면
93
93
  하드컷을 넘어도 같은 경로 위에서 끊김 없이 이어진다. ── */
94
+
95
+ /* ── 전역 캡션 트랙 — 타임라인 절대시각 기준, 신 경계를 넘어 이어진다.
96
+ ffmpeg 자막 필터(libass) 의존 없이 템플릿 레이어로 렌더 — 어떤 ffmpeg
97
+ 빌드에서도 동작하고 폰트·브랜드 토큰을 그대로 쓴다. ── */
98
+ .gcap { position: absolute; left: 50%; transform: translateX(-50%);
99
+ bottom: 2.4%; z-index: 8; max-width: 86%;
100
+ font-family: var(--sans); font-weight: 600; color: var(--ink);
101
+ background: rgba(255, 255, 255, 0.82); border: 1px solid rgba(20, 20, 19, 0.08);
102
+ border-radius: 12px; padding: 0.5em 1.1em; text-align: center;
103
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
104
+ opacity: 0; animation: gcap-show linear both; }
105
+ @keyframes gcap-show {
106
+ 0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
107
+ 7% { opacity: 1; transform: translateX(-50%) translateY(0); }
108
+ 93% { opacity: 1; transform: translateX(-50%) translateY(0); }
109
+ 100% { opacity: 0; transform: translateX(-50%) translateY(0); }
110
+ }
111
+
94
112
  .thread { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
95
113
  .thread path { fill: none; stroke: var(--accent); stroke-width: 2.5; opacity: 0.55;
96
114
  stroke-linecap: round;
@@ -114,7 +132,8 @@
114
132
  <script>
115
133
  const P = new URLSearchParams(location.search);
116
134
  document.documentElement.style.setProperty("--accent", P.get("accent") || "#6366f1");
117
- if (P.get("size")) document.documentElement.style.setProperty("--size", P.get("size") + "px");
135
+ const stageScale = Math.max(0.55, Math.min(1, innerWidth / 1280));
136
+ if (P.get("size")) document.documentElement.style.setProperty("--size", (parseFloat(P.get("size")) * stageScale).toFixed(1) + "px");
118
137
  document.documentElement.dataset.font = P.get("font") || "editorial";
119
138
  const FX = P.get("fx") || "underline"; // underline | marker | off
120
139
 
@@ -164,13 +183,17 @@
164
183
  }
165
184
 
166
185
  // ── 스레드: 모든 신이 공유하는 동일 경로 — 구간만 다르게 전진 ──
167
- const THREAD_PATH = "M -6 716 C 150 704, 320 730, 480 714 S 770 724, 940 706 S 1180 728, 1286 712";
186
+ // 스레드 경로 스테이지 크기에서 동적 생성. 템플릿이 동일 공식을 쓰므로
187
+ // 어떤 비율(16:9/9:16/1:1)에서도 신 간 연속성이 유지된다.
188
+ const _W = innerWidth, _H = innerHeight, _ty = _H * 0.895, _a = _H * 0.016;
189
+ const _f = (n) => n.toFixed(1);
190
+ const THREAD_PATH = `M -6 ${_f(_ty - _a * 0.3)} C ${_f(_W * 0.12)} ${_f(_ty - _a)}, ${_f(_W * 0.25)} ${_f(_ty + _a)}, ${_f(_W * 0.375)} ${_f(_ty - _a * 0.2)} S ${_f(_W * 0.6)} ${_f(_ty + _a * 0.6)}, ${_f(_W * 0.734)} ${_f(_ty - _a * 1.1)} S ${_f(_W * 0.92)} ${_f(_ty + _a)}, ${_f(_W + 6)} ${_f(_ty - _a * 0.4)}`;
168
191
  const tf = P.get("threadFrom"), tt = P.get("threadTo");
169
192
  if (tf !== null && tt !== null) {
170
193
  const durS = parseFloat(P.get("dur") || "3");
171
194
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
172
195
  svg.setAttribute("class", "thread");
173
- svg.setAttribute("viewBox", "0 0 1280 800");
196
+ svg.setAttribute("viewBox", `0 0 ${_W} ${_H}`);
174
197
  svg.innerHTML = `<path d="${THREAD_PATH}" pathLength="1"
175
198
  style="--tf:${tf};--tt:${tt};animation-duration:${durS}s"/>`;
176
199
  stage.appendChild(svg);
@@ -196,6 +219,25 @@
196
219
  }
197
220
  });
198
221
 
222
+
223
+ // 전역 캡션 — capsJson(절대 start/end 초) + capsOffset(이 신의 절대 시작 초)
224
+ if (P.get("capsJson")) {
225
+ const offset = parseFloat(P.get("capsOffset") || "0");
226
+ const sceneDur = parseFloat(P.get("dur") || "4");
227
+ const capFs = Math.max(15, innerHeight * 0.026);
228
+ for (const c of JSON.parse(P.get("capsJson"))) {
229
+ const s = c.start - offset, e = c.end - offset;
230
+ if (e <= 0 || s >= sceneDur) continue; // 이 신과 무관
231
+ const el = document.createElement("div");
232
+ el.className = "gcap";
233
+ el.textContent = c.text;
234
+ el.style.fontSize = capFs.toFixed(1) + "px";
235
+ el.style.animationDelay = Math.max(0, s).toFixed(3) + "s";
236
+ el.style.animationDuration = (Math.min(e, sceneDur) - Math.max(0, s)).toFixed(3) + "s";
237
+ document.querySelector(".stage").appendChild(el);
238
+ }
239
+ }
240
+
199
241
  window.__clipwiseSeek = async (t) => {
200
242
  await ready;
201
243
  for (const a of document.getAnimations()) { a.pause(); a.currentTime = t; }
@@ -64,6 +64,24 @@
64
64
  .stage { width: 100%; height: 100%; display: flex; flex-direction: column;
65
65
  align-items: center; justify-content: center; gap: 22px; position: relative; }
66
66
 
67
+
68
+ /* ── 전역 캡션 트랙 — 타임라인 절대시각 기준, 신 경계를 넘어 이어진다.
69
+ ffmpeg 자막 필터(libass) 의존 없이 템플릿 레이어로 렌더 — 어떤 ffmpeg
70
+ 빌드에서도 동작하고 폰트·브랜드 토큰을 그대로 쓴다. ── */
71
+ .gcap { position: absolute; left: 50%; transform: translateX(-50%);
72
+ bottom: 2.4%; z-index: 8; max-width: 86%;
73
+ font-family: var(--sans); font-weight: 600; color: var(--ink);
74
+ background: rgba(255, 255, 255, 0.82); border: 1px solid rgba(20, 20, 19, 0.08);
75
+ border-radius: 12px; padding: 0.5em 1.1em; text-align: center;
76
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
77
+ opacity: 0; animation: gcap-show linear both; }
78
+ @keyframes gcap-show {
79
+ 0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
80
+ 7% { opacity: 1; transform: translateX(-50%) translateY(0); }
81
+ 93% { opacity: 1; transform: translateX(-50%) translateY(0); }
82
+ 100% { opacity: 0; transform: translateX(-50%) translateY(0); }
83
+ }
84
+
67
85
  /* 스레드 — 카드 뒤를 지나는 연결 선 (kinetic-type.html과 동일 경로/문법) */
68
86
  .thread { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
69
87
  .thread path { fill: none; stroke: var(--accent); stroke-width: 2.5; opacity: 0.55;
@@ -279,14 +297,18 @@
279
297
  const img = document.getElementById("footage");
280
298
 
281
299
  // ── 스레드 — 모든 신이 공유하는 동일 경로, 구간(threadFrom→threadTo)만 전진 ──
282
- const THREAD_PATH = "M -6 716 C 150 704, 320 730, 480 714 S 770 724, 940 706 S 1180 728, 1286 712";
300
+ // 스레드 경로 스테이지 크기에서 동적 생성. 템플릿이 동일 공식을 쓰므로
301
+ // 어떤 비율(16:9/9:16/1:1)에서도 신 간 연속성이 유지된다.
302
+ const _W = innerWidth, _H = innerHeight, _ty = _H * 0.895, _a = _H * 0.016;
303
+ const _f = (n) => n.toFixed(1);
304
+ const THREAD_PATH = `M -6 ${_f(_ty - _a * 0.3)} C ${_f(_W * 0.12)} ${_f(_ty - _a)}, ${_f(_W * 0.25)} ${_f(_ty + _a)}, ${_f(_W * 0.375)} ${_f(_ty - _a * 0.2)} S ${_f(_W * 0.6)} ${_f(_ty + _a * 0.6)}, ${_f(_W * 0.734)} ${_f(_ty - _a * 1.1)} S ${_f(_W * 0.92)} ${_f(_ty + _a)}, ${_f(_W + 6)} ${_f(_ty - _a * 0.4)}`;
283
305
  const tf = P.get("threadFrom"), tt = P.get("threadTo");
284
306
  if (tf !== null && tt !== null) {
285
307
  const durS = parseFloat(P.get("dur") || "4");
286
308
  const stage = document.querySelector(".stage");
287
309
  const tsvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
288
310
  tsvg.setAttribute("class", "thread");
289
- tsvg.setAttribute("viewBox", "0 0 1280 800");
311
+ tsvg.setAttribute("viewBox", `0 0 ${_W} ${_H}`);
290
312
  tsvg.innerHTML = `<path d="${THREAD_PATH}" pathLength="1"
291
313
  style="--tf:${tf};--tt:${tt};animation-duration:${durS}s"/>`;
292
314
  stage.appendChild(tsvg);
@@ -296,6 +318,25 @@
296
318
  stage.appendChild(dot);
297
319
  }
298
320
 
321
+
322
+ // 전역 캡션 — capsJson(절대 start/end 초) + capsOffset(이 신의 절대 시작 초)
323
+ if (P.get("capsJson")) {
324
+ const offset = parseFloat(P.get("capsOffset") || "0");
325
+ const sceneDur = parseFloat(P.get("dur") || "4");
326
+ const capFs = Math.max(15, innerHeight * 0.026);
327
+ for (const c of JSON.parse(P.get("capsJson"))) {
328
+ const s = c.start - offset, e = c.end - offset;
329
+ if (e <= 0 || s >= sceneDur) continue; // 이 신과 무관
330
+ const el = document.createElement("div");
331
+ el.className = "gcap";
332
+ el.textContent = c.text;
333
+ el.style.fontSize = capFs.toFixed(1) + "px";
334
+ el.style.animationDelay = Math.max(0, s).toFixed(3) + "s";
335
+ el.style.animationDuration = (Math.min(e, sceneDur) - Math.max(0, s)).toFixed(3) + "s";
336
+ document.querySelector(".stage").appendChild(el);
337
+ }
338
+ }
339
+
299
340
  window.__clipwiseSeek = async (t) => {
300
341
  for (const a of document.getAnimations()) { a.pause(); a.currentTime = t; }
301
342
  if (BASE) {