mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
feat: Implement initial video editor with timeline, effects, audio waveforms, and export capabilities.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -247,6 +247,10 @@ export interface AudioRegion {
|
||||
endMs: number;
|
||||
audioPath: string;
|
||||
volume: number;
|
||||
muted?: boolean;
|
||||
soloed?: boolean;
|
||||
fadeInMs?: number;
|
||||
fadeOutMs?: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ export interface ExportConfig {
|
||||
frameRate: number;
|
||||
bitrate: number;
|
||||
codec?: string;
|
||||
masterAudioVolume?: number;
|
||||
audioTrackVolume?: number;
|
||||
masterAudioMuted?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user