clipwise 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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({
@@ -3978,6 +3988,7 @@ __export(runner_exports, {
3978
3988
  });
3979
3989
  import { chromium as chromium2 } from "playwright";
3980
3990
  import { createServer } from "http";
3991
+ import { createHash } from "crypto";
3981
3992
  import { execSync } from "child_process";
3982
3993
  import { readFile as readFile4, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
3983
3994
  import { existsSync as existsSync2 } from "fs";
@@ -4084,12 +4095,12 @@ async function executeStepsForProbe(page, scene) {
4084
4095
  }
4085
4096
  }
4086
4097
  }
4087
- function segmentOutput(scenario) {
4098
+ function segmentOutput(scenario, stageW, stageH) {
4088
4099
  const dpr = scenario.viewport.deviceScaleFactor ?? 1;
4089
4100
  return {
4090
4101
  ...scenario.output,
4091
- width: scenario.output.width * dpr,
4092
- height: scenario.output.height * dpr,
4102
+ width: stageW * dpr,
4103
+ height: stageH * dpr,
4093
4104
  preset: "archive"
4094
4105
  };
4095
4106
  }
@@ -4118,32 +4129,36 @@ async function recordFootageTake(scenario, scene, selectors) {
4118
4129
  };
4119
4130
  const recorder = new ClipwiseRecorder();
4120
4131
  const session = await recorder.record(takeScenario);
4121
- const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
4122
- const composed = [];
4123
- for await (const f of renderer.composeStream(session.frames)) composed.push(f);
4124
- const frames = await Promise.all(
4125
- composed.map(
4126
- (f) => f.rawInfo ? sharp10(f.buffer, {
4127
- raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
4128
- }).png().toBuffer() : Promise.resolve(f.buffer)
4129
- )
4132
+ const renderer = new CanvasRenderer(
4133
+ takeScenario.effects,
4134
+ segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
4135
+ scene.steps
4130
4136
  );
4137
+ const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
4138
+ let count = 0;
4139
+ for await (const f of renderer.composeStream(session.frames)) {
4140
+ const png = f.rawInfo ? await sharp10(f.buffer, {
4141
+ raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
4142
+ }).png().toBuffer() : f.buffer;
4143
+ await writeFile2(join2(framesDir, `${count}.png`), png);
4144
+ count++;
4145
+ }
4131
4146
  const anchors = [];
4132
4147
  for (let k = 0; k < scene.steps.length; k++) {
4133
4148
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
4134
4149
  anchors.push(Math.max(0, idx) / scenario.output.fps);
4135
4150
  }
4136
- return { frames, anchors, boxes };
4151
+ return { framesDir, count, anchors, boxes };
4137
4152
  }
4138
4153
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
4139
4154
  return Math.round(Math.min(maxW, maxH * cw / ch));
4140
4155
  }
4141
- async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
4156
+ async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
4142
4157
  const fps = scenario.output.fps;
4143
4158
  const totalFrames = Math.round(durationMs / 1e3 * fps);
4144
4159
  const browser = await chromium2.launch();
4145
4160
  const page = await browser.newPage({
4146
- viewport: { width: scenario.output.width, height: scenario.output.height },
4161
+ viewport: { width: stageW, height: stageH },
4147
4162
  deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
4148
4163
  });
4149
4164
  const params = new URLSearchParams(
@@ -4161,9 +4176,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
4161
4176
  frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
4162
4177
  }
4163
4178
  await browser.close();
4164
- 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
+ };
4165
4183
  }
4166
- function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4184
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
4167
4185
  const W = scenario.viewport.width;
4168
4186
  const H = scenario.viewport.height;
4169
4187
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -4193,7 +4211,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4193
4211
  label: scene.label ?? "",
4194
4212
  caption: scene.caption ?? "",
4195
4213
  base: serverBase,
4196
- count: take.frames.length,
4214
+ count: take.count,
4197
4215
  fps: scenario.output.fps,
4198
4216
  start,
4199
4217
  rate: scene.rate,
@@ -4201,7 +4219,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4201
4219
  cropY: crop.y,
4202
4220
  cropW: crop.w,
4203
4221
  cropH: crop.h,
