clipwise 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +130 -15
- package/README.md +138 -22
- package/dist/cli/index.js +2982 -1936
- package/dist/compose/frame-worker.js +146 -50
- package/dist/index.d.ts +6531 -331
- package/dist/index.js +943 -133
- package/package.json +3 -2
- package/skills/clipwise.md +112 -9
- 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,11 +248,23 @@ 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
|
-
|
|
145
|
-
viewport: this.viewport
|
|
146
|
-
|
|
254
|
+
const contextOptions = {
|
|
255
|
+
viewport: this.viewport,
|
|
256
|
+
deviceScaleFactor: this.deviceScaleFactor
|
|
257
|
+
};
|
|
258
|
+
if (scenario.auth?.storageState) {
|
|
259
|
+
contextOptions.storageState = scenario.auth.storageState;
|
|
260
|
+
}
|
|
261
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
262
|
+
if (scenario.auth?.cookies?.length) {
|
|
263
|
+
await this.context.addCookies(scenario.auth.cookies);
|
|
264
|
+
}
|
|
265
|
+
if (scenario.prepare) {
|
|
266
|
+
await applyPrepare(this.context, scenario.prepare);
|
|
267
|
+
}
|
|
147
268
|
this.page = await this.context.newPage();
|
|
148
269
|
this.rawFrames = [];
|
|
149
270
|
this.cursorTimeline = [];
|
|
@@ -243,7 +364,7 @@ var ClipwiseRecorder = class {
|
|
|
243
364
|
});
|
|
244
365
|
this.cdpClient = null;
|
|
245
366
|
}
|
|
246
|
-
await new Promise((
|
|
367
|
+
await new Promise((resolve3) => setTimeout(resolve3, 200));
|
|
247
368
|
}
|
|
248
369
|
/**
|
|
249
370
|
* Execute the full scenario with continuous capture and return a RecordingSession.
|
|
@@ -461,11 +582,36 @@ var ClipwiseRecorder = class {
|
|
|
461
582
|
const remaining = endTime - Date.now();
|
|
462
583
|
if (remaining > 0) {
|
|
463
584
|
await new Promise(
|
|
464
|
-
(
|
|
585
|
+
(resolve3) => setTimeout(resolve3, Math.min(REPAINT_INTERVAL_MS, remaining))
|
|
465
586
|
);
|
|
466
587
|
}
|
|
467
588
|
}
|
|
468
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* 조건 대기 중 프레임을 연속 캡처하는 헬퍼.
|
|
592
|
+
* smartWait과 동일한 패턴: isWaitingPhase 플래그 + forceRepaint 루프를 조건 promise와 병렬 실행.
|
|
593
|
+
*/
|
|
594
|
+
async waitForConditionWithCapture(conditionPromise, displaySpeed) {
|
|
595
|
+
this.isWaitingPhase = true;
|
|
596
|
+
this.currentDisplaySpeed = displaySpeed;
|
|
597
|
+
try {
|
|
598
|
+
let waitDone = false;
|
|
599
|
+
const repaintLoop = (async () => {
|
|
600
|
+
let toggle = false;
|
|
601
|
+
while (!waitDone && this.isCapturing && this.page) {
|
|
602
|
+
await this.forceRepaint(toggle);
|
|
603
|
+
toggle = !toggle;
|
|
604
|
+
await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
await conditionPromise;
|
|
608
|
+
waitDone = true;
|
|
609
|
+
await repaintLoop;
|
|
610
|
+
} finally {
|
|
611
|
+
this.isWaitingPhase = false;
|
|
612
|
+
this.currentDisplaySpeed = void 0;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
469
615
|
/**
|
|
470
616
|
* Pre-register waitForResponse listeners at the start of each step.
|
|
471
617
|
* This ensures the listener is active before any preceding action
|
|
@@ -546,7 +692,7 @@ var ClipwiseRecorder = class {
|
|
|
546
692
|
timestamp: Date.now(),
|
|
547
693
|
sessionId: currentSessionId
|
|
548
694
|
});
|
|
549
|
-
await new Promise((
|
|
695
|
+
await new Promise((resolve3) => setTimeout(resolve3, action.delay));
|
|
550
696
|
const now = Date.now();
|
|
551
697
|
if (now - lastClickRefresh >= 400) {
|
|
552
698
|
this.clickTimeline.push({
|
|
@@ -556,6 +702,17 @@ var ClipwiseRecorder = class {
|
|
|
556
702
|
lastClickRefresh = now;
|
|
557
703
|
}
|
|
558
704
|
}
|
|
705
|
+
await this.page.evaluate((sel) => {
|
|
706
|
+
const el = document.querySelector(sel);
|
|
707
|
+
if (!el) return;
|
|
708
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
709
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
710
|
+
if (setter) {
|
|
711
|
+
setter.call(el, el.value);
|
|
712
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
713
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
714
|
+
}
|
|
715
|
+
}, action.selector);
|
|
559
716
|
this.clickTimeline.push({
|
|
560
717
|
position: { ...inputTarget },
|
|
561
718
|
timestamp: Date.now()
|
|
@@ -581,7 +738,7 @@ var ClipwiseRecorder = class {
|
|
|
581
738
|
},
|
|
582
739
|
{ dy: yStep, dx: xStep, sel: action.selector ?? null }
|
|
583
740
|
);
|
|
584
|
-
await new Promise((
|
|
741
|
+
await new Promise((resolve3) => setTimeout(resolve3, 30));
|
|
585
742
|
}
|
|
586
743
|
await this.waitWithRepaints(150);
|
|
587
744
|
} else {
|
|
@@ -629,83 +786,89 @@ var ClipwiseRecorder = class {
|
|
|
629
786
|
}
|
|
630
787
|
case "waitForSelector": {
|
|
631
788
|
const locator = this.page.locator(action.selector).first();
|
|
632
|
-
|
|
789
|
+
const selectorPromise = locator.waitFor({ state: action.state, timeout: action.timeout });
|
|
790
|
+
if (action.captureWhileWaiting) {
|
|
791
|
+
await this.waitForConditionWithCapture(selectorPromise, action.displaySpeed);
|
|
792
|
+
} else {
|
|
793
|
+
await selectorPromise;
|
|
794
|
+
}
|
|
633
795
|
break;
|
|
634
796
|
}
|
|
635
797
|
case "waitForNavigation": {
|
|
636
|
-
|
|
798
|
+
const navPromise = this.page.waitForLoadState(action.waitUntil, { timeout: action.timeout });
|
|
799
|
+
if (action.captureWhileWaiting) {
|
|
800
|
+
await this.waitForConditionWithCapture(navPromise, action.displaySpeed);
|
|
801
|
+
} else {
|
|
802
|
+
await navPromise;
|
|
803
|
+
}
|
|
637
804
|
break;
|
|
638
805
|
}
|
|
639
806
|
case "waitForURL": {
|
|
640
|
-
|
|
807
|
+
const urlPromise = this.page.waitForURL(action.url, { timeout: action.timeout });
|
|
808
|
+
if (action.captureWhileWaiting) {
|
|
809
|
+
await this.waitForConditionWithCapture(urlPromise, action.displaySpeed);
|
|
810
|
+
} else {
|
|
811
|
+
await urlPromise;
|
|
812
|
+
}
|
|
641
813
|
break;
|
|
642
814
|
}
|
|
643
815
|
case "waitForFunction": {
|
|
644
|
-
|
|
816
|
+
const fnPromise = this.page.waitForFunction(action.expression, void 0, {
|
|
645
817
|
polling: action.polling,
|
|
646
818
|
timeout: action.timeout
|
|
647
819
|
});
|
|
820
|
+
if (action.captureWhileWaiting) {
|
|
821
|
+
await this.waitForConditionWithCapture(fnPromise, action.displaySpeed);
|
|
822
|
+
} else {
|
|
823
|
+
await fnPromise;
|
|
824
|
+
}
|
|
648
825
|
break;
|
|
649
826
|
}
|
|
650
827
|
case "waitForResponse": {
|
|
651
828
|
const pending = this.pendingResponsePromises.get(actionIndex);
|
|
652
829
|
if (pending) {
|
|
653
|
-
|
|
830
|
+
if (action.captureWhileWaiting) {
|
|
831
|
+
await this.waitForConditionWithCapture(pending, action.displaySpeed);
|
|
832
|
+
} else {
|
|
833
|
+
await pending;
|
|
834
|
+
}
|
|
654
835
|
}
|
|
655
836
|
break;
|
|
656
837
|
}
|
|
657
838
|
case "smartWait": {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
let timer;
|
|
673
|
-
const observer = new MutationObserver(() => {
|
|
674
|
-
clearTimeout(timer);
|
|
675
|
-
timer = setTimeout(() => {
|
|
676
|
-
observer.disconnect();
|
|
677
|
-
resolve(true);
|
|
678
|
-
}, 500);
|
|
679
|
-
});
|
|
680
|
-
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
839
|
+
let conditionPromise;
|
|
840
|
+
switch (action.until) {
|
|
841
|
+
case "networkIdle":
|
|
842
|
+
conditionPromise = this.page.waitForLoadState("networkidle", { timeout: action.timeout });
|
|
843
|
+
break;
|
|
844
|
+
case "selector":
|
|
845
|
+
conditionPromise = action.selector ? this.page.locator(action.selector).first().waitFor({ state: "visible", timeout: action.timeout }) : Promise.resolve();
|
|
846
|
+
break;
|
|
847
|
+
case "domStable":
|
|
848
|
+
conditionPromise = this.page.waitForFunction(
|
|
849
|
+
() => new Promise((resolve3) => {
|
|
850
|
+
let timer;
|
|
851
|
+
const observer = new MutationObserver(() => {
|
|
852
|
+
clearTimeout(timer);
|
|
681
853
|
timer = setTimeout(() => {
|
|
682
854
|
observer.disconnect();
|
|
683
|
-
|
|
855
|
+
resolve3(true);
|
|
684
856
|
}, 500);
|
|
685
|
-
})
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
toggle = !toggle;
|
|
699
|
-
await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
|
|
700
|
-
}
|
|
701
|
-
})();
|
|
702
|
-
await conditionPromise;
|
|
703
|
-
waitDone = true;
|
|
704
|
-
await repaintLoop;
|
|
705
|
-
} finally {
|
|
706
|
-
this.isWaitingPhase = false;
|
|
707
|
-
this.currentDisplaySpeed = void 0;
|
|
857
|
+
});
|
|
858
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
859
|
+
timer = setTimeout(() => {
|
|
860
|
+
observer.disconnect();
|
|
861
|
+
resolve3(true);
|
|
862
|
+
}, 500);
|
|
863
|
+
}),
|
|
864
|
+
void 0,
|
|
865
|
+
{ timeout: action.timeout }
|
|
866
|
+
);
|
|
867
|
+
break;
|
|
868
|
+
default:
|
|
869
|
+
conditionPromise = Promise.resolve();
|
|
708
870
|
}
|
|
871
|
+
await this.waitForConditionWithCapture(conditionPromise, action.displaySpeed);
|
|
709
872
|
break;
|
|
710
873
|
}
|
|
711
874
|
}
|
|
@@ -785,7 +948,7 @@ var ClipwiseRecorder = class {
|
|
|
785
948
|
position: { x: point.x, y: point.y },
|
|
786
949
|
timestamp: Date.now()
|
|
787
950
|
});
|
|
788
|
-
await new Promise((
|
|
951
|
+
await new Promise((resolve3) => setTimeout(resolve3, preset.stepDelayMs));
|
|
789
952
|
}
|
|
790
953
|
} finally {
|
|
791
954
|
await this.restoreTransitions();
|
|
@@ -961,13 +1124,11 @@ import sharp7 from "sharp";
|
|
|
961
1124
|
|
|
962
1125
|
// src/effects/frame.ts
|
|
963
1126
|
import sharp from "sharp";
|
|
964
|
-
var TITLE_BAR_HEIGHT =
|
|
965
|
-
var
|
|
966
|
-
var
|
|
967
|
-
var
|
|
968
|
-
var
|
|
969
|
-
var ADDRESS_BAR_HEIGHT = 24;
|
|
970
|
-
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;
|
|
971
1132
|
var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
|
|
972
1133
|
var IPHONE_OUTER_RADIUS = 47;
|
|
973
1134
|
var IPHONE_INNER_RADIUS = 39;
|
|
@@ -980,38 +1141,89 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
|
|
|
980
1141
|
var ANDROID_OUTER_RADIUS = 35;
|
|
981
1142
|
var ANDROID_INNER_RADIUS = 30;
|
|
982
1143
|
var ANDROID_CAMERA_RADIUS = 6;
|
|
983
|
-
function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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;
|
|
990
1166
|
const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
|
|
991
1167
|
const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
|
|
992
1168
|
const tlGap = TRAFFIC_LIGHT_GAP * dpr;
|
|
993
|
-
const
|
|
994
|
-
const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
|
|
995
|
-
const fontSize = 12 * dpr;
|
|
1169
|
+
const s = dpr;
|
|
996
1170
|
const trafficLights = [
|
|
997
|
-
{ cx: tlStartX, fill: "#ff5f57" },
|
|
998
|
-
{ cx: tlStartX + tlGap, fill: "#febc2e" },
|
|
999
|
-
{ cx: tlStartX + tlGap * 2, fill: "#28c840" }
|
|
1000
|
-
].map(
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
const
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
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}"/>
|
|
1008
1216
|
${trafficLights}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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}
|
|
1015
1227
|
</svg>`;
|
|
1016
1228
|
}
|
|
1017
1229
|
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
@@ -1163,7 +1375,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
|
|
|
1163
1375
|
case "browser": {
|
|
1164
1376
|
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
1165
1377
|
const totalHeight = frameHeight + tbarH;
|
|
1166
|
-
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
|
|
1378
|
+
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr, config.url);
|
|
1167
1379
|
const chromeBuffer = Buffer.from(chromeSvg);
|
|
1168
1380
|
const canvas = await sharp({
|
|
1169
1381
|
create: {
|
|
@@ -1551,6 +1763,44 @@ import sharp5 from "sharp";
|
|
|
1551
1763
|
function escapeXml(s) {
|
|
1552
1764
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1553
1765
|
}
|
|
1766
|
+
function isCJK(ch) {
|
|
1767
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
1768
|
+
return code >= 4352 && code <= 4607 || // 한글 자모
|
|
1769
|
+
code >= 11904 && code <= 40959 || // CJK 부수, 한자
|
|
1770
|
+
code >= 44032 && code <= 55215 || // 한글 음절
|
|
1771
|
+
code >= 63744 && code <= 64255 || // CJK 호환 한자
|
|
1772
|
+
code >= 65072 && code <= 65103 || // CJK 호환 형태
|
|
1773
|
+
code >= 65280 && code <= 65519 || // Full-width 문자
|
|
1774
|
+
code >= 12288 && code <= 12543 || // CJK 기호, 히라가나, 가타카나
|
|
1775
|
+
code >= 12784 && code <= 12799 || // 가타카나 확장
|
|
1776
|
+
code >= 131072 && code <= 195103;
|
|
1777
|
+
}
|
|
1778
|
+
function displayWidth(text) {
|
|
1779
|
+
let w = 0;
|
|
1780
|
+
for (const ch of text) {
|
|
1781
|
+
w += isCJK(ch) ? 1.7 : 1;
|
|
1782
|
+
}
|
|
1783
|
+
return w;
|
|
1784
|
+
}
|
|
1785
|
+
function wrapText(text, maxWidth) {
|
|
1786
|
+
if (displayWidth(text) <= maxWidth) return [text];
|
|
1787
|
+
const lines = [];
|
|
1788
|
+
let current = "";
|
|
1789
|
+
let currentWidth = 0;
|
|
1790
|
+
for (const ch of text) {
|
|
1791
|
+
const chWidth = isCJK(ch) ? 1.7 : 1;
|
|
1792
|
+
if (currentWidth + chWidth > maxWidth && current.length > 0) {
|
|
1793
|
+
lines.push(current);
|
|
1794
|
+
current = ch;
|
|
1795
|
+
currentWidth = chWidth;
|
|
1796
|
+
} else {
|
|
1797
|
+
current += ch;
|
|
1798
|
+
currentWidth += chWidth;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
if (current.length > 0) lines.push(current);
|
|
1802
|
+
return lines;
|
|
1803
|
+
}
|
|
1554
1804
|
function buildSessions(keystrokes) {
|
|
1555
1805
|
const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
|
|
1556
1806
|
if (!hasSessionIds) {
|
|
@@ -1584,16 +1834,22 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1584
1834
|
const lineGap = Math.round(fontSize * 0.45);
|
|
1585
1835
|
const charWidth = fontSize * 0.615;
|
|
1586
1836
|
const maxHudWidth = frameWidth - 60 * dpr;
|
|
1587
|
-
const
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1837
|
+
const maxDisplayWidth = Math.max(10, (maxHudWidth - hudPadH * 2) / charWidth);
|
|
1838
|
+
const wrappedLines = [];
|
|
1839
|
+
sessions.forEach((text, sIdx) => {
|
|
1840
|
+
const wrapped = wrapText(text, maxDisplayWidth);
|
|
1841
|
+
for (const line of wrapped) {
|
|
1842
|
+
wrappedLines.push({ text: line, sessionIdx: sIdx });
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
const lines = wrappedLines.map((l) => l.text);
|
|
1846
|
+
const totalLineCount = lines.length;
|
|
1847
|
+
const maxLineDisplayWidth = Math.max(...lines.map((l) => displayWidth(l)));
|
|
1592
1848
|
const hudWidth = Math.min(
|
|
1593
|
-
Math.ceil(
|
|
1849
|
+
Math.ceil(maxLineDisplayWidth * charWidth) + hudPadH * 2,
|
|
1594
1850
|
maxHudWidth
|
|
1595
1851
|
);
|
|
1596
|
-
const hudHeight = Math.ceil(fontSize *
|
|
1852
|
+
const hudHeight = Math.ceil(fontSize * totalLineCount + lineGap * (totalLineCount - 1) + hudPadV * 2);
|
|
1597
1853
|
const margin = 30 * dpr;
|
|
1598
1854
|
const hudY = frameHeight - hudHeight - margin;
|
|
1599
1855
|
let hudX;
|
|
@@ -1608,18 +1864,20 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1608
1864
|
default:
|
|
1609
1865
|
hudX = Math.round((frameWidth - hudWidth) / 2);
|
|
1610
1866
|
}
|
|
1611
|
-
const
|
|
1612
|
-
const
|
|
1867
|
+
const SESSION_OPACITY_FACTORS = [0.45, 0.7, 1];
|
|
1868
|
+
const sessionOpacities = SESSION_OPACITY_FACTORS.slice(-lineCount);
|
|
1613
1869
|
const rx = (8 * dpr).toFixed(1);
|
|
1614
1870
|
const boxOp = (globalOpacity * 0.92).toFixed(3);
|
|
1615
1871
|
const textX = hudX + hudPadH;
|
|
1616
1872
|
const baselineY = hudY + hudPadV + fontSize * 0.82;
|
|
1617
|
-
const textElements =
|
|
1618
|
-
const
|
|
1873
|
+
const textElements = wrappedLines.map(({ text, sessionIdx }, i) => {
|
|
1874
|
+
const sessionPos = sessions.length <= 3 ? sessionIdx : sessionIdx - (sessions.length - 3);
|
|
1875
|
+
const opFactor = sessionOpacities[Math.max(0, sessionPos)] ?? 1;
|
|
1876
|
+
const op = (globalOpacity * opFactor).toFixed(3);
|
|
1619
1877
|
const lineY = baselineY + i * (fontSize + lineGap);
|
|
1620
1878
|
return `<text x="${textX}" y="${lineY}"
|
|
1621
1879
|
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1622
|
-
fill="${config.textColor}" opacity="${op}">${escapeXml(
|
|
1880
|
+
fill="${config.textColor}" opacity="${op}">${escapeXml(text)}</text>`;
|
|
1623
1881
|
}).join("\n ");
|
|
1624
1882
|
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1625
1883
|
<rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
|
|
@@ -2093,7 +2351,7 @@ var CanvasRenderer = class {
|
|
|
2093
2351
|
* Workers process frames concurrently; results are collected in order.
|
|
2094
2352
|
*/
|
|
2095
2353
|
processWithWorkers(frames, contexts, workerCount, perFrameEffects) {
|
|
2096
|
-
return new Promise((
|
|
2354
|
+
return new Promise((resolve3, reject) => {
|
|
2097
2355
|
const results = new Array(frames.length);
|
|
2098
2356
|
let completed = 0;
|
|
2099
2357
|
let nextIndex = 0;
|
|
@@ -2131,7 +2389,7 @@ var CanvasRenderer = class {
|
|
|
2131
2389
|
completed++;
|
|
2132
2390
|
if (completed === frames.length) {
|
|
2133
2391
|
workers.forEach((wk) => wk.terminate());
|
|
2134
|
-
|
|
2392
|
+
resolve3(results);
|
|
2135
2393
|
} else {
|
|
2136
2394
|
dispatch(worker);
|
|
2137
2395
|
}
|
|
@@ -2805,7 +3063,7 @@ var CanvasRenderer = class {
|
|
|
2805
3063
|
// src/compose/video-encoder.ts
|
|
2806
3064
|
import gifenc from "gifenc";
|
|
2807
3065
|
import sharp9 from "sharp";
|
|
2808
|
-
import { writeFile, mkdir, readFile, rm } from "fs/promises";
|
|
3066
|
+
import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
|
|
2809
3067
|
import { join } from "path";
|
|
2810
3068
|
import { tmpdir } from "os";
|
|
2811
3069
|
import { spawn } from "child_process";
|
|
@@ -2828,20 +3086,20 @@ function resolveEncodingParams(config) {
|
|
|
2828
3086
|
var encoderScanPromise = null;
|
|
2829
3087
|
function scanAvailableEncoders() {
|
|
2830
3088
|
if (!encoderScanPromise) {
|
|
2831
|
-
encoderScanPromise = new Promise((
|
|
3089
|
+
encoderScanPromise = new Promise((resolve3) => {
|
|
2832
3090
|
const proc = spawn("ffmpeg", ["-encoders"], {
|
|
2833
3091
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2834
3092
|
});
|
|
2835
3093
|
let out = "";
|
|
2836
3094
|
proc.stdout.on("data", (d) => out += d.toString());
|
|
2837
3095
|
proc.on("close", () => {
|
|
2838
|
-
|
|
3096
|
+
resolve3({
|
|
2839
3097
|
hevcHw: out.includes("hevc_videotoolbox"),
|
|
2840
3098
|
h264Hw: out.includes("h264_videotoolbox"),
|
|
2841
3099
|
av1: out.includes("libsvtav1")
|
|
2842
3100
|
});
|
|
2843
3101
|
});
|
|
2844
|
-
proc.on("error", () =>
|
|
3102
|
+
proc.on("error", () => resolve3({ hevcHw: false, h264Hw: false, av1: false }));
|
|
2845
3103
|
});
|
|
2846
3104
|
}
|
|
2847
3105
|
return encoderScanPromise;
|
|
@@ -2954,7 +3212,7 @@ async function encodeMp4(frames, config, audio) {
|
|
|
2954
3212
|
const encoder = await detectVideoEncoder(config.codec);
|
|
2955
3213
|
const params = resolveEncodingParams(config);
|
|
2956
3214
|
await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio);
|
|
2957
|
-
return await
|
|
3215
|
+
return await readFile2(outputPath);
|
|
2958
3216
|
} finally {
|
|
2959
3217
|
await rm(outputPath, { force: true }).catch(() => {
|
|
2960
3218
|
});
|
|
@@ -2970,7 +3228,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
2970
3228
|
if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
|
|
2971
3229
|
}
|
|
2972
3230
|
const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
|
|
2973
|
-
return new Promise((
|
|
3231
|
+
return new Promise((resolve3, reject) => {
|
|
2974
3232
|
const ffmpeg = spawn(
|
|
2975
3233
|
"ffmpeg",
|
|
2976
3234
|
[
|
|
@@ -3005,7 +3263,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
3005
3263
|
ffmpeg.stderr.on("data", (d) => stderr += d.toString());
|
|
3006
3264
|
ffmpeg.on("close", (code) => {
|
|
3007
3265
|
if (code === 0) {
|
|
3008
|
-
|
|
3266
|
+
resolve3();
|
|
3009
3267
|
} else {
|
|
3010
3268
|
reject(
|
|
3011
3269
|
new Error(
|
|
@@ -3044,7 +3302,7 @@ async function encodeMp4Stream(frames, config, audio) {
|
|
|
3044
3302
|
const encoder = await detectVideoEncoder(config.codec);
|
|
3045
3303
|
const params = resolveEncodingParams(config);
|
|
3046
3304
|
await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
|
|
3047
|
-
return await
|
|
3305
|
+
return await readFile2(outputPath);
|
|
3048
3306
|
} finally {
|
|
3049
3307
|
await rm(outputPath, { force: true }).catch(() => {
|
|
3050
3308
|
});
|
|
@@ -3097,7 +3355,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
3097
3355
|
if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
|
|
3098
3356
|
}
|
|
3099
3357
|
const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
|
|
3100
|
-
return new Promise((
|
|
3358
|
+
return new Promise((resolve3, reject) => {
|
|
3101
3359
|
const ffmpeg = spawn(
|
|
3102
3360
|
"ffmpeg",
|
|
3103
3361
|
[
|
|
@@ -3130,7 +3388,7 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
3130
3388
|
ffmpeg.stderr.on("data", (d) => stderr += d.toString());
|
|
3131
3389
|
ffmpeg.on("close", (code) => {
|
|
3132
3390
|
if (code === 0) {
|
|
3133
|
-
|
|
3391
|
+
resolve3();
|
|
3134
3392
|
} else {
|
|
3135
3393
|
reject(
|
|
3136
3394
|
new Error(
|
|
@@ -3254,9 +3512,342 @@ var StreamingSession = class extends EventEmitter {
|
|
|
3254
3512
|
}
|
|
3255
3513
|
};
|
|
3256
3514
|
|
|
3257
|
-
// 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";
|
|
3258
3524
|
import { parse as parseYaml } from "yaml";
|
|
3259
|
-
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";
|
|
3260
3851
|
|
|
3261
3852
|
// src/script/types.ts
|
|
3262
3853
|
import { z } from "zod";
|
|
@@ -3308,29 +3899,41 @@ var WaitForSelectorActionSchema = z.object({
|
|
|
3308
3899
|
action: z.literal("waitForSelector"),
|
|
3309
3900
|
selector: SafeSelectorSchema,
|
|
3310
3901
|
state: z.enum(["visible", "attached", "hidden"]).default("visible"),
|
|
3311
|
-
timeout: z.number().min(0).default(15e3)
|
|
3902
|
+
timeout: z.number().min(0).default(15e3),
|
|
3903
|
+
/** 대기 중 프레임 연속 캡처 (로딩 애니메이션 보존). */
|
|
3904
|
+
captureWhileWaiting: z.boolean().default(false),
|
|
3905
|
+
/** captureWhileWaiting 사용 시 출력 영상 속도 배율 (1-32). */
|
|
3906
|
+
displaySpeed: z.number().min(1).max(32).default(8)
|
|
3312
3907
|
});
|
|
3313
3908
|
var WaitForNavigationActionSchema = z.object({
|
|
3314
3909
|
action: z.literal("waitForNavigation"),
|
|
3315
3910
|
waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).default("networkidle"),
|
|
3316
|
-
timeout: z.number().min(0).default(15e3)
|
|
3911
|
+
timeout: z.number().min(0).default(15e3),
|
|
3912
|
+
captureWhileWaiting: z.boolean().default(false),
|
|
3913
|
+
displaySpeed: z.number().min(1).max(32).default(8)
|
|
3317
3914
|
});
|
|
3318
3915
|
var WaitForURLActionSchema = z.object({
|
|
3319
3916
|
action: z.literal("waitForURL"),
|
|
3320
3917
|
url: z.string().min(1),
|
|
3321
|
-
timeout: z.number().min(0).default(15e3)
|
|
3918
|
+
timeout: z.number().min(0).default(15e3),
|
|
3919
|
+
captureWhileWaiting: z.boolean().default(false),
|
|
3920
|
+
displaySpeed: z.number().min(1).max(32).default(8)
|
|
3322
3921
|
});
|
|
3323
3922
|
var WaitForFunctionActionSchema = z.object({
|
|
3324
3923
|
action: z.literal("waitForFunction"),
|
|
3325
3924
|
expression: z.string().min(1),
|
|
3326
3925
|
polling: z.union([z.literal("raf"), z.number().min(0)]).default("raf"),
|
|
3327
|
-
timeout: z.number().min(0).default(3e4)
|
|
3926
|
+
timeout: z.number().min(0).default(3e4),
|
|
3927
|
+
captureWhileWaiting: z.boolean().default(false),
|
|
3928
|
+
displaySpeed: z.number().min(1).max(32).default(8)
|
|
3328
3929
|
});
|
|
3329
3930
|
var WaitForResponseActionSchema = z.object({
|
|
3330
3931
|
action: z.literal("waitForResponse"),
|
|
3331
3932
|
url: z.string().min(1),
|
|
3332
3933
|
status: z.number().min(100).max(599).optional(),
|
|
3333
|
-
timeout: z.number().min(0).default(3e4)
|
|
3934
|
+
timeout: z.number().min(0).default(3e4),
|
|
3935
|
+
captureWhileWaiting: z.boolean().default(false),
|
|
3936
|
+
displaySpeed: z.number().min(1).max(32).default(8)
|
|
3334
3937
|
});
|
|
3335
3938
|
var SmartWaitActionSchema = z.object({
|
|
3336
3939
|
action: z.literal("smartWait"),
|
|
@@ -3415,7 +4018,9 @@ var BackgroundSchema = z.object({
|
|
|
3415
4018
|
var DeviceFrameSchema = z.object({
|
|
3416
4019
|
enabled: z.boolean().default(false),
|
|
3417
4020
|
type: z.enum(["browser", "macbook", "iphone", "ipad", "android", "none"]).default("browser"),
|
|
3418
|
-
darkMode: z.boolean().default(false)
|
|
4021
|
+
darkMode: z.boolean().default(false),
|
|
4022
|
+
/** browser 타입의 주소창에 표시할 URL (실제 녹화 URL과 무관한 표시용). */
|
|
4023
|
+
url: z.string().default("localhost")
|
|
3419
4024
|
});
|
|
3420
4025
|
var SpeedRampConfigSchema = z.object({
|
|
3421
4026
|
enabled: z.boolean().default(false),
|
|
@@ -3486,7 +4091,8 @@ var OutputConfigSchema = z.object({
|
|
|
3486
4091
|
preset: z.enum(["social", "balanced", "archive"]).optional(),
|
|
3487
4092
|
/** Codec override: h264 (default), hevc (10-bit), av1 (smallest files, slow encode) */
|
|
3488
4093
|
codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
|
|
3489
|
-
|
|
4094
|
+
// Zero-Footprint 계약 (v0.8): 모든 산출물은 .clipwise/ 아래로.
|
|
4095
|
+
outputDir: z.string().default(".clipwise/output"),
|
|
3490
4096
|
filename: z.string().default("clipwise-recording")
|
|
3491
4097
|
});
|
|
3492
4098
|
var StepEffectsOverrideSchema = z.object({
|
|
@@ -3525,18 +4131,140 @@ var AudioConfigSchema = z.object({
|
|
|
3525
4131
|
/** Fade-out duration in milliseconds. */
|
|
3526
4132
|
fadeOut: z.number().min(0).default(0)
|
|
3527
4133
|
});
|
|
4134
|
+
var AuthConfigSchema = z.object({
|
|
4135
|
+
/** Path to a Playwright storageState JSON file (cookies + localStorage). */
|
|
4136
|
+
storageState: z.string().optional(),
|
|
4137
|
+
/** Inline cookie definitions (applied after storageState if both specified). */
|
|
4138
|
+
cookies: z.array(
|
|
4139
|
+
z.object({
|
|
4140
|
+
name: z.string(),
|
|
4141
|
+
value: z.string(),
|
|
4142
|
+
domain: z.string(),
|
|
4143
|
+
path: z.string().default("/"),
|
|
4144
|
+
httpOnly: z.boolean().default(false),
|
|
4145
|
+
secure: z.boolean().default(false),
|
|
4146
|
+
sameSite: z.enum(["Strict", "Lax", "None"]).default("Lax")
|
|
4147
|
+
})
|
|
4148
|
+
).optional()
|
|
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
|
+
]);
|
|
3528
4239
|
var ScenarioSchema = z.object({
|
|
3529
4240
|
name: z.string(),
|
|
3530
4241
|
description: z.string().optional(),
|
|
3531
4242
|
viewport: z.object({
|
|
3532
4243
|
width: z.number().default(1280),
|
|
3533
|
-
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)
|
|
3534
4247
|
}).default({}),
|
|
4248
|
+
/** Optional authentication — restores browser session for logged-in pages. */
|
|
4249
|
+
auth: AuthConfigSchema.optional(),
|
|
4250
|
+
/** Optional recording-time runtime injection (hide/mock/freezeTime/...). */
|
|
4251
|
+
prepare: PrepareConfigSchema.optional(),
|
|
3535
4252
|
effects: EffectsConfigSchema.default({}),
|
|
3536
4253
|
output: OutputConfigSchema.default({}),
|
|
3537
4254
|
/** Optional audio narration — muxed into MP4 output. */
|
|
3538
4255
|
audio: AudioConfigSchema.optional(),
|
|
3539
|
-
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
|
+
}
|
|
3540
4268
|
});
|
|
3541
4269
|
|
|
3542
4270
|
// src/script/parser.ts
|
|
@@ -3544,7 +4272,7 @@ import { ZodError } from "zod";
|
|
|
3544
4272
|
function parseScenario(yamlContent) {
|
|
3545
4273
|
let raw;
|
|
3546
4274
|
try {
|
|
3547
|
-
raw =
|
|
4275
|
+
raw = parseYaml2(yamlContent);
|
|
3548
4276
|
} catch (error) {
|
|
3549
4277
|
const message = error instanceof Error ? error.message : "Unknown parse error";
|
|
3550
4278
|
throw new Error(`YAML parse error: ${message}`);
|
|
@@ -3563,21 +4291,69 @@ ${details}`);
|
|
|
3563
4291
|
throw error;
|
|
3564
4292
|
}
|
|
3565
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
|
+
}
|
|
3566
4308
|
async function loadScenario(filePath) {
|
|
3567
4309
|
let content;
|
|
3568
4310
|
try {
|
|
3569
|
-
content = await
|
|
4311
|
+
content = await readFile4(filePath, "utf-8");
|
|
3570
4312
|
} catch (error) {
|
|
3571
4313
|
const message = error instanceof Error ? error.message : "Unknown file error";
|
|
3572
4314
|
throw new Error(`Failed to read scenario file "${filePath}": ${message}`);
|
|
3573
4315
|
}
|
|
3574
|
-
|
|
4316
|
+
const scenario = parseScenario(content);
|
|
4317
|
+
resolvePreparePaths(scenario, dirname2(resolve2(filePath)));
|
|
4318
|
+
return scenario;
|
|
3575
4319
|
}
|
|
3576
4320
|
|
|
3577
4321
|
// src/script/validator.ts
|
|
3578
4322
|
function validateScenario(scenario) {
|
|
3579
4323
|
const errors = [];
|
|
3580
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
|
+
}
|
|
3581
4357
|
if (scenario.steps.length > 0) {
|
|
3582
4358
|
const firstStep = scenario.steps[0];
|
|
3583
4359
|
const hasNavigate = firstStep.actions.some(
|
|
@@ -3626,6 +4402,32 @@ function validateScenario(scenario) {
|
|
|
3626
4402
|
`Output height ${output.height} is out of range (must be 100-3840)`
|
|
3627
4403
|
);
|
|
3628
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
|
+
}
|
|
3629
4431
|
if (output.fps > 30) {
|
|
3630
4432
|
warnings.push(
|
|
3631
4433
|
`FPS is set to ${output.fps}. High FPS may produce very large files.`
|
|
@@ -3655,9 +4457,15 @@ export {
|
|
|
3655
4457
|
ZOOM_INTENSITY_SCALES,
|
|
3656
4458
|
applyBlur,
|
|
3657
4459
|
applyCrossfade,
|
|
4460
|
+
applyPrepare,
|
|
3658
4461
|
applySlide,
|
|
3659
4462
|
applyTransition,
|
|
3660
4463
|
applyZoomEasing,
|
|
4464
|
+
buildCssInjectionScript,
|
|
4465
|
+
buildFreezeTimeScript,
|
|
4466
|
+
buildHideCss,
|
|
4467
|
+
buildSeedRandomScript,
|
|
4468
|
+
buildStorageScript,
|
|
3661
4469
|
buildZoomClickLookup,
|
|
3662
4470
|
calculateAdaptiveZoom,
|
|
3663
4471
|
calculateAdaptiveZoomFromLookup,
|
|
@@ -3674,7 +4482,9 @@ export {
|
|
|
3674
4482
|
renderCursorHighlight,
|
|
3675
4483
|
renderCursorTrail,
|
|
3676
4484
|
renderKeystrokeHud,
|
|
4485
|
+
renderScenesTimeline,
|
|
3677
4486
|
renderWatermark,
|
|
4487
|
+
resolvePreparePaths,
|
|
3678
4488
|
resolveZoomScale,
|
|
3679
4489
|
savePngSequence,
|
|
3680
4490
|
springEasing,
|