framewebworker 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +318 -260
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -4
- package/dist/index.d.ts +27 -4
- package/dist/index.js +318 -260
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +16 -7
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +29 -5
- package/dist/react/index.d.ts +29 -5
- package/dist/react/index.js +16 -7
- package/dist/react/index.js.map +1 -1
- package/dist/worker/render-worker.js +256 -0
- package/package.json +1 -1
- package/dist/render-worker.js +0 -177
- package/dist/render-worker.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -469,48 +469,87 @@ function seekVideo(video, time) {
|
|
|
469
469
|
}
|
|
470
470
|
|
|
471
471
|
// src/worker/pool.ts
|
|
472
|
+
var ASPECT_RATIO_MAP2 = {
|
|
473
|
+
"16:9": [16, 9],
|
|
474
|
+
"9:16": [9, 16],
|
|
475
|
+
"1:1": [1, 1],
|
|
476
|
+
"4:3": [4, 3],
|
|
477
|
+
"3:4": [3, 4],
|
|
478
|
+
original: [0, 0]
|
|
479
|
+
};
|
|
480
|
+
function resolveOutputDimensions2(clip, videoWidth, videoHeight, width, height) {
|
|
481
|
+
const ar = clip.aspectRatio ?? "original";
|
|
482
|
+
const ratio = ASPECT_RATIO_MAP2[ar] ?? [0, 0];
|
|
483
|
+
if (ratio[0] === 0) return [width, height];
|
|
484
|
+
const w = width;
|
|
485
|
+
const h = Math.round(w * (ratio[1] / ratio[0]));
|
|
486
|
+
return [w, h];
|
|
487
|
+
}
|
|
488
|
+
function seekVideo2(video, time) {
|
|
489
|
+
return new Promise((resolve) => {
|
|
490
|
+
if (Math.abs(video.currentTime - time) < 1e-3) {
|
|
491
|
+
resolve();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const onSeeked = () => {
|
|
495
|
+
video.removeEventListener("seeked", onSeeked);
|
|
496
|
+
resolve();
|
|
497
|
+
};
|
|
498
|
+
video.addEventListener("seeked", onSeeked);
|
|
499
|
+
video.currentTime = time;
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
function drawVideoFrame2(ctx, video, clip, outW, outH) {
|
|
503
|
+
const vw = video.videoWidth;
|
|
504
|
+
const vh = video.videoHeight;
|
|
505
|
+
if (clip.crop) {
|
|
506
|
+
const { x, y, width, height } = clip.crop;
|
|
507
|
+
ctx.drawImage(video, x * vw, y * vh, width * vw, height * vh, 0, 0, outW, outH);
|
|
508
|
+
} else {
|
|
509
|
+
const videoAR = vw / vh;
|
|
510
|
+
const outAR = outW / outH;
|
|
511
|
+
let sx = 0, sy = 0, sw = vw, sh = vh;
|
|
512
|
+
if (videoAR > outAR) {
|
|
513
|
+
sw = vh * outAR;
|
|
514
|
+
sx = (vw - sw) / 2;
|
|
515
|
+
} else if (videoAR < outAR) {
|
|
516
|
+
sh = vw / outAR;
|
|
517
|
+
sy = (vh - sh) / 2;
|
|
518
|
+
}
|
|
519
|
+
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, outW, outH);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
472
522
|
var WorkerPool = class {
|
|
473
523
|
constructor(maxConcurrency) {
|
|
474
524
|
this.workers = [];
|
|
475
|
-
this.
|
|
476
|
-
this.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
{ type: "module" }
|
|
482
|
-
);
|
|
483
|
-
this.workers.push(worker);
|
|
484
|
-
this.idleWorkers.push(worker);
|
|
525
|
+
this.available = [];
|
|
526
|
+
this.waiters = [];
|
|
527
|
+
for (let i = 0; i < maxConcurrency; i++) {
|
|
528
|
+
const w = new Worker(new URL("./render-worker.js", import.meta.url), { type: "module" });
|
|
529
|
+
this.workers.push(w);
|
|
530
|
+
this.available.push(w);
|
|
485
531
|
}
|
|
486
532
|
}
|
|
487
|
-
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
this.processQueue();
|
|
491
|
-
});
|
|
533
|
+
acquire() {
|
|
534
|
+
if (this.available.length > 0) return Promise.resolve(this.available.pop());
|
|
535
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
492
536
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
this.
|
|
537
|
+
release(worker) {
|
|
538
|
+
if (this.waiters.length > 0) {
|
|
539
|
+
this.waiters.shift()(worker);
|
|
540
|
+
} else {
|
|
541
|
+
this.available.push(worker);
|
|
498
542
|
}
|
|
499
543
|
}
|
|
500
|
-
async
|
|
544
|
+
async dispatch(clip, width, height, fps, signal, onProgress) {
|
|
545
|
+
const worker = await this.acquire();
|
|
501
546
|
try {
|
|
502
|
-
|
|
503
|
-
job.resolve(frames);
|
|
504
|
-
} catch (err) {
|
|
505
|
-
job.reject(err instanceof Error ? err : new Error(String(err)));
|
|
547
|
+
return await this.processClip(worker, clip, width, height, fps, signal, onProgress);
|
|
506
548
|
} finally {
|
|
507
|
-
this.
|
|
508
|
-
this.processQueue();
|
|
549
|
+
this.release(worker);
|
|
509
550
|
}
|
|
510
551
|
}
|
|
511
|
-
async
|
|
512
|
-
const { jobId, clip, options, onProgress } = job;
|
|
513
|
-
const { fps, width: outW, height: outH, signal } = options;
|
|
552
|
+
async processClip(worker, clip, width, height, fps, signal, onProgress) {
|
|
514
553
|
let srcUrl;
|
|
515
554
|
let needsRevoke = false;
|
|
516
555
|
if (typeof clip.source === "string") {
|
|
@@ -525,245 +564,120 @@ var WorkerPool = class {
|
|
|
525
564
|
video.muted = true;
|
|
526
565
|
video.crossOrigin = "anonymous";
|
|
527
566
|
video.preload = "auto";
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
});
|
|
534
|
-
} catch (err) {
|
|
535
|
-
if (needsRevoke) URL.revokeObjectURL(srcUrl);
|
|
536
|
-
throw err;
|
|
537
|
-
}
|
|
567
|
+
await new Promise((resolve, reject) => {
|
|
568
|
+
video.onloadedmetadata = () => resolve();
|
|
569
|
+
video.onerror = () => reject(new Error(`Failed to load video: ${srcUrl}`));
|
|
570
|
+
video.src = srcUrl;
|
|
571
|
+
});
|
|
538
572
|
const duration = video.duration;
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
const clipDuration =
|
|
573
|
+
const startTime = clip.startTime ?? 0;
|
|
574
|
+
const endTime = clip.endTime ?? duration;
|
|
575
|
+
const clipDuration = endTime - startTime;
|
|
576
|
+
const [outW, outH] = resolveOutputDimensions2(clip, video.videoWidth, video.videoHeight, width, height);
|
|
542
577
|
const totalFrames = Math.ceil(clipDuration * fps);
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
meta: { captions: captionSegments, captionStyle, width: outW, height: outH, totalFrames }
|
|
552
|
-
};
|
|
553
|
-
worker.postMessage(initMsg, [offscreen]);
|
|
554
|
-
const resultFrames = [];
|
|
555
|
-
await new Promise((res, rej) => {
|
|
556
|
-
let aborted = false;
|
|
557
|
-
const onMessage = (event) => {
|
|
558
|
-
const msg = event.data;
|
|
559
|
-
if (msg.jobId !== jobId) return;
|
|
560
|
-
if (msg.type === "progress") {
|
|
561
|
-
onProgress(msg.currentFrame, msg.totalFrames);
|
|
562
|
-
} else if (msg.type === "done") {
|
|
578
|
+
const canvas = document.createElement("canvas");
|
|
579
|
+
canvas.width = outW;
|
|
580
|
+
canvas.height = outH;
|
|
581
|
+
const ctx = canvas.getContext("2d");
|
|
582
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
583
|
+
const onMessage = (e) => {
|
|
584
|
+
const msg = e.data;
|
|
585
|
+
if (msg.type === "done") {
|
|
563
586
|
worker.removeEventListener("message", onMessage);
|
|
564
|
-
|
|
565
|
-
resultFrames.push({
|
|
566
|
-
imageData: new ImageData(new Uint8ClampedArray(tf.buffer), tf.width, tf.height),
|
|
567
|
-
timestamp: tf.timestamp,
|
|
568
|
-
width: tf.width,
|
|
569
|
-
height: tf.height
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
res();
|
|
587
|
+
resolve(msg.frames);
|
|
573
588
|
} else if (msg.type === "error") {
|
|
574
589
|
worker.removeEventListener("message", onMessage);
|
|
575
|
-
|
|
590
|
+
reject(new Error(msg.message));
|
|
591
|
+
} else if (msg.type === "progress") {
|
|
592
|
+
onProgress?.(msg.value);
|
|
576
593
|
}
|
|
577
594
|
};
|
|
578
595
|
worker.addEventListener("message", onMessage);
|
|
579
|
-
if (signal) {
|
|
580
|
-
signal.addEventListener("abort", () => {
|
|
581
|
-
if (aborted) return;
|
|
582
|
-
aborted = true;
|
|
583
|
-
const abortMsg = { type: "abort", jobId };
|
|
584
|
-
worker.postMessage(abortMsg);
|
|
585
|
-
worker.removeEventListener("message", onMessage);
|
|
586
|
-
rej(new DOMException("Render cancelled", "AbortError"));
|
|
587
|
-
}, { once: true });
|
|
588
|
-
}
|
|
589
|
-
(async () => {
|
|
590
|
-
try {
|
|
591
|
-
for (let i = 0; i < totalFrames; i++) {
|
|
592
|
-
if (signal?.aborted || aborted) break;
|
|
593
|
-
const t = startSec + i / fps;
|
|
594
|
-
await seekVideo2(video, t);
|
|
595
|
-
const vw = video.videoWidth;
|
|
596
|
-
const vh = video.videoHeight;
|
|
597
|
-
let sx = 0, sy = 0, sw = vw, sh = vh;
|
|
598
|
-
if (clip.crop) {
|
|
599
|
-
sx = clip.crop.x * vw;
|
|
600
|
-
sy = clip.crop.y * vw;
|
|
601
|
-
sw = clip.crop.width * vw;
|
|
602
|
-
sh = clip.crop.height * vh;
|
|
603
|
-
} else {
|
|
604
|
-
const videoAR = vw / vh;
|
|
605
|
-
const outAR = outW / outH;
|
|
606
|
-
if (videoAR > outAR) {
|
|
607
|
-
sw = vh * outAR;
|
|
608
|
-
sx = (vw - sw) / 2;
|
|
609
|
-
} else if (videoAR < outAR) {
|
|
610
|
-
sh = vw / outAR;
|
|
611
|
-
sy = (vh - sh) / 2;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
const bitmap = await createImageBitmap(video, sx, sy, sw, sh, {
|
|
615
|
-
resizeWidth: outW,
|
|
616
|
-
resizeHeight: outH
|
|
617
|
-
});
|
|
618
|
-
const frameMsg = {
|
|
619
|
-
type: "frame",
|
|
620
|
-
jobId,
|
|
621
|
-
frameIndex: i,
|
|
622
|
-
bitmap,
|
|
623
|
-
timestamp: t - startSec
|
|
624
|
-
};
|
|
625
|
-
worker.postMessage(frameMsg, [bitmap]);
|
|
626
|
-
}
|
|
627
|
-
if (!signal?.aborted && !aborted) {
|
|
628
|
-
const endMsg = { type: "end", jobId };
|
|
629
|
-
worker.postMessage(endMsg);
|
|
630
|
-
}
|
|
631
|
-
} catch (err) {
|
|
632
|
-
if (!aborted) rej(err instanceof Error ? err : new Error(String(err)));
|
|
633
|
-
}
|
|
634
|
-
})();
|
|
635
596
|
});
|
|
636
|
-
|
|
637
|
-
|
|
597
|
+
const initMsg = {
|
|
598
|
+
type: "init",
|
|
599
|
+
meta: { width: outW, height: outH, fps, captions: clip.captions, totalFrames }
|
|
600
|
+
};
|
|
601
|
+
worker.postMessage(initMsg);
|
|
602
|
+
try {
|
|
603
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
604
|
+
if (signal?.aborted) {
|
|
605
|
+
worker.postMessage({ type: "abort" });
|
|
606
|
+
throw new DOMException("Render cancelled", "AbortError");
|
|
607
|
+
}
|
|
608
|
+
const t = startTime + i / fps;
|
|
609
|
+
await seekVideo2(video, t);
|
|
610
|
+
ctx.clearRect(0, 0, outW, outH);
|
|
611
|
+
drawVideoFrame2(ctx, video, clip, outW, outH);
|
|
612
|
+
const bitmap = await createImageBitmap(canvas);
|
|
613
|
+
const frameMsg = { type: "frame", bitmap, timestamp: t - startTime, index: i };
|
|
614
|
+
worker.postMessage(frameMsg, [bitmap]);
|
|
615
|
+
}
|
|
616
|
+
worker.postMessage({ type: "end" });
|
|
617
|
+
const transferableFrames = await resultPromise;
|
|
618
|
+
return transferableFrames.map((f) => ({
|
|
619
|
+
imageData: new ImageData(new Uint8ClampedArray(f.buffer), f.width, f.height),
|
|
620
|
+
timestamp: f.timestamp,
|
|
621
|
+
width: f.width,
|
|
622
|
+
height: f.height
|
|
623
|
+
}));
|
|
624
|
+
} finally {
|
|
625
|
+
if (needsRevoke) URL.revokeObjectURL(srcUrl);
|
|
626
|
+
}
|
|
638
627
|
}
|
|
639
628
|
terminate() {
|
|
640
|
-
for (const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
this.workers = [];
|
|
644
|
-
this.idleWorkers = [];
|
|
629
|
+
for (const w of this.workers) w.terminate();
|
|
630
|
+
this.workers.length = 0;
|
|
631
|
+
this.available.length = 0;
|
|
645
632
|
}
|
|
646
633
|
};
|
|
647
|
-
function seekVideo2(video, time) {
|
|
648
|
-
return new Promise((resolve) => {
|
|
649
|
-
if (Math.abs(video.currentTime - time) < 1e-3) {
|
|
650
|
-
resolve();
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
const onSeeked = () => {
|
|
654
|
-
video.removeEventListener("seeked", onSeeked);
|
|
655
|
-
resolve();
|
|
656
|
-
};
|
|
657
|
-
video.addEventListener("seeked", onSeeked);
|
|
658
|
-
video.currentTime = time;
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
634
|
|
|
662
635
|
// src/stitch.ts
|
|
636
|
+
function supportsOffscreenWorkers() {
|
|
637
|
+
return typeof Worker !== "undefined" && typeof OffscreenCanvas !== "undefined" && typeof createImageBitmap !== "undefined";
|
|
638
|
+
}
|
|
663
639
|
async function stitchClips(clips, backend, options) {
|
|
640
|
+
if (supportsOffscreenWorkers() && clips.length > 1) {
|
|
641
|
+
return stitchParallel(clips, backend, options);
|
|
642
|
+
}
|
|
643
|
+
return stitchSequential(clips, backend, options);
|
|
644
|
+
}
|
|
645
|
+
async function stitchSequential(clips, backend, options) {
|
|
664
646
|
const fps = options.fps ?? 30;
|
|
665
647
|
const width = options.width ?? 1280;
|
|
666
648
|
const height = options.height ?? 720;
|
|
667
|
-
const onProgress = options
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
const overall = clipProgresses.reduce((a, b) => a + b, 0) / clips.length;
|
|
678
|
-
const clipData = clips.map((_, i) => ({
|
|
679
|
-
index: i,
|
|
680
|
-
status: clipStatuses[i],
|
|
681
|
-
progress: clipProgresses[i]
|
|
682
|
-
}));
|
|
683
|
-
onProgress({ overall, clips: clipData });
|
|
649
|
+
const { onProgress, onComplete, signal } = options;
|
|
650
|
+
const stitchStart = performance.now();
|
|
651
|
+
const clipStatuses = clips.map((_, i) => ({
|
|
652
|
+
index: i,
|
|
653
|
+
status: "pending",
|
|
654
|
+
progress: 0
|
|
655
|
+
}));
|
|
656
|
+
const clipMetrics = [];
|
|
657
|
+
const emit = (overall) => {
|
|
658
|
+
onProgress?.({ overall, clips: clipStatuses.slice() });
|
|
684
659
|
};
|
|
685
|
-
try {
|
|
686
|
-
const frameArrays = await Promise.all(
|
|
687
|
-
clips.map(async (clip, i) => {
|
|
688
|
-
clipStatuses[i] = "rendering";
|
|
689
|
-
emitProgress();
|
|
690
|
-
try {
|
|
691
|
-
const frames = await pool.dispatch(
|
|
692
|
-
`clip-${i}`,
|
|
693
|
-
clip,
|
|
694
|
-
{ fps, width, height, signal: options.signal },
|
|
695
|
-
(current, total) => {
|
|
696
|
-
clipProgresses[i] = current / total;
|
|
697
|
-
emitProgress();
|
|
698
|
-
}
|
|
699
|
-
);
|
|
700
|
-
return frames;
|
|
701
|
-
} catch (err) {
|
|
702
|
-
clipStatuses[i] = "failed";
|
|
703
|
-
emitProgress();
|
|
704
|
-
throw err;
|
|
705
|
-
}
|
|
706
|
-
})
|
|
707
|
-
);
|
|
708
|
-
const blobs = [];
|
|
709
|
-
for (let i = 0; i < frameArrays.length; i++) {
|
|
710
|
-
const blob = await backend.encode(frameArrays[i], {
|
|
711
|
-
width,
|
|
712
|
-
height,
|
|
713
|
-
fps,
|
|
714
|
-
mimeType: options.mimeType ?? "video/mp4",
|
|
715
|
-
quality: options.quality ?? 0.92,
|
|
716
|
-
encoderOptions: options.encoderOptions,
|
|
717
|
-
signal: options.signal
|
|
718
|
-
});
|
|
719
|
-
clipStatuses[i] = "done";
|
|
720
|
-
clipProgresses[i] = 1;
|
|
721
|
-
emitProgress();
|
|
722
|
-
blobs.push(blob);
|
|
723
|
-
}
|
|
724
|
-
if (blobs.length === 1) {
|
|
725
|
-
onProgress?.({
|
|
726
|
-
overall: 1,
|
|
727
|
-
clips: clips.map((_, i) => ({ index: i, status: "done", progress: 1 }))
|
|
728
|
-
});
|
|
729
|
-
return blobs[0];
|
|
730
|
-
}
|
|
731
|
-
return backend.concat(blobs, {
|
|
732
|
-
width,
|
|
733
|
-
height,
|
|
734
|
-
fps,
|
|
735
|
-
mimeType: options.mimeType ?? "video/mp4",
|
|
736
|
-
quality: options.quality ?? 0.92,
|
|
737
|
-
signal: options.signal
|
|
738
|
-
});
|
|
739
|
-
} finally {
|
|
740
|
-
pool.terminate();
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
async function sequentialStitch(clips, backend, options, fps, width, height) {
|
|
744
|
-
const onProgress = options.onProgress;
|
|
745
660
|
const blobs = [];
|
|
746
661
|
for (let ci = 0; ci < clips.length; ci++) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
overall,
|
|
753
|
-
clips: clips.map((_, i) => ({
|
|
754
|
-
index: i,
|
|
755
|
-
status: i < ci ? "done" : i === ci ? "rendering" : "queued",
|
|
756
|
-
progress: i < ci ? 1 : i === ci ? p : 0
|
|
757
|
-
}))
|
|
758
|
-
});
|
|
759
|
-
};
|
|
760
|
-
const frames = await extractFrames(clip, {
|
|
761
|
-
...options,
|
|
662
|
+
clipStatuses[ci].status = "rendering";
|
|
663
|
+
emit(ci / clips.length);
|
|
664
|
+
const extractStart = performance.now();
|
|
665
|
+
const frames = await extractFrames(clips[ci], {
|
|
666
|
+
fps,
|
|
762
667
|
width,
|
|
763
668
|
height,
|
|
764
|
-
|
|
765
|
-
|
|
669
|
+
mimeType: options.mimeType,
|
|
670
|
+
quality: options.quality,
|
|
671
|
+
encoderOptions: options.encoderOptions,
|
|
672
|
+
signal,
|
|
673
|
+
onProgress: (p) => {
|
|
674
|
+
clipStatuses[ci].progress = p * 0.9;
|
|
675
|
+
emit((ci + p * 0.9) / clips.length);
|
|
676
|
+
}
|
|
766
677
|
});
|
|
678
|
+
const extractionMs = performance.now() - extractStart;
|
|
679
|
+
clipStatuses[ci].status = "encoding";
|
|
680
|
+
const encodeStart = performance.now();
|
|
767
681
|
const blob = await backend.encode(frames, {
|
|
768
682
|
width,
|
|
769
683
|
height,
|
|
@@ -771,26 +685,170 @@ async function sequentialStitch(clips, backend, options, fps, width, height) {
|
|
|
771
685
|
mimeType: options.mimeType ?? "video/mp4",
|
|
772
686
|
quality: options.quality ?? 0.92,
|
|
773
687
|
encoderOptions: options.encoderOptions,
|
|
774
|
-
|
|
775
|
-
|
|
688
|
+
signal,
|
|
689
|
+
onProgress: (p) => {
|
|
690
|
+
clipStatuses[ci].progress = 0.9 + p * 0.1;
|
|
691
|
+
emit((ci + 0.9 + p * 0.1) / clips.length);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
const encodingMs = performance.now() - encodeStart;
|
|
695
|
+
clipStatuses[ci].status = "done";
|
|
696
|
+
clipStatuses[ci].progress = 1;
|
|
697
|
+
clipMetrics.push({
|
|
698
|
+
clipId: String(ci),
|
|
699
|
+
extractionMs,
|
|
700
|
+
encodingMs,
|
|
701
|
+
totalMs: extractionMs + encodingMs,
|
|
702
|
+
framesExtracted: frames.length
|
|
776
703
|
});
|
|
777
704
|
blobs.push(blob);
|
|
778
705
|
}
|
|
706
|
+
let finalBlob;
|
|
707
|
+
let stitchMs = 0;
|
|
779
708
|
if (blobs.length === 1) {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
709
|
+
emit(1);
|
|
710
|
+
finalBlob = blobs[0];
|
|
711
|
+
} else {
|
|
712
|
+
const stitchPhaseStart = performance.now();
|
|
713
|
+
finalBlob = await backend.concat(blobs, {
|
|
714
|
+
width,
|
|
715
|
+
height,
|
|
716
|
+
fps,
|
|
717
|
+
mimeType: options.mimeType ?? "video/mp4",
|
|
718
|
+
quality: options.quality ?? 0.92,
|
|
719
|
+
signal,
|
|
720
|
+
onProgress: (p) => emit((clips.length - 1 + p) / clips.length)
|
|
783
721
|
});
|
|
784
|
-
|
|
722
|
+
stitchMs = performance.now() - stitchPhaseStart;
|
|
723
|
+
}
|
|
724
|
+
const totalMs = performance.now() - stitchStart;
|
|
725
|
+
const totalFrames = clipMetrics.reduce((s, c) => s + c.framesExtracted, 0);
|
|
726
|
+
const metrics = {
|
|
727
|
+
totalMs,
|
|
728
|
+
extractionMs: clipMetrics.reduce((s, c) => s + c.extractionMs, 0),
|
|
729
|
+
encodingMs: clipMetrics.reduce((s, c) => s + c.encodingMs, 0),
|
|
730
|
+
stitchMs,
|
|
731
|
+
clips: clipMetrics,
|
|
732
|
+
framesPerSecond: totalFrames / (totalMs / 1e3)
|
|
733
|
+
};
|
|
734
|
+
onComplete?.(metrics);
|
|
735
|
+
return { blob: finalBlob, metrics };
|
|
736
|
+
}
|
|
737
|
+
async function stitchParallel(clips, backend, options) {
|
|
738
|
+
const fps = options.fps ?? 30;
|
|
739
|
+
const width = options.width ?? 1280;
|
|
740
|
+
const height = options.height ?? 720;
|
|
741
|
+
const { onProgress, onComplete, signal } = options;
|
|
742
|
+
const stitchStart = performance.now();
|
|
743
|
+
const concurrency = Math.min(
|
|
744
|
+
clips.length,
|
|
745
|
+
typeof navigator !== "undefined" ? navigator.hardwareConcurrency || 2 : 2,
|
|
746
|
+
4
|
|
747
|
+
);
|
|
748
|
+
const clipStatuses = clips.map((_, i) => ({
|
|
749
|
+
index: i,
|
|
750
|
+
status: "pending",
|
|
751
|
+
progress: 0
|
|
752
|
+
}));
|
|
753
|
+
const clipMetrics = new Array(clips.length);
|
|
754
|
+
const emit = () => {
|
|
755
|
+
const overall = clipStatuses.reduce((sum, c) => sum + c.progress, 0) / clips.length;
|
|
756
|
+
onProgress?.({ overall, clips: clipStatuses.slice() });
|
|
757
|
+
};
|
|
758
|
+
const pool = new WorkerPool(concurrency);
|
|
759
|
+
const blobs = new Array(clips.length);
|
|
760
|
+
let encodeChain = Promise.resolve();
|
|
761
|
+
try {
|
|
762
|
+
await Promise.all(
|
|
763
|
+
clips.map(async (clip, ci) => {
|
|
764
|
+
clipStatuses[ci].status = "rendering";
|
|
765
|
+
emit();
|
|
766
|
+
const extractStart = performance.now();
|
|
767
|
+
const frames = await pool.dispatch(
|
|
768
|
+
clip,
|
|
769
|
+
width,
|
|
770
|
+
height,
|
|
771
|
+
fps,
|
|
772
|
+
signal,
|
|
773
|
+
(p) => {
|
|
774
|
+
clipStatuses[ci].progress = p * 0.85;
|
|
775
|
+
emit();
|
|
776
|
+
}
|
|
777
|
+
);
|
|
778
|
+
const extractionMs = performance.now() - extractStart;
|
|
779
|
+
clipStatuses[ci].status = "encoding";
|
|
780
|
+
clipStatuses[ci].progress = 0.85;
|
|
781
|
+
emit();
|
|
782
|
+
await new Promise((resolve, reject) => {
|
|
783
|
+
encodeChain = encodeChain.then(async () => {
|
|
784
|
+
const encodeStart = performance.now();
|
|
785
|
+
try {
|
|
786
|
+
blobs[ci] = await backend.encode(frames, {
|
|
787
|
+
width,
|
|
788
|
+
height,
|
|
789
|
+
fps,
|
|
790
|
+
mimeType: options.mimeType ?? "video/mp4",
|
|
791
|
+
quality: options.quality ?? 0.92,
|
|
792
|
+
encoderOptions: options.encoderOptions,
|
|
793
|
+
signal,
|
|
794
|
+
onProgress: (p) => {
|
|
795
|
+
clipStatuses[ci].progress = 0.85 + p * 0.15;
|
|
796
|
+
emit();
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
const encodingMs = performance.now() - encodeStart;
|
|
800
|
+
clipMetrics[ci] = {
|
|
801
|
+
clipId: String(ci),
|
|
802
|
+
extractionMs,
|
|
803
|
+
encodingMs,
|
|
804
|
+
totalMs: extractionMs + encodingMs,
|
|
805
|
+
framesExtracted: frames.length
|
|
806
|
+
};
|
|
807
|
+
clipStatuses[ci].status = "done";
|
|
808
|
+
clipStatuses[ci].progress = 1;
|
|
809
|
+
emit();
|
|
810
|
+
resolve();
|
|
811
|
+
} catch (err) {
|
|
812
|
+
clipStatuses[ci].status = "error";
|
|
813
|
+
reject(err);
|
|
814
|
+
throw err;
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
})
|
|
819
|
+
);
|
|
820
|
+
let finalBlob;
|
|
821
|
+
let stitchMs = 0;
|
|
822
|
+
if (blobs.length === 1) {
|
|
823
|
+
onProgress?.({ overall: 1, clips: clipStatuses.slice() });
|
|
824
|
+
finalBlob = blobs[0];
|
|
825
|
+
} else {
|
|
826
|
+
const stitchPhaseStart = performance.now();
|
|
827
|
+
finalBlob = await backend.concat(blobs, {
|
|
828
|
+
width,
|
|
829
|
+
height,
|
|
830
|
+
fps,
|
|
831
|
+
mimeType: options.mimeType ?? "video/mp4",
|
|
832
|
+
quality: options.quality ?? 0.92,
|
|
833
|
+
signal
|
|
834
|
+
});
|
|
835
|
+
stitchMs = performance.now() - stitchPhaseStart;
|
|
836
|
+
}
|
|
837
|
+
const totalMs = performance.now() - stitchStart;
|
|
838
|
+
const totalFrames = clipMetrics.reduce((s, c) => s + c.framesExtracted, 0);
|
|
839
|
+
const metrics = {
|
|
840
|
+
totalMs,
|
|
841
|
+
extractionMs: clipMetrics.reduce((s, c) => s + c.extractionMs, 0),
|
|
842
|
+
encodingMs: clipMetrics.reduce((s, c) => s + c.encodingMs, 0),
|
|
843
|
+
stitchMs,
|
|
844
|
+
clips: clipMetrics,
|
|
845
|
+
framesPerSecond: totalFrames / (totalMs / 1e3)
|
|
846
|
+
};
|
|
847
|
+
onComplete?.(metrics);
|
|
848
|
+
return { blob: finalBlob, metrics };
|
|
849
|
+
} finally {
|
|
850
|
+
pool.terminate();
|
|
785
851
|
}
|
|
786
|
-
return backend.concat(blobs, {
|
|
787
|
-
width,
|
|
788
|
-
height,
|
|
789
|
-
fps,
|
|
790
|
-
mimeType: options.mimeType ?? "video/mp4",
|
|
791
|
-
quality: options.quality ?? 0.92,
|
|
792
|
-
signal: options.signal
|
|
793
|
-
});
|
|
794
852
|
}
|
|
795
853
|
|
|
796
854
|
// src/index.ts
|
|
@@ -836,8 +894,8 @@ function createFrameWorker(config = {}) {
|
|
|
836
894
|
return stitchClips(clips, backend, mergedOpts);
|
|
837
895
|
}
|
|
838
896
|
async function stitchToUrl(clips, options) {
|
|
839
|
-
const blob = await stitch(clips, options);
|
|
840
|
-
return URL.createObjectURL(blob);
|
|
897
|
+
const { blob, metrics } = await stitch(clips, options);
|
|
898
|
+
return { url: URL.createObjectURL(blob), metrics };
|
|
841
899
|
}
|
|
842
900
|
return { render, renderToUrl, stitch, stitchToUrl };
|
|
843
901
|
}
|