implemented the lower priority features; haven't tested them
This commit is contained in:
@ -10,6 +10,8 @@ import DevPanel from './components/DevPanel';
|
||||
import MarkersPanel from './components/MarkersPanel';
|
||||
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||
import ZoneEditor from './components/ZoneEditor';
|
||||
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
||||
import AppendClipPanel from './components/AppendClipPanel';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import {
|
||||
Film,
|
||||
@ -27,11 +29,13 @@ import {
|
||||
RefreshCw,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
Music,
|
||||
ListVideo,
|
||||
} from 'lucide-react';
|
||||
|
||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | null;
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | null;
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
@ -654,6 +658,20 @@ export default function App() {
|
||||
onClick={() => togglePanel('markers')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Music className="w-4 h-4" />}
|
||||
label="Music"
|
||||
active={activePanel === 'music'}
|
||||
onClick={() => togglePanel('music')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<ListVideo className="w-4 h-4" />}
|
||||
label="Append"
|
||||
active={activePanel === 'append'}
|
||||
onClick={() => togglePanel('append')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
||||
<select
|
||||
value={whisperModel}
|
||||
@ -812,6 +830,8 @@ export default function App() {
|
||||
)}
|
||||
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
||||
{activePanel === 'markers' && <MarkersPanel />}
|
||||
{activePanel === 'music' && <BackgroundMusicPanel />}
|
||||
{activePanel === 'append' && <AppendClipPanel />}
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
{activePanel === 'settings' && <SettingsPanel />}
|
||||
|
||||
78
frontend/src/components/AppendClipPanel.tsx
Normal file
78
frontend/src/components/AppendClipPanel.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Video, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
|
||||
export default function AppendClipPanel() {
|
||||
const { additionalClips, addAdditionalClip, removeAdditionalClip, reorderAdditionalClip, videoPath } = useEditorStore();
|
||||
|
||||
const handleAddClip = async () => {
|
||||
const path = await window.electronAPI?.openFile();
|
||||
if (path) {
|
||||
addAdditionalClip(path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Video className="w-4 h-4" />
|
||||
Append Clips
|
||||
</h3>
|
||||
<p className="text-[10px] text-editor-text-muted leading-relaxed">
|
||||
Load additional video clips to append after the main video. Clips are concatenated in order during export.
|
||||
</p>
|
||||
|
||||
{additionalClips.length === 0 ? (
|
||||
<div className="text-[11px] text-editor-text-muted text-center py-3">
|
||||
No additional clips loaded
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{additionalClips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="flex items-center gap-2 p-2 rounded bg-editor-surface border border-editor-border text-xs"
|
||||
>
|
||||
<Video className="w-3 h-3 text-editor-accent shrink-0" />
|
||||
<span className="flex-1 truncate text-editor-text">{clip.label}</span>
|
||||
<span className="text-[10px] text-editor-text-muted shrink-0">#{idx + 1}</span>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => reorderAdditionalClip(clip.id, -1)}
|
||||
disabled={idx === 0}
|
||||
className="p-0.5 rounded hover:bg-editor-bg disabled:opacity-30 text-editor-text-muted hover:text-editor-text"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reorderAdditionalClip(clip.id, 1)}
|
||||
disabled={idx === additionalClips.length - 1}
|
||||
className="p-0.5 rounded hover:bg-editor-bg disabled:opacity-30 text-editor-text-muted hover:text-editor-text"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeAdditionalClip(clip.id)}
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-400"
|
||||
title="Remove clip"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAddClip}
|
||||
disabled={!videoPath}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Clip
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/BackgroundMusicPanel.tsx
Normal file
139
frontend/src/components/BackgroundMusicPanel.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Music, Trash2, Volume2, Disc3 } from 'lucide-react';
|
||||
|
||||
export default function BackgroundMusicPanel() {
|
||||
const { backgroundMusic, setBackgroundMusic, updateBackgroundMusic } = useEditorStore();
|
||||
|
||||
const handleLoadMusic = async () => {
|
||||
const path = await window.electronAPI?.openFile();
|
||||
if (path) {
|
||||
setBackgroundMusic({
|
||||
path,
|
||||
volumeDb: -10,
|
||||
duckingEnabled: true,
|
||||
duckingDb: 6,
|
||||
duckingAttackMs: 10,
|
||||
duckingReleaseMs: 200,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMusic = () => {
|
||||
setBackgroundMusic(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Music className="w-4 h-4" />
|
||||
Background Music
|
||||
</h3>
|
||||
|
||||
{!backgroundMusic ? (
|
||||
<button
|
||||
onClick={handleLoadMusic}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted transition-colors"
|
||||
>
|
||||
<Disc3 className="w-4 h-4" />
|
||||
Load Music File
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2 rounded bg-editor-surface border border-editor-border">
|
||||
<Music className="w-4 h-4 text-editor-accent shrink-0" />
|
||||
<span className="flex-1 text-xs truncate">
|
||||
{backgroundMusic.path.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRemoveMusic}
|
||||
className="p-1 rounded hover:bg-red-500/20 text-red-400 transition-colors"
|
||||
title="Remove music"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="w-3 h-3 text-editor-text-muted shrink-0" />
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Volume:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-30}
|
||||
max={12}
|
||||
step={1}
|
||||
value={backgroundMusic.volumeDb}
|
||||
onChange={(e) => updateBackgroundMusic({ volumeDb: Number(e.target.value) })}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.volumeDb} dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backgroundMusic.duckingEnabled}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingEnabled: e.target.checked })}
|
||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-xs font-medium">Auto-ducking</span>
|
||||
<p className="text-[10px] text-editor-text-muted">
|
||||
Lower music volume when speech is detected
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{backgroundMusic.duckingEnabled && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-20">Duck amount:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={backgroundMusic.duckingDb}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingDb: Number(e.target.value) })}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingDb} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-20">Attack:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={backgroundMusic.duckingAttackMs}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingAttackMs: Number(e.target.value) })}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingAttackMs}ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-20">Release:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={1000}
|
||||
step={10}
|
||||
value={backgroundMusic.duckingReleaseMs}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingReleaseMs: Number(e.target.value) })}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingReleaseMs}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-editor-text-muted leading-relaxed">
|
||||
The music will be mixed during export. Enable auto-ducking to lower music volume whenever speech is active.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText } from 'lucide-react';
|
||||
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
||||
import type { ExportOptions } from '../types/project';
|
||||
|
||||
export default function ExportDialog() {
|
||||
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
||||
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
|
||||
useEditorStore();
|
||||
|
||||
const hasCuts = cutRanges.length > 0;
|
||||
@ -22,6 +22,10 @@ export default function ExportDialog() {
|
||||
captions: 'none',
|
||||
normalizeAudio: false,
|
||||
normalizeTarget: -14,
|
||||
zoom: { enabled: false, zoomFactor: 1.25, panX: 0, panY: 0 },
|
||||
removeBackground: false,
|
||||
backgroundReplacement: 'blur',
|
||||
backgroundReplacementValue: '',
|
||||
});
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [transcriptFormat, setTranscriptFormat] = useState<'txt' | 'srt'>('txt');
|
||||
@ -147,27 +151,51 @@ export default function ExportDialog() {
|
||||
speed: r.speed,
|
||||
}));
|
||||
|
||||
const body: Record<string, any> = {
|
||||
input_path: videoPath,
|
||||
output_path: outputPath,
|
||||
keep_segments: keepSegments,
|
||||
mute_ranges: muteRanges.length > 0 ? muteRanges.map((r) => ({ start: r.start, end: r.end })) : undefined,
|
||||
gain_ranges: backendGainRanges.length > 0 ? backendGainRanges : undefined,
|
||||
speed_ranges: backendSpeedRanges.length > 0 ? backendSpeedRanges : undefined,
|
||||
global_gain_db: globalGainDb,
|
||||
words: options.captions !== 'none' ? words : undefined,
|
||||
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
||||
mode: options.mode,
|
||||
resolution: options.resolution,
|
||||
format: options.format,
|
||||
enhanceAudio: options.enhanceAudio,
|
||||
normalize_loudness: options.normalizeAudio,
|
||||
normalize_target_lufs: options.normalizeTarget,
|
||||
captions: options.captions,
|
||||
};
|
||||
|
||||
// Zoom
|
||||
if (options.zoom?.enabled) {
|
||||
body.zoom = options.zoom;
|
||||
}
|
||||
|
||||
// Additional clips
|
||||
if (additionalClips.length > 0) {
|
||||
body.additional_clips = additionalClips.map((c) => c.path);
|
||||
}
|
||||
|
||||
// Background music
|
||||
if (backgroundMusic) {
|
||||
body.background_music = backgroundMusic;
|
||||
}
|
||||
|
||||
// Background removal
|
||||
if (options.removeBackground) {
|
||||
body.remove_background = true;
|
||||
body.background_replacement = options.backgroundReplacement || 'blur';
|
||||
body.background_replacement_value = options.backgroundReplacementValue || '';
|
||||
}
|
||||
|
||||
const res = await fetch(`${backendUrl}/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input_path: videoPath,
|
||||
output_path: outputPath,
|
||||
keep_segments: keepSegments,
|
||||
mute_ranges: muteRanges.length > 0 ? muteRanges.map((r) => ({ start: r.start, end: r.end })) : undefined,
|
||||
gain_ranges: backendGainRanges.length > 0 ? backendGainRanges : undefined,
|
||||
speed_ranges: backendSpeedRanges.length > 0 ? backendSpeedRanges : undefined,
|
||||
global_gain_db: globalGainDb,
|
||||
words: options.captions !== 'none' ? words : undefined,
|
||||
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
||||
mode: options.mode,
|
||||
resolution: options.resolution,
|
||||
format: options.format,
|
||||
enhanceAudio: options.enhanceAudio,
|
||||
normalize_loudness: options.normalizeAudio,
|
||||
normalize_target_lufs: options.normalizeTarget,
|
||||
captions: options.captions,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
@ -185,7 +213,7 @@ export default function ExportDialog() {
|
||||
setExportError(err instanceof Error ? err.message : 'Export failed');
|
||||
setExporting(false);
|
||||
}
|
||||
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters]);
|
||||
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-5">
|
||||
@ -239,6 +267,139 @@ export default function ExportDialog() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Video zoom / punch-in */}
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.zoom?.enabled || false}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, enabled: e.target.checked } }))}
|
||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-xs font-medium flex items-center gap-1">
|
||||
<ZoomIn className="w-3 h-3" />
|
||||
Video zoom / punch-in
|
||||
</span>
|
||||
<p className="text-[10px] text-editor-text-muted">
|
||||
Crop and zoom into the center of the video. Requires re-encode.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
{options.zoom?.enabled && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Zoom:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.05}
|
||||
value={options.zoom?.zoomFactor || 1}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Pan X:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={options.zoom?.panX || 0}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Pan Y:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={options.zoom?.panY || 0}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Background removal */}
|
||||
{!isAudioOnly && (
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.removeBackground || false}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))}
|
||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-xs font-medium flex items-center gap-1">
|
||||
<Video className="w-3 h-3" />
|
||||
Remove background
|
||||
</span>
|
||||
<p className="text-[10px] text-editor-text-muted">
|
||||
Replace or blur the background. Uses MediaPipe if available.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
{options.removeBackground && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<SelectField
|
||||
label="Background replacement"
|
||||
value={options.backgroundReplacement || 'blur'}
|
||||
onChange={(v) => setOptions((o) => ({ ...o, backgroundReplacement: v as 'blur' | 'color' | 'image' }))}
|
||||
options={[
|
||||
{ value: 'blur', label: 'Blur background' },
|
||||
{ value: 'color', label: 'Solid color' },
|
||||
{ value: 'image', label: 'Custom image' },
|
||||
]}
|
||||
/>
|
||||
{options.backgroundReplacement === 'color' && (
|
||||
<input
|
||||
type="text"
|
||||
value={options.backgroundReplacementValue || '#00FF00'}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, backgroundReplacementValue: e.target.value }))}
|
||||
placeholder="#00FF00"
|
||||
className="w-full px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]"
|
||||
/>
|
||||
)}
|
||||
{options.backgroundReplacement === 'image' && (
|
||||
<p className="text-[10px] text-editor-text-muted">Place a background image file path above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background music track info */}
|
||||
{backgroundMusic && (
|
||||
<div className="pt-1 border-t border-editor-border">
|
||||
<div className="flex items-center gap-1.5 text-xs text-editor-accent">
|
||||
<Music className="w-3 h-3" />
|
||||
Background music: {backgroundMusic.path.split(/[/\\]/).pop()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Append clips info */}
|
||||
{additionalClips.length > 0 && (
|
||||
<div className="pt-1 border-t border-editor-border">
|
||||
<div className="flex items-center gap-1.5 text-xs text-editor-accent">
|
||||
<Video className="w-3 h-3" />
|
||||
{additionalClips.length} additional clip{additionalClips.length > 1 ? 's' : ''} appended
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio normalization — integrated into export */}
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@ -14,6 +14,9 @@ import type {
|
||||
SilenceTrimGroup,
|
||||
TimelineMarker,
|
||||
Chapter,
|
||||
ZoomConfig,
|
||||
ClipInfo,
|
||||
BackgroundMusicConfig,
|
||||
} from '../types/project';
|
||||
|
||||
interface EditorState {
|
||||
@ -50,6 +53,10 @@ interface EditorState {
|
||||
|
||||
backendUrl: string;
|
||||
zonePreviewPaddingSeconds: number;
|
||||
|
||||
zoomConfig: ZoomConfig;
|
||||
additionalClips: ClipInfo[];
|
||||
backgroundMusic: BackgroundMusicConfig | null;
|
||||
}
|
||||
|
||||
interface EditorActions {
|
||||
@ -104,6 +111,12 @@ interface EditorActions {
|
||||
getWordAtTime: (time: number) => number;
|
||||
loadProject: (projectData: any) => void;
|
||||
reset: () => void;
|
||||
setZoomConfig: (config: Partial<ZoomConfig>) => void;
|
||||
addAdditionalClip: (path: string, label?: string) => void;
|
||||
removeAdditionalClip: (id: string) => void;
|
||||
reorderAdditionalClip: (id: string, direction: -1 | 1) => void;
|
||||
setBackgroundMusic: (config: BackgroundMusicConfig | null) => void;
|
||||
updateBackgroundMusic: (updates: Partial<BackgroundMusicConfig>) => void;
|
||||
}
|
||||
|
||||
const ZONE_PREVIEW_PADDING_KEY = 'talkedit-zone-preview-padding-seconds';
|
||||
@ -146,6 +159,9 @@ const initialState: EditorState = {
|
||||
exportProgress: 0,
|
||||
backendUrl: 'http://127.0.0.1:8000',
|
||||
zonePreviewPaddingSeconds: getStoredZonePreviewPaddingSeconds(),
|
||||
zoomConfig: { enabled: false, zoomFactor: 1, panX: 0, panY: 0 },
|
||||
additionalClips: [],
|
||||
backgroundMusic: null,
|
||||
};
|
||||
|
||||
let nextRangeId = 1;
|
||||
@ -190,7 +206,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||
|
||||
saveProject: (): ProjectFile => {
|
||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath } = get();
|
||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get();
|
||||
if (!videoPath) throw new Error('No video loaded');
|
||||
const now = new Date().toISOString();
|
||||
// Strip globalStartIndex (runtime-only field) before persisting.
|
||||
@ -214,8 +230,11 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
silenceTrimGroups,
|
||||
timelineMarkers,
|
||||
language,
|
||||
createdAt: now, // will be overwritten if we track original creation time later
|
||||
createdAt: now,
|
||||
modifiedAt: now,
|
||||
zoomConfig,
|
||||
additionalClips,
|
||||
backgroundMusic: backgroundMusic ?? undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@ -600,6 +619,43 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
return lo < words.length ? lo : words.length - 1;
|
||||
},
|
||||
|
||||
setZoomConfig: (config) => {
|
||||
const { zoomConfig } = get();
|
||||
set({ zoomConfig: { ...zoomConfig, ...config } });
|
||||
},
|
||||
|
||||
addAdditionalClip: (path, label) => {
|
||||
const { additionalClips } = get();
|
||||
const id = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
set({ additionalClips: [...additionalClips, { id, path, label: label || path.split(/[/\\]/).pop() || 'Clip' }] });
|
||||
},
|
||||
|
||||
removeAdditionalClip: (id) => {
|
||||
const { additionalClips } = get();
|
||||
set({ additionalClips: additionalClips.filter((c) => c.id !== id) });
|
||||
},
|
||||
|
||||
reorderAdditionalClip: (id, direction) => {
|
||||
const { additionalClips } = get();
|
||||
const idx = additionalClips.findIndex((c) => c.id === id);
|
||||
if (idx === -1) return;
|
||||
const target = idx + direction;
|
||||
if (target < 0 || target >= additionalClips.length) return;
|
||||
const reordered = [...additionalClips];
|
||||
[reordered[idx], reordered[target]] = [reordered[target], reordered[idx]];
|
||||
set({ additionalClips: reordered });
|
||||
},
|
||||
|
||||
setBackgroundMusic: (config) => {
|
||||
set({ backgroundMusic: config });
|
||||
},
|
||||
|
||||
updateBackgroundMusic: (updates) => {
|
||||
const { backgroundMusic } = get();
|
||||
if (!backgroundMusic) return;
|
||||
set({ backgroundMusic: { ...backgroundMusic, ...updates } });
|
||||
},
|
||||
|
||||
loadProject: (data) => {
|
||||
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath } = get();
|
||||
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
||||
@ -634,6 +690,9 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
transcriptionModel: data.transcriptionModel ?? null,
|
||||
language: data.language || '',
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
zoomConfig: data.zoomConfig || { enabled: false, zoomFactor: 1, panX: 0, panY: 0 },
|
||||
additionalClips: data.additionalClips || [],
|
||||
backgroundMusic: data.backgroundMusic || null,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@ -76,6 +76,9 @@ export interface ProjectFile {
|
||||
language: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
zoomConfig?: ZoomConfig;
|
||||
additionalClips?: ClipInfo[];
|
||||
backgroundMusic?: BackgroundMusicConfig;
|
||||
}
|
||||
|
||||
export interface TranscriptionResult {
|
||||
@ -84,6 +87,28 @@ export interface TranscriptionResult {
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface ZoomConfig {
|
||||
enabled: boolean;
|
||||
zoomFactor: number; // 1.0 = no zoom, 2.0 = 2x zoom
|
||||
panX: number; // -1 to 1, normalized pan offset
|
||||
panY: number;
|
||||
}
|
||||
|
||||
export interface ClipInfo {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BackgroundMusicConfig {
|
||||
path: string;
|
||||
volumeDb: number; // gain in dB for music track
|
||||
duckingEnabled: boolean;
|
||||
duckingDb: number; // how much to duck (dB reduction)
|
||||
duckingAttackMs: number;
|
||||
duckingReleaseMs: number;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
outputPath: string;
|
||||
mode: 'fast' | 'reencode';
|
||||
@ -92,6 +117,10 @@ export interface ExportOptions {
|
||||
enhanceAudio: boolean;
|
||||
captions: 'none' | 'burn-in' | 'sidecar';
|
||||
captionStyle?: CaptionStyle;
|
||||
zoom?: ZoomConfig;
|
||||
removeBackground?: boolean;
|
||||
backgroundReplacement?: 'blur' | 'color' | 'image';
|
||||
backgroundReplacementValue?: string;
|
||||
}
|
||||
|
||||
export interface TimelineMarker {
|
||||
|
||||
Reference in New Issue
Block a user