@editframe/elements 0.15.0-beta.9 → 0.16.0-beta.0

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 +164 -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 +13 -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 +220 -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 +18 -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,21 @@ 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");
135
+ console.log("Requesting update for durationMs", this, this.rootTimegroup);
134
136
  this.rootTimegroup?.requestUpdate("ownCurrentTimeMs");
137
+ this.rootTimegroup?.requestUpdate("durationMs");
135
138
  },
136
139
  });
137
140
 
@@ -341,75 +344,7 @@ export class EFMedia extends EFTargetable(
341
344
  changedProperties.has("currentTime") ||
342
345
  changedProperties.has("ownCurrentTimeMs")
343
346
  ) {
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
- }
347
+ updateAnimations(this);
413
348
  }
414
349
  }
415
350
 
@@ -417,7 +352,7 @@ export class EFMedia extends EFTargetable(
417
352
  return true;
418
353
  }
419
354
 
420
- get durationMs() {
355
+ get intrinsicDurationMs() {
421
356
  if (!this.trackFragmentIndexLoader.value) {
422
357
  return 0;
423
358
  }
@@ -430,39 +365,7 @@ export class EFMedia extends EFTargetable(
430
365
  if (durations.length === 0) {
431
366
  return 0;
432
367
  }
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;
368
+ return Math.max(...durations);
466
369
  }
467
370
 
468
371
  #audioContext = new OfflineAudioContext(2, 48000 / 30, 48000);
@@ -500,13 +403,15 @@ export class EFMedia extends EFTargetable(
500
403
  async fetchAudioSpanningTime(fromMs: number, toMs: number) {
501
404
  // Adjust range for track's own time
502
405
  if (this.sourceInMs) {
503
- fromMs -= this.startTimeMs - this.trimStartMs - this.sourceInMs;
406
+ fromMs -=
407
+ this.startTimeMs - (this.trimStartMs ?? 0) - (this.sourceInMs ?? 0);
504
408
  }
505
409
  if (this.sourceOutMs) {
506
- toMs -= this.startTimeMs - this.trimStartMs - this.sourceOutMs;
410
+ toMs -=
411
+ this.startTimeMs - (this.trimStartMs ?? 0) - (this.sourceOutMs ?? 0);
507
412
  }
508
- fromMs -= this.startTimeMs - this.trimStartMs;
509
- toMs -= this.startTimeMs - this.trimStartMs;
413
+ fromMs -= this.startTimeMs - (this.trimStartMs ?? 0);
414
+ toMs -= this.startTimeMs - (this.trimStartMs ?? 0);
510
415
 
511
416
  await this.trackFragmentIndexLoader.taskComplete;
512
417
  const audioTrackId = this.defaultAudioTrackId;
@@ -576,22 +481,51 @@ export class EFMedia extends EFTargetable(
576
481
  blob: audioBlob,
577
482
  startMs:
578
483
  (firstFragment.dts / audioTrackIndex.timescale) * 1000 -
579
- this.trimStartMs,
484
+ (this.trimStartMs ?? 0),
580
485
  endMs:
581
486
  (lastFragment.dts / audioTrackIndex.timescale) * 1000 +
582
487
  (lastFragment.duration / audioTrackIndex.timescale) * 1000 -
583
- this.trimEndMs,
488
+ (this.trimEndMs ?? 0),
584
489
  };
585
490
  }
586
491
 
587
- @property({ type: Number })
588
- fftSize = 512; // Default FFT size
492
+ set fftSize(value: number) {
493
+ const oldValue = this.fftSize;
494
+ this.setAttribute("fft-size", String(value));
495
+ this.requestUpdate("fft-size", oldValue);
496
+ }
589
497
 
590
- @property({ type: Number })
591
- fftDecay = 8; // Default number of frames to analyze
498
+ set fftDecay(value: number) {
499
+ const oldValue = this.fftDecay;
500
+ this.setAttribute("fft-decay", String(value));
501
+ this.requestUpdate("fft-decay", oldValue);
502
+ }
503
+
504
+ get fftSize() {
505
+ return Number.parseInt(this.getAttribute("fft-size") ?? "128", 10);
506
+ }
507
+
508
+ get fftDecay() {
509
+ return Number.parseInt(this.getAttribute("fft-decay") ?? "8", 10);
510
+ }
511
+
512
+ set interpolateFrequencies(value: boolean) {
513
+ const oldValue = this.interpolateFrequencies;
514
+ this.setAttribute("interpolate-frequencies", String(value));
515
+ this.requestUpdate("interpolate-frequencies", oldValue);
516
+ }
517
+
518
+ get interpolateFrequencies() {
519
+ return this.getAttribute("interpolate-frequencies") !== "false";
520
+ }
521
+
522
+ get shouldInterpolateFrequencies() {
523
+ if (this.hasAttribute("interpolate-frequencies")) {
524
+ return this.getAttribute("interpolate-frequencies") !== "false";
525
+ }
526
+ return false;
527
+ }
592
528
 
593
- private static readonly MIN_DB = -90;
594
- private static readonly MAX_DB = -20;
595
529
  private static readonly DECAY_WEIGHT = 0.7;
596
530
 
597
531
  // Update FREQ_WEIGHTS to use the instance fftSize instead of a static value
@@ -626,6 +560,8 @@ export class EFMedia extends EFTargetable(
626
560
  this.currentSourceTimeMs,
627
561
  this.fftSize,
628
562
  this.fftDecay,
563
+ this.fftGain,
564
+ this.shouldInterpolateFrequencies,
629
565
  ] as const,
630
566
  task: async () => {
631
567
  await this.audioBufferTask.taskComplete;
@@ -635,100 +571,107 @@ export class EFMedia extends EFTargetable(
635
571
  const currentTimeMs = this.currentSourceTimeMs;
636
572
  const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
637
573
  const audioBuffer = this.audioBufferTask.value.buffer;
638
- const smoothedKey = `${this.fftSize}:${this.fftDecay}:${startOffsetMs}:${currentTimeMs}`;
639
574
 
640
- const cachedSmoothedData = this.#byteTimeDomainCache.get(smoothedKey);
641
- if (cachedSmoothedData) {
642
- return cachedSmoothedData;
643
- }
575
+ const smoothedKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftDecay}:${this.fftGain}:${startOffsetMs}:${currentTimeMs}`;
576
+ const cachedData = this.#byteTimeDomainCache.get(smoothedKey);
577
+ if (cachedData) return cachedData;
644
578
 
579
+ // Process multiple frames with decay, similar to the reference code
645
580
  const framesData = await Promise.all(
646
- Array.from({ length: this.fftDecay }, async (_, i) => {
647
- const frameOffset = i * (1000 / 30);
581
+ Array.from({ length: this.fftDecay }, async (_, frameIndex) => {
582
+ const frameOffset = frameIndex * (1000 / 30);
648
583
  const startTime = Math.max(
649
584
  0,
650
585
  (currentTimeMs - frameOffset - startOffsetMs) / 1000,
651
586
  );
652
587
 
653
- const cacheKey = `${this.fftSize}:${startOffsetMs}:${startTime}`;
588
+ const cacheKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftGain}:${startOffsetMs}:${startTime}`;
654
589
  const cachedFrame = this.#byteTimeDomainCache.get(cacheKey);
655
- if (cachedFrame) {
656
- return cachedFrame;
657
- }
590
+ if (cachedFrame) return cachedFrame;
658
591
 
659
592
  const audioContext = new OfflineAudioContext(
660
593
  2,
661
594
  48000 * (1 / 30),
662
595
  48000,
663
596
  );
664
- const analyser = audioContext.createAnalyser();
665
- analyser.fftSize = this.fftSize;
666
597
 
667
- // Increase gain even more for better signal
668
- const gainNode = audioContext.createGain();
669
- gainNode.gain.value = 10.0; // Try a higher gain
598
+ const source = audioContext.createBufferSource();
599
+ source.buffer = audioBuffer;
670
600
 
671
- // More aggressive settings for the analyzer
672
- analyser.smoothingTimeConstant = 0.4;
601
+ // Create analyzer for PCM data
602
+ const analyser = audioContext.createAnalyser();
603
+ analyser.fftSize = this.fftSize; // Ensure enough samples
673
604
  analyser.minDecibels = -90;
674
- analyser.maxDecibels = -10;
675
-
676
- const audioBufferSource = audioContext.createBufferSource();
677
- audioBufferSource.buffer = audioBuffer;
605
+ analyser.maxDecibels = -20;
678
606
 
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
607
+ const gainNode = audioContext.createGain();
608
+ gainNode.gain.value = this.fftGain; // Amplify the signal
684
609
 
685
- audioBufferSource.connect(gainNode);
686
- gainNode.connect(filter);
687
- filter.connect(analyser);
610
+ source.connect(gainNode);
611
+ gainNode.connect(analyser);
688
612
  analyser.connect(audioContext.destination);
689
613
 
690
- audioBufferSource.start(0, startTime, 1 / 30);
614
+ source.start(0, startTime, 1 / 30);
691
615
 
616
+ const dataLength = analyser.fftSize / 2;
692
617
  try {
693
618
  await audioContext.startRendering();
694
- // Change to time domain data
695
- const frameData = new Uint8Array(analyser.fftSize);
619
+ const frameData = new Uint8Array(dataLength);
696
620
  analyser.getByteTimeDomainData(frameData);
697
621
 
698
- this.#byteTimeDomainCache.set(cacheKey, frameData);
699
- return frameData;
622
+ // const points = frameData;
623
+ // Calculate RMS and midpoint values
624
+ const points = new Uint8Array(dataLength);
625
+ for (let i = 0; i < dataLength; i++) {
626
+ const pointSamples = frameData.slice(
627
+ i * (frameData.length / dataLength),
628
+ (i + 1) * (frameData.length / dataLength),
629
+ );
630
+
631
+ // Calculate RMS while preserving sign
632
+ const rms = Math.sqrt(
633
+ pointSamples.reduce((sum, sample) => {
634
+ const normalized = (sample - 128) / 128;
635
+ return sum + normalized * normalized;
636
+ }, 0) / pointSamples.length,
637
+ );
638
+
639
+ // Get average sign of the samples to determine direction
640
+ const avgSign = Math.sign(
641
+ pointSamples.reduce((sum, sample) => sum + (sample - 128), 0),
642
+ );
643
+
644
+ // Convert RMS back to byte range, preserving direction
645
+ points[i] = Math.min(255, Math.round(128 + avgSign * rms * 128));
646
+ }
647
+
648
+ this.#byteTimeDomainCache.set(cacheKey, points);
649
+ return points;
700
650
  } finally {
701
- audioBufferSource.disconnect();
651
+ source.disconnect();
702
652
  analyser.disconnect();
703
653
  }
704
654
  }),
705
655
  );
706
656
 
657
+ // Combine frames with decay weighting
707
658
  const frameLength = framesData[0]?.length ?? 0;
708
659
  const smoothedData = new Uint8Array(frameLength);
709
660
 
710
- // Combine frames with decay
711
661
  for (let i = 0; i < frameLength; i++) {
712
662
  let weightedSum = 0;
713
663
  let weightSum = 0;
714
664
 
715
665
  framesData.forEach((frame, frameIndex) => {
716
666
  const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
717
- // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
718
- weightedSum += frame[i]! * decayWeight;
667
+ weightedSum += (frame[i] ?? 0) * decayWeight;
719
668
  weightSum += decayWeight;
720
669
  });
721
670
 
722
671
  smoothedData[i] = Math.min(255, Math.round(weightedSum / weightSum));
723
672
  }
724
673
 
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
- );
674
+ this.#byteTimeDomainCache.set(smoothedKey, smoothedData);
732
675
  return smoothedData;
