feat: Implement initial video editor with timeline, effects, audio waveforms, and export capabilities.

This commit is contained in:
Mahdy Arief
2026-03-28 01:25:40 +07:00
parent 6e910f0593
commit 74dfa0d5fa
13 changed files with 1018 additions and 53 deletions
@@ -0,0 +1,217 @@
import { Volume2, VolumeX, Trash2, Music } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { generateWaveform } from "@/utils/audioWaveform";
import { useEffect, useState } from "react";
import type { AudioRegion } from "./types";
interface AudioSettingsPanelProps {
audio: AudioRegion;
onVolumeChange: (volume: number) => void;
onMutedChange: (muted: boolean) => void;
onSoloedChange: (soloed: boolean) => void;
onFadeInMsChange: (ms: number) => void;
onFadeOutMsChange: (ms: number) => void;
onDelete: () => void;
}
function formatFadeTime(ms: number): string {
if (ms === 0) return "Off";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export function AudioSettingsPanel({
audio,
onVolumeChange,
onMutedChange,
onSoloedChange,
onFadeInMsChange,
onFadeOutMsChange,
onDelete,
}: AudioSettingsPanelProps) {
const [waveform, setWaveform] = useState<number[] | null>(null);
useEffect(() => {
if (audio.audioPath) {
generateWaveform(audio.audioPath, 120).then(setWaveform);
}
}, [audio.audioPath]);
const clipDurationMs = audio.endMs - audio.startMs;
const maxFadeMs = Math.max(0, Math.floor(clipDurationMs / 2));
const volumePct = Math.round(audio.volume * 100);
const isMaster = audio.id === "master";
// Mute and Solo are mutually exclusive
const handleMuteToggle = () => {
const nextMuted = !audio.muted;
onMutedChange(nextMuted);
if (nextMuted && audio.soloed) onSoloedChange(false);
};
const handleSoloToggle = () => {
const nextSoloed = !audio.soloed;
onSoloedChange(nextSoloed);
if (nextSoloed && audio.muted) onMutedChange(false);
};
return (
<section className="flex flex-col gap-3 pb-4">
{/* Header */}
<div className="flex items-center justify-between gap-2 pb-1">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-purple-500/10 text-purple-400 shrink-0">
<Music className="w-3.5 h-3.5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-100 leading-none">
{isMaster ? "Original Audio" : "Audio Region"}
</p>
{!isMaster && (
<p className="text-[10px] text-slate-500 mt-0.5 truncate max-w-[160px]">
{audio.audioPath.split(/[\\/]/).pop()}
</p>
)}
{isMaster && (
<p className="text-[10px] text-slate-500 mt-0.5">
Adjust the volume of the video's audio
</p>
)}
</div>
</div>
<span className="text-[9px] uppercase tracking-widest font-semibold text-[#2563EB] bg-[#2563EB]/10 px-2 py-1 rounded-full shrink-0">
Active
</span>
</div>
{/* Waveform — only for audio regions with a dedicated audio path */}
{waveform && !isMaster && (
<div className="h-10 bg-white/[0.03] rounded-xl border border-white/5 flex items-center overflow-hidden relative">
<div className="absolute inset-0 flex items-center pointer-events-none px-2">
<svg
width="100%"
height="100%"
viewBox={`0 0 ${waveform.length} 100`}
preserveAspectRatio="none"
className="text-purple-400 opacity-50"
>
{waveform.map((peak, i) => (
<rect
key={i}
x={i}
y={50 - peak * 50}
width={0.8}
height={peak * 100}
fill="currentColor"
rx={0.2}
/>
))}
</svg>
</div>
</div>
)}
{/* Mute / Solo — only for audio regions, not master */}
{!isMaster && (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={handleMuteToggle}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-xs font-medium transition-all",
audio.muted
? "bg-red-500/15 border-red-500/30 text-red-400"
: "bg-white/[0.03] border-white/[0.08] text-slate-400 hover:bg-white/[0.06] hover:text-slate-200"
)}
>
<VolumeX className="w-3.5 h-3.5 shrink-0" />
<span>Mute</span>
</button>
<button
type="button"
onClick={handleSoloToggle}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-xs font-medium transition-all",
audio.soloed
? "bg-amber-500/15 border-amber-500/30 text-amber-400"
: "bg-white/[0.03] border-white/[0.08] text-slate-400 hover:bg-white/[0.06] hover:text-slate-200"
)}
>
<span className="text-[11px] font-bold w-3.5 text-center shrink-0">S</span>
<span>Solo</span>
</button>
</div>
)}
{/* Volume */}
<div className="rounded-xl bg-white/[0.03] border border-white/5 px-3 py-2.5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Volume2 className="w-3.5 h-3.5 text-slate-500" />
<span className="text-xs font-medium text-slate-300">Volume</span>
</div>
<span
className={cn(
"text-[11px] tabular-nums font-semibold px-1.5 py-0.5 rounded-md",
volumePct > 100
? "text-amber-400 bg-amber-500/10"
: "text-[#2563EB] bg-[#2563EB]/10"
)}
>
{volumePct}%
</span>
</div>
<Slider
value={[audio.volume * 100]}
onValueChange={([value]) => onVolumeChange(value / 100)}
min={0}
max={200}
step={1}
/>
{volumePct > 100 && (
<p className="text-[10px] text-amber-500/70 leading-snug">
Amplifying above 100% may clip the audio.
</p>
)}
</div>
{/* Fades — only for audio regions */}
{!isMaster && (
<div className="rounded-xl bg-white/[0.03] border border-white/5 px-3 py-2.5 space-y-3">
<span className="text-xs font-medium text-slate-300">Fades</span>
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Fade In</span>
<span className="text-[10px] tabular-nums text-slate-400 font-medium">{formatFadeTime(audio.fadeInMs || 0)}</span>
</div>
<Slider value={[audio.fadeInMs || 0]} onValueChange={([v]) => onFadeInMsChange(v)} min={0} max={maxFadeMs} step={50} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Fade Out</span>
<span className="text-[10px] tabular-nums text-slate-400 font-medium">{formatFadeTime(audio.fadeOutMs || 0)}</span>
</div>
<Slider value={[audio.fadeOutMs || 0]} onValueChange={([v]) => onFadeOutMsChange(v)} min={0} max={maxFadeMs} step={50} />
</div>
</div>
</div>
)}
{/* Delete — only for audio regions */}
{!isMaster && (
<Button
onClick={onDelete}
variant="ghost"
size="sm"
className="w-full gap-2 text-red-400/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 transition-all mt-1"
>
<Trash2 className="w-3.5 h-3.5" />
Remove Audio Region
</Button>
)}
</section>
);
}
+86 -1
View File
@@ -1,4 +1,4 @@
import { MessageSquare, Palette, Trash2, Upload, X } from "lucide-react";
import { MessageSquare, Music, Palette, Trash2, Upload, X } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -26,11 +26,13 @@ import parchedCursorUrl from "../../assets/cursors/parched/default.png";
import turtleCursorUrl from "../../assets/cursors/turtle/default.png";
import { useI18n, useScopedT } from "../../contexts/I18nContext";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { AudioSettingsPanel } from "./AudioSettingsPanel";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import { SliderControl } from "./SliderControl";
import type {
AnnotationRegion,
AnnotationType,
AudioRegion,
AutoCaptionAnimation,
AutoCaptionSettings,
CaptionCue,
@@ -111,6 +113,7 @@ export type EditorEffectSection =
| "cursor"
| "captions"
| "webcam"
| "audio"
| "zoom"
| "frame"
| "crop";
@@ -233,7 +236,24 @@ interface SettingsPanelProps {
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
onSpeedDelete?: (id: string) => void;
audioRegions?: AudioRegion[];
selectedAudioId?: string | null;
onAudioVolumeChange?: (id: string, volume: number) => void;
onAudioMutedChange?: (id: string, muted: boolean) => void;
onAudioSoloedChange?: (id: string, soloed: boolean) => void;
onAudioFadeInMsChange?: (id: string, ms: number) => void;
onAudioFadeOutMsChange?: (id: string, ms: number) => void;
onAudioDelete?: (id: string) => void;
timeSelection?: { startMs: number; endMs: number } | null;
isMasterSelected?: boolean;
masterAudioVolume?: number;
masterAudioMuted?: boolean;
masterAudioSoloed?: boolean;
videoDuration?: number;
videoPath?: string;
onMasterAudioVolumeChange?: (volume: number) => void;
onMasterAudioMutedChange?: (muted: boolean) => void;
onMasterAudioSoloedChange?: (soloed: boolean) => void;
}
export default SettingsPanel;
@@ -591,7 +611,24 @@ export function SettingsPanel({
selectedSpeedValue,
onSpeedChange,
onSpeedDelete,
audioRegions = [],
selectedAudioId,
onAudioVolumeChange,
onAudioMutedChange,
onAudioSoloedChange,
onAudioFadeInMsChange,
onAudioFadeOutMsChange,
onAudioDelete,
timeSelection,
isMasterSelected,
masterAudioVolume = 1,
masterAudioMuted = false,
masterAudioSoloed = false,
videoDuration,
videoPath,
onMasterAudioVolumeChange,
onMasterAudioMutedChange,
onMasterAudioSoloedChange,
}: SettingsPanelProps) {
const tSettings = useScopedT("settings");
const { t } = useI18n();
@@ -1782,6 +1819,54 @@ export function SettingsPanel({
return sceneSectionContent;
case "captions":
return captionsSectionContent;
case "audio": {
const selectedAudio = audioRegions?.find((a) => a.id === selectedAudioId);
if (selectedAudio) {
return (
<AudioSettingsPanel
audio={selectedAudio}
onVolumeChange={(volume) => onAudioVolumeChange?.(selectedAudio.id, volume)}
onMutedChange={(muted) => onAudioMutedChange?.(selectedAudio.id, muted)}
onSoloedChange={(soloed) => onAudioSoloedChange?.(selectedAudio.id, soloed)}
onFadeInMsChange={(ms) => onAudioFadeInMsChange?.(selectedAudio.id, ms)}
onFadeOutMsChange={(ms) => onAudioFadeOutMsChange?.(selectedAudio.id, ms)}
onDelete={() => onAudioDelete?.(selectedAudio.id)}
/>
);
}
if (isMasterSelected) {
const masterAudioMock: AudioRegion = {
id: "master",
startMs: 0,
endMs: (videoDuration || 0) * 1000,
volume: masterAudioVolume,
muted: masterAudioMuted,
soloed: masterAudioSoloed,
audioPath: videoPath || "",
fadeInMs: 0,
fadeOutMs: 0,
};
return (
<AudioSettingsPanel
audio={masterAudioMock}
onVolumeChange={onMasterAudioVolumeChange || (() => {})}
onMutedChange={onMasterAudioMutedChange || (() => {})}
onSoloedChange={onMasterAudioSoloedChange || (() => {})}
onFadeInMsChange={() => {}}
onFadeOutMsChange={() => {}}
onDelete={() => {}}
/>
);
}
return (
<div className="flex flex-col items-center justify-center h-full text-slate-500 gap-2 py-12">
<Music className="w-8 h-8 opacity-20" />
<p className="text-xs">Select an audio region to edit its settings</p>
</div>
);
}
case "cursor":
return (
<section className="flex flex-col gap-2">
+275 -22
View File
@@ -6,6 +6,7 @@ import {
FolderOpen,
Languages,
MousePointer2,
Music,
Redo2,
Save,
Sparkles,
@@ -130,6 +131,11 @@ type EditorHistorySnapshot = {
selectedAnnotationId: string | null;
selectedAudioId: string | null;
selectedCaptionId: string | null;
masterAudioMuted: boolean;
masterAudioSoloed: boolean;
masterAudioVolume: number;
audioTrackVolume: number;
isMasterSelected: boolean;
};
type PendingExportSave = {
@@ -402,6 +408,11 @@ export default function VideoEditor() {
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDropdown, setShowExportDropdown] = useState(false);
const [masterAudioMuted, setMasterAudioMuted] = useState(false);
const [masterAudioSoloed, setMasterAudioSoloed] = useState(false);
const [masterAudioVolume, setMasterAudioVolume] = useState(1);
const [audioTrackVolume, setAudioTrackVolume] = useState(1);
const [isMasterSelected, setIsMasterSelected] = useState(false);
const [previewVolume, setPreviewVolume] = useState(1);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(initialEditorPreferences.aspectRatio);
const [activeEffectSection, setActiveEffectSection] = useState<EditorEffectSection>("scene");
@@ -423,8 +434,14 @@ export default function VideoEditor() {
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null);
const [showCropModal, setShowCropModal] = useState(false);
const [previewVersion, setPreviewVersion] = useState(0);
const [isAudioEngineReady, setIsAudioEngineReady] = useState(false);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const masterGainRef = useRef<GainNode | null>(null);
const audioRegionNodesRef = useRef<Map<string, { source: MediaElementAudioSourceNode; gain: GainNode }>>(new Map());
const videoAudioNodeRef = useRef<{ source: MediaElementAudioSourceNode; gain: GainNode } | null>(null);
const projectBrowserTriggerRef = useRef<HTMLButtonElement | null>(null);
const projectBrowserFallbackTriggerRef = useRef<HTMLButtonElement | null>(null);
const nextZoomIdRef = useRef(1);
@@ -798,6 +815,7 @@ export default function VideoEditor() {
label: t("settings.sections.captions", "Captions"),
icon: Captions,
},
{ id: "audio" as const, label: t("settings.sections.audio", "Audio"), icon: Music },
],
[t],
);
@@ -973,6 +991,11 @@ export default function VideoEditor() {
selectedAnnotationId,
selectedAudioId,
selectedCaptionId,
masterAudioMuted,
masterAudioSoloed,
masterAudioVolume,
audioTrackVolume,
isMasterSelected,
};
}, [
zoomRegions,
@@ -987,6 +1010,11 @@ export default function VideoEditor() {
selectedAnnotationId,
selectedAudioId,
selectedCaptionId,
masterAudioMuted,
masterAudioSoloed,
masterAudioVolume,
audioTrackVolume,
isMasterSelected,
]);
const applyHistorySnapshot = useCallback(
@@ -1004,6 +1032,11 @@ export default function VideoEditor() {
setSelectedAnnotationId(snapshot.selectedAnnotationId);
setSelectedAudioId(snapshot.selectedAudioId);
setSelectedCaptionId(snapshot.selectedCaptionId);
setMasterAudioMuted(snapshot.masterAudioMuted);
setMasterAudioSoloed(snapshot.masterAudioSoloed);
setMasterAudioVolume(snapshot.masterAudioVolume);
setAudioTrackVolume(snapshot.audioTrackVolume);
setIsMasterSelected(snapshot.isMasterSelected);
nextZoomIdRef.current = deriveNextId(
"zoom",
@@ -1128,6 +1161,11 @@ export default function VideoEditor() {
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setMasterAudioMuted(normalizedEditor.masterAudioMuted);
setMasterAudioSoloed(normalizedEditor.masterAudioSoloed);
setMasterAudioVolume(normalizedEditor.masterAudioVolume);
setAudioTrackVolume(normalizedEditor.audioTrackVolume);
setIsMasterSelected(normalizedEditor.isMasterSelected ?? false);
setSelectedZoomId(null);
setSelectedTrimId(null);
@@ -1366,6 +1404,8 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
masterAudioMuted,
masterAudioSoloed,
whisperExecutablePath,
whisperModelPath,
whisperSelectedModel: autoCaptionSettings.selectedModel,
@@ -1482,6 +1522,19 @@ export default function VideoEditor() {
toast.success("Whisper executable selected");
}, []);
const handleSelectMaster = useCallback((selected: boolean) => {
setIsMasterSelected(selected);
if (selected) {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedAudioId(null);
setSelectedCaptionId(null);
setActiveEffectSection("audio");
}
}, []);
const handleDownloadWhisperModel = useCallback(async () => {
if (whisperModelDownloadStatus === "downloading") {
return;
@@ -1789,6 +1842,30 @@ export default function VideoEditor() {
};
}, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs]);
useEffect(() => {
const video = videoPlaybackRef.current?.video;
if (!video || !audioContextRef.current || (videoAudioNodeRef.current && videoAudioNodeRef.current.source.mediaElement === video)) {
return;
}
try {
// If we had an old source node for a different video, disconnect it
if (videoAudioNodeRef.current) {
videoAudioNodeRef.current.source.disconnect();
videoAudioNodeRef.current.gain.disconnect();
}
const source = audioContextRef.current.createMediaElementSource(video);
const gain = audioContextRef.current.createGain();
source.connect(gain);
gain.connect(masterGainRef.current!);
videoAudioNodeRef.current = { source, gain };
console.log("[VideoEditor] Video audio routed through Web Audio API");
} catch (e) {
console.warn("[VideoEditor] Could not route video audio", e);
}
}, [videoPlaybackRef.current?.video, previewVersion, isAudioEngineReady]);
useEffect(() => {
let mounted = true;
@@ -1820,6 +1897,25 @@ export default function VideoEditor() {
};
}, [videoPath]);
// Apply master volume to the master gain node
useEffect(() => {
if (masterGainRef.current) {
masterGainRef.current.gain.setTargetAtTime(masterAudioVolume * previewVolume, 0, 0.03);
}
}, [masterAudioVolume, previewVolume]);
// Handle video master solo/mute
useEffect(() => {
if (videoAudioNodeRef.current) {
const hasGlobalSolo = masterAudioSoloed || audioRegions.some((r) => r.soloed);
let vol = 1;
if (masterAudioMuted || (hasGlobalSolo && !masterAudioSoloed)) {
vol = 0;
}
videoAudioNodeRef.current.gain.gain.setTargetAtTime(vol, 0, 0.03);
}
}, [masterAudioMuted, masterAudioSoloed, audioRegions]);
const normalizedCursorTelemetry = useMemo(() => {
if (cursorTelemetry.length === 0) {
return [] as CursorTelemetryPoint[];
@@ -1938,11 +2034,34 @@ export default function VideoEditor() {
autoSuggestedVideoPathRef.current = videoPath;
}, [videoPath, duration, effectiveCursorTelemetry, loopCursor, zoomRegions.length]);
const initAudioContext = useCallback(() => {
if (audioContextRef.current) {
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume().then(() => setIsAudioEngineReady(true));
}
return;
}
try {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
audioContextRef.current = ctx;
masterGainRef.current = masterGain;
setIsAudioEngineReady(true);
console.log("[VideoEditor] Web Audio API context initialized");
} catch (e) {
console.error("[VideoEditor] Failed to initialize AudioContext", e);
}
}, []);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
const video = playback?.video;
if (!playback || !video) return;
initAudioContext();
if (!video.paused && !video.ended) {
playback.pause();
} else {
@@ -1964,6 +2083,7 @@ export default function VideoEditor() {
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedCaptionId(null);
setIsMasterSelected(false);
}
}, []);
@@ -1975,6 +2095,7 @@ export default function VideoEditor() {
setSelectedAudioId(null);
setSelectedSpeedId(null);
setSelectedCaptionId(null);
setIsMasterSelected(false);
}
}, []);
@@ -1986,6 +2107,7 @@ export default function VideoEditor() {
setSelectedAudioId(null);
setSelectedSpeedId(null);
setSelectedCaptionId(null);
setIsMasterSelected(false);
}
}, []);
@@ -2033,6 +2155,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedCaptionId(null);
setIsMasterSelected(false);
}, []);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
@@ -2122,6 +2245,7 @@ export default function VideoEditor() {
setSelectedAnnotationId(null);
setSelectedAudioId(null);
setSelectedCaptionId(null);
setIsMasterSelected(false);
}
}, []);
@@ -2173,6 +2297,8 @@ export default function VideoEditor() {
setSelectedAnnotationId(null);
setSelectedSpeedId(null);
setSelectedCaptionId(null);
setIsMasterSelected(false);
setActiveEffectSection("audio");
}
}, []);
@@ -2192,6 +2318,7 @@ export default function VideoEditor() {
setSelectedAnnotationId(null);
setSelectedSpeedId(null);
setSelectedCaptionId(null);
setIsMasterSelected(false);
}, []);
const handleAudioSpanChange = useCallback((id: string, span: Span) => {
@@ -2218,6 +2345,26 @@ export default function VideoEditor() {
[selectedAudioId],
);
const handleAudioVolumeChange = useCallback((id: string, volume: number) => {
setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, volume } : r)));
}, []);
const handleAudioMutedChange = useCallback((id: string, muted: boolean) => {
setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, muted } : r)));
}, []);
const handleAudioSoloedChange = useCallback((id: string, soloed: boolean) => {
setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, soloed } : r)));
}, []);
const handleAudioFadeInMsChange = useCallback((id: string, fadeInMs: number) => {
setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, fadeInMs } : r)));
}, []);
const handleAudioFadeOutMsChange = useCallback((id: string, fadeOutMs: number) => {
setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, fadeOutMs } : r)));
}, []);
const handleCaptionSpanChange = useCallback((id: string, span: Span) => {
setAutoCaptions((prev) =>
prev.map((caption) =>
@@ -2240,6 +2387,7 @@ export default function VideoEditor() {
setSelectedAnnotationId(null);
setSelectedSpeedId(null);
setSelectedAudioId(null);
setIsMasterSelected(false);
}
}, []);
@@ -2271,7 +2419,8 @@ export default function VideoEditor() {
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedCaptionId(null); // Added
setSelectedCaptionId(null);
setIsMasterSelected(false);
}, []);
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
@@ -2493,40 +2642,75 @@ export default function VideoEditor() {
useEffect(() => {
const existing = audioElementsRef.current;
const currentIds = new Set(audioRegions.map((r) => r.id));
const unused = new Set(existing.keys());
// Remove old audio elements
for (const [id, audio] of existing) {
if (!currentIds.has(id)) {
audio.pause();
audio.src = "";
existing.delete(id);
}
}
const hasAudioContext = !!(audioContextRef.current && masterGainRef.current && isAudioEngineReady);
// Create/update audio elements
for (const region of audioRegions) {
unused.delete(region.id);
let audio = existing.get(region.id);
if (!audio) {
audio = new Audio();
audio.preload = "auto";
// Ensure cross-origin is handled to avoid CORS issues with AudioContext
audio.crossOrigin = "anonymous";
existing.set(region.id, audio);
}
const expectedSrc = toFileUrl(region.audioPath);
if (audio.src !== expectedSrc) {
audio.src = expectedSrc;
}
audio.volume = Math.max(0, Math.min(1, region.volume * previewVolume));
// Route through Web Audio API if ready
if (hasAudioContext && !audioRegionNodesRef.current.has(region.id)) {
try {
const source = audioContextRef.current!.createMediaElementSource(audio);
const gain = audioContextRef.current!.createGain();
source.connect(gain);
gain.connect(masterGainRef.current!);
audioRegionNodesRef.current.set(region.id, { source, gain });
console.log(`[VideoEditor] Audio region ${region.id} routed through GainNode`);
} catch (e) {
console.warn(`[VideoEditor] Failed to route audio ${region.id}:`, e);
}
}
// Initial volume setup — masterAudioMuted only affects the video element, not audio regions
const hasGlobalSolo = audioRegions.some((r) => r.soloed);
let baseVolume = region.volume * audioTrackVolume * previewVolume * masterAudioVolume;
if (region.muted || (hasGlobalSolo && !region.soloed)) {
baseVolume = 0;
}
const nodeEntry = audioRegionNodesRef.current.get(region.id);
if (nodeEntry) {
audio.volume = 1;
nodeEntry.gain.gain.setTargetAtTime(baseVolume, 0, 0.03);
} else {
// Fallback to native volume (limited to 100%)
audio.volume = Math.max(0, Math.min(1, baseVolume));
}
}
return () => {
for (const audio of existing.values()) {
audio.pause();
audio.src = "";
for (const id of unused) {
const audio = existing.get(id);
if (audio) {
audio.pause();
audio.src = "";
}
existing.delete(id);
const nodeEntry = audioRegionNodesRef.current.get(id);
if (nodeEntry) {
try {
nodeEntry.source.disconnect();
nodeEntry.gain.disconnect();
} catch (e) { /* ignore */ }
audioRegionNodesRef.current.delete(id);
}
}
existing.clear();
};
}, [audioRegions, previewVolume]);
}, [audioRegions, previewVolume, masterAudioVolume, audioTrackVolume, isAudioEngineReady]);
// Sync audio playback with video currentTime and isPlaying state
useEffect(() => {
@@ -2538,9 +2722,39 @@ export default function VideoEditor() {
const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs;
if (isPlaying && isInRegion) {
// Calculate fade multiplier
let fadeMultiplier = 1;
if (region.fadeInMs && currentTimeMs < region.startMs + region.fadeInMs) {
fadeMultiplier = (currentTimeMs - region.startMs) / region.fadeInMs;
} else if (region.fadeOutMs && currentTimeMs > region.endMs - region.fadeOutMs) {
fadeMultiplier = (region.endMs - currentTimeMs) / region.fadeOutMs;
}
fadeMultiplier = Math.max(0, Math.min(1, fadeMultiplier));
// masterAudioMuted only affects the video element — not audio regions
const hasGlobalSolo = audioRegions.some((r) => r.soloed);
let baseVolume = region.volume * audioTrackVolume * previewVolume * masterAudioVolume;
if (region.muted || (hasGlobalSolo && !region.soloed)) {
baseVolume = 0;
}
const targetVolume = baseVolume * fadeMultiplier;
const nodeEntry = audioRegionNodesRef.current.get(region.id);
if (nodeEntry) {
// Use Web Audio gain automation for smooth high-precision fade
nodeEntry.gain.gain.setTargetAtTime(targetVolume, 0, 0.03);
} else {
// Fallback to native volume (limited to 1.0)
const fallbackValue = Math.max(0, Math.min(1, targetVolume));
if (Math.abs(audio.volume - fallbackValue) > 0.01) {
audio.volume = fallbackValue;
}
}
const audioOffset = (currentTimeMs - region.startMs) / 1000;
// Only seek if significantly out of sync (> 200ms)
if (Math.abs(audio.currentTime - audioOffset) > 0.2) {
// Only seek if significantly out of sync (> 20ms) - tightened for Web Audio era
if (Math.abs(audio.currentTime - audioOffset) > 0.02) {
audio.currentTime = audioOffset;
}
if (audio.paused) {
@@ -2552,7 +2766,7 @@ export default function VideoEditor() {
}
}
}
}, [isPlaying, currentTime, audioRegions]);
}, [isPlaying, currentTime, audioRegions, previewVolume, masterAudioVolume, audioTrackVolume]);
const showExportSuccessToast = useCallback((filePath: string) => {
toast.success(`Exported successfully to ${filePath}`, {
@@ -2762,6 +2976,9 @@ export default function VideoEditor() {
cursorClickBounceDuration,
cursorSway,
audioRegions,
masterAudioVolume: masterAudioVolume ?? 1,
audioTrackVolume: audioTrackVolume ?? 1,
masterAudioMuted: masterAudioMuted ?? false,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
@@ -3370,6 +3587,13 @@ export default function VideoEditor() {
>
<VideoPlayback
key={`${videoPath || "no-video"}:${previewVersion}`}
volume={(() => {
const hasGlobalSolo = masterAudioSoloed || audioRegions.some((r) => r.soloed);
if (masterAudioMuted || (hasGlobalSolo && !masterAudioSoloed)) {
return 0;
}
return previewVolume * masterAudioVolume;
})()}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
@@ -3420,7 +3644,6 @@ export default function VideoEditor() {
cursorClickBounce={cursorClickBounce}
cursorClickBounceDuration={cursorClickBounceDuration}
cursorSway={cursorSway}
volume={previewVolume}
/>
</div>
</div>
@@ -3459,6 +3682,15 @@ export default function VideoEditor() {
<div className="h-full min-h-0 bg-[#17171a] rounded-2xl border border-white/10 shadow-lg overflow-auto flex flex-col">
<TimelineEditor
videoDuration={duration}
videoPath={videoPath || undefined}
masterAudioMuted={masterAudioMuted}
onMasterAudioMutedChange={setMasterAudioMuted}
masterAudioSoloed={masterAudioSoloed}
onMasterAudioSoloedChange={setMasterAudioSoloed}
masterAudioVolume={masterAudioVolume}
onMasterAudioVolumeChange={setMasterAudioVolume}
audioTrackVolume={audioTrackVolume}
onAudioTrackVolumeChange={setAudioTrackVolume}
currentTime={currentTime}
onSeek={handleSeek}
cursorTelemetry={normalizedCursorTelemetry}
@@ -3484,6 +3716,8 @@ export default function VideoEditor() {
audioRegions={audioRegions}
onAudioAdded={handleAudioAdded}
onAudioSpanChange={handleAudioSpanChange}
onAudioMutedChange={handleAudioMutedChange}
onAudioSoloedChange={handleAudioSoloedChange}
onAudioDelete={handleAudioDelete}
selectedAudioId={selectedAudioId}
onSelectAudio={handleSelectAudio}
@@ -3504,6 +3738,8 @@ export default function VideoEditor() {
isCropped={isCropped}
timeSelection={timeSelection}
onTimeSelectionChange={setTimeSelection}
isMasterSelected={isMasterSelected}
onSelectMaster={handleSelectMaster}
/>
</div>
</Panel>
@@ -3611,9 +3847,26 @@ export default function VideoEditor() {
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
audioRegions={audioRegions}
selectedAudioId={selectedAudioId}
onAudioVolumeChange={handleAudioVolumeChange}
onAudioMutedChange={handleAudioMutedChange}
onAudioSoloedChange={handleAudioSoloedChange}
onAudioFadeInMsChange={handleAudioFadeInMsChange}
onAudioFadeOutMsChange={handleAudioFadeOutMsChange}
onAudioDelete={handleAudioDelete}
selectedCaptionId={selectedCaptionId}
onSelectCaption={setSelectedCaptionId}
timeSelection={timeSelection}
isMasterSelected={isMasterSelected}
masterAudioVolume={masterAudioVolume}
masterAudioMuted={masterAudioMuted}
masterAudioSoloed={masterAudioSoloed}
videoDuration={duration}
videoPath={videoPath || undefined}
onMasterAudioVolumeChange={setMasterAudioVolume}
onMasterAudioMutedChange={setMasterAudioMuted}
onMasterAudioSoloedChange={setMasterAudioSoloed}
/>
</div>
</div>
@@ -33,6 +33,9 @@ type PersistedEditorControls = Pick<
| "gifFrameRate"
| "gifLoop"
| "gifSizePreset"
| "masterAudioMuted"
| "masterAudioSoloed"
| "masterAudioVolume"
>;
type PartialEditorControls = Partial<PersistedEditorControls>;
@@ -82,6 +85,9 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate,
gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop,
gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset,
masterAudioMuted: DEFAULT_EDITOR_CONTROLS.masterAudioMuted,
masterAudioSoloed: DEFAULT_EDITOR_CONTROLS.masterAudioSoloed,
masterAudioVolume: DEFAULT_EDITOR_CONTROLS.masterAudioVolume,
customAspectWidth: "16",
customAspectHeight: "9",
customWallpapers: [],
@@ -161,6 +167,9 @@ function normalizeEditorControls(
gifFrameRate: raw.gifFrameRate ?? fallback.gifFrameRate,
gifLoop: raw.gifLoop ?? fallback.gifLoop,
gifSizePreset: raw.gifSizePreset ?? fallback.gifSizePreset,
masterAudioMuted: raw.masterAudioMuted ?? fallback.masterAudioMuted,
masterAudioSoloed: raw.masterAudioSoloed ?? fallback.masterAudioSoloed,
masterAudioVolume: raw.masterAudioVolume ?? fallback.masterAudioVolume,
};
const normalized = normalizeProjectEditor(candidate);
@@ -197,6 +206,9 @@ function normalizeEditorControls(
gifFrameRate: normalized.gifFrameRate,
gifLoop: normalized.gifLoop,
gifSizePreset: normalized.gifSizePreset,
masterAudioMuted: normalized.masterAudioMuted,
masterAudioSoloed: normalized.masterAudioSoloed,
masterAudioVolume: normalized.masterAudioVolume,
};
}
@@ -94,6 +94,11 @@ export interface ProjectEditorState {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
masterAudioMuted: boolean;
masterAudioSoloed: boolean;
masterAudioVolume: number;
audioTrackVolume: number;
isMasterSelected?: boolean;
}
export interface EditorProjectData {
@@ -404,6 +409,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
endMs,
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
muted: typeof region.muted === "boolean" ? region.muted : false,
soloed: typeof region.soloed === "boolean" ? region.soloed : false,
fadeInMs: isFiniteNumber(region.fadeInMs) ? clamp(region.fadeInMs, 0, 10000) : 0,
fadeOutMs: isFiniteNumber(region.fadeOutMs) ? clamp(region.fadeOutMs, 0, 10000) : 0,
};
})
: [];
@@ -683,6 +692,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
masterAudioMuted: typeof editor.masterAudioMuted === "boolean" ? editor.masterAudioMuted : false,
masterAudioSoloed: typeof editor.masterAudioSoloed === "boolean" ? editor.masterAudioSoloed : false,
masterAudioVolume: isFiniteNumber(editor.masterAudioVolume) ? clamp(editor.masterAudioVolume, 0, 2) : 1,
audioTrackVolume: isFiniteNumber(editor.audioTrackVolume) ? clamp(editor.audioTrackVolume, 0, 2) : 1,
isMasterSelected: Boolean(editor.isMasterSelected),
};
}
+91 -17
View File
@@ -1,9 +1,10 @@
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { Gauge, MessageSquare, Music, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import glassStyles from "./ItemGlass.module.css";
import { generateWaveform } from "@/utils/audioWaveform";
interface ItemProps {
id: string;
@@ -14,7 +15,13 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
audioPath?: string;
variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption' | 'caption-range';
isDraggable?: boolean;
isResizable?: boolean;
muted?: boolean;
fadeInMs?: number;
fadeOutMs?: number;
}
// Map zoom depth to multiplier labels
@@ -45,8 +52,14 @@ export default function Item({
onSelect,
zoomDepth = 1,
speedValue,
audioPath,
variant = "zoom",
children,
isDraggable = true,
isResizable = true,
muted = false,
fadeInMs,
fadeOutMs,
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
@@ -54,6 +67,8 @@ export default function Item({
data: { rowId },
});
const durationMs = span.end - span.start;
const isZoom = variant === 'zoom';
const isTrim = variant === 'trim';
const isSpeed = variant === 'speed';
@@ -61,6 +76,16 @@ export default function Item({
const isCaption = variant === 'caption';
const isCaptionRange = variant === 'caption-range';
const [waveform, setWaveform] = useState<number[] | null>(null);
useEffect(() => {
if (isAudio && audioPath) {
generateWaveform(audioPath).then((peaks) => {
setWaveform(peaks);
});
}
}, [isAudio, audioPath]);
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
@@ -101,8 +126,8 @@ export default function Item({
<div
ref={setNodeRef}
style={safeItemStyle}
{...listeners}
{...attributes}
{...(isDraggable ? listeners : {})}
{...(isDraggable ? attributes : {})}
onPointerDownCapture={() => onSelect?.()}
className="group h-full"
>
@@ -111,7 +136,8 @@ export default function Item({
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected
isSelected && glassStyles.selected,
muted && "opacity-40 grayscale-[0.5]"
)}
style={{ height: "100%", minHeight: 22, color: '#fff', minWidth: 24 }}
onClick={(event) => {
@@ -119,19 +145,67 @@ export default function Item({
onSelect?.();
}}
>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize right"
/>
{/* Waveform Background for Audio */}
{isAudio && waveform && (
<div className="absolute inset-0 z-0 opacity-30 flex items-center pointer-events-none px-4">
<svg
width="100%"
height="80%"
viewBox={`0 0 ${waveform.length} 100`}
preserveAspectRatio="none"
className="text-white"
>
{waveform.map((peak, i) => (
<rect
key={i}
x={i}
y={50 - (peak * 50)}
width={0.8}
height={peak * 100}
fill="currentColor"
rx={0.2}
/>
))}
</svg>
</div>
)}
{/* Fade Visualizations */}
{isAudio && (fadeInMs || fadeOutMs) && (
<div className="absolute inset-0 z-[5] pointer-events-none flex">
{fadeInMs && fadeInMs > 0 && (
<div
className="h-full bg-gradient-to-r from-black/40 to-transparent"
style={{ width: `${(fadeInMs / durationMs) * 100}%` }}
/>
)}
<div className="flex-1" />
{fadeOutMs && fadeOutMs > 0 && (
<div
className="h-full bg-gradient-to-l from-black/40 to-transparent"
style={{ width: `${(fadeOutMs / durationMs) * 100}%` }}
/>
)}
</div>
)}
{isResizable && (
<>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize right"
/>
</>
)}
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden max-w-full">
<div className="flex items-center gap-1.5 max-w-full">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
@@ -156,7 +230,7 @@ export default function Item({
) : isAudio ? (
<>
<Music className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight truncate max-w-full">
<span className="text-[11px] font-semibold tracking-tight truncate max-w-full px-2">
{children}
</span>
</>
+14 -5
View File
@@ -7,9 +7,10 @@ interface RowProps extends RowDefinition {
hint?: string;
isEmpty?: boolean;
labelColor?: string;
controls?: React.ReactNode;
}
export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666' }: RowProps) {
export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666', controls }: RowProps) {
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
return (
@@ -17,12 +18,20 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = '
className="border-b border-[#18181b] bg-[#18181b] relative flex-1 min-h-[26px]"
style={{ ...rowWrapperStyle, marginBottom: 2 }}
>
{label && (
{(label || controls) && (
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
style={{ color: labelColor, writingMode: 'horizontal-tb' }}
className="absolute left-1.5 top-1/2 -translate-y-1/2 z-20 flex items-center gap-2"
style={{ writingMode: 'horizontal-tb' }}
>
{label}
{label && (
<div
className="text-[9px] font-semibold uppercase tracking-widest select-none pointer-events-none"
style={{ color: labelColor }}
>
{label}
</div>
)}
{controls}
</div>
)}
{isEmpty && hint && (
@@ -31,6 +31,7 @@ const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const SPEED_ROW_ID = "row-speed";
const ORIGINAL_AUDIO_ROW_ID = "row-original-audio";
const AUDIO_ROW_ID = "row-audio";
const CAPTION_ROW_ID = "row-caption";
const FALLBACK_RANGE_MS = 1000;
@@ -40,6 +41,7 @@ const DRAG_THRESHOLD_PX = 5;
interface TimelineEditorProps {
videoDuration: number;
videoPath?: string;
currentTime: number;
onSeek?: (time: number) => void;
cursorTelemetry?: CursorTelemetryPoint[];
@@ -72,9 +74,19 @@ interface TimelineEditorProps {
audioRegions?: AudioRegion[];
onAudioAdded?: (span: Span, audioPath: string) => void;
onAudioSpanChange?: (id: string, span: Span) => void;
onAudioMutedChange?: (id: string, muted: boolean) => void;
onAudioSoloedChange?: (id: string, soloed: boolean) => void;
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
masterAudioMuted?: boolean;
onMasterAudioMutedChange?: (muted: boolean) => void;
masterAudioSoloed?: boolean;
onMasterAudioSoloedChange?: (soloed: boolean) => void;
masterAudioVolume?: number;
audioTrackVolume?: number;
onMasterAudioVolumeChange?: (volume: number) => void;
onAudioTrackVolumeChange?: (volume: number) => void;
autoCaptions?: CaptionCue[];
onCaptionSpanChange?: (id: string, span: Span) => void;
selectedCaptionId?: string | null;
@@ -86,6 +98,8 @@ interface TimelineEditorProps {
isCropped?: boolean;
timeSelection?: TimeSelection | null;
onTimeSelectionChange?: (selection: TimeSelection | null) => void;
isMasterSelected?: boolean;
onSelectMaster?: (selected: boolean) => void;
}
interface TimelineScaleConfig {
@@ -101,7 +115,12 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
audioPath?: string;
variant: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption';
muted?: boolean;
soloed?: boolean;
fadeInMs?: number;
fadeOutMs?: number;
}
const SCALE_CANDIDATES = [
@@ -202,6 +221,7 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) {
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
}
function formatPlayheadTime(ms: number): string {
const s = ms / 1000;
const min = Math.floor(s / 60);
@@ -466,6 +486,15 @@ function Timeline({
onSelectAnnotation,
onSelectSpeed,
onSelectAudio,
onAudioMutedChange,
onAudioSoloedChange,
audioRegions,
masterAudioMuted = false,
onMasterAudioMutedChange,
masterAudioSoloed = false,
onMasterAudioSoloedChange,
masterAudioVolume = 1,
audioTrackVolume = 1,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
@@ -478,6 +507,8 @@ function Timeline({
keyframes = [],
timeSelection,
onTimeSelectionChange,
isMasterSelected = false,
onSelectMaster,
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
@@ -488,6 +519,17 @@ function Timeline({
onSelectAnnotation?: (id: string | null) => void;
onSelectSpeed?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
onAudioMutedChange?: (id: string, muted: boolean) => void;
onAudioSoloedChange?: (id: string, soloed: boolean) => void;
audioRegions?: AudioRegion[];
masterAudioMuted?: boolean;
onMasterAudioMutedChange?: (muted: boolean) => void;
masterAudioSoloed?: boolean;
onMasterAudioSoloedChange?: (soloed: boolean) => void;
masterAudioVolume?: number;
audioTrackVolume?: number;
onMasterAudioVolumeChange?: (volume: number) => void;
onAudioTrackVolumeChange?: (volume: number) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
@@ -500,6 +542,8 @@ function Timeline({
keyframes?: { id: string; time: number }[];
timeSelection?: TimeSelection | null;
onTimeSelectionChange?: (selection: TimeSelection | null) => void;
isMasterSelected?: boolean;
onSelectMaster?: (selected: boolean) => void;
}) {
const { setTimelineRef, style, sidebarWidth = 0, range, pixelsToValue, valueToPixels } = useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
@@ -610,6 +654,7 @@ function Timeline({
onSelectSpeed?.(null);
onSelectAudio?.(null);
onSelectCaption?.(null);
onSelectMaster?.(false);
onClearBlockSelection?.();
const rect = e.currentTarget.getBoundingClientRect();
@@ -637,9 +682,119 @@ function Timeline({
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
const originalAudioItems = items.filter(item => item.rowId === ORIGINAL_AUDIO_ROW_ID);
const audioItems = items.filter(item => item.rowId === AUDIO_ROW_ID);
const captionItems = items.filter(item => item.rowId === CAPTION_ROW_ID);
const handleAllAudioMute = useCallback(() => {
if (!audioRegions || audioRegions.length === 0) return;
const allMuted = audioRegions.every((r) => r.muted);
audioRegions.forEach((r) => {
onAudioMutedChange?.(r.id, !allMuted);
if (!allMuted && r.soloed) onAudioSoloedChange?.(r.id, false);
});
}, [audioRegions, onAudioMutedChange, onAudioSoloedChange]);
const handleAllAudioSolo = useCallback(() => {
if (!audioRegions || audioRegions.length === 0) return;
const anySoloed = audioRegions.some((r) => r.soloed);
audioRegions.forEach((r) => {
onAudioSoloedChange?.(r.id, !anySoloed);
if (!anySoloed && r.muted) onAudioMutedChange?.(r.id, false);
});
}, [audioRegions, onAudioSoloedChange, onAudioMutedChange]);
const anyAudioMuted = useMemo(() => audioRegions && audioRegions.length > 0 && audioRegions.every((r) => r.muted), [audioRegions]);
const anyAudioSoloed = useMemo(() => audioRegions && audioRegions.length > 0 && audioRegions.some((r) => r.soloed), [audioRegions]);
const masterAudioControls = (
<div className="flex items-center gap-1 ml-1 overflow-hidden pointer-events-auto">
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
const nextMuted = !masterAudioMuted;
onMasterAudioMutedChange?.(nextMuted);
if (nextMuted && masterAudioSoloed) onMasterAudioSoloedChange?.(false);
}}
className={cn(
"w-4 h-4 rounded-[4px] border flex items-center justify-center text-[8px] font-bold transition-all",
masterAudioMuted
? "bg-red-500/20 border-red-500/50 text-red-400"
: "bg-white/5 border-white/10 text-white/50 hover:bg-white/10 hover:text-white"
)}
title="Mute Master"
>
M
</button>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
const nextSoloed = !masterAudioSoloed;
onMasterAudioSoloedChange?.(nextSoloed);
if (nextSoloed && masterAudioMuted) onMasterAudioMutedChange?.(false);
}}
className={cn(
"w-4 h-4 rounded-[4px] border flex items-center justify-center text-[8px] font-bold transition-all",
masterAudioSoloed
? "bg-amber-500/20 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-white/50 hover:bg-white/10 hover:text-white"
)}
title="Solo Master"
>
S
</button>
{masterAudioVolume !== 1 && (
<span className="text-[8px] tabular-nums text-white/30 ml-0.5">{Math.round(masterAudioVolume * 100)}%</span>
)}
</div>
);
const audioControls = (
<div className="flex items-center gap-1 ml-1 overflow-hidden pointer-events-auto">
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handleAllAudioMute();
}}
className={cn(
"w-4 h-4 rounded-[4px] border flex items-center justify-center text-[8px] font-bold transition-all",
anyAudioMuted
? "bg-red-500/20 border-red-500/50 text-red-400"
: "bg-white/5 border-white/10 text-white/50 hover:bg-white/10 hover:text-white"
)}
title="Mute All Audio"
>
M
</button>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handleAllAudioSolo();
}}
className={cn(
"w-4 h-4 rounded-[4px] border flex items-center justify-center text-[8px] font-bold transition-all",
anyAudioSoloed
? "bg-amber-500/20 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-white/50 hover:bg-white/10 hover:text-white"
)}
title="Solo All Audio"
>
S
</button>
{audioTrackVolume !== 1 && (
<span className="text-[8px] tabular-nums text-white/30 ml-0.5">{Math.round(audioTrackVolume * 100)}%</span>
)}
</div>
);
return (
<div
ref={setRefs}
@@ -739,7 +894,33 @@ function Timeline({
))}
</Row>
<Row id={AUDIO_ROW_ID} isEmpty={audioItems.length === 0} hint="Click music icon to add audio">
<Row
id={ORIGINAL_AUDIO_ROW_ID}
label="Master"
labelColor="#A855F7"
isEmpty={originalAudioItems.length === 0}
controls={masterAudioControls}
>
{originalAudioItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={isMasterSelected}
onSelect={() => onSelectMaster?.(true)}
variant="audio"
audioPath={item.audioPath}
isDraggable={false}
isResizable={false}
muted={item.muted}
>
{item.label}
</Item>
))}
</Row>
<Row id={AUDIO_ROW_ID} label="Audio" labelColor="#A855F7" controls={audioControls} isEmpty={audioItems.length === 0} hint="Click music icon to add audio">
{audioItems.map((item) => (
<Item
id={item.id}
@@ -749,13 +930,16 @@ function Timeline({
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
onSelect={() => onSelectAudio?.(item.id)}
variant="audio"
audioPath={item.audioPath}
muted={item.muted}
fadeInMs={item.fadeInMs}
fadeOutMs={item.fadeOutMs}
>
{item.label}
</Item>
))}
</Row>
<Row id={CAPTION_ROW_ID} isEmpty={captionItems.length === 0} hint="Generated captions will appear here">
{captionItems.map((item) => (
<Item
@@ -813,11 +997,22 @@ export default function TimelineEditor({
onAudioDelete,
selectedAudioId,
onSelectAudio,
onAudioMutedChange,
onAudioSoloedChange,
autoCaptions = [],
onCaptionSpanChange,
selectedCaptionId,
onSelectCaption,
onClearAutoCaptions,
videoPath,
masterAudioMuted = false,
onMasterAudioMutedChange,
masterAudioSoloed = false,
onMasterAudioSoloedChange,
masterAudioVolume = 1,
audioTrackVolume = 1,
isMasterSelected = false,
onSelectMaster,
aspectRatio,
onAspectRatioChange,
onOpenCropEditor,
@@ -1605,13 +1800,28 @@ export default function TimelineEditor({
variant: 'speed',
}));
const videoAudio: TimelineRenderItem[] = videoPath && totalMs > 0 ? [{
id: 'original-video-audio',
rowId: ORIGINAL_AUDIO_ROW_ID,
span: { start: 0, end: totalMs },
label: 'Original Audio',
variant: 'audio',
audioPath: videoPath,
muted: masterAudioMuted
}] : [];
const audios: TimelineRenderItem[] = [
...audioRegions.map((audio): TimelineRenderItem => ({
id: audio.id,
rowId: AUDIO_ROW_ID,
span: { start: audio.startMs, end: audio.endMs },
label: audio.audioPath.split(/[\\/]/).pop() || 'Audio',
variant: 'audio'
variant: 'audio',
audioPath: audio.audioPath,
muted: audio.muted,
soloed: audio.soloed,
fadeInMs: audio.fadeInMs,
fadeOutMs: audio.fadeOutMs
})),
...autoCaptions.map((cue): TimelineRenderItem => ({
id: cue.id,
@@ -1622,8 +1832,8 @@ export default function TimelineEditor({
}))
];
return [...zooms, ...trims, ...annotations, ...speeds, ...audios];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions, autoCaptions]);
return [...zooms, ...trims, ...annotations, ...speeds, ...videoAudio, ...audios];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions, autoCaptions, videoPath, totalMs, masterAudioMuted]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1905,7 +2115,16 @@ export default function TimelineEditor({
onSelectAnnotation={handleSelectAnnotation}
onSelectSpeed={handleSelectSpeed}
onSelectAudio={handleSelectAudio}
onAudioMutedChange={onAudioMutedChange}
onAudioSoloedChange={onAudioSoloedChange}
audioRegions={audioRegions}
onSelectCaption={handleSelectCaption}
masterAudioMuted={masterAudioMuted}
onMasterAudioMutedChange={onMasterAudioMutedChange}
masterAudioSoloed={masterAudioSoloed}
onMasterAudioSoloedChange={onMasterAudioSoloedChange}
masterAudioVolume={masterAudioVolume}
audioTrackVolume={audioTrackVolume}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
@@ -1917,6 +2136,8 @@ export default function TimelineEditor({
keyframes={keyframes}
timeSelection={timeSelection}
onTimeSelectionChange={onTimeSelectionChange}
isMasterSelected={isMasterSelected}
onSelectMaster={onSelectMaster}
/>
</TimelineWrapper>
</div>
+4
View File
@@ -247,6 +247,10 @@ export interface AudioRegion {
endMs: number;
audioPath: string;
volume: number;
muted?: boolean;
soloed?: boolean;
fadeInMs?: number;
fadeOutMs?: number;
}
+29 -3
View File
@@ -25,6 +25,9 @@ export class AudioProcessor {
speedRegions?: SpeedRegion[],
readEndSec?: number,
audioRegions?: AudioRegion[],
masterAudioVolume = 1,
audioTrackVolume = 1,
masterAudioMuted = false,
): Promise<void> {
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : []
const sortedSpeedRegions = speedRegions
@@ -43,6 +46,9 @@ export class AudioProcessor {
sortedTrims,
sortedSpeedRegions,
sortedAudioRegions,
masterAudioVolume,
audioTrackVolume,
masterAudioMuted,
)
if (!this.cancelled) {
await this.muxRenderedAudioBlob(renderedAudioBlob, muxer)
@@ -279,6 +285,9 @@ export class AudioProcessor {
trimRegions: TrimRegion[],
speedRegions: SpeedRegion[],
audioRegions: AudioRegion[],
masterAudioVolume: number,
audioTrackVolume: number,
masterAudioMuted: boolean,
): Promise<Blob> {
const mediaSource = await resolveMediaElementSource(videoUrl)
const media = document.createElement('audio')
@@ -331,7 +340,8 @@ export class AudioProcessor {
const regionSourceNode = audioContext.createMediaElementSource(audioEl)
const gainNode = audioContext.createGain()
gainNode.gain.value = Math.max(0, Math.min(1, region.volume))
// Initial volume (will be updated in the tick loop for fades)
gainNode.gain.value = 0
regionSourceNode.connect(gainNode)
gainNode.connect(destinationNode)
@@ -404,20 +414,36 @@ export class AudioProcessor {
// Sync external audio regions with the video timeline position
for (const entry of audioRegionElements) {
const { media: audioEl, region } = entry
const { media: audioEl, region, gainNode } = entry
const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs
if (isInRegion) {
const audioOffset = (currentTimeMs - region.startMs) / 1000
// Apply fade-in / fade-out multiplier
let fadeMultiplier = 1;
if (region.fadeInMs && currentTimeMs < region.startMs + region.fadeInMs) {
fadeMultiplier = (currentTimeMs - region.startMs) / region.fadeInMs;
} else if (region.fadeOutMs && currentTimeMs > region.endMs - region.fadeOutMs) {
fadeMultiplier = (region.endMs - currentTimeMs) / region.fadeOutMs;
}
fadeMultiplier = Math.max(0, Math.min(1, fadeMultiplier));
// Apply total volume including global settings
const totalVolume = masterAudioMuted ? 0 : region.volume * audioTrackVolume * masterAudioVolume * fadeMultiplier;
gainNode.gain.setTargetAtTime(totalVolume, audioContext.currentTime, 0.015);
if (audioEl.paused) {
audioEl.currentTime = audioOffset
audioEl.play().catch(() => {})
} else if (Math.abs(audioEl.currentTime - audioOffset) > 0.3) {
} else if (Math.abs(audioEl.currentTime - audioOffset) > 0.1) {
// Tightened sync for export
audioEl.currentTime = audioOffset
}
} else {
if (!audioEl.paused) {
audioEl.pause()
gainNode.gain.value = 0
}
}
}
+3
View File
@@ -4,6 +4,9 @@ export interface ExportConfig {
frameRate: number;
bitrate: number;
codec?: string;
masterAudioVolume?: number;
audioTrackVolume?: number;
masterAudioMuted?: boolean;
}
export interface ExportProgress {
+3
View File
@@ -207,6 +207,9 @@ export class VideoExporter {
this.config.speedRegions,
undefined,
this.config.audioRegions,
this.config.masterAudioVolume,
this.config.audioTrackVolume,
this.config.masterAudioMuted,
),
"audio processing",
);
+44
View File
@@ -0,0 +1,44 @@
/**
* Extracts waveform peaks from an audio file.
* We decode the audio file using the Web Audio API and calculate the max peaks for each sample.
*/
import { toFileUrl } from "@/components/video-editor/projectPersistence";
const waveformCache = new Map<string, number[]>();
export async function generateWaveform(audioPath: string, samples = 200): Promise<number[]> {
const cacheKey = `${audioPath}:${samples}`;
if (waveformCache.has(cacheKey)) {
return waveformCache.get(cacheKey)!;
}
try {
const response = await fetch(toFileUrl(audioPath));
const arrayBuffer = await response.arrayBuffer();
// Use an offline audio context to decode the data
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const channelData = audioBuffer.getChannelData(0); // Use the first channel
const blockSize = Math.floor(channelData.length / samples);
const peaks: number[] = [];
for (let i = 0; i < samples; i++) {
const start = i * blockSize;
let max = 0;
for (let j = 0; j < blockSize; j++) {
const value = Math.abs(channelData[start + j]);
if (value > max) max = value;
}
peaks.push(max);
}
waveformCache.set(cacheKey, peaks);
return peaks;
} catch (error) {
console.error('Failed to generate waveform:', error);
return new Array(samples).fill(0);
}
}