implemented the lower priority features; haven't tested them

This commit is contained in:
2026-05-05 20:46:55 -06:00
parent cde635a660
commit 4d4dfa7f7c
12 changed files with 957 additions and 60 deletions

View File

@ -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 />}

View 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>
);
}

View 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>
);
}

View File

@ -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">

View File

@ -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,
});
},

View File

@ -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 {