@editframe/elements 0.14.0-beta.3 → 0.15.0-beta.10

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.
Files changed (36) hide show
  1. package/dist/EF_FRAMEGEN.js +0 -2
  2. package/dist/elements/EFAudio.d.ts +0 -1
  3. package/dist/elements/EFAudio.js +1 -5
  4. package/dist/elements/EFCaptions.js +1 -1
  5. package/dist/elements/EFImage.d.ts +2 -1
  6. package/dist/elements/EFImage.js +9 -3
  7. package/dist/elements/EFMedia.d.ts +8 -0
  8. package/dist/elements/EFMedia.js +247 -8
  9. package/dist/elements/EFTemporal.d.ts +7 -3
  10. package/dist/elements/EFTemporal.js +19 -1
  11. package/dist/elements/EFTimegroup.d.ts +1 -5
  12. package/dist/elements/EFTimegroup.js +5 -6
  13. package/dist/elements/EFWaveform.d.ts +16 -7
  14. package/dist/elements/EFWaveform.js +273 -163
  15. package/dist/elements/TargetController.d.ts +25 -0
  16. package/dist/elements/TargetController.js +164 -0
  17. package/dist/elements/TargetController.test.d.ts +19 -0
  18. package/dist/gui/EFPreview.d.ts +1 -1
  19. package/dist/gui/EFPreview.js +1 -0
  20. package/dist/gui/EFWorkbench.js +1 -1
  21. package/dist/gui/TWMixin.css.js +1 -1
  22. package/dist/style.css +3 -0
  23. package/package.json +10 -4
  24. package/src/elements/EFAudio.ts +1 -4
  25. package/src/elements/EFCaptions.ts +1 -1
  26. package/src/elements/EFImage.browsertest.ts +33 -2
  27. package/src/elements/EFImage.ts +10 -3
  28. package/src/elements/EFMedia.ts +304 -6
  29. package/src/elements/EFTemporal.ts +37 -5
  30. package/src/elements/EFTimegroup.ts +5 -7
  31. package/src/elements/EFWaveform.ts +341 -194
  32. package/src/elements/TargetController.test.ts +229 -0
  33. package/src/elements/TargetController.ts +219 -0
  34. package/src/gui/EFPreview.ts +10 -9
  35. package/src/gui/EFWorkbench.ts +1 -1
  36. package/types.json +1 -0
@@ -14,9 +14,44 @@ import { EF_RENDERING } from "../EF_RENDERING.js";
14
14
  import { EFSourceMixin } from "./EFSourceMixin.js";
15
15
  import { EFTemporal, isEFTemporal } from "./EFTemporal.js";
16
16
  import { FetchMixin } from "./FetchMixin.js";
17
+ import { EFTargetable } from "./TargetController.ts";
17
18
 
18
19
  const log = debug("ef:elements:EFMedia");
19
20
 
