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 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.idleWorkers = [];
479
- this.queue = [];
480
- const count = Math.min(maxConcurrency, navigator.hardwareConcurrency || 2, 4);
481
- for (let i = 0; i < count; i++) {
482
- const worker = new Worker(
483
- 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))),
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
- dispatch(jobId, clip, options, onProgress) {
491
- return new Promise((resolve, reject) => {
492
- this.queue.push({ jobId, clip, options, onProgress, resolve, reject });
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
- processQueue() {
497
- while (this.queue.length > 0 && this.idleWorkers.length > 0) {
498
- const worker = this.idleWorkers.pop();
499
- const job = this.queue.shift();
500
- this.runJob(worker, job);
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 runJob(worker, job) {
547
+ async dispatch(clip, width, height, fps, signal, onProgress) {
548
+ const worker = await this.acquire();
504
549
  try {
505
- const frames = await this.executeJob(worker, job);
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.idleWorkers.push(worker);
511
- this.processQueue();
552
+ this.release(worker);
512
553
  }
513
554
  }
514
- async executeJob(worker, job) {
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
- try {
532
- await new Promise((res, rej) => {
533
- video.onloadedmetadata = () => res();
534
- video.onerror = () => rej(new Error(`Failed to load video: ${srcUrl}`));
535
- video.src = srcUrl;
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 startSec = clip.startTime ?? 0;
543
- const endSec = clip.endTime ?? duration;
544
- const clipDuration = endSec - startSec;
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 captionSegments = clip.captions?.segments ?? [];
547
- const baseStylePreset = clip.captions?.style?.preset ?? "modern";
548
- const captionStyle = mergeStyle(STYLE_PRESETS[baseStylePreset], clip.captions?.style);
549
- const offscreen = new OffscreenCanvas(outW, outH);
550
- const initMsg = {
551
- type: "init",
552
- jobId,
553
- canvas: offscreen,
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
- for (const tf of msg.frames) {
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
- rej(new Error(msg.message));
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
- if (needsRevoke) URL.revokeObjectURL(srcUrl);
640
- return resultFrames;
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 worker of this.workers) {
644
- worker.terminate();
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.onProgress;
671
- if (!("OffscreenCanvas" in globalThis) || !("Worker" in globalThis)) {
672
- return sequentialStitch(clips, backend, options, fps, width, height);
673
- }
674
- const concurrency = Math.min(clips.length, navigator.hardwareConcurrency || 2, 4);
675
- const pool = new WorkerPool(concurrency);
676
- const clipStatuses = clips.map(() => "queued");
677
- const clipProgresses = clips.map(() => 0);
678
- const emitProgress = () => {
679
- if (!onProgress) return;
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
- const clip = clips[ci];
751
- const emitClipProgress = (p) => {
752
- if (!onProgress) return;
753
- const overall = (ci + p) / clips.length;
754
- onProgress({
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
- fps,
768
- onProgress: (p) => emitClipProgress(p * 0.9)
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,170 @@ 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
- onProgress: (p) => emitClipProgress(0.9 + p * 0.1),
778
- signal: options.signal
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
- onProgress?.({
784
- overall: 1,
785
- clips: clips.map((_, i) => ({ index: i, status: "done", progress: 1 }))
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
- return blobs[0];
725
+ stitchMs = performance.now() - stitchPhaseStart;
726
+ }
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();
788
854
  }
789
- return backend.concat(blobs, {
790
- width,
791
- height,
792
- fps,
793
- mimeType: options.mimeType ?? "video/mp4",
794
- quality: options.quality ?? 0.92,
795
- signal: options.signal
796
- });
797
855
  }
798
856
 
799
857
  // src/index.ts
@@ -839,8 +897,8 @@ function createFrameWorker(config = {}) {
839
897
  return stitchClips(clips, backend, mergedOpts);
840
898
  }
841
899
  async function stitchToUrl(clips, options) {
842
- const blob = await stitch(clips, options);
843
- return URL.createObjectURL(blob);
900
+ const { blob, metrics } = await stitch(clips, options);
901
+ return { url: URL.createObjectURL(blob), metrics };
844
902
  }
845
903
  return { render, renderToUrl, stitch, stitchToUrl };
846
904
  }