@twick/video-editor 0.15.28 → 0.15.29

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.js CHANGED
@@ -7168,7 +7168,7 @@ const COLOR_FILTERS = {
7168
7168
  DRAMATIC: "dramatic",
7169
7169
  FADED: "faded"
7170
7170
  };
7171
- class LRUCache {
7171
+ let LRUCache$1 = class LRUCache {
7172
7172
  constructor(maxSize = 100) {
7173
7173
  if (maxSize <= 0) {
7174
7174
  throw new Error("maxSize must be greater than 0");
@@ -7228,10 +7228,10 @@ class LRUCache {
7228
7228
  get size() {
7229
7229
  return this.cache.size;
7230
7230
  }
7231
- }
7232
- class VideoFrameExtractor {
7231
+ };
7232
+ let VideoFrameExtractor$1 = class VideoFrameExtractor {
7233
7233
  constructor(options = {}) {
7234
- this.frameCache = new LRUCache(
7234
+ this.frameCache = new LRUCache$1(
7235
7235
  options.maxCacheSize ?? 50
7236
7236
  );
7237
7237
  this.videoElements = /* @__PURE__ */ new Map();
@@ -7497,16 +7497,16 @@ class VideoFrameExtractor {
7497
7497
  this.videoElements.clear();
7498
7498
  this.frameCache.clear();
7499
7499
  }
7500
- }
7501
- let defaultExtractor = null;
7502
- function getDefaultVideoFrameExtractor(options) {
7503
- if (!defaultExtractor) {
7504
- defaultExtractor = new VideoFrameExtractor(options);
7500
+ };
7501
+ let defaultExtractor$1 = null;
7502
+ function getDefaultVideoFrameExtractor$1(options) {
7503
+ if (!defaultExtractor$1) {
7504
+ defaultExtractor$1 = new VideoFrameExtractor$1(options);
7505
7505
  }
7506
- return defaultExtractor;
7506
+ return defaultExtractor$1;
7507
7507
  }
7508
- async function getThumbnailCached(videoUrl, seekTime = 0.1, playbackRate) {
7509
- const extractor = getDefaultVideoFrameExtractor(
7508
+ async function getThumbnailCached$1(videoUrl, seekTime = 0.1, playbackRate) {
7509
+ const extractor = getDefaultVideoFrameExtractor$1(
7510
7510
  void 0
7511
7511
  );
7512
7512
  return extractor.getFrame(videoUrl, seekTime);
@@ -8014,7 +8014,7 @@ const addVideoElement = async ({
8014
8014
  }) => {
8015
8015
  var _a;
8016
8016
  try {
8017
- const thumbnailUrl = await getThumbnailCached(
8017
+ const thumbnailUrl = await getThumbnailCached$1(
8018
8018
  ((_a = element == null ? void 0 : element.props) == null ? void 0 : _a.src) || "",
8019
8019
  snapTime
8020
8020
  );
@@ -9579,6 +9579,7 @@ function useTimelineDrop({
9579
9579
  zoomLevel,
9580
9580
  labelWidth,
9581
9581
  trackHeight,
9582
+ separatorHeight = 0,
9582
9583
  /** Width of the track content area (timeline minus labels). Used for accurate time mapping. */
9583
9584
  trackContentWidth,
9584
9585
  onDrop,
@@ -9596,7 +9597,9 @@ function useTimelineDrop({
9596
9597
  const viewportLeft = ((_b = (_a = scrollEl == null ? void 0 : scrollEl.getBoundingClientRect) == null ? void 0 : _a.call(scrollEl)) == null ? void 0 : _b.left) ?? rect.left;
9597
9598
  const contentX = clientX - viewportLeft + scrollLeft - labelWidth;
9598
9599
  const relY = clientY - rect.top;
9599
- const rawTrackIndex = Math.floor(relY / trackHeight);
9600
+ const rowHeight = trackHeight + separatorHeight;
9601
+ const yFromFirstTrack = Math.max(0, relY - separatorHeight);
9602
+ const rawTrackIndex = Math.floor(yFromFirstTrack / Math.max(1, rowHeight));
9600
9603
  const trackIndex = tracks.length === 0 ? 0 : Math.max(0, Math.min(tracks.length - 1, rawTrackIndex));
9601
9604
  const pixelsPerSecond = trackContentWidth != null && trackContentWidth > 0 ? trackContentWidth / duration : 100 * zoomLevel;
9602
9605
  const timeSec = Math.max(
@@ -9611,6 +9614,7 @@ function useTimelineDrop({
9611
9614
  tracks.length,
9612
9615
  labelWidth,
9613
9616
  trackHeight,
9617
+ separatorHeight,
9614
9618
  zoomLevel,
9615
9619
  duration,
9616
9620
  trackContentWidth
@@ -19508,6 +19512,602 @@ const TrackElementContextMenu = ({
19508
19512
  }
19509
19513
  return reactDom.createPortal(menu, document.body);
19510
19514
  };
19515
+ const waveformCache = /* @__PURE__ */ new Map();
19516
+ const buildCacheKey = (src, bucketCount) => `${src}::${bucketCount}`;
19517
+ const getSeed = (src) => {
19518
+ let seed = 2166136261;
19519
+ for (let i2 = 0; i2 < src.length; i2 += 1) {
19520
+ seed ^= src.charCodeAt(i2);
19521
+ seed = Math.imul(seed, 16777619);
19522
+ }
19523
+ return seed >>> 0;
19524
+ };
19525
+ const generateFallbackPeaks = (bucketCount, seed) => {
19526
+ const peaks = new Float32Array(bucketCount);
19527
+ let randomState = seed || 123456789;
19528
+ for (let i2 = 0; i2 < bucketCount; i2 += 1) {
19529
+ randomState = 1103515245 * randomState + 12345 >>> 0;
19530
+ const noise = randomState % 1e3 / 1e3;
19531
+ const shape = 0.35 + 0.65 * Math.abs(Math.sin(i2 * 0.21 + noise * 2.7));
19532
+ peaks[i2] = Math.max(0.08, Math.min(1, shape));
19533
+ }
19534
+ return peaks;
19535
+ };
19536
+ const computePeaks = (channelData, bucketCount) => {
19537
+ const peaks = new Float32Array(bucketCount);
19538
+ const step = Math.max(1, Math.floor(channelData.length / bucketCount));
19539
+ for (let i2 = 0; i2 < bucketCount; i2 += 1) {
19540
+ const start = i2 * step;
19541
+ const end = Math.min(channelData.length, start + step);
19542
+ let peak = 0;
19543
+ for (let j2 = start; j2 < end; j2 += 1) {
19544
+ const sample = Math.abs(channelData[j2]);
19545
+ if (sample > peak) {
19546
+ peak = sample;
19547
+ }
19548
+ }
19549
+ peaks[i2] = peak;
19550
+ }
19551
+ return peaks;
19552
+ };
19553
+ const AudioWaveform = ({ src, widthPx, heightPx, label }) => {
19554
+ const canvasRef = React.useRef(null);
19555
+ const [peaks, setPeaks] = React.useState(null);
19556
+ const bucketCount = React.useMemo(
19557
+ () => Math.max(32, Math.min(2048, Math.floor(widthPx / 3))),
19558
+ [widthPx]
19559
+ );
19560
+ React.useEffect(() => {
19561
+ let isCancelled = false;
19562
+ const controller = new AbortController();
19563
+ const loadWaveform = async () => {
19564
+ if (!src) {
19565
+ setPeaks(generateFallbackPeaks(bucketCount, 1));
19566
+ return;
19567
+ }
19568
+ const cacheKey = buildCacheKey(src, bucketCount);
19569
+ const cached = waveformCache.get(cacheKey);
19570
+ if (cached) {
19571
+ setPeaks(cached);
19572
+ return;
19573
+ }
19574
+ try {
19575
+ const response = await fetch(src, { signal: controller.signal });
19576
+ if (!response.ok) {
19577
+ throw new Error(`Failed to fetch audio (${response.status})`);
19578
+ }
19579
+ const arrayBuffer = await response.arrayBuffer();
19580
+ const audioCtx = new AudioContext();
19581
+ const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
19582
+ const channelData = audioBuffer.getChannelData(0);
19583
+ const computed = computePeaks(channelData, bucketCount);
19584
+ waveformCache.set(cacheKey, computed);
19585
+ if (!isCancelled) {
19586
+ setPeaks(computed);
19587
+ }
19588
+ await audioCtx.close();
19589
+ } catch {
19590
+ if (!isCancelled) {
19591
+ setPeaks(generateFallbackPeaks(bucketCount, getSeed(src)));
19592
+ }
19593
+ }
19594
+ };
19595
+ loadWaveform();
19596
+ return () => {
19597
+ isCancelled = true;
19598
+ controller.abort();
19599
+ };
19600
+ }, [src, bucketCount]);
19601
+ React.useEffect(() => {
19602
+ const canvas = canvasRef.current;
19603
+ if (!canvas) {
19604
+ return;
19605
+ }
19606
+ const dpr = window.devicePixelRatio || 1;
19607
+ canvas.width = Math.max(1, Math.floor(widthPx * dpr));
19608
+ canvas.height = Math.max(1, Math.floor(heightPx * dpr));
19609
+ canvas.style.width = `${widthPx}px`;
19610
+ canvas.style.height = `${heightPx}px`;
19611
+ const context = canvas.getContext("2d");
19612
+ if (!context) {
19613
+ return;
19614
+ }
19615
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
19616
+ context.clearRect(0, 0, widthPx, heightPx);
19617
+ const centerY = Math.floor(heightPx / 2) + 0.5;
19618
+ context.strokeStyle = "rgba(255, 255, 255, 0.18)";
19619
+ context.lineWidth = 1;
19620
+ context.beginPath();
19621
+ context.moveTo(0, centerY);
19622
+ context.lineTo(widthPx, centerY);
19623
+ context.stroke();
19624
+ if (!peaks || peaks.length === 0) {
19625
+ return;
19626
+ }
19627
+ const maxHeight = Math.max(6, heightPx - 10);
19628
+ let peakMax = 1e-3;
19629
+ for (let i2 = 0; i2 < peaks.length; i2 += 1) {
19630
+ peakMax = Math.max(peakMax, peaks[i2]);
19631
+ }
19632
+ const gap = 1;
19633
+ const barWidth = Math.max(1, Math.floor(widthPx / peaks.length) - gap);
19634
+ context.fillStyle = "rgba(255, 255, 255, 0.95)";
19635
+ for (let i2 = 0; i2 < peaks.length; i2 += 1) {
19636
+ const normalized = peaks[i2] / peakMax;
19637
+ const waveHeight = Math.max(2, normalized * maxHeight);
19638
+ const x2 = i2 * (barWidth + gap);
19639
+ if (x2 > widthPx) {
19640
+ break;
19641
+ }
19642
+ const y2 = (heightPx - waveHeight) / 2;
19643
+ context.fillRect(x2, y2, barWidth, waveHeight);
19644
+ }
19645
+ }, [widthPx, heightPx, peaks]);
19646
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "twick-audio-waveform-root", "aria-label": "Audio waveform", children: [
19647
+ /* @__PURE__ */ jsxRuntime.jsx("canvas", { ref: canvasRef, className: "twick-audio-waveform-canvas" }),
19648
+ label ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "twick-audio-waveform-label", children: label }) : null
19649
+ ] });
19650
+ };
19651
+ class LRUCache2 {
19652
+ constructor(maxSize = 100) {
19653
+ if (maxSize <= 0) {
19654
+ throw new Error("maxSize must be greater than 0");
19655
+ }
19656
+ this.maxSize = maxSize;
19657
+ this.cache = /* @__PURE__ */ new Map();
19658
+ }
19659
+ /**
19660
+ * Get a value from the cache.
19661
+ * Moves the item to the end (most recently used).
19662
+ */
19663
+ get(key) {
19664
+ const value = this.cache.get(key);
19665
+ if (value === void 0) {
19666
+ return void 0;
19667
+ }
19668
+ this.cache.delete(key);
19669
+ this.cache.set(key, value);
19670
+ return value;
19671
+ }
19672
+ /**
19673
+ * Set a value in the cache.
19674
+ * If cache is full, removes the least recently used item.
19675
+ */
19676
+ set(key, value) {
19677
+ if (this.cache.has(key)) {
19678
+ this.cache.delete(key);
19679
+ } else if (this.cache.size >= this.maxSize) {
19680
+ const firstKey = this.cache.keys().next().value;
19681
+ if (firstKey !== void 0) {
19682
+ this.cache.delete(firstKey);
19683
+ }
19684
+ }
19685
+ this.cache.set(key, value);
19686
+ }
19687
+ /**
19688
+ * Check if a key exists in the cache.
19689
+ */
19690
+ has(key) {
19691
+ return this.cache.has(key);
19692
+ }
19693
+ /**
19694
+ * Delete a key from the cache.
19695
+ */
19696
+ delete(key) {
19697
+ return this.cache.delete(key);
19698
+ }
19699
+ /**
19700
+ * Clear all entries from the cache.
19701
+ */
19702
+ clear() {
19703
+ this.cache.clear();
19704
+ }
19705
+ /**
19706
+ * Get the current size of the cache.
19707
+ */
19708
+ get size() {
19709
+ return this.cache.size;
19710
+ }
19711
+ }
19712
+ class VideoFrameExtractor2 {
19713
+ constructor(options = {}) {
19714
+ this.frameCache = new LRUCache2(
19715
+ options.maxCacheSize ?? 50
19716
+ );
19717
+ this.videoElements = /* @__PURE__ */ new Map();
19718
+ this.maxVideoElements = options.maxVideoElements ?? 5;
19719
+ this.loadTimeout = options.loadTimeout ?? 15e3;
19720
+ this.jpegQuality = options.jpegQuality ?? 0.8;
19721
+ this.playbackRate = options.playbackRate ?? 1;
19722
+ }
19723
+ /**
19724
+ * Get a frame thumbnail from a video at a specific time.
19725
+ * Uses caching and reuses video elements for optimal performance.
19726
+ * Uses 0.1s instead of 0 when seekTime is 0, since frames at t=0 are often blank.
19727
+ *
19728
+ * @param videoUrl - The URL of the video
19729
+ * @param seekTime - The time in seconds to extract the frame (0 is treated as 0.1)
19730
+ * @returns Promise resolving to a thumbnail image URL (data URL or blob URL)
19731
+ */
19732
+ async getFrame(videoUrl, seekTime = 0.1) {
19733
+ const effectiveSeekTime = seekTime === 0 ? 0.1 : seekTime;
19734
+ const cacheKey = this.getCacheKey(videoUrl, effectiveSeekTime);
19735
+ const cached = this.frameCache.get(cacheKey);
19736
+ if (cached) {
19737
+ return cached;
19738
+ }
19739
+ const videoState = await this.getVideoElement(videoUrl);
19740
+ const thumbnail = await this.extractFrame(videoState.video, effectiveSeekTime);
19741
+ this.frameCache.set(cacheKey, thumbnail);
19742
+ return thumbnail;
19743
+ }
19744
+ /**
19745
+ * Get or create a video element for the given URL.
19746
+ * Reuses existing elements and manages cleanup.
19747
+ */
19748
+ async getVideoElement(videoUrl) {
19749
+ let videoState = this.videoElements.get(videoUrl);
19750
+ if (videoState && videoState.isReady) {
19751
+ videoState.lastUsed = Date.now();
19752
+ return videoState;
19753
+ }
19754
+ if (videoState && videoState.isLoading && videoState.loadPromise) {
19755
+ await videoState.loadPromise;
19756
+ if (videoState.isReady) {
19757
+ videoState.lastUsed = Date.now();
19758
+ return videoState;
19759
+ }
19760
+ }
19761
+ if (this.videoElements.size >= this.maxVideoElements) {
19762
+ this.cleanupOldVideoElements();
19763
+ }
19764
+ videoState = await this.createVideoElement(videoUrl);
19765
+ this.videoElements.set(videoUrl, videoState);
19766
+ videoState.lastUsed = Date.now();
19767
+ return videoState;
19768
+ }
19769
+ /**
19770
+ * Create and initialize a new video element.
19771
+ */
19772
+ async createVideoElement(videoUrl) {
19773
+ const video = document.createElement("video");
19774
+ video.crossOrigin = "anonymous";
19775
+ video.muted = true;
19776
+ video.playsInline = true;
19777
+ video.autoplay = false;
19778
+ video.preload = "auto";
19779
+ video.playbackRate = this.playbackRate;
19780
+ video.style.position = "absolute";
19781
+ video.style.left = "-9999px";
19782
+ video.style.top = "-9999px";
19783
+ video.style.width = "1px";
19784
+ video.style.height = "1px";
19785
+ video.style.opacity = "0";
19786
+ video.style.pointerEvents = "none";
19787
+ video.style.zIndex = "-1";
19788
+ const state = {
19789
+ video,
19790
+ isReady: false,
19791
+ isLoading: true,
19792
+ loadPromise: null,
19793
+ lastUsed: Date.now()
19794
+ };
19795
+ state.loadPromise = new Promise((resolve, reject) => {
19796
+ let timeoutId;
19797
+ const cleanup = () => {
19798
+ if (timeoutId) clearTimeout(timeoutId);
19799
+ };
19800
+ const handleError = () => {
19801
+ var _a;
19802
+ cleanup();
19803
+ state.isLoading = false;
19804
+ reject(new Error(`Failed to load video: ${((_a = video.error) == null ? void 0 : _a.message) || "Unknown error"}`));
19805
+ };
19806
+ const handleLoadedMetadata = () => {
19807
+ cleanup();
19808
+ state.isReady = true;
19809
+ state.isLoading = false;
19810
+ resolve();
19811
+ };
19812
+ video.addEventListener("error", handleError, { once: true });
19813
+ video.addEventListener("loadedmetadata", handleLoadedMetadata, { once: true });
19814
+ timeoutId = window.setTimeout(() => {
19815
+ cleanup();
19816
+ state.isLoading = false;
19817
+ reject(new Error("Video loading timed out"));
19818
+ }, this.loadTimeout);
19819
+ video.src = videoUrl;
19820
+ document.body.appendChild(video);
19821
+ });
19822
+ try {
19823
+ await state.loadPromise;
19824
+ } catch (error) {
19825
+ if (video.parentNode) {
19826
+ video.remove();
19827
+ }
19828
+ throw error;
19829
+ }
19830
+ return state;
19831
+ }
19832
+ /**
19833
+ * Extract a frame from a video at the specified time.
19834
+ */
19835
+ async extractFrame(video, seekTime) {
19836
+ return new Promise((resolve, reject) => {
19837
+ video.pause();
19838
+ const timeThreshold = 0.1;
19839
+ if (Math.abs(video.currentTime - seekTime) < timeThreshold) {
19840
+ try {
19841
+ const canvas = document.createElement("canvas");
19842
+ const width = video.videoWidth || 640;
19843
+ const height = video.videoHeight || 360;
19844
+ canvas.width = width;
19845
+ canvas.height = height;
19846
+ const ctx = canvas.getContext("2d");
19847
+ if (!ctx) {
19848
+ reject(new Error("Failed to get canvas context"));
19849
+ return;
19850
+ }
19851
+ ctx.drawImage(video, 0, 0, width, height);
19852
+ try {
19853
+ const dataUrl = canvas.toDataURL("image/jpeg", this.jpegQuality);
19854
+ resolve(dataUrl);
19855
+ } catch {
19856
+ canvas.toBlob(
19857
+ (blob) => {
19858
+ if (!blob) {
19859
+ reject(new Error("Failed to create Blob"));
19860
+ return;
19861
+ }
19862
+ const blobUrl = URL.createObjectURL(blob);
19863
+ resolve(blobUrl);
19864
+ },
19865
+ "image/jpeg",
19866
+ this.jpegQuality
19867
+ );
19868
+ }
19869
+ return;
19870
+ } catch (err) {
19871
+ reject(new Error(`Error creating thumbnail: ${err}`));
19872
+ return;
19873
+ }
19874
+ }
19875
+ const handleSeeked = () => {
19876
+ try {
19877
+ const canvas = document.createElement("canvas");
19878
+ const width = video.videoWidth || 640;
19879
+ const height = video.videoHeight || 360;
19880
+ canvas.width = width;
19881
+ canvas.height = height;
19882
+ const ctx = canvas.getContext("2d");
19883
+ if (!ctx) {
19884
+ reject(new Error("Failed to get canvas context"));
19885
+ return;
19886
+ }
19887
+ ctx.drawImage(video, 0, 0, width, height);
19888
+ try {
19889
+ const dataUrl = canvas.toDataURL("image/jpeg", this.jpegQuality);
19890
+ resolve(dataUrl);
19891
+ } catch {
19892
+ canvas.toBlob(
19893
+ (blob) => {
19894
+ if (!blob) {
19895
+ reject(new Error("Failed to create Blob"));
19896
+ return;
19897
+ }
19898
+ const blobUrl = URL.createObjectURL(blob);
19899
+ resolve(blobUrl);
19900
+ },
19901
+ "image/jpeg",
19902
+ this.jpegQuality
19903
+ );
19904
+ }
19905
+ } catch (err) {
19906
+ reject(new Error(`Error creating thumbnail: ${err}`));
19907
+ }
19908
+ };
19909
+ video.addEventListener("seeked", handleSeeked, { once: true });
19910
+ const playPromise = video.play();
19911
+ if (playPromise !== void 0) {
19912
+ playPromise.then(() => {
19913
+ video.currentTime = seekTime;
19914
+ }).catch(() => {
19915
+ video.currentTime = seekTime;
19916
+ });
19917
+ } else {
19918
+ video.currentTime = seekTime;
19919
+ }
19920
+ });
19921
+ }
19922
+ /**
19923
+ * Generate cache key for a video URL and seek time.
19924
+ */
19925
+ getCacheKey(videoUrl, seekTime) {
19926
+ const roundedTime = Math.round(seekTime * 100) / 100;
19927
+ return `${videoUrl}:${roundedTime}`;
19928
+ }
19929
+ /**
19930
+ * Cleanup least recently used video elements.
19931
+ */
19932
+ cleanupOldVideoElements() {
19933
+ if (this.videoElements.size < this.maxVideoElements) {
19934
+ return;
19935
+ }
19936
+ const entries = Array.from(this.videoElements.entries());
19937
+ entries.sort((a2, b2) => a2[1].lastUsed - b2[1].lastUsed);
19938
+ const toRemove = entries.slice(0, entries.length - this.maxVideoElements + 1);
19939
+ for (const [url, state] of toRemove) {
19940
+ if (state.video.parentNode) {
19941
+ state.video.remove();
19942
+ }
19943
+ this.videoElements.delete(url);
19944
+ }
19945
+ }
19946
+ /**
19947
+ * Clear the frame cache.
19948
+ */
19949
+ clearCache() {
19950
+ this.frameCache.clear();
19951
+ }
19952
+ /**
19953
+ * Remove a specific video element and clear its cached frames.
19954
+ */
19955
+ removeVideo(videoUrl) {
19956
+ const state = this.videoElements.get(videoUrl);
19957
+ if (state) {
19958
+ if (state.video.parentNode) {
19959
+ state.video.remove();
19960
+ }
19961
+ this.videoElements.delete(videoUrl);
19962
+ }
19963
+ this.frameCache.clear();
19964
+ }
19965
+ /**
19966
+ * Dispose of all video elements and clear caches.
19967
+ * Removes all video elements from the DOM and clears both the frame cache
19968
+ * and video element cache. Call this when the extractor is no longer needed
19969
+ * to prevent memory leaks.
19970
+ */
19971
+ dispose() {
19972
+ for (const state of this.videoElements.values()) {
19973
+ if (state.video.parentNode) {
19974
+ state.video.remove();
19975
+ }
19976
+ }
19977
+ this.videoElements.clear();
19978
+ this.frameCache.clear();
19979
+ }
19980
+ }
19981
+ let defaultExtractor = null;
19982
+ function getDefaultVideoFrameExtractor(options) {
19983
+ if (!defaultExtractor) {
19984
+ defaultExtractor = new VideoFrameExtractor2(options);
19985
+ }
19986
+ return defaultExtractor;
19987
+ }
19988
+ async function getThumbnailCached(videoUrl, seekTime = 0.1, playbackRate) {
19989
+ const extractor = getDefaultVideoFrameExtractor(
19990
+ void 0
19991
+ );
19992
+ return extractor.getFrame(videoUrl, seekTime);
19993
+ }
19994
+ const MAX_THUMBNAILS_PER_CLIP = 24;
19995
+ const MIN_THUMBNAIL_WIDTH = 40;
19996
+ const MAX_THUMBNAIL_WIDTH = 96;
19997
+ const videoThumbCache = /* @__PURE__ */ new Map();
19998
+ const inFlightVideoThumbs = /* @__PURE__ */ new Map();
19999
+ const getThumbCacheKey = (src, seekTime) => `${src}:${Math.round(seekTime * 100) / 100}`;
20000
+ const getCachedVideoThumbnail = async (src, seekTime) => {
20001
+ const key = getThumbCacheKey(src, seekTime);
20002
+ const cached = videoThumbCache.get(key);
20003
+ if (cached) {
20004
+ return cached;
20005
+ }
20006
+ const inFlight = inFlightVideoThumbs.get(key);
20007
+ if (inFlight) {
20008
+ return inFlight;
20009
+ }
20010
+ const request = getThumbnailCached(src, seekTime).then((thumb) => {
20011
+ videoThumbCache.set(key, thumb);
20012
+ inFlightVideoThumbs.delete(key);
20013
+ return thumb;
20014
+ }).catch(() => {
20015
+ inFlightVideoThumbs.delete(key);
20016
+ throw new Error("Thumbnail extraction failed");
20017
+ });
20018
+ inFlightVideoThumbs.set(key, request);
20019
+ return request;
20020
+ };
20021
+ const runWithConcurrencyLimit = async (tasks, concurrency) => {
20022
+ const active = /* @__PURE__ */ new Set();
20023
+ for (const task of tasks) {
20024
+ const promise = task();
20025
+ active.add(promise);
20026
+ promise.finally(() => active.delete(promise));
20027
+ if (active.size >= concurrency) {
20028
+ await Promise.race(active);
20029
+ }
20030
+ }
20031
+ await Promise.all(active);
20032
+ };
20033
+ const getThumbnailCount = (widthPx, heightPx) => {
20034
+ const targetThumbWidth = Math.max(
20035
+ MIN_THUMBNAIL_WIDTH,
20036
+ Math.min(MAX_THUMBNAIL_WIDTH, Math.round(heightPx * 1.5))
20037
+ );
20038
+ return Math.max(
20039
+ 1,
20040
+ Math.min(MAX_THUMBNAILS_PER_CLIP, Math.ceil(widthPx / targetThumbWidth))
20041
+ );
20042
+ };
20043
+ const ImageTimelineStrip = ({ src, widthPx, heightPx }) => {
20044
+ const count = React.useMemo(() => getThumbnailCount(widthPx, heightPx), [widthPx, heightPx]);
20045
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "twick-media-strip twick-media-strip-image", "aria-hidden": "true", children: Array.from({ length: count }, (_2, index) => /* @__PURE__ */ jsxRuntime.jsx(
20046
+ "img",
20047
+ {
20048
+ src,
20049
+ className: "twick-media-strip-thumb",
20050
+ alt: "",
20051
+ draggable: false
20052
+ },
20053
+ `${src}-${index}`
20054
+ )) });
20055
+ };
20056
+ const VideoTimelineStrip = ({
20057
+ src,
20058
+ widthPx,
20059
+ heightPx,
20060
+ durationSec,
20061
+ mediaOffsetSec = 0,
20062
+ playbackRate = 1,
20063
+ mediaDurationSec
20064
+ }) => {
20065
+ const count = React.useMemo(() => getThumbnailCount(widthPx, heightPx), [widthPx, heightPx]);
20066
+ const [thumbs, setThumbs] = React.useState({});
20067
+ const slots = React.useMemo(() => {
20068
+ const timelineDuration = Math.max(1e-3, durationSec);
20069
+ return Array.from({ length: count }, (_2, index) => {
20070
+ const progress2 = count === 1 ? 0 : index / (count - 1);
20071
+ const timelineTime = progress2 * timelineDuration;
20072
+ let mediaTime = Math.max(0, mediaOffsetSec + timelineTime * playbackRate);
20073
+ if (typeof mediaDurationSec === "number" && mediaDurationSec > 0) {
20074
+ mediaTime = Math.min(mediaTime, Math.max(0, mediaDurationSec - 0.05));
20075
+ }
20076
+ return mediaTime;
20077
+ });
20078
+ }, [count, durationSec, mediaOffsetSec, playbackRate, mediaDurationSec]);
20079
+ React.useEffect(() => {
20080
+ let cancelled = false;
20081
+ const loadThumbs = async () => {
20082
+ const nextThumbs = {};
20083
+ const tasks = slots.map((seekTime, index) => async () => {
20084
+ try {
20085
+ const thumb = await getCachedVideoThumbnail(src, seekTime);
20086
+ nextThumbs[index] = thumb;
20087
+ } catch {
20088
+ }
20089
+ });
20090
+ await runWithConcurrencyLimit(tasks, 3);
20091
+ if (!cancelled) {
20092
+ setThumbs(nextThumbs);
20093
+ }
20094
+ };
20095
+ loadThumbs();
20096
+ return () => {
20097
+ cancelled = true;
20098
+ };
20099
+ }, [src, slots]);
20100
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "twick-media-strip twick-media-strip-video", "aria-hidden": "true", children: slots.map((_2, index) => /* @__PURE__ */ jsxRuntime.jsx(
20101
+ "img",
20102
+ {
20103
+ src: thumbs[index],
20104
+ className: "twick-media-strip-thumb",
20105
+ alt: "",
20106
+ draggable: false
20107
+ },
20108
+ `${src}-${index}`
20109
+ )) });
20110
+ };
19511
20111
  const TrackElementView = ({
19512
20112
  element,
19513
20113
  parentWidth,
@@ -19658,6 +20258,18 @@ const TrackElementView = ({
19658
20258
  const isSelected = React.useMemo(() => {
19659
20259
  return selectedIds.has(element.getId());
19660
20260
  }, [selectedIds, element]);
20261
+ const isAudioElement = element.getType() === timeline.TIMELINE_ELEMENT_TYPE.AUDIO;
20262
+ const isVideoElement = element.getType() === timeline.TIMELINE_ELEMENT_TYPE.VIDEO;
20263
+ const isImageElement = element.getType() === timeline.TIMELINE_ELEMENT_TYPE.IMAGE;
20264
+ const elementLabel = element.getType() === timeline.TIMELINE_ELEMENT_TYPE.EFFECT ? ((_b = (_a = element.getProps) == null ? void 0 : _a.call(element)) == null ? void 0 : _b.effectKey) ?? "Effect" : element.getText ? element.getText() : element.getName() || element.getType();
20265
+ const mediaSrc = (isAudioElement || isVideoElement || isImageElement) && element.getSrc ? element.getSrc() : void 0;
20266
+ const mediaOffsetSec = isVideoElement && element.getStartAt ? element.getStartAt() : 0;
20267
+ const playbackRate = isVideoElement && element.getPlaybackRate ? element.getPlaybackRate() : 1;
20268
+ const mediaDurationSec = isVideoElement && element.getMediaDuration ? element.getMediaDuration() : void 0;
20269
+ const elementWidthPx = Math.max(
20270
+ 1,
20271
+ (position.end - position.start) / Math.max(duration, MIN_DURATION) * parentWidth
20272
+ );
19661
20273
  const hasHandles = (selectedItem == null ? void 0 : selectedItem.getId()) === element.getId();
19662
20274
  const contextActionsEnabled = Boolean(
19663
20275
  onDeleteElement && onSplitElement && onContextMenuTarget
@@ -19671,7 +20283,7 @@ const TrackElementView = ({
19671
20283
  };
19672
20284
  const motionProps = {
19673
20285
  ref,
19674
- className: `twick-track-element ${isSelected ? "twick-track-element-selected" : "twick-track-element-default"} ${isDragging2 ? "twick-track-element-dragging" : ""}`,
20286
+ className: `twick-track-element ${isSelected ? "twick-track-element-selected" : "twick-track-element-default"} ${isDragging2 ? "twick-track-element-dragging" : ""} ${isAudioElement ? "twick-track-element-audio" : ""}`,
19675
20287
  onMouseDown: (e3) => {
19676
20288
  if (e3.target === ref.current) {
19677
20289
  setLastPos();
@@ -19718,7 +20330,39 @@ const TrackElementView = ({
19718
20330
  className: "twick-track-element-handle twick-track-element-handle-start"
19719
20331
  }
19720
20332
  ) : null,
19721
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "twick-track-element-content", children: element.getType() === timeline.TIMELINE_ELEMENT_TYPE.EFFECT ? ((_b = (_a = element.getProps) == null ? void 0 : _a.call(element)) == null ? void 0 : _b.effectKey) ?? "Effect" : element.getText ? element.getText() : element.getName() || element.getType() }),
20333
+ /* @__PURE__ */ jsxRuntime.jsx(
20334
+ "div",
20335
+ {
20336
+ className: `twick-track-element-content ${isAudioElement ? "twick-track-element-content-audio" : ""}`,
20337
+ children: isAudioElement ? /* @__PURE__ */ jsxRuntime.jsx(
20338
+ AudioWaveform,
20339
+ {
20340
+ src: mediaSrc,
20341
+ widthPx: elementWidthPx,
20342
+ heightPx: 46,
20343
+ label: elementLabel
20344
+ }
20345
+ ) : isVideoElement && mediaSrc ? /* @__PURE__ */ jsxRuntime.jsx(
20346
+ VideoTimelineStrip,
20347
+ {
20348
+ src: mediaSrc,
20349
+ widthPx: elementWidthPx,
20350
+ heightPx: 46,
20351
+ durationSec: Math.max(0, element.getDuration()),
20352
+ mediaOffsetSec,
20353
+ playbackRate,
20354
+ mediaDurationSec
20355
+ }
20356
+ ) : isImageElement && mediaSrc ? /* @__PURE__ */ jsxRuntime.jsx(
20357
+ ImageTimelineStrip,
20358
+ {
20359
+ src: mediaSrc,
20360
+ widthPx: elementWidthPx,
20361
+ heightPx: 46
20362
+ }
20363
+ ) : elementLabel
20364
+ }
20365
+ ),
19722
20366
  hasHandles ? /* @__PURE__ */ jsxRuntime.jsx(
19723
20367
  "div",
19724
20368
  {
@@ -19858,6 +20502,7 @@ function useMarqueeSelection({
19858
20502
  labelWidth,
19859
20503
  trackCount,
19860
20504
  trackHeight,
20505
+ separatorHeight = 2,
19861
20506
  tracks,
19862
20507
  containerRef,
19863
20508
  onMarqueeSelect,
@@ -19911,7 +20556,7 @@ function useMarqueeSelection({
19911
20556
  const bottom = Math.max(currentMarquee.startY, currentMarquee.endY);
19912
20557
  const startTime = Math.max(0, (left - labelWidth) / pixelsPerSecond);
19913
20558
  const endTime = Math.min(duration, (right - labelWidth) / pixelsPerSecond);
19914
- const rowHeight = trackHeight + 2;
20559
+ const rowHeight = trackHeight + separatorHeight;
19915
20560
  const startTrackIdx = Math.max(0, Math.floor(top / rowHeight));
19916
20561
  const endTrackIdx = Math.min(
19917
20562
  trackCount - 1,
@@ -19940,6 +20585,7 @@ function useMarqueeSelection({
19940
20585
  labelWidth,
19941
20586
  trackCount,
19942
20587
  trackHeight,
20588
+ separatorHeight,
19943
20589
  tracks,
19944
20590
  onMarqueeSelect,
19945
20591
  onEmptyClick,
@@ -20076,7 +20722,7 @@ function getTrackOrSeparatorAt(clientY, containerTop, trackHeight) {
20076
20722
  return { type: "separator", separatorIndex: index + 1 };
20077
20723
  }
20078
20724
  const LABEL_WIDTH = 40;
20079
- const TRACK_HEIGHT = 44;
20725
+ const TRACK_HEIGHT = 52;
20080
20726
  const SEPARATOR_HEIGHT = 6;
20081
20727
  function TimelineView({
20082
20728
  zoomLevel,
@@ -20219,6 +20865,7 @@ function TimelineView({
20219
20865
  labelWidth: LABEL_WIDTH,
20220
20866
  trackCount: (tracks == null ? void 0 : tracks.length) ?? 0,
20221
20867
  trackHeight: TRACK_HEIGHT,
20868
+ separatorHeight: SEPARATOR_HEIGHT,
20222
20869
  tracks: tracks ?? [],
20223
20870
  containerRef: timelineContentRef,
20224
20871
  onMarqueeSelect,
@@ -20232,6 +20879,7 @@ function TimelineView({
20232
20879
  zoomLevel,
20233
20880
  labelWidth: LABEL_WIDTH,
20234
20881
  trackHeight: TRACK_HEIGHT,
20882
+ separatorHeight: SEPARATOR_HEIGHT,
20235
20883
  trackContentWidth: timelineWidth - LABEL_WIDTH,
20236
20884
  onDrop: onDropOnTimeline ?? (async () => {
20237
20885
  }),
@@ -20327,7 +20975,7 @@ function TimelineView({
20327
20975
  style: {
20328
20976
  position: "absolute",
20329
20977
  left: LABEL_WIDTH + preview.timeSec / duration * (timelineWidth - LABEL_WIDTH),
20330
- top: preview.trackIndex * TRACK_HEIGHT + 2,
20978
+ top: SEPARATOR_HEIGHT + preview.trackIndex * (TRACK_HEIGHT + SEPARATOR_HEIGHT) + 2,
20331
20979
  width: preview.widthPct / 100 * (timelineWidth - LABEL_WIDTH),
20332
20980
  height: TRACK_HEIGHT - 4
20333
20981
  }