@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/components/track/audio-waveform.d.ts +6 -0
- package/dist/components/track/timeline-media-strip.d.ts +14 -0
- package/dist/hooks/use-marquee-selection.d.ts +2 -1
- package/dist/hooks/use-timeline-drop.d.ts +2 -1
- package/dist/index.js +667 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +668 -20
- package/dist/index.mjs.map +1 -1
- package/dist/video-editor.css +67 -2
- package/package.json +5 -5
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
|
|
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(
|
|
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 +
|
|
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 =
|
|
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
|
}
|