4204
- 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)),
4205
4224
  pushFrom: scene.push?.from ?? 1,
4206
4225
  pushTo: scene.push?.to ?? 1
4207
4226
  };
@@ -4251,9 +4270,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4251
4270
  const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
4252
4271
  const take = m ? takes.get(m[1]) : void 0;
4253
4272
  if (take) {
4254
- const idx = Math.min(take.frames.length - 1, parseInt(m[2], 10));
4255
- res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
4256
- res.end(take.frames[idx]);
4273
+ const idx = Math.min(take.count - 1, parseInt(m[2], 10));
4274
+ readFile4(join2(take.framesDir, `${idx}.png`)).then((png) => {
4275
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
4276
+ res.end(png);
4277
+ }).catch(() => {
4278
+ res.writeHead(404);
4279
+ res.end();
4280
+ });
4257
4281
  } else {
4258
4282
  res.writeHead(404);
4259
4283
  res.end();
@@ -4266,19 +4290,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4266
4290
  (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
4267
4291
  );
4268
4292
  const totalMs = durations.reduce((s, d) => s + d, 0);
4269
- let elapsedMs = 0;
4270
- 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
+ }
4271
4307
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
4272
- try {
4308
+ const renderPass = async (stageW, stageH, passLabel) => {
4309
+ let elapsedMs = 0;
4310
+ const segments = [];
4273
4311
  for (let i = 0; i < timeline.length; i++) {
4274
4312
  const scene = timeline[i];
4275
4313
  const dur = durations[i];
4276
- const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
4314
+ const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
4277
4315
  onProgress?.({ scene: i + 1, total: timeline.length, label });
4278
4316
  const thread = brand.annotations ? {
4279
4317
  threadFrom: (elapsedMs / totalMs).toFixed(4),
4280
4318
  threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
4281
4319
  } : {};
4320
+ if (scenario.captions.length > 0) {
4321
+ thread.capsJson = JSON.stringify(scenario.captions);
4322
+ thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
4323
+ }
4282
4324
  elapsedMs += dur;
4283
4325
  let segment;
4284
4326
  if (scene.type === "motion") {
@@ -4287,50 +4329,70 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4287
4329
  url,
4288
4330
  { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
4289
4331
  dur,
4290
- scenario
4332
+ scenario,
4333
+ stageW,
4334
+ stageH
4291
4335
  );
4292
4336
  } else {
4293
4337
  const take = takes.get(scene.footage);
4294
4338
  const url = resolveMotionTemplate("vignette", scenarioDir);
4295
4339
  segment = await captureMotionSegment(
4296
4340
  url,
4297
- { ...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
+ },
4298
4345
  dur,
4299
- scenario
4346
+ scenario,
4347
+ stageW,
4348
+ stageH
4300
4349
  );
4301
4350
  }
4302
- const segPath = join2(tmp, `s${i}.mp4`);
4351
+ const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
4303
4352
  await writeFile2(segPath, segment.buffer);
4304
4353
  segments.push({ path: segPath, seconds: segment.seconds });
4305
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 };
4306
4385
  } finally {
4307
4386
  server.close();
4387
+ await rm2(tmp, { recursive: true, force: true }).catch(() => {
4388
+ });
4389
+ for (const take of takes.values()) {
4390
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
4391
+ });
4392
+ }
4308
4393
  }
4309
- const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
4310
- const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
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
- }
4325
- execSync(
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}"`,
4327
- { stdio: ["ignore", "ignore", "pipe"] }
4328
- );
4329
- const buffer = await readFile4(outPath);
4330
- await rm2(tmp, { recursive: true, force: true }).catch(() => {
4331
- });
4332
- return buffer;
4333
4394
  }
