framewebworker 0.1.0 → 0.1.1

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.d.cts CHANGED
@@ -114,15 +114,29 @@ interface FrameWorkerConfig {
114
114
  /** Default output height */
115
115
  height?: number;
116
116
  }
117
+ type ClipStatus = 'pending' | 'rendering' | 'encoding' | 'done' | 'error';
118
+ interface ClipProgress {
119
+ index: number;
120
+ status: ClipStatus;
121
+ progress: number;
122
+ }
123
+ interface RichProgress {
124
+ overall: number;
125
+ clips: ClipProgress[];
126
+ }
127
+ /** Extends RenderOptions with rich per-clip progress reporting */
128
+ interface StitchOptions extends Omit<RenderOptions, 'onProgress'> {
129
+ onProgress?: (progress: RichProgress) => void;
130
+ }
117
131
  interface FrameWorker {
118
132
  /** Render a single clip to a Blob */
119
133
  render(clip: ClipInput, options?: RenderOptions): Promise<Blob>;
120
134
  /** Render a single clip and return an object URL */
121
135
  renderToUrl(clip: ClipInput, options?: RenderOptions): Promise<string>;
122
136
  /** Stitch multiple clips into one Blob */
123
- stitch(clips: ClipInput[], options?: RenderOptions): Promise<Blob>;
137
+ stitch(clips: ClipInput[], options?: StitchOptions): Promise<Blob>;
124
138
  /** Stitch multiple clips and return an object URL */
125
- stitchToUrl(clips: ClipInput[], options?: RenderOptions): Promise<string>;
139
+ stitchToUrl(clips: ClipInput[], options?: StitchOptions): Promise<string>;
126
140
  }
127
141
 
128
142
  declare const STYLE_PRESETS: Record<CaptionStylePreset, CaptionStyle>;
@@ -141,4 +155,4 @@ declare function createFFmpegBackend(): FFmpegBackend;
141
155
 
142
156
  declare function createFrameWorker(config?: FrameWorkerConfig): FrameWorker;
143
157
 
144
- export { type AspectRatio, type CaptionOptions, type CaptionSegment, type CaptionStyle, type CaptionStylePreset, type ClipInput, type CropOptions, type EncodeOptions, FFmpegBackend, type FrameData, type FrameWorker, type FrameWorkerConfig, type RenderOptions, type RendererBackend, STYLE_PRESETS, createFFmpegBackend, createFrameWorker };
158
+ export { type AspectRatio, type CaptionOptions, type CaptionSegment, type CaptionStyle, type CaptionStylePreset, type ClipInput, type ClipProgress, type ClipStatus, type CropOptions, type EncodeOptions, FFmpegBackend, type FrameData, type FrameWorker, type FrameWorkerConfig, type RenderOptions, type RendererBackend, type RichProgress, STYLE_PRESETS, type StitchOptions, createFFmpegBackend, createFrameWorker };
package/dist/index.d.ts CHANGED
@@ -114,15 +114,29 @@ interface FrameWorkerConfig {
114
114
  /** Default output height */
115
115
  height?: number;
116
116
  }
117
+ type ClipStatus = 'pending' | 'rendering' | 'encoding' | 'done' | 'error';
118
+ interface ClipProgress {
119
+ index: number;
120
+ status: ClipStatus;
121
+ progress: number;
122
+ }
123
+ interface RichProgress {
124
+ overall: number;
125
+ clips: ClipProgress[];
126
+ }
127
+ /** Extends RenderOptions with rich per-clip progress reporting */
128
+ interface StitchOptions extends Omit<RenderOptions, 'onProgress'> {
129
+ onProgress?: (progress: RichProgress) => void;
130
+ }
117
131
  interface FrameWorker {
118
132
  /** Render a single clip to a Blob */
119
133
  render(clip: ClipInput, options?: RenderOptions): Promise<Blob>;
120
134
  /** Render a single clip and return an object URL */
121
135
  renderToUrl(clip: ClipInput, options?: RenderOptions): Promise<string>;
122
136
  /** Stitch multiple clips into one Blob */
123
- stitch(clips: ClipInput[], options?: RenderOptions): Promise<Blob>;
137
+ stitch(clips: ClipInput[], options?: StitchOptions): Promise<Blob>;
124
138
  /** Stitch multiple clips and return an object URL */
125
- stitchToUrl(clips: ClipInput[], options?: RenderOptions): Promise<string>;
139
+ stitchToUrl(clips: ClipInput[], options?: StitchOptions): Promise<string>;
126
140
  }
127
141
 
128
142
  declare const STYLE_PRESETS: Record<CaptionStylePreset, CaptionStyle>;
@@ -141,4 +155,4 @@ declare function createFFmpegBackend(): FFmpegBackend;
141
155
 
142
156
  declare function createFrameWorker(config?: FrameWorkerConfig): FrameWorker;
143
157
 
