@stream-io/video-filters-web 0.8.2 → 0.8.4

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.ts CHANGED
@@ -4,5 +4,6 @@ export { SegmentationLevel } from './src/legacy/segmentation';
4
4
  export * from './src/legacy/tflite';
5
5
  export * from './src/mediapipe';
6
6
  export * from './src/types';
7
+ export * from './src/BaseVideoProcessor';
7
8
  export * from './src/VirtualBackground';
8
9
  export * from './src/FullScreenBlur';
package/dist/index.es.js CHANGED
@@ -1559,7 +1559,7 @@ const createTFLiteSIMDModule = (__Module) => {
1559
1559
  return __Module.ready;
1560
1560
  };
1561
1561
 
1562
- const version = "0.8.2";
1562
+ const version = "0.8.4";
1563
1563
  const packageName = "@stream-io/video-filters-web";
1564
1564
 
1565
1565
  // @ts-expect-error - module is not declared
@@ -1609,6 +1609,249 @@ const BACKGROUND_BLUR_MAP = {
1609
1609
  high: 7,
1610
1610
  };
1611
1611
 
1612
+ /**
1613
+ * Fallback video processor for browsers that do not support MediaStreamTrackGenerator.
1614
+ *
1615
+ * Produces a video MediaStreamTrack sourced from a canvas and exposes
1616
+ * a WritableStream<VideoFrame> on track.writable for writing frames.
1617
+ */
1618
+ class FallbackGenerator {
1619
+ constructor({ kind, signalTarget }) {
1620
+ if (kind !== 'video') {
1621
+ throw new Error('Only video tracks are supported');
1622
+ }
1623
+ const canvas = document.createElement('canvas');
1624
+ const ctx = canvas.getContext('2d', { desynchronized: true });
1625
+ if (!ctx) {
1626
+ throw new Error('Failed to get 2D context from canvas');
1627
+ }
1628
+ const mediaStream = canvas.captureStream();
1629
+ const track = mediaStream.getVideoTracks()[0];
1630
+ const height = signalTarget?.getSettings().height;
1631
+ const width = signalTarget?.getSettings().width;
1632
+ if (height && width) {
1633
+ canvas.height = height;
1634
+ canvas.width = width;
1635
+ }
1636
+ if (!track) {
1637
+ throw new Error('Failed to create canvas track');
1638
+ }
1639
+ if (signalTarget) {
1640
+ signalTarget.addEventListener('ended', () => {
1641
+ track.stop();
1642
+ });
1643
+ }
1644
+ track.writable = new WritableStream({
1645
+ write: (frame) => {
1646
+ if (canvas.width !== frame.displayWidth ||
1647
+ canvas.height !== frame.displayHeight) {
1648
+ canvas.width = frame.displayWidth;
1649
+ canvas.height = frame.displayHeight;
1650
+ }
1651
+ ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
1652
+ frame.close();
1653
+ },
1654
+ abort: () => {
1655
+ track.stop();
1656
+ },
1657
+ close: () => {
1658
+ track.stop();
1659
+ },
1660
+ });
1661
+ return track;
1662
+ }
1663
+ }
1664
+ const TrackGenerator = typeof MediaStreamTrackGenerator !== 'undefined'
1665
+ ? MediaStreamTrackGenerator
1666
+ : FallbackGenerator;
1667
+
1668
+ /**
1669
+ * Fallback implementation for browsers without MediaStreamTrackGenerator.
1670
+ *
1671
+ * Produces a video MediaStreamTrack sourced from a canvas and exposes a
1672
+ * WritableStream<VideoFrame> on track.writable. Written frames are drawn
1673
+ * into the canvas and update the underlying track automatically.
1674
+ */
1675
+ class FallbackProcessor {
1676
+ readable;
1677
+ workerTimer;
1678
+ video;
1679
+ constructor({ track }) {
1680
+ if (!track)
1681
+ throw new Error('MediaStreamTrack is required');
1682
+ if (track.kind !== 'video') {
1683
+ throw new Error('MediaStreamTrack must be video');
1684
+ }
1685
+ let running = true;
1686
+ this.video = document.createElement('video');
1687
+ this.video.muted = true;
1688
+ this.video.playsInline = true;
1689
+ this.video.srcObject = new MediaStream([track]);
1690
+ const canvas = new OffscreenCanvas(1, 1);
1691
+ const ctx = canvas.getContext('2d');
1692
+ if (!ctx)
1693
+ throw new Error('Failed to get 2D context from OffscreenCanvas');
1694
+ let timestamp = 0;
1695
+ const frameRate = track.getSettings().frameRate || 30;
1696
+ let frameDuration = 1000 / frameRate;
1697
+ this.workerTimer = new WorkerTimer({ useWorker: true });
1698
+ this.readable = new ReadableStream({
1699
+ start: async () => {
1700
+ await Promise.all([
1701
+ this.video.play(),
1702
+ new Promise((r) => this.video.addEventListener('loadeddata', r, { once: true })),
1703
+ ]);
1704
+ frameDuration = 1000 / (track.getSettings().frameRate || 30);
1705
+ timestamp = performance.now();
1706
+ },
1707
+ pull: async (controller) => {
1708
+ if (!running) {
1709
+ controller.close();
1710
+ this.close();
1711
+ return;
1712
+ }
1713
+ const delta = performance.now() - timestamp;
1714
+ if (delta <= frameDuration) {
1715
+ await new Promise((r) => this.workerTimer.setTimeout(r, frameDuration - delta));
1716
+ }
1717
+ timestamp = performance.now();
1718
+ if (canvas.width !== this.video.videoWidth ||
1719
+ canvas.height !== this.video.videoHeight) {
1720
+ canvas.width = this.video.videoWidth;
1721
+ canvas.height = this.video.videoHeight;
1722
+ }
1723
+ ctx.drawImage(this.video, 0, 0);
1724
+ try {
1725
+ const frame = new VideoFrame(canvas, {
1726
+ timestamp: Math.round(this.video.currentTime * 1000000),
1727
+ });
1728
+ controller.enqueue(frame);
1729
+ }
1730
+ catch (err) {
1731
+ running = false;
1732
+ controller.error(err);
1733
+ this.close();
1734
+ }
1735
+ },
1736
+ cancel: () => {
1737
+ running = false;
1738
+ this.close();
1739
+ },
1740
+ });
1741
+ }
1742
+ close = () => {
1743
+ this.video.pause();
1744
+ this.video.srcObject = null;
1745
+ this.video.src = '';
1746
+ this.workerTimer.destroy();
1747
+ };
1748
+ }
1749
+ const TrackProcessor = typeof MediaStreamTrackProcessor !== 'undefined'
1750
+ ? MediaStreamTrackProcessor
1751
+ : FallbackProcessor;
1752
+
1753
+ /**
1754
+ * Base class for real-time video filters.
1755
+ *
1756
+ * It sets up the full pipeline that reads frames from the input track,
1757
+ * processes them, and outputs a new track with your effect applied. Subclasses
1758
+ * only need to implement `initialize` (run once before processing starts) and
1759
+ * `transform` (called for every frame).
1760
+ *
1761
+ * Everything else—canvas setup, performance tracking, error handling, and
1762
+ * clean shutdown is handled for you. Calling `start()` returns a processed
1763
+ * `MediaStreamTrack` ready to use.
1764
+ */
1765
+ class BaseVideoProcessor {
1766
+ track;
1767
+ processor;
1768
+ generator;
1769
+ hooks;
1770
+ abortController = new AbortController();
1771
+ canvas;
1772
+ frames = 0;
1773
+ delayTotal = 0;
1774
+ lastStatsTime = 0;
1775
+ /**
1776
+ * Constructs a new instance.
1777
+ */
1778
+ constructor(track, hooks = {}) {
1779
+ this.track = track;
1780
+ this.processor = new TrackProcessor({ track });
1781
+ this.generator = new TrackGenerator({
1782
+ kind: 'video',
1783
+ signalTarget: track,
1784
+ });
1785
+ this.hooks = hooks;
1786
+ }
1787
+ async start() {
1788
+ const { readable } = this.processor;
1789
+ const { writable } = this.generator;
1790
+ const { width = 1280, height = 720 } = this.track.getSettings();
1791
+ this.canvas = new OffscreenCanvas(width, height);
1792
+ await this.initialize();
1793
+ const transformStream = new TransformStream({
1794
+ transform: async (frame, controller) => {
1795
+ try {
1796
+ if (this.abortController.signal.aborted)
1797
+ return frame.close();
1798
+ if (this.canvas.width !== frame.displayWidth ||
1799
+ this.canvas.height !== frame.displayHeight) {
1800
+ this.canvas.width = frame.displayWidth;
1801
+ this.canvas.height = frame.displayHeight;
1802
+ }
1803
+ const processed = await this.transform(frame);
1804
+ controller.enqueue(processed);
1805
+ }
1806
+ catch (e) {
1807
+ this.hooks.onError?.(e);
1808
+ }
1809
+ finally {
1810
+ frame.close();
1811
+ }
1812
+ },
1813
+ flush: () => this.onFlush(),
1814
+ });
1815
+ readable
1816
+ .pipeThrough(transformStream, { signal: this.abortController.signal })
1817
+ .pipeTo(writable, { signal: this.abortController.signal })
1818
+ .catch((e) => {
1819
+ if (e.name !== 'AbortError' && e.name !== 'InvalidStateError') {
1820
+ console.error(`[${this.processorName}] Error processing track:`, e);
1821
+ this.hooks.onError?.(e);
1822
+ }
1823
+ });
1824
+ return this.generator;
1825
+ }
1826
+ stop() {
1827
+ this.abortController.abort();
1828
+ this.generator.stop();
1829
+ this.onStop();
1830
+ }
1831
+ updateStats(delay) {
1832
+ this.frames++;
1833
+ this.delayTotal += delay;
1834
+ const now = performance.now();
1835
+ if (this.lastStatsTime === 0) {
1836
+ this.lastStatsTime = now;
1837
+ return;
1838
+ }
1839
+ if (now - this.lastStatsTime >= 1000) {
1840
+ const avgDelay = Math.round((this.delayTotal / this.frames) * 100) / 100;
1841
+ const fps = Math.round((1000 * this.frames) / (now - this.lastStatsTime));
1842
+ this.hooks.onStats?.({ delay: avgDelay, fps, timestamp: now });
1843
+ this.frames = 0;
1844
+ this.delayTotal = 0;
1845
+ this.lastStatsTime = now;
1846
+ }
1847
+ }
1848
+ onFlush() { }
1849
+ onStop() { }
1850
+ get processorName() {
1851
+ return 'base-processor';
1852
+ }
1853
+ }
1854
+
1612
1855
  class WebGLRenderer {
1613
1856
  canvas;
1614
1857
  gl;
@@ -2330,249 +2573,6 @@ class WebGLRenderer {
2330
2573
  }
2331
2574
  }
