@mediafox/core 1.2.12 → 1.2.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediafox/core",
3
- "version": "1.2.12",
3
+ "version": "1.2.13",
4
4
  "description": "Framework-agnostic media player library powered by MediaBunny",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,7 +113,7 @@ export class CompositorAudioManager {
113
113
  * Updates which sources are playing and their parameters.
114
114
  */
115
115
  processAudioLayers(layers: AudioLayer[], mediaTime: number): void {
116
- if (this.disposed || !this.playing) return;
116
+ if (this.disposed) return;
117
117
 
118
118
  // Track which sources are active in this frame
119
119
  const activeSourceIds = this.activeSourceIdsScratch;
@@ -179,6 +179,14 @@ export class CompositorAudioManager {
179
179
  this.startContextTime = this.audioContext.currentTime;
180
180
  this.startMediaTime = fromTime;
181
181
  this.pauseTime = fromTime;
182
+
183
+ // Clear any stale iterators so sources can be restarted fresh
184
+ for (const source of this.activeSources.values()) {
185
+ if (source.iterator) {
186
+ void source.iterator.return();
187
+ source.iterator = null;
188
+ }
189
+ }
182
190
  }
183
191
 
184
192
  /**
@@ -251,7 +259,7 @@ export class CompositorAudioManager {
251
259
 
252
260
  // Throttle if we're too far ahead (more than 1 second of audio buffered)
253
261
  const elapsedSinceStart = this.audioContext.currentTime - source.iteratorStartTime;
254
- const bufferedAhead = (timestamp - source.startSourceTime) - elapsedSinceStart;
262
+ const bufferedAhead = timestamp - source.startSourceTime - elapsedSinceStart;
255
263
  if (bufferedAhead > 1) {
256
264
  await this.waitForCatchup(source, timestamp);
257
265
  }
@@ -272,7 +280,7 @@ export class CompositorAudioManager {
272
280
 
273
281
  // Calculate how far ahead we've buffered
274
282
  const elapsedSinceStart = this.audioContext.currentTime - source.iteratorStartTime;
275
- const bufferedAhead = (targetSourceTime - source.startSourceTime) - elapsedSinceStart;
283
+ const bufferedAhead = targetSourceTime - source.startSourceTime - elapsedSinceStart;
276
284
  if (bufferedAhead < 1) {
277
285
  clearInterval(checkInterval);
278
286
  resolve();
@@ -1,16 +1,16 @@
1
1
  import { Compositor } from './compositor';
2
- import type { CompositorLayer, CompositionFrame, CompositorSource } from './types';
2
+ import type { CompositionFrame, CompositorLayer, CompositorSource } from './types';
3
3
  import type {
4
4
  CompositorWorkerExportPayload,
5
5
  CompositorWorkerFrame,
6
6
  CompositorWorkerInitPayload,
7
7
  CompositorWorkerLoadPayload,
8
8
  CompositorWorkerRenderPayload,
9
+ CompositorWorkerRequest,
9
10
  CompositorWorkerResizePayload,
11
+ CompositorWorkerResponse,
10
12
  CompositorWorkerSourceInfo,
11
13
  CompositorWorkerUnloadPayload,
12
- CompositorWorkerRequest,
13
- CompositorWorkerResponse,
14
14
  } from './worker-types';
15
15
 
16
16
  type WorkerScope = {
@@ -48,6 +48,7 @@ const mapFrame = (frame: CompositorWorkerFrame): CompositionFrame => {
48
48
  source,
49
49
  sourceTime: layer.sourceTime,
50
50
  transform: layer.transform,
51
+ fitMode: layer.fitMode,
51
52
  visible: layer.visible,
52
53
  zIndex: layer.zIndex,
53
54
  };
@@ -122,8 +123,8 @@ workerScope.onmessage = async (event: MessageEvent<CompositorWorkerRequest>) =>
122
123
  }
123
124
  case 'resize': {
124
125
  if (!compositor) throw new Error('Compositor not initialized');
125
- const { width, height } = payload as CompositorWorkerResizePayload;
126
- compositor.resize(width, height);
126
+ const { width, height, fitMode } = payload as CompositorWorkerResizePayload;
127
+ compositor.resize(width, height, fitMode);
127
128
  postResponse({ id, ok: true, result: true });
128
129
  return;
129
130
  }
@@ -11,6 +11,7 @@ import type {
11
11
  CompositorOptions,
12
12
  CompositorSource,
13
13
  CompositorSourceOptions,
14
+ FitMode,
14
15
  FrameExportOptions,
15
16
  PreviewOptions,
16
17
  } from './types';
@@ -60,6 +61,7 @@ export class Compositor {
60
61
  private width: number;
61
62
  private height: number;
62
63
  private backgroundColor: string;
64
+ private fitMode: FitMode;
63
65
  private sourcePool: SourcePool;
64
66
  private audioManager: CompositorAudioManager | null = null;
65
67
  private workerClient: CompositorWorkerClient | null = null;
@@ -88,6 +90,9 @@ export class Compositor {
88
90
  };
89
91
  private registeredAudioSources = new Set<string>();
90
92
 
93
+ // Seek/Play synchronization
94
+ private pendingPlayAfterSeek = false;
95
+
91
96
  /**
92
97
  * Creates a new Compositor instance.
93
98
  * @param options - Configuration options for the compositor
@@ -97,6 +102,7 @@ export class Compositor {
97
102
  this.width = options.width ?? (this.canvas.width || 1920);
98
103
  this.height = options.height ?? (this.canvas.height || 1080);
99
104
  this.backgroundColor = options.backgroundColor ?? '#000000';
105
+ this.fitMode = options.fitMode ?? 'fill';
100
106
  this.emitter = new EventEmitter({ maxListeners: 50 });
101
107
  this.state = {
102
108
  playing: false,
@@ -110,11 +116,7 @@ export class Compositor {
110
116
  this.canvas.height = this.height;
111
117
 
112
118
  const workerEnabled =
113
- typeof options.worker === 'boolean'
114
- ? options.worker
115
- : options.worker
116
- ? (options.worker.enabled ?? true)
117
- : false;
119
+ typeof options.worker === 'boolean' ? options.worker : options.worker ? (options.worker.enabled ?? true) : false;
118
120
  const canUseWorker =
119
121
  workerEnabled &&
120
122
  typeof Worker !== 'undefined' &&
@@ -434,29 +436,99 @@ export class Compositor {
434
436
  const transform = layer.transform;
435
437
  const sourceWidth = layer.source.width ?? this.width;
436
438
  const sourceHeight = layer.source.height ?? this.height;
439
+ const effectiveFitMode =
440
+ layer.fitMode === undefined || layer.fitMode === 'auto' ? this.fitMode : layer.fitMode;
441
+
442
+ let fittedWidth = sourceWidth;
443
+ let fittedHeight = sourceHeight;
444
+ let fittedX = 0;
445
+ let fittedY = 0;
446
+
447
+ if (sourceWidth === 0 || sourceHeight === 0) {
448
+ fittedWidth = this.width;
449
+ fittedHeight = this.height;
450
+ } else if (effectiveFitMode === 'none') {
451
+ fittedWidth = sourceWidth;
452
+ fittedHeight = sourceHeight;
453
+ fittedX = (this.width - fittedWidth) / 2;
454
+ fittedY = (this.height - fittedHeight) / 2;
455
+ } else {
456
+ const sourceAspect = sourceWidth / sourceHeight;
457
+ const canvasAspect = this.width / this.height;
458
+
459
+ switch (effectiveFitMode) {
460
+ case 'fill':
461
+ // Stretch to fill canvas - ignore aspect ratio
462
+ fittedWidth = this.width;
463
+ fittedHeight = this.height;
464
+ break;
465
+
466
+ case 'cover':
467
+ // Scale to cover entire canvas - may crop
468
+ if (sourceAspect > canvasAspect) {
469
+ fittedHeight = this.height;
470
+ fittedWidth = this.height * sourceAspect;
471
+ fittedX = (this.width - fittedWidth) / 2;
472
+ } else {
473
+ fittedWidth = this.width;
474
+ fittedHeight = this.width / sourceAspect;
475
+ fittedY = (this.height - fittedHeight) / 2;
476
+ }
477
+ break;
478
+
479
+ default:
480
+ // Scale to fit entirely within canvas - may letterbox
481
+ if (sourceAspect > canvasAspect) {
482
+ fittedWidth = this.width;
483
+ fittedHeight = this.width / sourceAspect;
484
+ fittedY = (this.height - fittedHeight) / 2;
485
+ } else {
486
+ fittedHeight = this.height;
487
+ fittedWidth = this.height * sourceAspect;
488
+ fittedX = (this.width - fittedWidth) / 2;
489
+ }
490
+ break;
491
+ }
492
+ }
437
493
 
438
- // Fast path: no transform object means draw at origin with source dimensions
439
494
  if (!transform) {
440
- ctx.drawImage(image, 0, 0, sourceWidth, sourceHeight);
495
+ ctx.drawImage(image, fittedX, fittedY, fittedWidth, fittedHeight);
441
496
  return;
442
497
  }
443
498
 
444
- const destWidth = transform.width ?? sourceWidth;
445
- const destHeight = transform.height ?? sourceHeight;
446
- const x = transform.x ?? 0;
447
- const y = transform.y ?? 0;
499
+ const x = (transform.x ?? 0) + fittedX;
500
+ const y = (transform.y ?? 0) + fittedY;
501
+ const destWidth = transform.width ?? fittedWidth;
502
+ const destHeight = transform.height ?? fittedHeight;
448
503
  const rotation = transform.rotation ?? 0;
449
504
  const scaleX = transform.scaleX ?? 1;
450
505
  const scaleY = transform.scaleY ?? 1;
451
506
  const opacity = transform.opacity ?? 1;
507
+ const filter = transform.filter;
452
508
 
453
509
  // Check if we need context state changes
454
510
  const needsOpacity = opacity !== 1;
455
511
  const needsTransform = rotation !== 0 || scaleX !== 1 || scaleY !== 1;
512
+ const needsFilter = !!filter && filter !== 'none';
513
+ const needsContextState = needsOpacity || needsTransform || needsFilter;
456
514
 
457
515
  // Fast path: simple position/size only, no rotation/scale/opacity
458
- if (!needsOpacity && !needsTransform) {
516
+ if (!needsContextState) {
517
+ ctx.drawImage(image, x, y, destWidth, destHeight);
518
+ return;
519
+ }
520
+
521
+ // If we only need opacity/filter but no transforms, avoid translate/rotate work
522
+ if (!needsTransform) {
523
+ ctx.save();
524
+ if (needsOpacity) {
525
+ ctx.globalAlpha = opacity;
526
+ }
527
+ if (needsFilter) {
528
+ ctx.filter = filter;
529
+ }
459
530
  ctx.drawImage(image, x, y, destWidth, destHeight);
531
+ ctx.restore();
460
532
  return;
461
533
  }
462
534
 
@@ -470,6 +542,10 @@ export class Compositor {
470
542
  ctx.globalAlpha = opacity;
471
543
  }
472
544
 
545
+ if (needsFilter) {
546
+ ctx.filter = filter;
547
+ }
548
+
473
549
  // Move to layer position
474
550
  ctx.translate(x + destWidth * anchorX, y + destHeight * anchorY);
475
551
 
@@ -556,6 +632,7 @@ export class Compositor {
556
632
  sourceId,
557
633
  sourceTime: layer.sourceTime,
558
634
  transform: layer.transform,
635
+ fitMode: layer.fitMode,
559
636
  visible: layer.visible,
560
637
  zIndex: layer.zIndex,
561
638
  };
@@ -606,6 +683,12 @@ export class Compositor {
606
683
  throw new Error('No preview configured. Call preview() first.');
607
684
  }
608
685
 
686
+ // If currently seeking, queue play to execute after seek completes
687
+ if (this.state.seeking) {
688
+ this.pendingPlayAfterSeek = true;
689
+ return;
690
+ }
691
+
609
692
  this.state.playing = true;
610
693
  this.lastFrameTime = performance.now();
611
694
  this.lastRenderTime = this.lastFrameTime;
@@ -616,6 +699,11 @@ export class Compositor {
616
699
  // Reset active audio tracking so sources restart after pause/seek.
617
700
  this.activeAudioSourceIds.clear();
618
701
  await this.audioManager.play(this.state.currentTime);
702
+
703
+ // Process audio layers immediately to start sources right away
704
+ // instead of waiting for render loop which might skip first frame
705
+ const frame = this.previewOptions.getComposition(this.state.currentTime);
706
+ this.processAudioLayers(frame.audio ?? [], this.state.currentTime);
619
707
  }
620
708
 
621
709
  // Start render loop
@@ -630,6 +718,7 @@ export class Compositor {
630
718
  if (!this.state.playing) return;
631
719
 
632
720
  this.state.playing = false;
721
+ this.pendingPlayAfterSeek = false;
633
722
  this.stopRenderLoop();
634
723
  if (this.audioManager) {
635
724
  this.audioManager.pause();
@@ -654,15 +743,26 @@ export class Compositor {
654
743
  // Seek audio
655
744
  if (this.audioManager) {
656
745
  await this.audioManager.seek(clampedTime);
746
+ // Clear tracking so sources are treated as new and restarted
747
+ this.activeAudioSourceIds.clear();
657
748
  }
658
749
 
659
- // Render frame at new time
750
+ // Render frame at new time and process audio layers during seek
660
751
  const frame = this.previewOptions.getComposition(clampedTime);
752
+ if (this.audioManager) {
753
+ this.processAudioLayers(frame.audio ?? [], clampedTime);
754
+ }
661
755
  await this.render(frame);
662
756
 
663
757
  this.state.seeking = false;
664
758
  this.emitter.emit('seeked', { time: clampedTime });
665
759
  this.emitter.emit('timeupdate', { currentTime: clampedTime });
760
+
761
+ // Execute pending play if one was queued during seek
762
+ if (this.pendingPlayAfterSeek) {
763
+ this.pendingPlayAfterSeek = false;
764
+ await this.play();
765
+ }
666
766
  }
667
767
 
668
768
  private startRenderLoop(): void {
@@ -820,22 +920,55 @@ export class Compositor {
820
920
 
821
921
  /**
822
922
  * Resizes the compositor canvas without disposing loaded sources.
923
+ * When worker mode is active, delegates resizing to the worker thread
924
+ * since the OffscreenCanvas cannot be resized from the main thread.
823
925
  * @param width - New width in pixels
824
926
  * @param height - New height in pixels
927
+ * @param fitMode - Optional fit mode for scaling sources to the canvas
825
928
  */
826
- resize(width: number, height: number): void {
929
+ resize(width: number, height: number, fitMode?: FitMode): void {
827
930
  this.checkDisposed();
828
931
  this.width = width;
829
932
  this.height = height;
830
- this.canvas.width = width;
831
- this.canvas.height = height;
933
+ if (fitMode !== undefined) {
934
+ this.fitMode = fitMode;
935
+ }
936
+
937
+ // When worker mode is active, the canvas control has been transferred
938
+ // to OffscreenCanvas via transferControlToOffscreen(). Attempting to
939
+ // modify the HTMLCanvasElement's width/height would throw InvalidStateError.
940
+ // Delegate resizing entirely to the worker in this case.
832
941
  if (this.workerClient) {
833
- void this.workerClient.resize(width, height);
942
+ void this.workerClient.resize(width, height, this.fitMode);
834
943
  return;
835
944
  }
945
+
946
+ // Main thread rendering: update DOM canvas dimensions directly
947
+ this.canvas.width = width;
948
+ this.canvas.height = height;
836
949
  this.clear();
837
950
  }
838
951
 
952
+ /**
953
+ * Sets the fit mode for scaling sources to the canvas.
954
+ * @param fitMode - The fit mode to use
955
+ */
956
+ setFitMode(fitMode: FitMode): void {
957
+ this.checkDisposed();
958
+ this.fitMode = fitMode;
959
+ if (this.workerClient) {
960
+ void this.workerClient.resize(this.width, this.height, fitMode);
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Gets the current fit mode.
966
+ * @returns The current fit mode
967
+ */
968
+ getFitMode(): FitMode {
969
+ return this.fitMode;
970
+ }
971
+
839
972
  // Events
840
973
 
841
974
  /**
@@ -12,7 +12,9 @@ export type {
12
12
  CompositorSource,
13
13
  CompositorSourceOptions,
14
14
  CompositorWorkerOptions,
15
+ FitMode,
15
16
  FrameExportOptions,
17
+ LayerFitMode,
16
18
  LayerTransform,
17
19
  PreviewOptions,
18
20
  SourceType,
@@ -2,6 +2,15 @@ import type { RendererType, Rotation } from '../types';
2
2
 
3
3
  export type CompositorRendererType = RendererType;
4
4
 
5
+ /**
6
+ * Fit mode for scaling video/image content within the compositor canvas.
7
+ * - `'fill'` (default): Stretch to exactly fill the canvas, ignoring aspect ratio. May distort the image.
8
+ * - `'contain'`: Scale to fit entirely within the canvas, preserving aspect ratio. May result in letterboxing/pillarboxing.
9
+ * - `'cover'`: Scale to completely cover the canvas, preserving aspect ratio. Parts may be cropped.
10
+ */
11
+ export type FitMode = 'contain' | 'cover' | 'fill';
12
+ export type LayerFitMode = FitMode | 'none' | 'auto';
13
+
5
14
  export interface CompositorOptions {
6
15
  canvas: HTMLCanvasElement | OffscreenCanvas;
7
16
  width?: number;
@@ -10,6 +19,8 @@ export interface CompositorOptions {
10
19
  backgroundColor?: string;
11
20
  enableAudio?: boolean;
12
21
  worker?: boolean | CompositorWorkerOptions;
22
+ /** Initial fit mode for scaling sources to the canvas. Defaults to 'fill'. */
23
+ fitMode?: FitMode;
13
24
  }
14
25
 
15
26
  export interface LayerTransform {
@@ -23,12 +34,19 @@ export interface LayerTransform {
23
34
  opacity?: number;
24
35
  anchorX?: number;
25
36
  anchorY?: number;
37
+ /** CSS filter string applied to this layer (e.g. "brightness(1.1) contrast(1.05)"). */
38
+ filter?: string;
26
39
  }
27
40
 
28
41
  export interface CompositorLayer {
29
42
  source: CompositorSource;
30
43
  sourceTime?: number;
31
44
  transform?: LayerTransform;
45
+ /**
46
+ * Fit mode override for this layer. Use 'auto' or leave undefined to use the
47
+ * compositor's global fitMode, or 'none' to render at the source's original size.
48
+ */
49
+ fitMode?: LayerFitMode;
32
50
  visible?: boolean;
33
51
  zIndex?: number;
34
52
  }
@@ -1,5 +1,5 @@
1
1
  import type { MediaSource } from '../types';
2
- import type { CompositorWorkerOptions, CompositorSourceOptions, FrameExportOptions } from './types';
2
+ import type { CompositorSourceOptions, CompositorWorkerOptions, FrameExportOptions } from './types';
3
3
  import type {
4
4
  CompositorWorkerExportPayload,
5
5
  CompositorWorkerFrame,
@@ -32,10 +32,9 @@ export class CompositorWorkerClient {
32
32
  private ready: Promise<void>;
33
33
 
34
34
  constructor(options: CompositorWorkerClientOptions) {
35
- const workerOptions = typeof options.worker === 'boolean' ? {} : options.worker ?? {};
35
+ const workerOptions = typeof options.worker === 'boolean' ? {} : (options.worker ?? {});
36
36
  const workerType = workerOptions.type ?? 'module';
37
- const workerUrl =
38
- workerOptions.url ?? new URL('./compositor-worker.js', import.meta.url);
37
+ const workerUrl = workerOptions.url ?? new URL('./compositor-worker.js', import.meta.url);
39
38
 
40
39
  this.worker = new Worker(workerUrl, { type: workerType });
41
40
  this.worker.onmessage = (event: MessageEvent<CompositorWorkerResponse>) => {
@@ -115,9 +114,9 @@ export class CompositorWorkerClient {
115
114
  return this.call<boolean>('clear');
116
115
  }
117
116
 
118
- async resize(width: number, height: number): Promise<boolean> {
117
+ async resize(width: number, height: number, fitMode?: 'contain' | 'cover' | 'fill'): Promise<boolean> {
119
118
  await this.ready;
120
- const payload: CompositorWorkerResizePayload = { width, height };
119
+ const payload: CompositorWorkerResizePayload = { width, height, fitMode };
121
120
  return this.call<boolean>('resize', payload);
122
121
  }
123
122
 
@@ -1,10 +1,11 @@
1
1
  import type { MediaSource } from '../types';
2
- import type { FrameExportOptions, LayerTransform, CompositorSourceOptions, SourceType } from './types';
2
+ import type { CompositorSourceOptions, FrameExportOptions, LayerFitMode, LayerTransform, SourceType } from './types';
3
3
 
4
4
  export interface CompositorWorkerLayer {
5
5
  sourceId: string;
6
6
  sourceTime?: number;
7
7
  transform?: LayerTransform;
8
+ fitMode?: LayerFitMode;
8
9
  visible?: boolean;
9
10
  zIndex?: number;
10
11
  }
@@ -59,6 +60,7 @@ export interface CompositorWorkerRenderPayload {
59
60
  export interface CompositorWorkerResizePayload {
60
61
  width: number;
61
62
  height: number;
63
+ fitMode?: 'contain' | 'cover' | 'fill';
62
64
  }
63
65
 
64
66
  export interface CompositorWorkerExportPayload {
package/src/index.ts CHANGED
@@ -26,7 +26,9 @@ export type {
26
26
  CompositorSource,
27
27
  CompositorSourceOptions,
28
28
  CompositorWorkerOptions,
29
+ FitMode,
29
30
  FrameExportOptions,
31
+ LayerFitMode,
30
32
  LayerTransform,
31
33
  PreviewOptions,
32
34
  SourceType,