144
- export { type AspectRatio, type CaptionOptions, type CaptionSegment, type CaptionStyle, type CaptionStylePreset, type ClipInput, type CropOptions, type EncodeOptions, FFmpegBackend, type FrameData, type FrameWorker, type FrameWorkerConfig, type RenderOptions, type RendererBackend, STYLE_PRESETS, createFFmpegBackend, createFrameWorker };
158
+ export { type AspectRatio, type CaptionOptions, type CaptionSegment, type CaptionStyle, type CaptionStylePreset, type ClipInput, type ClipProgress, type ClipStatus, type CropOptions, type EncodeOptions, FFmpegBackend, type FrameData, type FrameWorker, type FrameWorkerConfig, type RenderOptions, type RendererBackend, type RichProgress, STYLE_PRESETS, type StitchOptions, createFFmpegBackend, createFrameWorker };
package/dist/index.js CHANGED
@@ -468,25 +468,211 @@ function seekVideo(video, time) {
468
468
  });
469
469
  }
470
470
 
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
+ var WorkerPool = class {
523
+ constructor(maxConcurrency) {
524
+ 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);
531
+ }
532
+ }
533
+ acquire() {
534
+ if (this.available.length > 0) return Promise.resolve(this.available.pop());
535
+ return new Promise((resolve) => this.waiters.push(resolve));
536
+ }
537
+ release(worker) {
538
+ if (this.waiters.length > 0) {
539
+ this.waiters.shift()(worker);
540
+ } else {
541
+ this.available.push(worker);
542
+ }
543
+ }
544
+ async dispatch(clip, width, height, fps, signal, onProgress) {
545
+ const worker = await this.acquire();
546
+ try {
547
+ return await this.processClip(worker, clip, width, height, fps, signal, onProgress);
548
+ } finally {
549
+ this.release(worker);
550
+ }
551
+ }
552
+ async processClip(worker, clip, width, height, fps, signal, onProgress) {
553
+ let srcUrl;
554
+ let needsRevoke = false;
555
+ if (typeof clip.source === "string") {
556
+ srcUrl = clip.source;
557
+ } else if (clip.source instanceof HTMLVideoElement) {
558
+ srcUrl = clip.source.src;
559
+ } else {
560
+ srcUrl = URL.createObjectURL(clip.source);
561
+ needsRevoke = true;
562
+ }
563
+ const video = document.createElement("video");
564
+ video.muted = true;
565
+ video.crossOrigin = "anonymous";
566
+ 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
+ });
572
+ 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);
577
+ 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") {
586
+ worker.removeEventListener("message", onMessage);
587
+ resolve(msg.frames);
588
+ } else if (msg.type === "error") {
589
+ worker.removeEventListener("message", onMessage);
590
+ reject(new Error(msg.message));
591
+ } else if (msg.type === "progress") {
592
+ onProgress?.(msg.value);
593
+ }
594
+ };
595
+ 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]);
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
+ }
627
+ }
628
+ terminate() {
629
+ for (const w of this.workers) w.terminate();
630
+ this.workers.length = 0;
631
+ this.available.length = 0;
632
+ }
633
+ };
634
+
471
635
  // src/stitch.ts
636
+ function supportsOffscreenWorkers() {
637
+ return typeof Worker !== "undefined" && typeof OffscreenCanvas !== "undefined" && typeof createImageBitmap !== "undefined";
638
+ }
472
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) {
473
646
  const fps = options.fps ?? 30;
474
647
  const width = options.width ?? 1280;
475
648
  const height = options.height ?? 720;
476
- const onProgress = options.onProgress;
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() });
657
+ };
477
658
  const blobs = [];
478
659
  for (let ci = 0; ci < clips.length; ci++) {
479
- const clip = clips[ci];
480
- const clipProgress = (p) => {
481
- onProgress?.((ci + p * 0.9) / clips.length);
482
- };
483
- const frames = await extractFrames(clip, {
484
- ...options,
660
+ clipStatuses[ci].status = "rendering";
661
+ emit(ci / clips.length);
662
+ const frames = await extractFrames(clips[ci], {
663
+ fps,
485
664
  width,
486
665
  height,
487
- fps,
488
- onProgress: clipProgress
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
+ }
489
674
  });
675
+ clipStatuses[ci].status = "encoding";
490
676
  const blob = await backend.encode(frames, {
491
677
  width,
492
678
  height,
@@ -494,13 +680,18 @@ async function stitchClips(clips, backend, options) {
494
680
  mimeType: options.mimeType ?? "video/mp4",
495
681
  quality: options.quality ?? 0.92,
496
682
  encoderOptions: options.encoderOptions,
497
- onProgress: (p) => clipProgress(0.9 + p * 0.1),
498
- signal: options.signal
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
+ }
499
688
  });
689
+ clipStatuses[ci].status = "done";
690
+ clipStatuses[ci].progress = 1;
500
691
  blobs.push(blob);
501
692
  }
502
693
  if (blobs.length === 1) {
503
- onProgress?.(1);
694
+ emit(1);
504
695
  return blobs[0];
505
696
  }
506
697
  return backend.concat(blobs, {
@@ -509,10 +700,96 @@ async function stitchClips(clips, backend, options) {
509
700
  fps,
510
701
  mimeType: options.mimeType ?? "video/mp4",
511
702
  quality: options.quality ?? 0.92,
512
- onProgress: (p) => onProgress?.((clips.length - 1 + p) / clips.length),
513
- signal: options.signal
703
+ signal,
704
+ onProgress: (p) => emit((clips.length - 1 + p) / clips.length)
514
705
  });
515
706
  }
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
+ }
516
793
 
517
794
  // src/index.ts
518
795
  function createFrameWorker(config = {}) {