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