clipwise 0.11.0 → 0.12.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 +13 -1
- package/README.md +13 -1
- package/dist/cli/index.js +51 -11
- package/dist/index.d.ts +12 -1
- package/dist/index.js +44 -10
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -294,6 +294,7 @@ prepare:
|
|
|
294
294
|
| 시드 데이터를 가진 "데모 모드" 구현 | `mock:` |
|
|
295
295
|
| 일관된 데모를 위한 날짜/랜덤 스텁 | `freezeTime:` + `seedRandom:` |
|
|
296
296
|
| 녹화용 온보딩 사전 완료 분기 | `storage:` |
|
|
297
|
+
| 민감 정보(이메일·금액) 블러 | `mask:` — 요소 단위, 스크롤 추적 |
|
|
297
298
|
|
|
298
299
|
`freezeTime` + `seedRandom`을 함께 쓰면 녹화가 **결정론적**이 됩니다 —
|
|
299
300
|
같은 시나리오는 몇 번을 돌려도 바이트 단위로 동일한 프레임을 만듭니다.
|
|
@@ -327,11 +328,22 @@ scenes:
|
|
|
327
328
|
push: { from: 1.05, to: 1 }
|
|
328
329
|
start: { step: 3 } # step 경계에서 인용 시작
|
|
329
330
|
rate: 1.15
|
|
330
|
-
fx:
|
|
331
|
+
fx:
|
|
332
|
+
- { kind: circle, selector: "#revenue", delay: 2500 } # 손그림 동그라미
|
|
333
|
+
- { kind: spotlight, selector: "#revenue", delay: 2500 } # 주변 디밍
|
|
334
|
+
# push: { from: 1.0, to: 1.2, origin: ".panel" } # 셀렉터를 향한 매치컷
|
|
335
|
+
|
|
336
|
+
# 선택 — 무료 BGM을 URL로 (사용자 머신에서 다운로드) + 비트 싱크 컷
|
|
337
|
+
audio: { file: "https://assets.mixkit.co/music/132/132.mp3", bpm: 120, volume: 0.32, fadeOut: 2000 }
|
|
338
|
+
|
|
339
|
+
# 선택 — 캡션 필 (타임라인 절대 초), 모든 비율에 번인
|
|
340
|
+
captions:
|
|
341
|
+
- { text: "실제 앱을 그대로 녹화했습니다", start: 0.4, end: 2.4 }
|
|
331
342
|
```
|
|
332
343
|
|
|
333
344
|
**고퀄리티 레시피** (쇼케이스 영상이 이렇게 나오는 이유):
|
|
334
345
|
1. `viewport.deviceScaleFactor: 2` — 레티나 해상도 캡처 (푸티지·타이포 전부)
|
|
346
|
+
0. `output.aspects: ["9:16", "1:1"]` — 같은 실행에서 릴스/피드 파일까지 (푸티지는 1회 녹화)
|
|
335
347
|
2. `prepare:` — 배너 숨김, 시간 동결, 랜덤 시드, API 목킹
|
|
336
348
|
3. `.clipwise/brand.yaml` — 톤 프리셋, accent, 폰트 프리셋(`editorial` = Inter + Fraunces),
|
|
337
349
|
캐치프레이즈. 선 드로잉 주석 + 연결 스레드는 자동 적용
|
package/README.md
CHANGED
|
@@ -296,6 +296,7 @@ prepare:
|
|
|
296
296
|
| Build a "demo mode" with seeded data | `mock:` |
|
|
297
297
|
| Stub dates and randomness for consistent demos | `freezeTime:` + `seedRandom:` |
|
|
298
298
|
| Pre-complete onboarding for recordings | `storage:` |
|
|
299
|
+
| Blur sensitive data (emails, amounts) | `mask:` — element-level, follows scrolling |
|
|
299
300
|
|
|
300
301
|
Combined with `freezeTime` + `seedRandom`, recordings become **deterministic** —
|
|
301
302
|
the same scenario produces byte-identical frames run after run.
|
|
@@ -330,11 +331,22 @@ scenes:
|
|
|
330
331
|
push: { from: 1.05, to: 1 }
|
|
331
332
|
start: { step: 3 } # quote from a step boundary
|
|
332
333
|
rate: 1.15
|
|
333
|
-
fx:
|
|
334
|
+
fx:
|
|
335
|
+
- { kind: circle, selector: "#revenue", delay: 2500 } # hand-drawn circle
|
|
336
|
+
- { kind: spotlight, selector: "#revenue", delay: 2500 } # dim everything else
|
|
337
|
+
# push: { from: 1.0, to: 1.2, origin: ".panel" } # match-cut toward a selector
|
|
338
|
+
|
|
339
|
+
# Optional — free BGM by URL (downloaded on your machine) + beat-synced cuts
|
|
340
|
+
audio: { file: "https://assets.mixkit.co/music/132/132.mp3", bpm: 120, volume: 0.32, fadeOut: 2000 }
|
|
341
|
+
|
|
342
|
+
# Optional — caption pills (timeline-absolute seconds), burned into every aspect
|
|
343
|
+
captions:
|
|
344
|
+
- { text: "Recorded from a real app", start: 0.4, end: 2.4 }
|
|
334
345
|
```
|
|
335
346
|
|
|
336
347
|
**Quality recipe** (what makes the showcase videos look the way they do):
|
|
337
348
|
1. `viewport.deviceScaleFactor: 2` — retina-resolution capture (footage, type, everything)
|
|
349
|
+
0. `output.aspects: ["9:16", "1:1"]` — reels/feed files from the same run (footage recorded once)
|
|
338
350
|
2. `prepare:` — hide banners, freeze time, seed randomness, mock APIs
|
|
339
351
|
3. `.clipwise/brand.yaml` — tone preset, accent, font preset (`editorial` = Inter + Fraunces), catchphrases; line annotations + the connecting thread switch on automatically
|
|
340
352
|
4. Structure: kinetic hook → hero push-in → close-up vignettes → interstitial → split (YAML × footage) → outro
|
package/dist/cli/index.js
CHANGED
|
@@ -789,6 +789,9 @@ var init_recorder = __esm({
|
|
|
789
789
|
frameChannel = null;
|
|
790
790
|
channelIndex = 0;
|
|
791
791
|
// sequential index for channel-pushed frames
|
|
792
|
+
/** low-memory 채널 모드 — 버퍼는 스트림으로만 내보내고 내부에는 메타데이터만
|
|
793
|
+
* 보관한다. done의 frames는 빈 screenshot + sourceIndex(원본 참조)를 갖는다. */
|
|
794
|
+
channelLowMemory = false;
|
|
792
795
|
/**
|
|
793
796
|
* Launch the browser and create a page with the scenario viewport.
|
|
794
797
|
*/
|
|
@@ -834,6 +837,7 @@ var init_recorder = __esm({
|
|
|
834
837
|
this.dedupStats = { received: 0, stored: 0, skipped: 0 };
|
|
835
838
|
this.frameChannel = null;
|
|
836
839
|
this.channelIndex = 0;
|
|
840
|
+
this.channelLowMemory = false;
|
|
837
841
|
}
|
|
838
842
|
/**
|
|
839
843
|
* Start CDP screencast for continuous frame capture.
|
|
@@ -867,6 +871,9 @@ var init_recorder = __esm({
|
|
|
867
871
|
this.channelIndex++
|
|
868
872
|
);
|
|
869
873
|
this.frameChannel.push(frame);
|
|
874
|
+
if (this.channelLowMemory) {
|
|
875
|
+
rawFrame.buffer = Buffer.alloc(0);
|
|
876
|
+
}
|
|
870
877
|
}
|
|
871
878
|
}
|
|
872
879
|
await this.cdpClient.send("Page.screencastFrameAck", {
|
|
@@ -1000,12 +1007,13 @@ var init_recorder = __esm({
|
|
|
1000
1007
|
* Use this with CanvasRenderer.composeStreamOnline() to overlap recording
|
|
1001
1008
|
* time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
|
|
1002
1009
|
*/
|
|
1003
|
-
recordToChannel(scenario) {
|
|
1010
|
+
recordToChannel(scenario, options) {
|
|
1004
1011
|
const channel = new FrameChannel();
|
|
1005
1012
|
const done = (async () => {
|
|
1006
1013
|
try {
|
|
1007
1014
|
await this.init(scenario);
|
|
1008
1015
|
this.frameChannel = channel;
|
|
1016
|
+
this.channelLowMemory = options?.lowMemory ?? false;
|
|
1009
1017
|
const startTime = Date.now();
|
|
1010
1018
|
if (scenario.steps.length > 0) {
|
|
1011
1019
|
const s0 = scenario.steps[0];
|
|
@@ -1562,7 +1570,9 @@ var init_recorder = __esm({
|
|
|
1562
1570
|
frames.length,
|
|
1563
1571
|
Math.round(recordingDurationMs / 1e3 * this.targetFps)
|
|
1564
1572
|
);
|
|
1565
|
-
if (targetFrameCount <= frames.length)
|
|
1573
|
+
if (targetFrameCount <= frames.length) {
|
|
1574
|
+
return frames.map((f, idx) => ({ ...f, sourceIndex: idx }));
|
|
1575
|
+
}
|
|
1566
1576
|
const startTime = frames[0].timestamp;
|
|
1567
1577
|
const endTime = frames[frames.length - 1].timestamp;
|
|
1568
1578
|
const duration = Math.max(1, endTime - startTime);
|
|
@@ -1588,6 +1598,7 @@ var init_recorder = __esm({
|
|
|
1588
1598
|
resampled.push({
|
|
1589
1599
|
index: i,
|
|
1590
1600
|
screenshot: frames[nearestIdx].screenshot,
|
|
1601
|
+
sourceIndex: nearestIdx,
|
|
1591
1602
|
timestamp: targetTimestamp,
|
|
1592
1603
|
cursorPosition: cursorPos,
|
|
1593
1604
|
clickPosition: clickEvent?.position ?? null,
|
|
@@ -2824,7 +2835,7 @@ var init_canvas_renderer = __esm({
|
|
|
2824
2835
|
init_transition();
|
|
2825
2836
|
MIN_FRAMES_PER_WORKER = 4;
|
|
2826
2837
|
cachedWorkerUrl = null;
|
|
2827
|
-
CanvasRenderer = class {
|
|
2838
|
+
CanvasRenderer = class _CanvasRenderer {
|
|
2828
2839
|
constructor(effects, output, steps) {
|
|
2829
2840
|
this.effects = effects;
|
|
2830
2841
|
this.output = output;
|
|
@@ -3219,25 +3230,28 @@ var init_canvas_renderer = __esm({
|
|
|
3219
3230
|
* Uses a notify-on-progress pattern (same as streamWithWorkers) extended
|
|
3220
3231
|
* with an intake coroutine that feeds the growing frames[] buffer.
|
|
3221
3232
|
*/
|
|
3233
|
+
static EMPTY_SCREENSHOT = Buffer.alloc(0);
|
|
3222
3234
|
async *streamOnlineWithWorkers(source, workerCount) {
|
|
3223
3235
|
const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
|
|
3224
3236
|
const trailLength = this.effects.cursor.trailLength;
|
|
3225
3237
|
const frames = [];
|
|
3226
3238
|
let sourceComplete = false;
|
|
3227
3239
|
let workerError = null;
|
|
3228
|
-
let
|
|
3240
|
+
let waiters = [];
|
|
3229
3241
|
const trigger = () => {
|
|
3230
|
-
|
|
3231
|
-
|
|
3242
|
+
const ws = waiters;
|
|
3243
|
+
waiters = [];
|
|
3244
|
+
for (const w of ws) w();
|
|
3232
3245
|
};
|
|
3233
3246
|
const waitForProgress = () => new Promise((r) => {
|
|
3234
|
-
|
|
3247
|
+
waiters.push(r);
|
|
3235
3248
|
});
|
|
3236
3249
|
const completed = /* @__PURE__ */ new Map();
|
|
3237
3250
|
const idleWorkers = [];
|
|
3238
3251
|
let nextToDispatch = 0;
|
|
3239
3252
|
let nextToYield = 0;
|
|
3240
|
-
const
|
|
3253
|
+
const MAX_BACKLOG = workerCount * 3;
|
|
3254
|
+
const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames) && i - nextToYield < MAX_BACKLOG;
|
|
3241
3255
|
const effectiveScale = resolveZoomScale(
|
|
3242
3256
|
this.effects.zoom.scale,
|
|
3243
3257
|
this.effects.zoom.intensity
|
|
@@ -3277,6 +3291,7 @@ var init_canvas_renderer = __esm({
|
|
|
3277
3291
|
output: this.output,
|
|
3278
3292
|
context: computeContext(i)
|
|
3279
3293
|
});
|
|
3294
|
+
frames[i] = { ...frames[i], screenshot: _CanvasRenderer.EMPTY_SCREENSHOT };
|
|
3280
3295
|
} else {
|
|
3281
3296
|
idleWorkers.push(worker);
|
|
3282
3297
|
}
|
|
@@ -3312,11 +3327,15 @@ var init_canvas_renderer = __esm({
|
|
|
3312
3327
|
});
|
|
3313
3328
|
idleWorkers.push(worker);
|
|
3314
3329
|
}
|
|
3330
|
+
const INTAKE_AHEAD = Math.max(MAX_BACKLOG, transitionFrames + workerCount) + 16;
|
|
3315
3331
|
const intakeTask = (async () => {
|
|
3316
3332
|
for await (const frame of source) {
|
|
3317
3333
|
frames.push(frame);
|
|
3318
3334
|
dispatchToIdle();
|
|
3319
3335
|
trigger();
|
|
3336
|
+
while (!workerError && frames.length - nextToYield > INTAKE_AHEAD) {
|
|
3337
|
+
await waitForProgress();
|
|
3338
|
+
}
|
|
3320
3339
|
}
|
|
3321
3340
|
sourceComplete = true;
|
|
3322
3341
|
dispatchToIdle();
|
|
@@ -3332,6 +3351,8 @@ var init_canvas_renderer = __esm({
|
|
|
3332
3351
|
const frame = completed.get(nextToYield);
|
|
3333
3352
|
completed.delete(nextToYield);
|
|
3334
3353
|
nextToYield++;
|
|
3354
|
+
dispatchToIdle();
|
|
3355
|
+
trigger();
|
|
3335
3356
|
yield frame;
|
|
3336
3357
|
continue;
|
|
3337
3358
|
}
|
|
@@ -4128,21 +4149,34 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
4128
4149
|
effects: footageEffects(scenario.effects)
|
|
4129
4150
|
};
|
|
4130
4151
|
const recorder = new ClipwiseRecorder();
|
|
4131
|
-
const
|
|
4152
|
+
const handle = recorder.recordToChannel(takeScenario, { lowMemory: true });
|
|
4153
|
+
const rawDir = await mkdtemp(join2(tmpdir2(), `clipwise-raw-${scene.id}-`));
|
|
4154
|
+
for await (const frame of handle.frameStream) {
|
|
4155
|
+
await writeFile2(join2(rawDir, `${frame.index}.png`), frame.screenshot);
|
|
4156
|
+
}
|
|
4157
|
+
const session = await handle.done;
|
|
4132
4158
|
const renderer = new CanvasRenderer(
|
|
4133
4159
|
takeScenario.effects,
|
|
4134
4160
|
segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
|
|
4135
4161
|
scene.steps
|
|
4136
4162
|
);
|
|
4163
|
+
const planStream = (async function* () {
|
|
4164
|
+
for (const f of session.frames) {
|
|
4165
|
+
yield { ...f, screenshot: await readFile4(join2(rawDir, `${f.sourceIndex ?? f.index}.png`)) };
|
|
4166
|
+
}
|
|
4167
|
+
})();
|
|
4137
4168
|
const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
|
|
4138
4169
|
let count = 0;
|
|
4139
|
-
|
|
4170
|
+
const composed = renderer.canStreamOnline() ? renderer.composeStreamOnline(planStream) : renderer.composeStream(session.frames);
|
|
4171
|
+
for await (const f of composed) {
|
|
4140
4172
|
const png = f.rawInfo ? await sharp10(f.buffer, {
|
|
4141
4173
|
raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
|
|
4142
4174
|
}).png().toBuffer() : f.buffer;
|
|
4143
4175
|
await writeFile2(join2(framesDir, `${count}.png`), png);
|
|
4144
4176
|
count++;
|
|
4145
4177
|
}
|
|
4178
|
+
await rm2(rawDir, { recursive: true, force: true }).catch(() => {
|
|
4179
|
+
});
|
|
4146
4180
|
const anchors = [];
|
|
4147
4181
|
for (let k = 0; k < scene.steps.length; k++) {
|
|
4148
4182
|
const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
|
|
@@ -4642,7 +4676,7 @@ import { homedir } from "os";
|
|
|
4642
4676
|
var program = new Command();
|
|
4643
4677
|
program.name("clipwise").description(
|
|
4644
4678
|
"Playwright-based cinematic screen recorder for product demos"
|
|
4645
|
-
).version("0.
|
|
4679
|
+
).version("0.12.0");
|
|
4646
4680
|
program.command("record").description("Record a demo from a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").option("-o, --output <dir>", "Output directory (default: scenario outputDir or .clipwise/output)").option(
|
|
4647
4681
|
"-f, --format <format>",
|
|
4648
4682
|
"Output format (gif|mp4|png-sequence)"
|
|
@@ -4926,6 +4960,11 @@ steps:
|
|
|
4926
4960
|
name: "My Launch Video"
|
|
4927
4961
|
viewport: { width: 1280, height: 800, deviceScaleFactor: 2 } # 2 = retina quality
|
|
4928
4962
|
|
|
4963
|
+
# Optional extras (uncomment to use):
|
|
4964
|
+
# audio: { file: "https://assets.mixkit.co/music/132/132.mp3", bpm: 120, volume: 0.32, fadeOut: 2000 }
|
|
4965
|
+
# captions:
|
|
4966
|
+
# - { text: "Recorded from a real app", start: 0.4, end: 2.4 }
|
|
4967
|
+
|
|
4929
4968
|
effects:
|
|
4930
4969
|
cursor: { enabled: true, clickEffect: true, highlight: false, trail: false }
|
|
4931
4970
|
|
|
@@ -4934,6 +4973,7 @@ output:
|
|
|
4934
4973
|
fps: 30
|
|
4935
4974
|
preset: balanced
|
|
4936
4975
|
filename: keynote
|
|
4976
|
+
# aspects: ["9:16"] # also render a reels-format file in the same run
|
|
4937
4977
|
|
|
4938
4978
|
scenes:
|
|
4939
4979
|
# footage take \u2014 recorded once; vignettes below quote it by step
|
package/dist/index.d.ts
CHANGED
|
@@ -9889,6 +9889,11 @@ interface CapturedFrame {
|
|
|
9889
9889
|
isWaitingPhase?: boolean;
|
|
9890
9890
|
/** Speed multiplier for this frame when in a smartWait phase. */
|
|
9891
9891
|
displaySpeed?: number;
|
|
9892
|
+
/**
|
|
9893
|
+
* low-memory 스트리밍 녹화에서 이 프레임이 참조하는 원본(채널) 프레임 인덱스.
|
|
9894
|
+
* screenshot이 빈 버퍼일 때, 소비자가 디스크에 받아둔 원본을 이 인덱스로 찾는다.
|
|
9895
|
+
*/
|
|
9896
|
+
sourceIndex?: number;
|
|
9892
9897
|
}
|
|
9893
9898
|
interface ComposedFrame {
|
|
9894
9899
|
index: number;
|
|
@@ -9967,6 +9972,9 @@ declare class ClipwiseRecorder {
|
|
|
9967
9972
|
private dedupStats;
|
|
9968
9973
|
private frameChannel;
|
|
9969
9974
|
private channelIndex;
|
|
9975
|
+
/** low-memory 채널 모드 — 버퍼는 스트림으로만 내보내고 내부에는 메타데이터만
|
|
9976
|
+
* 보관한다. done의 frames는 빈 screenshot + sourceIndex(원본 참조)를 갖는다. */
|
|
9977
|
+
private channelLowMemory;
|
|
9970
9978
|
/**
|
|
9971
9979
|
* Launch the browser and create a page with the scenario viewport.
|
|
9972
9980
|
*/
|
|
@@ -9997,7 +10005,9 @@ declare class ClipwiseRecorder {
|
|
|
9997
10005
|
* Use this with CanvasRenderer.composeStreamOnline() to overlap recording
|
|
9998
10006
|
* time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
|
|
9999
10007
|
*/
|
|
10000
|
-
recordToChannel(scenario: Scenario
|
|
10008
|
+
recordToChannel(scenario: Scenario, options?: {
|
|
10009
|
+
lowMemory?: boolean;
|
|
10010
|
+
}): RecordingHandle;
|
|
10001
10011
|
/**
|
|
10002
10012
|
* Build a single CapturedFrame from a RawFrame in real-time.
|
|
10003
10013
|
* Used by recordToChannel() to emit frames as they arrive.
|
|
@@ -10292,6 +10302,7 @@ declare class CanvasRenderer {
|
|
|
10292
10302
|
* Uses a notify-on-progress pattern (same as streamWithWorkers) extended
|
|
10293
10303
|
* with an intake coroutine that feeds the growing frames[] buffer.
|
|
10294
10304
|
*/
|
|
10305
|
+
private static readonly EMPTY_SCREENSHOT;
|
|
10295
10306
|
private streamOnlineWithWorkers;
|
|
10296
10307
|
/**
|
|
10297
10308
|
* Stream frame composition — yields ComposedFrames as workers finish,
|
package/dist/index.js
CHANGED
|
@@ -247,6 +247,9 @@ var ClipwiseRecorder = class {
|
|
|
247
247
|
frameChannel = null;
|
|
248
248
|
channelIndex = 0;
|
|
249
249
|
// sequential index for channel-pushed frames
|
|
250
|
+
/** low-memory 채널 모드 — 버퍼는 스트림으로만 내보내고 내부에는 메타데이터만
|
|
251
|
+
* 보관한다. done의 frames는 빈 screenshot + sourceIndex(원본 참조)를 갖는다. */
|
|
252
|
+
channelLowMemory = false;
|
|
250
253
|
/**
|
|
251
254
|
* Launch the browser and create a page with the scenario viewport.
|
|
252
255
|
*/
|
|
@@ -292,6 +295,7 @@ var ClipwiseRecorder = class {
|
|
|
292
295
|
this.dedupStats = { received: 0, stored: 0, skipped: 0 };
|
|
293
296
|
this.frameChannel = null;
|
|
294
297
|
this.channelIndex = 0;
|
|
298
|
+
this.channelLowMemory = false;
|
|
295
299
|
}
|
|
296
300
|
/**
|
|
297
301
|
* Start CDP screencast for continuous frame capture.
|
|
@@ -325,6 +329,9 @@ var ClipwiseRecorder = class {
|
|
|
325
329
|
this.channelIndex++
|
|
326
330
|
);
|
|
327
331
|
this.frameChannel.push(frame);
|
|
332
|
+
if (this.channelLowMemory) {
|
|
333
|
+
rawFrame.buffer = Buffer.alloc(0);
|
|
334
|
+
}
|
|
328
335
|
}
|
|
329
336
|
}
|
|
330
337
|
await this.cdpClient.send("Page.screencastFrameAck", {
|
|
@@ -458,12 +465,13 @@ var ClipwiseRecorder = class {
|
|
|
458
465
|
* Use this with CanvasRenderer.composeStreamOnline() to overlap recording
|
|
459
466
|
* time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
|
|
460
467
|
*/
|
|
461
|
-
recordToChannel(scenario) {
|
|
468
|
+
recordToChannel(scenario, options) {
|
|
462
469
|
const channel = new FrameChannel();
|
|
463
470
|
const done = (async () => {
|
|
464
471
|
try {
|
|
465
472
|
await this.init(scenario);
|
|
466
473
|
this.frameChannel = channel;
|
|
474
|
+
this.channelLowMemory = options?.lowMemory ?? false;
|
|
467
475
|
const startTime = Date.now();
|
|
468
476
|
if (scenario.steps.length > 0) {
|
|
469
477
|
const s0 = scenario.steps[0];
|
|
@@ -1020,7 +1028,9 @@ var ClipwiseRecorder = class {
|
|
|
1020
1028
|
frames.length,
|
|
1021
1029
|
Math.round(recordingDurationMs / 1e3 * this.targetFps)
|
|
1022
1030
|
);
|
|
1023
|
-
if (targetFrameCount <= frames.length)
|
|
1031
|
+
if (targetFrameCount <= frames.length) {
|
|
1032
|
+
return frames.map((f, idx) => ({ ...f, sourceIndex: idx }));
|
|
1033
|
+
}
|
|
1024
1034
|
const startTime = frames[0].timestamp;
|
|
1025
1035
|
const endTime = frames[frames.length - 1].timestamp;
|
|
1026
1036
|
const duration = Math.max(1, endTime - startTime);
|
|
@@ -1046,6 +1056,7 @@ var ClipwiseRecorder = class {
|
|
|
1046
1056
|
resampled.push({
|
|
1047
1057
|
index: i,
|
|
1048
1058
|
screenshot: frames[nearestIdx].screenshot,
|
|
1059
|
+
sourceIndex: nearestIdx,
|
|
1049
1060
|
timestamp: targetTimestamp,
|
|
1050
1061
|
cursorPosition: cursorPos,
|
|
1051
1062
|
clickPosition: clickEvent?.position ?? null,
|
|
@@ -2300,7 +2311,7 @@ function getWorkerUrl() {
|
|
|
2300
2311
|
cachedWorkerUrl = candidates[1];
|
|
2301
2312
|
return cachedWorkerUrl;
|
|
2302
2313
|
}
|
|
2303
|
-
var CanvasRenderer = class {
|
|
2314
|
+
var CanvasRenderer = class _CanvasRenderer {
|
|
2304
2315
|
constructor(effects, output, steps) {
|
|
2305
2316
|
this.effects = effects;
|
|
2306
2317
|
this.output = output;
|
|
@@ -2695,25 +2706,28 @@ var CanvasRenderer = class {
|
|
|
2695
2706
|
* Uses a notify-on-progress pattern (same as streamWithWorkers) extended
|
|
2696
2707
|
* with an intake coroutine that feeds the growing frames[] buffer.
|
|
2697
2708
|
*/
|
|
2709
|
+
static EMPTY_SCREENSHOT = Buffer.alloc(0);
|
|
2698
2710
|
async *streamOnlineWithWorkers(source, workerCount) {
|
|
2699
2711
|
const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
|
|
2700
2712
|
const trailLength = this.effects.cursor.trailLength;
|
|
2701
2713
|
const frames = [];
|
|
2702
2714
|
let sourceComplete = false;
|
|
2703
2715
|
let workerError = null;
|
|
2704
|
-
let
|
|
2716
|
+
let waiters = [];
|
|
2705
2717
|
const trigger = () => {
|
|
2706
|
-
|
|
2707
|
-
|
|
2718
|
+
const ws = waiters;
|
|
2719
|
+
waiters = [];
|
|
2720
|
+
for (const w of ws) w();
|
|
2708
2721
|
};
|
|
2709
2722
|
const waitForProgress = () => new Promise((r) => {
|
|
2710
|
-
|
|
2723
|
+
waiters.push(r);
|
|
2711
2724
|
});
|
|
2712
2725
|
const completed = /* @__PURE__ */ new Map();
|
|
2713
2726
|
const idleWorkers = [];
|
|
2714
2727
|
let nextToDispatch = 0;
|
|
2715
2728
|
let nextToYield = 0;
|
|
2716
|
-
const
|
|
2729
|
+
const MAX_BACKLOG = workerCount * 3;
|
|
2730
|
+
const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames) && i - nextToYield < MAX_BACKLOG;
|
|
2717
2731
|
const effectiveScale = resolveZoomScale(
|
|
2718
2732
|
this.effects.zoom.scale,
|
|
2719
2733
|
this.effects.zoom.intensity
|
|
@@ -2753,6 +2767,7 @@ var CanvasRenderer = class {
|
|
|
2753
2767
|
output: this.output,
|
|
2754
2768
|
context: computeContext(i)
|
|
2755
2769
|
});
|
|
2770
|
+
frames[i] = { ...frames[i], screenshot: _CanvasRenderer.EMPTY_SCREENSHOT };
|
|
2756
2771
|
} else {
|
|
2757
2772
|
idleWorkers.push(worker);
|
|
2758
2773
|
}
|
|
@@ -2788,11 +2803,15 @@ var CanvasRenderer = class {
|
|
|
2788
2803
|
});
|
|
2789
2804
|
idleWorkers.push(worker);
|
|
2790
2805
|
}
|
|
2806
|
+
const INTAKE_AHEAD = Math.max(MAX_BACKLOG, transitionFrames + workerCount) + 16;
|
|
2791
2807
|
const intakeTask = (async () => {
|
|
2792
2808
|
for await (const frame of source) {
|
|
2793
2809
|
frames.push(frame);
|
|
2794
2810
|
dispatchToIdle();
|
|
2795
2811
|
trigger();
|
|
2812
|
+
while (!workerError && frames.length - nextToYield > INTAKE_AHEAD) {
|
|
2813
|
+
await waitForProgress();
|
|
2814
|
+
}
|
|
2796
2815
|
}
|
|
2797
2816
|
sourceComplete = true;
|
|
2798
2817
|
dispatchToIdle();
|
|
@@ -2808,6 +2827,8 @@ var CanvasRenderer = class {
|
|
|
2808
2827
|
const frame = completed.get(nextToYield);
|
|
2809
2828
|
completed.delete(nextToYield);
|
|
2810
2829
|
nextToYield++;
|
|
2830
|
+
dispatchToIdle();
|
|
2831
|
+
trigger();
|
|
2811
2832
|
yield frame;
|
|
2812
2833
|
continue;
|
|
2813
2834
|
}
|
|
@@ -3669,21 +3690,34 @@ async function recordFootageTake(scenario, scene, selectors) {
|
|
|
3669
3690
|
effects: footageEffects(scenario.effects)
|
|
3670
3691
|
};
|
|
3671
3692
|
const recorder = new ClipwiseRecorder();
|
|
3672
|
-
const
|
|
3693
|
+
const handle = recorder.recordToChannel(takeScenario, { lowMemory: true });
|
|
3694
|
+
const rawDir = await mkdtemp(join2(tmpdir2(), `clipwise-raw-${scene.id}-`));
|
|
3695
|
+
for await (const frame of handle.frameStream) {
|
|
3696
|
+
await writeFile2(join2(rawDir, `${frame.index}.png`), frame.screenshot);
|
|
3697
|
+
}
|
|
3698
|
+
const session = await handle.done;
|
|
3673
3699
|
const renderer = new CanvasRenderer(
|
|
3674
3700
|
takeScenario.effects,
|
|
3675
3701
|
segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
|
|
3676
3702
|
scene.steps
|
|
3677
3703
|
);
|
|
3704
|
+
const planStream = (async function* () {
|
|
3705
|
+
for (const f of session.frames) {
|
|
3706
|
+
yield { ...f, screenshot: await readFile3(join2(rawDir, `${f.sourceIndex ?? f.index}.png`)) };
|
|
3707
|
+
}
|
|
3708
|
+
})();
|
|
3678
3709
|
const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
|
|
3679
3710
|
let count = 0;
|
|
3680
|
-
|
|
3711
|
+
const composed = renderer.canStreamOnline() ? renderer.composeStreamOnline(planStream) : renderer.composeStream(session.frames);
|
|
3712
|
+
for await (const f of composed) {
|
|
3681
3713
|
const png = f.rawInfo ? await sharp10(f.buffer, {
|
|
3682
3714
|
raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
|
|
3683
3715
|
}).png().toBuffer() : f.buffer;
|
|
3684
3716
|
await writeFile2(join2(framesDir, `${count}.png`), png);
|
|
3685
3717
|
count++;
|
|
3686
3718
|
}
|
|
3719
|
+
await rm2(rawDir, { recursive: true, force: true }).catch(() => {
|
|
3720
|
+
});
|
|
3687
3721
|
const anchors = [];
|
|
3688
3722
|
for (let k = 0; k < scene.steps.length; k++) {
|
|
3689
3723
|
const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
|