@editframe/elements 0.15.0-beta.9 → 0.16.0-beta.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.
Files changed (52) hide show
  1. package/dist/EF_FRAMEGEN.d.ts +14 -10
  2. package/dist/EF_FRAMEGEN.js +17 -28
  3. package/dist/elements/EFCaptions.js +0 -7
  4. package/dist/elements/EFImage.js +0 -4
  5. package/dist/elements/EFMedia.d.ts +13 -8
  6. package/dist/elements/EFMedia.js +163 -146
  7. package/dist/elements/EFSourceMixin.js +2 -1
  8. package/dist/elements/EFTemporal.browsertest.d.ts +4 -3
  9. package/dist/elements/EFTemporal.d.ts +14 -11
  10. package/dist/elements/EFTemporal.js +63 -87
  11. package/dist/elements/EFTimegroup.d.ts +2 -4
  12. package/dist/elements/EFTimegroup.js +15 -103
  13. package/dist/elements/EFVideo.js +3 -1
  14. package/dist/elements/EFWaveform.d.ts +1 -1
  15. package/dist/elements/EFWaveform.js +11 -28
  16. package/dist/elements/durationConverter.d.ts +8 -8
  17. package/dist/elements/durationConverter.js +2 -2
  18. package/dist/elements/updateAnimations.d.ts +9 -0
  19. package/dist/elements/updateAnimations.js +62 -0
  20. package/dist/getRenderInfo.d.ts +51 -0
  21. package/dist/getRenderInfo.js +72 -0
  22. package/dist/gui/EFFilmstrip.js +7 -16
  23. package/dist/gui/EFFitScale.d.ts +27 -0
  24. package/dist/gui/EFFitScale.js +138 -0
  25. package/dist/gui/EFWorkbench.d.ts +2 -5
  26. package/dist/gui/EFWorkbench.js +11 -56
  27. package/dist/gui/TWMixin.css.js +1 -1
  28. package/dist/gui/TWMixin.js +14 -2
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +6 -1
  31. package/dist/style.css +3 -3
  32. package/package.json +4 -3
  33. package/src/elements/EFCaptions.browsertest.ts +2 -2
  34. package/src/elements/EFCaptions.ts +0 -7
  35. package/src/elements/EFImage.browsertest.ts +2 -2
  36. package/src/elements/EFImage.ts +0 -4
  37. package/src/elements/EFMedia.browsertest.ts +14 -14
  38. package/src/elements/EFMedia.ts +219 -182
  39. package/src/elements/EFSourceMixin.ts +4 -4
  40. package/src/elements/EFTemporal.browsertest.ts +64 -31
  41. package/src/elements/EFTemporal.ts +99 -119
  42. package/src/elements/EFTimegroup.ts +15 -133
  43. package/src/elements/EFVideo.ts +3 -1
  44. package/src/elements/EFWaveform.ts +10 -44
  45. package/src/elements/durationConverter.ts +9 -4
  46. package/src/elements/updateAnimations.ts +88 -0
  47. package/src/gui/ContextMixin.ts +0 -3
  48. package/src/gui/EFFilmstrip.ts +7 -16
  49. package/src/gui/EFFitScale.ts +152 -0
  50. package/src/gui/EFWorkbench.ts +16 -65
  51. package/src/gui/TWMixin.ts +19 -2
  52. package/types.json +1 -1
@@ -10,11 +10,11 @@ import type { TrackFragmentIndex, TrackSegment } from "@editframe/assets";
10
10
  import { VideoAsset } from "@editframe/assets/EncodedAsset.js";
11
11
  import { MP4File } from "@editframe/assets/MP4File.js";
12
12
  import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
13
- import { EF_RENDERING } from "../EF_RENDERING.js";
14
13
  import { EFSourceMixin } from "./EFSourceMixin.js";
15
- import { EFTemporal, isEFTemporal } from "./EFTemporal.js";
14
+ import { EFTemporal } from "./EFTemporal.js";
16
15
  import { FetchMixin } from "./FetchMixin.js";
17
16
  import { EFTargetable } from "./TargetController.ts";
17
+ import { updateAnimations } from "./updateAnimations.ts";
18
18
 
19
19
  const log = debug("ef:elements:EFMedia");
20
20
 
