From e82159b9a273fb662b3f5accfa283cafa6c97c7e Mon Sep 17 00:00:00 2001 From: Xinrea Date: Sat, 25 Oct 2025 12:22:36 +0800 Subject: [PATCH] feat: video waveform (close #184) --- package.json | 3 +- src/lib/components/VideoPreview.svelte | 192 ++++++++++++++++++++++++- yarn.lock | 5 + 3 files changed, 196 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e839ba7..9e6fefa 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lucide-svelte": "^0.479.0", "marked": "^16.1.1", "qrcode": "^1.5.4", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "wavesurfer.js": "^7.11.0" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^2.0.0", diff --git a/src/lib/components/VideoPreview.svelte b/src/lib/components/VideoPreview.svelte index 70226c7..3d71d5f 100644 --- a/src/lib/components/VideoPreview.svelte +++ b/src/lib/components/VideoPreview.svelte @@ -39,6 +39,7 @@ import { onDestroy, onMount } from "svelte"; import { listen as tauriListen } from "@tauri-apps/api/event"; import type { AccountInfo } from "../db"; + import WaveSurfer from "wavesurfer.js"; export let show = false; export let video: VideoItem; @@ -123,6 +124,13 @@ let uid_selected = 0; let show_cover_editor = false; + // WaveSurfer.js 相关变量 + let wavesurfer: any = null; + let waveformContainer: HTMLElement; + let isWaveformLoaded = false; + let isWaveformLoading = false; + let syncTimeout: ReturnType | null = null; + // 获取 profile 从 localStorage function get_profile(): Profile { const profile_str = window.localStorage.getItem("profile-" + roomId); @@ -138,6 +146,125 @@ window.localStorage.setItem("profile-" + roomId, JSON.stringify(profile)); } + // 初始化 WaveSurfer.js + async function initWaveSurfer() { + if (typeof window === "undefined" || !video?.file) return; + + isWaveformLoading = true; + + try { + createWaveSurfer(); + } catch (error) { + console.error("Failed to initialize WaveSurfer.js:", error); + isWaveformLoading = false; + } + } + + function createWaveSurfer() { + // 使用更稳定的容器查找方式 + const container = document.querySelector( + "[data-waveform-container]" + ) as HTMLElement; + + if (!container || !video?.file) { + console.log("Missing container or video file:", { + container, + videoFile: video?.file, + }); + return; + } + + // 确保容器有正确的尺寸,考虑 timeline scale + container.style.width = `${100 * timelineScale}%`; + container.style.height = "60px"; + container.style.minHeight = "60px"; + container.style.display = "block"; + + console.log("Creating WaveSurfer with:", { + container: container, + file: video.file, + containerDimensions: { + width: container.offsetWidth, + height: container.offsetHeight, + }, + }); + + try { + wavesurfer = WaveSurfer.create({ + container: container, + waveColor: "#4a5568", + progressColor: "#0A84FF", + cursorColor: "#0A84FF", + barWidth: 2, + barRadius: 1, + height: 60, + normalize: true, + interact: true, // 启用交互,允许点击切换进度 + plugins: [], + }); + + console.log("WaveSurfer created, loading file:", video.file); + // 加载音频 + wavesurfer.load(video.file); + + // 监听加载完成 + wavesurfer.on("ready", () => { + isWaveformLoaded = true; + isWaveformLoading = false; + console.log("Waveform loaded successfully"); + console.log("WaveSurfer instance:", wavesurfer); + }); + + // 监听点击事件,同步视频进度 + wavesurfer.on("interaction", (newTime: number) => { + if (videoElement && videoElement.duration) { + videoElement.currentTime = newTime; + currentTime = newTime; + } + }); + + // 监听错误 + wavesurfer.on("error", (e: any) => { + console.error("WaveSurfer error:", e); + isWaveformLoading = false; + }); + + // 监听加载进度 + wavesurfer.on("loading", (percent: number) => { + console.log("WaveSurfer loading:", percent + "%"); + }); + } catch (error) { + console.error("Failed to create WaveSurfer:", error); + } + } + + // 同步波形图与视频进度 + function syncWaveformWithVideo() { + if ( + !wavesurfer || + !videoElement || + !isWaveformLoaded || + !videoElement.duration + ) + return; + + try { + const progress = videoElement.currentTime / videoElement.duration; + wavesurfer.seekTo(progress); + } catch (error) { + console.warn("Failed to sync waveform:", error); + } + } + + // 销毁 WaveSurfer 实例 + function destroyWaveSurfer() { + if (wavesurfer) { + wavesurfer.destroy(); + wavesurfer = null; + isWaveformLoaded = false; + } + } + // on window close, save subtitles onMount(async () => { if (TAURI_ENV) { @@ -183,6 +310,8 @@ if (windowCloseUnlisten) { windowCloseUnlisten(); } + // 清理 WaveSurfer 实例 + destroyWaveSurfer(); }); function update_encode_prompt(content: string) { @@ -446,6 +575,9 @@ currentSubtitleIndex = -1; subtitleElements = []; loadSubtitleStyle(); // 加载字幕样式 + + // 销毁旧的波形图实例 + destroyWaveSurfer(); } // 当视频改变时重新初始化切片时间(只在视频ID改变时触发) @@ -479,6 +611,11 @@ } await loadSubtitles(); // 加载保存的字幕 initClipTimes(); // 初始化切片时间 + + // 初始化波形图 + setTimeout(() => { + initWaveSurfer(); + }, 100); } function updateTimeMarkers() { @@ -771,6 +908,9 @@ currentSubtitleIndex = getCurrentSubtitleIndex(); const currentSub = subtitles[currentSubtitleIndex]; currentSubtitle = currentSub?.text || ""; + + // 同步波形图进度 + syncWaveformWithVideo(); } function handleVideoEnded() { @@ -1083,6 +1223,11 @@ const rect = timelineElement.getBoundingClientRect(); timelineWidth = rect.width; updateTimeMarkers(); + + // 同步调整 waveform 容器宽度 + if (waveformContainer) { + waveformContainer.style.width = `${100 * timelineScale}%`; + } } function handleWheel(e: WheelEvent) { @@ -1578,17 +1723,50 @@ - +