diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d3683976..6534180f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,5 +1,6 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { + Brackets, Bug, Crop, Download, @@ -258,6 +259,8 @@ interface SettingsPanelProps { onShadowCommit?: () => void; showBlur?: boolean; onBlurChange?: (showBlur: boolean) => void; + showTrimWaveform?: boolean; + onTrimWaveformChange?: (show: boolean) => void; motionBlurAmount?: number; onMotionBlurChange?: (amount: number) => void; onMotionBlurCommit?: () => void; @@ -343,7 +346,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; -type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export"; +type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline"; const MP4_EXPORT_SHORT_SIDES = { medium: 720, @@ -389,6 +392,8 @@ export function SettingsPanel({ onShadowCommit, showBlur, onBlurChange, + showTrimWaveform = false, + onTrimWaveformChange, motionBlurAmount = 0, onMotionBlurChange, onMotionBlurCommit, @@ -603,6 +608,7 @@ export function SettingsPanel({ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, + { id: "timeline", label: t("timeline.title"), icon: Brackets }, ...(hasCursorPanel ? [ { @@ -624,8 +630,10 @@ export function SettingsPanel({ : selectedSpeedId ? t("speed.playbackSpeed") : t("trim.deleteRegion") - : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? - t("background.title")); + : activePanelMode === "timeline" + ? t("timeline.title") + : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? + t("background.title")); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { @@ -1696,6 +1704,28 @@ export function SettingsPanel({ )} + {activePanelMode === "timeline" && ( + + +
+ + {t("timeline.title")} +
+
+ +
+
+ {t("timeline.waveform")} +
+ +
+
+
+ )} )} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b32621c9..e645ec7c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -169,6 +169,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -355,6 +356,7 @@ export default function VideoEditor() { wallpaper: normalizedEditor.wallpaper, shadowIntensity: normalizedEditor.shadowIntensity, showBlur: normalizedEditor.showBlur, + showTrimWaveform: normalizedEditor.showTrimWaveform, motionBlurAmount: normalizedEditor.motionBlurAmount, borderRadius: normalizedEditor.borderRadius, padding: normalizedEditor.padding, @@ -426,6 +428,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -449,6 +452,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -572,6 +576,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -631,6 +636,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -2192,6 +2198,8 @@ export default function VideoEditor() { onShadowCommit={commitState} showBlur={showBlur} onBlurChange={(v) => pushState({ showBlur: v })} + showTrimWaveform={showTrimWaveform} + onTrimWaveformChange={(v) => pushState({ showTrimWaveform: v })} motionBlurAmount={motionBlurAmount} onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} onMotionBlurCommit={commitState} @@ -2355,6 +2363,8 @@ export default function VideoEditor() { : webcamLayoutPreset, }) } + videoUrl={videoPath ?? undefined} + showTrimWaveform={showTrimWaveform} /> diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index ac6ab614..914ec769 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -34,11 +34,13 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { showBlur: boolean; motionBlurAmount: number; borderRadius: number; + showTrimWaveform: boolean; } = { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, borderRadius: 0, + showTrimWaveform: false, }; export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index f16d29ea..46673290 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -65,6 +65,7 @@ export interface ProjectEditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -445,6 +446,10 @@ export function normalizeProjectEditor(editor: Partial): Pro typeof editor.showBlur === "boolean" ? editor.showBlur : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: + typeof editor.showTrimWaveform === "boolean" + ? editor.showTrimWaveform + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx new file mode 100644 index 00000000..05d8e650 --- /dev/null +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -0,0 +1,90 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useEffect, useRef, useState } from "react"; + +export interface BackgroundWaveformProps { + /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ + peaks: Float32Array | null; + videoDurationMs: number; +} + +/** + * Renders a faint audio waveform on a `` that fills its containing + * block. Designed to be passed as the `background` prop of ``, which + * already provides `relative overflow-hidden` — no wrapper element needed. + * + * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). + * - Redraws whenever the timeline zoom/pan range changes. + * - `pointer-events: none` — never blocks drag-to-create interactions. + */ +export default function BackgroundWaveform({ peaks, videoDurationMs }: BackgroundWaveformProps) { + const { range } = useTimelineContext(); + const canvasRef = useRef(null); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + + // Observe the canvas itself — Row's `relative overflow-hidden` parent + // makes it fill the row exactly, so no wrapper div is needed. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ro = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setCanvasSize({ w: width, h: height }); + }); + ro.observe(canvas); + return () => ro.disconnect(); + }, []); + + // Redraw whenever peaks, range, or canvas size changes. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvasSize.w * dpr); + canvas.height = Math.round(canvasSize.h * dpr); + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, canvasSize.w, canvasSize.h); + + if (!peaks || peaks.length === 0) return; + + const W = canvasSize.w; + const H = canvasSize.h; + const mid = H / 2; + const amp = mid * 0.9; + const rangeMs = range.end - range.start; + if (rangeMs <= 0 || videoDurationMs <= 0) return; + + const N = peaks.length / 2; + + ctx.beginPath(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.12)"; + ctx.lineWidth = 1; + + for (let x = 0; x < W; x++) { + const startMs = range.start + (x / W) * rangeMs; + const endMs = range.start + ((x + 1) / W) * rangeMs; + const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N)); + const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N)); + + let minVal = 0; + let maxVal = 0; + for (let i = lo; i <= hi; i++) { + const mn = peaks[i * 2]; + const mx = peaks[i * 2 + 1]; + if (mn < minVal) minVal = mn; + if (mx > maxVal) maxVal = mx; + } + + ctx.moveTo(x + 0.5, mid - maxVal * amp); + ctx.lineTo(x + 0.5, mid - minVal * amp); + } + + ctx.stroke(); + }, [peaks, range, canvasSize, videoDurationMs]); + + return ; +} diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 77fb52de..17a59e83 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -5,9 +5,15 @@ interface RowProps extends RowDefinition { children: React.ReactNode; hint?: string; isEmpty?: boolean; + background?: React.ReactNode; } -export default function Row({ id, children, hint, isEmpty }: RowProps) { +/** + * A single horizontal lane in the timeline. Wraps the dnd-timeline `useRow` + * hook and adds an optional `background` layer (e.g. `BackgroundWaveform`), + * an empty-state hint label, and a minimum height. + */ +export default function Row({ id, children, hint, isEmpty, background }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return ( @@ -15,6 +21,7 @@ export default function Row({ id, children, hint, isEmpty }: RowProps) { className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden" style={{ ...rowWrapperStyle, minHeight: 36 }} > + {background} {isEmpty && hint && (
{hint} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 759fcbbe..734e6e1e 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { useAudioPeaks } from "@/hooks/useAudioPeaks"; import { matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; @@ -34,6 +35,7 @@ import type { ZoomFocus, ZoomRegion, } from "../types"; +import BackgroundWaveform from "./BackgroundWaveform"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; @@ -88,6 +90,8 @@ interface TimelineEditorProps { onSelectSpeed?: (id: string | null) => void; aspectRatio: AspectRatio; onAspectRatioChange: (aspectRatio: AspectRatio) => void; + videoUrl?: string; + showTrimWaveform?: boolean; } interface TimelineScaleConfig { @@ -567,6 +571,8 @@ function Timeline({ selectedBlurId, selectedSpeedId, keyframes = [], + videoUrl, + showTrimWaveform = false, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -584,12 +590,15 @@ function Timeline({ selectedBlurId?: string | null; selectedSpeedId?: string | null; keyframes?: { id: string; time: number }[]; + videoUrl?: string; + showTrimWaveform?: boolean; }) { const t = useScopedT("timeline"); const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); const localTimelineRef = useRef(null); const isScrubbingTimelineRef = useRef(false); const scrubPointerIdRef = useRef(null); + const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined); const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -788,7 +797,16 @@ function Timeline({ ))} - + + ) : undefined + } + > {trimItems.map((item) => ( Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); @@ -1700,6 +1720,8 @@ export default function TimelineEditor({ selectedBlurId={selectedBlurId} selectedSpeedId={selectedSpeedId} keyframes={keyframes} + videoUrl={videoUrl} + showTrimWaveform={showTrimWaveform} />
diff --git a/src/hooks/audioPeaksWorker.ts b/src/hooks/audioPeaksWorker.ts new file mode 100644 index 00000000..27812fd9 --- /dev/null +++ b/src/hooks/audioPeaksWorker.ts @@ -0,0 +1,40 @@ +/** + * Web Worker: computes min/max peak pairs from raw audio channel data. + * + * Input message: { channels: Float32Array[]; duration: number } + * Output message: Float32Array of length 2*N — [min0, max0, min1, max1, …] + * + * Channel buffers are transferred (zero-copy) from the caller. + * The peaks buffer is transferred back. + */ +self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: number }>) => { + const { channels, duration } = event.data; + const nCh = channels.length; + if (nCh === 0) { + (self as unknown as Worker).postMessage(new Float32Array(0)); + return; + } + + const totalSamples = channels[0].length; + const N = Math.min(24000, Math.ceil(duration * 200)); + const blockSize = totalSamples / N; + const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] + + for (let i = 0; i < N; i++) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let minVal = 0; + let maxVal = 0; + for (let j = start; j < end; j++) { + let sample = 0; + for (let c = 0; c < nCh; c++) sample += channels[c][j]; + sample /= nCh; + if (sample < minVal) minVal = sample; + if (sample > maxVal) maxVal = sample; + } + peaks[i * 2] = minVal; + peaks[i * 2 + 1] = maxVal; + } + + (self as unknown as Worker).postMessage(peaks, [peaks.buffer]); +}; diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts new file mode 100644 index 00000000..3be6ac61 --- /dev/null +++ b/src/hooks/useAudioPeaks.ts @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from "react"; +import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; + +let _audioCtx: AudioContext | null = null; +/** Returns the shared AudioContext, creating it lazily on first call. */ +function getAudioCtx(): AudioContext { + if (!_audioCtx) _audioCtx = new AudioContext(); + return _audioCtx; +} + +/** + * Offloads peak computation to a Web Worker (zero-copy via Transferable). + * Accepts an optional AbortSignal — if aborted, the worker is terminated + * immediately and the promise rejects with an AbortError. + */ +function computePeaksInWorker( + audioBuffer: AudioBuffer, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + + const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), { + type: "module", + }); + + const onAbort = () => { + worker.terminate(); + reject(new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + // slice() creates an owned copy so the transfer is safe and the + // AudioBuffer remains valid if anything else holds a reference. + const channels: Float32Array[] = []; + for (let c = 0; c < audioBuffer.numberOfChannels; c++) { + channels.push(audioBuffer.getChannelData(c).slice()); + } + + worker.onmessage = (e: MessageEvent) => { + signal?.removeEventListener("abort", onAbort); + worker.terminate(); + resolve(e.data); + }; + + worker.onerror = (e) => { + signal?.removeEventListener("abort", onAbort); + worker.terminate(); + reject(e); + }; + + worker.postMessage( + { channels, duration: audioBuffer.duration }, + channels.map((ch) => ch.buffer), + ); + }); +} + +/** + * Decodes audio from `videoUrl` and returns a Float32Array of paired + * [min, max] peak values (length = 2 * N blocks). Returns `null` while + * decoding is in progress, and stays `null` when the file has no audio + * track or decoding fails (silent degradation). + * + * - File loading uses the Electron IPC bridge for local paths (same as the exporter). + * - Peak computation runs in a Web Worker to avoid blocking the main thread. + * - Results are cached in a ref scoped to the hook instance (survives re-renders + * and waveform toggle off/on, but not component unmount). + */ +export function useAudioPeaks(videoUrl?: string): Float32Array | null { + const cacheRef = useRef>(new Map()); + const [peaks, setPeaks] = useState(() => + videoUrl ? (cacheRef.current.get(videoUrl) ?? null) : null, + ); + + useEffect(() => { + if (!videoUrl) { + setPeaks(null); + return; + } + + const cached = cacheRef.current.get(videoUrl); + if (cached) { + setPeaks(cached); + return; + } + + setPeaks(null); + let cancelled = false; + const controller = new AbortController(); + + (async () => { + try { + const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); + if (cancelled) return; + const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); + if (cancelled) return; + const p = await computePeaksInWorker(audioBuffer, controller.signal); + if (cancelled) return; + cacheRef.current.set(videoUrl, p); + setPeaks(p); + } catch (err) { + // AbortError means the effect cleaned up — no state update needed. + if (err instanceof DOMException && err.name === "AbortError") return; + // No audio track or unsupported format — clear stale data silently. + if (!cancelled) setPeaks(null); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [videoUrl]); + + return peaks; +} diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index b6525c10..bfa1a4be 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -29,6 +29,7 @@ export interface EditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -48,6 +49,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 6cd90b6a..216740a1 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "اللغة" + }, + "timeline": { + "title": "المخطط الزمني", + "waveform": "عرض الموجة الصوتية على مسار القطع" } } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 3e2541aa..b34e0c29 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "Language" + }, + "timeline": { + "title": "Timeline", + "waveform": "Show Audio Waveform on Trim Track" } } diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 99cff77e..b7b1b2e0 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "Idioma" + }, + "timeline": { + "title": "Línea de tiempo", + "waveform": "Mostrar forma de onda en pista de recorte" } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index c968c68d..b3e7fce3 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -204,5 +204,9 @@ }, "language": { "title": "Langue" + }, + "timeline": { + "title": "Montage", + "waveform": "Afficher la forme d'onde sur la piste de découpe" } } diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 0515a765..0625e057 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -202,5 +202,9 @@ }, "language": { "title": "Lingua" + }, + "timeline": { + "title": "Timeline", + "waveform": "Mostra la forma d'onda sulla traccia di ritaglio" } } diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 697e1ac5..8280e00c 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "言語" + }, + "timeline": { + "title": "タイムライン", + "waveform": "トリムトラックに音声波形を表示" } } diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index df4c6a27..9ee96664 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "언어" + }, + "timeline": { + "title": "타임라인", + "waveform": "트림 트랙에 오디오 파형 표시" } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index c0dbba8f..ebfca9d3 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "Язык" + }, + "timeline": { + "title": "Таймлайн", + "waveform": "Показать волну на дорожке обрезки" } } diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 587f2668..5b318edb 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "Dil" + }, + "timeline": { + "title": "Zaman Tüneli", + "waveform": "Kırpma Parçasında Ses Dalgasını Göster" } } diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index e83c799d..d3b67ac8 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "Ngôn ngữ" + }, + "timeline": { + "title": "Dòng thời gian", + "waveform": "Hiển thị dạng sóng âm thanh trên rãnh cắt" } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index b9b516a0..15aa57d7 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -203,5 +203,9 @@ }, "language": { "title": "语言" + }, + "timeline": { + "title": "时间轴", + "waveform": "在剪辑轨道上显示音频波形" } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index ee56459e..e579aeae 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -204,5 +204,9 @@ }, "language": { "title": "語言" + }, + "timeline": { + "title": "時間軸", + "waveform": "在剪輯軌道上顯示音訊波形" } } diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index e8093df0..752d5cd4 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -137,6 +137,27 @@ export function shouldFailDecodeEndedEarly({ return true; } +/** + * Loads a video file as an ArrayBuffer, delegating to + * `StreamingVideoDecoder.loadLocalSourceFile` for local paths (Electron IPC) + * and `StreamingVideoDecoder.loadRemoteSourceFile` for remote / blob / data URLs. + * Also returns the `contentType` derived from the blob (empty string for local + * IPC reads where no Content-Type is available). + */ +export async function loadFileAsArrayBuffer( + videoUrl: string, +): Promise<{ data: ArrayBuffer; contentType: string }> { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + + if (!isRemoteUrl && window.electronAPI) { + const { blob } = await StreamingVideoDecoder.loadLocalSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: "" }; + } + + const { blob } = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: blob.type }; +} + /** Caller must close the VideoFrame after use. */ type OnFrameCallback = ( frame: VideoFrame, @@ -157,40 +178,47 @@ export class StreamingVideoDecoder { private cancelled = false; private metadata: DecodedVideoInfo | null = null; + /** Routes to the appropriate loader based on whether the source is local or remote. */ private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await this.withTimeout( - window.electronAPI.readBinaryFile(videoUrl), + if (!isRemoteUrl && window.electronAPI) { + return this.withTimeout( + StreamingVideoDecoder.loadLocalSourceFile(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - const blob = new Blob([result.data]); - return { - blob, - file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), - }; } - - const response = await this.withTimeout( - fetch(videoUrl), + return this.withTimeout( + StreamingVideoDecoder.loadRemoteSourceFile(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); + } + + /** Loads a local video file via the Electron IPC bridge. */ + static async loadLocalSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + const result = await window.electronAPI.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message || result.error || "Failed to read source video"); + } + + const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; + const blob = new Blob([result.data]); + return { + blob, + file: new File([blob], filename, { + type: blob.type || "application/octet-stream", + }), + }; + } + + /** Loads a remote or blob video URL via fetch. */ + static async loadRemoteSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + const response = await fetch(videoUrl); if (!response.ok) { throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); } - const blob = await this.withTimeout( - response.blob(), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while reading the source video.", - ); + const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; return { blob, @@ -620,6 +648,10 @@ export class StreamingVideoDecoder { } } + /** + * Converts trim regions into the segments that should be kept. + * Returns a single full-duration segment when no trim regions are present. + */ private computeSegments( totalDuration: number, trimRegions?: TrimRegion[], @@ -648,6 +680,11 @@ export class StreamingVideoDecoder { return segments; } + /** + * Calculates the effective output duration (in seconds) and total frame count + * for a given combination of trim and speed regions at the target frame rate. + * Requires `loadMetadata()` to have been called first. + */ getExportMetrics( targetFrameRate: number, trimRegions?: TrimRegion[], @@ -668,6 +705,10 @@ export class StreamingVideoDecoder { }; } + /** + * Splits keep-segments by overlapping speed regions, annotating each + * sub-segment with its playback speed multiplier (defaults to 1×). + */ private splitBySpeed( segments: Array<{ startSec: number; endSec: number }>, speedRegions?: SpeedRegion[], @@ -700,14 +741,17 @@ export class StreamingVideoDecoder { return result.filter((s) => s.endSec - s.startSec > 0.0001); } + /** Returns the underlying WebDemuxer instance, or null if not yet loaded. */ getDemuxer(): WebDemuxer | null { return this.demuxer; } + /** Signals the decoder to stop processing at the next cancellation checkpoint. */ cancel(): void { this.cancelled = true; } + /** Cancels decoding and releases the VideoDecoder and WebDemuxer resources. */ destroy(): void { this.cancelled = true; @@ -730,6 +774,7 @@ export class StreamingVideoDecoder { } } + /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);