clipwise 0.7.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +142 -13
- package/README.md +119 -14
- package/dist/cli/index.js +2967 -1834
- package/dist/compose/frame-worker.js +88 -38
- package/dist/index.d.ts +6275 -374
- package/dist/index.js +750 -65
- package/package.json +3 -2
- package/skills/clipwise.md +83 -8
- package/templates/motion/feature-callout.html +81 -0
- package/templates/motion/intro-title.html +146 -0
- package/templates/motion/kinetic-type.html +205 -0
- package/templates/motion/vignette.html +288 -0
package/dist/index.js
CHANGED
|
@@ -54,6 +54,115 @@ async function getElementCenter(page, selector, timeout) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// src/core/prepare.ts
|
|
58
|
+
import { readFile } from "fs/promises";
|
|
59
|
+
function buildHideCss(selectors) {
|
|
60
|
+
return `${selectors.join(",\n")} {
|
|
61
|
+
display: none !important;
|
|
62
|
+
visibility: hidden !important;
|
|
63
|
+
}`;
|
|
64
|
+
}
|
|
65
|
+
function buildCssInjectionScript(css) {
|
|
66
|
+
return `(() => {
|
|
67
|
+
const apply = () => {
|
|
68
|
+
const style = document.createElement("style");
|
|
69
|
+
style.setAttribute("data-clipwise", "prepare");
|
|
70
|
+
style.textContent = ${JSON.stringify(css)};
|
|
71
|
+
(document.head || document.documentElement).appendChild(style);
|
|
72
|
+
};
|
|
73
|
+
if (document.readyState === "loading") {
|
|
74
|
+
document.addEventListener("DOMContentLoaded", apply);
|
|
75
|
+
} else {
|
|
76
|
+
apply();
|
|
77
|
+
}
|
|
78
|
+
})();`;
|
|
79
|
+
}
|
|
80
|
+
function buildFreezeTimeScript(epochMs) {
|
|
81
|
+
return `(() => {
|
|
82
|
+
const frozen = ${epochMs};
|
|
83
|
+
const OrigDate = Date;
|
|
84
|
+
class FrozenDate extends OrigDate {
|
|
85
|
+
constructor(...args) {
|
|
86
|
+
if (args.length === 0) { super(frozen); } else { super(...args); }
|
|
87
|
+
}
|
|
88
|
+
static now() { return frozen; }
|
|
89
|
+
}
|
|
90
|
+
FrozenDate.parse = OrigDate.parse;
|
|
91
|
+
FrozenDate.UTC = OrigDate.UTC;
|
|
92
|
+
Object.defineProperty(globalThis, "Date", { value: FrozenDate, writable: true, configurable: true });
|
|
93
|
+
})();`;
|
|
94
|
+
}
|
|
95
|
+
function buildSeedRandomScript(seed) {
|
|
96
|
+
return `(() => {
|
|
97
|
+
let s = (${seed}) >>> 0;
|
|
98
|
+
Math.random = () => {
|
|
99
|
+
s = (s + 0x6D2B79F5) | 0;
|
|
100
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
101
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
102
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
103
|
+
};
|
|
104
|
+
})();`;
|
|
105
|
+
}
|
|
106
|
+
function buildStorageScript(storage) {
|
|
107
|
+
return `(() => {
|
|
108
|
+
try {
|
|
109
|
+
const seed = ${JSON.stringify(storage)};
|
|
110
|
+
for (const [k, v] of Object.entries(seed.localStorage)) localStorage.setItem(k, v);
|
|
111
|
+
for (const [k, v] of Object.entries(seed.sessionStorage)) sessionStorage.setItem(k, v);
|
|
112
|
+
} catch { /* opaque origin \u2014 storage unavailable */ }
|
|
113
|
+
})();`;
|
|
114
|
+
}
|
|
115
|
+
async function resolveMockBody(route) {
|
|
116
|
+
if (route.fixture) {
|
|
117
|
+
return readFile(route.fixture, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
if (route.body !== void 0) {
|
|
120
|
+
return typeof route.body === "string" ? route.body : JSON.stringify(route.body);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`prepare.mock "${route.url}": either "fixture" or "body" is required`);
|
|
123
|
+
}
|
|
124
|
+
async function applyPrepare(context, prepare) {
|
|
125
|
+
if (prepare.freezeTime) {
|
|
126
|
+
const epochMs = Date.parse(prepare.freezeTime);
|
|
127
|
+
if (Number.isNaN(epochMs)) {
|
|
128
|
+
throw new Error(`prepare.freezeTime: invalid date "${prepare.freezeTime}" (use ISO 8601, e.g. "2026-06-10T09:00:00Z")`);
|
|
129
|
+
}
|
|
130
|
+
await context.addInitScript(buildFreezeTimeScript(epochMs));
|
|
131
|
+
}
|
|
132
|
+
if (prepare.seedRandom !== void 0) {
|
|
133
|
+
await context.addInitScript(buildSeedRandomScript(prepare.seedRandom));
|
|
134
|
+
}
|
|
135
|
+
if (prepare.storage) {
|
|
136
|
+
await context.addInitScript(buildStorageScript(prepare.storage));
|
|
137
|
+
}
|
|
138
|
+
const cssChunks = [];
|
|
139
|
+
if (prepare.hide.length > 0) {
|
|
140
|
+
cssChunks.push(buildHideCss(prepare.hide));
|
|
141
|
+
}
|
|
142
|
+
if (prepare.inject?.css) {
|
|
143
|
+
const cssFiles = Array.isArray(prepare.inject.css) ? prepare.inject.css : [prepare.inject.css];
|
|
144
|
+
for (const file of cssFiles) {
|
|
145
|
+
cssChunks.push(await readFile(file, "utf-8"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (cssChunks.length > 0) {
|
|
149
|
+
await context.addInitScript(buildCssInjectionScript(cssChunks.join("\n\n")));
|
|
150
|
+
}
|
|
151
|
+
if (prepare.inject?.js) {
|
|
152
|
+
const jsFiles = Array.isArray(prepare.inject.js) ? prepare.inject.js : [prepare.inject.js];
|
|
153
|
+
for (const file of jsFiles) {
|
|
154
|
+
await context.addInitScript(await readFile(file, "utf-8"));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const mock of prepare.mock) {
|
|
158
|
+
const body = await resolveMockBody(mock);
|
|
159
|
+
await context.route(
|
|
160
|
+
(url) => url.href.includes(mock.url),
|
|
161
|
+
(route) => route.fulfill({ status: mock.status, contentType: mock.contentType, body })
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
57
166
|
// src/core/recorder.ts
|
|
58
167
|
var CLICK_EFFECT_DURATION_MS = 500;
|
|
59
168
|
var REPAINT_INTERVAL_MS = 25;
|
|
@@ -139,10 +248,12 @@ var ClipwiseRecorder = class {
|
|
|
139
248
|
};
|
|
140
249
|
this.targetFps = scenario.output.fps;
|
|
141
250
|
this.cursorSpeed = scenario.effects.cursor.speed;
|
|
251
|
+
this.deviceScaleFactor = scenario.viewport.deviceScaleFactor ?? 1;
|
|
142
252
|
this.loaderDetectionEnabled = scenario.effects.smartSpeed?.enabled ?? false;
|
|
143
253
|
this.browser = await chromium.launch({ headless: true });
|
|
144
254
|
const contextOptions = {
|
|
145
|
-
viewport: this.viewport
|
|
255
|
+
viewport: this.viewport,
|
|
256
|
+
deviceScaleFactor: this.deviceScaleFactor
|
|
146
257
|
};
|
|
147
258
|
if (scenario.auth?.storageState) {
|
|
148
259
|
contextOptions.storageState = scenario.auth.storageState;
|
|
@@ -151,6 +262,9 @@ var ClipwiseRecorder = class {
|
|
|
151
262
|
if (scenario.auth?.cookies?.length) {
|
|
152
263
|
await this.context.addCookies(scenario.auth.cookies);
|
|
153
264
|
}
|
|
265
|
+
if (scenario.prepare) {
|
|
266
|
+
await applyPrepare(this.context, scenario.prepare);
|
|
267
|
+
}
|
|
154
268
|
this.page = await this.context.newPage();
|
|
155
269
|
this.rawFrames = [];
|
|
156
270
|
this.cursorTimeline = [];
|
|
@@ -250,7 +364,7 @@ var ClipwiseRecorder = class {
|
|
|
250
364
|
});
|
|
251
365
|
this.cdpClient = null;
|
|
252
366
|
}
|
|
253
|
-
await new Promise((
|
|
367
|
+
await new Promise((resolve3) => setTimeout(resolve3, 200));
|
|
254
368
|
}
|
|
255
369
|
/**
|
|
256
370
|
* Execute the full scenario with continuous capture and return a RecordingSession.
|
|
@@ -468,7 +582,7 @@ var ClipwiseRecorder = class {
|
|
|
468
582
|
const remaining = endTime - Date.now();
|
|
469
583
|
if (remaining > 0) {
|
|
470
584
|
await new Promise(
|
|
471
|
-
(
|
|
585
|
+
(resolve3) => setTimeout(resolve3, Math.min(REPAINT_INTERVAL_MS, remaining))
|
|
472
586
|
);
|
|
473
587
|
}
|
|
474
588
|
}
|
|
@@ -578,7 +692,7 @@ var ClipwiseRecorder = class {
|
|
|
578
692
|
timestamp: Date.now(),
|
|
579
693
|
sessionId: currentSessionId
|
|
580
694
|
});
|
|
581
|
-
await new Promise((
|
|
695
|
+
await new Promise((resolve3) => setTimeout(resolve3, action.delay));
|
|
582
696
|
const now = Date.now();
|
|
583
697
|
if (now - lastClickRefresh >= 400) {
|
|
584
698
|
this.clickTimeline.push({
|
|
@@ -624,7 +738,7 @@ var ClipwiseRecorder = class {
|
|
|
624
738
|
},
|
|
625
739
|
{ dy: yStep, dx: xStep, sel: action.selector ?? null }
|
|
626
740
|
);
|
|
627
|
-
await new Promise((
|
|
741
|
+
await new Promise((resolve3) => setTimeout(resolve3, 30));
|
|
628
742
|
}
|
|
629
743
|
await this.waitWithRepaints(150);
|
|
630
744
|
} else {
|
|
@@ -732,19 +846,19 @@ var ClipwiseRecorder = class {
|
|
|
732
846
|
break;
|
|
733
847
|
case "domStable":
|
|
734
848
|
conditionPromise = this.page.waitForFunction(
|
|
735
|
-
() => new Promise((
|
|
849
|
+
() => new Promise((resolve3) => {
|
|
736
850
|
let timer;
|
|
737
851
|
const observer = new MutationObserver(() => {
|
|
738
852
|
clearTimeout(timer);
|
|
739
853
|
timer = setTimeout(() => {
|
|
740
854
|
observer.disconnect();
|
|
741
|
-
|
|
855
|
+
resolve3(true);
|
|
742
856
|
}, 500);
|
|
743
857
|
});
|
|
744
858
|
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
745
859
|
timer = setTimeout(() => {
|
|
746
860
|
observer.disconnect();
|
|
747
|
-
|
|
861
|
+
resolve3(true);
|
|
748
862
|
}, 500);
|
|
749
863
|
}),
|
|
750
864
|
void 0,
|
|
@@ -834,7 +948,7 @@ var ClipwiseRecorder = class {
|
|
|
834
948
|
position: { x: point.x, y: point.y },
|
|
835
949
|
timestamp: Date.now()
|
|
836
950
|
});
|
|
837
|
-
await new Promise((
|
|
951
|
+
await new Promise((resolve3) => setTimeout(resolve3, preset.stepDelayMs));
|
|
838
952
|
}
|
|
839
953
|
} finally {
|
|
840
954
|
await this.restoreTransitions();
|
|
@@ -1010,13 +1124,11 @@ import sharp7 from "sharp";
|
|
|
1010
1124
|
|
|
1011
1125
|
// src/effects/frame.ts
|
|
1012
1126
|
import sharp from "sharp";
|
|
1013
|
-
var TITLE_BAR_HEIGHT =
|
|
1014
|
-
var
|
|
1015
|
-
var
|
|
1016
|
-
var
|
|
1017
|
-
var
|
|
1018
|
-
var ADDRESS_BAR_HEIGHT = 24;
|
|
1019
|
-
var ADDRESS_BAR_MARGIN = 70;
|
|
1127
|
+
var TITLE_BAR_HEIGHT = 48;
|
|
1128
|
+
var TRAFFIC_LIGHT_RADIUS = 6.5;
|
|
1129
|
+
var TRAFFIC_LIGHTS_START_X = 22;
|
|
1130
|
+
var TRAFFIC_LIGHT_GAP = 20;
|
|
1131
|
+
var URL_PILL_HEIGHT = 30;
|
|
1020
1132
|
var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
|
|
1021
1133
|
var IPHONE_OUTER_RADIUS = 47;
|
|
1022
1134
|
var IPHONE_INNER_RADIUS = 39;
|
|
@@ -1029,38 +1141,89 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
|
|
|
1029
1141
|
var ANDROID_OUTER_RADIUS = 35;
|
|
1030
1142
|
var ANDROID_INNER_RADIUS = 30;
|
|
1031
1143
|
var ANDROID_CAMERA_RADIUS = 6;
|
|
1032
|
-
function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1144
|
+
function buildBrowserChromeSvg(width, darkMode, dpr = 1, url = "localhost") {
|
|
1145
|
+
const c = darkMode ? {
|
|
1146
|
+
bgTop: "#3c3c3e",
|
|
1147
|
+
bgBottom: "#343436",
|
|
1148
|
+
border: "#232325",
|
|
1149
|
+
pillBg: "#28282a",
|
|
1150
|
+
pillBorder: "#48484b",
|
|
1151
|
+
text: "#d8d8da",
|
|
1152
|
+
icon: "#a8a8ac",
|
|
1153
|
+
iconDim: "#5f5f63"
|
|
1154
|
+
} : {
|
|
1155
|
+
bgTop: "#f8f7f6",
|
|
1156
|
+
bgBottom: "#eeedeb",
|
|
1157
|
+
border: "#d8d6d3",
|
|
1158
|
+
pillBg: "#ffffff",
|
|
1159
|
+
pillBorder: "#dedcd9",
|
|
1160
|
+
text: "#3a3a3c",
|
|
1161
|
+
icon: "#6f6f72",
|
|
1162
|
+
iconDim: "#bdbdc0"
|
|
1163
|
+
};
|
|
1164
|
+
const h = TITLE_BAR_HEIGHT * dpr;
|
|
1165
|
+
const midY = h / 2;
|
|
1039
1166
|
const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
|
|
1040
1167
|
const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
|
|
1041
1168
|
const tlGap = TRAFFIC_LIGHT_GAP * dpr;
|
|
1042
|
-
const
|
|
1043
|
-
const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
|
|
1044
|
-
const fontSize = 12 * dpr;
|
|
1169
|
+
const s = dpr;
|
|
1045
1170
|
const trafficLights = [
|
|
1046
|
-
{ cx: tlStartX, fill: "#ff5f57" },
|
|
1047
|
-
{ cx: tlStartX + tlGap, fill: "#febc2e" },
|
|
1048
|
-
{ cx: tlStartX + tlGap * 2, fill: "#28c840" }
|
|
1049
|
-
].map(
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1171
|
+
{ cx: tlStartX, fill: "#ff5f57", stroke: "#e0443e" },
|
|
1172
|
+
{ cx: tlStartX + tlGap, fill: "#febc2e", stroke: "#d89e24" },
|
|
1173
|
+
{ cx: tlStartX + tlGap * 2, fill: "#28c840", stroke: "#1ea133" }
|
|
1174
|
+
].map((l) => `<circle cx="${l.cx}" cy="${midY}" r="${tlR}" fill="${l.fill}" stroke="${l.stroke}" stroke-width="${0.5 * s}"/>`).join("\n ");
|
|
1175
|
+
const navX = tlStartX + tlGap * 2 + 34 * s;
|
|
1176
|
+
const back = `<path d="M ${navX + 4 * s} ${midY - 6 * s} l ${-6 * s} ${6 * s} l ${6 * s} ${6 * s}"
|
|
1177
|
+
fill="none" stroke="${c.icon}" stroke-width="${1.8 * s}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
1178
|
+
const fwdX = navX + 28 * s;
|
|
1179
|
+
const forward = `<path d="M ${fwdX - 4 * s} ${midY - 6 * s} l ${6 * s} ${6 * s} l ${-6 * s} ${6 * s}"
|
|
1180
|
+
fill="none" stroke="${c.iconDim}" stroke-width="${1.8 * s}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
1181
|
+
const relX = fwdX + 28 * s;
|
|
1182
|
+
const reload = `<path d="M ${relX + 6 * s} ${midY - 3.5 * s} a ${6 * s} ${6 * s} 0 1 0 ${1.2 * s} ${5.5 * s}"
|
|
1183
|
+
fill="none" stroke="${c.icon}" stroke-width="${1.7 * s}" stroke-linecap="round"/>
|
|
1184
|
+
<path d="M ${relX + 6.4 * s} ${midY - 7.5 * s} l ${0.4 * s} ${4.6 * s} l ${-4.6 * s} ${-0.4 * s} z" fill="${c.icon}"/>`;
|
|
1185
|
+
const fontSize = 12.5 * dpr;
|
|
1186
|
+
const pillH = URL_PILL_HEIGHT * dpr;
|
|
1187
|
+
const pillW = Math.max(200 * s, Math.min(width * 0.42, 520 * s));
|
|
1188
|
+
const pillX = (width - pillW) / 2;
|
|
1189
|
+
const pillY = midY - pillH / 2;
|
|
1190
|
+
const textW = url.length * fontSize * 0.56;
|
|
1191
|
+
const lockX = width / 2 - textW / 2 - 16 * s;
|
|
1192
|
+
const lockY = midY - 5 * s;
|
|
1193
|
+
const padlock = `
|
|
1194
|
+
<rect x="${lockX}" y="${lockY + 4 * s}" width="${9 * s}" height="${7 * s}" rx="${1.5 * s}" fill="${c.icon}"/>
|
|
1195
|
+
<path d="M ${lockX + 2 * s} ${lockY + 4 * s} v ${-2 * s} a ${2.5 * s} ${2.5 * s} 0 0 1 ${5 * s} 0 v ${2 * s}"
|
|
1196
|
+
fill="none" stroke="${c.icon}" stroke-width="${1.4 * s}"/>`;
|
|
1197
|
+
const dotsX = width - 26 * s;
|
|
1198
|
+
const dots = [-4.5, 0, 4.5].map((dy) => `<circle cx="${dotsX}" cy="${midY + dy * s}" r="${1.6 * s}" fill="${c.icon}"/>`).join("");
|
|
1199
|
+
const avatar = `
|
|
1200
|
+
<circle cx="${width - 56 * s}" cy="${midY}" r="${11 * s}" fill="url(#cwAvatar)"/>
|
|
1201
|
+
<text x="${width - 56 * s}" y="${midY + 4 * s}" text-anchor="middle"
|
|
1202
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="${10.5 * dpr}" font-weight="600" fill="#ffffff">S</text>`;
|
|
1203
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
|
|
1204
|
+
<defs>
|
|
1205
|
+
<linearGradient id="cwChromeBg" x1="0" y1="0" x2="0" y2="1">
|
|
1206
|
+
<stop offset="0" stop-color="${c.bgTop}"/>
|
|
1207
|
+
<stop offset="1" stop-color="${c.bgBottom}"/>
|
|
1208
|
+
</linearGradient>
|
|
1209
|
+
<linearGradient id="cwAvatar" x1="0" y1="0" x2="1" y2="1">
|
|
1210
|
+
<stop offset="0" stop-color="#818cf8"/>
|
|
1211
|
+
<stop offset="1" stop-color="#6366f1"/>
|
|
1212
|
+
</linearGradient>
|
|
1213
|
+
</defs>
|
|
1214
|
+
<rect width="${width}" height="${h}" fill="url(#cwChromeBg)"/>
|
|
1215
|
+
<rect y="${h - s}" width="${width}" height="${s}" fill="${c.border}"/>
|
|
1057
1216
|
${trafficLights}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1217
|
+
${back}
|
|
1218
|
+
${forward}
|
|
1219
|
+
${reload}
|
|
1220
|
+
<rect x="${pillX}" y="${pillY}" width="${pillW}" height="${pillH}"
|
|
1221
|
+
rx="${pillH / 2}" ry="${pillH / 2}" fill="${c.pillBg}" stroke="${c.pillBorder}" stroke-width="${s}"/>
|
|
1222
|
+
${padlock}
|
|
1223
|
+
<text x="${width / 2 + 7 * s}" y="${midY + fontSize * 0.35}" text-anchor="middle"
|
|
1224
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${c.text}">${url}</text>
|
|
1225
|
+
${dots}
|
|
1226
|
+
${avatar}
|
|
1064
1227
|
</svg>`;
|
|
1065
1228
|
}
|
|
1066
1229
|
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
@@ -1212,7 +1375,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
|
|
|
1212
1375
|
case "browser": {
|
|
1213
1376
|
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
1214
1377
|
const totalHeight = frameHeight + tbarH;
|
|
1215
|
-
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
|
|
1378
|
+
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr, config.url);
|
|
1216
1379
|
const chromeBuffer = Buffer.from(chromeSvg);
|
|
1217
1380
|
const canvas = await sharp({
|
|
1218
1381
|
create: {
|
|
@@ -2188,7 +2351,7 @@ var CanvasRenderer = class {
|
|
|
2188
2351
|
* Workers process frames concurrently; results are collected in order.
|
|
2189
2352
|
*/
|
|
2190
2353
|
processWithWorkers(frames, contexts, workerCount, perFrameEffects) {
|
|
2191
|
-
return new Promise((
|
|
2354
|
+
return new Promise((resolve3, reject) => {
|
|
2192
2355
|
const results = new Array(frames.length);
|
|
2193
2356
|
let completed = 0;
|
|
2194
2357
|
let nextIndex = 0;
|
|
@@ -2226,7 +2389,7 @@ var CanvasRenderer = class {
|
|
|
2226
2389
|
completed++;
|
|
2227
2390
|
if (completed === frames.length) {
|
|
2228
2391
|
workers.forEach((wk) => wk.terminate());
|
|
2229
|
-
|
|
2392
|
+
resolve3(results);
|
|
2230
2393
|
} else {
|
|
2231
2394
|
dispatch(worker);
|
|
2232
2395
|
}
|
|
@@ -2900,7 +3063,7 @@ var CanvasRenderer = class {
|
|
|
2900
3063
|
// src/compose/video-encoder.ts
|
|
2901
3064
|
import gifenc from "gifenc";
|
|
2902
3065
|
import sharp9 from "sharp";
|
|
2903
|
-
import { writeFile, mkdir, readFile, rm } from "fs/promises";
|
|
3066
|
+
import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
|
|
2904
3067
|
import { join } from "path";
|
|
2905
3068
|
import { tmpdir } from "os";
|
|
2906
3069
|
import { spawn } from "child_process";
|
|
@@ -2923,20 +3086,20 @@ function resolveEncodingParams(config) {
|
|
|
2923
3086
|
var encoderScanPromise = null;
|
|
2924
3087
|
function scanAvailableEncoders() {
|
|
2925
3088
|
if (!encoderScanPromise) {
|
|
2926
|
-
encoderScanPromise = new Promise((
|
|
3089
|
+
encoderScanPromise = new Promise((resolve3) => {
|
|
2927
3090
|
const proc = spawn("ffmpeg", ["-encoders"], {
|
|
2928
3091
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2929
3092
|
});
|
|
2930
3093
|
let out = "";
|
|
2931
3094
|
proc.stdout.on("data", (d) => out += d.toString());
|
|
2932
3095
|
proc.on("close", () => {
|
|
2933
|
-
|
|
3096
|
+
resolve3({
|
|
2934
3097
|
hevcHw: out.includes("hevc_videotoolbox"),
|
|
2935
3098
|
h264Hw: out.includes("h264_videotoolbox"),
|
|
2936
3099
|
av1: out.includes("libsvtav1")
|
|
2937
3100
|
});
|
|
2938
3101
|
});
|
|
2939
|
-
proc.on("error", () =>
|
|
3102
|
+
proc.on("error", () => resolve3({ hevcHw: false, h264Hw: false, av1: false }));
|
|
2940
3103
|
});
|
|
2941
3104
|
}
|
|
2942
3105
|
return encoderScanPromise;
|
|
@@ -3049,7 +3212,7 @@ async function encodeMp4(frames, config, audio) {
|
|
|
3049
3212
|
const encoder = await detectVideoEncoder(config.codec);
|
|
3050
3213
|
const params = resolveEncodingParams(config);
|
|
3051
3214
|
await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio);
|
|
3052
|
-
return await
|
|
3215
|
+
return await readFile2(outputPath);
|
|
3053
3216
|
} finally {
|
|
3054
3217
|
await rm(outputPath, { force: true }).catch(() => {
|
|
3055
3218
|
});
|
|
@@ -3065,7 +3228,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
3065
3228
|
if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
|
|
3066
3229
|
}
|
|
3067
3230
|
const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
|
|
3068
|
-
return new Promise((
|
|
3231
|
+
return new Promise((resolve3, reject) => {
|
|
3069
3232
|
const ffmpeg = spawn(
|
|
3070
3233
|
"ffmpeg",
|
|
3071
3234
|
[
|
|
@@ -3100,7 +3263,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
3100
3263
|
ffmpeg.stderr.on("data", (d) => stderr += d.toString());
|
|
3101
3264
|
ffmpeg.on("close", (code) => {
|
|
3102
3265
|
if (code === 0) {
|
|
3103
|
-
|
|
3266
|
+
resolve3();
|
|
3104
3267
|
} else {
|
|
3105
3268
|
reject(
|
|
3106
3269
|
new Error(
|
|
@@ -3139,7 +3302,7 @@ async function encodeMp4Stream(frames, config, audio) {
|
|
|
3139
3302
|
const encoder = await detectVideoEncoder(config.codec);
|
|
3140
3303
|
const params = resolveEncodingParams(config);
|
|
3141
3304
|
await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
|
|
3142
|
-
return await
|
|
3305
|
+
return await readFile2(outputPath);
|
|
3143
3306
|
} finally {
|
|
3144
3307
|
await rm(outputPath, { force: true }).catch(() => {
|
|
3145
3308
|
});
|
|
@@ -3192,7 +3355,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
3192
3355
|
if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
|
|
3193
3356
|
}
|
|
3194
3357
|
const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
|
|
3195
|
-
return new Promise((
|
|
3358
|
+
return new Promise((resolve3, reject) => {
|
|
3196
3359
|
const ffmpeg = spawn(
|
|
3197
3360
|
"ffmpeg",
|
|
3198
3361
|
[
|
|
@@ -3225,7 +3388,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
3225
3388
|
ffmpeg.stderr.on("data", (d) => stderr += d.toString());
|
|
3226
3389
|
ffmpeg.on("close", (code) => {
|
|
3227
3390
|
if (code === 0) {
|
|
3228
|
-
|
|
3391
|
+
resolve3();
|
|
3229
3392
|
} else {
|
|
3230
3393
|
reject(
|
|
3231
3394
|
new Error(
|
|
@@ -3349,9 +3512,342 @@ var StreamingSession = class extends EventEmitter {
|
|
|
3349
3512
|
}
|
|
3350
3513
|
};
|
|
3351
3514
|
|
|
3352
|
-
// src/
|
|
3515
|
+
// src/scenes/runner.ts
|
|
3516
|
+
import { chromium as chromium2 } from "playwright";
|
|
3517
|
+
import { createServer } from "http";
|
|
3518
|
+
import { execSync } from "child_process";
|
|
3519
|
+
import { readFile as readFile3, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
|
|
3520
|
+
import { existsSync as existsSync2 } from "fs";
|
|
3521
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
3522
|
+
import { join as join2, resolve, dirname, isAbsolute } from "path";
|
|
3523
|
+
import { pathToFileURL, fileURLToPath as fileURLToPath2 } from "url";
|
|
3353
3524
|
import { parse as parseYaml } from "yaml";
|
|
3354
|
-
import
|
|
3525
|
+
import sharp10 from "sharp";
|
|
3526
|
+
async function loadBrandMotion(scenarioDir) {
|
|
3527
|
+
const defaults = { accent: "#6366f1", font: "editorial", annotations: true };
|
|
3528
|
+
for (const candidate of [
|
|
3529
|
+
resolve(scenarioDir, "..", "brand.yaml"),
|
|
3530
|
+
// .clipwise/scenarios/x.yaml → .clipwise/brand.yaml
|
|
3531
|
+
resolve(scenarioDir, "brand.yaml"),
|
|
3532
|
+
resolve(process.cwd(), ".clipwise", "brand.yaml")
|
|
3533
|
+
]) {
|
|
3534
|
+
try {
|
|
3535
|
+
const raw = parseYaml(await readFile3(candidate, "utf-8"));
|
|
3536
|
+
return {
|
|
3537
|
+
accent: raw.accent ?? defaults.accent,
|
|
3538
|
+
font: raw.font ?? defaults.font,
|
|
3539
|
+
annotations: raw.annotations ?? defaults.annotations
|
|
3540
|
+
};
|
|
3541
|
+
} catch {
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
return defaults;
|
|
3545
|
+
}
|
|
3546
|
+
function resolveMotionTemplate(template, scenarioDir) {
|
|
3547
|
+
if (template.endsWith(".html")) {
|
|
3548
|
+
const p = isAbsolute(template) ? template : resolve(scenarioDir, template);
|
|
3549
|
+
if (!existsSync2(p)) throw new Error(`Motion template not found: ${p}`);
|
|
3550
|
+
return pathToFileURL(p).href;
|
|
3551
|
+
}
|
|
3552
|
+
const here = dirname(fileURLToPath2(import.meta.url));
|
|
3553
|
+
for (const base of [
|
|
3554
|
+
resolve(here, "..", "templates", "motion"),
|
|
3555
|
+
// dist/ 기준 → 패키지 루트
|
|
3556
|
+
resolve(here, "..", "..", "templates", "motion")
|
|
3557
|
+
// src/scenes/ 기준
|
|
3558
|
+
]) {
|
|
3559
|
+
const p = join2(base, `${template}.html`);
|
|
3560
|
+
if (existsSync2(p)) return pathToFileURL(p).href;
|
|
3561
|
+
}
|
|
3562
|
+
throw new Error(
|
|
3563
|
+
`Unknown built-in motion template "${template}" (built-ins: intro-title, feature-callout, kinetic-type, vignette)`
|
|
3564
|
+
);
|
|
3565
|
+
}
|
|
3566
|
+
function footageEffects(effects) {
|
|
3567
|
+
return {
|
|
3568
|
+
...effects,
|
|
3569
|
+
zoom: { ...effects.zoom, enabled: false },
|
|
3570
|
+
deviceFrame: { ...effects.deviceFrame, enabled: false },
|
|
3571
|
+
keystroke: { ...effects.keystroke, enabled: false },
|
|
3572
|
+
speedRamp: { ...effects.speedRamp, enabled: false },
|
|
3573
|
+
smartSpeed: effects.smartSpeed ? { ...effects.smartSpeed, enabled: false } : effects.smartSpeed,
|
|
3574
|
+
background: {
|
|
3575
|
+
...effects.background,
|
|
3576
|
+
type: "solid",
|
|
3577
|
+
value: "#000000",
|
|
3578
|
+
padding: 0,
|
|
3579
|
+
borderRadius: 0,
|
|
3580
|
+
shadow: false
|
|
3581
|
+
}
|
|
3582
|
+
};
|
|
3583
|
+
}
|
|
3584
|
+
async function executeStepsForProbe(page, scene) {
|
|
3585
|
+
for (const step of scene.steps) {
|
|
3586
|
+
for (const action of step.actions) {
|
|
3587
|
+
switch (action.action) {
|
|
3588
|
+
case "navigate":
|
|
3589
|
+
await page.goto(action.url, { waitUntil: action.waitUntil ?? "networkidle" });
|
|
3590
|
+
break;
|
|
3591
|
+
case "click":
|
|
3592
|
+
await page.click(action.selector, { timeout: action.timeout ?? 15e3 });
|
|
3593
|
+
break;
|
|
3594
|
+
case "type":
|
|
3595
|
+
await page.fill(action.selector, action.text, { timeout: action.timeout ?? 15e3 });
|
|
3596
|
+
break;
|
|
3597
|
+
case "hover":
|
|
3598
|
+
await page.hover(action.selector, { timeout: action.timeout ?? 15e3 });
|
|
3599
|
+
break;
|
|
3600
|
+
case "scroll":
|
|
3601
|
+
await page.evaluate(
|
|
3602
|
+
({ x, y }) => window.scrollBy(x, y),
|
|
3603
|
+
{ x: action.x ?? 0, y: action.y ?? 0 }
|
|
3604
|
+
);
|
|
3605
|
+
break;
|
|
3606
|
+
case "wait":
|
|
3607
|
+
await page.waitForTimeout(Math.min(action.duration, 3e3));
|
|
3608
|
+
break;
|
|
3609
|
+
case "waitForSelector":
|
|
3610
|
+
await page.waitForSelector(action.selector, {
|
|
3611
|
+
state: action.state ?? "visible",
|
|
3612
|
+
timeout: action.timeout ?? 15e3
|
|
3613
|
+
});
|
|
3614
|
+
break;
|
|
3615
|
+
case "waitForFunction":
|
|
3616
|
+
await page.waitForFunction(action.expression, void 0, { timeout: action.timeout ?? 3e4 });
|
|
3617
|
+
break;
|
|
3618
|
+
default:
|
|
3619
|
+
break;
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
function segmentOutput(scenario) {
|
|
3625
|
+
const dpr = scenario.viewport.deviceScaleFactor ?? 1;
|
|
3626
|
+
return {
|
|
3627
|
+
...scenario.output,
|
|
3628
|
+
width: scenario.output.width * dpr,
|
|
3629
|
+
height: scenario.output.height * dpr,
|
|
3630
|
+
preset: "archive"
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
async function recordFootageTake(scenario, scene, selectors) {
|
|
3634
|
+
const boxes = /* @__PURE__ */ new Map();
|
|
3635
|
+
if (selectors.length > 0) {
|
|
3636
|
+
const browser = await chromium2.launch();
|
|
3637
|
+
const context = await browser.newContext({
|
|
3638
|
+
viewport: { width: scenario.viewport.width, height: scenario.viewport.height }
|
|
3639
|
+
});
|
|
3640
|
+
if (scenario.prepare) await applyPrepare(context, scenario.prepare);
|
|
3641
|
+
const page = await context.newPage();
|
|
3642
|
+
await executeStepsForProbe(page, scene);
|
|
3643
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
3644
|
+
for (const selector of selectors) {
|
|
3645
|
+
const box = await page.locator(selector).first().boundingBox();
|
|
3646
|
+
if (box) boxes.set(selector, box);
|
|
3647
|
+
}
|
|
3648
|
+
await browser.close();
|
|
3649
|
+
}
|
|
3650
|
+
const takeScenario = {
|
|
3651
|
+
...scenario,
|
|
3652
|
+
steps: scene.steps,
|
|
3653
|
+
scenes: void 0,
|
|
3654
|
+
effects: footageEffects(scenario.effects)
|
|
3655
|
+
};
|
|
3656
|
+
const recorder = new ClipwiseRecorder();
|
|
3657
|
+
const session = await recorder.record(takeScenario);
|
|
3658
|
+
const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
|
|
3659
|
+
const composed = [];
|
|
3660
|
+
for await (const f of renderer.composeStream(session.frames)) composed.push(f);
|
|
3661
|
+
const frames = await Promise.all(
|
|
3662
|
+
composed.map(
|
|
3663
|
+
(f) => f.rawInfo ? sharp10(f.buffer, {
|
|
3664
|
+
raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
|
|
3665
|
+
}).png().toBuffer() : Promise.resolve(f.buffer)
|
|
3666
|
+
)
|
|
3667
|
+
);
|
|
3668
|
+
const anchors = [];
|
|
3669
|
+
for (let k = 0; k < scene.steps.length; k++) {
|
|
3670
|
+
const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
|
|
3671
|
+
anchors.push(Math.max(0, idx) / scenario.output.fps);
|
|
3672
|
+
}
|
|
3673
|
+
return { frames, anchors, boxes };
|
|
3674
|
+
}
|
|
3675
|
+
function fitCardW(cw, ch, maxW = 940, maxH = 540) {
|
|
3676
|
+
return Math.round(Math.min(maxW, maxH * cw / ch));
|
|
3677
|
+
}
|
|
3678
|
+
async function captureMotionSegment(templateUrl, props, durationMs, scenario) {
|
|
3679
|
+
const fps = scenario.output.fps;
|
|
3680
|
+
const totalFrames = Math.round(durationMs / 1e3 * fps);
|
|
3681
|
+
const browser = await chromium2.launch();
|
|
3682
|
+
const page = await browser.newPage({
|
|
3683
|
+
viewport: { width: scenario.output.width, height: scenario.output.height },
|
|
3684
|
+
deviceScaleFactor: scenario.viewport.deviceScaleFactor ?? 1
|
|
3685
|
+
});
|
|
3686
|
+
const params = new URLSearchParams(
|
|
3687
|
+
Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
|
|
3688
|
+
);
|
|
3689
|
+
await page.goto(`${templateUrl}?${params}`, { waitUntil: "load" });
|
|
3690
|
+
await page.evaluate(() => document.fonts.ready);
|
|
3691
|
+
const frames = [];
|
|
3692
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
3693
|
+
const t = i / fps * 1e3;
|
|
3694
|
+
await page.evaluate(
|
|
3695
|
+
(time) => window.__clipwiseSeek(time),
|
|
3696
|
+
t
|
|
3697
|
+
);
|
|
3698
|
+
frames.push({ index: i, buffer: await page.screenshot({ type: "png" }), timestamp: t });
|
|
3699
|
+
}
|
|
3700
|
+
await browser.close();
|
|
3701
|
+
return { buffer: await encodeMp4(frames, segmentOutput(scenario)), seconds: totalFrames / fps };
|
|
3702
|
+
}
|
|
3703
|
+
function vignetteProps(scene, take, serverBase, scenario, brand) {
|
|
3704
|
+
const W = scenario.viewport.width;
|
|
3705
|
+
const H = scenario.viewport.height;
|
|
3706
|
+
let crop = { x: 0, y: 0, w: W, h: H };
|
|
3707
|
+
if (scene.crop) {
|
|
3708
|
+
const c = scene.crop;
|
|
3709
|
+
if (c.selector) {
|
|
3710
|
+
const box = take.boxes.get(c.selector);
|
|
3711
|
+
if (!box) throw new Error(`vignette crop selector "${c.selector}" not found in footage "${scene.footage}"`);
|
|
3712
|
+
crop = {
|
|
3713
|
+
x: Math.max(0, box.x - c.pad),
|
|
3714
|
+
y: Math.max(0, box.y - c.pad),
|
|
3715
|
+
w: box.width + c.pad * 2,
|
|
3716
|
+
h: box.height + c.pad * 2
|
|
3717
|
+
};
|
|
3718
|
+
} else if (c.w !== void 0) {
|
|
3719
|
+
crop = { x: c.x ?? 0, y: c.y ?? 0, w: c.w, h: c.h ?? H };
|
|
3720
|
+
}
|
|
3721
|
+
if (c.maxH) crop.h = Math.min(crop.h, c.maxH);
|
|
3722
|
+
}
|
|
3723
|
+
const start = typeof scene.start === "number" ? scene.start : (take.anchors[scene.start.step] ?? 0) + scene.start.offset;
|
|
3724
|
+
const props = {
|
|
3725
|
+
accent: brand.accent,
|
|
3726
|
+
font: brand.font,
|
|
3727
|
+
dur: scene.duration / 1e3,
|
|
3728
|
+
layout: scene.layout,
|
|
3729
|
+
num: scene.num ?? "",
|
|
3730
|
+
label: scene.label ?? "",
|
|
3731
|
+
caption: scene.caption ?? "",
|
|
3732
|
+
base: serverBase,
|
|
3733
|
+
count: take.frames.length,
|
|
3734
|
+
fps: scenario.output.fps,
|
|
3735
|
+
start,
|
|
3736
|
+
rate: scene.rate,
|
|
3737
|
+
cropX: crop.x,
|
|
3738
|
+
cropY: crop.y,
|
|
3739
|
+
cropW: crop.w,
|
|
3740
|
+
cropH: crop.h,
|
|
3741
|
+
cardW: fitCardW(crop.w, crop.h),
|
|
3742
|
+
pushFrom: scene.push?.from ?? 1,
|
|
3743
|
+
pushTo: scene.push?.to ?? 1
|
|
3744
|
+
};
|
|
3745
|
+
if (scene.code?.length) props.code = scene.code.join("||");
|
|
3746
|
+
if (brand.annotations && scene.fx.length > 0) {
|
|
3747
|
+
props.fx = scene.fx.map((fx) => {
|
|
3748
|
+
let coords = fx.coords;
|
|
3749
|
+
if (fx.selector) {
|
|
3750
|
+
const box = take.boxes.get(fx.selector);
|
|
3751
|
+
if (!box) throw new Error(`vignette fx selector "${fx.selector}" not found in footage "${scene.footage}"`);
|
|
3752
|
+
coords = fx.kind === "circle" ? [box.x, box.y, box.width, box.height] : [box.x - 160, box.y + 120, box.x - 12, box.y + box.height / 2];
|
|
3753
|
+
}
|
|
3754
|
+
return `${fx.kind}@${coords.join(",")}@${fx.delay}`;
|
|
3755
|
+
}).join(";");
|
|
3756
|
+
}
|
|
3757
|
+
return props;
|
|
3758
|
+
}
|
|
3759
|
+
async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
|
|
3760
|
+
const scenes = scenario.scenes ?? [];
|
|
3761
|
+
const screens = scenes.filter((s) => s.type === "screen");
|
|
3762
|
+
const timeline = scenes.filter((s) => s.type !== "screen");
|
|
3763
|
+
const brand = await loadBrandMotion(scenarioDir);
|
|
3764
|
+
const selectorsByFootage = /* @__PURE__ */ new Map();
|
|
3765
|
+
for (const scene of timeline) {
|
|
3766
|
+
if (scene.type !== "vignette") continue;
|
|
3767
|
+
const set = selectorsByFootage.get(scene.footage) ?? /* @__PURE__ */ new Set();
|
|
3768
|
+
if (scene.crop?.selector) set.add(scene.crop.selector);
|
|
3769
|
+
for (const fx of scene.fx) if (fx.selector) set.add(fx.selector);
|
|
3770
|
+
selectorsByFootage.set(scene.footage, set);
|
|
3771
|
+
}
|
|
3772
|
+
const takes = /* @__PURE__ */ new Map();
|
|
3773
|
+
for (const screen of screens) {
|
|
3774
|
+
onProgress?.({ scene: 0, total: timeline.length, label: `footage "${screen.id}"` });
|
|
3775
|
+
takes.set(
|
|
3776
|
+
screen.id,
|
|
3777
|
+
await recordFootageTake(scenario, screen, [...selectorsByFootage.get(screen.id) ?? []])
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3780
|
+
const server = createServer((req, res) => {
|
|
3781
|
+
const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
|
|
3782
|
+
const take = m ? takes.get(m[1]) : void 0;
|
|
3783
|
+
if (take) {
|
|
3784
|
+
const idx = Math.min(take.frames.length - 1, parseInt(m[2], 10));
|
|
3785
|
+
res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
|
|
3786
|
+
res.end(take.frames[idx]);
|
|
3787
|
+
} else {
|
|
3788
|
+
res.writeHead(404);
|
|
3789
|
+
res.end();
|
|
3790
|
+
}
|
|
3791
|
+
});
|
|
3792
|
+
await new Promise((r) => server.listen(0, r));
|
|
3793
|
+
const port = server.address().port;
|
|
3794
|
+
const totalMs = timeline.reduce((s, sc) => s + sc.duration, 0);
|
|
3795
|
+
let elapsedMs = 0;
|
|
3796
|
+
const segments = [];
|
|
3797
|
+
const tmp = await mkdtemp(join2(tmpdir2(), "clipwise-scenes-"));
|
|
3798
|
+
try {
|
|
3799
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
3800
|
+
const scene = timeline[i];
|
|
3801
|
+
const label = scene.type === "motion" ? scene.template : `vignette(${scene.footage})`;
|
|
3802
|
+
onProgress?.({ scene: i + 1, total: timeline.length, label });
|
|
3803
|
+
const thread = brand.annotations ? {
|
|
3804
|
+
threadFrom: (elapsedMs / totalMs).toFixed(4),
|
|
3805
|
+
threadTo: ((elapsedMs + scene.duration) / totalMs).toFixed(4)
|
|
3806
|
+
} : {};
|
|
3807
|
+
elapsedMs += scene.duration;
|
|
3808
|
+
let segment;
|
|
3809
|
+
if (scene.type === "motion") {
|
|
3810
|
+
const url = resolveMotionTemplate(scene.template, scenarioDir);
|
|
3811
|
+
segment = await captureMotionSegment(
|
|
3812
|
+
url,
|
|
3813
|
+
{ accent: brand.accent, font: brand.font, dur: scene.duration / 1e3, ...scene.props, ...thread },
|
|
3814
|
+
scene.duration,
|
|
3815
|
+
scenario
|
|
3816
|
+
);
|
|
3817
|
+
} else {
|
|
3818
|
+
const take = takes.get(scene.footage);
|
|
3819
|
+
const url = resolveMotionTemplate("vignette", scenarioDir);
|
|
3820
|
+
segment = await captureMotionSegment(
|
|
3821
|
+
url,
|
|
3822
|
+
{ ...vignetteProps(scene, take, `http://localhost:${port}/${scene.footage}`, scenario, brand), ...thread },
|
|
3823
|
+
scene.duration,
|
|
3824
|
+
scenario
|
|
3825
|
+
);
|
|
3826
|
+
}
|
|
3827
|
+
const segPath = join2(tmp, `s${i}.mp4`);
|
|
3828
|
+
await writeFile2(segPath, segment.buffer);
|
|
3829
|
+
segments.push({ path: segPath, seconds: segment.seconds });
|
|
3830
|
+
}
|
|
3831
|
+
} finally {
|
|
3832
|
+
server.close();
|
|
3833
|
+
}
|
|
3834
|
+
const filters = segments.map((_, i) => `[${i}:v]format=yuv420p[v${i}]`).join(";");
|
|
3835
|
+
const concatInputs = segments.map((_, i) => `[v${i}]`).join("");
|
|
3836
|
+
const outPath = join2(tmp, "timeline.mp4");
|
|
3837
|
+
execSync(
|
|
3838
|
+
`ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} -filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
|
|
3839
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
3840
|
+
);
|
|
3841
|
+
const buffer = await readFile3(outPath);
|
|
3842
|
+
await rm2(tmp, { recursive: true, force: true }).catch(() => {
|
|
3843
|
+
});
|
|
3844
|
+
return buffer;
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
// src/script/parser.ts
|
|
3848
|
+
import { parse as parseYaml2 } from "yaml";
|
|
3849
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
3850
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
3355
3851
|
|
|
3356
3852
|
// src/script/types.ts
|
|
3357
3853
|
import { z } from "zod";
|
|
@@ -3522,7 +4018,9 @@ var BackgroundSchema = z.object({
|
|
|
3522
4018
|
var DeviceFrameSchema = z.object({
|
|
3523
4019
|
enabled: z.boolean().default(false),
|
|
3524
4020
|
type: z.enum(["browser", "macbook", "iphone", "ipad", "android", "none"]).default("browser"),
|
|
3525
|
-
darkMode: z.boolean().default(false)
|
|
4021
|
+
darkMode: z.boolean().default(false),
|
|
4022
|
+
/** browser 타입의 주소창에 표시할 URL (실제 녹화 URL과 무관한 표시용). */
|
|
4023
|
+
url: z.string().default("localhost")
|
|
3526
4024
|
});
|
|
3527
4025
|
var SpeedRampConfigSchema = z.object({
|
|
3528
4026
|
enabled: z.boolean().default(false),
|
|
@@ -3593,7 +4091,8 @@ var OutputConfigSchema = z.object({
|
|
|
3593
4091
|
preset: z.enum(["social", "balanced", "archive"]).optional(),
|
|
3594
4092
|
/** Codec override: h264 (default), hevc (10-bit), av1 (smallest files, slow encode) */
|
|
3595
4093
|
codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
|
|
3596
|
-
|
|
4094
|
+
// Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
|
|
4095
|
+
outputDir: z.string().default(".clipwise/output"),
|
|
3597
4096
|
filename: z.string().default("clipwise-recording")
|
|
3598
4097
|
});
|
|
3599
4098
|
var StepEffectsOverrideSchema = z.object({
|
|
@@ -3648,20 +4147,124 @@ var AuthConfigSchema = z.object({
|
|
|
3648
4147
|
})
|
|
3649
4148
|
).optional()
|
|
3650
4149
|
});
|
|
4150
|
+
var MockRouteSchema = z.object({
|
|
4151
|
+
/** 매칭할 URL 부분 문자열 (예: "/api/dashboard/stats"). */
|
|
4152
|
+
url: z.string().min(1),
|
|
4153
|
+
/** 응답 본문 JSON 파일 경로 (시나리오 파일 기준 상대 경로). */
|
|
4154
|
+
fixture: z.string().optional(),
|
|
4155
|
+
/** 인라인 응답 본문 — fixture 대신 YAML에 직접 작성. */
|
|
4156
|
+
body: z.unknown().optional(),
|
|
4157
|
+
status: z.number().int().min(100).max(599).default(200),
|
|
4158
|
+
contentType: z.string().default("application/json")
|
|
4159
|
+
});
|
|
4160
|
+
var PrepareConfigSchema = z.object({
|
|
4161
|
+
/** 녹화에서 숨길 요소의 CSS 셀렉터 (쿠키 배너, dev 오버레이 등). */
|
|
4162
|
+
hide: z.array(z.string().min(1)).default([]),
|
|
4163
|
+
/** Date/Date.now를 이 시각으로 고정 (ISO 8601, 예: "2026-06-10T09:00:00Z"). */
|
|
4164
|
+
freezeTime: z.string().optional(),
|
|
4165
|
+
/** Math.random을 이 시드의 결정론적 PRNG로 대체. */
|
|
4166
|
+
seedRandom: z.number().int().optional(),
|
|
4167
|
+
/** 페이지 로드 전 시드할 웹 스토리지 항목. */
|
|
4168
|
+
storage: z.object({
|
|
4169
|
+
localStorage: z.record(z.string()).default({}),
|
|
4170
|
+
sessionStorage: z.record(z.string()).default({})
|
|
4171
|
+
}).optional(),
|
|
4172
|
+
/** 네트워크 응답 목(mock) — 사용자 DB를 시드하지 않고 데모 데이터 제공. */
|
|
4173
|
+
mock: z.array(MockRouteSchema).default([]),
|
|
4174
|
+
/** 임의 CSS/JS 파일 주입 (시나리오 파일 기준 상대 경로). */
|
|
4175
|
+
inject: z.object({
|
|
4176
|
+
css: z.union([z.string(), z.array(z.string())]).optional(),
|
|
4177
|
+
js: z.union([z.string(), z.array(z.string())]).optional()
|
|
4178
|
+
}).optional()
|
|
4179
|
+
});
|
|
4180
|
+
var MotionSceneSchema = z.object({
|
|
4181
|
+
type: z.literal("motion"),
|
|
4182
|
+
/** 내장 템플릿(intro-title|feature-callout|kinetic-type|vignette) 또는 .html 경로. */
|
|
4183
|
+
template: z.string().min(1),
|
|
4184
|
+
/** 신 길이 (ms). */
|
|
4185
|
+
duration: z.number().min(200).max(6e4),
|
|
4186
|
+
/** 템플릿에 query param으로 주입할 props. */
|
|
4187
|
+
props: z.record(z.union([z.string(), z.number(), z.boolean()])).default({})
|
|
4188
|
+
});
|
|
4189
|
+
var ScreenSceneSchema = z.object({
|
|
4190
|
+
type: z.literal("screen"),
|
|
4191
|
+
/** vignette가 참조할 푸티지 ID. */
|
|
4192
|
+
id: z.string().min(1),
|
|
4193
|
+
steps: z.array(StepSchema).min(1)
|
|
4194
|
+
});
|
|
4195
|
+
var SceneFxSchema = z.object({
|
|
4196
|
+
kind: z.enum(["circle", "arrow"]),
|
|
4197
|
+
/** 대상 요소 셀렉터 — bounding box를 실측해 좌표로 사용. */
|
|
4198
|
+
selector: z.string().optional(),
|
|
4199
|
+
/** 명시 좌표 — circle: [x,y,w,h], arrow: [x1,y1,x2,y2]. */
|
|
4200
|
+
coords: z.array(z.number()).length(4).optional(),
|
|
4201
|
+
/** 신 내 드로잉 시작 시각 (ms). */
|
|
4202
|
+
delay: z.number().min(0).default(0)
|
|
4203
|
+
});
|
|
4204
|
+
var VignetteSceneSchema = z.object({
|
|
4205
|
+
type: z.literal("vignette"),
|
|
4206
|
+
/** 인용할 screen 신의 id. */
|
|
4207
|
+
footage: z.string().min(1),
|
|
4208
|
+
duration: z.number().min(500).max(6e4),
|
|
4209
|
+
layout: z.enum(["hero", "crop", "split"]).default("hero"),
|
|
4210
|
+
num: z.string().optional(),
|
|
4211
|
+
label: z.string().optional(),
|
|
4212
|
+
caption: z.string().optional(),
|
|
4213
|
+
/** split 레이아웃의 코드 카드 라인들. */
|
|
4214
|
+
code: z.array(z.string()).optional(),
|
|
4215
|
+
/** 크롭 영역 — selector 실측(+pad) 또는 명시 좌표. 생략 시 전체 화면. */
|
|
4216
|
+
crop: z.object({
|
|
4217
|
+
selector: z.string().optional(),
|
|
4218
|
+
pad: z.number().default(14),
|
|
4219
|
+
x: z.number().optional(),
|
|
4220
|
+
y: z.number().optional(),
|
|
4221
|
+
w: z.number().optional(),
|
|
4222
|
+
h: z.number().optional(),
|
|
4223
|
+
/** 크롭 높이 상한 (px, 원본 기준) — 와이드 스트립 연출용. */
|
|
4224
|
+
maxH: z.number().optional()
|
|
4225
|
+
}).optional(),
|
|
4226
|
+
/** 푸시인 카메라 (스케일 from→to). */
|
|
4227
|
+
push: z.object({ from: z.number().default(1), to: z.number().default(1) }).optional(),
|
|
4228
|
+
/** 푸티지 인용 시작점 — 초(number) 또는 step 경계 anchor. */
|
|
4229
|
+
start: z.union([z.number(), z.object({ step: z.number().int().min(0), offset: z.number().default(0) })]).default(0),
|
|
4230
|
+
/** 푸티지 재생 배속. */
|
|
4231
|
+
rate: z.number().min(0.1).max(8).default(1),
|
|
4232
|
+
fx: z.array(SceneFxSchema).default([])
|
|
4233
|
+
});
|
|
4234
|
+
var SceneSchema = z.discriminatedUnion("type", [
|
|
4235
|
+
MotionSceneSchema,
|
|
4236
|
+
ScreenSceneSchema,
|
|
4237
|
+
VignetteSceneSchema
|
|
4238
|
+
]);
|
|
3651
4239
|
var ScenarioSchema = z.object({
|
|
3652
4240
|
name: z.string(),
|
|
3653
4241
|
description: z.string().optional(),
|
|
3654
4242
|
viewport: z.object({
|
|
3655
4243
|
width: z.number().default(1280),
|
|
3656
|
-
height: z.number().default(800)
|
|
4244
|
+
height: z.number().default(800),
|
|
4245
|
+
/** HiDPI 캡처 배율 — 2면 물리 픽셀 2배(레티나급)로 녹화·합성한다. */
|
|
4246
|
+
deviceScaleFactor: z.number().min(1).max(3).default(1)
|
|
3657
4247
|
}).default({}),
|
|
3658
4248
|
/** Optional authentication — restores browser session for logged-in pages. */
|
|
3659
4249
|
auth: AuthConfigSchema.optional(),
|
|
4250
|
+
/** Optional recording-time runtime injection (hide/mock/freezeTime/...). */
|
|
4251
|
+
prepare: PrepareConfigSchema.optional(),
|
|
3660
4252
|
effects: EffectsConfigSchema.default({}),
|
|
3661
4253
|
output: OutputConfigSchema.default({}),
|
|
3662
4254
|
/** Optional audio narration — muxed into MP4 output. */
|
|
3663
4255
|
audio: AudioConfigSchema.optional(),
|
|
3664
|
-
steps
|
|
4256
|
+
/** steps 기반(클래식) 시나리오. scenes가 있으면 생략 가능. */
|
|
4257
|
+
steps: z.array(StepSchema).default([]),
|
|
4258
|
+
/** Scene System (v0.9 preview) — motion/screen/vignette 타임라인. */
|
|
4259
|
+
scenes: z.array(SceneSchema).optional()
|
|
4260
|
+
}).superRefine((s, ctx) => {
|
|
4261
|
+
if (s.steps.length === 0 && !s.scenes?.length) {
|
|
4262
|
+
ctx.addIssue({
|
|
4263
|
+
code: z.ZodIssueCode.custom,
|
|
4264
|
+
path: ["steps"],
|
|
4265
|
+
message: "Array must contain at least 1 element(s) \u2014 provide steps or scenes"
|
|
4266
|
+
});
|
|
4267
|
+
}
|
|
3665
4268
|
});
|
|
3666
4269
|
|
|
3667
4270
|
// src/script/parser.ts
|
|
@@ -3669,7 +4272,7 @@ import { ZodError } from "zod";
|
|
|
3669
4272
|
function parseScenario(yamlContent) {
|
|
3670
4273
|
let raw;
|
|
3671
4274
|
try {
|
|
3672
|
-
raw =
|
|
4275
|
+
raw = parseYaml2(yamlContent);
|
|
3673
4276
|
} catch (error) {
|
|
3674
4277
|
const message = error instanceof Error ? error.message : "Unknown parse error";
|
|
3675
4278
|
throw new Error(`YAML parse error: ${message}`);
|
|
@@ -3688,21 +4291,69 @@ ${details}`);
|
|
|
3688
4291
|
throw error;
|
|
3689
4292
|
}
|
|
3690
4293
|
}
|
|
4294
|
+
function resolvePreparePaths(scenario, scenarioDir) {
|
|
4295
|
+
const prepare = scenario.prepare;
|
|
4296
|
+
if (!prepare) return;
|
|
4297
|
+
const abs = (p) => isAbsolute2(p) ? p : resolve2(scenarioDir, p);
|
|
4298
|
+
for (const mock of prepare.mock) {
|
|
4299
|
+
if (mock.fixture) mock.fixture = abs(mock.fixture);
|
|
4300
|
+
}
|
|
4301
|
+
if (prepare.inject?.css) {
|
|
4302
|
+
prepare.inject.css = Array.isArray(prepare.inject.css) ? prepare.inject.css.map(abs) : abs(prepare.inject.css);
|
|
4303
|
+
}
|
|
4304
|
+
if (prepare.inject?.js) {
|
|
4305
|
+
prepare.inject.js = Array.isArray(prepare.inject.js) ? prepare.inject.js.map(abs) : abs(prepare.inject.js);
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
3691
4308
|
async function loadScenario(filePath) {
|
|
3692
4309
|
let content;
|
|
3693
4310
|
try {
|
|
3694
|
-
content = await
|
|
4311
|
+
content = await readFile4(filePath, "utf-8");
|
|
3695
4312
|
} catch (error) {
|
|
3696
4313
|
const message = error instanceof Error ? error.message : "Unknown file error";
|
|
3697
4314
|
throw new Error(`Failed to read scenario file "${filePath}": ${message}`);
|
|
3698
4315
|
}
|
|
3699
|
-
|
|
4316
|
+
const scenario = parseScenario(content);
|
|
4317
|
+
resolvePreparePaths(scenario, dirname2(resolve2(filePath)));
|
|
4318
|
+
return scenario;
|
|
3700
4319
|
}
|
|
3701
4320
|
|
|
3702
4321
|
// src/script/validator.ts
|
|
3703
4322
|
function validateScenario(scenario) {
|
|
3704
4323
|
const errors = [];
|
|
3705
4324
|
const warnings = [];
|
|
4325
|
+
if (scenario.scenes?.length) {
|
|
4326
|
+
const screenIds = new Set(
|
|
4327
|
+
scenario.scenes.filter((s) => s.type === "screen").map((s) => s.id)
|
|
4328
|
+
);
|
|
4329
|
+
const timeline = scenario.scenes.filter((s) => s.type !== "screen");
|
|
4330
|
+
if (timeline.length === 0) {
|
|
4331
|
+
errors.push("scenes: at least one motion or vignette scene is required (screen scenes are footage sources only)");
|
|
4332
|
+
}
|
|
4333
|
+
if (scenario.output.format !== "mp4") {
|
|
4334
|
+
errors.push(`scenes timeline requires output.format mp4 (got "${scenario.output.format}")`);
|
|
4335
|
+
}
|
|
4336
|
+
for (const scene of scenario.scenes) {
|
|
4337
|
+
if (scene.type === "screen") {
|
|
4338
|
+
const hasNavigate = scene.steps[0]?.actions.some((a) => a.action === "navigate");
|
|
4339
|
+
if (!hasNavigate) {
|
|
4340
|
+
errors.push(`scenes: screen "${scene.id}" must start with a navigate action`);
|
|
4341
|
+
}
|
|
4342
|
+
} else if (scene.type === "vignette") {
|
|
4343
|
+
if (!screenIds.has(scene.footage)) {
|
|
4344
|
+
errors.push(`scenes: vignette references unknown footage "${scene.footage}"`);
|
|
4345
|
+
}
|
|
4346
|
+
for (const fx of scene.fx) {
|
|
4347
|
+
if (!fx.selector && !fx.coords) {
|
|
4348
|
+
errors.push(`scenes: vignette fx (${fx.kind}) needs "selector" or "coords"`);
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
if (scene.crop && !scene.crop.selector && scene.crop.w === void 0) {
|
|
4352
|
+
warnings.push("scenes: vignette crop without selector/coords falls back to full frame");
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
3706
4357
|
if (scenario.steps.length > 0) {
|
|
3707
4358
|
const firstStep = scenario.steps[0];
|
|
3708
4359
|
const hasNavigate = firstStep.actions.some(
|
|
@@ -3751,6 +4402,32 @@ function validateScenario(scenario) {
|
|
|
3751
4402
|
`Output height ${output.height} is out of range (must be 100-3840)`
|
|
3752
4403
|
);
|
|
3753
4404
|
}
|
|
4405
|
+
if (scenario.prepare) {
|
|
4406
|
+
const prepare = scenario.prepare;
|
|
4407
|
+
if (prepare.freezeTime && Number.isNaN(Date.parse(prepare.freezeTime))) {
|
|
4408
|
+
errors.push(
|
|
4409
|
+
`prepare.freezeTime "${prepare.freezeTime}" is not a valid date (use ISO 8601, e.g. "2026-06-10T09:00:00Z")`
|
|
4410
|
+
);
|
|
4411
|
+
}
|
|
4412
|
+
for (let i = 0; i < prepare.mock.length; i++) {
|
|
4413
|
+
const mock = prepare.mock[i];
|
|
4414
|
+
if (!mock.fixture && mock.body === void 0) {
|
|
4415
|
+
errors.push(
|
|
4416
|
+
`prepare.mock #${i + 1} ("${mock.url}"): either "fixture" or "body" is required`
|
|
4417
|
+
);
|
|
4418
|
+
}
|
|
4419
|
+
if (mock.fixture && mock.body !== void 0) {
|
|
4420
|
+
warnings.push(
|
|
4421
|
+
`prepare.mock #${i + 1} ("${mock.url}"): both "fixture" and "body" set \u2014 fixture takes precedence`
|
|
4422
|
+
);
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
for (const selector of prepare.hide) {
|
|
4426
|
+
if (selector.trim() === "") {
|
|
4427
|
+
errors.push("prepare.hide: selector must not be empty");
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
3754
4431
|
if (output.fps > 30) {
|
|
3755
4432
|
warnings.push(
|
|
3756
4433
|
`FPS is set to ${output.fps}. High FPS may produce very large files.`
|
|
@@ -3780,9 +4457,15 @@ export {
|
|
|
3780
4457
|
ZOOM_INTENSITY_SCALES,
|
|
3781
4458
|
applyBlur,
|
|
3782
4459
|
applyCrossfade,
|
|
4460
|
+
applyPrepare,
|
|
3783
4461
|
applySlide,
|
|
3784
4462
|
applyTransition,
|
|
3785
4463
|
applyZoomEasing,
|
|
4464
|
+
buildCssInjectionScript,
|
|
4465
|
+
buildFreezeTimeScript,
|
|
4466
|
+
buildHideCss,
|
|
4467
|
+
buildSeedRandomScript,
|
|
4468
|
+
buildStorageScript,
|
|
3786
4469
|
buildZoomClickLookup,
|
|
3787
4470
|
calculateAdaptiveZoom,
|
|
3788
4471
|
calculateAdaptiveZoomFromLookup,
|
|
@@ -3799,7 +4482,9 @@ export {
|
|
|
3799
4482
|
renderCursorHighlight,
|
|
3800
4483
|
renderCursorTrail,
|
|
3801
4484
|
renderKeystrokeHud,
|
|
4485
|
+
renderScenesTimeline,
|
|
3802
4486
|
renderWatermark,
|
|
4487
|
+
resolvePreparePaths,
|
|
3803
4488
|
resolveZoomScale,
|
|
3804
4489
|
savePngSequence,
|
|
3805
4490
|
springEasing,
|