clipwise 0.3.0 → 0.4.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 +15 -1
- package/README.md +15 -1
- package/dist/cli/index.js +862 -137
- package/dist/compose/frame-worker.js +142 -13
- package/dist/index.d.ts +306 -8
- package/dist/index.js +877 -48
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -401,6 +401,35 @@ var CURSOR_SPEED_PRESETS = {
|
|
|
401
401
|
slow: { steps: 20, delay: 25 }
|
|
402
402
|
// ~500ms, ~20 frames captured
|
|
403
403
|
};
|
|
404
|
+
var FrameChannel = class {
|
|
405
|
+
buffer = [];
|
|
406
|
+
resolve = null;
|
|
407
|
+
closed = false;
|
|
408
|
+
push(frame) {
|
|
409
|
+
if (this.closed) return;
|
|
410
|
+
this.buffer.push(frame);
|
|
411
|
+
this.resolve?.();
|
|
412
|
+
this.resolve = null;
|
|
413
|
+
}
|
|
414
|
+
close() {
|
|
415
|
+
if (this.closed) return;
|
|
416
|
+
this.closed = true;
|
|
417
|
+
this.resolve?.();
|
|
418
|
+
this.resolve = null;
|
|
419
|
+
}
|
|
420
|
+
async *[Symbol.asyncIterator]() {
|
|
421
|
+
while (true) {
|
|
422
|
+
while (this.buffer.length > 0) {
|
|
423
|
+
yield this.buffer.shift();
|
|
424
|
+
}
|
|
425
|
+
if (this.closed) return;
|
|
426
|
+
await new Promise((r) => {
|
|
427
|
+
this.resolve = r;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
var DEDUP_SIGNATURE_BYTES = 2048;
|
|
404
433
|
var ClipwiseRecorder = class {
|
|
405
434
|
browser = null;
|
|
406
435
|
context = null;
|
|
@@ -419,6 +448,15 @@ var ClipwiseRecorder = class {
|
|
|
419
448
|
cursorSpeed = "fast";
|
|
420
449
|
firstContentTimestamp = 0;
|
|
421
450
|
pendingResponsePromises = /* @__PURE__ */ new Map();
|
|
451
|
+
// ── 중복 프레임 제거 (Phase 1-A) ──────────────────────────────────────────
|
|
452
|
+
// 직전 저장된 프레임의 앞부분 시그니처. 동일하면 화면 내용이 바뀌지 않은 것.
|
|
453
|
+
lastFrameSignature = null;
|
|
454
|
+
dedupStats = { received: 0, stored: 0, skipped: 0 };
|
|
455
|
+
// ── 스트리밍 채널 (Phase 3-B) ───────────────────────────────────────────
|
|
456
|
+
// Set during recordToChannel(); null in normal record() mode.
|
|
457
|
+
frameChannel = null;
|
|
458
|
+
channelIndex = 0;
|
|
459
|
+
// sequential index for channel-pushed frames
|
|
422
460
|
/**
|
|
423
461
|
* Launch the browser and create a page with the scenario viewport.
|
|
424
462
|
*/
|
|
@@ -442,6 +480,10 @@ var ClipwiseRecorder = class {
|
|
|
442
480
|
this.cursorPosition = { x: 0, y: 0 };
|
|
443
481
|
this.isCapturing = false;
|
|
444
482
|
this.firstContentTimestamp = 0;
|
|
483
|
+
this.lastFrameSignature = null;
|
|
484
|
+
this.dedupStats = { received: 0, stored: 0, skipped: 0 };
|
|
485
|
+
this.frameChannel = null;
|
|
486
|
+
this.channelIndex = 0;
|
|
445
487
|
}
|
|
446
488
|
/**
|
|
447
489
|
* Start CDP screencast for continuous frame capture.
|
|
@@ -456,10 +498,24 @@ var ClipwiseRecorder = class {
|
|
|
456
498
|
async (event) => {
|
|
457
499
|
if (!this.isCapturing || !this.cdpClient) return;
|
|
458
500
|
const buffer = Buffer.from(event.data, "base64");
|
|
459
|
-
this.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
501
|
+
this.dedupStats.received++;
|
|
502
|
+
const signature = buffer.subarray(0, DEDUP_SIGNATURE_BYTES);
|
|
503
|
+
const isDuplicate = this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
|
|
504
|
+
if (isDuplicate) {
|
|
505
|
+
this.dedupStats.skipped++;
|
|
506
|
+
} else {
|
|
507
|
+
this.lastFrameSignature = Buffer.from(signature);
|
|
508
|
+
const captureTime = Date.now();
|
|
509
|
+
this.rawFrames.push({ buffer, timestamp: captureTime, stepIndex: this.currentStepIndex });
|
|
510
|
+
this.dedupStats.stored++;
|
|
511
|
+
if (this.frameChannel && this.firstContentTimestamp > 0) {
|
|
512
|
+
const frame = this.buildFrameOnline(
|
|
513
|
+
{ buffer, timestamp: captureTime, stepIndex: this.currentStepIndex },
|
|
514
|
+
this.channelIndex++
|
|
515
|
+
);
|
|
516
|
+
this.frameChannel.push(frame);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
463
519
|
await this.cdpClient.send("Page.screencastFrameAck", {
|
|
464
520
|
sessionId: event.sessionId
|
|
465
521
|
}).catch(() => {
|
|
@@ -498,13 +554,23 @@ var ClipwiseRecorder = class {
|
|
|
498
554
|
await this.init(scenario);
|
|
499
555
|
const startTime = Date.now();
|
|
500
556
|
try {
|
|
557
|
+
if (scenario.steps.length > 0) {
|
|
558
|
+
const s0 = scenario.steps[0];
|
|
559
|
+
this.currentStepIndex = 0;
|
|
560
|
+
this.preRegisterResponseListeners(s0.actions);
|
|
561
|
+
for (let ai = 0; ai < s0.actions.length; ai++) {
|
|
562
|
+
await this.executeAction(s0.actions[ai], ai);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
501
565
|
await this.startCapture();
|
|
502
566
|
for (let si = 0; si < scenario.steps.length; si++) {
|
|
503
567
|
const step = scenario.steps[si];
|
|
504
568
|
this.currentStepIndex = si;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
569
|
+
if (si > 0) {
|
|
570
|
+
this.preRegisterResponseListeners(step.actions);
|
|
571
|
+
for (let ai = 0; ai < step.actions.length; ai++) {
|
|
572
|
+
await this.executeAction(step.actions[ai], ai);
|
|
573
|
+
}
|
|
508
574
|
}
|
|
509
575
|
if (step.captureDelay > 0) {
|
|
510
576
|
await this.waitWithRepaints(step.captureDelay);
|
|
@@ -525,7 +591,8 @@ var ClipwiseRecorder = class {
|
|
|
525
591
|
scenario,
|
|
526
592
|
frames,
|
|
527
593
|
startTime,
|
|
528
|
-
endTime: Date.now()
|
|
594
|
+
endTime: Date.now(),
|
|
595
|
+
dedupStats: { ...this.dedupStats }
|
|
529
596
|
};
|
|
530
597
|
} catch (error) {
|
|
531
598
|
await this.stopCapture().catch(() => {
|
|
@@ -541,13 +608,116 @@ var ClipwiseRecorder = class {
|
|
|
541
608
|
scenario,
|
|
542
609
|
frames,
|
|
543
610
|
startTime,
|
|
544
|
-
endTime: Date.now()
|
|
611
|
+
endTime: Date.now(),
|
|
612
|
+
dedupStats: { ...this.dedupStats }
|
|
545
613
|
};
|
|
546
614
|
throw err;
|
|
547
615
|
} finally {
|
|
548
616
|
await this.cleanup();
|
|
549
617
|
}
|
|
550
618
|
}
|
|
619
|
+
// ─── Streaming recording API (Phase 3-B) ──────────────────────────────────
|
|
620
|
+
/**
|
|
621
|
+
* Start recording concurrently and return a RecordingHandle immediately.
|
|
622
|
+
*
|
|
623
|
+
* frameStream: yields CapturedFrames as each unique frame arrives from CDP
|
|
624
|
+
* (post-dedup, sequential indices starting at 0, NO FPS resampling).
|
|
625
|
+
* Closes when recording ends.
|
|
626
|
+
*
|
|
627
|
+
* done: resolves with the full RecordingSession (FPS-resampled) once
|
|
628
|
+
* all steps have completed and the browser has been cleaned up.
|
|
629
|
+
*
|
|
630
|
+
* Use this with CanvasRenderer.composeStreamOnline() to overlap recording
|
|
631
|
+
* time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
|
|
632
|
+
*/
|
|
633
|
+
recordToChannel(scenario) {
|
|
634
|
+
const channel = new FrameChannel();
|
|
635
|
+
const done = (async () => {
|
|
636
|
+
try {
|
|
637
|
+
await this.init(scenario);
|
|
638
|
+
this.frameChannel = channel;
|
|
639
|
+
const startTime = Date.now();
|
|
640
|
+
if (scenario.steps.length > 0) {
|
|
641
|
+
const s0 = scenario.steps[0];
|
|
642
|
+
this.currentStepIndex = 0;
|
|
643
|
+
this.preRegisterResponseListeners(s0.actions);
|
|
644
|
+
for (let ai = 0; ai < s0.actions.length; ai++) {
|
|
645
|
+
await this.executeAction(s0.actions[ai], ai);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
await this.startCapture();
|
|
649
|
+
for (let si = 0; si < scenario.steps.length; si++) {
|
|
650
|
+
const step = scenario.steps[si];
|
|
651
|
+
this.currentStepIndex = si;
|
|
652
|
+
if (si > 0) {
|
|
653
|
+
this.preRegisterResponseListeners(step.actions);
|
|
654
|
+
for (let ai = 0; ai < step.actions.length; ai++) {
|
|
655
|
+
await this.executeAction(step.actions[ai], ai);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (step.captureDelay > 0) await this.waitWithRepaints(step.captureDelay);
|
|
659
|
+
if (step.holdDuration > 0) await this.waitWithRepaints(step.holdDuration);
|
|
660
|
+
}
|
|
661
|
+
await this.stopCapture();
|
|
662
|
+
channel.close();
|
|
663
|
+
const rawFrames = this.buildCapturedFrames();
|
|
664
|
+
const recordingDurationMs = Date.now() - startTime;
|
|
665
|
+
const frames = this.resampleToTargetFps(rawFrames, recordingDurationMs);
|
|
666
|
+
return {
|
|
667
|
+
scenario,
|
|
668
|
+
frames,
|
|
669
|
+
startTime,
|
|
670
|
+
endTime: Date.now(),
|
|
671
|
+
dedupStats: { ...this.dedupStats }
|
|
672
|
+
};
|
|
673
|
+
} catch (error) {
|
|
674
|
+
channel.close();
|
|
675
|
+
await this.stopCapture().catch(() => {
|
|
676
|
+
});
|
|
677
|
+
const rawFrames = this.buildCapturedFrames();
|
|
678
|
+
const session = {
|
|
679
|
+
scenario,
|
|
680
|
+
frames: rawFrames,
|
|
681
|
+
startTime: Date.now(),
|
|
682
|
+
dedupStats: { ...this.dedupStats }
|
|
683
|
+
};
|
|
684
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
685
|
+
err.partialSession = session;
|
|
686
|
+
throw err;
|
|
687
|
+
} finally {
|
|
688
|
+
await this.cleanup();
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
691
|
+
return { frameStream: channel, done };
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Build a single CapturedFrame from a RawFrame in real-time.
|
|
695
|
+
* Used by recordToChannel() to emit frames as they arrive.
|
|
696
|
+
* Cursor/click data reflects the timeline up to this moment.
|
|
697
|
+
*/
|
|
698
|
+
buildFrameOnline(raw, sequentialIndex) {
|
|
699
|
+
const cursorPos = this.interpolateCursorAt(raw.timestamp);
|
|
700
|
+
const clickEvent = this.clickTimeline.find(
|
|
701
|
+
(click) => raw.timestamp >= click.timestamp && raw.timestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
702
|
+
);
|
|
703
|
+
let clickProgress;
|
|
704
|
+
if (clickEvent) {
|
|
705
|
+
clickProgress = Math.min(1, (raw.timestamp - clickEvent.timestamp) / CLICK_EFFECT_DURATION_MS);
|
|
706
|
+
}
|
|
707
|
+
const frameKeystrokes = this.keystrokeTimeline.filter((k) => k.timestamp <= raw.timestamp);
|
|
708
|
+
return {
|
|
709
|
+
index: sequentialIndex,
|
|
710
|
+
screenshot: raw.buffer,
|
|
711
|
+
timestamp: raw.timestamp,
|
|
712
|
+
cursorPosition: cursorPos,
|
|
713
|
+
clickPosition: clickEvent?.position ?? null,
|
|
714
|
+
clickProgress,
|
|
715
|
+
viewport: { ...this.viewport },
|
|
716
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
717
|
+
stepIndex: raw.stepIndex,
|
|
718
|
+
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
719
|
+
};
|
|
720
|
+
}
|
|
551
721
|
/**
|
|
552
722
|
* Wait for a given duration while forcing periodic repaints
|
|
553
723
|
* so CDP screencast keeps sending frames even on static pages.
|
|
@@ -785,7 +955,8 @@ var ClipwiseRecorder = class {
|
|
|
785
955
|
viewport: { ...this.viewport },
|
|
786
956
|
deviceScaleFactor: this.deviceScaleFactor,
|
|
787
957
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
788
|
-
stepIndex:
|
|
958
|
+
stepIndex: raw.stepIndex
|
|
959
|
+
// use per-frame step index captured at event time
|
|
789
960
|
};
|
|
790
961
|
});
|
|
791
962
|
}
|
|
@@ -1242,22 +1413,43 @@ async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight
|
|
|
1242
1413
|
top = Math.max(0, Math.min(top, frameHeight - cropHeight));
|
|
1243
1414
|
return sharp3(frameBuffer).extract({ left, top, width: cropWidth, height: cropHeight }).resize(frameWidth, frameHeight, { kernel: sharp3.kernel.lanczos3 }).png().toBuffer();
|
|
1244
1415
|
}
|
|
1245
|
-
function
|
|
1246
|
-
|
|
1247
|
-
let minDistance = Infinity;
|
|
1416
|
+
function buildZoomClickLookup(frames) {
|
|
1417
|
+
const indices = [];
|
|
1248
1418
|
for (let i = 0; i < frames.length; i++) {
|
|
1249
|
-
if (frames[i].clickPosition) {
|
|
1250
|
-
|
|
1251
|
-
minDistance = Math.min(minDistance, distance);
|
|
1419
|
+
if (frames[i].clickPosition !== null && frames[i].clickPosition !== void 0) {
|
|
1420
|
+
indices.push(i);
|
|
1252
1421
|
}
|
|
1253
1422
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1423
|
+
return indices;
|
|
1424
|
+
}
|
|
1425
|
+
function calculateAdaptiveZoomFromLookup(clickLookup, currentIndex, maxScale, transitionFrames) {
|
|
1426
|
+
if (maxScale <= 1 || clickLookup.length === 0) return 1;
|
|
1427
|
+
let lo = 0;
|
|
1428
|
+
let hi = clickLookup.length;
|
|
1429
|
+
while (lo < hi) {
|
|
1430
|
+
const mid = lo + hi >>> 1;
|
|
1431
|
+
if (clickLookup[mid] < currentIndex) lo = mid + 1;
|
|
1432
|
+
else hi = mid;
|
|
1433
|
+
}
|
|
1434
|
+
const distBefore = lo > 0 ? currentIndex - clickLookup[lo - 1] : Infinity;
|
|
1435
|
+
const distAfter = lo < clickLookup.length ? clickLookup[lo] - currentIndex : Infinity;
|
|
1436
|
+
const minDistance = Math.min(distBefore, distAfter);
|
|
1437
|
+
if (minDistance > transitionFrames) return 1;
|
|
1438
|
+
const t = 1 - minDistance / transitionFrames;
|
|
1439
|
+
return 1 + (maxScale - 1) * easeInOutCubic2(t);
|
|
1440
|
+
}
|
|
1441
|
+
function calculateAdaptiveZoomInWindow(windowFrames, windowStart, currentIndex, maxScale, transitionFrames) {
|
|
1442
|
+
if (maxScale <= 1) return 1;
|
|
1443
|
+
let minDistance = Infinity;
|
|
1444
|
+
for (let j = 0; j < windowFrames.length; j++) {
|
|
1445
|
+
if (windowFrames[j].clickPosition !== null && windowFrames[j].clickPosition !== void 0) {
|
|
1446
|
+
const dist = Math.abs(windowStart + j - currentIndex);
|
|
1447
|
+
if (dist < minDistance) minDistance = dist;
|
|
1448
|
+
}
|
|
1259
1449
|
}
|
|
1260
|
-
return 1;
|
|
1450
|
+
if (minDistance > transitionFrames) return 1;
|
|
1451
|
+
const t = 1 - minDistance / transitionFrames;
|
|
1452
|
+
return 1 + (maxScale - 1) * easeInOutCubic2(t);
|
|
1261
1453
|
}
|
|
1262
1454
|
function easeInOutCubic2(t) {
|
|
1263
1455
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
@@ -1408,8 +1600,8 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1408
1600
|
|
|
1409
1601
|
// src/effects/watermark.ts
|
|
1410
1602
|
import sharp6 from "sharp";
|
|
1411
|
-
|
|
1412
|
-
if (!config.enabled || !config.text) return
|
|
1603
|
+
function buildWatermarkSvg(config, frameWidth, frameHeight) {
|
|
1604
|
+
if (!config.enabled || !config.text) return "";
|
|
1413
1605
|
const charWidth = config.fontSize * 0.62;
|
|
1414
1606
|
const textWidth = Math.ceil(config.text.length * charWidth);
|
|
1415
1607
|
const margin = 16;
|
|
@@ -1435,13 +1627,17 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1435
1627
|
break;
|
|
1436
1628
|
}
|
|
1437
1629
|
const escaped = config.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1438
|
-
|
|
1630
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1439
1631
|
<text x="${x}" y="${y}"
|
|
1440
1632
|
font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
|
|
1441
1633
|
font-weight="600" fill="${config.color}"
|
|
1442
1634
|
opacity="${config.opacity.toFixed(3)}">${escaped}</text>
|
|
1443
1635
|
</svg>`;
|
|
1444
|
-
|
|
1636
|
+
}
|
|
1637
|
+
async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
1638
|
+
if (!config.enabled || !config.text) return frameBuffer;
|
|
1639
|
+
const svg = buildWatermarkSvg(config, frameWidth, frameHeight);
|
|
1640
|
+
return sharp6(frameBuffer).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toBuffer();
|
|
1445
1641
|
}
|
|
1446
1642
|
|
|
1447
1643
|
// src/compose/compose-frame.ts
|
|
@@ -1472,7 +1668,18 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1472
1668
|
cursorTrail: context?.cursorTrail ?? []
|
|
1473
1669
|
};
|
|
1474
1670
|
if (effects.deviceFrame.enabled) {
|
|
1475
|
-
|
|
1671
|
+
const sl2 = ctx.staticLayers;
|
|
1672
|
+
if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
|
|
1673
|
+
buffer = await sharp7(buffer).extend({
|
|
1674
|
+
top: sl2.browserChromeHeight,
|
|
1675
|
+
bottom: 0,
|
|
1676
|
+
left: 0,
|
|
1677
|
+
right: 0,
|
|
1678
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
1679
|
+
}).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
|
|
1680
|
+
} else {
|
|
1681
|
+
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
1682
|
+
}
|
|
1476
1683
|
const meta2 = await sharp7(buffer).metadata();
|
|
1477
1684
|
width = meta2.width ?? width;
|
|
1478
1685
|
height = meta2.height ?? height;
|
|
@@ -1540,38 +1747,83 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1540
1747
|
};
|
|
1541
1748
|
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1542
1749
|
}
|
|
1543
|
-
|
|
1544
|
-
if (
|
|
1545
|
-
|
|
1750
|
+
const sl = ctx.staticLayers;
|
|
1751
|
+
if (sl) {
|
|
1752
|
+
const padding = effects.background.padding;
|
|
1753
|
+
const contentWidth = output.width - padding * 2;
|
|
1754
|
+
const contentHeight = output.height - padding * 2;
|
|
1755
|
+
if (contentWidth > 0 && contentHeight > 0) {
|
|
1756
|
+
const radius = effects.background.borderRadius;
|
|
1757
|
+
const roundedMask = Buffer.from(
|
|
1758
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${contentWidth}" height="${contentHeight}">
|
|
1759
|
+
<rect width="${contentWidth}" height="${contentHeight}" rx="${radius}" ry="${radius}" fill="#ffffff"/>
|
|
1760
|
+
</svg>`
|
|
1761
|
+
);
|
|
1762
|
+
const { data: maskedData, info: maskedInfo } = await sharp7(buffer).resize(contentWidth, contentHeight, { fit: "fill" }).composite([{ input: roundedMask, blend: "dest-in" }]).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1763
|
+
const { data: composited, info: compInfo } = await sharp7(sl.backdropRaw, {
|
|
1764
|
+
raw: { width: sl.backdropWidth, height: sl.backdropHeight, channels: 4 }
|
|
1765
|
+
}).composite([{
|
|
1766
|
+
input: Buffer.from(maskedData),
|
|
1767
|
+
raw: { width: maskedInfo.width, height: maskedInfo.height, channels: 4 },
|
|
1768
|
+
left: padding,
|
|
1769
|
+
top: padding
|
|
1770
|
+
}]).raw().toBuffer({ resolveWithObject: true });
|
|
1771
|
+
return {
|
|
1772
|
+
index: frame.index,
|
|
1773
|
+
buffer: Buffer.from(composited),
|
|
1774
|
+
timestamp: frame.timestamp,
|
|
1775
|
+
rawInfo: { width: compInfo.width, height: compInfo.height, channels: 4 }
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
buffer = sl.backdropRaw;
|
|
1779
|
+
} else {
|
|
1780
|
+
buffer = await applyBackground(buffer, effects.background, output.width, output.height);
|
|
1781
|
+
if (effects.watermark.enabled) {
|
|
1782
|
+
buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
|
|
1783
|
+
}
|
|
1546
1784
|
}
|
|
1547
|
-
|
|
1785
|
+
const { data: finalData, info: finalInfo } = await sharp7(buffer).resize(output.width, output.height, {
|
|
1548
1786
|
fit: "fill",
|
|
1549
1787
|
kernel: sharp7.kernel.lanczos3
|
|
1550
|
-
}).
|
|
1551
|
-
return {
|
|
1788
|
+
}).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1789
|
+
return {
|
|
1790
|
+
index: frame.index,
|
|
1791
|
+
buffer: Buffer.from(finalData),
|
|
1792
|
+
timestamp: frame.timestamp,
|
|
1793
|
+
rawInfo: { width: finalInfo.width, height: finalInfo.height, channels: 4 }
|
|
1794
|
+
};
|
|
1552
1795
|
}
|
|
1553
1796
|
|
|
1554
1797
|
// src/effects/transition.ts
|
|
1555
1798
|
import sharp8 from "sharp";
|
|
1556
|
-
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
|
|
1799
|
+
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
|
|
1557
1800
|
const t = Math.max(0, Math.min(1, progress));
|
|
1558
|
-
if (t <= 0)
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1801
|
+
if (t <= 0) {
|
|
1802
|
+
const rawInfo = fromRawInfo ?? { width, height, channels: 4 };
|
|
1803
|
+
if (fromRawInfo) return { buffer: fromBuffer, rawInfo };
|
|
1804
|
+
const { data, info } = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1805
|
+
return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
|
|
1806
|
+
}
|
|
1807
|
+
if (t >= 1) {
|
|
1808
|
+
const rawInfo = toRawInfo ?? { width, height, channels: 4 };
|
|
1809
|
+
if (toRawInfo) return { buffer: toBuffer, rawInfo };
|
|
1810
|
+
const { data, info } = await sharp8(toBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1811
|
+
return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
|
|
1812
|
+
}
|
|
1813
|
+
const fromSrc = fromRawInfo ? sharp8(fromBuffer, { raw: { width: fromRawInfo.width, height: fromRawInfo.height, channels: fromRawInfo.channels } }) : sharp8(fromBuffer);
|
|
1814
|
+
const fromRaw = await fromSrc.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1815
|
+
const toSrc = toRawInfo ? sharp8(toBuffer, { raw: { width: toRawInfo.width, height: toRawInfo.height, channels: toRawInfo.channels } }) : sharp8(toBuffer);
|
|
1816
|
+
const toRaw = await toSrc.resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1562
1817
|
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
1563
1818
|
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
1564
1819
|
pixels[i] = Math.round(
|
|
1565
1820
|
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
1566
1821
|
);
|
|
1567
1822
|
}
|
|
1568
|
-
return
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
channels: 4
|
|
1573
|
-
}
|
|
1574
|
-
}).png().toBuffer();
|
|
1823
|
+
return {
|
|
1824
|
+
buffer: pixels,
|
|
1825
|
+
rawInfo: { width: fromRaw.info.width, height: fromRaw.info.height, channels: 4 }
|
|
1826
|
+
};
|
|
1575
1827
|
}
|
|
1576
1828
|
|
|
1577
1829
|
// src/compose/canvas-renderer.ts
|
|
@@ -1683,7 +1935,8 @@ var CanvasRenderer = class {
|
|
|
1683
1935
|
results[msg.taskId] = {
|
|
1684
1936
|
index: frames[msg.taskId].index,
|
|
1685
1937
|
buffer: Buffer.from(msg.buffer),
|
|
1686
|
-
timestamp: frames[msg.taskId].timestamp
|
|
1938
|
+
timestamp: frames[msg.taskId].timestamp,
|
|
1939
|
+
rawInfo: msg.rawInfo
|
|
1687
1940
|
};
|
|
1688
1941
|
completed++;
|
|
1689
1942
|
if (completed === frames.length) {
|
|
@@ -1711,12 +1964,13 @@ var CanvasRenderer = class {
|
|
|
1711
1964
|
const transitionFrames = Math.round(
|
|
1712
1965
|
this.output.fps * (this.effects.zoom.duration / 1e3)
|
|
1713
1966
|
);
|
|
1967
|
+
const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
|
|
1714
1968
|
for (let i = 0; i < frames.length; i++) {
|
|
1715
1969
|
const frame = frames[i];
|
|
1716
1970
|
let zoomScale = 1;
|
|
1717
1971
|
if (this.effects.zoom.enabled) {
|
|
1718
|
-
zoomScale =
|
|
1719
|
-
|
|
1972
|
+
zoomScale = calculateAdaptiveZoomFromLookup(
|
|
1973
|
+
clickLookup,
|
|
1720
1974
|
i,
|
|
1721
1975
|
this.effects.zoom.scale,
|
|
1722
1976
|
transitionFrames
|
|
@@ -1766,6 +2020,369 @@ var CanvasRenderer = class {
|
|
|
1766
2020
|
}
|
|
1767
2021
|
return result;
|
|
1768
2022
|
}
|
|
2023
|
+
// ─── Online streaming pipeline (Phase 3-B) ─────────────────────────────────
|
|
2024
|
+
/**
|
|
2025
|
+
* Returns true when no effect requires the full frame array upfront.
|
|
2026
|
+
*
|
|
2027
|
+
* When true, composeStreamOnline() can be used: frames are composited as they
|
|
2028
|
+
* arrive (no need to wait for all frames to be collected first).
|
|
2029
|
+
*
|
|
2030
|
+
* Currently the only blocking effect is speed ramp, which needs to scan all
|
|
2031
|
+
* frames to compute action-proximity indices. Zoom uses the window-based
|
|
2032
|
+
* calculateAdaptiveZoomInWindow() so it works with a rolling lookahead buffer.
|
|
2033
|
+
*/
|
|
2034
|
+
canStreamOnline() {
|
|
2035
|
+
return !this.effects.speedRamp.enabled;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Online streaming compose — accepts an AsyncIterable of frames (e.g. from
|
|
2039
|
+
* ClipwiseRecorder.recordToChannel()) and begins compositing immediately,
|
|
2040
|
+
* without waiting for all frames to be collected.
|
|
2041
|
+
*
|
|
2042
|
+
* Each frame is dispatched to the worker pool as soon as its zoom lookahead
|
|
2043
|
+
* window is satisfied (i.e. when frame i + transitionFrames has arrived).
|
|
2044
|
+
* This creates a natural pipeline: recording produces frames while workers
|
|
2045
|
+
* consume them in parallel.
|
|
2046
|
+
*
|
|
2047
|
+
* Requires canStreamOnline() === true (speedRamp must be disabled).
|
|
2048
|
+
* Transitions (step boundaries with transition: fade) are applied inline
|
|
2049
|
+
* using the same applyTransitionsToStream() logic as composeStream().
|
|
2050
|
+
*/
|
|
2051
|
+
async *composeStreamOnline(source) {
|
|
2052
|
+
const hasFadeTransitions = this.steps.some((s) => s.transition === "fade");
|
|
2053
|
+
if (!hasFadeTransitions) {
|
|
2054
|
+
const cpuCount = os.cpus().length;
|
|
2055
|
+
const workerCount = Math.min(cpuCount, 8);
|
|
2056
|
+
yield* this.streamOnlineWithWorkers(source, workerCount);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const collected = [];
|
|
2060
|
+
for await (const frame of source) {
|
|
2061
|
+
collected.push(frame);
|
|
2062
|
+
}
|
|
2063
|
+
yield* this.composeStream(collected);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Worker-pool online streaming: dispatches frame i to a worker as soon as
|
|
2067
|
+
* frame i + transitionFrames has arrived from the source.
|
|
2068
|
+
*
|
|
2069
|
+
* Uses a notify-on-progress pattern (same as streamWithWorkers) extended
|
|
2070
|
+
* with an intake coroutine that feeds the growing frames[] buffer.
|
|
2071
|
+
*/
|
|
2072
|
+
async *streamOnlineWithWorkers(source, workerCount) {
|
|
2073
|
+
const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
|
|
2074
|
+
const trailLength = this.effects.cursor.trailLength;
|
|
2075
|
+
const frames = [];
|
|
2076
|
+
let sourceComplete = false;
|
|
2077
|
+
let workerError = null;
|
|
2078
|
+
let notify = null;
|
|
2079
|
+
const trigger = () => {
|
|
2080
|
+
notify?.();
|
|
2081
|
+
notify = null;
|
|
2082
|
+
};
|
|
2083
|
+
const waitForProgress = () => new Promise((r) => {
|
|
2084
|
+
notify = r;
|
|
2085
|
+
});
|
|
2086
|
+
const completed = /* @__PURE__ */ new Map();
|
|
2087
|
+
const idleWorkers = [];
|
|
2088
|
+
let nextToDispatch = 0;
|
|
2089
|
+
let nextToYield = 0;
|
|
2090
|
+
const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
|
|
2091
|
+
const computeContext = (i) => {
|
|
2092
|
+
const frame = frames[i];
|
|
2093
|
+
let zoomScale = 1;
|
|
2094
|
+
if (this.effects.zoom.enabled) {
|
|
2095
|
+
const lo = Math.max(0, i - transitionFrames);
|
|
2096
|
+
const hi = Math.min(frames.length - 1, i + transitionFrames);
|
|
2097
|
+
zoomScale = calculateAdaptiveZoomInWindow(
|
|
2098
|
+
frames.slice(lo, hi + 1),
|
|
2099
|
+
lo,
|
|
2100
|
+
i,
|
|
2101
|
+
this.effects.zoom.scale,
|
|
2102
|
+
transitionFrames
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
|
|
2106
|
+
const trail = [];
|
|
2107
|
+
for (let j = Math.max(0, i - trailLength); j <= i; j++) {
|
|
2108
|
+
if (frames[j].cursorPosition) trail.push(frames[j].cursorPosition);
|
|
2109
|
+
}
|
|
2110
|
+
return { zoomScale, clickProgress, cursorTrail: trail };
|
|
2111
|
+
};
|
|
2112
|
+
const dispatch = (worker) => {
|
|
2113
|
+
if (canDispatch(nextToDispatch)) {
|
|
2114
|
+
const i = nextToDispatch++;
|
|
2115
|
+
worker.postMessage({
|
|
2116
|
+
taskId: i,
|
|
2117
|
+
frame: frames[i],
|
|
2118
|
+
effects: this.effects,
|
|
2119
|
+
output: this.output,
|
|
2120
|
+
context: computeContext(i)
|
|
2121
|
+
});
|
|
2122
|
+
} else {
|
|
2123
|
+
idleWorkers.push(worker);
|
|
2124
|
+
}
|
|
2125
|
+
};
|
|
2126
|
+
const dispatchToIdle = () => {
|
|
2127
|
+
while (idleWorkers.length > 0 && canDispatch(nextToDispatch)) {
|
|
2128
|
+
dispatch(idleWorkers.shift());
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
const workerUrl = getWorkerUrl();
|
|
2132
|
+
const workers = [];
|
|
2133
|
+
for (let w = 0; w < workerCount; w++) {
|
|
2134
|
+
const worker = new Worker(workerUrl);
|
|
2135
|
+
workers.push(worker);
|
|
2136
|
+
worker.on("message", (msg) => {
|
|
2137
|
+
if (workerError) return;
|
|
2138
|
+
if (msg.error) {
|
|
2139
|
+
workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
|
|
2140
|
+
} else {
|
|
2141
|
+
completed.set(msg.taskId, {
|
|
2142
|
+
index: frames[msg.taskId].index,
|
|
2143
|
+
buffer: Buffer.from(msg.buffer),
|
|
2144
|
+
timestamp: frames[msg.taskId].timestamp,
|
|
2145
|
+
rawInfo: msg.rawInfo
|
|
2146
|
+
});
|
|
2147
|
+
dispatch(worker);
|
|
2148
|
+
}
|
|
2149
|
+
trigger();
|
|
2150
|
+
});
|
|
2151
|
+
worker.on("error", (err) => {
|
|
2152
|
+
workerError = err;
|
|
2153
|
+
trigger();
|
|
2154
|
+
});
|
|
2155
|
+
idleWorkers.push(worker);
|
|
2156
|
+
}
|
|
2157
|
+
const intakeTask = (async () => {
|
|
2158
|
+
for await (const frame of source) {
|
|
2159
|
+
frames.push(frame);
|
|
2160
|
+
dispatchToIdle();
|
|
2161
|
+
trigger();
|
|
2162
|
+
}
|
|
2163
|
+
sourceComplete = true;
|
|
2164
|
+
dispatchToIdle();
|
|
2165
|
+
trigger();
|
|
2166
|
+
})();
|
|
2167
|
+
try {
|
|
2168
|
+
while (true) {
|
|
2169
|
+
if (workerError) throw workerError;
|
|
2170
|
+
if (sourceComplete && nextToDispatch >= frames.length && nextToYield >= frames.length) {
|
|
2171
|
+
break;
|
|
2172
|
+
}
|
|
2173
|
+
if (completed.has(nextToYield)) {
|
|
2174
|
+
const frame = completed.get(nextToYield);
|
|
2175
|
+
completed.delete(nextToYield);
|
|
2176
|
+
nextToYield++;
|
|
2177
|
+
yield frame;
|
|
2178
|
+
continue;
|
|
2179
|
+
}
|
|
2180
|
+
await waitForProgress();
|
|
2181
|
+
}
|
|
2182
|
+
} finally {
|
|
2183
|
+
await intakeTask;
|
|
2184
|
+
workers.forEach((w) => w.terminate());
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
// ─── Streaming pipeline (Phase 1-B) ────────────────────────────────────────
|
|
2188
|
+
/**
|
|
2189
|
+
* Stream frame composition — yields ComposedFrames as workers finish,
|
|
2190
|
+
* in display order, so the encoder can start before all frames are composed.
|
|
2191
|
+
*
|
|
2192
|
+
* Same 4-pass structure as composeAll():
|
|
2193
|
+
* Pass 1 & 2 run upfront (need the full frame set).
|
|
2194
|
+
* Pass 3 streams via the worker pool (ordered yield).
|
|
2195
|
+
* Pass 4 transitions are buffered inline and applied at step boundaries.
|
|
2196
|
+
*/
|
|
2197
|
+
async *composeStream(frames) {
|
|
2198
|
+
if (frames.length === 0) return;
|
|
2199
|
+
let processFrames = frames;
|
|
2200
|
+
if (this.effects.speedRamp.enabled) {
|
|
2201
|
+
processFrames = this.applySpeedRamp(frames);
|
|
2202
|
+
}
|
|
2203
|
+
const contexts = this.calculateFrameContexts(processFrames);
|
|
2204
|
+
const windows = this.getTransitionWindows(processFrames);
|
|
2205
|
+
const cpuCount = os.cpus().length;
|
|
2206
|
+
const workerCount = Math.min(cpuCount, 8);
|
|
2207
|
+
const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
|
|
2208
|
+
const rawStream = useWorkers ? this.streamWithWorkers(processFrames, contexts, workerCount) : this.streamSequential(processFrames, contexts);
|
|
2209
|
+
yield* this.applyTransitionsToStream(rawStream, windows);
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Worker-pool streaming: dispatches frames to workers and yields results
|
|
2213
|
+
* in display order as soon as each frame is ready.
|
|
2214
|
+
*
|
|
2215
|
+
* Uses a notify-on-progress pattern to bridge event-driven workers
|
|
2216
|
+
* to an ordered AsyncGenerator without busy-polling.
|
|
2217
|
+
*/
|
|
2218
|
+
async *streamWithWorkers(frames, contexts, workerCount) {
|
|
2219
|
+
const completed = new Array(frames.length);
|
|
2220
|
+
let workerError = null;
|
|
2221
|
+
let notify = null;
|
|
2222
|
+
const waitForProgress = () => new Promise((r) => {
|
|
2223
|
+
notify = r;
|
|
2224
|
+
});
|
|
2225
|
+
const workerUrl = getWorkerUrl();
|
|
2226
|
+
const workers = [];
|
|
2227
|
+
let nextToDispatch = 0;
|
|
2228
|
+
const dispatch = (worker) => {
|
|
2229
|
+
if (nextToDispatch >= frames.length || workerError) return;
|
|
2230
|
+
const i = nextToDispatch++;
|
|
2231
|
+
worker.postMessage({
|
|
2232
|
+
taskId: i,
|
|
2233
|
+
frame: frames[i],
|
|
2234
|
+
effects: this.effects,
|
|
2235
|
+
output: this.output,
|
|
2236
|
+
context: contexts[i]
|
|
2237
|
+
});
|
|
2238
|
+
};
|
|
2239
|
+
for (let w = 0; w < workerCount; w++) {
|
|
2240
|
+
const worker = new Worker(workerUrl);
|
|
2241
|
+
workers.push(worker);
|
|
2242
|
+
worker.on("message", (msg) => {
|
|
2243
|
+
if (workerError) return;
|
|
2244
|
+
if (msg.error) {
|
|
2245
|
+
workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
|
|
2246
|
+
} else {
|
|
2247
|
+
completed[msg.taskId] = {
|
|
2248
|
+
index: frames[msg.taskId].index,
|
|
2249
|
+
buffer: Buffer.from(msg.buffer),
|
|
2250
|
+
timestamp: frames[msg.taskId].timestamp,
|
|
2251
|
+
rawInfo: msg.rawInfo
|
|
2252
|
+
};
|
|
2253
|
+
dispatch(worker);
|
|
2254
|
+
}
|
|
2255
|
+
notify?.();
|
|
2256
|
+
notify = null;
|
|
2257
|
+
});
|
|
2258
|
+
worker.on("error", (err) => {
|
|
2259
|
+
workerError = err;
|
|
2260
|
+
notify?.();
|
|
2261
|
+
notify = null;
|
|
2262
|
+
});
|
|
2263
|
+
dispatch(worker);
|
|
2264
|
+
}
|
|
2265
|
+
try {
|
|
2266
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2267
|
+
while (completed[i] === void 0 && !workerError) {
|
|
2268
|
+
await waitForProgress();
|
|
2269
|
+
}
|
|
2270
|
+
if (workerError) throw workerError;
|
|
2271
|
+
const frame = completed[i];
|
|
2272
|
+
completed[i] = void 0;
|
|
2273
|
+
yield frame;
|
|
2274
|
+
}
|
|
2275
|
+
} finally {
|
|
2276
|
+
workers.forEach((w) => w.terminate());
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Sequential streaming fallback for small frame counts where worker
|
|
2281
|
+
* thread overhead would exceed the parallelism benefit.
|
|
2282
|
+
*/
|
|
2283
|
+
async *streamSequential(frames, contexts) {
|
|
2284
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2285
|
+
yield await composeFrame(frames[i], this.effects, this.output, contexts[i]);
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Pre-compute [startIdx, endIdx] windows for every fade transition so that
|
|
2290
|
+
* applyTransitionsToStream can buffer only those frames.
|
|
2291
|
+
*/
|
|
2292
|
+
getTransitionWindows(frames) {
|
|
2293
|
+
if (this.steps.length === 0) return [];
|
|
2294
|
+
const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
|
|
2295
|
+
const windows = [];
|
|
2296
|
+
for (let i = 1; i < frames.length; i++) {
|
|
2297
|
+
if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
|
|
2298
|
+
const stepIdx = frames[i].stepIndex;
|
|
2299
|
+
const step = this.steps[stepIdx];
|
|
2300
|
+
if (step && step.transition === "fade") {
|
|
2301
|
+
const startIdx = Math.max(0, i - Math.floor(transitionFrames / 2));
|
|
2302
|
+
const endIdx = Math.min(frames.length - 1, i + Math.ceil(transitionFrames / 2));
|
|
2303
|
+
if (endIdx - startIdx >= 2) {
|
|
2304
|
+
windows.push({ startIdx, endIdx });
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
return windows;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Wrap a ComposedFrame stream with inline transition buffering.
|
|
2313
|
+
*
|
|
2314
|
+
* Non-transition frames are yielded immediately.
|
|
2315
|
+
* Frames inside a fade window are held until both endpoints are available,
|
|
2316
|
+
* then the crossfade is applied and all window frames are flushed in order.
|
|
2317
|
+
* A pending map maintains global display order across window boundaries.
|
|
2318
|
+
*/
|
|
2319
|
+
async *applyTransitionsToStream(source, windows) {
|
|
2320
|
+
if (windows.length === 0) {
|
|
2321
|
+
yield* source;
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
const frameToWindow = /* @__PURE__ */ new Map();
|
|
2325
|
+
for (let wi = 0; wi < windows.length; wi++) {
|
|
2326
|
+
for (let i = windows[wi].startIdx; i <= windows[wi].endIdx; i++) {
|
|
2327
|
+
frameToWindow.set(i, wi);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
const windowState = windows.map((w) => ({
|
|
2331
|
+
frames: new Array(w.endIdx - w.startIdx + 1),
|
|
2332
|
+
received: 0
|
|
2333
|
+
}));
|
|
2334
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2335
|
+
let nextToYield = 0;
|
|
2336
|
+
let frameIdx = 0;
|
|
2337
|
+
for await (const frame of source) {
|
|
2338
|
+
const idx = frameIdx++;
|
|
2339
|
+
const wi = frameToWindow.get(idx);
|
|
2340
|
+
if (wi === void 0) {
|
|
2341
|
+
pending.set(idx, frame);
|
|
2342
|
+
} else {
|
|
2343
|
+
const win = windows[wi];
|
|
2344
|
+
const state = windowState[wi];
|
|
2345
|
+
state.frames[idx - win.startIdx] = frame;
|
|
2346
|
+
state.received++;
|
|
2347
|
+
if (state.received === state.frames.length) {
|
|
2348
|
+
const fromBuf = state.frames[0].buffer;
|
|
2349
|
+
const toBuf = state.frames[state.frames.length - 1].buffer;
|
|
2350
|
+
const range = state.frames.length - 1;
|
|
2351
|
+
const fromRawInfo = state.frames[0].rawInfo;
|
|
2352
|
+
const toRawInfo = state.frames[state.frames.length - 1].rawInfo;
|
|
2353
|
+
for (let j = 1; j < state.frames.length - 1; j++) {
|
|
2354
|
+
const blended = await applyCrossfade(
|
|
2355
|
+
fromBuf,
|
|
2356
|
+
toBuf,
|
|
2357
|
+
j / range,
|
|
2358
|
+
this.output.width,
|
|
2359
|
+
this.output.height,
|
|
2360
|
+
fromRawInfo,
|
|
2361
|
+
toRawInfo
|
|
2362
|
+
);
|
|
2363
|
+
state.frames[j] = {
|
|
2364
|
+
...state.frames[j],
|
|
2365
|
+
buffer: blended.buffer,
|
|
2366
|
+
rawInfo: blended.rawInfo
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
for (let j = 0; j < state.frames.length; j++) {
|
|
2370
|
+
pending.set(win.startIdx + j, state.frames[j]);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
while (pending.has(nextToYield)) {
|
|
2375
|
+
yield pending.get(nextToYield);
|
|
2376
|
+
pending.delete(nextToYield);
|
|
2377
|
+
nextToYield++;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
while (pending.has(nextToYield)) {
|
|
2381
|
+
yield pending.get(nextToYield);
|
|
2382
|
+
pending.delete(nextToYield);
|
|
2383
|
+
nextToYield++;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
1769
2386
|
/**
|
|
1770
2387
|
* Apply crossfade transitions at step boundaries where configured.
|
|
1771
2388
|
*/
|
|
@@ -1788,15 +2405,21 @@ var CanvasRenderer = class {
|
|
|
1788
2405
|
if (range < 2) continue;
|
|
1789
2406
|
const fromBuffer = composed[startIdx].buffer;
|
|
1790
2407
|
const toBuffer = composed[endIdx].buffer;
|
|
2408
|
+
const fromRawInfo = composed[startIdx].rawInfo;
|
|
2409
|
+
const toRawInfo = composed[endIdx].rawInfo;
|
|
1791
2410
|
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
1792
2411
|
const progress = (i - startIdx) / range;
|
|
1793
|
-
|
|
2412
|
+
const blended = await applyCrossfade(
|
|
1794
2413
|
fromBuffer,
|
|
1795
2414
|
toBuffer,
|
|
1796
2415
|
progress,
|
|
1797
2416
|
this.output.width,
|
|
1798
|
-
this.output.height
|
|
2417
|
+
this.output.height,
|
|
2418
|
+
fromRawInfo,
|
|
2419
|
+
toRawInfo
|
|
1799
2420
|
);
|
|
2421
|
+
composed[i].buffer = blended.buffer;
|
|
2422
|
+
composed[i].rawInfo = blended.rawInfo;
|
|
1800
2423
|
}
|
|
1801
2424
|
}
|
|
1802
2425
|
}
|
|
@@ -1853,7 +2476,8 @@ async function encodeGif(frames, config) {
|
|
|
1853
2476
|
const gif = GIFEncoder();
|
|
1854
2477
|
const delay = Math.round(1e3 / config.fps);
|
|
1855
2478
|
for (const frame of frames) {
|
|
1856
|
-
const
|
|
2479
|
+
const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
|
|
2480
|
+
const { data, info } = await src.resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1857
2481
|
const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1858
2482
|
const palette = quantize(rgba, 256);
|
|
1859
2483
|
const indexed = applyPalette(rgba, palette);
|
|
@@ -1862,22 +2486,19 @@ async function encodeGif(frames, config) {
|
|
|
1862
2486
|
gif.finish();
|
|
1863
2487
|
return Buffer.from(gif.bytes());
|
|
1864
2488
|
}
|
|
1865
|
-
async function
|
|
1866
|
-
if (frames.length === 0) {
|
|
1867
|
-
throw new Error("Cannot encode MP4: no frames provided");
|
|
1868
|
-
}
|
|
2489
|
+
async function encodeMp4Stream(frames, config) {
|
|
1869
2490
|
const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
1870
2491
|
try {
|
|
1871
2492
|
const encoder = await detectVideoEncoder();
|
|
1872
2493
|
const params = resolveEncodingParams(config);
|
|
1873
|
-
await
|
|
2494
|
+
await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath);
|
|
1874
2495
|
return await readFile2(outputPath);
|
|
1875
2496
|
} finally {
|
|
1876
2497
|
await rm(outputPath, { force: true }).catch(() => {
|
|
1877
2498
|
});
|
|
1878
2499
|
}
|
|
1879
2500
|
}
|
|
1880
|
-
async function
|
|
2501
|
+
async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
1881
2502
|
const videoArgs = encoder === "hevc_videotoolbox" ? [
|
|
1882
2503
|
"-c:v",
|
|
1883
2504
|
"hevc_videotoolbox",
|
|
@@ -1887,7 +2508,6 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
|
1887
2508
|
"yuv420p",
|
|
1888
2509
|
"-tag:v",
|
|
1889
2510
|
"hvc1"
|
|
1890
|
-
// required for playback in QuickTime / Apple devices
|
|
1891
2511
|
] : encoder === "h264_videotoolbox" ? [
|
|
1892
2512
|
"-c:v",
|
|
1893
2513
|
"h264_videotoolbox",
|
|
@@ -1916,7 +2536,6 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
|
1916
2536
|
"ffmpeg",
|
|
1917
2537
|
[
|
|
1918
2538
|
"-y",
|
|
1919
|
-
// Video input: raw RGB24 from stdin
|
|
1920
2539
|
"-f",
|
|
1921
2540
|
"rawvideo",
|
|
1922
2541
|
"-pixel_format",
|
|
@@ -1927,7 +2546,6 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
|
1927
2546
|
String(config.fps),
|
|
1928
2547
|
"-i",
|
|
1929
2548
|
"pipe:0",
|
|
1930
|
-
// Silent audio track for platform compatibility
|
|
1931
2549
|
"-f",
|
|
1932
2550
|
"lavfi",
|
|
1933
2551
|
"-i",
|
|
@@ -1970,8 +2588,9 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
|
1970
2588
|
}
|
|
1971
2589
|
});
|
|
1972
2590
|
(async () => {
|
|
1973
|
-
for (const frame of frames) {
|
|
1974
|
-
const
|
|
2591
|
+
for await (const frame of frames) {
|
|
2592
|
+
const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
|
|
2593
|
+
const raw = await src.flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
|
|
1975
2594
|
if (!ffmpeg.stdin.write(raw)) {
|
|
1976
2595
|
await new Promise((r) => ffmpeg.stdin.once("drain", r));
|
|
1977
2596
|
}
|
|
@@ -1999,6 +2618,76 @@ async function savePngSequence(frames, config) {
|
|
|
1999
2618
|
return paths;
|
|
2000
2619
|
}
|
|
2001
2620
|
|
|
2621
|
+
// src/compose/streaming-session.ts
|
|
2622
|
+
import { EventEmitter } from "events";
|
|
2623
|
+
var ConcurrentSession = class extends EventEmitter {
|
|
2624
|
+
constructor(recorder, scenario, renderer) {
|
|
2625
|
+
super();
|
|
2626
|
+
this.recorder = recorder;
|
|
2627
|
+
this.scenario = scenario;
|
|
2628
|
+
this.renderer = renderer;
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Start recording and compositing concurrently.
|
|
2632
|
+
* Returns when both recording and encoding are complete.
|
|
2633
|
+
*/
|
|
2634
|
+
async run() {
|
|
2635
|
+
const handle = this.recorder.recordToChannel(this.scenario);
|
|
2636
|
+
let composed = 0;
|
|
2637
|
+
const self = this;
|
|
2638
|
+
const buffer = await encodeMp4Stream(
|
|
2639
|
+
(async function* () {
|
|
2640
|
+
for await (const frame of self.renderer.composeStreamOnline(handle.frameStream)) {
|
|
2641
|
+
composed++;
|
|
2642
|
+
self.emit("progress", { composed, total: -1, pct: -1 });
|
|
2643
|
+
yield frame;
|
|
2644
|
+
}
|
|
2645
|
+
})(),
|
|
2646
|
+
this.scenario.output
|
|
2647
|
+
);
|
|
2648
|
+
const session = await handle.done;
|
|
2649
|
+
this.emit("progress", { composed, total: composed, pct: 100 });
|
|
2650
|
+
return { buffer, session };
|
|
2651
|
+
}
|
|
2652
|
+
};
|
|
2653
|
+
var StreamingSession = class extends EventEmitter {
|
|
2654
|
+
constructor(session, renderer) {
|
|
2655
|
+
super();
|
|
2656
|
+
this.session = session;
|
|
2657
|
+
this.renderer = renderer;
|
|
2658
|
+
}
|
|
2659
|
+
/** Total frames in the underlying recording session. */
|
|
2660
|
+
get totalFrames() {
|
|
2661
|
+
return this.session.frames.length;
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Run the compose → encode pipeline.
|
|
2665
|
+
*
|
|
2666
|
+
* Composes frames via the worker pool (Phase 1-B streaming, ordered yield),
|
|
2667
|
+
* forwarding each to FFmpeg as it completes. Emits a 'progress' event after
|
|
2668
|
+
* every composed frame so callers can update a spinner or progress bar.
|
|
2669
|
+
*
|
|
2670
|
+
* @returns The fully-encoded MP4 as a Buffer.
|
|
2671
|
+
*/
|
|
2672
|
+
async run() {
|
|
2673
|
+
const { frames, scenario } = this.session;
|
|
2674
|
+
const total = frames.length;
|
|
2675
|
+
let composed = 0;
|
|
2676
|
+
const self = this;
|
|
2677
|
+
return encodeMp4Stream(
|
|
2678
|
+
(async function* () {
|
|
2679
|
+
for await (const frame of self.renderer.composeStream(frames)) {
|
|
2680
|
+
composed++;
|
|
2681
|
+
const pct = total > 0 ? Math.round(composed / total * 100) : 100;
|
|
2682
|
+
self.emit("progress", { composed, total, pct });
|
|
2683
|
+
yield frame;
|
|
2684
|
+
}
|
|
2685
|
+
})(),
|
|
2686
|
+
scenario.output
|
|
2687
|
+
);
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
|
|
2002
2691
|
// src/cli/index.ts
|
|
2003
2692
|
import { writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
|
|
2004
2693
|
import { join as join2, resolve, dirname } from "path";
|
|
@@ -2063,72 +2752,91 @@ program.command("record").description("Record a demo from a YAML scenario file")
|
|
|
2063
2752
|
process.exit(1);
|
|
2064
2753
|
}
|
|
2065
2754
|
}
|
|
2066
|
-
|
|
2067
|
-
`Recording ${scenario.steps.length} steps...`
|
|
2068
|
-
);
|
|
2755
|
+
await mkdir2(options.output, { recursive: true });
|
|
2069
2756
|
const recorder = new ClipwiseRecorder();
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2757
|
+
const renderer = new CanvasRenderer(
|
|
2758
|
+
scenario.effects,
|
|
2759
|
+
scenario.output,
|
|
2760
|
+
scenario.steps
|
|
2073
2761
|
);
|
|
2074
|
-
|
|
2075
|
-
if (
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
);
|
|
2082
|
-
|
|
2083
|
-
spinner.succeed("Effects applied");
|
|
2084
|
-
} else {
|
|
2085
|
-
composedFrames = session.frames.map((f) => ({
|
|
2086
|
-
index: f.index,
|
|
2087
|
-
buffer: f.screenshot,
|
|
2088
|
-
timestamp: f.timestamp
|
|
2089
|
-
}));
|
|
2090
|
-
spinner.info("Effects disabled, using raw frames");
|
|
2091
|
-
}
|
|
2092
|
-
await mkdir2(options.output, { recursive: true });
|
|
2093
|
-
if (scenario.output.format === "png-sequence") {
|
|
2094
|
-
spinner.start("Saving PNG sequence...");
|
|
2095
|
-
const paths = await savePngSequence(
|
|
2096
|
-
composedFrames,
|
|
2097
|
-
scenario.output
|
|
2098
|
-
);
|
|
2099
|
-
spinner.succeed(
|
|
2100
|
-
`Saved ${paths.length} frames to ${chalk.bold(options.output)}`
|
|
2101
|
-
);
|
|
2102
|
-
} else if (scenario.output.format === "mp4") {
|
|
2103
|
-
spinner.start("Encoding MP4...");
|
|
2104
|
-
const mp4Buffer = await encodeMp4(
|
|
2105
|
-
composedFrames,
|
|
2106
|
-
scenario.output
|
|
2107
|
-
);
|
|
2108
|
-
const outputPath = join2(
|
|
2109
|
-
options.output,
|
|
2110
|
-
`${scenario.output.filename}.mp4`
|
|
2111
|
-
);
|
|
2762
|
+
const isConcurrentEligible = scenario.output.format === "mp4" && options.effects !== false && renderer.canStreamOnline();
|
|
2763
|
+
if (isConcurrentEligible) {
|
|
2764
|
+
const pipeline = new ConcurrentSession(recorder, scenario, renderer);
|
|
2765
|
+
pipeline.on("progress", ({ composed, total, pct }) => {
|
|
2766
|
+
spinner.text = total > 0 ? `Recording & composing... ${composed}/${total} (${pct}%)` : `Recording & composing... ${composed} frames`;
|
|
2767
|
+
});
|
|
2768
|
+
spinner.start(`Recording & composing ${scenario.steps.length} steps concurrently...`);
|
|
2769
|
+
const { buffer: mp4Buffer, session } = await pipeline.run();
|
|
2770
|
+
const outputPath = join2(options.output, `${scenario.output.filename}.mp4`);
|
|
2112
2771
|
await writeFile2(outputPath, mp4Buffer);
|
|
2113
2772
|
const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
|
|
2114
2773
|
spinner.succeed(
|
|
2115
|
-
`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
|
|
2774
|
+
`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB, ${session.frames.length} frames)`
|
|
2116
2775
|
);
|
|
2117
2776
|
} else {
|
|
2118
|
-
spinner.start(
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2777
|
+
spinner.start(`Recording ${scenario.steps.length} steps...`);
|
|
2778
|
+
const session = await recorder.record(scenario);
|
|
2779
|
+
spinner.succeed(`Recorded ${session.frames.length} frames`);
|
|
2780
|
+
if (scenario.output.format === "png-sequence") {
|
|
2781
|
+
let composedFrames;
|
|
2782
|
+
if (options.effects !== false) {
|
|
2783
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
2784
|
+
composedFrames = await renderer.composeAll(session.frames);
|
|
2785
|
+
spinner.succeed("Effects applied");
|
|
2786
|
+
} else {
|
|
2787
|
+
composedFrames = session.frames.map((f) => ({
|
|
2788
|
+
index: f.index,
|
|
2789
|
+
buffer: f.screenshot,
|
|
2790
|
+
timestamp: f.timestamp
|
|
2791
|
+
}));
|
|
2792
|
+
spinner.info("Effects disabled, using raw frames");
|
|
2793
|
+
}
|
|
2794
|
+
spinner.start("Saving PNG sequence...");
|
|
2795
|
+
const paths = await savePngSequence(composedFrames, scenario.output);
|
|
2796
|
+
spinner.succeed(`Saved ${paths.length} frames to ${chalk.bold(options.output)}`);
|
|
2797
|
+
} else if (scenario.output.format === "mp4") {
|
|
2798
|
+
const outputPath = join2(options.output, `${scenario.output.filename}.mp4`);
|
|
2799
|
+
let mp4Buffer;
|
|
2800
|
+
if (options.effects === false) {
|
|
2801
|
+
spinner.start(`Encoding ${session.frames.length} raw frames...`);
|
|
2802
|
+
const rawStream = (async function* () {
|
|
2803
|
+
for (const f of session.frames) {
|
|
2804
|
+
yield { index: f.index, buffer: f.screenshot, timestamp: f.timestamp };
|
|
2805
|
+
}
|
|
2806
|
+
})();
|
|
2807
|
+
mp4Buffer = await encodeMp4Stream(rawStream, scenario.output);
|
|
2808
|
+
} else {
|
|
2809
|
+
const pipeline = new StreamingSession(session, renderer);
|
|
2810
|
+
pipeline.on("progress", ({ composed, total, pct }) => {
|
|
2811
|
+
spinner.text = `Composing & encoding... ${composed}/${total} (${pct}%)`;
|
|
2812
|
+
});
|
|
2813
|
+
spinner.start(`Composing & encoding ${session.frames.length} frames...`);
|
|
2814
|
+
mp4Buffer = await pipeline.run();
|
|
2815
|
+
}
|
|
2816
|
+
await writeFile2(outputPath, mp4Buffer);
|
|
2817
|
+
const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
|
|
2818
|
+
spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`);
|
|
2819
|
+
} else {
|
|
2820
|
+
let composedFrames;
|
|
2821
|
+
if (options.effects !== false) {
|
|
2822
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
2823
|
+
composedFrames = await renderer.composeAll(session.frames);
|
|
2824
|
+
spinner.succeed("Effects applied");
|
|
2825
|
+
} else {
|
|
2826
|
+
composedFrames = session.frames.map((f) => ({
|
|
2827
|
+
index: f.index,
|
|
2828
|
+
buffer: f.screenshot,
|
|
2829
|
+
timestamp: f.timestamp
|
|
2830
|
+
}));
|
|
2831
|
+
spinner.info("Effects disabled, using raw frames");
|
|
2832
|
+
}
|
|
2833
|
+
spinner.start("Encoding GIF...");
|
|
2834
|
+
const gifBuffer = await encodeGif(composedFrames, scenario.output);
|
|
2835
|
+
const outputPath = join2(options.output, `${scenario.output.filename}.gif`);
|
|
2836
|
+
await writeFile2(outputPath, gifBuffer);
|
|
2837
|
+
const sizeMB = (gifBuffer.length / (1024 * 1024)).toFixed(2);
|
|
2838
|
+
spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`);
|
|
2839
|
+
}
|
|
2132
2840
|
}
|
|
2133
2841
|
console.log(chalk.green("\nDone! \u{1F3AC}"));
|
|
2134
2842
|
} catch (error) {
|
|
@@ -2411,27 +3119,44 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
|
|
|
2411
3119
|
process.exit(1);
|
|
2412
3120
|
}
|
|
2413
3121
|
}
|
|
2414
|
-
spinner.start(`Recording ${scenario.steps.length} steps...`);
|
|
2415
|
-
const recorder = new ClipwiseRecorder();
|
|
2416
|
-
const session = await recorder.record(scenario);
|
|
2417
|
-
spinner.succeed(`Recorded ${session.frames.length} frames`);
|
|
2418
|
-
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
2419
|
-
const renderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
|
|
2420
|
-
const composedFrames = await renderer.composeAll(session.frames);
|
|
2421
|
-
spinner.succeed("Effects applied");
|
|
2422
3122
|
await mkdir2(options.output, { recursive: true });
|
|
3123
|
+
const demoRenderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
|
|
2423
3124
|
const ext = scenario.output.format === "gif" ? "gif" : "mp4";
|
|
2424
3125
|
const outputPath = join2(options.output, `clipwise-demo-${device}.${ext}`);
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
const
|
|
3126
|
+
const isConcurrentEligible = ext === "mp4" && demoRenderer.canStreamOnline();
|
|
3127
|
+
if (isConcurrentEligible) {
|
|
3128
|
+
const recorder = new ClipwiseRecorder();
|
|
3129
|
+
const concPipeline = new ConcurrentSession(recorder, scenario, demoRenderer);
|
|
3130
|
+
concPipeline.on("progress", ({ composed, total, pct }) => {
|
|
3131
|
+
spinner.text = total > 0 ? `Recording & composing... ${composed}/${total} (${pct}%)` : `Recording & composing... ${composed} frames`;
|
|
3132
|
+
});
|
|
3133
|
+
spinner.start(`Recording & composing ${scenario.steps.length} steps concurrently...`);
|
|
3134
|
+
const { buffer: buf, session } = await concPipeline.run();
|
|
2428
3135
|
await writeFile2(outputPath, buf);
|
|
2429
|
-
spinner.succeed(`
|
|
3136
|
+
spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB, ${session.frames.length} frames)`);
|
|
2430
3137
|
} else {
|
|
2431
|
-
spinner.start(
|
|
2432
|
-
const
|
|
2433
|
-
await
|
|
2434
|
-
spinner.succeed(`
|
|
3138
|
+
spinner.start(`Recording ${scenario.steps.length} steps...`);
|
|
3139
|
+
const recorder = new ClipwiseRecorder();
|
|
3140
|
+
const session = await recorder.record(scenario);
|
|
3141
|
+
spinner.succeed(`Recorded ${session.frames.length} frames`);
|
|
3142
|
+
if (ext === "gif") {
|
|
3143
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
3144
|
+
const composedFrames = await demoRenderer.composeAll(session.frames);
|
|
3145
|
+
spinner.succeed("Effects applied");
|
|
3146
|
+
spinner.start("Encoding GIF...");
|
|
3147
|
+
const buf = await encodeGif(composedFrames, scenario.output);
|
|
3148
|
+
await writeFile2(outputPath, buf);
|
|
3149
|
+
spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
3150
|
+
} else {
|
|
3151
|
+
const pipeline = new StreamingSession(session, demoRenderer);
|
|
3152
|
+
pipeline.on("progress", ({ composed, total, pct }) => {
|
|
3153
|
+
spinner.text = `Composing & encoding... ${composed}/${total} (${pct}%)`;
|
|
3154
|
+
});
|
|
3155
|
+
spinner.start(`Composing & encoding ${session.frames.length} frames...`);
|
|
3156
|
+
const buf = await pipeline.run();
|
|
3157
|
+
await writeFile2(outputPath, buf);
|
|
3158
|
+
spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
3159
|
+
}
|
|
2435
3160
|
}
|
|
2436
3161
|
console.log(chalk.green("\nDemo complete! \u{1F3AC}"));
|
|
2437
3162
|
} catch (error) {
|