733
676
  },
734
677
  });
@@ -741,8 +684,10 @@ export class EFMedia extends EFTargetable(
741
684
  [
742
685
  this.audioBufferTask.status,
743
686
  this.currentSourceTimeMs,
744
- this.fftSize, // Add fftSize to dependency array
745
- this.fftDecay, // Add fftDecay to dependency array
687
+ this.fftSize,
688
+ this.fftDecay,
689
+ this.fftGain,
690
+ this.shouldInterpolateFrequencies,
746
691
  ] as const,
747
692
  task: async () => {
748
693
  await this.audioBufferTask.taskComplete;
@@ -752,7 +697,7 @@ export class EFMedia extends EFTargetable(
752
697
  const currentTimeMs = this.currentSourceTimeMs;
753
698
  const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
754
699
  const audioBuffer = this.audioBufferTask.value.buffer;
755
- const smoothedKey = `${this.fftSize}:${this.fftDecay}:${startOffsetMs}:${currentTimeMs}`;
700
+ const smoothedKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftDecay}:${this.fftGain}:${startOffsetMs}:${currentTimeMs}`;
756
701
 
757
702
  const cachedSmoothedData = this.#frequencyDataCache.get(smoothedKey);
758
703
  if (cachedSmoothedData) {
@@ -768,7 +713,7 @@ export class EFMedia extends EFTargetable(
768
713
  );
769
714
 
770
715
  // Cache key for this specific frame
771
- const cacheKey = `${this.fftSize}:${startOffsetMs}:${startTime}`;
716
+ const cacheKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftGain}:${startOffsetMs}:${startTime}`;
772
717
 
773
718
  // Check cache for this specific frame
774
719
  const cachedFrame = this.#frequencyDataCache.get(cacheKey);
@@ -783,13 +728,23 @@ export class EFMedia extends EFTargetable(
783
728
  );
784
729
  const analyser = audioContext.createAnalyser();
785
730
  analyser.fftSize = this.fftSize;
786
- analyser.minDecibels = EFMedia.MIN_DB;
787
- analyser.maxDecibels = EFMedia.MAX_DB;
731
+ analyser.minDecibels = -90;
732
+ analyser.maxDecibels = -10;
733
+
734
+ const gainNode = audioContext.createGain();
735
+ gainNode.gain.value = this.fftGain;
736
+
737
+ const filter = audioContext.createBiquadFilter();
738
+ filter.type = "bandpass";
739
+ filter.frequency.value = 15000;
740
+ filter.Q.value = 0.05;
788
741
 
789
742
  const audioBufferSource = audioContext.createBufferSource();
790
743
  audioBufferSource.buffer = audioBuffer;
791
744
 
792
- audioBufferSource.connect(analyser);
745
+ audioBufferSource.connect(filter);
746
+ filter.connect(gainNode);
747
+ gainNode.connect(analyser);
793
748
  analyser.connect(audioContext.destination);
794
749
 
795
750
  audioBufferSource.start(0, startTime, 1 / 30);
@@ -819,7 +774,7 @@ export class EFMedia extends EFTargetable(
819
774
 
820
775
  framesData.forEach((frame, frameIndex) => {
821
776
  const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
822
- // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
777
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
823
778
  weightedSum += frame[i]! * decayWeight;
824
779
  weightSum += decayWeight;
825
780
  });
@@ -829,7 +784,7 @@ export class EFMedia extends EFTargetable(
829
784
 
830
785
  // Apply frequency weights using instance FREQ_WEIGHTS
831
786
  smoothedData.forEach((value, i) => {
832
- // biome-ignore lint/style/noNonNullAssertion: Will exist due to forEach
787
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
833
788
  const freqWeight = this.FREQ_WEIGHTS[i]!;
834
789
  smoothedData[i] = Math.min(255, Math.round(value * freqWeight));
835
790
  });
@@ -840,8 +795,91 @@ export class EFMedia extends EFTargetable(
840
795
  0,
841
796
  Math.floor(smoothedData.length / 2),
842
797
  );
843
- this.#frequencyDataCache.set(smoothedKey, slicedData);
844
- return slicedData;
798
+ const processedData = this.shouldInterpolateFrequencies
799
+ ? processFFTData(slicedData)
800
+ : slicedData;
801
+ this.#frequencyDataCache.set(smoothedKey, processedData);
802
+ return processedData;
845
803
  },
846
804
  });
805
+
806
+ set fftGain(value: number) {
807
+ const oldValue = this.fftGain;
808
+ this.setAttribute("fft-gain", String(value));
809
+ this.requestUpdate("fft-gain", oldValue);
810
+ }
811
+
812
+ get fftGain() {
813
+ return Number.parseFloat(this.getAttribute("fft-gain") ?? "3.0");
814
+ }
815
+ }
816
+
817
+ function processFFTData(fftData: Uint8Array, zeroThresholdPercent = 0.1) {
818
+ // Step 1: Determine the threshold for zeros
819
+ const totalBins = fftData.length;
820
+ const zeroThresholdCount = Math.floor(totalBins * zeroThresholdPercent);
821
+
822
+ // Step 2: Interrogate the FFT output to find the cutoff point
823
+ let zeroCount = 0;
824
+ let cutoffIndex = totalBins; // Default to the end of the array
825
+
826
+ for (let i = totalBins - 1; i >= 0; i--) {
827
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
828
+ if (fftData[i]! < 10) {
829
+ zeroCount++;
830
+ } else {
831
+ // If we encounter a non-zero value, we can stop
832
+ if (zeroCount >= zeroThresholdCount) {
833
+ cutoffIndex = i + 1; // Include this index
834
+ break;
835
+ }
836
+ }
837
+ }
838
+
839
+ if (cutoffIndex < zeroThresholdCount) {
840
+ return fftData;
841
+ }
842
+
843
+ // Step 3: Resample the "good" portion of the data
844
+ const goodData = fftData.slice(0, cutoffIndex);
845
+ const resampledData = interpolateData(goodData, fftData.length);
846
+
847
+ // Step 4: Attenuate the top 10% of interpolated samples
848
+ const attenuationStartIndex = Math.floor(totalBins * 0.9);
849
+ for (let i = attenuationStartIndex; i < totalBins; i++) {
850
+ // Calculate attenuation factor that goes from 1 to 0 over the top 10%
851
+ const attenuationProgress =
852
+ (i - attenuationStartIndex) / (totalBins - attenuationStartIndex) + 0.2;
853
+ const attenuationFactor = Math.max(0, 1 - attenuationProgress);
854
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
855
+ resampledData[i] = Math.floor(resampledData[i]! * attenuationFactor);
856
+ }
857
+
858
+ return resampledData;
859
+ }
860
+
861
+ function interpolateData(data: Uint8Array, targetSize: number) {
862
+ const resampled = new Uint8Array(targetSize);
863
+ const dataLength = data.length;
864
+
865
+ for (let i = 0; i < targetSize; i++) {
866
+ // Calculate the corresponding index in the original data
867
+ const ratio = (i / (targetSize - 1)) * (dataLength - 1);
868
+ const index = Math.floor(ratio);
869
+ const fraction = ratio - index;
870
+
871
+ // Handle edge cases
872
+ if (index >= dataLength - 1) {
873
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
874
+ resampled[i] = data[dataLength - 1]!; // Last value
875
+ } else {
876
+ // Linear interpolation
877
+ resampled[i] = Math.round(
878
+ // biome-ignore lint/style/noNonNullAssertion: Manual bounds check
879
+ data[index]! * (1 - fraction) + data[index + 1]! * fraction,
880
+ );
881
+ }
882
+ }
883
+
884
+ return resampled;
847
885
  }
@@ -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 })