4395
+ var ASPECT_DIMS;
4334
4396
  var init_runner = __esm({
4335
4397
  "src/scenes/runner.ts"() {
4336
4398
  "use strict";
@@ -4338,6 +4400,11 @@ var init_runner = __esm({
4338
4400
  init_prepare();
4339
4401
  init_canvas_renderer();
4340
4402
  init_video_encoder();
4403
+ ASPECT_DIMS = {
4404
+ "16:9": [1280, 720],
4405
+ "9:16": [720, 1280],
4406
+ "1:1": [960, 960]
4407
+ };
4341
4408
  }
4342
4409
  });
4343
4410
 
@@ -4383,6 +4450,15 @@ function validateScenario(scenario) {
4383
4450
  }
4384
4451
  }
4385
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
+ }
4386
4462
  if (scenario.steps.length > 0) {
4387
4463
  const firstStep = scenario.steps[0];
4388
4464
  const hasNavigate = firstStep.actions.some(
@@ -4566,7 +4642,7 @@ import { homedir } from "os";
4566
4642
  var program = new Command();
4567
4643
  program.name("clipwise").description(
4568
4644
  "Playwright-based cinematic screen recorder for product demos"
4569
- ).version("0.10.0");
4645
+ ).version("0.11.0");
4570
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(
4571
4647
  "-f, --format <format>",
4572
4648
  "Output format (gif|mp4|png-sequence)"
@@ -4630,12 +4706,17 @@ program.command("record").description("Record a demo from a YAML scenario file")
4630
4706
  const outDir2 = scenario.output.outputDir;
4631
4707
  await mkdir2(outDir2, { recursive: true });
4632
4708
  spinner.start(`Rendering ${scenario.scenes.length}-scene timeline...`);
4633
- const buf = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
4709
+ const { buffer: buf, extras } = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
4634
4710
  spinner.text = scene === 0 ? `Recording footage \u2014 ${label}...` : `Rendering scene ${scene}/${total} \u2014 ${label}...`;
4635
4711
  });
4636
4712
  const outputPath = join3(outDir2, `${scenario.output.filename}.mp4`);
4637
4713
  await writeFile3(outputPath, buf);
4638
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
+ }
4639
4720
  console.log(chalk.green("\nDone! \u{1F3AC}"));
4640
4721
  return;
4641
4722
  }
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
@@ -3524,6 +3524,7 @@ var StreamingSession = class extends EventEmitter {
3524
3524
  // src/scenes/runner.ts
3525
3525
  import { chromium as chromium2 } from "playwright";
3526
3526
  import { createServer } from "http";
3527
+ import { createHash } from "crypto";
3527
3528
  import { execSync } from "child_process";
3528
3529
  import { readFile as readFile3, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
3529
3530
  import { existsSync as existsSync2 } from "fs";
@@ -3630,15 +3631,20 @@ async function executeStepsForProbe(page, scene) {
3630
3631
  }
3631
3632
  }
3632
3633
  }
3633
- function segmentOutput(scenario) {
3634
+ function segmentOutput(scenario, stageW, stageH) {
3634
3635
  const dpr = scenario.viewport.deviceScaleFactor ?? 1;
3635
3636
  return {
3636
3637
  ...scenario.output,
3637
- width: scenario.output.width * dpr,
3638
- height: scenario.output.height * dpr,
3638
+ width: stageW * dpr,
3639
+ height: stageH * dpr,
3639
3640
  preset: "archive"
3640
3641
  };
3641
3642
  }
3643
+ var ASPECT_DIMS = {
3644
+ "16:9": [1280, 720],
3645
+ "9:16": [720, 1280],
3646
+ "1:1": [960, 960]
3647
+ };
3642
3648
  async function recordFootageTake(scenario, scene, selectors) {
3643
3649
  const boxes = /* @__PURE__ */ new Map();
3644
3650
  if (selectors.length > 0) {
@@ -3664,32 +3670,36 @@ async function recordFootageTake(scenario, scene, selectors) {
3664
3670
  };
3665
3671
  const recorder = new ClipwiseRecorder();
3666
3672
  const session = await recorder.record(takeScenario);
3667
- const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
3668
- const composed = [];
3669
- for await (const f of renderer.composeStream(session.frames)) composed.push(f);
3670
- const frames = await Promise.all(
3671
- composed.map(
3672
- (f) => f.rawInfo ? sharp10(f.buffer, {
3673
- raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
3674
- }).png().toBuffer() : Promise.resolve(f.buffer)
3675
- )
3673
+ const renderer = new CanvasRenderer(
3674
+ takeScenario.effects,
3675
+ segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
3676
+ scene.steps
3676
3677
  );
3678
+ const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
3679
+ let count = 0;
3680
+ for await (const f of renderer.composeStream(session.frames)) {
3681
+ const png = f.rawInfo ? await sharp10(f.buffer, {
3682
+ raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
3683
+ }).png().toBuffer() : f.buffer;
3684
+ await writeFile2(join2(framesDir, `${count}.png`), png);
3685
+ count++;
3686
+ }
3677
3687
  const anchors = [];
3678
3688
  for (let k = 0; k < scene.steps.length; k++) {
3679
3689
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
3680
3690
  anchors.push(Math.max(0, idx) / scenario.output.fps);
3681
3691
  }
3682
- return { frames, anchors, boxes };
3692
+ return { framesDir, count, anchors, boxes };
3683
3693
  }
3684
3694
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
3685
3695
  return Math.round(Math.min(maxW, maxH * cw / ch));
3686
3696
  }
3687
- async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
3697
+ async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
3688
3698
  const fps = scenario.output.fps;
3689
3699
  const totalFrames = Math.round(durationMs / 1e3 * fps);
3690
3700
  const browser = await chromium2.launch();
3691
3701
  const page = await browser.newPage({
3692
- viewport: { width: scenario.output.width, height: scenario.output.height },
3702
+ viewport: { width: stageW, height: stageH },
3693
3703
  deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
3694
3704
  });
3695
3705
  const params = new URLSearchParams(
@@ -3707,9 +3717,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
3707
3717
  frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
3708
3718
  }
3709
3719
  await browser.close();
3710
- 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
+ };
3711
3724
  }
