clipwise 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +140 -59
- package/dist/index.d.ts +59 -2
- package/dist/index.js +131 -56
- package/package.json +1 -1
- package/skills/clipwise.md +8 -1
- 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({
|
|
@@ -3978,6 +3988,7 @@ __export(runner_exports, {
|
|
|
3978
3988
|
});
|
|
3979
3989
|
import { chromium as chromium2 } from "playwright";
|
|
3980
3990
|
import { createServer } from "http";
|
|
3991
|
+
import { createHash } from "crypto";
|
|
3981
3992
|
import { execSync } from "child_process";
|
|
3982
3993
|
import { readFile as readFile4, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
|
|
3983
3994
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -4084,12 +4095,12 @@ async function executeStepsForProbe(page, scene) {
|
|
|
4084
4095
|
}
|
|
4085
4096
|
}
|
|
4086
4097
|
}
|
|
4087
|
-
function segmentOutput(scenario) {
|
|
4098
|
+
function segmentOutput(scenario, stageW, stageH) {
|
|
4088
4099
|
const dpr = scenario.viewport.deviceScaleFactor ?? 1;
|
|
4089
4100
|
return {
|
|
4090
4101
|
...scenario.output,
|
|
4091
|
-
width:
|
|
4092
|
-
height:
|
|
4102
|
+
width: stageW * dpr,
|
|
4103
|
+
height: stageH * dpr,
|
|
4093
4104
|
preset: "archive"
|
|
4094
4105
|
};
|
|
4095
4106
|
}
|
|
@@ -4118,32 +4129,36 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
4118
4129
|
};
|
|
4119
4130
|
const recorder = new ClipwiseRecorder();
|
|
4120
4131
|
const session = await recorder.record(takeScenario);
|
|
4121
|
-
const renderer = new CanvasRenderer(
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
composed.map(
|
|
4126
|
-
(f) => f.rawInfo ? sharp10(f.buffer, {
|
|
4127
|
-
raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
|
|
4128
|
-
}).png().toBuffer() : Promise.resolve(f.buffer)
|
|
4129
|
-
)
|
|
4132
|
+
const renderer = new CanvasRenderer(
|
|
4133
|
+
takeScenario.effects,
|
|
4134
|
+
segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
|
|
4135
|
+
scene.steps
|
|
4130
4136
|
);
|
|
4137
|
+
const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
|
|
4138
|
+
let count = 0;
|
|
4139
|
+
for await (const f of renderer.composeStream(session.frames)) {
|
|
4140
|
+
const png = f.rawInfo ? await sharp10(f.buffer, {
|
|
4141
|
+
raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
|
|
4142
|
+
}).png().toBuffer() : f.buffer;
|
|
4143
|
+
await writeFile2(join2(framesDir, `${count}.png`), png);
|
|
4144
|
+
count++;
|
|
4145
|
+
}
|
|
4131
4146
|
const anchors = [];
|
|
4132
4147
|
for (let k = 0; k < scene.steps.length; k++) {
|
|
4133
4148
|
const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
|
|
4134
4149
|
anchors.push(Math.max(0, idx) / scenario.output.fps);
|
|
4135
4150
|
}
|
|
4136
|
-
return {
|
|
4151
|
+
return { framesDir, count, anchors, boxes };
|
|
4137
4152
|
}
|
|
4138
4153
|
function fitCardW(cw, ch, maxW = 940, maxH = 540) {
|
|
4139
4154
|
return Math.round(Math.min(maxW, maxH * cw / ch));
|
|
4140
4155
|
}
|
|
4141
|
-
async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
4156
|
+
async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
|
|
4142
4157
|
const fps = scenario.output.fps;
|
|
4143
4158
|
const totalFrames = Math.round(durationMs / 1e3 * fps);
|
|
4144
4159
|
const browser = await chromium2.launch();
|
|
4145
4160
|
const page = await browser.newPage({
|
|
4146
|
-
viewport: { width:
|
|
4161
|
+
viewport: { width: stageW, height: stageH },
|
|
4147
4162
|
deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
|
|
4148
4163
|
});
|
|
4149
4164
|
const params = new URLSearchParams(
|
|
@@ -4161,9 +4176,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
|
4161
4176
|
frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
|
|
4162
4177
|
}
|
|
4163
4178
|
await browser.close();
|
|
4164
|
-
return {
|
|
4179
|
+
return {
|
|
4180
|
+
buffer: await encodeMp4(frames, segmentOutput(scenario, stageW, stageH)),
|
|
4181
|
+
seconds: totalFrames / fps
|
|
4182
|
+
};
|
|
4165
4183
|
}
|
|
4166
|
-
function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
4184
|
+
function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
|
|
4167
4185
|
const W = scenario.viewport.width;
|
|
4168
4186
|
const H = scenario.viewport.height;
|
|
4169
4187
|
let crop = { x: 0, y: 0, w: W, h: H };
|
|
@@ -4193,7 +4211,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
|
4193
4211
|
label: scene.label ?? "",
|
|
4194
4212
|
caption: scene.caption ?? "",
|
|
4195
4213
|
base: serverBase,
|
|
4196
|
-
count: take.
|
|
4214
|
+
count: take.count,
|
|
4197
4215
|
fps: scenario.output.fps,
|
|
4198
4216
|
start,
|
|
4199
4217
|
rate: scene.rate,
|
|
@@ -4201,7 +4219,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
|
4201
4219
|
cropY: crop.y,
|
|
4202
4220
|
cropW: crop.w,
|
|
4203
4221
|
cropH: crop.h,
|
|
4204
|
-
|
|
4222
|
+
// 카드 크기 상한은 무대(stage) 크기 비례 — 어떤 출력 비율에서도 일관된 여백
|
|
4223
|
+
cardW: fitCardW(crop.w, crop.h, Math.round(stageW * 0.735), Math.round(stageH * 0.675)),
|
|
4205
4224
|
pushFrom: scene.push?.from ?? 1,
|
|
4206
4225
|
pushTo: scene.push?.to ?? 1
|
|
4207
4226
|
};
|
|
@@ -4251,9 +4270,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4251
4270
|
const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
|
|
4252
4271
|
const take = m ? takes.get(m[1]) : void 0;
|
|
4253
4272
|
if (take) {
|
|
4254
|
-
const idx = Math.min(take.
|
|
4255
|
-
|
|
4256
|
-
|
|
4273
|
+
const idx = Math.min(take.count - 1, parseInt(m[2], 10));
|
|
4274
|
+
readFile4(join2(take.framesDir, `${idx}.png`)).then((png) => {
|
|
4275
|
+
res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
|
|
4276
|
+
res.end(png);
|
|
4277
|
+
}).catch(() => {
|
|
4278
|
+
res.writeHead(404);
|
|
4279
|
+
res.end();
|
|
4280
|
+
});
|
|
4257
4281
|
} else {
|
|
4258
4282
|
res.writeHead(404);
|
|
4259
4283
|
res.end();
|
|
@@ -4266,19 +4290,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4266
4290
|
(sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
|
|
4267
4291
|
);
|
|
4268
4292
|
const totalMs = durations.reduce((s, d) => s + d, 0);
|
|
4269
|
-
|
|
4270
|
-
|
|
4293
|
+
const totalSec = totalMs / 1e3;
|
|
4294
|
+
let audioPath = null;
|
|
4295
|
+
if (scenario.audio) {
|
|
4296
|
+
const a = scenario.audio;
|
|
4297
|
+
if (/^https?:\/\//.test(a.file)) {
|
|
4298
|
+
const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
|
|
4299
|
+
audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
|
|
4300
|
+
if (!existsSync2(audioPath)) {
|
|
4301
|
+
execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
|
|
4302
|
+
}
|
|
4303
|
+
} else {
|
|
4304
|
+
audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4271
4307
|
const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
|
|
4272
|
-
|
|
4308
|
+
const renderPass = async (stageW, stageH, passLabel) => {
|
|
4309
|
+
let elapsedMs = 0;
|
|
4310
|
+
const segments = [];
|
|
4273
4311
|
for (let i = 0; i < timeline.length; i++) {
|
|
4274
4312
|
const scene = timeline[i];
|
|
4275
4313
|
const dur = durations[i];
|
|
4276
|
-
const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
|
|
4314
|
+
const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
|
|
4277
4315
|
onProgress?.({ scene: i + 1, total: timeline.length, label });
|
|
4278
4316
|
const thread = brand.annotations ? {
|
|
4279
4317
|
threadFrom: (elapsedMs / totalMs).toFixed(4),
|
|
4280
4318
|
threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
|
|
4281
4319
|
} : {};
|
|
4320
|
+
if (scenario.captions.length > 0) {
|
|
4321
|
+
thread.capsJson = JSON.stringify(scenario.captions);
|
|
4322
|
+
thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
|
|
4323
|
+
}
|
|
4282
4324
|
elapsedMs += dur;
|
|
4283
4325
|
let segment;
|
|
4284
4326
|
if (scene.type === "motion") {
|
|
@@ -4287,50 +4329,70 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
4287
4329
|
url,
|
|
4288
4330
|
{ accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
|
|
4289
4331
|
dur,
|
|
4290
|
-
scenario
|
|
4332
|
+
scenario,
|
|
4333
|
+
stageW,
|
|
4334
|
+
stageH
|
|
4291
4335
|
);
|
|
4292
4336
|
} else {
|
|
4293
4337
|
const take = takes.get(scene.footage);
|
|
4294
4338
|
const url = resolveMotionTemplate("vignette", scenarioDir);
|
|
4295
4339
|
segment = await captureMotionSegment(
|
|
4296
4340
|
url,
|
|
4297
|
-
{
|
|
4341
|
+
{
|
|
4342
|
+
...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur, stageW, stageH),
|
|
4343
|
+
...thread
|
|
4344
|
+
},
|
|
4298
4345
|
dur,
|
|
4299
|
-
scenario
|
|
4346
|
+
scenario,
|
|
4347
|
+
stageW,
|
|
4348
|
+
stageH
|
|
4300
4349
|
);
|
|
4301
4350
|
}
|
|
4302
|
-
const segPath = join2(tmp,
|
|
4351
|
+
const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
|
|
4303
4352
|
await writeFile2(segPath, segment.buffer);
|
|
4304
4353
|
segments.push({ path: segPath, seconds: segment.seconds });
|
|
4305
4354
|
}
|
|
4355
|
+
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
4356
|
+
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
4357
|
+
const vChain = `${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]`;
|
|
4358
|
+
const finalLabel = "[v]";
|
|
4359
|
+
let audioInput = "";
|
|
4360
|
+
let audioMap = "";
|
|
4361
|
+
if (scenario.audio && audioPath) {
|
|
4362
|
+
const a = scenario.audio;
|
|
4363
|
+
const af = [];
|
|
4364
|
+
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
4365
|
+
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
4366
|
+
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
4367
|
+
audioInput = `-stream_loop -1 -i "${audioPath}" `;
|
|
4368
|
+
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
|
|
4369
|
+
}
|
|
4370
|
+
const outPath = join2(tmp, `${passLabel.replace(/\W/g, "")}timeline.mp4`);
|
|
4371
|
+
execSync(
|
|
4372
|
+
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${vChain}" -map "${finalLabel}" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
4373
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
4374
|
+
);
|
|
4375
|
+
return readFile4(outPath);
|
|
4376
|
+
};
|
|
4377
|
+
try {
|
|
4378
|
+
const buffer = await renderPass(scenario.viewport.width, scenario.viewport.height, "");
|
|
4379
|
+
const extras = [];
|
|
4380
|
+
for (const aspect of scenario.output.aspects) {
|
|
4381
|
+
const [w, h] = ASPECT_DIMS[aspect];
|
|
4382
|
+
extras.push({ label: aspect.replace(":", "x"), buffer: await renderPass(w, h, `${aspect} \xB7 `) });
|
|
4383
|
+
}
|
|
4384
|
+
return { buffer, extras };
|
|
4306
4385
|
} finally {
|
|
4307
4386
|
server.close();
|
|
4387
|
+
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
4388
|
+
});
|
|
4389
|
+
for (const take of takes.values()) {
|
|
4390
|
+
await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4308
4393
|
}
|
|
4309
|
-
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
4310
|
-
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
4311
|
-
const outPath = join2(tmp, "timeline.mp4");
|
|
4312
|
-
let audioInput = "";
|
|
4313
|
-
let audioMap = "";
|
|
4314
|
-
if (scenario.audio) {
|
|
4315
|
-
const a = scenario.audio;
|
|
4316
|
-
const audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
|
|
4317
|
-
const totalSec = totalMs / 1e3;
|
|
4318
|
-
const af = [];
|
|
4319
|
-
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
4320
|
-
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
4321
|
-
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
4322
|
-
audioInput = `-i "${audioPath}" `;
|
|
4323
|
-
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
|
|
4324
|
-
}
|
|
4325
|
-
execSync(
|
|
4326
|
-
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
4327
|
-
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
4328
|
-
);
|
|
4329
|
-
const buffer = await readFile4(outPath);
|
|
4330
|
-
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
4331
|
-
});
|
|
4332
|
-
return buffer;
|
|
4333
4394
|
}
|
|
4395
|
+
var ASPECT_DIMS;
|
|
4334
4396
|
var init_runner = __esm({
|
|
4335
4397
|
"src/scenes/runner.ts"() {
|
|
4336
4398
|
"use strict";
|
|
@@ -4338,6 +4400,11 @@ var init_runner = __esm({
|
|
|
4338
4400
|
init_prepare();
|
|
4339
4401
|
init_canvas_renderer();
|
|
4340
4402
|
init_video_encoder();
|
|
4403
|
+
ASPECT_DIMS = {
|
|
4404
|
+
"16:9": [1280, 720],
|
|
4405
|
+
"9:16": [720, 1280],
|
|
4406
|
+
"1:1": [960, 960]
|
|
4407
|
+
};
|
|
4341
4408
|
}
|
|
4342
4409
|
});
|
|
4343
4410
|
|
|
@@ -4383,6 +4450,15 @@ function validateScenario(scenario) {
|
|
|
4383
4450
|
}
|
|
4384
4451
|
}
|
|
4385
4452
|
}
|
|
4453
|
+
for (let i = 0; i < scenario.captions.length; i++) {
|
|
4454
|
+
const c = scenario.captions[i];
|
|
4455
|
+
if (c.end <= c.start) {
|
|
4456
|
+
errors.push(`captions #${i + 1} ("${c.text.slice(0, 20)}"): end must be greater than start`);
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4459
|
+
if (scenario.captions.length > 0 && !scenario.scenes?.length) {
|
|
4460
|
+
warnings.push("captions are only rendered in scenes timelines (ignored for classic steps recordings)");
|
|
4461
|
+
}
|
|
4386
4462
|
if (scenario.steps.length > 0) {
|
|
4387
4463
|
const firstStep = scenario.steps[0];
|
|
4388
4464
|
const hasNavigate = firstStep.actions.some(
|
|
@@ -4566,7 +4642,7 @@ import { homedir } from "os";
|
|
|
4566
4642
|
var program = new Command();
|
|
4567
4643
|
program.name("clipwise").description(
|
|
4568
4644
|
"Playwright-based cinematic screen recorder for product demos"
|
|
4569
|
-
).version("0.
|
|
4645
|
+
).version("0.11.0");
|
|
4570
4646
|
program.command("record").description("Record a demo from a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").option("-o, --output <dir>", "Output directory (default: scenario outputDir or .clipwise/output)").option(
|
|
4571
4647
|
"-f, --format <format>",
|
|
4572
4648
|
"Output format (gif|mp4|png-sequence)"
|
|
@@ -4630,12 +4706,17 @@ program.command("record").description("Record a demo from a YAML scenario file")
|
|
|
4630
4706
|
const outDir2 = scenario.output.outputDir;
|
|
4631
4707
|
await mkdir2(outDir2, { recursive: true });
|
|
4632
4708
|
spinner.start(`Rendering ${scenario.scenes.length}-scene timeline...`);
|
|
4633
|
-
const buf = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
|
|
4709
|
+
const { buffer: buf, extras } = await renderScenesTimeline2(scenario, scenarioDir, ({ scene, total, label }) => {
|
|
4634
4710
|
spinner.text = scene === 0 ? `Recording footage \u2014 ${label}...` : `Rendering scene ${scene}/${total} \u2014 ${label}...`;
|
|
4635
4711
|
});
|
|
4636
4712
|
const outputPath = join3(outDir2, `${scenario.output.filename}.mp4`);
|
|
4637
4713
|
await writeFile3(outputPath, buf);
|
|
4638
4714
|
spinner.succeed(`Timeline saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
4715
|
+
for (const extra of extras) {
|
|
4716
|
+
const extraPath = join3(outDir2, `${scenario.output.filename}-${extra.label}.mp4`);
|
|
4717
|
+
await writeFile3(extraPath, extra.buffer);
|
|
4718
|
+
spinner.succeed(` + ${chalk.bold(extraPath)} (${(extra.buffer.length / 1048576).toFixed(2)} MB)`);
|
|
4719
|
+
}
|
|
4639
4720
|
console.log(chalk.green("\nDone! \u{1F3AC}"));
|
|
4640
4721
|
return;
|
|
4641
4722
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -627,6 +627,9 @@ declare const OutputConfigSchema: z.ZodObject<{
|
|
|
627
627
|
codec: z.ZodDefault<z.ZodEnum<["auto", "h264", "hevc", "av1"]>>;
|
|
628
628
|
outputDir: z.ZodDefault<z.ZodString>;
|
|
629
629
|
filename: z.ZodDefault<z.ZodString>;
|
|
630
|
+
/** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
|
|
631
|
+
* 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
|
|
632
|
+
aspects: z.ZodDefault<z.ZodArray<z.ZodEnum<["16:9", "9:16", "1:1"]>, "many">>;
|
|
630
633
|
}, "strip", z.ZodTypeAny, {
|
|
631
634
|
format: "gif" | "mp4" | "webm" | "png-sequence";
|
|
632
635
|
width: number;
|
|
@@ -636,6 +639,7 @@ declare const OutputConfigSchema: z.ZodObject<{
|
|
|
636
639
|
codec: "auto" | "h264" | "hevc" | "av1";
|
|
637
640
|
outputDir: string;
|
|
638
641
|
filename: string;
|
|
642
|
+
aspects: ("16:9" | "9:16" | "1:1")[];
|
|
639
643
|
preset?: "social" | "balanced" | "archive" | undefined;
|
|
640
644
|
}, {
|
|
641
645
|
format?: "gif" | "mp4" | "webm" | "png-sequence" | undefined;
|
|
@@ -647,6 +651,7 @@ declare const OutputConfigSchema: z.ZodObject<{
|
|
|
647
651
|
codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
|
|
648
652
|
outputDir?: string | undefined;
|
|
649
653
|
filename?: string | undefined;
|
|
654
|
+
aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
|
|
650
655
|
}>;
|
|
651
656
|
type OutputConfig = z.infer<typeof OutputConfigSchema>;
|
|
652
657
|
/**
|
|
@@ -5521,6 +5526,9 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
5521
5526
|
codec: z.ZodDefault<z.ZodEnum<["auto", "h264", "hevc", "av1"]>>;
|
|
5522
5527
|
outputDir: z.ZodDefault<z.ZodString>;
|
|
5523
5528
|
filename: z.ZodDefault<z.ZodString>;
|
|
5529
|
+
/** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
|
|
5530
|
+
* 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
|
|
5531
|
+
aspects: z.ZodDefault<z.ZodArray<z.ZodEnum<["16:9", "9:16", "1:1"]>, "many">>;
|
|
5524
5532
|
}, "strip", z.ZodTypeAny, {
|
|
5525
5533
|
format: "gif" | "mp4" | "webm" | "png-sequence";
|
|
5526
5534
|
width: number;
|
|
@@ -5530,6 +5538,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
5530
5538
|
codec: "auto" | "h264" | "hevc" | "av1";
|
|
5531
5539
|
outputDir: string;
|
|
5532
5540
|
filename: string;
|
|
5541
|
+
aspects: ("16:9" | "9:16" | "1:1")[];
|
|
5533
5542
|
preset?: "social" | "balanced" | "archive" | undefined;
|
|
5534
5543
|
}, {
|
|
5535
5544
|
format?: "gif" | "mp4" | "webm" | "png-sequence" | undefined;
|
|
@@ -5541,6 +5550,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
5541
5550
|
codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
|
|
5542
5551
|
outputDir?: string | undefined;
|
|
5543
5552
|
filename?: string | undefined;
|
|
5553
|
+
aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
|
|
5544
5554
|
}>>;
|
|
5545
5555
|
/** Optional audio narration — muxed into MP4 output. */
|
|
5546
5556
|
audio: z.ZodOptional<z.ZodObject<{
|
|
@@ -7860,6 +7870,20 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7860
7870
|
coords?: number[] | undefined;
|
|
7861
7871
|
}[] | undefined;
|
|
7862
7872
|
}>]>, "many">>;
|
|
7873
|
+
/** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
|
|
7874
|
+
captions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
7875
|
+
text: z.ZodString;
|
|
7876
|
+
start: z.ZodNumber;
|
|
7877
|
+
end: z.ZodNumber;
|
|
7878
|
+
}, "strip", z.ZodTypeAny, {
|
|
7879
|
+
text: string;
|
|
7880
|
+
start: number;
|
|
7881
|
+
end: number;
|
|
7882
|
+
}, {
|
|
7883
|
+
text: string;
|
|
7884
|
+
start: number;
|
|
7885
|
+
end: number;
|
|
7886
|
+
}>, "many">>;
|
|
7863
7887
|
}, "strip", z.ZodTypeAny, {
|
|
7864
7888
|
name: string;
|
|
7865
7889
|
effects: {
|
|
@@ -8105,8 +8129,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8105
8129
|
codec: "auto" | "h264" | "hevc" | "av1";
|
|
8106
8130
|
outputDir: string;
|
|
8107
8131
|
filename: string;
|
|
8132
|
+
aspects: ("16:9" | "9:16" | "1:1")[];
|
|
8108
8133
|
preset?: "social" | "balanced" | "archive" | undefined;
|
|
8109
8134
|
};
|
|
8135
|
+
captions: {
|
|
8136
|
+
text: string;
|
|
8137
|
+
start: number;
|
|
8138
|
+
end: number;
|
|
8139
|
+
}[];
|
|
8110
8140
|
description?: string | undefined;
|
|
8111
8141
|
auth?: {
|
|
8112
8142
|
storageState?: string | undefined;
|
|
@@ -8625,6 +8655,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8625
8655
|
codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
|
|
8626
8656
|
outputDir?: string | undefined;
|
|
8627
8657
|
filename?: string | undefined;
|
|
8658
|
+
aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
|
|
8628
8659
|
} | undefined;
|
|
8629
8660
|
audio?: {
|
|
8630
8661
|
file: string;
|
|
@@ -8830,6 +8861,11 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
8830
8861
|
coords?: number[] | undefined;
|
|
8831
8862
|
}[] | undefined;
|
|
8832
8863
|
})[] | undefined;
|
|
8864
|
+
captions?: {
|
|
8865
|
+
text: string;
|
|
8866
|
+
start: number;
|
|
8867
|
+
end: number;
|
|
8868
|
+
}[] | undefined;
|
|
8833
8869
|
}>, {
|
|
8834
8870
|
name: string;
|
|
8835
8871
|
effects: {
|
|
@@ -9075,8 +9111,14 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9075
9111
|
codec: "auto" | "h264" | "hevc" | "av1";
|
|
9076
9112
|
outputDir: string;
|
|
9077
9113
|
filename: string;
|
|
9114
|
+
aspects: ("16:9" | "9:16" | "1:1")[];
|
|
9078
9115
|
preset?: "social" | "balanced" | "archive" | undefined;
|
|
9079
9116
|
};
|
|
9117
|
+
captions: {
|
|
9118
|
+
text: string;
|
|
9119
|
+
start: number;
|
|
9120
|
+
end: number;
|
|
9121
|
+
}[];
|
|
9080
9122
|
description?: string | undefined;
|
|
9081
9123
|
auth?: {
|
|
9082
9124
|
storageState?: string | undefined;
|
|
@@ -9595,6 +9637,7 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9595
9637
|
codec?: "auto" | "h264" | "hevc" | "av1" | undefined;
|
|
9596
9638
|
outputDir?: string | undefined;
|
|
9597
9639
|
filename?: string | undefined;
|
|
9640
|
+
aspects?: ("16:9" | "9:16" | "1:1")[] | undefined;
|
|
9598
9641
|
} | undefined;
|
|
9599
9642
|
audio?: {
|
|
9600
9643
|
file: string;
|
|
@@ -9800,6 +9843,11 @@ declare const ScenarioSchema: z.ZodEffects<z.ZodObject<{
|
|
|
9800
9843
|
coords?: number[] | undefined;
|
|
9801
9844
|
}[] | undefined;
|
|
9802
9845
|
})[] | undefined;
|
|
9846
|
+
captions?: {
|
|
9847
|
+
text: string;
|
|
9848
|
+
start: number;
|
|
9849
|
+
end: number;
|
|
9850
|
+
}[] | undefined;
|
|
9803
9851
|
}>;
|
|
9804
9852
|
type Scenario = z.infer<typeof ScenarioSchema>;
|
|
9805
9853
|
interface KeystrokeEvent {
|
|
@@ -10628,10 +10676,19 @@ interface SceneProgress {
|
|
|
10628
10676
|
total: number;
|
|
10629
10677
|
label: string;
|
|
10630
10678
|
}
|
|
10679
|
+
interface ScenesRenderResult {
|
|
10680
|
+
/** 기본 비율(viewport 크기) 렌더. */
|
|
10681
|
+
buffer: Buffer;
|
|
10682
|
+
/** output.aspects로 요청된 추가 비율 렌더 (label 예: "9x16"). */
|
|
10683
|
+
extras: {
|
|
10684
|
+
label: string;
|
|
10685
|
+
buffer: Buffer;
|
|
10686
|
+
}[];
|
|
10687
|
+
}
|
|
10631
10688
|
/**
|
|
10632
|
-
* scenes 타임라인을
|
|
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
|
@@ -3524,6 +3524,7 @@ var StreamingSession = class extends EventEmitter {
|
|
|
3524
3524
|
// src/scenes/runner.ts
|
|
3525
3525
|
import { chromium as chromium2 } from "playwright";
|
|
3526
3526
|
import { createServer } from "http";
|
|
3527
|
+
import { createHash } from "crypto";
|
|
3527
3528
|
import { execSync } from "child_process";
|
|
3528
3529
|
import { readFile as readFile3, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
|
|
3529
3530
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -3630,15 +3631,20 @@ async function executeStepsForProbe(page, scene) {
|
|
|
3630
3631
|
}
|
|
3631
3632
|
}
|
|
3632
3633
|
}
|
|
3633
|
-
function segmentOutput(scenario) {
|
|
3634
|
+
function segmentOutput(scenario, stageW, stageH) {
|
|
3634
3635
|
const dpr = scenario.viewport.deviceScaleFactor ?? 1;
|
|
3635
3636
|
return {
|
|
3636
3637
|
...scenario.output,
|
|
3637
|
-
width:
|
|
3638
|
-
height:
|
|
3638
|
+
width: stageW * dpr,
|
|
3639
|
+
height: stageH * dpr,
|
|
3639
3640
|
preset: "archive"
|
|
3640
3641
|
};
|
|
3641
3642
|
}
|
|
3643
|
+
var ASPECT_DIMS = {
|
|
3644
|
+
"16:9": [1280, 720],
|
|
3645
|
+
"9:16": [720, 1280],
|
|
3646
|
+
"1:1": [960, 960]
|
|
3647
|
+
};
|
|
3642
3648
|
async function recordFootageTake(scenario, scene, selectors) {
|
|
3643
3649
|
const boxes = /* @__PURE__ */ new Map();
|
|
3644
3650
|
if (selectors.length > 0) {
|
|
@@ -3664,32 +3670,36 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
3664
3670
|
};
|
|
3665
3671
|
const recorder = new ClipwiseRecorder();
|
|
3666
3672
|
const session = await recorder.record(takeScenario);
|
|
3667
|
-
const renderer = new CanvasRenderer(
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
composed.map(
|
|
3672
|
-
(f) => f.rawInfo ? sharp10(f.buffer, {
|
|
3673
|
-
raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
|
|
3674
|
-
}).png().toBuffer() : Promise.resolve(f.buffer)
|
|
3675
|
-
)
|
|
3673
|
+
const renderer = new CanvasRenderer(
|
|
3674
|
+
takeScenario.effects,
|
|
3675
|
+
segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
|
|
3676
|
+
scene.steps
|
|
3676
3677
|
);
|
|
3678
|
+
const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
|
|
3679
|
+
let count = 0;
|
|
3680
|
+
for await (const f of renderer.composeStream(session.frames)) {
|
|
3681
|
+
const png = f.rawInfo ? await sharp10(f.buffer, {
|
|
3682
|
+
raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
|
|
3683
|
+
}).png().toBuffer() : f.buffer;
|
|
3684
|
+
await writeFile2(join2(framesDir, `${count}.png`), png);
|
|
3685
|
+
count++;
|
|
3686
|
+
}
|
|
3677
3687
|
const anchors = [];
|
|
3678
3688
|
for (let k = 0; k < scene.steps.length; k++) {
|
|
3679
3689
|
const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
|
|
3680
3690
|
anchors.push(Math.max(0, idx) / scenario.output.fps);
|
|
3681
3691
|
}
|
|
3682
|
-
return {
|
|
3692
|
+
return { framesDir, count, anchors, boxes };
|
|
3683
3693
|
}
|
|
3684
3694
|
function fitCardW(cw, ch, maxW = 940, maxH = 540) {
|
|
3685
3695
|
return Math.round(Math.min(maxW, maxH * cw / ch));
|
|
3686
3696
|
}
|
|
3687
|
-
async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
3697
|
+
async function captureMotionSegment(templateUrl, props, durationMs, scenario, stageW, stageH) {
|
|
3688
3698
|
const fps = scenario.output.fps;
|
|
3689
3699
|
const totalFrames = Math.round(durationMs / 1e3 * fps);
|
|
3690
3700
|
const browser = await chromium2.launch();
|
|
3691
3701
|
const page = await browser.newPage({
|
|
3692
|
-
viewport: { width:
|
|
3702
|
+
viewport: { width: stageW, height: stageH },
|
|
3693
3703
|
deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
|
|
3694
3704
|
});
|
|
3695
3705
|
const params = new URLSearchParams(
|
|
@@ -3707,9 +3717,12 @@ async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
|
3707
3717
|
frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
|
|
3708
3718
|
}
|
|
3709
3719
|
await browser.close();
|
|
3710
|
-
return {
|
|
3720
|
+
return {
|
|
3721
|
+
buffer: await encodeMp4(frames, segmentOutput(scenario, stageW, stageH)),
|
|
3722
|
+
seconds: totalFrames / fps
|
|
3723
|
+
};
|
|
3711
3724
|
}
|
|
3712
|
-
function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
3725
|
+
function vignetteProps(scene, take, serverBase, scenario, brand, durMs, stageW, stageH) {
|
|
3713
3726
|
const W = scenario.viewport.width;
|
|
3714
3727
|
const H = scenario.viewport.height;
|
|
3715
3728
|
let crop = { x: 0, y: 0, w: W, h: H };
|
|
@@ -3739,7 +3752,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
|
3739
3752
|
label: scene.label ?? "",
|
|
3740
3753
|
caption: scene.caption ?? "",
|
|
3741
3754
|
base: serverBase,
|
|
3742
|
-
count: take.
|
|
3755
|
+
count: take.count,
|
|
3743
3756
|
fps: scenario.output.fps,
|
|
3744
3757
|
start,
|
|
3745
3758
|
rate: scene.rate,
|
|
@@ -3747,7 +3760,8 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
|
|
|
3747
3760
|
cropY: crop.y,
|
|
3748
3761
|
cropW: crop.w,
|
|
3749
3762
|
cropH: crop.h,
|
|
3750
|
-
|
|
3763
|
+
// 카드 크기 상한은 무대(stage) 크기 비례 — 어떤 출력 비율에서도 일관된 여백
|
|
3764
|
+
cardW: fitCardW(crop.w, crop.h, Math.round(stageW * 0.735), Math.round(stageH * 0.675)),
|
|
3751
3765
|
pushFrom: scene.push?.from ?? 1,
|
|
3752
3766
|
pushTo: scene.push?.to ?? 1
|
|
3753
3767
|
};
|
|
@@ -3797,9 +3811,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3797
3811
|
const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
|
|
3798
3812
|
const take = m ? takes.get(m[1]) : void 0;
|
|
3799
3813
|
if (take) {
|
|
3800
|
-
const idx = Math.min(take.
|
|
3801
|
-
|
|
3802
|
-
|
|
3814
|
+
const idx = Math.min(take.count - 1, parseInt(m[2], 10));
|
|
3815
|
+
readFile3(join2(take.framesDir, `${idx}.png`)).then((png) => {
|
|
3816
|
+
res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
|
|
3817
|
+
res.end(png);
|
|
3818
|
+
}).catch(() => {
|
|
3819
|
+
res.writeHead(404);
|
|
3820
|
+
res.end();
|
|
3821
|
+
});
|
|
3803
3822
|
} else {
|
|
3804
3823
|
res.writeHead(404);
|
|
3805
3824
|
res.end();
|
|
@@ -3812,19 +3831,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3812
3831
|
(sc) => beatMs ? Math.max(beatMs, Math.round(sc.duration / beatMs) * beatMs) : sc.duration
|
|
3813
3832
|
);
|
|
3814
3833
|
const totalMs = durations.reduce((s, d) => s + d, 0);
|
|
3815
|
-
|
|
3816
|
-
|
|
3834
|
+
const totalSec = totalMs / 1e3;
|
|
3835
|
+
let audioPath = null;
|
|
3836
|
+
if (scenario.audio) {
|
|
3837
|
+
const a = scenario.audio;
|
|
3838
|
+
if (/^https?:\/\//.test(a.file)) {
|
|
3839
|
+
const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
|
|
3840
|
+
audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
|
|
3841
|
+
if (!existsSync2(audioPath)) {
|
|
3842
|
+
execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3843
|
+
}
|
|
3844
|
+
} else {
|
|
3845
|
+
audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3817
3848
|
const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
|
|
3818
|
-
|
|
3849
|
+
const renderPass = async (stageW, stageH, passLabel) => {
|
|
3850
|
+
let elapsedMs = 0;
|
|
3851
|
+
const segments = [];
|
|
3819
3852
|
for (let i = 0; i < timeline.length; i++) {
|
|
3820
3853
|
const scene = timeline[i];
|
|
3821
3854
|
const dur = durations[i];
|
|
3822
|
-
const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
|
|
3855
|
+
const label = `${passLabel}${scene.type === "motion" ? scene.template : `vignette(${scene.footage})`}`;
|
|
3823
3856
|
onProgress?.({ scene: i + 1, total: timeline.length, label });
|
|
3824
3857
|
const thread = brand.annotations ? {
|
|
3825
3858
|
threadFrom: (elapsedMs / totalMs).toFixed(4),
|
|
3826
3859
|
threadTo: ((elapsedMs + dur) / totalMs).toFixed(4)
|
|
3827
3860
|
} : {};
|
|
3861
|
+
if (scenario.captions.length > 0) {
|
|
3862
|
+
thread.capsJson = JSON.stringify(scenario.captions);
|
|
3863
|
+
thread.capsOffset = (elapsedMs / 1e3).toFixed(3);
|
|
3864
|
+
}
|
|
3828
3865
|
elapsedMs += dur;
|
|
3829
3866
|
let segment;
|
|
3830
3867
|
if (scene.type === "motion") {
|
|
@@ -3833,49 +3870,68 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
|
3833
3870
|
url,
|
|
3834
3871
|
{ accent: brand.accent, font: brand.font, dur: dur / 1e3, ...scene.props, ...thread },
|
|
3835
3872
|
dur,
|
|
3836
|
-
scenario
|
|
3873
|
+
scenario,
|
|
3874
|
+
stageW,
|
|
3875
|
+
stageH
|
|
3837
3876
|
);
|
|
3838
3877
|
} else {
|
|
3839
3878
|
const take = takes.get(scene.footage);
|
|
3840
3879
|
const url = resolveMotionTemplate("vignette", scenarioDir);
|
|
3841
3880
|
segment = await captureMotionSegment(
|
|
3842
3881
|
url,
|
|
3843
|
-
{
|
|
3882
|
+
{
|
|
3883
|
+
...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand, dur, stageW, stageH),
|
|
3884
|
+
...thread
|
|
3885
|
+
},
|
|
3844
3886
|
dur,
|
|
3845
|
-
scenario
|
|
3887
|
+
scenario,
|
|
3888
|
+
stageW,
|
|
3889
|
+
stageH
|
|
3846
3890
|
);
|
|
3847
3891
|
}
|
|
3848
|
-
const segPath = join2(tmp,
|
|
3892
|
+
const segPath = join2(tmp, `${passLabel.replace(/\W/g, "")}s${i}.mp4`);
|
|
3849
3893
|
await writeFile2(segPath, segment.buffer);
|
|
3850
3894
|
segments.push({ path: segPath, seconds: segment.seconds });
|
|
3851
3895
|
}
|
|
3896
|
+
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
3897
|
+
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
3898
|
+
const vChain = `${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]`;
|
|
3899
|
+
const finalLabel = "[v]";
|
|
3900
|
+
let audioInput = "";
|
|
3901
|
+
let audioMap = "";
|
|
3902
|
+
if (scenario.audio && audioPath) {
|
|
3903
|
+
const a = scenario.audio;
|
|
3904
|
+
const af = [];
|
|
3905
|
+
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
3906
|
+
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
3907
|
+
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
3908
|
+
audioInput = `-stream_loop -1 -i "${audioPath}" `;
|
|
3909
|
+
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
|
|
3910
|
+
}
|
|
3911
|
+
const outPath = join2(tmp, `${passLabel.replace(/\W/g, "")}timeline.mp4`);
|
|
3912
|
+
execSync(
|
|
3913
|
+
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${vChain}" -map "${finalLabel}" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
3914
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
3915
|
+
);
|
|
3916
|
+
return readFile3(outPath);
|
|
3917
|
+
};
|
|
3918
|
+
try {
|
|
3919
|
+
const buffer = await renderPass(scenario.viewport.width, scenario.viewport.height, "");
|
|
3920
|
+
const extras = [];
|
|
3921
|
+
for (const aspect of scenario.output.aspects) {
|
|
3922
|
+
const [w, h] = ASPECT_DIMS[aspect];
|
|
3923
|
+
extras.push({ label: aspect.replace(":", "x"), buffer: await renderPass(w, h, `${aspect} \xB7 `) });
|
|
3924
|
+
}
|
|
3925
|
+
return { buffer, extras };
|
|
3852
3926
|
} finally {
|
|
3853
3927
|
server.close();
|
|
3928
|
+
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
3929
|
+
});
|
|
3930
|
+
for (const take of takes.values()) {
|
|
3931
|
+
await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3854
3934
|
}
|
|
3855
|
-
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
3856
|
-
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
3857
|
-
const outPath = join2(tmp, "timeline.mp4");
|
|
3858
|
-
let audioInput = "";
|
|
3859
|
-
let audioMap = "";
|
|
3860
|
-
if (scenario.audio) {
|
|
3861
|
-
const a = scenario.audio;
|
|
3862
|
-
const audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
|
|
3863
|
-
const totalSec = totalMs / 1e3;
|
|
3864
|
-
const af = [];
|
|
3865
|
-
if (a.volume !== 1) af.push(`volume=${a.volume}`);
|
|
3866
|
-
if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
|
|
3867
|
-
if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
|
|
3868
|
-
audioInput = `-i "${audioPath}" `;
|
|
3869
|
-
audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
|
|
3870
|
-
}
|
|
3871
|
-
execSync(
|
|
3872
|
-
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
3873
|
-
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
3874
|
-
);
|
|
3875
|
-
const buffer = await readFile3(outPath);
|
|
3876
|
-
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
3877
|
-
});
|
|
3878
|
-
return buffer;
|
|
3879
3935
|
}
|
|
3880
3936
|
|
|
3881
3937
|
// src/script/parser.ts
|
|
@@ -4127,7 +4183,10 @@ var OutputConfigSchema = z.object({
|
|
|
4127
4183
|
codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
|
|
4128
4184
|
// Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
|
|
4129
4185
|
outputDir: z.string().default(".clipwise/output"),
|
|
4130
|
-
filename: z.string().default("clipwise-recording")
|
|
4186
|
+
filename: z.string().default("clipwise-recording"),
|
|
4187
|
+
/** scenes 전용 — 추가 출력 비율. 푸티지는 1회 녹화, 무대만 비율별 재합성해
|
|
4188
|
+
* 소셜 배포용 파일을 동시 생성한다 (예: ["9:16", "1:1"]). */
|
|
4189
|
+
aspects: z.array(z.enum(["16:9", "9:16", "1:1"])).default([])
|
|
4131
4190
|
});
|
|
4132
4191
|
var StepEffectsOverrideSchema = z.object({
|
|
4133
4192
|
zoom: ZoomEffectSchema.partial().optional(),
|
|
@@ -4280,6 +4339,11 @@ var SceneSchema = z.discriminatedUnion("type", [
|
|
|
4280
4339
|
ScreenSceneSchema,
|
|
4281
4340
|
VignetteSceneSchema
|
|
4282
4341
|
]);
|
|
4342
|
+
var CaptionSchema = z.object({
|
|
4343
|
+
text: z.string().min(1),
|
|
4344
|
+
start: z.number().min(0),
|
|
4345
|
+
end: z.number().min(0)
|
|
4346
|
+
});
|
|
4283
4347
|
var ScenarioSchema = z.object({
|
|
4284
4348
|
name: z.string(),
|
|
4285
4349
|
description: z.string().optional(),
|
|
@@ -4300,7 +4364,9 @@ var ScenarioSchema = z.object({
|
|
|
4300
4364
|
/** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
|
|
4301
4365
|
steps: z.array(StepSchema).default([]),
|
|
4302
4366
|
/** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
|
|
4303
|
-
scenes: z.array(SceneSchema).optional()
|
|
4367
|
+
scenes: z.array(SceneSchema).optional(),
|
|
4368
|
+
/** scenes 전용 — 선언형 캡션 트랙 (스타일은 brand accent를 따른다). */
|
|
4369
|
+
captions: z.array(CaptionSchema).default([])
|
|
4304
4370
|
}).superRefine((s, ctx) => {
|
|
4305
4371
|
if (s.steps.length === 0 && !s.scenes?.length) {
|
|
4306
4372
|
ctx.addIssue({
|
|
@@ -4398,6 +4464,15 @@ function validateScenario(scenario) {
|
|
|
4398
4464
|
}
|
|
4399
4465
|
}
|
|
4400
4466
|
}
|
|
4467
|
+
for (let i = 0; i < scenario.captions.length; i++) {
|
|
4468
|
+
const c = scenario.captions[i];
|
|
4469
|
+
if (c.end <= c.start) {
|
|
4470
|
+
errors.push(`captions #${i + 1} ("${c.text.slice(0, 20)}"): end must be greater than start`);
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
if (scenario.captions.length > 0 && !scenario.scenes?.length) {
|
|
4474
|
+
warnings.push("captions are only rendered in scenes timelines (ignored for classic steps recordings)");
|
|
4475
|
+
}
|
|
4401
4476
|
if (scenario.steps.length > 0) {
|
|
4402
4477
|
const firstStep = scenario.steps[0];
|
|
4403
4478
|
const hasNavigate = firstStep.actions.some(
|
package/package.json
CHANGED
package/skills/clipwise.md
CHANGED
|
@@ -399,8 +399,15 @@ scenes:
|
|
|
399
399
|
6. Keep one screen take (~12-15s) and let vignettes quote segments via `start: { step: N }`
|
|
400
400
|
7. **Sensitive data**: `prepare.mask: [".email", ".amount"]` blurs elements at record time
|
|
401
401
|
(follows scrolling — never ask the user to fake their data)
|
|
402
|
+
9. **Social formats**: `output.aspects: ["9:16", "1:1"]` renders extra reels/feed files in the
|
|
403
|
+
same run — footage is recorded once, only the stage re-composes. Files: `name-9x16.mp4` etc.
|
|
404
|
+
10. **Captions**: top-level `captions: [{ text, start, end }]` (timeline-absolute seconds)
|
|
405
|
+
burns styled caption pills into every aspect — great for sound-off social playback
|
|
402
406
|
8. **Music**: `audio: { file: bgm.mp3, bpm: 122, fadeOut: 1500 }` muxes BGM into the final
|
|
403
|
-
video AND snaps every scene cut onto the beat grid (beat-synced cuts)
|
|
407
|
+
video AND snaps every scene cut onto the beat grid (beat-synced cuts).
|
|
408
|
+
`file:` also accepts a URL (downloaded+cached on the user's machine — use license-free
|
|
409
|
+
sources like Mixkit, e.g. `https://assets.mixkit.co/music/132/132.mp3`, ~120bpm).
|
|
410
|
+
Track shorter than the video loops automatically; video length is always authoritative
|
|
404
411
|
|
|
405
412
|
## Critical Rules
|
|
406
413
|
|
|
@@ -91,6 +91,24 @@
|
|
|
91
91
|
/* ── 스레드 — 영상 전체를 관통하는 한 줄의 잉크 선.
|
|
92
92
|
threadFrom→threadTo 구간을 신 길이 동안 선형으로 전진시키면
|
|
93
93
|
하드컷을 넘어도 같은 경로 위에서 끊김 없이 이어진다. ── */
|
|
94
|
+
|
|
95
|
+
/* ── 전역 캡션 트랙 — 타임라인 절대시각 기준, 신 경계를 넘어 이어진다.
|
|
96
|
+
ffmpeg 자막 필터(libass) 의존 없이 템플릿 레이어로 렌더 — 어떤 ffmpeg
|
|
97
|
+
빌드에서도 동작하고 폰트·브랜드 토큰을 그대로 쓴다. ── */
|
|
98
|
+
.gcap { position: absolute; left: 50%; transform: translateX(-50%);
|
|
99
|
+
bottom: 2.4%; z-index: 8; max-width: 86%;
|
|
100
|
+
font-family: var(--sans); font-weight: 600; color: var(--ink);
|
|
101
|
+
background: rgba(255, 255, 255, 0.82); border: 1px solid rgba(20, 20, 19, 0.08);
|
|
102
|
+
border-radius: 12px; padding: 0.5em 1.1em; text-align: center;
|
|
103
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
104
|
+
opacity: 0; animation: gcap-show linear both; }
|
|
105
|
+
@keyframes gcap-show {
|
|
106
|
+
0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
|
107
|
+
7% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
108
|
+
93% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
109
|
+
100% { opacity: 0; transform: translateX(-50%) translateY(0); }
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
.thread { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
|
|
95
113
|
.thread path { fill: none; stroke: var(--accent); stroke-width: 2.5; opacity: 0.55;
|
|
96
114
|
stroke-linecap: round;
|
|
@@ -114,7 +132,8 @@
|
|
|
114
132
|
<script>
|
|
115
133
|
const P = new URLSearchParams(location.search);
|
|
116
134
|
document.documentElement.style.setProperty("--accent", P.get("accent") || "#6366f1");
|
|
117
|
-
|
|
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) {
|