2332
2575
 
2333
- /**
2334
- * Fallback video processor for browsers that do not support MediaStreamTrackGenerator.
2335
- *
2336
- * Produces a video MediaStreamTrack sourced from a canvas and exposes
2337
- * a WritableStream<VideoFrame> on track.writable for writing frames.
2338
- */
2339
- class FallbackGenerator {
2340
- constructor({ kind, signalTarget }) {
2341
- if (kind !== 'video') {
2342
- throw new Error('Only video tracks are supported');
2343
- }
2344
- const canvas = document.createElement('canvas');
2345
- const ctx = canvas.getContext('2d', { desynchronized: true });
2346
- if (!ctx) {
2347
- throw new Error('Failed to get 2D context from canvas');
2348
- }
2349
- const mediaStream = canvas.captureStream();
2350
- const track = mediaStream.getVideoTracks()[0];
2351
- const height = signalTarget?.getSettings().height;
2352
- const width = signalTarget?.getSettings().width;
2353
- if (height && width) {
2354
- canvas.height = height;
2355
- canvas.width = width;
2356
- }
2357
- if (!track) {
2358
- throw new Error('Failed to create canvas track');
2359
- }
2360
- if (signalTarget) {
2361
- signalTarget.addEventListener('ended', () => {
2362
- track.stop();
2363
- });
2364
- }
2365
- track.writable = new WritableStream({
2366
- write: (frame) => {
2367
- if (canvas.width !== frame.displayWidth ||
2368
- canvas.height !== frame.displayHeight) {
2369
- canvas.width = frame.displayWidth;
2370
- canvas.height = frame.displayHeight;
2371
- }
2372
- ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
2373
- frame.close();
2374
- },
2375
- abort: () => {
2376
- track.stop();
2377
- },
2378
- close: () => {
2379
- track.stop();
2380
- },
2381
- });
2382
- return track;
2383
- }
2384
- }
2385
- const TrackGenerator = typeof MediaStreamTrackGenerator !== 'undefined'
2386
- ? MediaStreamTrackGenerator
2387
- : FallbackGenerator;
2388
-
2389
- /**
2390
- * Fallback implementation for browsers without MediaStreamTrackGenerator.
2391
- *
2392
- * Produces a video MediaStreamTrack sourced from a canvas and exposes a
2393
- * WritableStream<VideoFrame> on track.writable. Written frames are drawn
2394
- * into the canvas and update the underlying track automatically.
2395
- */
2396
- class FallbackProcessor {
2397
- readable;
2398
- workerTimer;
2399
- video;
2400
- constructor({ track }) {
2401
- if (!track)
2402
- throw new Error('MediaStreamTrack is required');
2403
- if (track.kind !== 'video') {
2404
- throw new Error('MediaStreamTrack must be video');
2405
- }
2406
- let running = true;
2407
- this.video = document.createElement('video');
2408
- this.video.muted = true;
2409
- this.video.playsInline = true;
2410
- this.video.srcObject = new MediaStream([track]);
2411
- const canvas = new OffscreenCanvas(1, 1);
2412
- const ctx = canvas.getContext('2d');
2413
- if (!ctx)
2414
- throw new Error('Failed to get 2D context from OffscreenCanvas');
2415
- let timestamp = 0;
2416
- const frameRate = track.getSettings().frameRate || 30;
2417
- let frameDuration = 1000 / frameRate;
2418
- this.workerTimer = new WorkerTimer({ useWorker: true });
2419
- this.readable = new ReadableStream({
2420
- start: async () => {
2421
- await Promise.all([
2422
- this.video.play(),
2423
- new Promise((r) => this.video.addEventListener('loadeddata', r, { once: true })),
2424
- ]);
2425
- frameDuration = 1000 / (track.getSettings().frameRate || 30);
2426
- timestamp = performance.now();
2427
- },
2428
- pull: async (controller) => {
2429
- if (!running) {
2430
- controller.close();
2431
- this.close();
2432
- return;
2433
- }
2434
- const delta = performance.now() - timestamp;
2435
- if (delta <= frameDuration) {
2436
- await new Promise((r) => this.workerTimer.setTimeout(r, frameDuration - delta));
2437
- }
2438
- timestamp = performance.now();
2439
- if (canvas.width !== this.video.videoWidth ||
2440
- canvas.height !== this.video.videoHeight) {
2441
- canvas.width = this.video.videoWidth;
2442
- canvas.height = this.video.videoHeight;
2443
- }
2444
- ctx.drawImage(this.video, 0, 0);
2445
- try {
2446
- const frame = new VideoFrame(canvas, {
2447
- timestamp: Math.round(this.video.currentTime * 1000000),
2448
- });
2449
- controller.enqueue(frame);
2450
- }
2451
- catch (err) {
2452
- running = false;
2453
- controller.error(err);
2454
- this.close();
2455
- }
2456
- },
2457
- cancel: () => {
2458
- running = false;
2459
- this.close();
2460
- },
2461
- });
2462
- }
2463
- close = () => {
2464
- this.video.pause();
2465
- this.video.srcObject = null;
2466
- this.video.src = '';
2467
- this.workerTimer.destroy();
2468
- };
2469
- }
2470
- const TrackProcessor = typeof MediaStreamTrackProcessor !== 'undefined'
2471
- ? MediaStreamTrackProcessor
2472
- : FallbackProcessor;
2473
-
2474
- /**
2475
- * Base class for real-time video filters.
2476
- *
2477
- * It sets up the full pipeline that reads frames from the input track,
2478
- * processes them, and outputs a new track with your effect applied. Subclasses
2479
- * only need to implement `initialize` (run once before processing starts) and
2480
- * `transform` (called for every frame).
2481
- *
2482
- * Everything else—canvas setup, performance tracking, error handling, and
2483
- * clean shutdown is handled for you. Calling `start()` returns a processed
2484
- * `MediaStreamTrack` ready to use.
2485
- */
2486
- class BaseVideoProcessor {
2487
- track;
2488
- processor;
2489
- generator;
2490
- hooks;
2491
- abortController = new AbortController();
2492
- canvas;
2493
- frames = 0;
2494
- delayTotal = 0;
2495
- lastStatsTime = 0;
2496
- /**
2497
- * Constructs a new instance.
2498
- */
2499
- constructor(track, hooks = {}) {
2500
- this.track = track;
2501
- this.processor = new TrackProcessor({ track });
2502
- this.generator = new TrackGenerator({
2503
- kind: 'video',
2504
- signalTarget: track,
2505
- });
2506
- this.hooks = hooks;
2507
- }
2508
- async start() {
2509
- const { readable } = this.processor;
2510
- const { writable } = this.generator;
2511
- const { width = 1280, height = 720 } = this.track.getSettings();
2512
- this.canvas = new OffscreenCanvas(width, height);
2513
- await this.initialize();
2514
- const transformStream = new TransformStream({
2515
- transform: async (frame, controller) => {
2516
- try {
2517
- if (this.abortController.signal.aborted)
2518
- return frame.close();
2519
- if (this.canvas.width !== frame.displayWidth ||
2520
- this.canvas.height !== frame.displayHeight) {
2521
- this.canvas.width = frame.displayWidth;
2522
- this.canvas.height = frame.displayHeight;
2523
- }
2524
- const processed = await this.transform(frame);
2525
- controller.enqueue(processed);
2526
- }
2527
- catch (e) {
2528
- this.hooks.onError?.(e);
2529
- }
2530
- finally {
2531
- frame.close();
2532
- }
2533
- },
2534
- flush: () => this.onFlush(),
2535
- });
2536
- readable
2537
- .pipeThrough(transformStream, { signal: this.abortController.signal })
2538
- .pipeTo(writable, { signal: this.abortController.signal })
2539
- .catch((e) => {
2540
- if (e.name !== 'AbortError' && e.name !== 'InvalidStateError') {
2541
- console.error(`[${this.processorName}] Error processing track:`, e);
2542
- this.hooks.onError?.(e);
2543
- }
2544
- });
2545
- return this.generator;
2546
- }
2547
- stop() {
2548
- this.abortController.abort();
2549
- this.generator.stop();
2550
- this.onStop();
2551
- }
2552
- updateStats(delay) {
2553
- this.frames++;
2554
- this.delayTotal += delay;
2555
- const now = performance.now();
2556
- if (this.lastStatsTime === 0) {
2557
- this.lastStatsTime = now;
2558
- return;
2559
- }
2560
- if (now - this.lastStatsTime >= 1000) {
2561
- const avgDelay = Math.round((this.delayTotal / this.frames) * 100) / 100;
2562
- const fps = Math.round((1000 * this.frames) / (now - this.lastStatsTime));
2563
- this.hooks.onStats?.({ delay: avgDelay, fps, timestamp: now });
2564
- this.frames = 0;
2565
- this.delayTotal = 0;
2566
- this.lastStatsTime = now;
2567
- }
2568
- }
2569
- onFlush() { }
2570
- onStop() { }
2571
- get processorName() {
2572
- return 'base-processor';
2573
- }
2574
- }
2575
-
2576
2576
  /**
2577
2577
  * Wraps a video MediaStreamTrack in a real-time processing pipeline.
2578
2578
  * Incoming frames are processed through a transformer and re-emitted
@@ -3018,5 +3018,5 @@ class FullScreenBlur extends BaseVideoProcessor {
3018
3018
  }
3019
3019
  }
3020
3020
 
3021
- export { BACKGROUND_BLUR_MAP, FullScreenBlur, SegmentationLevel, VirtualBackground, createRenderer, isMediaPipePlatformSupported, isPlatformSupported, loadMediaPipe, loadTFLite };
3021
+ export { BACKGROUND_BLUR_MAP, BaseVideoProcessor, FullScreenBlur, SegmentationLevel, VirtualBackground, createRenderer, isMediaPipePlatformSupported, isPlatformSupported, loadMediaPipe, loadTFLite };
3022
3022
  //# sourceMappingURL=index.es.js.map