clipwise 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +14 -0
- package/README.md +14 -0
- package/dist/cli/index.js +167 -28
- package/dist/index.d.ts +76 -26
- package/dist/index.js +59 -15
- package/package.json +1 -1
- package/skills/clipwise.md +10 -4
- package/templates/motion/vignette.html +22 -1
package/README.ko.md
CHANGED
|
@@ -25,6 +25,20 @@ npx clipwise@latest record .clipwise/scenarios/demo.yaml
|
|
|
25
25
|
**Zero footprint**: Clipwise가 남기는 모든 것(시나리오, 픽스처, 인증 상태, 출력물)은
|
|
26
26
|
`.clipwise/` 디렉토리 하나에 담깁니다. `rm -rf .clipwise` 한 줄로 모든 흔적이 사라집니다.
|
|
27
27
|
|
|
28
|
+
### 처음 5분 가이드
|
|
29
|
+
|
|
30
|
+
1. `npx clipwise@latest init` — 바로 쓸 수 있는 시나리오 2개와 함께 `.clipwise/` 생성
|
|
31
|
+
2. `npx clipwise@latest record .clipwise/scenarios/keynote.yaml` — **수정 없이** 키노트
|
|
32
|
+
런치 영상이 렌더됩니다 (호스팅 데모 대시보드를 녹화)
|
|
33
|
+
3. `.clipwise/output/keynote.mp4` 열기 — 이것이 기본으로 제공되는 퀄리티 기준입니다
|
|
34
|
+
4. `keynote.yaml` 수정: `url:`과 셀렉터를 **내 앱**으로 교체, 캡션 문구 조정
|
|
35
|
+
5. `brand.yaml` 수정: accent 컬러·폰트 프리셋·캐치프레이즈 — 영상 전체가 따라옵니다
|
|
36
|
+
|
|
37
|
+
자연어가 편하시면 `npx clipwise install-skill` 후 Claude Code에서 `/clipwise`로
|
|
38
|
+
요청하세요 — 키노트 레시피가 내장된 스킬이 YAML을 대신 작성합니다. 런치 영상이
|
|
39
|
+
아니라 단순 화면 녹화가 필요하면 `scenarios/demo.yaml`과 아래
|
|
40
|
+
[YAML 시나리오 형식](#yaml-시나리오-형식)에서 시작하세요.
|
|
41
|
+
|
|
28
42
|
## 요구사항
|
|
29
43
|
|
|
30
44
|
- **Node.js** >= 18
|
package/README.md
CHANGED
|
@@ -25,6 +25,20 @@ npx clipwise@latest record .clipwise/scenarios/demo.yaml
|
|
|
25
25
|
**Zero footprint**: everything Clipwise touches lives in one `.clipwise/` directory —
|
|
26
26
|
scenarios, fixtures, auth state, output. Remove every trace with `rm -rf .clipwise`.
|
|
27
27
|
|
|
28
|
+
### Your first 5 minutes
|
|
29
|
+
|
|
30
|
+
1. `npx clipwise@latest init` — scaffolds `.clipwise/` with two ready scenarios
|
|
31
|
+
2. `npx clipwise@latest record .clipwise/scenarios/keynote.yaml` — renders a full
|
|
32
|
+
keynote-style launch video **with zero edits** (it records the hosted demo dashboard)
|
|
33
|
+
3. Open `.clipwise/output/keynote.mp4` — this is the quality bar you get out of the box
|
|
34
|
+
4. Edit `keynote.yaml`: swap the `url:` and selectors for **your** app, tweak the captions
|
|
35
|
+
5. Edit `brand.yaml`: your accent color, font preset, catchphrases — the whole video follows
|
|
36
|
+
|
|
37
|
+
Prefer natural language? `npx clipwise install-skill`, then ask `/clipwise` in
|
|
38
|
+
Claude Code — it writes these YAMLs for you (the skill ships with the full
|
|
39
|
+
keynote recipe). For a plain screen recording instead of a launch video, start
|
|
40
|
+
from `scenarios/demo.yaml` and the [YAML Scenario Format](#yaml-scenario-format) below.
|
|
41
|
+
|
|
28
42
|
## Requirements
|
|
29
43
|
|
|
30
44
|
- **Node.js** >= 18
|
package/dist/cli/index.js
CHANGED
|
@@ -293,7 +293,9 @@ var init_types = __esm({
|
|
|
293
293
|
/** Fade-in duration in milliseconds. */
|
|
294
294
|
fadeIn: z.number().min(0).default(0),
|
|
295
295
|
/** Fade-out duration in milliseconds. */
|
|
296
|
-
fadeOut: z.number().min(0).default(0)
|
|
296
|
+
fadeOut: z.number().min(0).default(0),
|
|
297
|
+
/** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
|
|
298
|
+
bpm: z.number().min(40).max(220).optional()
|
|
297
299
|
});
|
|
298
300
|
AuthConfigSchema = z.object({
|
|
299
301
|
/** Path to a Playwright storageState JSON file (cookies + localStorage). */
|
|
@@ -324,6 +326,8 @@ var init_types = __esm({
|
|
|
324
326
|
PrepareConfigSchema = z.object({
|
|
325
327
|
/** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
|
|
326
328
|
hide: z.array(z.string().min(1)).default([]),
|
|
329
|
+
/** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
|
|
330
|
+
mask: z.array(z.string().min(1)).default([]),
|
|
327
331
|
/** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
|
|
328
332
|
freezeTime: z.string().optional(),
|
|
329
333
|
/** Math.random을 이 시드의 결정론적 PRNG로 대체. */
|
|
@@ -357,7 +361,7 @@ var init_types = __esm({
|
|
|
357
361
|
steps: z.array(StepSchema).min(1)
|
|
358
362
|
});
|
|
359
363
|
SceneFxSchema = z.object({
|
|
360
|
-
kind: z.enum(["circle", "arrow"]),
|
|
364
|
+
kind: z.enum(["circle", "arrow", "spotlight"]),
|
|
361
365
|
/** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
|
|
362
366
|
selector: z.string().optional(),
|
|
363
367
|
/** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
|
|
@@ -387,8 +391,14 @@ var init_types = __esm({
|
|
|
387
391
|
/** 크롭 높이 상한 (px, 원본 기준) — 와이드 스트립 연출용. */
|
|
388
392
|
maxH: z.number().optional()
|
|
389
393
|
}).optional(),
|
|
390
|
-
/** 푸시인 카메라 (스케일 from→to).
|
|
391
|
-
|
|
394
|
+
/** 푸시인 카메라 (스케일 from→to).
|
|
395
|
+
* origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
|
|
396
|
+
* 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
|
|
397
|
+
push: z.object({
|
|
398
|
+
from: z.number().default(1),
|
|
399
|
+
to: z.number().default(1),
|
|
400
|
+
origin: z.string().optional()
|
|
401
|
+
}).optional(),
|
|
392
402
|
/** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
|
|
393
403
|
start: z.union([z.number(), z.object({ step: z.number().int().min(0), offset: z.number().default(0) })]).default(0),
|
|
394
404
|
/** 푸티지 재생 배속. */
|
|
@@ -571,6 +581,12 @@ function buildHideCss(selectors) {
|
|
|
571
581
|
visibility: hidden !important;
|
|
572
582
|
}`;
|
|
573
583
|
}
|
|
584
|
+
function buildMaskCss(selectors) {
|
|
585
|
+
return `${selectors.join(",\n")} {
|
|
586
|
+
filter: blur(10px) !important;
|
|
587
|
+
border-radius: 4px;
|
|
588
|
+
}`;
|
|
589
|
+
}
|
|
574
590
|
function buildCssInjectionScript(css) {
|
|
575
591
|
return `(() => {
|
|
576
592
|
const apply = () => {
|
|
@@ -648,6 +664,9 @@ async function applyPrepare(context, prepare) {
|
|
|
648
664
|
if (prepare.hide.length > 0) {
|
|
649
665
|
cssChunks.push(buildHideCss(prepare.hide));
|
|
650
666
|
}
|
|
667
|
+
if (prepare.mask.length > 0) {
|
|
668
|
+
cssChunks.push(buildMaskCss(prepare.mask));
|
|
669
|
+
}
|
|
651
670
|
if (prepare.inject?.css) {
|
|
652
671
|
const cssFiles = Array.isArray(prepare.inject.css) ? prepare.inject.css : [prepare.inject.css];
|
|
653
672
|
for (const file of cssFiles) {
|
|
@@ -4144,7 +4163,7 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
|
4144
4163
|
await browser.close();
|
|
4145
4164
|
return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
|
|
4146
4165
|
}
|
|
4147
|
-
function vignetteProps(scene, take, serverBase, scenario, brand) {
|
|
4166
|
+
function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
4148
4167
|
const W = scenario.viewport.width;
|
|
4149
4168
|
const H = scenario.viewport.height;
|
|
4150
4169
|
let crop = { x: 0, y: 0, w: W, h: H };
|
|
@@ -4168,7 +4187,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
|
|
|
4168
4187
|
const props = {
|
|
4169
4188
|
accent: brand.accent,
|
|
4170
4189
|
font: brand.font,
|
|
4171
|
-
dur:
|
|
4190
|
+
dur: durMs / 1e3,
|
|
4172
4191
|
layout: scene.layout,
|
|
4173
4192
|
num: scene.num ?? "",
|
|
4174
4193
|
label: scene.label ?? "",
|
|
@@ -4187,13 +4206,19 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
|
|
|
4187
4206
|
pushTo: scene.push?.to ?? 1
|
|
4188
4207
|
};
|
|
4189
4208
|
if (scene.code?.length) props.code = scene.code.join("||");
|
|
4209
|
+
if (scene.push?.origin) {
|
|
4210
|
+
const box = take.boxes.get(scene.push.origin);
|
|
4211
|
+
if (!box) throw new Error(`vignette push.origin selector "${scene.push.origin}" not found in footage "${scene.footage}"`);
|
|
4212
|
+
props.pushOx = Math.max(0, Math.min(100, (box.x + box.width / 2 - crop.x) / crop.w * 100)).toFixed(1);
|
|
4213
|
+
props.pushOy = Math.max(0, Math.min(100, (box.y + box.height / 2 - crop.y) / crop.h * 100)).toFixed(1);
|
|
4214
|
+
}
|
|
4190
4215
|
if (brand.annotations && scene.fx.length > 0) {
|
|
4191
4216
|
props.fx = scene.fx.map((fx) => {
|
|
4192
4217
|
let coords = fx.coords;
|
|
4193
4218
|
if (fx.selector) {
|
|
4194
4219
|
const box = take.boxes.get(fx.selector);
|
|
4195
4220
|
if (!box) throw new Error(`vignette fx selector "${fx.selector}" not found in footage "${scene.footage}"`);
|
|
4196
|
-
coords = fx.kind === "
|
|
4221
|
+
coords = fx.kind === "arrow" ? [box.x - 160, box.y + 120, box.x - 12, box.y + box.height / 2] : [box.x, box.y, box.width, box.height];
|
|
4197
4222
|
}
|
|
4198
4223
|
return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
|
|
4199
4224
|
}).join(";");
|
|
@@ -4210,6 +4235,7 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4210
4235
|
if (scene.type !== "vignette") continue;
|
|
4211
4236
|
const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
|
|
4212
4237
|
if (scene.crop?.selector) set.add(scene.crop.selector);
|
|
4238
|
+
if (scene.push?.origin) set.add(scene.push.origin);
|
|
4213
4239
|
for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
|
|
4214
4240
|
selectorsByFootage.set(scene.footage, set);
|
|
4215
4241
|
}
|
|
@@ -4235,27 +4261,32 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4235
4261
|
});
|
|
4236
4262
|
await new Promise((r) => server.listen(0, r));
|
|
4237
4263
|
const port = server.address().port;
|
|
4238
|
-
const
|
|
4264
|
+
const beatMs = scenario.audio?.bpm ? 6e4 / scenario.audio.bpm : 0;
|
|
4265
|
+
const durations = timeline.map(
|
|
4266
|
+
(sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
|
|
4267
|
+
);
|
|
4268
|
+
const totalMs = durations.reduce((s, d) => s + d, 0);
|
|
4239
4269
|
let elapsedMs = 0;
|
|
4240
4270
|
const segments = [];
|
|
4241
4271
|
const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
|
|
4242
4272
|
try {
|
|
4243
4273
|
for (let i = 0; i < timeline.length; i++) {
|
|
4244
4274
|
const scene = timeline[i];
|
|
4275
|
+
const dur = durations[i];
|
|
4245
4276
|
const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
|
|
4246
4277
|
onProgress?.({ scene: i + 1, total: timeline.length, label });
|
|
4247
4278
|
const thread = brand.annotations ? {
|
|
4248
4279
|
threadFrom: (elapsedMs / totalMs).toFixed(4),
|
|
4249
|
-
threadTo: ((elapsedMs +
|
|
4280
|
+
threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
|
|
4250
4281
|
} : {};
|
|
4251
|
-
elapsedMs +=
|
|
4282
|
+
elapsedMs += dur;
|
|
4252
4283
|
let segment;
|
|
4253
4284
|
if (scene.type === "motion") {
|
|
4254
4285
|
const url = resolveMotionTemplate(scene.template, scenarioDir);
|
|
4255
4286
|
segment = await captureMotionSegment(
|
|
4256
4287
|
url,
|
|
4257
|
-
{ accent: brand.accent, font: brand.font, dur:
|
|
4258
|
-
|
|
4288
|
+
{ accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
|
|
4289
|
+
dur,
|
|
4259
4290
|
scenario
|
|
4260
4291
|
);
|
|
4261
4292
|
} else {
|
|
@@ -4263,8 +4294,8 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4263
4294
|
const url = resolveMotionTemplate("vignette", scenarioDir);
|
|
4264
4295
|
segment = await captureMotionSegment(
|
|
4265
4296
|
url,
|
|
4266
|
-
{ ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
|
|
4267
|
-
|
|
4297
|
+
{ ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
|
|
4298
|
+
dur,
|
|
4268
4299
|
scenario
|
|
4269
4300
|
);
|
|
4270
4301
|
}
|
|
@@ -4278,8 +4309,21 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4278
4309
|
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
4279
4310
|
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
4280
4311
|
const outPath = join2(tmp, "timeline.mp4");
|
|
4312
|
+
let audioInput = "";
|
|
4313
|
+
let audioMap = "";
|
|
4314
|
+
if (scenario.audio) {
|
|
4315
|
+
const a = scenario.audio;
|
|
4316
|
+
const audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
|
|
4317
|
+
const totalSec = totalMs / 1e3;
|
|
4318
|
+
const af = [];
|
|
4319
|
+
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
4320
|
+
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
4321
|
+
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
4322
|
+
audioInput = `-i "${audioPath}" `;
|
|
4323
|
+
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
|
|
4324
|
+
}
|
|
4281
4325
|
execSync(
|
|
4282
|
-
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} -filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
4326
|
+
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
4283
4327
|
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
4284
4328
|
);
|
|
4285
4329
|
const buffer = await readFile4(outPath);
|
|
@@ -4522,7 +4566,7 @@ import { homedir } from "os";
|
|
|
4522
4566
|
var program = new Command();
|
|
4523
4567
|
program.name("clipwise").description(
|
|
4524
4568
|
"Playwright-based cinematic screen recorder for product demos"
|
|
4525
|
-
).version("0.
|
|
4569
|
+
).version("0.10.0");
|
|
4526
4570
|
program.command("record").description("Record a demo from a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").option("-o, --output <dir>", "Output directory (default: scenario outputDir or .clipwise/output)").option(
|
|
4527
4571
|
"-f, --format <format>",
|
|
4528
4572
|
"Output format (gif|mp4|png-sequence)"
|
|
@@ -4793,6 +4837,97 @@ steps:
|
|
|
4793
4837
|
actions:
|
|
4794
4838
|
- action: click
|
|
4795
4839
|
selector: "#my-button"
|
|
4840
|
+
`;
|
|
4841
|
+
const keynoteTemplate = `# Keynote-style launch video \u2014 runs AS-IS against the hosted demo.
|
|
4842
|
+
# Try it first: clipwise record .clipwise/scenarios/keynote.yaml
|
|
4843
|
+
# Then replace the url + selectors with your own app.
|
|
4844
|
+
|
|
4845
|
+
name: "My Launch Video"
|
|
4846
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 2 } # 2 = retina quality
|
|
4847
|
+
|
|
4848
|
+
effects:
|
|
4849
|
+
cursor: { enabled: true, clickEffect: true, highlight: false, trail: false }
|
|
4850
|
+
|
|
4851
|
+
output:
|
|
4852
|
+
format: mp4
|
|
4853
|
+
fps: 30
|
|
4854
|
+
preset: balanced
|
|
4855
|
+
filename: keynote
|
|
4856
|
+
|
|
4857
|
+
scenes:
|
|
4858
|
+
# footage take \u2014 recorded once; vignettes below quote it by step
|
|
4859
|
+
- type: screen
|
|
4860
|
+
id: demo
|
|
4861
|
+
steps:
|
|
4862
|
+
- name: "Open"
|
|
4863
|
+
captureDelay: 120
|
|
4864
|
+
holdDuration: 1400
|
|
4865
|
+
actions:
|
|
4866
|
+
- action: navigate
|
|
4867
|
+
url: "https://kwakseongjae.github.io/clipwise/demo/" # \u2190 your app URL
|
|
4868
|
+
waitUntil: networkidle
|
|
4869
|
+
- name: "Stats"
|
|
4870
|
+
captureDelay: 50
|
|
4871
|
+
holdDuration: 700
|
|
4872
|
+
actions:
|
|
4873
|
+
- { action: hover, selector: "#stat-users" } # \u2190 your selectors
|
|
4874
|
+
- name: "Search"
|
|
4875
|
+
captureDelay: 50
|
|
4876
|
+
holdDuration: 500
|
|
4877
|
+
actions:
|
|
4878
|
+
- { action: type, selector: "#search-input", text: "growth report", delay: 28 }
|
|
4879
|
+
- name: "Switch tab"
|
|
4880
|
+
captureDelay: 50
|
|
4881
|
+
holdDuration: 1100
|
|
4882
|
+
actions:
|
|
4883
|
+
- { action: click, selector: "#tab-daily" }
|
|
4884
|
+
- name: "Table"
|
|
4885
|
+
captureDelay: 80
|
|
4886
|
+
holdDuration: 1300
|
|
4887
|
+
actions:
|
|
4888
|
+
- action: scroll
|
|
4889
|
+
y: 420
|
|
4890
|
+
smooth: true
|
|
4891
|
+
|
|
4892
|
+
# \u2500\u2500 timeline \u2500\u2500
|
|
4893
|
+
- type: motion
|
|
4894
|
+
template: kinetic-type
|
|
4895
|
+
duration: 2200
|
|
4896
|
+
props: { lines: "Ship *demos*,||not edits.", size: 86 }
|
|
4897
|
+
|
|
4898
|
+
- type: vignette
|
|
4899
|
+
footage: demo
|
|
4900
|
+
duration: 4200
|
|
4901
|
+
layout: hero
|
|
4902
|
+
num: "01"
|
|
4903
|
+
label: "Cinematic camera"
|
|
4904
|
+
caption: "Recorded from a real app \u2014 *zero code changes*"
|
|
4905
|
+
push: { from: 1.02, to: 1.1 }
|
|
4906
|
+
start: { step: 0, offset: 0.15 }
|
|
4907
|
+
fx:
|
|
4908
|
+
- { kind: circle, selector: "#stat-users", delay: 2700 }
|
|
4909
|
+
|
|
4910
|
+
- type: vignette
|
|
4911
|
+
footage: demo
|
|
4912
|
+
duration: 4000
|
|
4913
|
+
layout: crop
|
|
4914
|
+
num: "02"
|
|
4915
|
+
label: "Close-up"
|
|
4916
|
+
caption: "Selector-measured crop \u2014 *no pixel guessing*"
|
|
4917
|
+
crop: { selector: "#chart-area", pad: 14 }
|
|
4918
|
+
push: { from: 1.04, to: 1 }
|
|
4919
|
+
start: { step: 3 }
|
|
4920
|
+
rate: 1.1
|
|
4921
|
+
|
|
4922
|
+
- type: motion
|
|
4923
|
+
template: kinetic-type
|
|
4924
|
+
duration: 1900
|
|
4925
|
+
props: { lines: "Your code,||*untouched.*", size: 80, fx: marker }
|
|
4926
|
+
|
|
4927
|
+
- type: motion
|
|
4928
|
+
template: kinetic-type
|
|
4929
|
+
duration: 2600
|
|
4930
|
+
props: { lines: "*My Product*", size: 90, sub: "npx clipwise@latest init" }
|
|
4796
4931
|
`;
|
|
4797
4932
|
const gitignore = `# Clipwise local artifacts \u2014 safe to ignore
|
|
4798
4933
|
auth/
|
|
@@ -4834,23 +4969,27 @@ catchphrases:
|
|
|
4834
4969
|
await mkdir2(join3(baseDir, "fixtures"), { recursive: true });
|
|
4835
4970
|
await mkdir2(join3(baseDir, "auth"), { recursive: true });
|
|
4836
4971
|
await writeFile3(join3(baseDir, "scenarios", "demo.yaml"), template, "utf-8");
|
|
4972
|
+
await writeFile3(join3(baseDir, "scenarios", "keynote.yaml"), keynoteTemplate, "utf-8");
|
|
4837
4973
|
await writeFile3(join3(baseDir, "brand.yaml"), brandTemplate, "utf-8");
|
|
4838
4974
|
await writeFile3(join3(baseDir, ".gitignore"), gitignore, "utf-8");
|
|
4839
4975
|
console.log(chalk.green("Created .clipwise/\n"));
|
|
4840
4976
|
console.log(" .clipwise/");
|
|
4841
|
-
console.log(" scenarios/
|
|
4842
|
-
console.log("
|
|
4843
|
-
console.log("
|
|
4844
|
-
console.log("
|
|
4845
|
-
console.log("
|
|
4846
|
-
console.log("
|
|
4847
|
-
console.log(
|
|
4848
|
-
|
|
4849
|
-
console.log(`
|
|
4850
|
-
console.log(
|
|
4977
|
+
console.log(" scenarios/keynote.yaml \u2014 keynote launch video (runs as-is!)");
|
|
4978
|
+
console.log(" scenarios/demo.yaml \u2014 simple screen recording (edit the URL)");
|
|
4979
|
+
console.log(" brand.yaml \u2014 tone & font presets + catchphrases");
|
|
4980
|
+
console.log(" prepare/ \u2014 CSS/JS injected only while recording");
|
|
4981
|
+
console.log(" fixtures/ \u2014 mocked API responses (JSON)");
|
|
4982
|
+
console.log(" auth/ \u2014 storageState files (gitignored)");
|
|
4983
|
+
console.log(`
|
|
4984
|
+
${chalk.bold("Try it right now")} (no edits needed \u2014 records the hosted demo):`);
|
|
4985
|
+
console.log(` ${chalk.bold("clipwise record .clipwise/scenarios/keynote.yaml")}`);
|
|
4986
|
+
console.log("\nThen make it yours:");
|
|
4987
|
+
console.log(` 1. Edit ${chalk.bold("keynote.yaml")} \u2014 swap the url + selectors for your app`);
|
|
4988
|
+
console.log(` 2. Edit ${chalk.bold("brand.yaml")} \u2014 your accent color & catchphrases`);
|
|
4989
|
+
console.log(` 3. Or let AI write scenarios: ${chalk.bold("clipwise install-skill")} \u2192 ask ${chalk.bold("/clipwise")} in Claude Code`);
|
|
4851
4990
|
console.log(`
|
|
4852
|
-
|
|
4853
|
-
console.log(`
|
|
4991
|
+
Output lands in ${chalk.bold(".clipwise/output/")} \xB7 remove every trace: ${chalk.bold("rm -rf .clipwise")}`);
|
|
4992
|
+
console.log(`Docs: ${chalk.bold("https://kwakseongjae.github.io/clipwise/")}
|
|
4854
4993
|
`);
|
|
4855
4994
|
});
|
|
4856
4995
|
program.command("demo").description("Record a demo video of the Clipwise showcase dashboard").option("-o, --output <dir>", "Output directory", ".clipwise/output").option(
|
package/dist/index.d.ts
CHANGED
|
@@ -1934,16 +1934,20 @@ declare const AudioConfigSchema: z.ZodObject<{
|
|
|
1934
1934
|
fadeIn: z.ZodDefault<z.ZodNumber>;
|
|
1935
1935
|
/** Fade-out duration in milliseconds. */
|
|
1936
1936
|
fadeOut: z.ZodDefault<z.ZodNumber>;
|
|
1937
|
+
/** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
|
|
1938
|
+
bpm: z.ZodOptional<z.ZodNumber>;
|
|
1937
1939
|
}, "strip", z.ZodTypeAny, {
|
|
1938
1940
|
file: string;
|
|
1939
1941
|
volume: number;
|
|
1940
1942
|
fadeIn: number;
|
|
1941
1943
|
fadeOut: number;
|
|
1944
|
+
bpm?: number | undefined;
|
|
1942
1945
|
}, {
|
|
1943
1946
|
file: string;
|
|
1944
1947
|
volume?: number | undefined;
|
|
1945
1948
|
fadeIn?: number | undefined;
|
|
1946
1949
|
fadeOut?: number | undefined;
|
|
1950
|
+
bpm?: number | undefined;
|
|
1947
1951
|
}>;
|
|
1948
1952
|
type AudioConfig = z.infer<typeof AudioConfigSchema>;
|
|
1949
1953
|
/**
|
|
@@ -2045,6 +2049,8 @@ type MockRoute = z.infer<typeof MockRouteSchema>;
|
|
|
2045
2049
|
declare const PrepareConfigSchema: z.ZodObject<{
|
|
2046
2050
|
/** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
|
|
2047
2051
|
hide: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
2052
|
+
/** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
|
|
2053
|
+
mask: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
2048
2054
|
/** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
|
|
2049
2055
|
freezeTime: z.ZodOptional<z.ZodString>;
|
|
2050
2056
|
/** Math.random을 이 시드의 결정론적 PRNG로 대체. */
|
|
@@ -2096,6 +2102,7 @@ declare const PrepareConfigSchema: z.ZodObject<{
|
|
|
2096
2102
|
}>>;
|
|
2097
2103
|
}, "strip", z.ZodTypeAny, {
|
|
2098
2104
|
hide: string[];
|
|
2105
|
+
mask: string[];
|
|
2099
2106
|
mock: {
|
|
2100
2107
|
status: number;
|
|
2101
2108
|
url: string;
|
|
@@ -2115,6 +2122,7 @@ declare const PrepareConfigSchema: z.ZodObject<{
|
|
|
2115
2122
|
} | undefined;
|
|
2116
2123
|
}, {
|
|
2117
2124
|
hide?: string[] | undefined;
|
|
2125
|
+
mask?: string[] | undefined;
|
|
2118
2126
|
freezeTime?: string | undefined;
|
|
2119
2127
|
seedRandom?: number | undefined;
|
|
2120
2128
|
storage?: {
|
|
@@ -3374,9 +3382,10 @@ declare const ScreenSceneSchema: z.ZodObject<{
|
|
|
3374
3382
|
} | undefined;
|
|
3375
3383
|
}[];
|
|
3376
3384
|
}>;
|
|
3377
|
-
/**
|
|
3385
|
+
/** 푸티지 주석 — circle/arrow(선 드로잉), spotlight(주변 디밍).
|
|
3386
|
+
* 좌표는 셀렉터 실측(권장) 또는 푸티지 원본 px. */
|
|
3378
3387
|
declare const SceneFxSchema: z.ZodObject<{
|
|
3379
|
-
kind: z.ZodEnum<["circle", "arrow"]>;
|
|
3388
|
+
kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
|
|
3380
3389
|
/** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
|
|
3381
3390
|
selector: z.ZodOptional<z.ZodString>;
|
|
3382
3391
|
/** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
|
|
@@ -3385,11 +3394,11 @@ declare const SceneFxSchema: z.ZodObject<{
|
|
|
3385
3394
|
delay: z.ZodDefault<z.ZodNumber>;
|
|
3386
3395
|
}, "strip", z.ZodTypeAny, {
|
|
3387
3396
|
delay: number;
|
|
3388
|
-
kind: "circle" | "arrow";
|
|
3397
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
3389
3398
|
selector?: string | undefined;
|
|
3390
3399
|
coords?: number[] | undefined;
|
|
3391
3400
|
}, {
|
|
3392
|
-
kind: "circle" | "arrow";
|
|
3401
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
3393
3402
|
selector?: string | undefined;
|
|
3394
3403
|
delay?: number | undefined;
|
|
3395
3404
|
coords?: number[] | undefined;
|
|
@@ -3433,16 +3442,21 @@ declare const VignetteSceneSchema: z.ZodObject<{
|
|
|
3433
3442
|
h?: number | undefined;
|
|
3434
3443
|
maxH?: number | undefined;
|
|
3435
3444
|
}>>;
|
|
3436
|
-
/** 푸시인 카메라 (스케일 from→to).
|
|
3445
|
+
/** 푸시인 카메라 (스케일 from→to).
|
|
3446
|
+
* origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
|
|
3447
|
+
* 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
|
|
3437
3448
|
push: z.ZodOptional<z.ZodObject<{
|
|
3438
3449
|
from: z.ZodDefault<z.ZodNumber>;
|
|
3439
3450
|
to: z.ZodDefault<z.ZodNumber>;
|
|
3451
|
+
origin: z.ZodOptional<z.ZodString>;
|
|
3440
3452
|
}, "strip", z.ZodTypeAny, {
|
|
3441
3453
|
from: number;
|
|
3442
3454
|
to: number;
|
|
3455
|
+
origin?: string | undefined;
|
|
3443
3456
|
}, {
|
|
3444
3457
|
from?: number | undefined;
|
|
3445
3458
|
to?: number | undefined;
|
|
3459
|
+
origin?: string | undefined;
|
|
3446
3460
|
}>>;
|
|
3447
3461
|
/** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
|
|
3448
3462
|
start: z.ZodDefault<z.ZodUnion<[z.ZodNumber, z.ZodObject<{
|
|
@@ -3458,7 +3472,7 @@ declare const VignetteSceneSchema: z.ZodObject<{
|
|
|
3458
3472
|
/** 푸티지 재생 배속. */
|
|
3459
3473
|
rate: z.ZodDefault<z.ZodNumber>;
|
|
3460
3474
|
fx: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
3461
|
-
kind: z.ZodEnum<["circle", "arrow"]>;
|
|
3475
|
+
kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
|
|
3462
3476
|
/** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
|
|
3463
3477
|
selector: z.ZodOptional<z.ZodString>;
|
|
3464
3478
|
/** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
|
|
@@ -3467,11 +3481,11 @@ declare const VignetteSceneSchema: z.ZodObject<{
|
|
|
3467
3481
|
delay: z.ZodDefault<z.ZodNumber>;
|
|
3468
3482
|
}, "strip", z.ZodTypeAny, {
|
|
3469
3483
|
delay: number;
|
|
3470
|
-
kind: "circle" | "arrow";
|
|
3484
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
3471
3485
|
selector?: string | undefined;
|
|
3472
3486
|
coords?: number[] | undefined;
|
|
3473
3487
|
}, {
|
|
3474
|
-
kind: "circle" | "arrow";
|
|
3488
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
3475
3489
|
selector?: string | undefined;
|
|
3476
3490
|
delay?: number | undefined;
|
|
3477
3491
|
coords?: number[] | undefined;
|
|
@@ -3488,13 +3502,14 @@ declare const VignetteSceneSchema: z.ZodObject<{
|
|
|
3488
3502
|
rate: number;
|
|
3489
3503
|
fx: {
|
|
3490
3504
|
delay: number;
|
|
3491
|
-
kind: "circle" | "arrow";
|
|
3505
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
3492
3506
|
selector?: string | undefined;
|
|
3493
3507
|
coords?: number[] | undefined;
|
|
3494
3508
|
}[];
|
|
3495
3509
|
push?: {
|
|
3496
3510
|
from: number;
|
|
3497
3511
|
to: number;
|
|
3512
|
+
origin?: string | undefined;
|
|
3498
3513
|
} | undefined;
|
|
3499
3514
|
code?: string[] | undefined;
|
|
3500
3515
|
crop?: {
|
|
@@ -3516,6 +3531,7 @@ declare const VignetteSceneSchema: z.ZodObject<{
|
|
|
3516
3531
|
push?: {
|
|
3517
3532
|
from?: number | undefined;
|
|
3518
3533
|
to?: number | undefined;
|
|
3534
|
+
origin?: string | undefined;
|
|
3519
3535
|
} | undefined;
|
|
3520
3536
|
code?: string[] | undefined;
|
|
3521
3537
|
crop?: {
|
|
@@ -3537,7 +3553,7 @@ declare const VignetteSceneSchema: z.ZodObject<{
|
|
|
3537
3553
|
} | undefined;
|
|
3538
3554
|
rate?: number | undefined;
|
|
3539
3555
|
fx?: {
|
|
3540
|
-
kind: "circle" | "arrow";
|
|
3556
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
3541
3557
|
selector?: string | undefined;
|
|
3542
3558
|
delay?: number | undefined;
|
|
3543
3559
|
coords?: number[] | undefined;
|
|
@@ -4817,16 +4833,21 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
4817
4833
|
h?: number | undefined;
|
|
4818
4834
|
maxH?: number | undefined;
|
|
4819
4835
|
}>>;
|
|
4820
|
-
/** 푸시인 카메라 (스케일 from→to).
|
|
4836
|
+
/** 푸시인 카메라 (스케일 from→to).
|
|
4837
|
+
* origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
|
|
4838
|
+
* 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
|
|
4821
4839
|
push: z.ZodOptional<z.ZodObject<{
|
|
4822
4840
|
from: z.ZodDefault<z.ZodNumber>;
|
|
4823
4841
|
to: z.ZodDefault<z.ZodNumber>;
|
|
4842
|
+
origin: z.ZodOptional<z.ZodString>;
|
|
4824
4843
|
}, "strip", z.ZodTypeAny, {
|
|
4825
4844
|
from: number;
|
|
4826
4845
|
to: number;
|
|
4846
|
+
origin?: string | undefined;
|
|
4827
4847
|
}, {
|
|
4828
4848
|
from?: number | undefined;
|
|
4829
4849
|
to?: number | undefined;
|
|
4850
|
+
origin?: string | undefined;
|
|
4830
4851
|
}>>;
|
|
4831
4852
|
/** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
|
|
4832
4853
|
start: z.ZodDefault<z.ZodUnion<[z.ZodNumber, z.ZodObject<{
|
|
@@ -4842,7 +4863,7 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
4842
4863
|
/** 푸티지 재생 배속. */
|
|
4843
4864
|
rate: z.ZodDefault<z.ZodNumber>;
|
|
4844
4865
|
fx: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
4845
|
-
kind: z.ZodEnum<["circle", "arrow"]>;
|
|
4866
|
+
kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
|
|
4846
4867
|
/** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
|
|
4847
4868
|
selector: z.ZodOptional<z.ZodString>;
|
|
4848
4869
|
/** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
|
|
@@ -4851,11 +4872,11 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
4851
4872
|
delay: z.ZodDefault<z.ZodNumber>;
|
|
4852
4873
|
}, "strip", z.ZodTypeAny, {
|
|
4853
4874
|
delay: number;
|
|
4854
|
-
kind: "circle" | "arrow";
|
|
4875
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
4855
4876
|
selector?: string | undefined;
|
|
4856
4877
|
coords?: number[] | undefined;
|
|
4857
4878
|
}, {
|
|
4858
|
-
kind: "circle" | "arrow";
|
|
4879
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
4859
4880
|
selector?: string | undefined;
|
|
4860
4881
|
delay?: number | undefined;
|
|
4861
4882
|
coords?: number[] | undefined;
|
|
@@ -4872,13 +4893,14 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
4872
4893
|
rate: number;
|
|
4873
4894
|
fx: {
|
|
4874
4895
|
delay: number;
|
|
4875
|
-
kind: "circle" | "arrow";
|
|
4896
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
4876
4897
|
selector?: string | undefined;
|
|
4877
4898
|
coords?: number[] | undefined;
|
|
4878
4899
|
}[];
|
|
4879
4900
|
push?: {
|
|
4880
4901
|
from: number;
|
|
4881
4902
|
to: number;
|
|
4903
|
+
origin?: string | undefined;
|
|
4882
4904
|
} | undefined;
|
|
4883
4905
|
code?: string[] | undefined;
|
|
4884
4906
|
crop?: {
|
|
@@ -4900,6 +4922,7 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
4900
4922
|
push?: {
|
|
4901
4923
|
from?: number | undefined;
|
|
4902
4924
|
to?: number | undefined;
|
|
4925
|
+
origin?: string | undefined;
|
|
4903
4926
|
} | undefined;
|
|
4904
4927
|
code?: string[] | undefined;
|
|
4905
4928
|
crop?: {
|
|
@@ -4921,7 +4944,7 @@ declare const SceneSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
4921
4944
|
} | undefined;
|
|
4922
4945
|
rate?: number | undefined;
|
|
4923
4946
|
fx?: {
|
|
4924
|
-
kind: "circle" | "arrow";
|
|
4947
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
4925
4948
|
selector?: string | undefined;
|
|
4926
4949
|
delay?: number | undefined;
|
|
4927
4950
|
coords?: number[] | undefined;
|
|
@@ -5006,6 +5029,8 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
5006
5029
|
prepare: z.ZodOptional<z.ZodObject<{
|
|
5007
5030
|
/** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
|
|
5008
5031
|
hide: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
5032
|
+
/** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
|
|
5033
|
+
mask: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
5009
5034
|
/** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
|
|
5010
5035
|
freezeTime: z.ZodOptional<z.ZodString>;
|
|
5011
5036
|
/** Math.random을 이 시드의 결정론적 PRNG로 대체. */
|
|
@@ -5057,6 +5082,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
5057
5082
|
}>>;
|
|
5058
5083
|
}, "strip", z.ZodTypeAny, {
|
|
5059
5084
|
hide: string[];
|
|
5085
|
+
mask: string[];
|
|
5060
5086
|
mock: {
|
|
5061
5087
|
status: number;
|
|
5062
5088
|
url: string;
|
|
@@ -5076,6 +5102,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
5076
5102
|
} | undefined;
|
|
5077
5103
|
}, {
|
|
5078
5104
|
hide?: string[] | undefined;
|
|
5105
|
+
mask?: string[] | undefined;
|
|
5079
5106
|
freezeTime?: string | undefined;
|
|
5080
5107
|
seedRandom?: number | undefined;
|
|
5081
5108
|
storage?: {
|
|
@@ -5525,16 +5552,20 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
5525
5552
|
fadeIn: z.ZodDefault<z.ZodNumber>;
|
|
5526
5553
|
/** Fade-out duration in milliseconds. */
|
|
5527
5554
|
fadeOut: z.ZodDefault<z.ZodNumber>;
|
|
5555
|
+
/** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
|
|
5556
|
+
bpm: z.ZodOptional<z.ZodNumber>;
|
|
5528
5557
|
}, "strip", z.ZodTypeAny, {
|
|
5529
5558
|
file: string;
|
|
5530
5559
|
volume: number;
|
|
5531
5560
|
fadeIn: number;
|
|
5532
5561
|
fadeOut: number;
|
|
5562
|
+
bpm?: number | undefined;
|
|
5533
5563
|
}, {
|
|
5534
5564
|
file: string;
|
|
5535
5565
|
volume?: number | undefined;
|
|
5536
5566
|
fadeIn?: number | undefined;
|
|
5537
5567
|
fadeOut?: number | undefined;
|
|
5568
|
+
bpm?: number | undefined;
|
|
5538
5569
|
}>>;
|
|
5539
5570
|
/** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
|
|
5540
5571
|
steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -7712,16 +7743,21 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7712
7743
|
h?: number | undefined;
|
|
7713
7744
|
maxH?: number | undefined;
|
|
7714
7745
|
}>>;
|
|
7715
|
-
/** 푸시인 카메라 (스케일 from→to).
|
|
7746
|
+
/** 푸시인 카메라 (스케일 from→to).
|
|
7747
|
+
* origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
|
|
7748
|
+
* 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
|
|
7716
7749
|
push: z.ZodOptional<z.ZodObject<{
|
|
7717
7750
|
from: z.ZodDefault<z.ZodNumber>;
|
|
7718
7751
|
to: z.ZodDefault<z.ZodNumber>;
|
|
7752
|
+
origin: z.ZodOptional<z.ZodString>;
|
|
7719
7753
|
}, "strip", z.ZodTypeAny, {
|
|
7720
7754
|
from: number;
|
|
7721
7755
|
to: number;
|
|
7756
|
+
origin?: string | undefined;
|
|
7722
7757
|
}, {
|
|
7723
7758
|
from?: number | undefined;
|
|
7724
7759
|
to?: number | undefined;
|
|
7760
|
+
origin?: string | undefined;
|
|
7725
7761
|
}>>;
|
|
7726
7762
|
/** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
|
|
7727
7763
|
start: z.ZodDefault<z.ZodUnion<[z.ZodNumber, z.ZodObject<{
|
|
@@ -7737,7 +7773,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7737
7773
|
/** 푸티지 재생 배속. */
|
|
7738
7774
|
rate: z.ZodDefault<z.ZodNumber>;
|
|
7739
7775
|
fx: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
7740
|
-
kind: z.ZodEnum<["circle", "arrow"]>;
|
|
7776
|
+
kind: z.ZodEnum<["circle", "arrow", "spotlight"]>;
|
|
7741
7777
|
/** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
|
|
7742
7778
|
selector: z.ZodOptional<z.ZodString>;
|
|
7743
7779
|
/** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
|
|
@@ -7746,11 +7782,11 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7746
7782
|
delay: z.ZodDefault<z.ZodNumber>;
|
|
7747
7783
|
}, "strip", z.ZodTypeAny, {
|
|
7748
7784
|
delay: number;
|
|
7749
|
-
kind: "circle" | "arrow";
|
|
7785
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
7750
7786
|
selector?: string | undefined;
|
|
7751
7787
|
coords?: number[] | undefined;
|
|
7752
7788
|
}, {
|
|
7753
|
-
kind: "circle" | "arrow";
|
|
7789
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
7754
7790
|
selector?: string | undefined;
|
|
7755
7791
|
delay?: number | undefined;
|
|
7756
7792
|
coords?: number[] | undefined;
|
|
@@ -7767,13 +7803,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7767
7803
|
rate: number;
|
|
7768
7804
|
fx: {
|
|
7769
7805
|
delay: number;
|
|
7770
|
-
kind: "circle" | "arrow";
|
|
7806
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
7771
7807
|
selector?: string | undefined;
|
|
7772
7808
|
coords?: number[] | undefined;
|
|
7773
7809
|
}[];
|
|
7774
7810
|
push?: {
|
|
7775
7811
|
from: number;
|
|
7776
7812
|
to: number;
|
|
7813
|
+
origin?: string | undefined;
|
|
7777
7814
|
} | undefined;
|
|
7778
7815
|
code?: string[] | undefined;
|
|
7779
7816
|
crop?: {
|
|
@@ -7795,6 +7832,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7795
7832
|
push?: {
|
|
7796
7833
|
from?: number | undefined;
|
|
7797
7834
|
to?: number | undefined;
|
|
7835
|
+
origin?: string | undefined;
|
|
7798
7836
|
} | undefined;
|
|
7799
7837
|
code?: string[] | undefined;
|
|
7800
7838
|
crop?: {
|
|
@@ -7816,7 +7854,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7816
7854
|
} | undefined;
|
|
7817
7855
|
rate?: number | undefined;
|
|
7818
7856
|
fx?: {
|
|
7819
|
-
kind: "circle" | "arrow";
|
|
7857
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
7820
7858
|
selector?: string | undefined;
|
|
7821
7859
|
delay?: number | undefined;
|
|
7822
7860
|
coords?: number[] | undefined;
|
|
@@ -8084,6 +8122,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8084
8122
|
} | undefined;
|
|
8085
8123
|
prepare?: {
|
|
8086
8124
|
hide: string[];
|
|
8125
|
+
mask: string[];
|
|
8087
8126
|
mock: {
|
|
8088
8127
|
status: number;
|
|
8089
8128
|
url: string;
|
|
@@ -8107,6 +8146,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8107
8146
|
volume: number;
|
|
8108
8147
|
fadeIn: number;
|
|
8109
8148
|
fadeOut: number;
|
|
8149
|
+
bpm?: number | undefined;
|
|
8110
8150
|
} | undefined;
|
|
8111
8151
|
scenes?: ({
|
|
8112
8152
|
type: "motion";
|
|
@@ -8282,13 +8322,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8282
8322
|
rate: number;
|
|
8283
8323
|
fx: {
|
|
8284
8324
|
delay: number;
|
|
8285
|
-
kind: "circle" | "arrow";
|
|
8325
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
8286
8326
|
selector?: string | undefined;
|
|
8287
8327
|
coords?: number[] | undefined;
|
|
8288
8328
|
}[];
|
|
8289
8329
|
push?: {
|
|
8290
8330
|
from: number;
|
|
8291
8331
|
to: number;
|
|
8332
|
+
origin?: string | undefined;
|
|
8292
8333
|
} | undefined;
|
|
8293
8334
|
code?: string[] | undefined;
|
|
8294
8335
|
crop?: {
|
|
@@ -8555,6 +8596,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8555
8596
|
} | undefined;
|
|
8556
8597
|
prepare?: {
|
|
8557
8598
|
hide?: string[] | undefined;
|
|
8599
|
+
mask?: string[] | undefined;
|
|
8558
8600
|
freezeTime?: string | undefined;
|
|
8559
8601
|
seedRandom?: number | undefined;
|
|
8560
8602
|
storage?: {
|
|
@@ -8589,6 +8631,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8589
8631
|
volume?: number | undefined;
|
|
8590
8632
|
fadeIn?: number | undefined;
|
|
8591
8633
|
fadeOut?: number | undefined;
|
|
8634
|
+
bpm?: number | undefined;
|
|
8592
8635
|
} | undefined;
|
|
8593
8636
|
scenes?: ({
|
|
8594
8637
|
type: "motion";
|
|
@@ -8759,6 +8802,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8759
8802
|
push?: {
|
|
8760
8803
|
from?: number | undefined;
|
|
8761
8804
|
to?: number | undefined;
|
|
8805
|
+
origin?: string | undefined;
|
|
8762
8806
|
} | undefined;
|
|
8763
8807
|
code?: string[] | undefined;
|
|
8764
8808
|
crop?: {
|
|
@@ -8780,7 +8824,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8780
8824
|
} | undefined;
|
|
8781
8825
|
rate?: number | undefined;
|
|
8782
8826
|
fx?: {
|
|
8783
|
-
kind: "circle" | "arrow";
|
|
8827
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
8784
8828
|
selector?: string | undefined;
|
|
8785
8829
|
delay?: number | undefined;
|
|
8786
8830
|
coords?: number[] | undefined;
|
|
@@ -9048,6 +9092,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9048
9092
|
} | undefined;
|
|
9049
9093
|
prepare?: {
|
|
9050
9094
|
hide: string[];
|
|
9095
|
+
mask: string[];
|
|
9051
9096
|
mock: {
|
|
9052
9097
|
status: number;
|
|
9053
9098
|
url: string;
|
|
@@ -9071,6 +9116,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9071
9116
|
volume: number;
|
|
9072
9117
|
fadeIn: number;
|
|
9073
9118
|
fadeOut: number;
|
|
9119
|
+
bpm?: number | undefined;
|
|
9074
9120
|
} | undefined;
|
|
9075
9121
|
scenes?: ({
|
|
9076
9122
|
type: "motion";
|
|
@@ -9246,13 +9292,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9246
9292
|
rate: number;
|
|
9247
9293
|
fx: {
|
|
9248
9294
|
delay: number;
|
|
9249
|
-
kind: "circle" | "arrow";
|
|
9295
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
9250
9296
|
selector?: string | undefined;
|
|
9251
9297
|
coords?: number[] | undefined;
|
|
9252
9298
|
}[];
|
|
9253
9299
|
push?: {
|
|
9254
9300
|
from: number;
|
|
9255
9301
|
to: number;
|
|
9302
|
+
origin?: string | undefined;
|
|
9256
9303
|
} | undefined;
|
|
9257
9304
|
code?: string[] | undefined;
|
|
9258
9305
|
crop?: {
|
|
@@ -9519,6 +9566,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9519
9566
|
} | undefined;
|
|
9520
9567
|
prepare?: {
|
|
9521
9568
|
hide?: string[] | undefined;
|
|
9569
|
+
mask?: string[] | undefined;
|
|
9522
9570
|
freezeTime?: string | undefined;
|
|
9523
9571
|
seedRandom?: number | undefined;
|
|
9524
9572
|
storage?: {
|
|
@@ -9553,6 +9601,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9553
9601
|
volume?: number | undefined;
|
|
9554
9602
|
fadeIn?: number | undefined;
|
|
9555
9603
|
fadeOut?: number | undefined;
|
|
9604
|
+
bpm?: number | undefined;
|
|
9556
9605
|
} | undefined;
|
|
9557
9606
|
scenes?: ({
|
|
9558
9607
|
type: "motion";
|
|
@@ -9723,6 +9772,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9723
9772
|
push?: {
|
|
9724
9773
|
from?: number | undefined;
|
|
9725
9774
|
to?: number | undefined;
|
|
9775
|
+
origin?: string | undefined;
|
|
9726
9776
|
} | undefined;
|
|
9727
9777
|
code?: string[] | undefined;
|
|
9728
9778
|
crop?: {
|
|
@@ -9744,7 +9794,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9744
9794
|
} | undefined;
|
|
9745
9795
|
rate?: number | undefined;
|
|
9746
9796
|
fx?: {
|
|
9747
|
-
kind: "circle" | "arrow";
|
|
9797
|
+
kind: "circle" | "arrow" | "spotlight";
|
|
9748
9798
|
selector?: string | undefined;
|
|
9749
9799
|
delay?: number | undefined;
|
|
9750
9800
|
coords?: number[] | undefined;
|
package/dist/index.js
CHANGED
|
@@ -62,6 +62,12 @@ function buildHideCss(selectors) {
|
|
|
62
62
|
visibility: hidden !important;
|
|
63
63
|
}`;
|
|
64
64
|
}
|
|
65
|
+
function buildMaskCss(selectors) {
|
|
66
|
+
return `${selectors.join(",\n")} {
|
|
67
|
+
filter: blur(10px) !important;
|
|
68
|
+
border-radius: 4px;
|
|
69
|
+
}`;
|
|
70
|
+
}
|
|
65
71
|
function buildCssInjectionScript(css) {
|
|
66
72
|
return `(() => {
|
|
67
73
|
const apply = () => {
|
|
@@ -139,6 +145,9 @@ async function applyPrepare(context, prepare) {
|
|
|
139
145
|
if (prepare.hide.length > 0) {
|
|
140
146
|
cssChunks.push(buildHideCss(prepare.hide));
|
|
141
147
|
}
|
|
148
|
+
if (prepare.mask.length > 0) {
|
|
149
|
+
cssChunks.push(buildMaskCss(prepare.mask));
|
|
150
|
+
}
|
|
142
151
|
if (prepare.inject?.css) {
|
|
143
152
|
const cssFiles = Array.isArray(prepare.inject.css) ? prepare.inject.css : [prepare.inject.css];
|
|
144
153
|
for (const file of cssFiles) {
|
|
@@ -3700,7 +3709,7 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
|
3700
3709
|
await browser.close();
|
|
3701
3710
|
return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
|
|
3702
3711
|
}
|
|
3703
|
-
function vignetteProps(scene, take, serverBase, scenario, brand) {
|
|
3712
|
+
function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
3704
3713
|
const W = scenario.viewport.width;
|
|
3705
3714
|
const H = scenario.viewport.height;
|
|
3706
3715
|
let crop = { x: 0, y: 0, w: W, h: H };
|
|
@@ -3724,7 +3733,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
|
|
|
3724
3733
|
const props = {
|
|
3725
3734
|
accent: brand.accent,
|
|
3726
3735
|
font: brand.font,
|
|
3727
|
-
dur:
|
|
3736
|
+
dur: durMs / 1e3,
|
|
3728
3737
|
layout: scene.layout,
|
|
3729
3738
|
num: scene.num ?? "",
|
|
3730
3739
|
label: scene.label ?? "",
|
|
@@ -3743,13 +3752,19 @@ function vignetteProps(scene, take, serverBase, scenario, brand) {
|
|
|
3743
3752
|
pushTo: scene.push?.to ?? 1
|
|
3744
3753
|
};
|
|
3745
3754
|
if (scene.code?.length) props.code = scene.code.join("||");
|
|
3755
|
+
if (scene.push?.origin) {
|
|
3756
|
+
const box = take.boxes.get(scene.push.origin);
|
|
3757
|
+
if (!box) throw new Error(`vignette push.origin selector "${scene.push.origin}" not found in footage "${scene.footage}"`);
|
|
3758
|
+
props.pushOx = Math.max(0, Math.min(100, (box.x + box.width / 2 - crop.x) / crop.w * 100)).toFixed(1);
|
|
3759
|
+
props.pushOy = Math.max(0, Math.min(100, (box.y + box.height / 2 - crop.y) / crop.h * 100)).toFixed(1);
|
|
3760
|
+
}
|
|
3746
3761
|
if (brand.annotations && scene.fx.length > 0) {
|
|
3747
3762
|
props.fx = scene.fx.map((fx) => {
|
|
3748
3763
|
let coords = fx.coords;
|
|
3749
3764
|
if (fx.selector) {
|
|
3750
3765
|
const box = take.boxes.get(fx.selector);
|
|
3751
3766
|
if (!box) throw new Error(`vignette fx selector "${fx.selector}" not found in footage "${scene.footage}"`);
|
|
3752
|
-
coords = fx.kind === "
|
|
3767
|
+
coords = fx.kind === "arrow" ? [box.x - 160, box.y + 120, box.x - 12, box.y + box.height / 2] : [box.x, box.y, box.width, box.height];
|
|
3753
3768
|
}
|
|
3754
3769
|
return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
|
|
3755
3770
|
}).join(";");
|
|
@@ -3766,6 +3781,7 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3766
3781
|
if (scene.type !== "vignette") continue;
|
|
3767
3782
|
const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
|
|
3768
3783
|
if (scene.crop?.selector) set.add(scene.crop.selector);
|
|
3784
|
+
if (scene.push?.origin) set.add(scene.push.origin);
|
|
3769
3785
|
for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
|
|
3770
3786
|
selectorsByFootage.set(scene.footage, set);
|
|
3771
3787
|
}
|
|
@@ -3791,27 +3807,32 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3791
3807
|
});
|
|
3792
3808
|
await new Promise((r) => server.listen(0, r));
|
|
3793
3809
|
const port = server.address().port;
|
|
3794
|
-
const
|
|
3810
|
+
const beatMs = scenario.audio?.bpm ? 6e4 / scenario.audio.bpm : 0;
|
|
3811
|
+
const durations = timeline.map(
|
|
3812
|
+
(sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
|
|
3813
|
+
);
|
|
3814
|
+
const totalMs = durations.reduce((s, d) => s + d, 0);
|
|
3795
3815
|
let elapsedMs = 0;
|
|
3796
3816
|
const segments = [];
|
|
3797
3817
|
const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
|
|
3798
3818
|
try {
|
|
3799
3819
|
for (let i = 0; i < timeline.length; i++) {
|
|
3800
3820
|
const scene = timeline[i];
|
|
3821
|
+
const dur = durations[i];
|
|
3801
3822
|
const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
|
|
3802
3823
|
onProgress?.({ scene: i + 1, total: timeline.length, label });
|
|
3803
3824
|
const thread = brand.annotations ? {
|
|
3804
3825
|
threadFrom: (elapsedMs / totalMs).toFixed(4),
|
|
3805
|
-
threadTo: ((elapsedMs +
|
|
3826
|
+
threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
|
|
3806
3827
|
} : {};
|
|
3807
|
-
elapsedMs +=
|
|
3828
|
+
elapsedMs += dur;
|
|
3808
3829
|
let segment;
|
|
3809
3830
|
if (scene.type === "motion") {
|
|
3810
3831
|
const url = resolveMotionTemplate(scene.template, scenarioDir);
|
|
3811
3832
|
segment = await captureMotionSegment(
|
|
3812
3833
|
url,
|
|
3813
|
-
{ accent: brand.accent, font: brand.font, dur:
|
|
3814
|
-
|
|
3834
|
+
{ accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
|
|
3835
|
+
dur,
|
|
3815
3836
|
scenario
|
|
3816
3837
|
);
|
|
3817
3838
|
} else {
|
|
@@ -3819,8 +3840,8 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3819
3840
|
const url = resolveMotionTemplate("vignette", scenarioDir);
|
|
3820
3841
|
segment = await captureMotionSegment(
|
|
3821
3842
|
url,
|
|
3822
|
-
{ ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
|
|
3823
|
-
|
|
3843
|
+
{ ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur), ...thread },
|
|
3844
|
+
dur,
|
|
3824
3845
|
scenario
|
|
3825
3846
|
);
|
|
3826
3847
|
}
|
|
@@ -3834,8 +3855,21 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3834
3855
|
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
3835
3856
|
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
3836
3857
|
const outPath = join2(tmp, "timeline.mp4");
|
|
3858
|
+
let audioInput = "";
|
|
3859
|
+
let audioMap = "";
|
|
3860
|
+
if (scenario.audio) {
|
|
3861
|
+
const a = scenario.audio;
|
|
3862
|
+
const audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
|
|
3863
|
+
const totalSec = totalMs / 1e3;
|
|
3864
|
+
const af = [];
|
|
3865
|
+
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
3866
|
+
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
3867
|
+
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
3868
|
+
audioInput = `-i "${audioPath}" `;
|
|
3869
|
+
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
|
|
3870
|
+
}
|
|
3837
3871
|
execSync(
|
|
3838
|
-
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} -filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
3872
|
+
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
3839
3873
|
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
3840
3874
|
);
|
|
3841
3875
|
const buffer = await readFile3(outPath);
|
|
@@ -4129,7 +4163,9 @@ var AudioConfigSchema = z.object({
|
|
|
4129
4163
|
/** Fade-in duration in milliseconds. */
|
|
4130
4164
|
fadeIn: z.number().min(0).default(0),
|
|
4131
4165
|
/** Fade-out duration in milliseconds. */
|
|
4132
|
-
fadeOut: z.number().min(0).default(0)
|
|
4166
|
+
fadeOut: z.number().min(0).default(0),
|
|
4167
|
+
/** 트랙 BPM — scenes 타임라인에서 지정 시 신 길이를 비트 격자에 스냅(비트 싱크 컷). */
|
|
4168
|
+
bpm: z.number().min(40).max(220).optional()
|
|
4133
4169
|
});
|
|
4134
4170
|
var AuthConfigSchema = z.object({
|
|
4135
4171
|
/** Path to a Playwright storageState JSON file (cookies + localStorage). */
|
|
@@ -4160,6 +4196,8 @@ var MockRouteSchema = z.object({
|
|
|
4160
4196
|
var PrepareConfigSchema = z.object({
|
|
4161
4197
|
/** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
|
|
4162
4198
|
hide: z.array(z.string().min(1)).default([]),
|
|
4199
|
+
/** 블러 처리할 요소의 CSS 셀렉터 (이메일, 금액 등 민감 정보) — 스크롤·이동을 따라간다. */
|
|
4200
|
+
mask: z.array(z.string().min(1)).default([]),
|
|
4163
4201
|
/** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
|
|
4164
4202
|
freezeTime: z.string().optional(),
|
|
4165
4203
|
/** Math.random을 이 시드의 결정론적 PRNG로 대체. */
|
|
@@ -4193,7 +4231,7 @@ var ScreenSceneSchema = z.object({
|
|
|
4193
4231
|
steps: z.array(StepSchema).min(1)
|
|
4194
4232
|
});
|
|
4195
4233
|
var SceneFxSchema = z.object({
|
|
4196
|
-
kind: z.enum(["circle", "arrow"]),
|
|
4234
|
+
kind: z.enum(["circle", "arrow", "spotlight"]),
|
|
4197
4235
|
/** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
|
|
4198
4236
|
selector: z.string().optional(),
|
|
4199
4237
|
/** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
|
|
@@ -4223,8 +4261,14 @@ var VignetteSceneSchema = z.object({
|
|
|
4223
4261
|
/** 크롭 높이 상한 (px, 원본 기준) — 와이드 스트립 연출용. */
|
|
4224
4262
|
maxH: z.number().optional()
|
|
4225
4263
|
}).optional(),
|
|
4226
|
-
/** 푸시인 카메라 (스케일 from→to).
|
|
4227
|
-
|
|
4264
|
+
/** 푸시인 카메라 (스케일 from→to).
|
|
4265
|
+
* origin: 푸시 중심점 — 셀렉터(실측) 지정 시 그 요소를 향해 밀어 들어가
|
|
4266
|
+
* 다음 신의 크롭과 이어지는 매치컷을 만든다. 기본은 화면 중앙 약간 위. */
|
|
4267
|
+
push: z.object({
|
|
4268
|
+
from: z.number().default(1),
|
|
4269
|
+
to: z.number().default(1),
|
|
4270
|
+
origin: z.string().optional()
|
|
4271
|
+
}).optional(),
|
|
4228
4272
|
/** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
|
|
4229
4273
|
start: z.union([z.number(), z.object({ step: z.number().int().min(0), offset: z.number().default(0) })]).default(0),
|
|
4230
4274
|
/** 푸티지 재생 배속. */
|
package/package.json
CHANGED
package/skills/clipwise.md
CHANGED
|
@@ -375,12 +375,14 @@ scenes:
|
|
|
375
375
|
label: "Smart Speed"
|
|
376
376
|
caption: "Loading compressed, *results crisp*"
|
|
377
377
|
crop: { selector: ".panel", pad: 14, maxH: 250 } # selector-measured, never guess pixels
|
|
378
|
-
push: { from: 1.05, to: 1 }
|
|
378
|
+
push: { from: 1.05, to: 1, origin: ".panel" } # origin: match-cut — push toward
|
|
379
|
+
# a selector so the NEXT scene's crop continues the move
|
|
379
380
|
start: { step: 3, offset: 0 } # quote footage from a step boundary (or seconds)
|
|
380
381
|
rate: 1.15 # playback speed of the quoted footage
|
|
381
|
-
fx: #
|
|
382
|
-
- { kind: circle, selector: "#revenue", delay: 2500 }
|
|
383
|
-
- { kind: arrow, selector: ".panel", delay: 2900 }
|
|
382
|
+
fx: # annotations on the footage
|
|
383
|
+
- { kind: circle, selector: "#revenue", delay: 2500 } # hand-drawn circle
|
|
384
|
+
- { kind: arrow, selector: ".panel", delay: 2900 } # drawn arrow
|
|
385
|
+
- { kind: spotlight, selector: "#revenue", delay: 2400 } # dim everything else
|
|
384
386
|
# code: ["prepare:", " hide: [...]"] # split layout left code card
|
|
385
387
|
```
|
|
386
388
|
|
|
@@ -395,6 +397,10 @@ scenes:
|
|
|
395
397
|
5. **Footage effects**: in scenes mode set only `cursor:` (highlight: false) — zoom/frame/background
|
|
396
398
|
are handled by the vignette compositor, not the recorder
|
|
397
399
|
6. Keep one screen take (~12-15s) and let vignettes quote segments via `start: { step: N }`
|
|
400
|
+
7. **Sensitive data**: `prepare.mask: [".email", ".amount"]` blurs elements at record time
|
|
401
|
+
(follows scrolling — never ask the user to fake their data)
|
|
402
|
+
8. **Music**: `audio: { file: bgm.mp3, bpm: 122, fadeOut: 1500 }` muxes BGM into the final
|
|
403
|
+
video AND snaps every scene cut onto the beat grid (beat-synced cuts)
|
|
398
404
|
|
|
399
405
|
## Critical Rules
|
|
400
406
|
|
|
@@ -113,6 +113,15 @@
|
|
|
113
113
|
/* ── 선 드로잉 주석 레이어 (fx=circle@…;arrow@…) — 카드 위에 그려짐 ── */
|
|
114
114
|
.annotations { position: absolute; inset: 0; z-index: 5; pointer-events: none;
|
|
115
115
|
overflow: visible; }
|
|
116
|
+
/* 스포트라이트 — 대상만 남기고 주변을 디밍 (fx kind: spotlight) */
|
|
117
|
+
.spotlight { position: absolute; z-index: 4; pointer-events: none;
|
|
118
|
+
/* spread가 너무 크면 Chromium이 그림자를 클램프하므로 카드 크기 수준으로 */
|
|
119
|
+
box-shadow: 0 0 0 1400px rgba(12, 12, 16, 0.52); border-radius: 10px;
|
|
120
|
+
/* 다크 UI에서도 컷아웃이 인지되도록 은은한 악센트 림 */
|
|
121
|
+
outline: 1.5px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
|
122
|
+
outline-offset: 3px;
|
|
123
|
+
opacity: 0; animation: spot-in 550ms cubic-bezier(0.4, 0, 0.2, 1) both; }
|
|
124
|
+
@keyframes spot-in { to { opacity: 1; } }
|
|
116
125
|
.annotations .stroke { stroke: var(--accent); fill: none; stroke-width: 3.5;
|
|
117
126
|
stroke-linecap: round; stroke-linejoin: round;
|
|
118
127
|
stroke-dasharray: 1; stroke-dashoffset: 1;
|
|
@@ -178,9 +187,12 @@
|
|
|
178
187
|
card.style.setProperty("--push-from", P.get("pushFrom") || "1");
|
|
179
188
|
card.style.setProperty("--push-to", P.get("pushTo") || "1");
|
|
180
189
|
const fullCrop = cw >= SRC_W;
|
|
190
|
+
// push origin — 매치컷: 다음 신의 크롭 중심을 향해 밀어 들어가도록 중심점 지정
|
|
191
|
+
const pushOx = num("pushOx", 50), pushOy = num("pushOy", 42);
|
|
181
192
|
card.innerHTML = `
|
|
182
193
|
${fullCrop ? '<div class="titlebar"><i></i><i></i><i></i></div>' : ""}
|
|
183
|
-
<div class="viewport"><div class="pusher"
|
|
194
|
+
<div class="viewport"><div class="pusher"
|
|
195
|
+
style="animation-duration:${num("dur", 4)}s; transform-origin:${pushOx}% ${pushOy}%">
|
|
184
196
|
<img class="footage" id="footage"
|
|
185
197
|
style="width:${SRC_W * scale}px;height:${SRC_H * scale}px;left:${-cx * scale}px;top:${-cy * scale}px" />
|
|
186
198
|
</div></div>`;
|
|
@@ -224,6 +236,15 @@
|
|
|
224
236
|
for (const spec of fxSpecs) {
|
|
225
237
|
const [kind, coords, delay = "0"] = spec.split("@");
|
|
226
238
|
const n = coords.split(",").map(Number);
|
|
239
|
+
if (kind === "spotlight") {
|
|
240
|
+
// 푸시 레이어 안에 디밍 컷아웃 — 푸티지에 앵커링되어 카메라를 따라간다
|
|
241
|
+
const [sx, sy] = toCard(n[0], n[1]);
|
|
242
|
+
const spot = document.createElement("div");
|
|
243
|
+
spot.className = "spotlight";
|
|
244
|
+
spot.style.cssText = `left:${sx - 6}px;top:${sy - 6}px;width:${n[2] * effScale + 12}px;height:${n[3] * effScale + 12}px;animation-delay:${delay}ms`;
|
|
245
|
+
card.querySelector(".pusher").appendChild(spot);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
227
248
|
if (kind === "circle") {
|
|
228
249
|
// 손그림 느낌: 살짝 큰 타원 + 미세 회전
|
|
229
250
|
const [x0, y0] = toCard(n[0], n[1]);
|