3712
- function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3725
+ function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
3713
3726
  const W = scenario.viewport.width;
3714
3727
  const H = scenario.viewport.height;
3715
3728
  let crop = { x: 0, y: 0, w: W, h: H };
@@ -3739,7 +3752,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3739
3752
  label: scene.label ?? "",
3740
3753
  caption: scene.caption ?? "",
3741
3754
  base: serverBase,
3742
- count: take.frames.length,
3755
+ count: take.count,
3743
3756
  fps: scenario.output.fps,
3744
3757
  start,
3745
3758
  rate: scene.rate,
@@ -3747,7 +3760,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3747
3760
  cropY: crop.y,
3748
3761
  cropW: crop.w,
3749
3762
  cropH: crop.h,
3750
- 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)),
3751
3765
  pushFrom: scene.push?.from ?? 1,
3752
3766
  pushTo: scene.push?.to ?? 1
3753
3767
  };
@@ -3797,9 +3811,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3797
3811
  const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
3798
3812
  const take = m ? takes.get(m[1]) : void 0;
3799
3813
  if (take) {
3800
- const idx = Math.min(take.frames.length - 1, parseInt(m[2], 10));
3801
- res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
3802
- res.end(take.frames[idx]);
3814
+ const idx = Math.min(take.count - 1, parseInt(m[2], 10));
3815
+ readFile3(join2(take.framesDir, `${idx}.png`)).then((png) => {
3816
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
3817
+ res.end(png);
3818
+ }).catch(() => {
3819
+ res.writeHead(404);
3820
+ res.end();
3821
+ });
3803
3822
  } else {
3804
3823
  res.writeHead(404);
3805
3824
  res.end();
@@ -3812,19 +3831,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3812
3831
  (sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
3813
3832
  );
3814
3833
  const totalMs = durations.reduce((s, d) => s + d, 0);
3815
- let elapsedMs = 0;
3816
- 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
+ }
3817
3848
  const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
