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