framewebworker 0.1.1 → 0.1.2

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,87 +469,48 @@ 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
- }
522
472
  var WorkerPool = class {
523
473
  constructor(maxConcurrency) {
524
474
  this.workers = [];
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);
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);
531
485
  }
532
486
  }
533
- acquire() {
534
- if (this.available.length > 0) return Promise.resolve(this.available.pop());
535
- return new Promise((resolve) => this.waiters.push(resolve));
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
+ });
536
492
  }
537
- release(worker) {
538
- if (this.waiters.length > 0) {
539
- this.waiters.shift()(worker);
540
- } else {
541
- this.available.push(worker);
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);
542
498
  }
543
499
  }
544
- async dispatch(clip, width, height, fps, signal, onProgress) {
545
- const worker = await this.acquire();
500
+ async runJob(worker, job) {
546
501
  try {
547
- return await this.processClip(worker, clip, width, height, fps, signal, onProgress);
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)));
548
506
  } finally {
549
- this.release(worker);
507
+ this.idleWorkers.push(worker);
508
+ this.processQueue();
550
509
  }
551
510
  }
552
- async processClip(worker, clip, width, height, fps, signal, onProgress) {
511
+ async executeJob(worker, job) {
512
+ const { jobId, clip, options, onProgress } = job;
513
+ const { fps, width: outW, height: outH, signal } = options;
553
514
  let srcUrl;
554
515
  let needsRevoke = false;
555
516
  if (typeof clip.source === "string") {
@@ -564,115 +525,245 @@ var WorkerPool = class {
564
525
  video.muted = true;
565
526
  video.crossOrigin = "anonymous";
566
527
  video.preload = "auto";
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
- });
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
+ }
572
538
  const duration = video.duration;
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);
539
+ const startSec = clip.startTime ?? 0;
540
+ const endSec = clip.endTime ?? duration;
541
+ const clipDuration = endSec - startSec;
577
542
  const totalFrames = Math.ceil(clipDuration * fps);
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") {
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") {
586
563
  worker.removeEventListener("message", onMessage);
587
- resolve(msg.frames);
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();
588
573
  } else if (msg.type === "error") {
589
574
  worker.removeEventListener("message", onMessage);
590
- reject(new Error(msg.message));
591
- } else if (msg.type === "progress") {
592
- onProgress?.(msg.value);
575
+ rej(new Error(msg.message));
593
576
  }
594
577
  };
595
578
  worker.addEventListener("message", onMessage);
596
- });
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]);
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 });
615
588
  }
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
- }
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
+ });
636
+ if (needsRevoke) URL.revokeObjectURL(srcUrl);
637
+ return resultFrames;
627
638
  }
628
639
  terminate() {
629
- for (const w of this.workers) w.terminate();
630
- this.workers.length = 0;
631
- this.available.length = 0;
640
+ for (const worker of this.workers) {
641
+ worker.terminate();
642
+ }
643
+ this.workers = [];
644
+ this.idleWorkers = [];
632
645
  }
633
646
  };
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
+ }
634
661
 
635
662
  // src/stitch.ts
636
- function supportsOffscreenWorkers() {
637
- return typeof Worker !== "undefined" && typeof OffscreenCanvas !== "undefined" && typeof createImageBitmap !== "undefined";
638
- }
639
663
  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) {
646
664
  const fps = options.fps ?? 30;
647
665
  const width = options.width ?? 1280;
648
666
  const height = options.height ?? 720;
649
- const { onProgress, signal } = options;
650
- const clipStatuses = clips.map((_, i) => ({
651
- index: i,
652
- status: "pending",
653
- progress: 0
654
- }));
655
- const emit = (overall) => {
656
- onProgress?.({ overall, clips: clipStatuses.slice() });
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 });
657
684
  };
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;
658
745
  const blobs = [];