3818
- try {
3849
+ const renderPass = async (stageW, stageH, passLabel) => {
3850
+ let elapsedMs = 0;
3851
+ const segments = [];
3819
3852
  for (let i = 0; i < timeline.length; i++) {
3820
3853
  const scene = timeline[i];
3821
3854
  const dur = durations[i];
3822
- const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
3855
+ const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
3823
3856
  onProgress?.({ scene: i + 1, total: timeline.length, label });
3824
3857
  const thread = brand.annotations ? {
3825
3858
  threadFrom: (elapsedMs / totalMs).toFixed(4),
3826
3859
  threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
3827
3860
  } : {};
3861
+ if (scenario.captions.length > 0) {
3862
+ thread.capsJson = JSON.stringify(scenario.captions);
3863
+ thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
3864
+ }
3828
3865
  elapsedMs += dur;
3829
3866
  let segment;
3830
3867
  if (scene.type === "motion") {
@@ -3833,49 +3870,68 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3833
3870
  url,
3834
3871
  { accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
3835
3872
  dur,
3836
- scenario
3873
+ scenario,
3874
+ stageW,
3875
+ stageH
3837
3876
  );
3838
3877
  } else {
3839
3878
  const take = takes.get(scene.footage);
3840
3879
  const url = resolveMotionTemplate("vignette", scenarioDir);
3841
3880
  segment = await captureMotionSegment(
3842
3881
  url,
3843
- { ...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
+ },
3844
3886
  dur,
3845
- scenario
3887
+ scenario,
3888
+ stageW,
3889
+ stageH
3846
3890
  );
3847
3891
  }
3848
- const segPath = join2(tmp, `s${i}.mp4`);
3892
+ const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
3849
3893
  await writeFile2(segPath, segment.buffer);
3850
3894
  segments.push({ path: segPath, seconds: segment.seconds });
3851
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 };
3852
3926
  } finally {
3853
3927
  server.close();
3928
+ await rm2(tmp, { recursive: true, force: true }).catch(() => {
3929
+ });
3930
+ for (const take of takes.values()) {
3931
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
3932
+ });
3933
+ }
3854
3934
  }
3855
- const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
3856
- const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
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
- }
3871
- execSync(
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}"`,
3873
- { stdio: ["ignore", "ignore", "pipe"] }
3874
- );
3875
- const buffer = await readFile3(outPath);
3876
- await rm2(tmp, { recursive: true, force: true }).catch(() => {
3877
- });
3878
- return buffer;
3879
3935
  }
3880
3936
 
3881
3937
  // src/script/parser.ts
@@ -4127,7 +4183,10 @@ var OutputConfigSchema = z.object({
4127
4183
  codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
4128
4184
  // Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
4129
4185
  outputDir: z.string().default(".clipwise/output"),
4130
- 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([])
4131
4190
  });
4132
4191
  var StepEffectsOverrideSchema = z.object({
4133
4192
  zoom: ZoomEffectSchema.partial().optional(),
@@ -4280,6 +4339,11 @@ var SceneSchema = z.discriminatedUnion("type", [
4280
4339
  ScreenSceneSchema,
4281
4340
  VignetteSceneSchema
4282
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
+ });
4283
4347
  var ScenarioSchema = z.object({
4284
4348
  name: z.string(),
4285
4349
  description: z.string().optional(),
@@ -4300,7 +4364,9 @@ var ScenarioSchema = z.object({
4300
4364
  /** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
4301
4365
  steps: z.array(StepSchema).default([]),
4302
4366
  /** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
4303
- scenes: z.array(SceneSchema).optional()
4367
+ scenes: z.array(SceneSchema).optional(),
4368
+ /** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
4369
+ captions: z.array(CaptionSchema).default([])
4304
4370
  }).superRefine((s, ctx) => {
4305
4371
  if (s.steps.length === 0 && !s.scenes?.length) {
4306
4372
  ctx.addIssue({
@@ -4398,6 +4464,15 @@ function validateScenario(scenario) {
4398
4464
  }
4399
4465
  }
4400
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
+ }
4401
4476
  if (scenario.steps.length > 0) {
4402
4477
  const firstStep = scenario.steps[0];
4403
4478
  const hasNavigate = firstStep.actions.some(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -399,8 +399,15 @@ 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
- video AND snaps every scene cut onto the beat grid (beat-synced cuts)
407
+ video AND snaps every scene cut onto the beat grid (beat-synced cuts).
408
+ `file:` also accepts a URL (downloaded+cached on the user's machine — use license-free
409
+ sources like Mixkit, e.g. `https://assets.mixkit.co/music/132/132.mp3`, ~120bpm).
410
+ Track shorter than the video loops automatically; video length is always authoritative
404
411
 
405
412
  ## Critical Rules
406
413
 
@@ -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) {