21
+ const freqWeightsCache = new Map<number, Float32Array>();
22
+
23
+ class LRUCache<K, V> {
24
+ private cache = new Map<K, V>();
25
+ private readonly maxSize: number;
26
+
27
+ constructor(maxSize: number) {
28
+ this.maxSize = maxSize;
29
+ }
30
+
31
+ get(key: K): V | undefined {
32
+ const value = this.cache.get(key);
33
+ if (value) {
34
+ // Refresh position by removing and re-adding
35
+ this.cache.delete(key);
36
+ this.cache.set(key, value);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ set(key: K, value: V): void {
42
+ if (this.cache.has(key)) {
43
+ this.cache.delete(key);
44
+ } else if (this.cache.size >= this.maxSize) {
45
+ // Remove oldest entry (first item in map)
46
+ const firstKey = this.cache.keys().next().value;
47
+ if (firstKey) {
48
+ this.cache.delete(firstKey);
49
+ }
50
+ }
51
+ this.cache.set(key, value);
52
+ }
53
+ }
54
+
20
55
  export const deepGetMediaElements = (
21
56
  element: Element,
22
57
  medias: EFMedia[] = [],
@@ -31,9 +66,11 @@ export const deepGetMediaElements = (
31
66
  return medias;
32
67
  };
33
68
 
34
- export class EFMedia extends EFSourceMixin(EFTemporal(FetchMixin(LitElement)), {
35
- assetType: "isobmff_files",
36
- }) {
69
+ export class EFMedia extends EFTargetable(
70
+ EFSourceMixin(EFTemporal(FetchMixin(LitElement)), {
71
+ assetType: "isobmff_files",
72
+ }),
73
+ ) {
37
74
  static styles = [
38
75
  css`
39
76
  :host {
@@ -284,7 +321,8 @@ export class EFMedia extends EFSourceMixin(EFTemporal(FetchMixin(LitElement)), {
284
321
  },
285
322
  });
286
323
 
287
- @state() desiredSeekTimeMs = 0;
324
+ @state()
325
+ desiredSeekTimeMs = 0;
288
326
 
289
327
  protected async executeSeek(seekToMs: number) {
290
328
  this.desiredSeekTimeMs = seekToMs;
@@ -294,7 +332,7 @@ export class EFMedia extends EFSourceMixin(EFTemporal(FetchMixin(LitElement)), {
294
332
  changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
295
333
  ): void {
296
334
  if (changedProperties.has("ownCurrentTimeMs")) {
297
- this.executeSeek(this.trimAdjustedOwnCurrentTimeMs);
335
+ this.executeSeek(this.currentSourceTimeMs);
298
336
  }
299
337
  // TODO: this is copied straight from EFTimegroup.ts
300
338
  // and should be refactored to be shared/reduce bad duplication of
@@ -312,7 +350,6 @@ export class EFMedia extends EFSourceMixin(EFTemporal(FetchMixin(LitElement)), {
312
350
  return;
313
351
  }
314
352
  this.style.display = "";
315
-
316
353
  const animations = this.getAnimations({ subtree: true });
317
354
 
318
355
  this.style.setProperty("--ef-duration", `${this.durationMs}ms`);
@@ -546,4 +583,265 @@ export class EFMedia extends EFSourceMixin(EFTemporal(FetchMixin(LitElement)), {
546
583
  this.trimEndMs,
547
584
  };
548
585
  }
586
+
587
+ @property({ type: Number })
588
+ fftSize = 512; // Default FFT size
589
+
590
+ @property({ type: Number })
591
+ fftDecay = 8; // Default number of frames to analyze
592
+
593
+ private static readonly MIN_DB = -90;
594
+ private static readonly MAX_DB = -20;
595
+ private static readonly DECAY_WEIGHT = 0.7;
596
+
597
+ // Update FREQ_WEIGHTS to use the instance fftSize instead of a static value
598
+ get FREQ_WEIGHTS() {
599
+ if (freqWeightsCache.has(this.fftSize)) {
600
+ // biome-ignore lint/style/noNonNullAssertion: Will exist due to prior has check
601
+ return freqWeightsCache.get(this.fftSize)!;
602
+ }
603
+
604
+ const weights = new Float32Array(this.fftSize / 2).map((_, i) => {
605
+ const frequency = (i * 48000) / this.fftSize;
606
+ if (frequency < 60) return 0.3;
607
+ if (frequency < 250) return 0.4;
608
+ if (frequency < 500) return 0.6;
609
+ if (frequency < 2000) return 0.8;
610
+ if (frequency < 4000) return 1.2;
611
+ if (frequency < 8000) return 1.6;
612
+ return 2.0;
613
+ });
614
+
615
+ freqWeightsCache.set(this.fftSize, weights);
616
+ return weights;
617
+ }
618
+
619
+ #byteTimeDomainCache = new LRUCache<string, Uint8Array>(100);
620
+
621
+ byteTimeDomainTask = new Task(this, {
622
+ autoRun: EF_INTERACTIVE,
623
+ args: () =>
624
+ [
625
+ this.audioBufferTask.status,
626
+ this.currentSourceTimeMs,
627
+ this.fftSize,
628
+ this.fftDecay,
629
+ ] as const,
630
+ task: async () => {
631
+ await this.audioBufferTask.taskComplete;
632
+ if (!this.audioBufferTask.value) return null;
633
+ if (this.currentSourceTimeMs <= 0) return null;
634
+
635
+ const currentTimeMs = this.currentSourceTimeMs;
636
+ const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
637
+ const audioBuffer = this.audioBufferTask.value.buffer;
638
+ const smoothedKey = `${this.fftSize}:${this.fftDecay}:${startOffsetMs}:${currentTimeMs}`;
639
+
640
+ const cachedSmoothedData = this.#byteTimeDomainCache.get(smoothedKey);
641
+ if (cachedSmoothedData) {
642
+ return cachedSmoothedData;
643
+ }
644
+
645
+ const framesData = await Promise.all(
646
+ Array.from({ length: this.fftDecay }, async (_, i) => {
647
+ const frameOffset = i * (1000 / 30);
648
+ const startTime = Math.max(
649
+ 0,
650
+ (currentTimeMs - frameOffset - startOffsetMs) / 1000,
651
+ );
652
+
653
+ const cacheKey = `${this.fftSize}:${startOffsetMs}:${startTime}`;
654
+ const cachedFrame = this.#byteTimeDomainCache.get(cacheKey);
655
+ if (cachedFrame) {
656
+ return cachedFrame;
657
+ }
658
+
659
+ const audioContext = new OfflineAudioContext(
660
+ 2,
661
+ 48000 * (1 / 30),
662
+ 48000,
663
+ );
664
+ const analyser = audioContext.createAnalyser();
665
+ analyser.fftSize = this.fftSize;
666
+
667
+ // Increase gain even more for better signal
668
+ const gainNode = audioContext.createGain();
669
+ gainNode.gain.value = 10.0; // Try a higher gain
670
+
671
+ // More aggressive settings for the analyzer
672
+ analyser.smoothingTimeConstant = 0.4;
673
+ analyser.minDecibels = -90;
674
+ analyser.maxDecibels = -10;
675
+
676
+ const audioBufferSource = audioContext.createBufferSource();
677
+ audioBufferSource.buffer = audioBuffer;
678
+
679
+ // Add a bandpass filter to focus on the most active frequency ranges
680
+ const filter = audioContext.createBiquadFilter();
681
+ filter.type = "bandpass";
682
+ filter.frequency.value = 1000; // Center frequency in Hz
683
+ filter.Q.value = 0.5; // Width of the band
684
+
685
+ audioBufferSource.connect(gainNode);
686
+ gainNode.connect(filter);
687
+ filter.connect(analyser);
688
+ analyser.connect(audioContext.destination);
689
+
690
+ audioBufferSource.start(0, startTime, 1 / 30);
691
+
692
+ try {
693
+ await audioContext.startRendering();
694
+ // Change to time domain data
695
+ const frameData = new Uint8Array(analyser.fftSize);
696
+ analyser.getByteTimeDomainData(frameData);
697
+
698
+ this.#byteTimeDomainCache.set(cacheKey, frameData);
699
+ return frameData;
700
+ } finally {
701
+ audioBufferSource.disconnect();
702
+ analyser.disconnect();
703
+ }
704
+ }),
705
+ );
706
+
707
+ const frameLength = framesData[0]?.length ?? 0;
708
+ const smoothedData = new Uint8Array(frameLength);
709
+
710
+ // Combine frames with decay
711
+ for (let i = 0; i < frameLength; i++) {
712
+ let weightedSum = 0;
713
+ let weightSum = 0;
714
+
715
+ framesData.forEach((frame, frameIndex) => {
716
+ const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
717
+ // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
718
+ weightedSum += frame[i]! * decayWeight;
719
+ weightSum += decayWeight;
720
+ });
721
+
722
+ smoothedData[i] = Math.min(255, Math.round(weightedSum / weightSum));
723
+ }
724
+
725
+ // Remove frequency weighting since we're using time domain data
726
+ // No need to slice the data either since we want the full waveform
727
+
728
+ this.#byteTimeDomainCache.set(
729
+ smoothedKey,
730
+ smoothedData.slice(0, Math.floor(smoothedData.length * 0.8)),
731
+ );
732
+ return smoothedData;
733
+ },
734
+ });
735
+
736
+ #frequencyDataCache = new LRUCache<string, Uint8Array>(100);
737
+
738
+ frequencyDataTask = new Task(this, {
739
+ autoRun: EF_INTERACTIVE,
740
+ args: () =>
741
+ [
742
+ this.audioBufferTask.status,
743
+ this.currentSourceTimeMs,
744
+ this.fftSize, // Add fftSize to dependency array
745
+ this.fftDecay, // Add fftDecay to dependency array
746
+ ] as const,
747
+ task: async () => {
748
+ await this.audioBufferTask.taskComplete;
749
+ if (!this.audioBufferTask.value) return null;
750
+ if (this.currentSourceTimeMs <= 0) return null;
751
+
752
+ const currentTimeMs = this.currentSourceTimeMs;
753
+ const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
754
+ const audioBuffer = this.audioBufferTask.value.buffer;
755
+ const smoothedKey = `${this.fftSize}:${this.fftDecay}:${startOffsetMs}:${currentTimeMs}`;
756
+
757
+ const cachedSmoothedData = this.#frequencyDataCache.get(smoothedKey);
758
+ if (cachedSmoothedData) {
759
+ return cachedSmoothedData;
760
+ }
761
+
762
+ const framesData = await Promise.all(
763
+ Array.from({ length: this.fftDecay }, async (_, i) => {
764
+ const frameOffset = i * (1000 / 30);
765
+ const startTime = Math.max(
766
+ 0,
767
+ (currentTimeMs - frameOffset - startOffsetMs) / 1000,
768
+ );
769
+
770
+ // Cache key for this specific frame
771
+ const cacheKey = `${this.fftSize}:${startOffsetMs}:${startTime}`;
772
+
773
+ // Check cache for this specific frame
774
+ const cachedFrame = this.#frequencyDataCache.get(cacheKey);
775
+ if (cachedFrame) {
776
+ return cachedFrame;
777
+ }
778
+
779
+ const audioContext = new OfflineAudioContext(
780
+ 2,
781
+ 48000 * (1 / 30),
782
+ 48000,
783
+ );
784
+ const analyser = audioContext.createAnalyser();
785
+ analyser.fftSize = this.fftSize;
786
+ analyser.minDecibels = EFMedia.MIN_DB;
787
+ analyser.maxDecibels = EFMedia.MAX_DB;
788
+
789
+ const audioBufferSource = audioContext.createBufferSource();
790
+ audioBufferSource.buffer = audioBuffer;
791
+
792
+ audioBufferSource.connect(analyser);
793
+ analyser.connect(audioContext.destination);
794
+
795
+ audioBufferSource.start(0, startTime, 1 / 30);
796
+
797
+ try {
798
+ await audioContext.startRendering();
799
+ const frameData = new Uint8Array(this.fftSize / 2);
800
+ analyser.getByteFrequencyData(frameData);
801
+
802
+ // Cache this frame's analysis
803
+ this.#frequencyDataCache.set(cacheKey, frameData);
804
+ return frameData;
805
+ } finally {
806
+ audioBufferSource.disconnect();
807
+ analyser.disconnect();
808
+ }
809
+ }),
810
+ );
811
+
812
+ const frameLength = framesData[0]?.length ?? 0;
813
+
814
+ // Combine frames with decay
815
+ const smoothedData = new Uint8Array(frameLength);
816
+ for (let i = 0; i < frameLength; i++) {
817
+ let weightedSum = 0;
818
+ let weightSum = 0;
819
+
820
+ framesData.forEach((frame, frameIndex) => {
821
+ const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
822
+ // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
823
+ weightedSum += frame[i]! * decayWeight;
824
+ weightSum += decayWeight;
825
+ });
826
+
827
+ smoothedData[i] = Math.min(255, Math.round(weightedSum / weightSum));
828
+ }
829
+
830
+ // Apply frequency weights using instance FREQ_WEIGHTS
831
+ smoothedData.forEach((value, i) => {
832
+ // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
833
+ const freqWeight = this.FREQ_WEIGHTS[i]!;
834
+ smoothedData[i] = Math.min(255, Math.round(value * freqWeight));
835
+ });
836
+
837
+ // Only return the lower half of the frequency data
838
+ // The top half is zeroed out, which makes for aesthetically unpleasing waveforms
839
+ const slicedData = smoothedData.slice(
840
+ 0,
841
+ Math.floor(smoothedData.length / 2),
842
+ );
843
+ this.#frequencyDataCache.set(smoothedKey, slicedData);
844
+ return slicedData;
845
+ },
846
+ });
549
847
  }
@@ -1,5 +1,5 @@
1
1
  import { consume, createContext } from "@lit/context";
2
- import type { LitElement, ReactiveController } from "lit";
2
+ import type { LitElement, PropertyValueMap, ReactiveController } from "lit";
3
3
  import { property, state } from "lit/decorators.js";
4
4
  import type { EFTimegroup } from "./EFTimegroup.js";
5
5
 
@@ -13,6 +13,10 @@ export const timegroupContext = createContext<EFTimegroup>(
13
13
 
14
14
  export declare class TemporalMixinInterface {
15
15
  get hasOwnDuration(): boolean;
16
+ /**
17
+ * Whether the element has a duration set as an attribute.
18
+ */
19
+ get hasExplicitDuration(): boolean;
16
20
 
17
21
  /**
18
22
  * Used to trim the start of the media.
@@ -147,18 +151,18 @@ export declare class TemporalMixinInterface {
147
151
  * elements.
148
152
  *
149
153
  * For example, if the media has a `sourcein` value of 10s, when `ownCurrentTimeMs` is 0s,
150
- * `trimAdjustedOwnCurrentTimeMs` will be 10s.
154
+ * `currentSourceTimeMs` will be 10s.
151
155
  *
152
156
  * sourcein=10s sourceout=10s
153
157
  * / / /
154
158
  * |--------|=================|---------|
155
159
  * ^
156
160
  * |_
157
- * trimAdjustedOwnCurrentTimeMs === 10s
161
+ * currentSourceTimeMs === 10s
158
162
  * |_
159
163
  * ownCurrentTimeMs === 0s
160
164
  */
161
- get trimAdjustedOwnCurrentTimeMs(): number;
165
+ get currentSourceTimeMs(): number;
162
166
 
163
167
  set duration(value: string);
164
168
  get duration(): string;
@@ -461,6 +465,10 @@ export const EFTemporal = <T extends Constructor<LitElement>>(
461
465
  return parent as EFTimegroup | undefined;
462
466
  }
463
467
 
468
+ get hasExplicitDuration() {
469
+ return this._durationMs !== undefined;
470
+ }
471
+
464
472
  get hasOwnDuration() {
465
473
  return false;
466
474
  }
@@ -559,6 +567,10 @@ export const EFTemporal = <T extends Constructor<LitElement>>(
559
567
  return this.startTimeMs + this.durationMs;
560
568
  }
561
569
 
570
+ /**
571
+ * The current time of the element within itself.
572
+ * Compare with `currentTimeMs` to see the current time with respect to the root timegroup
573
+ */
562
574
  get ownCurrentTimeMs() {
563
575
  if (this.rootTimegroup) {
564
576
  return Math.min(
@@ -573,7 +585,7 @@ export const EFTemporal = <T extends Constructor<LitElement>>(
573
585
  * Used to calculate the internal currentTimeMs of the element. This is useful
574
586
  * for mapping to internal media time codes for audio/video elements.
575
587
  */
576
- get trimAdjustedOwnCurrentTimeMs() {
588
+ get currentSourceTimeMs() {
577
589
  if (this.rootTimegroup) {
578
590
  if (this.sourceInMs && this.sourceOutMs) {
579
591
  return Math.min(
@@ -613,6 +625,26 @@ export const EFTemporal = <T extends Constructor<LitElement>>(
613
625
  }
614
626
  },
615
627
  });
628
+
629
+ protected updated(
630
+ changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
631
+ ): void {
632
+ super.updated(changedProperties);
633
+ if (
634
+ changedProperties.has("currentTime") ||
635
+ changedProperties.has("ownCurrentTimeMs")
636
+ ) {
637
+ const timelineTimeMs = (this.rootTimegroup ?? this).ownCurrentTimeMs;
638
+ if (
639
+ this.startTimeMs > timelineTimeMs ||
640
+ this.endTimeMs < timelineTimeMs
641
+ ) {
642
+ this.style.display = "none";
643
+ return;
644
+ }
645
+ this.style.display = "";
646
+ }
647
+ }
616
648
  }
617
649
 
618
650
  Object.defineProperty(TemporalMixinClass.prototype, EF_TEMPORAL, {
@@ -39,7 +39,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
39
39
  display: block;
40
40
  width: 100%;
41
41
  height: 100%;
42
- position: relative;
42
+ position: absolute;
43
43
  transform-origin: center center;
44
44
  }
45
45
  `;
@@ -53,7 +53,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
53
53
  type: String,
54
54
  attribute: "mode",
55
55
  })
56
- mode: "fixed" | "sequence" | "contain" = "sequence";
56
+ mode: "fixed" | "sequence" | "contain" = "contain";
57
57
 
58
58
  @property({
59
59
  type: Number,
@@ -67,7 +67,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
67
67
 
68
68
  #resizeObserver?: ResizeObserver;
69
69
 
70
- @property({ type: Number })
70
+ @property({ type: Number, attribute: "currenttime" })
71
71
  set currentTime(time: number) {
72
72
  this.#currentTime = Math.max(0, Math.min(time, this.durationMs / 1000));
73
73
  try {
@@ -227,10 +227,9 @@ export class EFTimegroup extends EFTemporal(LitElement) {
227
227
  * in calculations and it was not clear why.
228
228
  */
229
229
  async waitForMediaDurations() {
230
+ const mediaElements = deepGetMediaElements(this);
230
231
  return await Promise.all(
231
- deepGetMediaElements(this).map(
232
- (media) => media.initSegmentsLoader.taskComplete,
233
- ),
232
+ mediaElements.map((m) => m.trackFragmentIndexLoader.taskComplete),
234
233
  );
235
234
  }
236
235
 
@@ -261,7 +260,6 @@ export class EFTimegroup extends EFTemporal(LitElement) {
261
260
  return;
262
261
  }
263
262
  this.style.display = "";
264
-
265
263
  const animations = this.getAnimations({ subtree: true });
266
264
  this.style.setProperty("--ef-duration", `${this.durationMs}ms`);
267
265
  this.style.setProperty(