659
746
  for (let ci = 0; ci < clips.length; ci++) {
660
- clipStatuses[ci].status = "rendering";
661
- emit(ci / clips.length);
662
- const frames = await extractFrames(clips[ci], {
663
- fps,
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,
664
762
  width,
665
763
  height,
666
- mimeType: options.mimeType,
667
- quality: options.quality,
668
- encoderOptions: options.encoderOptions,
669
- signal,
670
- onProgress: (p) => {
671
- clipStatuses[ci].progress = p * 0.9;
672
- emit((ci + p * 0.9) / clips.length);
673
- }
764
+ fps,
765
+ onProgress: (p) => emitClipProgress(p * 0.9)
674
766
  });
675
- clipStatuses[ci].status = "encoding";
676
767
  const blob = await backend.encode(frames, {
677
768
  width,
678
769
  height,
@@ -680,18 +771,16 @@ async function stitchSequential(clips, backend, options) {
680
771
  mimeType: options.mimeType ?? "video/mp4",
681
772
  quality: options.quality ?? 0.92,
682
773
  encoderOptions: options.encoderOptions,
683
- signal,
684
- onProgress: (p) => {
685
- clipStatuses[ci].progress = 0.9 + p * 0.1;
686
- emit((ci + 0.9 + p * 0.1) / clips.length);
687
- }
774
+ onProgress: (p) => emitClipProgress(0.9 + p * 0.1),
775
+ signal: options.signal
688
776
  });
689
- clipStatuses[ci].status = "done";
690
- clipStatuses[ci].progress = 1;
691
777
  blobs.push(blob);
692
778
  }
693
779
  if (blobs.length === 1) {
694
- emit(1);
780
+ onProgress?.({
781
+ overall: 1,
782
+ clips: clips.map((_, i) => ({ index: i, status: "done", progress: 1 }))
783
+ });
695
784
  return blobs[0];
696
785
  }
697
786
  return backend.concat(blobs, {
@@ -700,96 +789,9 @@ async function stitchSequential(clips, backend, options) {
700
789
  fps,
701
790
  mimeType: options.mimeType ?? "video/mp4",
702
791
  quality: options.quality ?? 0.92,
703
- signal,
704
- onProgress: (p) => emit((clips.length - 1 + p) / clips.length)
792
+ signal: options.signal
705
793
  });
706
794
  }
707
- async function stitchParallel(clips, backend, options) {
708
- const fps = options.fps ?? 30;
709
- const width = options.width ?? 1280;
710
- const height = options.height ?? 720;
711
- const { onProgress, signal } = options;
712
- const concurrency = Math.min(
713
- clips.length,
714
- typeof navigator !== "undefined" ? navigator.hardwareConcurrency || 2 : 2,
715
- 4
716
- );
717
- const clipStatuses = clips.map((_, i) => ({
718
- index: i,
719
- status: "pending",
720
- progress: 0
721
- }));
722
- const emit = () => {
723
- const overall = clipStatuses.reduce((sum, c) => sum + c.progress, 0) / clips.length;
724
- onProgress?.({ overall, clips: clipStatuses.slice() });
725
- };
726
- const pool = new WorkerPool(concurrency);
727
- const blobs = new Array(clips.length);
728
- let encodeChain = Promise.resolve();
729
- try {
730
- await Promise.all(
731
- clips.map(async (clip, ci) => {
732
- clipStatuses[ci].status = "rendering";
733
- emit();
734
- const frames = await pool.dispatch(
735
- clip,
736
- width,
737
- height,
738
- fps,
739
- signal,
740
- (p) => {
741
- clipStatuses[ci].progress = p * 0.85;
742
- emit();
743
- }
744
- );
745
- clipStatuses[ci].status = "encoding";
746
- clipStatuses[ci].progress = 0.85;
747
- emit();
748
- await new Promise((resolve, reject) => {
749
- encodeChain = encodeChain.then(async () => {
750
- try {
751
- blobs[ci] = await backend.encode(frames, {
752
- width,
753
- height,
754
- fps,
755
- mimeType: options.mimeType ?? "video/mp4",
756
- quality: options.quality ?? 0.92,
757
- encoderOptions: options.encoderOptions,
758
- signal,
759
- onProgress: (p) => {
760
- clipStatuses[ci].progress = 0.85 + p * 0.15;
761
- emit();
762
- }
763
- });
764
- clipStatuses[ci].status = "done";
765
- clipStatuses[ci].progress = 1;
766
- emit();
767
- resolve();
768
- } catch (err) {
769
- clipStatuses[ci].status = "error";
770
- reject(err);
771
- throw err;
772
- }
773
- });
774
- });
775
- })
776
- );
777
- if (blobs.length === 1) {
778
- onProgress?.({ overall: 1, clips: clipStatuses.slice() });
779
- return blobs[0];
780
- }
781
- return backend.concat(blobs, {
782
- width,
783
- height,
784
- fps,
785
- mimeType: options.mimeType ?? "video/mp4",
786
- quality: options.quality ?? 0.92,
787
- signal
788
- });
789
- } finally {
790
- pool.terminate();
791
- }
792
- }
793
795
 
794
796
  // src/index.ts
795
797
  function createFrameWorker(config = {}) {