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.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.idleWorkers = [];
476
- this.queue = [];
477
- const count = Math.min(maxConcurrency, navigator.hardwareConcurrency || 2, 4);
478
- for (let i = 0; i < count; i++) {
479
- const worker = new Worker(
480
- new URL("./render-worker.js", import.meta.url),
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
- dispatch(jobId, clip, options, onProgress) {
488
- return new Promise((resolve, reject) => {
489
- this.queue.push({ jobId, clip, options, onProgress, resolve, reject });
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
- processQueue() {
494
- while (this.queue.length > 0 && this.idleWorkers.length > 0) {
495
- const worker = this.idleWorkers.pop();
496
- const job = this.queue.shift();
497
- this.runJob(worker, job);
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 runJob(worker, job) {
544
+ async dispatch(clip, width, height, fps, signal, onProgress) {
545
+ const worker = await this.acquire();
501
546
  try {
502
- const frames = await this.executeJob(worker, job);
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.idleWorkers.push(worker);
508
- this.processQueue();
549
+ this.release(worker);
509
550
  }
510
551
  }
511
- async executeJob(worker, job) {
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
- try {
529
- await new Promise((res, rej) => {
530
- video.onloadedmetadata = () => res();
531
- video.onerror = () => rej(new Error(`Failed to load video: ${srcUrl}`));
532
- video.src = srcUrl;
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 startSec = clip.startTime ?? 0;
540
- const endSec = clip.endTime ?? duration;
541
- const clipDuration = endSec - startSec;
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 captionSegments = clip.captions?.segments ?? [];
544
- const baseStylePreset = clip.captions?.style?.preset ?? "modern";
545
- const captionStyle = mergeStyle(STYLE_PRESETS[baseStylePreset], clip.captions?.style);
546
- const offscreen = new OffscreenCanvas(outW, outH);
547
- const initMsg = {
548
- type: "init",
549
- jobId,
550
- canvas: offscreen,
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
- for (const tf of msg.frames) {
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
- rej(new Error(msg.message));
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
- if (needsRevoke) URL.revokeObjectURL(srcUrl);
637
- return resultFrames;
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 worker of this.workers) {
641
- worker.terminate();
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.onProgress;
668
- if (!("OffscreenCanvas" in globalThis) || !("Worker" in globalThis)) {
669
- return sequentialStitch(clips, backend, options, fps, width, height);
670
- }
671
- const concurrency = Math.min(clips.length, navigator.hardwareConcurrency || 2, 4);
672
- const pool = new WorkerPool(concurrency);
673
- const clipStatuses = clips.map(() => "queued");
674
- const clipProgresses = clips.map(() => 0);
675
- const emitProgress = () => {
676
- if (!onProgress) return;
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
- const clip = clips[ci];
748
- const emitClipProgress = (p) => {
749
- if (!onProgress) return;
750
- const overall = (ci + p) / clips.length;
751
- onProgress({
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
- fps,
765
- onProgress: (p) => emitClipProgress(p * 0.9)
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
- onProgress: (p) => emitClipProgress(0.9 + p * 0.1),
775
- signal: options.signal
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
- onProgress?.({
781
- overall: 1,
782
- clips: clips.map((_, i) => ({ index: i, status: "done", progress: 1 }))
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
- return blobs[0];
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
  }