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