@@ -102,9 +102,6 @@ export class EFMedia extends EFTargetable(
102
102
 
103
103
  fragmentIndexPath() {
104
104
  if (this.assetId) {
105
- if (EF_RENDERING()) {
106
- return `editframe://api/v1/isobmff_files/${this.assetId}/index`;
107
- }
108
105
  return `${this.apiHost}/api/v1/isobmff_files/${this.assetId}/index`;
109
106
  }
110
107
  return `/@ef-track-fragment-index/${this.src ?? ""}`;
@@ -112,9 +109,6 @@ export class EFMedia extends EFTargetable(
112
109
 
113
110
  fragmentTrackPath(trackId: string) {
114
111
  if (this.assetId) {
115
- if (EF_RENDERING()) {
116
- return `editframe://api/v1/isobmff_tracks/${this.assetId}/${trackId}`;
117
- }
118
112
  return `${this.apiHost}/api/v1/isobmff_tracks/${this.assetId}/${trackId}`;
119
113
  }
120
114
  // trackId is only specified as a query in the @ef-track url shape
@@ -126,12 +120,20 @@ export class EFMedia extends EFTargetable(
126
120
  public trackFragmentIndexLoader = new Task(this, {
127
121
  args: () => [this.fragmentIndexPath(), this.fetch] as const,
128
122
  task: async ([fragmentIndexPath, fetch], { signal }) => {
129
- const response = await fetch(fragmentIndexPath, { signal });
130
- return (await response.json()) as Record<number, TrackFragmentIndex>;
123
+ try {
124
+ const response = await fetch(fragmentIndexPath, { signal });
125
+
126
+ return (await response.json()) as Record<number, TrackFragmentIndex>;
127
+ } catch (error) {
128
+ log("Failed to load track fragment index", error);
129
+ return undefined;
130
+ }
131
131
  },
132
132
  onComplete: () => {
133
+ this.requestUpdate("intrinsicDurationMs");
133
134
  this.requestUpdate("ownCurrentTimeMs");
134
135
  this.rootTimegroup?.requestUpdate("ownCurrentTimeMs");
136
+ this.rootTimegroup?.requestUpdate("durationMs");
135
137
  },
136
138
  });
137
139
 
@@ -341,75 +343,7 @@ export class EFMedia extends EFTargetable(
341
343
  changedProperties.has("currentTime") ||
342
344
  changedProperties.has("ownCurrentTimeMs")
343
345
  ) {
344
- const timelineTimeMs = (this.rootTimegroup ?? this).currentTimeMs;
345
- if (
346
- this.startTimeMs > timelineTimeMs ||
347
- this.endTimeMs < timelineTimeMs
348
- ) {
349
- this.style.display = "none";
350
- return;
351
- }
352
- this.style.display = "";
353
- const animations = this.getAnimations({ subtree: true });
354
-
355
- this.style.setProperty("--ef-duration", `${this.durationMs}ms`);
356
- this.style.setProperty(
357
- "--ef-transition-duration",
358
- `${this.parentTimegroup?.overlapMs ?? 0}ms`,
359
- );
360
- this.style.setProperty(
361
- "--ef-transition-out-start",
362
- `${this.durationMs - (this.parentTimegroup?.overlapMs ?? 0)}ms`,
363
- );
364
-
365
- for (const animation of animations) {
366
- if (animation.playState === "running") {
367
- animation.pause();
368
- }
369
- const effect = animation.effect;
370
- if (!(effect && effect instanceof KeyframeEffect)) {
371
- return;
372
- }
373
- const target = effect.target;
374
- // TODO: better generalize work avoidance for temporal elements
375
- if (!target) {
376
- return;
377
- }
378
- if (target.closest("ef-video, ef-audio") !== this) {
379
- return;
380
- }
381
-
382
- // Important to avoid going to the end of the animation
383
- // or it will reset awkwardly.
384
- if (isEFTemporal(target)) {
385
- const timing = effect.getTiming();
386
- const duration = Number(timing.duration) ?? 0;
387
- const delay = Number(timing.delay);
388
- const newTime = Math.floor(
389
- Math.min(target.ownCurrentTimeMs, duration - 1 + delay),
390
- );
391
- if (Number.isNaN(newTime)) {
392
- return;
393
- }
394
- animation.currentTime = newTime;
395
- } else if (target) {
396
- const nearestTimegroup = target.closest("ef-timegroup");
397
- if (!nearestTimegroup) {
398
- return;
399
- }
400
- const timing = effect.getTiming();
401
- const duration = Number(timing.duration) ?? 0;
402
- const delay = Number(timing.delay);
403
- const newTime = Math.floor(
404
- Math.min(nearestTimegroup.ownCurrentTimeMs, duration - 1 + delay),
405
- );
406
-
407
- if (Number.isNaN(newTime)) {
408
- return;
409
- }
410
- animation.currentTime = newTime;
411
- }
412
- }
346
+ updateAnimations(this);
413
347
  }
414
348
  }
415
349
 
@@ -417,7 +351,7 @@ export class EFMedia extends EFTargetable(
417
351
  return true;
418
352
  }
419
353
 
420
- get durationMs() {
354
+ get intrinsicDurationMs() {
421
355
  if (!this.trackFragmentIndexLoader.value) {
422
356
  return 0;
423
357
  }
@@ -430,39 +364,7 @@ export class EFMedia extends EFTargetable(
430
364
  if (durations.length === 0) {
431
365
  return 0;
432
366
  }
433
- if (
434
- this.sourceInMs &&
435
- this.sourceOutMs &&
436
- this.sourceOutMs > this.sourceInMs
437
- ) {
438
- return Math.max(this.sourceOutMs - this.sourceInMs);
439
- }
440
- if (this.sourceInMs) {
441
- return (
442
- Math.max(...durations) -
443
- this.trimStartMs -
444
- this.trimEndMs -
445
- this.sourceInMs
446
- );
447
- }
448
- if (this.sourceOutMs) {
449
- return (
450
- Math.max(...durations) -
451
- this.trimStartMs -
452
- this.trimEndMs -
453
- this.sourceOutMs
454
- );
455
- }
456
- if (this.sourceInMs && this.sourceOutMs) {
457
- return (
458
- Math.max(...durations) -
459
- this.trimStartMs -
460
- this.trimEndMs -
461
- this.sourceOutMs -
462
- this.sourceInMs
463
- );
464
- }
465
- return Math.max(...durations) - this.trimStartMs - this.trimEndMs;
367
+ return Math.max(...durations);
466
368
  }
467
369
 
468
370
  #audioContext = new OfflineAudioContext(2, 48000 / 30, 48000);
@@ -500,13 +402,15 @@ export class EFMedia extends EFTargetable(
500
402
  async fetchAudioSpanningTime(fromMs: number, toMs: number) {
501
403
  // Adjust range for track's own time
502
404
  if (this.sourceInMs) {
503
- fromMs -= this.startTimeMs - this.trimStartMs - this.sourceInMs;
405
+ fromMs -=
406
+ this.startTimeMs - (this.trimStartMs ?? 0) - (this.sourceInMs ?? 0);
504
407
  }
505
408
  if (this.sourceOutMs) {
506
- toMs -= this.startTimeMs - this.trimStartMs - this.sourceOutMs;
409
+ toMs -=
410
+ this.startTimeMs - (this.trimStartMs ?? 0) - (this.sourceOutMs ?? 0);
507
411
  }
508
- fromMs -= this.startTimeMs - this.trimStartMs;
509
- toMs -= this.startTimeMs - this.trimStartMs;
412
+ fromMs -= this.startTimeMs - (this.trimStartMs ?? 0);
413
+ toMs -= this.startTimeMs - (this.trimStartMs ?? 0);
510
414
 
511
415
  await this.trackFragmentIndexLoader.taskComplete;
512
416
  const audioTrackId = this.defaultAudioTrackId;
@@ -576,22 +480,51 @@ export class EFMedia extends EFTargetable(
576
480
  blob: audioBlob,
577
481
  startMs:
578
482
  (firstFragment.dts / audioTrackIndex.timescale) * 1000 -
579
- this.trimStartMs,
483
+ (this.trimStartMs ?? 0),
580
484
  endMs:
581
485
  (lastFragment.dts / audioTrackIndex.timescale) * 1000 +
582
486
  (lastFragment.duration / audioTrackIndex.timescale) * 1000 -
583
- this.trimEndMs,
487
+ (this.trimEndMs ?? 0),
584
488
  };
585
489
  }
586
490
 
587
- @property({ type: Number })
588
- fftSize = 512; // Default FFT size
491
+ set fftSize(value: number) {
492
+ const oldValue = this.fftSize;
493
+ this.setAttribute("fft-size", String(value));
494
+ this.requestUpdate("fft-size", oldValue);
495
+ }
589
496
 
590
- @property({ type: Number })
591
- fftDecay = 8; // Default number of frames to analyze
497
+ set fftDecay(value: number) {
498
+ const oldValue = this.fftDecay;
499
+ this.setAttribute("fft-decay", String(value));
500
+ this.requestUpdate("fft-decay", oldValue);
501
+ }
502
+
503
+ get fftSize() {
504
+ return Number.parseInt(this.getAttribute("fft-size") ?? "128", 10);
505
+ }
506
+
507
+ get fftDecay() {
508
+ return Number.parseInt(this.getAttribute("fft-decay") ?? "8", 10);
509
+ }
510
+
511
+ set interpolateFrequencies(value: boolean) {
512
+ const oldValue = this.interpolateFrequencies;
513
+ this.setAttribute("interpolate-frequencies", String(value));
514
+ this.requestUpdate("interpolate-frequencies", oldValue);
515
+ }
516
+
517
+ get interpolateFrequencies() {
518
+ return this.getAttribute("interpolate-frequencies") !== "false";
519
+ }
520
+
521
+ get shouldInterpolateFrequencies() {
522
+ if (this.hasAttribute("interpolate-frequencies")) {
523
+ return this.getAttribute("interpolate-frequencies") !== "false";
524
+ }
525
+ return false;
526
+ }
592
527
 
593
- private static readonly MIN_DB = -90;
594
- private static readonly MAX_DB = -20;
595
528
  private static readonly DECAY_WEIGHT = 0.7;
596
529
 
597
530
  // Update FREQ_WEIGHTS to use the instance fftSize instead of a static value
@@ -626,6 +559,8 @@ export class EFMedia extends EFTargetable(
626
559
  this.currentSourceTimeMs,
627
560
  this.fftSize,
628
561
  this.fftDecay,
562
+ this.fftGain,
563
+ this.shouldInterpolateFrequencies,
629
564
  ] as const,
630
565
  task: async () => {
631
566
  await this.audioBufferTask.taskComplete;
@@ -635,100 +570,107 @@ export class EFMedia extends EFTargetable(
635
570
  const currentTimeMs = this.currentSourceTimeMs;
636
571
  const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
637
572
  const audioBuffer = this.audioBufferTask.value.buffer;
638
- const smoothedKey = `${this.fftSize}:${this.fftDecay}:${startOffsetMs}:${currentTimeMs}`;
639
573
 
640
- const cachedSmoothedData = this.#byteTimeDomainCache.get(smoothedKey);
641
- if (cachedSmoothedData) {
642
- return cachedSmoothedData;
643
- }
574
+ const smoothedKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftDecay}:${this.fftGain}:${startOffsetMs}:${currentTimeMs}`;
575
+ const cachedData = this.#byteTimeDomainCache.get(smoothedKey);
576
+ if (cachedData) return cachedData;
644
577
 
578
+ // Process multiple frames with decay, similar to the reference code
645
579
  const framesData = await Promise.all(
646
- Array.from({ length: this.fftDecay }, async (_, i) => {
647
- const frameOffset = i * (1000 / 30);
580
+ Array.from({ length: this.fftDecay }, async (_, frameIndex) => {
581
+ const frameOffset = frameIndex * (1000 / 30);
648
582
  const startTime = Math.max(
649
583
  0,
650
584
  (currentTimeMs - frameOffset - startOffsetMs) / 1000,
651
585
  );
652
586
 
653
- const cacheKey = `${this.fftSize}:${startOffsetMs}:${startTime}`;
587
+ const cacheKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftGain}:${startOffsetMs}:${startTime}`;
654
588
  const cachedFrame = this.#byteTimeDomainCache.get(cacheKey);
655
- if (cachedFrame) {
656
- return cachedFrame;
657
- }
589
+ if (cachedFrame) return cachedFrame;
658
590
 
659
591
  const audioContext = new OfflineAudioContext(
660
592
  2,
661
593
  48000 * (1 / 30),
662
594
  48000,
663
595
  );
664
- const analyser = audioContext.createAnalyser();
665
- analyser.fftSize = this.fftSize;
666
596
 
667
- // Increase gain even more for better signal
668
- const gainNode = audioContext.createGain();
669
- gainNode.gain.value = 10.0; // Try a higher gain
597
+ const source = audioContext.createBufferSource();
598
+ source.buffer = audioBuffer;
670
599
 
671
- // More aggressive settings for the analyzer
672
- analyser.smoothingTimeConstant = 0.4;
600
+ // Create analyzer for PCM data
601
+ const analyser = audioContext.createAnalyser();
602
+ analyser.fftSize = this.fftSize; // Ensure enough samples
673
603
  analyser.minDecibels = -90;
674
- analyser.maxDecibels = -10;
675
-
676
- const audioBufferSource = audioContext.createBufferSource();
677
- audioBufferSource.buffer = audioBuffer;
604
+ analyser.maxDecibels = -20;
678
605
 
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
606
+ const gainNode = audioContext.createGain();
607
+ gainNode.gain.value = this.fftGain; // Amplify the signal
684
608
 
685
- audioBufferSource.connect(gainNode);
686
- gainNode.connect(filter);
687
- filter.connect(analyser);
609
+ source.connect(gainNode);
610
+ gainNode.connect(analyser);
688
611
  analyser.connect(audioContext.destination);
689
612
 
690
- audioBufferSource.start(0, startTime, 1 / 30);
613
+ source.start(0, startTime, 1 / 30);
691
614
 
615
+ const dataLength = analyser.fftSize / 2;
692
616
  try {
693
617
  await audioContext.startRendering();
694
- // Change to time domain data
695
- const frameData = new Uint8Array(analyser.fftSize);
618
+ const frameData = new Uint8Array(dataLength);
696
619
  analyser.getByteTimeDomainData(frameData);
697
620
 
698
- this.#byteTimeDomainCache.set(cacheKey, frameData);
699
- return frameData;
621
+ // const points = frameData;
622
+ // Calculate RMS and midpoint values
623
+ const points = new Uint8Array(dataLength);
624
+ for (let i = 0; i < dataLength; i++) {
625
+ const pointSamples = frameData.slice(
626
+ i * (frameData.length / dataLength),
627
+ (i + 1) * (frameData.length / dataLength),
628
+ );
629
+
630
+ // Calculate RMS while preserving sign
631
+ const rms = Math.sqrt(
632
+ pointSamples.reduce((sum, sample) => {
633
+ const normalized = (sample - 128) / 128;
634
+ return sum + normalized * normalized;
635
+ }, 0) / pointSamples.length,
636
+ );
637
+
638
+ // Get average sign of the samples to determine direction
639
+ const avgSign = Math.sign(
640
+ pointSamples.reduce((sum, sample) => sum + (sample - 128), 0),
641
+ );
642
+
643
+ // Convert RMS back to byte range, preserving direction
644
+ points[i] = Math.min(255, Math.round(128 + avgSign * rms * 128));
645
+ }
646
+
647
+ this.#byteTimeDomainCache.set(cacheKey, points);
648
+ return points;
700
649
  } finally {
701
- audioBufferSource.disconnect();
650
+ source.disconnect();
702
651
  analyser.disconnect();
703
652
  }
704
653
  }),
705
654
  );
706
655
 
656
+ // Combine frames with decay weighting
707
657
  const frameLength = framesData[0]?.length ?? 0;
708
658
  const smoothedData = new Uint8Array(frameLength);
709
659
 
710
- // Combine frames with decay
711
660
  for (let i = 0; i < frameLength; i++) {
712
661
  let weightedSum = 0;
713
662
  let weightSum = 0;
714
663
 
715
664
  framesData.forEach((frame, frameIndex) => {
716
665
  const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
717
- // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
718
- weightedSum += frame[i]! * decayWeight;
666
+ weightedSum += (frame[i] ?? 0) * decayWeight;
719
667
  weightSum += decayWeight;
720
668
  });
721
669
 
722
670
  smoothedData[i] = Math.min(255, Math.round(weightedSum / weightSum));
723
671
  }
724
672
 
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
- );
673
+ this.#byteTimeDomainCache.set(smoothedKey, smoothedData);
732
674
  return smoothedData;
733
675
  },
734
676
  });
@@ -741,8 +683,10 @@ export class EFMedia extends EFTargetable(
741
683
  [
742
684
  this.audioBufferTask.status,
743
685
  this.currentSourceTimeMs,
744
- this.fftSize, // Add fftSize to dependency array
745
- this.fftDecay, // Add fftDecay to dependency array
686
+ this.fftSize,
687
+ this.fftDecay,
688
+ this.fftGain,
689
+ this.shouldInterpolateFrequencies,
746
690
  ] as const,
747
691
  task: async () => {
748
692
  await this.audioBufferTask.taskComplete;
@@ -752,7 +696,7 @@ export class EFMedia extends EFTargetable(
752
696
  const currentTimeMs = this.currentSourceTimeMs;
753
697
  const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
754
698
  const audioBuffer = this.audioBufferTask.value.buffer;
755
- const smoothedKey = `${this.fftSize}:${this.fftDecay}:${startOffsetMs}:${currentTimeMs}`;
699
+ const smoothedKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftDecay}:${this.fftGain}:${startOffsetMs}:${currentTimeMs}`;
756
700
 
757
701
  const cachedSmoothedData = this.#frequencyDataCache.get(smoothedKey);
758
702
  if (cachedSmoothedData) {
@@ -768,7 +712,7 @@ export class EFMedia extends EFTargetable(
768
712
  );
769
713
 
770
714
  // Cache key for this specific frame
771
- const cacheKey = `${this.fftSize}:${startOffsetMs}:${startTime}`;
715
+ const cacheKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftGain}:${startOffsetMs}:${startTime}`;
772
716
 
773
717
  // Check cache for this specific frame
774
718
  const cachedFrame = this.#frequencyDataCache.get(cacheKey);
@@ -783,13 +727,23 @@ export class EFMedia extends EFTargetable(
783
727
  );
784
728
  const analyser = audioContext.createAnalyser();
785
729
  analyser.fftSize = this.fftSize;
786
- analyser.minDecibels = EFMedia.MIN_DB;
787
- analyser.maxDecibels = EFMedia.MAX_DB;
730
+ analyser.minDecibels = -90;
731
+ analyser.maxDecibels = -10;
732
+
733
+ const gainNode = audioContext.createGain();
734
+ gainNode.gain.value = this.fftGain;
735
+
736
+ const filter = audioContext.createBiquadFilter();
737
+ filter.type = "bandpass";
738
+ filter.frequency.value = 15000;
739
+ filter.Q.value = 0.05;
788
740
 
789
741
  const audioBufferSource = audioContext.createBufferSource();
790
742
  audioBufferSource.buffer = audioBuffer;
791
743
 
792
- audioBufferSource.connect(analyser);
744
+ audioBufferSource.connect(filter);
745
+ filter.connect(gainNode);
746
+ gainNode.connect(analyser);
793
747
  analyser.connect(audioContext.destination);
794
748
 
795
749
  audioBufferSource.start(0, startTime, 1 / 30);
@@ -819,7 +773,7 @@ export class EFMedia extends EFTargetable(
819
773
 
820
774
  framesData.forEach((frame, frameIndex) => {
821
775
  const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
822
- // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
776
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
823
777
  weightedSum += frame[i]! * decayWeight;
824
778
  weightSum += decayWeight;
825
779
  });
@@ -829,7 +783,7 @@ export class EFMedia extends EFTargetable(
829
783
 
830
784
  // Apply frequency weights using instance FREQ_WEIGHTS
831
785
  smoothedData.forEach((value, i) => {
832
- // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
786
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
833
787
  const freqWeight = this.FREQ_WEIGHTS[i]!;
834
788
  smoothedData[i] = Math.min(255, Math.round(value * freqWeight));
835
789
  });
@@ -840,8 +794,91 @@ export class EFMedia extends EFTargetable(
840
794
  0,
841
795
  Math.floor(smoothedData.length / 2),
842
796
  );
843
- this.#frequencyDataCache.set(smoothedKey, slicedData);
844
- return slicedData;
797
+ const processedData = this.shouldInterpolateFrequencies
798
+ ? processFFTData(slicedData)
799
+ : slicedData;
800
+ this.#frequencyDataCache.set(smoothedKey, processedData);
801
+ return processedData;
845
802
  },
846
803
  });
804
+
805
+ set fftGain(value: number) {
806
+ const oldValue = this.fftGain;
807
+ this.setAttribute("fft-gain", String(value));
808
+ this.requestUpdate("fft-gain", oldValue);
809
+ }
810
+
811
+ get fftGain() {
812
+ return Number.parseFloat(this.getAttribute("fft-gain") ?? "3.0");
813
+ }
814
+ }
815
+
816
+ function processFFTData(fftData: Uint8Array, zeroThresholdPercent = 0.1) {
817
+ // Step 1: Determine the threshold for zeros
818
+ const totalBins = fftData.length;
819
+ const zeroThresholdCount = Math.floor(totalBins * zeroThresholdPercent);
820
+
821
+ // Step 2: Interrogate the FFT output to find the cutoff point
822
+ let zeroCount = 0;
823
+ let cutoffIndex = totalBins; // Default to the end of the array
824
+
825
+ for (let i = totalBins - 1; i >= 0; i--) {
826
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
827
+ if (fftData[i]! < 10) {
828
+ zeroCount++;
829
+ } else {
830
+ // If we encounter a non-zero value, we can stop
831
+ if (zeroCount >= zeroThresholdCount) {
832
+ cutoffIndex = i + 1; // Include this index
833
+ break;
834
+ }
835
+ }
836
+ }
837
+
838
+ if (cutoffIndex < zeroThresholdCount) {
839
+ return fftData;
840
+ }
841
+
842
+ // Step 3: Resample the "good" portion of the data
843
+ const goodData = fftData.slice(0, cutoffIndex);
844
+ const resampledData = interpolateData(goodData, fftData.length);
845
+
846
+ // Step 4: Attenuate the top 10% of interpolated samples
847
+ const attenuationStartIndex = Math.floor(totalBins * 0.9);
848
+ for (let i = attenuationStartIndex; i < totalBins; i++) {
849
+ // Calculate attenuation factor that goes from 1 to 0 over the top 10%
850
+ const attenuationProgress =
851
+ (i - attenuationStartIndex) / (totalBins - attenuationStartIndex) + 0.2;
852
+ const attenuationFactor = Math.max(0, 1 - attenuationProgress);
853
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
854
+ resampledData[i] = Math.floor(resampledData[i]! * attenuationFactor);
855
+ }
856
+
857
+ return resampledData;
858
+ }
859
+
860
+ function interpolateData(data: Uint8Array, targetSize: number) {
861
+ const resampled = new Uint8Array(targetSize);
862
+ const dataLength = data.length;
863
+
864
+ for (let i = 0; i < targetSize; i++) {
865
+ // Calculate the corresponding index in the original data
866
+ const ratio = (i / (targetSize - 1)) * (dataLength - 1);
867
+ const index = Math.floor(ratio);
868
+ const fraction = ratio - index;
869
+
870
+ // Handle edge cases
871
+ if (index >= dataLength - 1) {
872
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
873
+ resampled[i] = data[dataLength - 1]!; // Last value
874
+ } else {
875
+ // Linear interpolation
876
+ resampled[i] = Math.round(
877
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
878
+ data[index]! * (1 - fraction) + data[index + 1]! * fraction,
879
+ );
880
+ }
881
+ }
882
+
883
+ return resampled;
847
884
  }
@@ -18,12 +18,12 @@ export function EFSourceMixin<T extends Constructor<LitElement>>(
18
18
  ) {
19
19
  class EFSourceElement extends superClass {
20
20
  get apiHost() {
21
- return (
21
+ const apiHost =
22
22
  this.closest("ef-configuration")?.apiHost ??
23
23
  this.closest("ef-workbench")?.apiHost ??
24
- this.closest("ef-preview")?.apiHost ??
25
- "https://editframe.dev"
26
- );
24
+ this.closest("ef-preview")?.apiHost;
25
+
26
+ return apiHost || "https://editframe.dev";
27
27
  }
28
28
 
29
29
  @property({ type: String })