framewebworker 0.1.2 → 0.1.4
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 +345 -264
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -4
- package/dist/index.d.ts +46 -4
- package/dist/index.js +344 -265
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +64 -8
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +54 -10
- package/dist/react/index.d.ts +54 -10
- package/dist/react/index.js +64 -9
- 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,191 @@ 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;
|
|
785
723
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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();
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/render.ts
|
|
855
|
+
init_ffmpeg();
|
|
856
|
+
function segmentsToClips(videoUrl, segments) {
|
|
857
|
+
return segments.map((seg) => ({
|
|
858
|
+
source: videoUrl,
|
|
859
|
+
startTime: seg.start,
|
|
860
|
+
endTime: seg.end,
|
|
861
|
+
captions: seg.captions?.length ? { segments: seg.captions } : void 0
|
|
862
|
+
}));
|
|
863
|
+
}
|
|
864
|
+
async function render(videoUrl, segments, options) {
|
|
865
|
+
const clips = segmentsToClips(videoUrl, segments);
|
|
866
|
+
const backend = createFFmpegBackend();
|
|
867
|
+
await backend.init();
|
|
868
|
+
return stitchClips(clips, backend, options ?? {});
|
|
869
|
+
}
|
|
870
|
+
async function renderToUrl(videoUrl, segments, options) {
|
|
871
|
+
const { blob, metrics } = await render(videoUrl, segments, options);
|
|
872
|
+
return { url: URL.createObjectURL(blob), metrics };
|
|
794
873
|
}
|
|
795
874
|
|
|
796
875
|
// src/index.ts
|
|
@@ -807,7 +886,7 @@ function createFrameWorker(config = {}) {
|
|
|
807
886
|
await _backend.init();
|
|
808
887
|
return _backend;
|
|
809
888
|
}
|
|
810
|
-
async function
|
|
889
|
+
async function render2(clip, options = {}) {
|
|
811
890
|
const mergedOpts = { fps, width, height, ...options };
|
|
812
891
|
const backend = await getBackend();
|
|
813
892
|
const onProgress = mergedOpts.onProgress;
|
|
@@ -826,8 +905,8 @@ function createFrameWorker(config = {}) {
|
|
|
826
905
|
signal: mergedOpts.signal
|
|
827
906
|
});
|
|
828
907
|
}
|
|
829
|
-
async function
|
|
830
|
-
const blob = await
|
|
908
|
+
async function renderToUrl2(clip, options) {
|
|
909
|
+
const blob = await render2(clip, options);
|
|
831
910
|
return URL.createObjectURL(blob);
|
|
832
911
|
}
|
|
833
912
|
async function stitch(clips, options = {}) {
|
|
@@ -836,12 +915,12 @@ function createFrameWorker(config = {}) {
|
|
|
836
915
|
return stitchClips(clips, backend, mergedOpts);
|
|
837
916
|
}
|
|
838
917
|
async function stitchToUrl(clips, options) {
|
|
839
|
-
const blob = await stitch(clips, options);
|
|
840
|
-
return URL.createObjectURL(blob);
|
|
918
|
+
const { blob, metrics } = await stitch(clips, options);
|
|
919
|
+
return { url: URL.createObjectURL(blob), metrics };
|
|
841
920
|
}
|
|
842
|
-
return { render, renderToUrl, stitch, stitchToUrl };
|
|
921
|
+
return { render: render2, renderToUrl: renderToUrl2, stitch, stitchToUrl };
|
|
843
922
|
}
|
|
844
923
|
|
|
845
|
-
export { FFmpegBackend, STYLE_PRESETS, createFFmpegBackend, createFrameWorker };
|
|
924
|
+
export { FFmpegBackend, STYLE_PRESETS, createFFmpegBackend, createFrameWorker, render, renderToUrl };
|
|
846
925
|
//# sourceMappingURL=index.js.map
|
|
847
926
|
//# sourceMappingURL=index.js.map
|