clipwise 0.10.1 → 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 +120 -58
- package/dist/index.d.ts +59 -2
- package/dist/index.js +111 -55
- package/package.json +1 -1
- package/skills/clipwise.md +4 -0
- package/templates/motion/kinetic-type.html +45 -3
- package/templates/motion/vignette.html +43 -2
package/dist/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ var __export = (target, all) => {
|
|
|
11
11
|
|
|
12
12
|
// src/script/types.ts
|
|
13
13
|
import { z } from "zod";
|
|
14
|
-
var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, SmartWaitActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, SmartSpeedConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepEffectsOverrideSchema, TransitionTypeSchema, StepSchema, AudioConfigSchema, AuthConfigSchema, MockRouteSchema, PrepareConfigSchema, MotionSceneSchema, ScreenSceneSchema, SceneFxSchema, VignetteSceneSchema, SceneSchema, ScenarioSchema;
|
|
14
|
+
var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, SmartWaitActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, SmartSpeedConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepEffectsOverrideSchema, TransitionTypeSchema, StepSchema, AudioConfigSchema, AuthConfigSchema, MockRouteSchema, PrepareConfigSchema, MotionSceneSchema, ScreenSceneSchema, SceneFxSchema, VignetteSceneSchema, SceneSchema, CaptionSchema, ScenarioSchema;
|
|
15
15
|
var init_types = __esm({
|
|
16
16
|
"src/script/types.ts"() {
|
|
17
17
|
"use strict";
|
|
@@ -257,7 +257,10 @@ var init_types = __esm({
|
|
|
257
257
|
codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
|
|
258
258
|
// Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
|
|
259
259
|
outputDir: z.string().default(".clipwise/output"),
|
|
260
|
-
filename: z.string().default("clipwise-recording")
|
|
260
|
+
filename: z.string().default("clipwise-recording"),
|
|
261
|
+
/** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
|
|
262
|
+
* 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
|
|
263
|
+
aspects: z.array(z.enum(["16:9", "9:16", "1:1"])).default([])
|
|
261
264
|
});
|
|
262
265
|
StepEffectsOverrideSchema = z.object({
|
|
263
266
|
zoom: ZoomEffectSchema.partial().optional(),
|
|
@@ -410,6 +413,11 @@ var init_types = __esm({
|
|
|
410
413
|
ScreenSceneSchema,
|
|
411
414
|
VignetteSceneSchema
|
|
412
415
|
]);
|
|
416
|
+
CaptionSchema = z.object({
|
|
417
|
+
text: z.string().min(1),
|
|
418
|
+
start: z.number().min(0),
|
|
419
|
+
end: z.number().min(0)
|
|
420
|
+
});
|
|
413
421
|
ScenarioSchema = z.object({
|
|
414
422
|
name: z.string(),
|
|
415
423
|
description: z.string().optional(),
|
|
@@ -430,7 +438,9 @@ var init_types = __esm({
|
|
|
430
438
|
/** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
|
|
431
439
|
steps: z.array(StepSchema).default([]),
|
|
432
440
|
/** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
|
|
433
|
-
scenes: z.array(SceneSchema).optional()
|
|
441
|
+
scenes: z.array(SceneSchema).optional(),
|
|
442
|
+
/** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
|
|
443
|
+
captions: z.array(CaptionSchema).default([])
|
|
434
444
|
}).superRefine((s, ctx) => {
|
|
435
445
|
if (s.steps.length === 0 && !s.scenes?.length) {
|
|
436
446
|
ctx.addIssue({
|
|
@@ -4085,12 +4095,12 @@ async function executeStepsForProbe(page, scene) {
|
|
|
4085
4095
|
}
|
|
4086
4096
|
}
|
|
4087
4097
|
}
|
|
4088
|
-
function segmentOutput(scenario) {
|
|
4098
|
+
function segmentOutput(scenario, stageW, stageH) {
|
|
4089
4099
|
const dpr = scenario.viewport.deviceScaleFactor ?? 1;
|
|
4090
4100
|
return {
|
|
4091
4101
|
...scenario.output,
|
|
4092
|
-
width:
|
|
4093
|
-
height:
|
|
4102
|
+
width: stageW * dpr,
|
|
4103
|
+
height: stageH * dpr,
|
|
4094
4104
|
preset: "archive"
|
|
4095
4105
|
};
|
|
4096
4106
|
}
|
|
@@ -4119,7 +4129,11 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
4119
4129
|
};
|
|
4120
4130
|
const recorder = new ClipwiseRecorder();
|
|
4121
4131
|
const session = await recorder.record(takeScenario);
|
|
4122
|
-
const renderer = new CanvasRenderer(
|
|
4132
|
+
const renderer = new CanvasRenderer(
|
|
4133
|
+
takeScenario.effects,
|
|
4134
|
+
segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
|
|
4135
|
+
scene.steps
|
|
4136
|
+
);
|
|
4123
4137
|
const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
|
|
4124
4138
|
let count = 0;
|
|
4125
4139
|
for await (const f of renderer.composeStream(session.frames)) {
|
|
@@ -4139,12 +4153,12 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
4139
4153
|
function fitCardW(cw, ch, maxW = 940, maxH = 540) {
|
|
4140
4154
|
return Math.round(Math.min(maxW, maxH * cw / ch));
|
|
4141
4155
|
}
|
|
4142
|
-
async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
4156
|
+
async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
|
|
4143
4157
|
const fps = scenario.output.fps;
|
|
4144
4158
|
const totalFrames = Math.round(durationMs / 1e3 * fps);
|
|
4145
4159
|
const browser = await chromium2.launch();
|
|
4146
4160
|
const page = await browser.newPage({
|
|
4147
|
-
viewport: { width:
|
|
4161
|
+
viewport: { width: stageW, height: stageH },
|
|
4148
4162
|
deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
|
|
4149
4163
|
});
|
|
4150
4164
|
const params = new URLSearchParams(
|
|
@@ -4162,9 +4176,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
|
4162
4176
|
frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
|
|
4163
4177
|
}
|
|
4164
4178
|
await browser.close();
|
|
4165
|
-
return {
|
|
4179
|
+
return {
|
|
4180
|
+
buffer: await encodeMp4(frames, segmentOutput(scenario, stageW, stageH)),
|
|
4181
|
+
seconds: totalFrames / fps
|
|
4182
|
+
};
|
|
4166
4183
|
}
|
|
4167
|
-
function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
4184
|
+
function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
|
|
4168
4185
|
const W = scenario.viewport.width;
|
|
4169
4186
|
const H = scenario.viewport.height;
|
|
4170
4187
|
let crop = { x: 0, y: 0, w: W, h: H };
|
|
@@ -4202,7 +4219,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
|
4202
4219
|
cropY: crop.y,
|
|
4203
4220
|
cropW: crop.w,
|
|
4204
4221
|
cropH: crop.h,
|
|
4205
|
-
|
|
4222
|
+
// 카드 크기 상한은 무대(stage) 크기 비례 — 어떤 출력 비율에서도 일관된 여백
|
|
4223
|
+
cardW: fitCardW(crop.w, crop.h, Math.round(stageW * 0.735), Math.round(stageH * 0.675)),
|
|
4206
4224
|
pushFrom: scene.push?.from ?? 1,
|
|
4207
4225
|
pushTo: scene.push?.to ?? 1
|
|
4208
4226
|
};
|
|
@@ -4272,19 +4290,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4272
4290
|
(sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
|
|
4273
4291
|
);
|
|
4274
4292
|
const totalMs = durations.reduce((s, d) => s + d, 0);
|
|
4275
|
-
|
|
4276
|
-
|
|
4293
|
+
const totalSec = totalMs / 1e3;
|
|
4294
|
+
let audioPath = null;
|
|
4295
|
+
if (scenario.audio) {
|
|
4296
|
+
const a = scenario.audio;
|
|
4297
|
+
if (/^https?:\/\//.test(a.file)) {
|
|
4298
|
+
const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
|
|
4299
|
+
audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
|
|
4300
|
+
if (!existsSync2(audioPath)) {
|
|
4301
|
+
execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
|
|
4302
|
+
}
|
|
4303
|
+
} else {
|
|
4304
|
+
audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4277
4307
|
const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
|
|
4278
|
-
|
|
4308
|
+
const renderPass = async (stageW, stageH, passLabel) => {
|
|
4309
|
+
let elapsedMs = 0;
|
|
4310
|
+
const segments = [];
|
|
4279
4311
|
for (let i = 0; i < timeline.length; i++) {
|
|
4280
4312
|
const scene = timeline[i];
|
|
4281
4313
|
const dur = durations[i];
|
|
4282
|
-
const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
|
|
4314
|
+
const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
|
|
4283
4315
|
onProgress?.({ scene: i + 1, total: timeline.length, label });
|
|
4284
4316
|
const thread = brand.annotations ? {
|
|
4285
4317
|
threadFrom: (elapsedMs / totalMs).toFixed(4),
|
|
4286
4318
|
threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
|
|
4287
4319
|
} : {};
|
|
4320
|
+
if (scenario.captions.length > 0) {
|
|
4321
|
+
thread.capsJson = JSON.stringify(scenario.captions);
|
|
4322
|
+
thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
|
|
4323
|
+
}
|
|
4288
4324
|
elapsedMs += dur;
|
|
4289
4325
|
let segment;
|
|
4290
4326
|
if (scene.type === "motion") {
|
|
@@ -4293,63 +4329,70 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4293
4329
|
url,
|
|
4294
4330
|
{ accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
|
|
4295
4331
|
dur,
|
|
4296
|
-
scenario
|
|
4332
|
+
scenario,
|
|
4333
|
+
stageW,
|
|
4334
|
+
stageH
|
|
4297
4335
|
);
|
|
4298
4336
|
} else {
|
|
4299
4337
|
const take = takes.get(scene.footage);
|
|
4300
4338
|
const url = resolveMotionTemplate("vignette", scenarioDir);
|
|
4301
4339
|
segment = await captureMotionSegment(
|
|
4302
4340
|
url,
|
|
4303
|
-
{
|
|
4341
|
+
{
|
|
4342
|
+
...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur, stageW, stageH),
|
|
4343
|
+
...thread
|
|
4344
|
+
},
|
|
4304
4345
|
dur,
|
|
4305
|
-
scenario
|
|
4346
|
+
scenario,
|
|
4347
|
+
stageW,
|
|
4348
|
+
stageH
|
|
4306
4349
|
);
|
|
4307
4350
|
}
|
|
4308
|
-
const segPath = join2(tmp,
|
|
4351
|
+
const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
|
|
4309
4352
|
await writeFile2(segPath, segment.buffer);
|
|
4310
4353
|
segments.push({ path: segPath, seconds: segment.seconds });
|
|
4311
4354
|
}
|
|
4355
|
+
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
4356
|
+
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
4357
|
+
const vChain = `${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]`;
|
|
4358
|
+
const finalLabel = "[v]";
|
|
4359
|
+
let audioInput = "";
|
|
4360
|
+
let audioMap = "";
|
|
4361
|
+
if (scenario.audio && audioPath) {
|
|
4362
|
+
const a = scenario.audio;
|
|
4363
|
+
const af = [];
|
|
4364
|
+
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
4365
|
+
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
4366
|
+
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
4367
|
+
audioInput = `-stream_loop -1 -i "${audioPath}" `;
|
|
4368
|
+
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
|
|
4369
|
+
}
|
|
4370
|
+
const outPath = join2(tmp, `${passLabel.replace(/\W/g, "")}timeline.mp4`);
|
|
4371
|
+
execSync(
|
|
4372
|
+
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${vChain}" -map "${finalLabel}" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
4373
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
4374
|
+
);
|
|
4375
|
+
return readFile4(outPath);
|
|
4376
|
+
};
|
|
4377
|
+
try {
|
|
4378
|
+
const buffer = await renderPass(scenario.viewport.width, scenario.viewport.height, "");
|
|
4379
|
+
const extras = [];
|
|
4380
|
+
for (const aspect of scenario.output.aspects) {
|
|
4381
|
+
const [w, h] = ASPECT_DIMS[aspect];
|
|
4382
|
+
extras.push({ label: aspect.replace(":", "x"), buffer: await renderPass(w, h, `${aspect} \xB7 `) });
|
|
4383
|
+
}
|
|
4384
|
+
return { buffer, extras };
|
|
4312
4385
|
} finally {
|
|
4313
4386
|
server.close();
|
|
4314
|
-
|
|
4315
|
-
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
4316
|
-
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
4317
|
-
const outPath = join2(tmp, "timeline.mp4");
|
|
4318
|
-
let audioInput = "";
|
|
4319
|
-
let audioMap = "";
|
|
4320
|
-
const totalSec = totalMs / 1e3;
|
|
4321
|
-
if (scenario.audio) {
|
|
4322
|
-
const a = scenario.audio;
|
|
4323
|
-
let audioPath;
|
|
4324
|
-
if (/^https?:\/\//.test(a.file)) {
|
|
4325
|
-
const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
|
|
4326
|
-
audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
|
|
4327
|
-
if (!existsSync2(audioPath)) {
|
|
4328
|
-
execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
|
|
4329
|
-
}
|
|
4330
|
-
} else {
|
|
4331
|
-
audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
|
|
4332
|
-
}
|
|
4333
|
-
const af = [];
|
|
4334
|
-
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
4335
|
-
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
4336
|
-
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
4337
|
-
audioInput = `-stream_loop -1 -i "${audioPath}" `;
|
|
4338
|
-
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
|
|
4339
|
-
}
|
|
4340
|
-
execSync(
|
|
4341
|
-
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
4342
|
-
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
4343
|
-
);
|
|
4344
|
-
const buffer = await readFile4(outPath);
|
|
4345
|
-
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
4346
|
-
});
|
|
4347
|
-
for (const take of takes.values()) {
|
|
4348
|
-
await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
|
|
4387
|
+
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
4349
4388
|
});
|
|
4389
|
+
for (const take of takes.values()) {
|
|
4390
|
+
await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4350
4393
|
}
|
|
4351
|
-
return buffer;
|
|
4352
4394
|
}
|
|
4395
|
+
var ASPECT_DIMS;
|
|
4353
4396
|
var init_runner = __esm({
|
|
4354
4397
|
"src/scenes/runner.ts"() {
|
|
4355
4398
|
"use strict";
|
|
@@ -4357,6 +4400,11 @@ var init_runner = __esm({
|
|
|
4357
4400
|
init_prepare();
|
|
4358
4401
|
init_canvas_renderer();
|
|
4359
4402
|
init_video_encoder();
|
|
4403
|
+
ASPECT_DIMS = {
|
|
4404
|
+
"16:9": [1280, 720],
|
|
4405
|
+
"9:16": [720, 1280],
|
|
4406
|
+
"1:1": [960, 960]
|
|
4407
|
+
};
|
|
4360
4408
|
}
|
|
4361
4409
|
});
|
|
4362
4410
|
|
|
@@ -4402,6 +4450,15 @@ function validateScenario(scenario) {
|
|
|
4402
4450
|
}
|
|
4403
4451
|
}
|
|
4404
4452
|
}
|
|
4453
|
+
for (let i = 0; i < scenario.captions.length; i++) {
|
|
4454
|
+
const c = scenario.captions[i];
|
|
4455
|
+
if (c.end <= c.start) {
|
|
4456
|
+
errors.push(`captions #${i + 1} ("${c.text.slice(0, 20)}"): end must be greater than start`);
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4459
|
+
if (scenario.captions.length > 0 && !scenario.scenes?.length) {
|
|
4460
|
+
warnings.push("captions are only rendered in scenes timelines (ignored for classic steps recordings)");
|
|
4461
|
+
}
|
|
4405
4462
|
if (scenario.steps.length > 0) {
|
|
4406
4463
|
const firstStep = scenario.steps[0];
|
|
4407
4464
|
const hasNavigate = firstStep.actions.some(
|
|
@@ -4585,7 +4642,7 @@ import { homedir } from "os";
|
|
|
4585
4642
|
var program = new Command();
|
|
4586
4643
|
program.name("clipwise").description(
|
|
4587
4644
|
"Playwright-based cinematic screen recorder for product demos"
|
|
4588
|
-
).version("0.
|
|
4645
|
+
).version("0.11.0");
|
|
4589
4646
|
program.command("record").description("Record a demo from a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").option("-o, --output <dir>", "Output directory (default: scenario outputDir or .clipwise/output)").option(
|
|
4590
4647
|
"-f, --format <format>",
|
|
4591
4648
|
"Output format (gif|mp4|png-sequence)"
|
|
@@ -4649,12 +4706,17 @@ program.command("record").description("Record a demo from a YAML scenario file")
|
|
|
4649
4706
|
const outDir2 = scenario.output.outputDir;
|
|
4650
4707
|
await mkdir2(outDir2, { recursive: true });
|
|
4651
4708
|
spinner.start(`Rendering ${scenario.scenes.length}-scene timeline...`);
|
|
4652
|
-
const buf = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
|
|
4709
|
+
const { buffer: buf, extras } = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
|
|
4653
4710
|
spinner.text = scene === 0 ? `Recording footage \u2014 ${label}...` : `Rendering scene ${scene}/${total} \u2014 ${label}...`;
|
|
4654
4711
|
});
|
|
4655
4712
|
const outputPath = join3(outDir2, `${scenario.output.filename}.mp4`);
|
|
4656
4713
|
await writeFile3(outputPath, buf);
|
|
4657
4714
|
spinner.succeed(`Timeline saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
4715
|
+
for (const extra of extras) {
|
|
4716
|
+
const extraPath = join3(outDir2, `${scenario.output.filename}-${extra.label}.mp4`);
|
|
4717
|
+
await writeFile3(extraPath, extra.buffer);
|
|
4718
|
+
spinner.succeed(` + ${chalk.bold(extraPath)} (${(extra.buffer.length / 1048576).toFixed(2)} MB)`);
|
|
4719
|
+
}
|
|
4658
4720
|
console.log(chalk.green("\nDone! \u{1F3AC}"));
|
|
4659
4721
|
return;
|
|
4660
4722
|
}
|
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 타임라인을
|
|
10689
|
+
* scenes 타임라인을 렌더한다 — 기본 비율 + output.aspects 추가 비율.
|
|
10633
10690
|
*/
|
|
10634
|
-
declare function renderScenesTimeline(scenario: Scenario, scenarioDir: string, onProgress?: (p: SceneProgress) => void): Promise<
|
|
10691
|
+
declare function renderScenesTimeline(scenario: Scenario, scenarioDir: string, onProgress?: (p: SceneProgress) => void): Promise<ScenesRenderResult>;
|
|
10635
10692
|
|
|
10636
10693
|
/**
|
|
10637
10694
|
* Parse a YAML string and return a validated Scenario object.
|
package/dist/index.js
CHANGED
|
@@ -3631,15 +3631,20 @@ async function executeStepsForProbe(page, scene) {
|
|
|
3631
3631
|
}
|
|
3632
3632
|
}
|
|
3633
3633
|
}
|
|
3634
|
-
function segmentOutput(scenario) {
|
|
3634
|
+
function segmentOutput(scenario, stageW, stageH) {
|
|
3635
3635
|
const dpr = scenario.viewport.deviceScaleFactor ?? 1;
|
|
3636
3636
|
return {
|
|
3637
3637
|
...scenario.output,
|
|
3638
|
-
width:
|
|
3639
|
-
height:
|
|
3638
|
+
width: stageW * dpr,
|
|
3639
|
+
height: stageH * dpr,
|
|
3640
3640
|
preset: "archive"
|
|
3641
3641
|
};
|
|
3642
3642
|
}
|
|
3643
|
+
var ASPECT_DIMS = {
|
|
3644
|
+
"16:9": [1280, 720],
|
|
3645
|
+
"9:16": [720, 1280],
|
|
3646
|
+
"1:1": [960, 960]
|
|
3647
|
+
};
|
|
3643
3648
|
async function recordFootageTake(scenario, scene, selectors) {
|
|
3644
3649
|
const boxes = /* @__PURE__ */ new Map();
|
|
3645
3650
|
if (selectors.length > 0) {
|
|
@@ -3665,7 +3670,11 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
3665
3670
|
};
|
|
3666
3671
|
const recorder = new ClipwiseRecorder();
|
|
3667
3672
|
const session = await recorder.record(takeScenario);
|
|
3668
|
-
const renderer = new CanvasRenderer(
|
|
3673
|
+
const renderer = new CanvasRenderer(
|
|
3674
|
+
takeScenario.effects,
|
|
3675
|
+
segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
|
|
3676
|
+
scene.steps
|
|
3677
|
+
);
|
|
3669
3678
|
const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
|
|
3670
3679
|
let count = 0;
|
|
3671
3680
|
for await (const f of renderer.composeStream(session.frames)) {
|
|
@@ -3685,12 +3694,12 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
3685
3694
|
function fitCardW(cw, ch, maxW = 940, maxH = 540) {
|
|
3686
3695
|
return Math.round(Math.min(maxW, maxH * cw / ch));
|
|
3687
3696
|
}
|
|
3688
|
-
async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
3697
|
+
async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
|
|
3689
3698
|
const fps = scenario.output.fps;
|
|
3690
3699
|
const totalFrames = Math.round(durationMs / 1e3 * fps);
|
|
3691
3700
|
const browser = await chromium2.launch();
|
|
3692
3701
|
const page = await browser.newPage({
|
|
3693
|
-
viewport: { width:
|
|
3702
|
+
viewport: { width: stageW, height: stageH },
|
|
3694
3703
|
deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
|
|
3695
3704
|
});
|
|
3696
3705
|
const params = new URLSearchParams(
|
|
@@ -3708,9 +3717,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
|
3708
3717
|
frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
|
|
3709
3718
|
}
|
|
3710
3719
|
await browser.close();
|
|
3711
|
-
return {
|
|
3720
|
+
return {
|
|
3721
|
+
buffer: await encodeMp4(frames, segmentOutput(scenario, stageW, stageH)),
|
|
3722
|
+
seconds: totalFrames / fps
|
|
3723
|
+
};
|
|
3712
3724
|
}
|
|
3713
|
-
function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
3725
|
+
function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
|
|
3714
3726
|
const W = scenario.viewport.width;
|
|
3715
3727
|
const H = scenario.viewport.height;
|
|
3716
3728
|
let crop = { x: 0, y: 0, w: W, h: H };
|
|
@@ -3748,7 +3760,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
|
3748
3760
|
cropY: crop.y,
|
|
3749
3761
|
cropW: crop.w,
|
|
3750
3762
|
cropH: crop.h,
|
|
3751
|
-
|
|
3763
|
+
// 카드 크기 상한은 무대(stage) 크기 비례 — 어떤 출력 비율에서도 일관된 여백
|
|
3764
|
+
cardW: fitCardW(crop.w, crop.h, Math.round(stageW * 0.735), Math.round(stageH * 0.675)),
|
|
3752
3765
|
pushFrom: scene.push?.from ?? 1,
|
|
3753
3766
|
pushTo: scene.push?.to ?? 1
|
|
3754
3767
|
};
|
|
@@ -3818,19 +3831,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3818
3831
|
(sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
|
|
3819
3832
|
);
|
|
3820
3833
|
const totalMs = durations.reduce((s, d) => s + d, 0);
|
|
3821
|
-
|
|
3822
|
-
|
|
3834
|
+
const totalSec = totalMs / 1e3;
|
|
3835
|
+
let audioPath = null;
|
|
3836
|
+
if (scenario.audio) {
|
|
3837
|
+
const a = scenario.audio;
|
|
3838
|
+
if (/^https?:\/\//.test(a.file)) {
|
|
3839
|
+
const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
|
|
3840
|
+
audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
|
|
3841
|
+
if (!existsSync2(audioPath)) {
|
|
3842
|
+
execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3843
|
+
}
|
|
3844
|
+
} else {
|
|
3845
|
+
audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3823
3848
|
const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
|
|
3824
|
-
|
|
3849
|
+
const renderPass = async (stageW, stageH, passLabel) => {
|
|
3850
|
+
let elapsedMs = 0;
|
|
3851
|
+
const segments = [];
|
|
3825
3852
|
for (let i = 0; i < timeline.length; i++) {
|
|
3826
3853
|
const scene = timeline[i];
|
|
3827
3854
|
const dur = durations[i];
|
|
3828
|
-
const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
|
|
3855
|
+
const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
|
|
3829
3856
|
onProgress?.({ scene: i + 1, total: timeline.length, label });
|
|
3830
3857
|
const thread = brand.annotations ? {
|
|
3831
3858
|
threadFrom: (elapsedMs / totalMs).toFixed(4),
|
|
3832
3859
|
threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
|
|
3833
3860
|
} : {};
|
|
3861
|
+
if (scenario.captions.length > 0) {
|
|
3862
|
+
thread.capsJson = JSON.stringify(scenario.captions);
|
|
3863
|
+
thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
|
|
3864
|
+
}
|
|
3834
3865
|
elapsedMs += dur;
|
|
3835
3866
|
let segment;
|
|
3836
3867
|
if (scene.type === "motion") {
|
|
@@ -3839,62 +3870,68 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3839
3870
|
url,
|
|
3840
3871
|
{ accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
|
|
3841
3872
|
dur,
|
|
3842
|
-
scenario
|
|
3873
|
+
scenario,
|
|
3874
|
+
stageW,
|
|
3875
|
+
stageH
|
|
3843
3876
|
);
|
|
3844
3877
|
} else {
|
|
3845
3878
|
const take = takes.get(scene.footage);
|
|
3846
3879
|
const url = resolveMotionTemplate("vignette", scenarioDir);
|
|
3847
3880
|
segment = await captureMotionSegment(
|
|
3848
3881
|
url,
|
|
3849
|
-
{
|
|
3882
|
+
{
|
|
3883
|
+
...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur, stageW, stageH),
|
|
3884
|
+
...thread
|
|
3885
|
+
},
|
|
3850
3886
|
dur,
|
|
3851
|
-
scenario
|
|
3887
|
+
scenario,
|
|
3888
|
+
stageW,
|
|
3889
|
+
stageH
|
|
3852
3890
|
);
|
|
3853
3891
|
}
|
|
3854
|
-
const segPath = join2(tmp,
|
|
3892
|
+
const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
|
|
3855
3893
|
await writeFile2(segPath, segment.buffer);
|
|
3856
3894
|
segments.push({ path: segPath, seconds: segment.seconds });
|
|
3857
3895
|
}
|
|
3896
|
+
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
3897
|
+
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
3898
|
+
const vChain = `${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]`;
|
|
3899
|
+
const finalLabel = "[v]";
|
|
3900
|
+
let audioInput = "";
|
|
3901
|
+
let audioMap = "";
|
|
3902
|
+
if (scenario.audio && audioPath) {
|
|
3903
|
+
const a = scenario.audio;
|
|
3904
|
+
const af = [];
|
|
3905
|
+
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
3906
|
+
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
3907
|
+
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
3908
|
+
audioInput = `-stream_loop -1 -i "${audioPath}" `;
|
|
3909
|
+
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
|
|
3910
|
+
}
|
|
3911
|
+
const outPath = join2(tmp, `${passLabel.replace(/\W/g, "")}timeline.mp4`);
|
|
3912
|
+
execSync(
|
|
3913
|
+
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${vChain}" -map "${finalLabel}" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
3914
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
3915
|
+
);
|
|
3916
|
+
return readFile3(outPath);
|
|
3917
|
+
};
|
|
3918
|
+
try {
|
|
3919
|
+
const buffer = await renderPass(scenario.viewport.width, scenario.viewport.height, "");
|
|
3920
|
+
const extras = [];
|
|
3921
|
+
for (const aspect of scenario.output.aspects) {
|
|
3922
|
+
const [w, h] = ASPECT_DIMS[aspect];
|
|
3923
|
+
extras.push({ label: aspect.replace(":", "x"), buffer: await renderPass(w, h, `${aspect} \xB7 `) });
|
|
3924
|
+
}
|
|
3925
|
+
return { buffer, extras };
|
|
3858
3926
|
} finally {
|
|
3859
3927
|
server.close();
|
|
3860
|
-
|
|
3861
|
-
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
3862
|
-
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
3863
|
-
const outPath = join2(tmp, "timeline.mp4");
|
|
3864
|
-
let audioInput = "";
|
|
3865
|
-
let audioMap = "";
|
|
3866
|
-
const totalSec = totalMs / 1e3;
|
|
3867
|
-
if (scenario.audio) {
|
|
3868
|
-
const a = scenario.audio;
|
|
3869
|
-
let audioPath;
|
|
3870
|
-
if (/^https?:\/\//.test(a.file)) {
|
|
3871
|
-
const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
|
|
3872
|
-
audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
|
|
3873
|
-
if (!existsSync2(audioPath)) {
|
|
3874
|
-
execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3875
|
-
}
|
|
3876
|
-
} else {
|
|
3877
|
-
audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
|
|
3878
|
-
}
|
|
3879
|
-
const af = [];
|
|
3880
|
-
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
3881
|
-
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
3882
|
-
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
3883
|
-
audioInput = `-stream_loop -1 -i "${audioPath}" `;
|
|
3884
|
-
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
|
|
3885
|
-
}
|
|
3886
|
-
execSync(
|
|
3887
|
-
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
3888
|
-
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
3889
|
-
);
|
|
3890
|
-
const buffer = await readFile3(outPath);
|
|
3891
|
-
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
3892
|
-
});
|
|
3893
|
-
for (const take of takes.values()) {
|
|
3894
|
-
await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
|
|
3928
|
+
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
3895
3929
|
});
|
|
3930
|
+
for (const take of takes.values()) {
|
|
3931
|
+
await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3896
3934
|
}
|
|
3897
|
-
return buffer;
|
|
3898
3935
|
}
|
|
3899
3936
|
|
|
3900
3937
|
// src/script/parser.ts
|
|
@@ -4146,7 +4183,10 @@ var OutputConfigSchema = z.object({
|
|
|
4146
4183
|
codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
|
|
4147
4184
|
// Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
|
|
4148
4185
|
outputDir: z.string().default(".clipwise/output"),
|
|
4149
|
-
filename: z.string().default("clipwise-recording")
|
|
4186
|
+
filename: z.string().default("clipwise-recording"),
|
|
4187
|
+
/** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
|
|
4188
|
+
* 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
|
|
4189
|
+
aspects: z.array(z.enum(["16:9", "9:16", "1:1"])).default([])
|
|
4150
4190
|
});
|
|
4151
4191
|
var StepEffectsOverrideSchema = z.object({
|
|
4152
4192
|
zoom: ZoomEffectSchema.partial().optional(),
|
|
@@ -4299,6 +4339,11 @@ var SceneSchema = z.discriminatedUnion("type", [
|
|
|
4299
4339
|
ScreenSceneSchema,
|
|
4300
4340
|
VignetteSceneSchema
|
|
4301
4341
|
]);
|
|
4342
|
+
var CaptionSchema = z.object({
|
|
4343
|
+
text: z.string().min(1),
|
|
4344
|
+
start: z.number().min(0),
|
|
4345
|
+
end: z.number().min(0)
|
|
4346
|
+
});
|
|
4302
4347
|
var ScenarioSchema = z.object({
|
|
4303
4348
|
name: z.string(),
|
|
4304
4349
|
description: z.string().optional(),
|
|
@@ -4319,7 +4364,9 @@ var ScenarioSchema = z.object({
|
|
|
4319
4364
|
/** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
|
|
4320
4365
|
steps: z.array(StepSchema).default([]),
|
|
4321
4366
|
/** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
|
|
4322
|
-
scenes: z.array(SceneSchema).optional()
|
|
4367
|
+
scenes: z.array(SceneSchema).optional(),
|
|
4368
|
+
/** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
|
|
4369
|
+
captions: z.array(CaptionSchema).default([])
|
|
4323
4370
|
}).superRefine((s, ctx) => {
|
|
4324
4371
|
if (s.steps.length === 0 && !s.scenes?.length) {
|
|
4325
4372
|
ctx.addIssue({
|
|
@@ -4417,6 +4464,15 @@ function validateScenario(scenario) {
|
|
|
4417
4464
|
}
|
|
4418
4465
|
}
|
|
4419
4466
|
}
|
|
4467
|
+
for (let i = 0; i < scenario.captions.length; i++) {
|
|
4468
|
+
const c = scenario.captions[i];
|
|
4469
|
+
if (c.end <= c.start) {
|
|
4470
|
+
errors.push(`captions #${i + 1} ("${c.text.slice(0, 20)}"): end must be greater than start`);
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
if (scenario.captions.length > 0 && !scenario.scenes?.length) {
|
|
4474
|
+
warnings.push("captions are only rendered in scenes timelines (ignored for classic steps recordings)");
|
|
4475
|
+
}
|
|
4420
4476
|
if (scenario.steps.length > 0) {
|
|
4421
4477
|
const firstStep = scenario.steps[0];
|
|
4422
4478
|
const hasNavigate = firstStep.actions.some(
|
package/package.json
CHANGED
package/skills/clipwise.md
CHANGED
|
@@ -399,6 +399,10 @@ scenes:
|
|
|
399
399
|
6. Keep one screen take (~12-15s) and let vignettes quote segments via `start: { step: N }`
|
|
400
400
|
7. **Sensitive data**: `prepare.mask: [".email", ".amount"]` blurs elements at record time
|
|
401
401
|
(follows scrolling — never ask the user to fake their data)
|
|
402
|
+
9. **Social formats**: `output.aspects: ["9:16", "1:1"]` renders extra reels/feed files in the
|
|
403
|
+
same run — footage is recorded once, only the stage re-composes. Files: `name-9x16.mp4` etc.
|
|
404
|
+
10. **Captions**: top-level `captions: [{ text, start, end }]` (timeline-absolute seconds)
|
|
405
|
+
burns styled caption pills into every aspect — great for sound-off social playback
|
|
402
406
|
8. **Music**: `audio: { file: bgm.mp3, bpm: 122, fadeOut: 1500 }` muxes BGM into the final
|
|
403
407
|
video AND snaps every scene cut onto the beat grid (beat-synced cuts).
|
|
404
408
|
`file:` also accepts a URL (downloaded+cached on the user's machine — use license-free
|
|
@@ -91,6 +91,24 @@
|
|
|
91
91
|
/* ── 스레드 — 영상 전체를 관통하는 한 줄의 잉크 선.
|
|
92
92
|
threadFrom→threadTo 구간을 신 길이 동안 선형으로 전진시키면
|
|
93
93
|
하드컷을 넘어도 같은 경로 위에서 끊김 없이 이어진다. ── */
|
|
94
|
+
|
|
95
|
+
/* ── 전역 캡션 트랙 — 타임라인 절대시각 기준, 신 경계를 넘어 이어진다.
|
|
96
|
+
ffmpeg 자막 필터(libass) 의존 없이 템플릿 레이어로 렌더 — 어떤 ffmpeg
|
|
97
|
+
빌드에서도 동작하고 폰트·브랜드 토큰을 그대로 쓴다. ── */
|
|
98
|
+
.gcap { position: absolute; left: 50%; transform: translateX(-50%);
|
|
99
|
+
bottom: 2.4%; z-index: 8; max-width: 86%;
|
|
100
|
+
font-family: var(--sans); font-weight: 600; color: var(--ink);
|
|
101
|
+
background: rgba(255, 255, 255, 0.82); border: 1px solid rgba(20, 20, 19, 0.08);
|
|
102
|
+
border-radius: 12px; padding: 0.5em 1.1em; text-align: center;
|
|
103
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
104
|
+
opacity: 0; animation: gcap-show linear both; }
|
|
105
|
+
@keyframes gcap-show {
|
|
106
|
+
0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
|
107
|
+
7% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
108
|
+
93% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
109
|
+
100% { opacity: 0; transform: translateX(-50%) translateY(0); }
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
.thread { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
|
|
95
113
|
.thread path { fill: none; stroke: var(--accent); stroke-width: 2.5; opacity: 0.55;
|
|
96
114
|
stroke-linecap: round;
|
|
@@ -114,7 +132,8 @@
|
|
|
114
132
|
<script>
|
|
115
133
|
const P = new URLSearchParams(location.search);
|
|
116
134
|
document.documentElement.style.setProperty("--accent", P.get("accent") || "#6366f1");
|
|
117
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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",
|
|
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) {
|