UI improvements, moved file name and moved buttons left

This commit is contained in:
2026-04-15 19:54:39 -06:00
parent 7479acd3ee
commit b7a795f986
9 changed files with 500 additions and 70 deletions

View File

@ -25,6 +25,10 @@ class GainRangeModel(SegmentModel):
gain_db: float gain_db: float
class SpeedRangeModel(SegmentModel):
speed: float
class ExportWordModel(BaseModel): class ExportWordModel(BaseModel):
word: str word: str
start: float start: float
@ -38,6 +42,7 @@ class ExportRequest(BaseModel):
keep_segments: List[SegmentModel] keep_segments: List[SegmentModel]
mute_ranges: Optional[List[SegmentModel]] = None mute_ranges: Optional[List[SegmentModel]] = None
gain_ranges: Optional[List[GainRangeModel]] = None gain_ranges: Optional[List[GainRangeModel]] = None
speed_ranges: Optional[List[SpeedRangeModel]] = None
global_gain_db: float = 0.0 global_gain_db: float = 0.0
mode: str = "fast" mode: str = "fast"
resolution: str = "1080p" resolution: str = "1080p"
@ -77,6 +82,8 @@ def _map_ranges_to_output_timeline(
} }
if "gain_db" in src_range: if "gain_db" in src_range:
mapped_range["gain_db"] = float(src_range["gain_db"]) mapped_range["gain_db"] = float(src_range["gain_db"])
if "speed" in src_range:
mapped_range["speed"] = float(src_range["speed"])
mapped.append(mapped_range) mapped.append(mapped_range)
output_cursor += keep_len output_cursor += keep_len
@ -109,6 +116,7 @@ async def export_video(req: ExportRequest):
segments = [{"start": s.start, "end": s.end} for s in req.keep_segments] segments = [{"start": s.start, "end": s.end} for s in req.keep_segments]
mute_segments = [{"start": s.start, "end": s.end} for s in req.mute_ranges] if req.mute_ranges else None mute_segments = [{"start": s.start, "end": s.end} for s in req.mute_ranges] if req.mute_ranges else None
gain_segments = [{"start": s.start, "end": s.end, "gain_db": s.gain_db} for s in req.gain_ranges] if req.gain_ranges else None gain_segments = [{"start": s.start, "end": s.end, "gain_db": s.gain_db} for s in req.gain_ranges] if req.gain_ranges else None
speed_segments = [{"start": s.start, "end": s.end, "speed": s.speed} for s in req.speed_ranges] if req.speed_ranges else None
if not segments and not mute_segments: if not segments and not mute_segments:
raise HTTPException(status_code=400, detail="No segments to export") raise HTTPException(status_code=400, detail="No segments to export")
@ -116,11 +124,19 @@ async def export_video(req: ExportRequest):
mapped_gain_segments = _map_ranges_to_output_timeline(gain_segments or [], segments) mapped_gain_segments = _map_ranges_to_output_timeline(gain_segments or [], segments)
has_gain = abs(float(req.global_gain_db)) > 1e-6 or bool(gain_segments) has_gain = abs(float(req.global_gain_db)) > 1e-6 or bool(gain_segments)
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments and not has_gain has_speed = bool(speed_segments)
if has_speed and (mute_segments or has_gain):
raise HTTPException(
status_code=400,
detail="Speed zones currently cannot be combined with mute/gain filters in one export",
)
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments and not has_gain and not has_speed
needs_reencode_for_subs = req.captions == "burn-in" needs_reencode_for_subs = req.captions == "burn-in"
# Burn-in captions or audio filters require re-encode # Burn-in captions or audio filters require re-encode
if needs_reencode_for_subs or mute_segments or has_gain: if needs_reencode_for_subs or mute_segments or has_gain or has_speed:
use_stream_copy = False use_stream_copy = False
words_dicts = [w.model_dump() for w in req.words] if req.words else [] words_dicts = [w.model_dump() for w in req.words] if req.words else []
@ -148,6 +164,7 @@ async def export_video(req: ExportRequest):
format_hint=req.format, format_hint=req.format,
mute_ranges=mute_segments, mute_ranges=mute_segments,
gain_ranges=mapped_gain_segments, gain_ranges=mapped_gain_segments,
speed_ranges=speed_segments,
global_gain_db=req.global_gain_db, global_gain_db=req.global_gain_db,
) )
else: else:
@ -159,6 +176,7 @@ async def export_video(req: ExportRequest):
format_hint=req.format, format_hint=req.format,
mute_ranges=mute_segments, mute_ranges=mute_segments,
gain_ranges=mapped_gain_segments, gain_ranges=mapped_gain_segments,
speed_ranges=speed_segments,
global_gain_db=req.global_gain_db, global_gain_db=req.global_gain_db,
) )
finally: finally:

View File

@ -13,6 +13,78 @@ from typing import List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _clamp_speed(speed: float) -> float:
return max(0.25, min(4.0, float(speed)))
def _build_atempo_chain(speed: float) -> str:
"""Build an FFmpeg atempo chain since each atempo node only supports 0.5..2.0."""
s = _clamp_speed(speed)
filters = []
while s > 2.0:
filters.append("atempo=2.0")
s /= 2.0
while s < 0.5:
filters.append("atempo=0.5")
s /= 0.5
filters.append(f"atempo={s:.6f}")
return ",".join(filters)
def _split_keep_segments_by_speed(
keep_segments: List[dict],
speed_ranges: List[dict] = None,
) -> List[dict]:
"""Split keep segments by speed ranges, attaching speed multiplier per piece."""
if not keep_segments:
return []
normalized_ranges = []
for r in speed_ranges or []:
start = float(r.get("start", 0.0))
end = float(r.get("end", 0.0))
if end <= start:
continue
normalized_ranges.append({
"start": start,
"end": end,
"speed": _clamp_speed(float(r.get("speed", 1.0))),
})
normalized_ranges.sort(key=lambda x: x["start"])
result = []
for keep in keep_segments:
k_start = float(keep["start"])
k_end = float(keep["end"])
if k_end <= k_start:
continue
cuts = {k_start, k_end}
for sr in normalized_ranges:
overlap_start = max(k_start, sr["start"])
overlap_end = min(k_end, sr["end"])
if overlap_end > overlap_start:
cuts.add(overlap_start)
cuts.add(overlap_end)
points = sorted(cuts)
for i in range(len(points) - 1):
seg_start = points[i]
seg_end = points[i + 1]
if seg_end - seg_start < 1e-6:
continue
speed = 1.0
for sr in normalized_ranges:
if seg_start >= sr["start"] and seg_end <= sr["end"]:
speed = sr["speed"]
break
result.append({"start": seg_start, "end": seg_end, "speed": speed})
return result
def _find_ffmpeg() -> str: def _find_ffmpeg() -> str:
"""Locate ffmpeg binary.""" """Locate ffmpeg binary."""
for cmd in ["ffmpeg", "ffmpeg.exe"]: for cmd in ["ffmpeg", "ffmpeg.exe"]:
@ -113,6 +185,7 @@ def export_reencode(
format_hint: str = "mp4", format_hint: str = "mp4",
mute_ranges: List[dict] = None, mute_ranges: List[dict] = None,
gain_ranges: List[dict] = None, gain_ranges: List[dict] = None,
speed_ranges: List[dict] = None,
global_gain_db: float = 0.0, global_gain_db: float = 0.0,
) -> str: ) -> str:
""" """
@ -150,8 +223,11 @@ def export_reencode(
has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6 has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6
# Handle filtered full-timeline audio case (mute/gain/global gain) speed_segments = _split_keep_segments_by_speed(keep_segments, speed_ranges)
if has_audio_filters: has_speed = any(abs(seg.get("speed", 1.0) - 1.0) > 1e-6 for seg in speed_segments)
# Handle filtered full-timeline audio case (mute/gain/global gain) when no speed warping is needed
if has_audio_filters and not has_speed:
audio_filter = build_audio_filter() audio_filter = build_audio_filter()
# Video filter - just scaling if needed # Video filter - just scaling if needed
@ -189,18 +265,25 @@ def export_reencode(
resolution, resolution,
) )
else: else:
# Original cutting logic # Cutting logic with optional per-segment speed changes
if not keep_segments: if not keep_segments:
raise ValueError("No segments to export") raise ValueError("No segments to export")
filter_parts = [] segments_for_concat = speed_segments if speed_segments else _split_keep_segments_by_speed(keep_segments, None)
for i, seg in enumerate(keep_segments): if not segments_for_concat:
filter_parts.append( raise ValueError("No segments to export")
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
n = len(keep_segments) filter_parts = []
for i, seg in enumerate(segments_for_concat):
speed = _clamp_speed(seg.get("speed", 1.0))
v_chain = f"trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS"
a_chain = f"atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS"
if abs(speed - 1.0) > 1e-6:
v_chain += f",setpts=PTS/{speed:.6f}"
a_chain += f",{_build_atempo_chain(speed)}"
filter_parts.append(f"[0:v]{v_chain}[v{i}];[0:a]{a_chain}[a{i}];")
n = len(segments_for_concat)
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n)) concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]") filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
@ -228,7 +311,13 @@ def export_reencode(
output_path, output_path,
] ]
logger.info(f"Re-encoding {n} segments -> {output_path} ({resolution})") logger.info(
"Re-encoding %s segments (speed-adjusted=%s) -> %s (%s)",
n,
has_speed,
output_path,
resolution,
)
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
@ -246,6 +335,7 @@ def export_reencode_with_subs(
format_hint: str = "mp4", format_hint: str = "mp4",
mute_ranges: List[dict] = None, mute_ranges: List[dict] = None,
gain_ranges: List[dict] = None, gain_ranges: List[dict] = None,
speed_ranges: List[dict] = None,
global_gain_db: float = 0.0, global_gain_db: float = 0.0,
) -> str: ) -> str:
""" """
@ -284,8 +374,11 @@ def export_reencode_with_subs(
has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6 has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6
# Handle filtered full-timeline audio case (mute/gain/global gain) speed_segments = _split_keep_segments_by_speed(keep_segments, speed_ranges)
if has_audio_filters: has_speed = any(abs(seg.get("speed", 1.0) - 1.0) > 1e-6 for seg in speed_segments)
# Handle filtered full-timeline audio case (mute/gain/global gain) when no speed warping is needed
if has_audio_filters and not has_speed:
audio_filter = build_audio_filter() audio_filter = build_audio_filter()
# Video filter with subtitles # Video filter with subtitles
@ -322,18 +415,25 @@ def export_reencode_with_subs(
resolution, resolution,
) )
else: else:
# Original cutting logic with subtitles # Cutting logic with subtitles and optional speed changes
if not keep_segments: if not keep_segments:
raise ValueError("No segments to export") raise ValueError("No segments to export")
filter_parts = [] segments_for_concat = speed_segments if speed_segments else _split_keep_segments_by_speed(keep_segments, None)
for i, seg in enumerate(keep_segments): if not segments_for_concat:
filter_parts.append( raise ValueError("No segments to export")
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
n = len(keep_segments) filter_parts = []
for i, seg in enumerate(segments_for_concat):
speed = _clamp_speed(seg.get("speed", 1.0))
v_chain = f"trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS"
a_chain = f"atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS"
if abs(speed - 1.0) > 1e-6:
v_chain += f",setpts=PTS/{speed:.6f}"
a_chain += f",{_build_atempo_chain(speed)}"
filter_parts.append(f"[0:v]{v_chain}[v{i}];[0:a]{a_chain}[a{i}];")
n = len(segments_for_concat)
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n)) concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]") filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
@ -364,7 +464,13 @@ def export_reencode_with_subs(
output_path, output_path,
] ]
logger.info(f"Re-encoding {n} segments with subtitles -> {output_path} ({resolution})") logger.info(
"Re-encoding %s segments with subtitles (speed-adjusted=%s) -> %s (%s)",
n,
has_speed,
output_path,
resolution,
)
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:

View File

@ -21,6 +21,7 @@ import {
Scissors, Scissors,
VolumeX, VolumeX,
SlidersHorizontal, SlidersHorizontal,
Gauge,
FilePlus2, FilePlus2,
RefreshCw, RefreshCw,
Grid3x3, Grid3x3,
@ -40,6 +41,7 @@ export default function App() {
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
speedRanges,
globalGainDb, globalGainDb,
silenceTrimGroups, silenceTrimGroups,
transcriptionModel, transcriptionModel,
@ -55,14 +57,18 @@ export default function App() {
addCutRange, addCutRange,
addMuteRange, addMuteRange,
addGainRange, addGainRange,
addSpeedRange,
} = useEditorStore(); } = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null); const [activePanel, setActivePanel] = useState<Panel>(null);
const [whisperModel, setWhisperModel] = useState('base'); const [whisperModel, setWhisperModel] = useState('base');
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
const [cutMode, setCutMode] = useState(false); const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false); const [muteMode, setMuteMode] = useState(false);
const [gainMode, setGainMode] = useState(false); const [gainMode, setGainMode] = useState(false);
const [gainModeDb, setGainModeDb] = useState(3); const [gainModeDb, setGainModeDb] = useState(3);
const [speedMode, setSpeedMode] = useState(false);
const [speedModeValue, setSpeedModeValue] = useState(1.25);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false); const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false); const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null); const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
@ -79,6 +85,7 @@ export default function App() {
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
speedRanges,
globalGainDb, globalGainDb,
silenceTrimGroups, silenceTrimGroups,
transcriptionModel, transcriptionModel,
@ -93,6 +100,7 @@ export default function App() {
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
speedRanges,
globalGainDb, globalGainDb,
silenceTrimGroups, silenceTrimGroups,
transcriptionModel, transcriptionModel,
@ -112,6 +120,7 @@ export default function App() {
cutRanges: data.cutRanges || [], cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [], muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [], gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [],
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0, globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [], silenceTrimGroups: data.silenceTrimGroups || [],
transcriptionModel: data.transcriptionModel ?? null, transcriptionModel: data.transcriptionModel ?? null,
@ -138,6 +147,7 @@ export default function App() {
setCutMode(false); setCutMode(false);
setMuteMode(false); setMuteMode(false);
setGainMode(false); setGainMode(false);
setSpeedMode(false);
} }
}; };
@ -217,6 +227,7 @@ export default function App() {
setCutMode(false); setCutMode(false);
setMuteMode(false); setMuteMode(false);
setGainMode(false); setGainMode(false);
setSpeedMode(false);
}); });
}; };
@ -315,6 +326,7 @@ export default function App() {
setCutMode(!cutMode); setCutMode(!cutMode);
setMuteMode(false); // Exit mute mode setMuteMode(false); // Exit mute mode
setGainMode(false); // Exit gain mode setGainMode(false); // Exit gain mode
setSpeedMode(false); // Exit speed mode
} }
}; };
@ -330,6 +342,7 @@ export default function App() {
setMuteMode(!muteMode); setMuteMode(!muteMode);
setCutMode(false); // Exit cut mode setCutMode(false); // Exit cut mode
setGainMode(false); // Exit gain mode setGainMode(false); // Exit gain mode
setSpeedMode(false); // Exit speed mode
} }
}; };
@ -343,6 +356,21 @@ export default function App() {
setGainMode(!gainMode); setGainMode(!gainMode);
setCutMode(false); setCutMode(false);
setMuteMode(false); setMuteMode(false);
setSpeedMode(false);
}
};
const handleSpeed = () => {
if (selectedWordIndices.length > 0) {
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addSpeedRange(startTime, endTime, speedModeValue);
} else {
setSpeedMode(!speedMode);
setCutMode(false);
setMuteMode(false);
setGainMode(false);
} }
}; };
@ -413,19 +441,8 @@ export default function App() {
return ( return (
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden"> <div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
{/* Top bar */} {/* Top bar */}
<header className="h-12 flex items-center justify-between px-4 border-b border-editor-border shrink-0"> <header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-0.5">
<Film className="w-5 h-5 text-editor-accent" />
<span className="text-sm font-medium truncate max-w-[300px]">
{videoPath.split(/[\\/]/).pop()}
</span>
{transcriptionModel && (
<span className="px-2 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted">
Model: {transcriptionModel}
</span>
)}
</div>
<div className="flex items-center gap-2">
<ToolbarButton <ToolbarButton
icon={<FilePlus2 className="w-4 h-4" />} icon={<FilePlus2 className="w-4 h-4" />}
label="New" label="New"
@ -477,6 +494,24 @@ export default function App() {
title="Gain dB for new gain zones" title="Gain dB for new gain zones"
/> />
</div> </div>
<div className="flex items-center gap-1">
<ToolbarButton
icon={<Gauge className="w-4 h-4" />}
label="Speed Zone"
onClick={handleSpeed}
active={speedMode}
/>
<input
type="number"
min={0.25}
max={4}
step={0.05}
value={speedModeValue}
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Playback rate for new speed zones"
/>
</div>
<ToolbarButton <ToolbarButton
icon={<Grid3x3 className="w-4 h-4" />} icon={<Grid3x3 className="w-4 h-4" />}
label="Zones" label="Zones"
@ -495,7 +530,7 @@ export default function App() {
<select <select
value={whisperModel} value={whisperModel}
onChange={(e) => setWhisperModel(e.target.value)} onChange={(e) => setWhisperModel(e.target.value)}
className="bg-transparent text-xs text-editor-text focus:outline-none" className="bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]"
title="Transcription model" title="Transcription model"
> >
<optgroup label="Multilingual"> <optgroup label="Multilingual">
@ -521,7 +556,7 @@ export default function App() {
onClick={handleReprocessProject} onClick={handleReprocessProject}
disabled={isTranscribing || !videoPath} disabled={isTranscribing || !videoPath}
title="Reprocess transcript with selected model" title="Reprocess transcript with selected model"
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
> >
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
Reprocess Reprocess
@ -562,6 +597,16 @@ export default function App() {
{/* Transcript */} {/* Transcript */}
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0"> <div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
{videoPath && (
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[\/]/).pop()}</span>
{transcriptionModel && (
<span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0">
{transcriptionModel}
</span>
)}
</div>
)}
{isTranscribing ? ( {isTranscribing ? (
<div className="flex-1 flex flex-col items-center justify-center gap-5"> <div className="flex-1 flex flex-col items-center justify-center gap-5">
{/* Animated waveform */} {/* Animated waveform */}
@ -588,6 +633,8 @@ export default function App() {
muteMode={muteMode} muteMode={muteMode}
gainMode={gainMode} gainMode={gainMode}
gainModeDb={gainModeDb} gainModeDb={gainModeDb}
speedMode={speedMode}
speedModeValue={speedModeValue}
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm"> <div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm">
@ -599,7 +646,14 @@ export default function App() {
{/* Waveform timeline */} {/* Waveform timeline */}
<div className="h-32 border-t border-editor-border shrink-0"> <div className="h-32 border-t border-editor-border shrink-0">
<WaveformTimeline cutMode={cutMode} muteMode={muteMode} gainMode={gainMode} gainModeDb={gainModeDb} /> <WaveformTimeline
cutMode={cutMode}
muteMode={muteMode}
gainMode={gainMode}
gainModeDb={gainModeDb}
speedMode={speedMode}
speedModeValue={speedModeValue}
/>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@ import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
import type { ExportOptions } from '../types/project'; import type { ExportOptions } from '../types/project';
export default function ExportDialog() { export default function ExportDialog() {
const { videoPath, words, deletedRanges, muteRanges, gainRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } = const { videoPath, words, deletedRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
useEditorStore(); useEditorStore();
const hasCuts = deletedRanges.length > 0; const hasCuts = deletedRanges.length > 0;
@ -48,6 +48,7 @@ export default function ExportDialog() {
keep_segments: keepSegments, keep_segments: keepSegments,
mute_ranges: muteRanges, mute_ranges: muteRanges,
gain_ranges: gainRanges, gain_ranges: gainRanges,
speed_ranges: speedRanges,
global_gain_db: globalGainDb, global_gain_db: globalGainDb,
words: options.captions !== 'none' ? words : undefined, words: options.captions !== 'none' ? words : undefined,
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined, deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
@ -60,7 +61,7 @@ export default function ExportDialog() {
console.error('Export error:', err); console.error('Export error:', err);
setExporting(false); setExporting(false);
} }
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, deletedRanges, muteRanges, gainRanges, globalGainDb, words]); }, [videoPath, options, backendUrl, setExporting, getKeepSegments, deletedRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
return ( return (
<div className="p-4 space-y-5"> <div className="p-4 space-y-5">

View File

@ -1,22 +1,32 @@
import { useCallback, useRef, useEffect, useMemo, useState } from 'react'; import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { Scissors, VolumeX, SlidersHorizontal, RotateCcw } from 'lucide-react'; import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw } from 'lucide-react';
interface TranscriptEditorProps { interface TranscriptEditorProps {
cutMode: boolean; cutMode: boolean;
muteMode: boolean; muteMode: boolean;
gainMode: boolean; gainMode: boolean;
gainModeDb: number; gainModeDb: number;
speedMode: boolean;
speedModeValue: number;
} }
export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainModeDb }: TranscriptEditorProps) { export default function TranscriptEditor({
cutMode,
muteMode,
gainMode,
gainModeDb,
speedMode,
speedModeValue,
}: TranscriptEditorProps) {
const words = useEditorStore((s) => s.words); const words = useEditorStore((s) => s.words);
const segments = useEditorStore((s) => s.segments); const segments = useEditorStore((s) => s.segments);
const deletedRanges = useEditorStore((s) => s.deletedRanges); const deletedRanges = useEditorStore((s) => s.deletedRanges);
const cutRanges = useEditorStore((s) => s.cutRanges); const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges); const muteRanges = useEditorStore((s) => s.muteRanges);
const gainRanges = useEditorStore((s) => s.gainRanges); const gainRanges = useEditorStore((s) => s.gainRanges);
const speedRanges = useEditorStore((s) => s.speedRanges);
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices); const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex); const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices); const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
@ -25,9 +35,11 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange); const removeGainRange = useEditorStore((s) => s.removeGainRange);
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
const addCutRange = useEditorStore((s) => s.addCutRange); const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange); const addMuteRange = useEditorStore((s) => s.addMuteRange);
const addGainRange = useEditorStore((s) => s.addGainRange); const addGainRange = useEditorStore((s) => s.addGainRange);
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
const getWordAtTime = useEditorStore((s) => s.getWordAtTime); const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
const selectionStart = useRef<number | null>(null); const selectionStart = useRef<number | null>(null);
@ -84,7 +96,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
return; return;
} }
if (cutMode || muteMode || gainMode) { if (cutMode || muteMode || gainMode || speedMode) {
zoneDragStart.current = index; zoneDragStart.current = index;
setZoneDragRange({ start: index, end: index }); setZoneDragRange({ start: index, end: index });
selectionStart.current = null; selectionStart.current = null;
@ -104,7 +116,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
setSelectedWordIndices([index]); setSelectedWordIndices([index]);
} }
}, },
[words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode], [words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode, speedMode],
); );
const handleWordMouseEnter = useCallback( const handleWordMouseEnter = useCallback(
@ -137,12 +149,13 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
if (cutMode) addCutRange(startWord.start, endWord.end); if (cutMode) addCutRange(startWord.start, endWord.end);
if (muteMode) addMuteRange(startWord.start, endWord.end); if (muteMode) addMuteRange(startWord.start, endWord.end);
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb); if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
if (speedMode) addSpeedRange(startWord.start, endWord.end, speedModeValue);
} }
} }
zoneDragStart.current = null; zoneDragStart.current = null;
setZoneDragRange(null); setZoneDragRange(null);
selectionStart.current = null; selectionStart.current = null;
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, addCutRange, addMuteRange, addGainRange]); }, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange]);
const handleClickOutside = useCallback( const handleClickOutside = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -186,6 +199,14 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
addGainRange(startTime, endTime, gainModeDb); addGainRange(startTime, endTime, gainModeDb);
}, [selectedWordIndices, words, addGainRange, gainModeDb]); }, [selectedWordIndices, words, addGainRange, gainModeDb]);
const speedSelectedWords = useCallback(() => {
if (selectedWordIndices.length === 0) return;
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addSpeedRange(startTime, endTime, speedModeValue);
}, [selectedWordIndices, words, addSpeedRange, speedModeValue]);
const getCutRangeForWord = useCallback( const getCutRangeForWord = useCallback(
(wordIndex: number) => { (wordIndex: number) => {
const word = words[wordIndex]; const word = words[wordIndex];
@ -213,6 +234,15 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
[words, gainRanges], [words, gainRanges],
); );
const getSpeedRangeForWord = useCallback(
(wordIndex: number) => {
const word = words[wordIndex];
if (!word) return null;
return speedRanges.find((r) => word.start >= r.start && word.end <= r.end);
},
[words, speedRanges],
);
const renderSegment = useCallback( const renderSegment = useCallback(
(index: number) => { (index: number) => {
const segment = segments[index]; const segment = segments[index];
@ -238,6 +268,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
const cutRange = getCutRangeForWord(globalIndex); const cutRange = getCutRangeForWord(globalIndex);
const muteRange = getMuteRangeForWord(globalIndex); const muteRange = getMuteRangeForWord(globalIndex);
const gainRange = getGainRangeForWord(globalIndex); const gainRange = getGainRangeForWord(globalIndex);
const speedRange = getSpeedRangeForWord(globalIndex);
return ( return (
<span <span
@ -254,12 +285,14 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
${cutRange ? 'bg-red-500/20 text-red-100' : ''} ${cutRange ? 'bg-red-500/20 text-red-100' : ''}
${muteRange ? 'bg-blue-500/20 text-blue-100' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
${gainRange ? 'bg-amber-500/20 text-amber-100' : ''} ${gainRange ? 'bg-amber-500/20 text-amber-100' : ''}
${speedRange ? 'bg-emerald-500/20 text-emerald-100' : ''}
${isZoneDragSelected && cutMode ? 'bg-red-500/30 ring-1 ring-red-400/60' : ''} ${isZoneDragSelected && cutMode ? 'bg-red-500/30 ring-1 ring-red-400/60' : ''}
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''} ${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''} ${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-selected text-white' : ''} ${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-hover' : ''} ${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
`} `}
> >
{word.word}{' '} {word.word}{' '}
@ -274,13 +307,14 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
<RotateCcw className="w-2.5 h-2.5" /> Restore <RotateCcw className="w-2.5 h-2.5" /> Restore
</button> </button>
)} )}
{(cutRange || muteRange || gainRange) && isHovered && ( {(cutRange || muteRange || gainRange || speedRange) && isHovered && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (cutRange) removeCutRange(cutRange.id); if (cutRange) removeCutRange(cutRange.id);
if (muteRange) removeMuteRange(muteRange.id); if (muteRange) removeMuteRange(muteRange.id);
if (gainRange) removeGainRange(gainRange.id); if (gainRange) removeGainRange(gainRange.id);
if (speedRange) removeSpeedRange(speedRange.id);
}} }}
className="absolute -top-5 left-1/2 -translate-x-1/2 flex items-center gap-0.5 px-1.5 py-0.5 bg-editor-surface border border-editor-border rounded text-[10px] text-editor-success whitespace-nowrap z-10" className="absolute -top-5 left-1/2 -translate-x-1/2 flex items-center gap-0.5 px-1.5 py-0.5 bg-editor-surface border border-editor-border rounded text-[10px] text-editor-success whitespace-nowrap z-10"
> >
@ -294,7 +328,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
</div> </div>
); );
}, },
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange, zoneDragRange, cutMode, muteMode, gainMode], [segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode],
); );
return ( return (
@ -302,6 +336,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
<div className="flex items-center gap-2 px-4 py-2 border-b border-editor-border shrink-0"> <div className="flex items-center gap-2 px-4 py-2 border-b border-editor-border shrink-0">
<span className="text-xs text-editor-text-muted flex-1"> <span className="text-xs text-editor-text-muted flex-1">
{words.length} words &middot; {deletedRanges.length} cuts &middot; {cutRanges.length} cut ranges &middot; {muteRanges.length} mute ranges &middot; {gainRanges.length} gain ranges {words.length} words &middot; {deletedRanges.length} cuts &middot; {cutRanges.length} cut ranges &middot; {muteRanges.length} mute ranges &middot; {gainRanges.length} gain ranges
&middot; {speedRanges.length} speed ranges
</span> </span>
{selectedWordIndices.length > 0 && ( {selectedWordIndices.length > 0 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -326,6 +361,13 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
<SlidersHorizontal className="w-3 h-3" /> <SlidersHorizontal className="w-3 h-3" />
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB) Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
</button> </button>
<button
onClick={speedSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors"
>
<Gauge className="w-3 h-3" />
Speed {speedModeValue.toFixed(2)}x
</button>
</div> </div>
)} )}
</div> </div>

View File

@ -108,11 +108,15 @@ export default function WaveformTimeline({
muteMode, muteMode,
gainMode, gainMode,
gainModeDb, gainModeDb,
speedMode,
speedModeValue,
}: { }: {
cutMode: boolean; cutMode: boolean;
muteMode: boolean; muteMode: boolean;
gainMode: boolean; gainMode: boolean;
gainModeDb: number; gainModeDb: number;
speedMode: boolean;
speedModeValue: number;
}) { }) {
const waveCanvasRef = useRef<HTMLCanvasElement>(null); const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null); const headCanvasRef = useRef<HTMLCanvasElement>(null);
@ -127,16 +131,20 @@ export default function WaveformTimeline({
const cutRanges = useEditorStore((s) => s.cutRanges); const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges); const muteRanges = useEditorStore((s) => s.muteRanges);
const gainRanges = useEditorStore((s) => s.gainRanges); const gainRanges = useEditorStore((s) => s.gainRanges);
const speedRanges = useEditorStore((s) => s.speedRanges);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime); const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const addCutRange = useEditorStore((s) => s.addCutRange); const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange); const addMuteRange = useEditorStore((s) => s.addMuteRange);
const addGainRange = useEditorStore((s) => s.addGainRange); const addGainRange = useEditorStore((s) => s.addGainRange);
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
const updateCutRange = useEditorStore((s) => s.updateCutRange); const updateCutRange = useEditorStore((s) => s.updateCutRange);
const updateMuteRange = useEditorStore((s) => s.updateMuteRange); const updateMuteRange = useEditorStore((s) => s.updateMuteRange);
const updateGainRangeBounds = useEditorStore((s) => s.updateGainRangeBounds); const updateGainRangeBounds = useEditorStore((s) => s.updateGainRangeBounds);
const updateSpeedRangeBounds = useEditorStore((s) => s.updateSpeedRangeBounds);
const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange); const removeGainRange = useEditorStore((s) => s.removeGainRange);
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
const waveformDataRef = useRef<WaveformData | null>(null); const waveformDataRef = useRef<WaveformData | null>(null);
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
@ -150,12 +158,13 @@ export default function WaveformTimeline({
const selectionEndRef = useRef<number | null>(null); const selectionEndRef = useRef<number | null>(null);
const [selectionStart, setSelectionStart] = useState<number | null>(null); const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null); const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string} | null>(null); const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain' | 'speed', id: string} | null>(null);
const [hoverCursor, setHoverCursor] = useState<string>('crosshair'); const [hoverCursor, setHoverCursor] = useState<string>('crosshair');
const editingZoneRef = useRef<{type: 'cut' | 'mute' | 'gain', id: string, edge: 'start' | 'end' | 'move'} | null>(null); const editingZoneRef = useRef<{type: 'cut' | 'mute' | 'gain' | 'speed', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
const [showCutZones, setShowCutZones] = useState(true); const [showCutZones, setShowCutZones] = useState(true);
const [showMuteZones, setShowMuteZones] = useState(true); const [showMuteZones, setShowMuteZones] = useState(true);
const [showGainZones, setShowGainZones] = useState(true); const [showGainZones, setShowGainZones] = useState(true);
const [showSpeedZones, setShowSpeedZones] = useState(true);
useEffect(() => { useEffect(() => {
if (!videoUrl || !videoPath) return; if (!videoUrl || !videoPath) return;
@ -394,16 +403,53 @@ export default function WaveformTimeline({
} }
} }
// Draw speed ranges (emerald overlays)
for (const range of showSpeedZones ? speedRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
ctx.fillStyle = isSelected ? 'rgba(16, 185, 129, 0.55)' : 'rgba(16, 185, 129, 0.35)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
if (isSelected) {
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
ctx.fillStyle = '#10b981';
ctx.beginPath();
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.beginPath();
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
ctx.fill();
}
const centerX = (x1 + x2) / 2;
ctx.fillStyle = '#d1fae5';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (centerX > 12 && centerX < width - 12) {
ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + waveH / 2);
}
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}
// Draw selection overlay (when in zone mode) // Draw selection overlay (when in zone mode)
if ((cutMode || muteMode || gainMode) && selectionStart !== null && selectionEnd !== null) { if ((cutMode || muteMode || gainMode || speedMode) && selectionStart !== null && selectionEnd !== null) {
const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec; const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec;
const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec; const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec;
const fillColor = cutMode const fillColor = cutMode
? 'rgba(239, 68, 68, 0.5)' ? 'rgba(239, 68, 68, 0.5)'
: muteMode : muteMode
? 'rgba(59, 130, 246, 0.5)' ? 'rgba(59, 130, 246, 0.5)'
: 'rgba(245, 158, 11, 0.5)'; : gainMode
const strokeColor = cutMode ? '#ef4444' : muteMode ? '#3b82f6' : '#f59e0b'; ? 'rgba(245, 158, 11, 0.5)'
: 'rgba(16, 185, 129, 0.5)';
const strokeColor = cutMode ? '#ef4444' : muteMode ? '#3b82f6' : gainMode ? '#f59e0b' : '#10b981';
ctx.fillStyle = fillColor; ctx.fillStyle = fillColor;
ctx.fillRect(x1, waveTop, x2 - x1, waveH); ctx.fillRect(x1, waveTop, x2 - x1, waveH);
@ -441,15 +487,18 @@ export default function WaveformTimeline({
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
speedRanges,
selectionStart, selectionStart,
selectionEnd, selectionEnd,
cutMode, cutMode,
muteMode, muteMode,
gainMode, gainMode,
speedMode,
selectedZone, selectedZone,
showCutZones, showCutZones,
showMuteZones, showMuteZones,
showGainZones, showGainZones,
showSpeedZones,
]); ]);
// Keep the ref in sync with the latest drawStaticWaveform closure // Keep the ref in sync with the latest drawStaticWaveform closure
@ -715,8 +764,43 @@ export default function WaveformTimeline({
} }
} }
// Check speed ranges
for (const range of showSpeedZones ? speedRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
if (forHover && isSelected) {
if (Math.abs(x - rangeX1) <= handleSize) {
return { type: 'speed' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize) {
return { type: 'speed' as const, id: range.id, edge: 'end' as const };
}
} else if (!forHover) {
if (isSelected) {
if (Math.abs(x - rangeX1) <= handleSize) {
return { type: 'speed' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize) {
return { type: 'speed' as const, id: range.id, edge: 'end' as const };
}
} else {
if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
return { type: 'speed' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
return { type: 'speed' as const, id: range.id, edge: 'end' as const };
}
}
if (x >= rangeX1 && x <= rangeX2) {
return { type: 'speed' as const, id: range.id, edge: 'move' as const };
}
}
}
return null; return null;
}, [cutRanges, muteRanges, gainRanges, selectedZone, showCutZones, showMuteZones, showGainZones]); }, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => { const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) return; // Don't change cursor while dragging if (isDragging) return; // Don't change cursor while dragging
@ -752,7 +836,9 @@ export default function WaveformTimeline({
? cutRanges.find(r => r.id === zoneHit.id) ? cutRanges.find(r => r.id === zoneHit.id)
: zoneHit.type === 'mute' : zoneHit.type === 'mute'
? muteRanges.find(r => r.id === zoneHit.id) ? muteRanges.find(r => r.id === zoneHit.id)
: gainRanges.find(r => r.id === zoneHit.id); : zoneHit.type === 'gain'
? gainRanges.find(r => r.id === zoneHit.id)
: speedRanges.find(r => r.id === zoneHit.id);
if (!originalRange) return; if (!originalRange) return;
@ -784,8 +870,10 @@ export default function WaveformTimeline({
updateCutRange(editingZoneRef.current.id, newStart, newEnd); updateCutRange(editingZoneRef.current.id, newStart, newEnd);
} else if (editingZoneRef.current.type === 'mute') { } else if (editingZoneRef.current.type === 'mute') {
updateMuteRange(editingZoneRef.current.id, newStart, newEnd); updateMuteRange(editingZoneRef.current.id, newStart, newEnd);
} else { } else if (editingZoneRef.current.type === 'gain') {
updateGainRangeBounds(editingZoneRef.current.id, newStart, newEnd); updateGainRangeBounds(editingZoneRef.current.id, newStart, newEnd);
} else {
updateSpeedRangeBounds(editingZoneRef.current.id, newStart, newEnd);
} }
} }
}; };
@ -806,7 +894,7 @@ export default function WaveformTimeline({
// Clear selection if clicking elsewhere // Clear selection if clicking elsewhere
setSelectedZone(null); setSelectedZone(null);
if (cutMode || muteMode || gainMode) { if (cutMode || muteMode || gainMode || speedMode) {
// Range selection mode // Range selection mode
const startTime = clientXToTime(e.clientX); const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime; selectionStartRef.current = startTime;
@ -838,6 +926,8 @@ export default function WaveformTimeline({
addMuteRange(start, end); addMuteRange(start, end);
} else if (end - start >= minDuration && gainMode) { } else if (end - start >= minDuration && gainMode) {
addGainRange(start, end, gainModeDb); addGainRange(start, end, gainModeDb);
} else if (end - start >= minDuration && speedMode) {
addSpeedRange(start, end, speedModeValue);
} }
} }
@ -873,7 +963,7 @@ export default function WaveformTimeline({
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
} }
}, },
[cutMode, muteMode, gainMode, gainModeDb, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, getZoneAtPosition, cutRanges, muteRanges, gainRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds], [cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, addSpeedRange, getZoneAtPosition, cutRanges, muteRanges, gainRanges, speedRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds, updateSpeedRangeBounds],
); );
// Handle keyboard shortcuts for zone editing // Handle keyboard shortcuts for zone editing
@ -896,8 +986,10 @@ export default function WaveformTimeline({
removeCutRange(selectedZone.id); removeCutRange(selectedZone.id);
} else if (selectedZone.type === 'mute') { } else if (selectedZone.type === 'mute') {
removeMuteRange(selectedZone.id); removeMuteRange(selectedZone.id);
} else { } else if (selectedZone.type === 'gain') {
removeGainRange(selectedZone.id); removeGainRange(selectedZone.id);
} else {
removeSpeedRange(selectedZone.id);
} }
setSelectedZone(null); setSelectedZone(null);
editingZoneRef.current = null; editingZoneRef.current = null;
@ -908,14 +1000,15 @@ export default function WaveformTimeline({
// Capture phase ensures zone delete runs before app-level bubble shortcuts. // Capture phase ensures zone delete runs before app-level bubble shortcuts.
window.addEventListener('keydown', handleKeyDown, { capture: true }); window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange]); }, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]);
useEffect(() => { useEffect(() => {
if (!selectedZone) return; if (!selectedZone) return;
if (selectedZone.type === 'cut' && !showCutZones) setSelectedZone(null); if (selectedZone.type === 'cut' && !showCutZones) setSelectedZone(null);
if (selectedZone.type === 'mute' && !showMuteZones) setSelectedZone(null); if (selectedZone.type === 'mute' && !showMuteZones) setSelectedZone(null);
if (selectedZone.type === 'gain' && !showGainZones) setSelectedZone(null); if (selectedZone.type === 'gain' && !showGainZones) setSelectedZone(null);
}, [selectedZone, showCutZones, showMuteZones, showGainZones]); if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
}, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
if (!videoUrl) { if (!videoUrl) {
return ( return (
@ -935,6 +1028,7 @@ export default function WaveformTimeline({
{cutMode && <span className="text-[10px] text-red-400">Cut mode</span>} {cutMode && <span className="text-[10px] text-red-400">Cut mode</span>}
{muteMode && <span className="text-[10px] text-blue-400">Mute mode</span>} {muteMode && <span className="text-[10px] text-blue-400">Mute mode</span>}
{gainMode && <span className="text-[10px] text-amber-400">Gain mode ({gainModeDb.toFixed(1)} dB)</span>} {gainMode && <span className="text-[10px] text-amber-400">Gain mode ({gainModeDb.toFixed(1)} dB)</span>}
{speedMode && <span className="text-[10px] text-emerald-400">Speed mode ({speedModeValue.toFixed(2)}x)</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@ -958,6 +1052,13 @@ export default function WaveformTimeline({
> >
Gain Gain
</button> </button>
<button
onClick={() => setShowSpeedZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle speed zones"
>
Speed
</button>
<span className="text-[10px] text-editor-text-muted"> <span className="text-[10px] text-editor-text-muted">
Scroll · Ctrl+Scroll to zoom Scroll · Ctrl+Scroll to zoom
</span> </span>

View File

@ -1,25 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { Trash2, Scissors, Volume2, SlidersHorizontal } from 'lucide-react'; import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge } from 'lucide-react';
export default function ZoneEditor() { export default function ZoneEditor() {
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain'>('all'); const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all');
const { const {
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
speedRanges,
globalGainDb, globalGainDb,
setGlobalGainDb, setGlobalGainDb,
removeCutRange, removeCutRange,
removeMuteRange, removeMuteRange,
removeGainRange, removeGainRange,
removeSpeedRange,
updateGainRange, updateGainRange,
updateSpeedRange,
} = useEditorStore(); } = useEditorStore();
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length; const totalZones = cutRanges.length + muteRanges.length + gainRanges.length + speedRanges.length;
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain') => { const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain' | 'speed') => {
switch (type) { switch (type) {
case 'cut': case 'cut':
return 'border-red-500/40 bg-red-500/5'; return 'border-red-500/40 bg-red-500/5';
@ -27,6 +30,8 @@ export default function ZoneEditor() {
return 'border-orange-500/40 bg-orange-500/5'; return 'border-orange-500/40 bg-orange-500/5';
case 'gain': case 'gain':
return 'border-amber-500/40 bg-amber-500/5'; return 'border-amber-500/40 bg-amber-500/5';
case 'speed':
return 'border-emerald-500/40 bg-emerald-500/5';
} }
}; };
@ -85,6 +90,16 @@ export default function ZoneEditor() {
> >
Gain Gain
</button> </button>
<button
onClick={() => setViewMode('speed')}
className={`px-2 py-1 text-xs rounded transition-colors ${
viewMode === 'speed'
? 'bg-emerald-500/30 text-emerald-500'
: 'text-editor-text-muted hover:text-editor-text'
}`}
>
Speed
</button>
</div> </div>
</div> </div>
@ -230,6 +245,50 @@ export default function ZoneEditor() {
</div> </div>
</div> </div>
)} )}
{/* Speed Zones */}
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
<Gauge className="w-3.5 h-3.5" />
Speed Zones ({speedRanges.length})
</div>
<div className="space-y-1">
{speedRanges.map((range) => (
<div
key={range.id}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('speed')}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{range.start.toFixed(2)}s {range.end.toFixed(2)}s
</div>
<div className="text-editor-text-muted text-[10px]">
{range.speed.toFixed(2)}x
</div>
</div>
<input
type="number"
min={0.25}
max={4}
step={0.05}
value={range.speed}
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Speed multiplier"
/>
<button
onClick={() => removeSpeedRange(range.id)}
className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete speed zone"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -7,6 +7,7 @@ import type {
CutRange, CutRange,
MuteRange, MuteRange,
GainRange, GainRange,
SpeedRange,
TranscriptionResult, TranscriptionResult,
ProjectFile, ProjectFile,
SilenceDetectionRange, SilenceDetectionRange,
@ -24,6 +25,7 @@ interface EditorState {
cutRanges: CutRange[]; cutRanges: CutRange[];
muteRanges: MuteRange[]; muteRanges: MuteRange[];
gainRanges: GainRange[]; gainRanges: GainRange[];
speedRanges: SpeedRange[];
globalGainDb: number; globalGainDb: number;
silenceTrimGroups: SilenceTrimGroup[]; silenceTrimGroups: SilenceTrimGroup[];
transcriptionModel: string | null; transcriptionModel: string | null;
@ -63,13 +65,17 @@ interface EditorActions {
addCutRange: (start: number, end: number, trimGroupId?: string) => void; addCutRange: (start: number, end: number, trimGroupId?: string) => void;
addMuteRange: (start: number, end: number) => void; addMuteRange: (start: number, end: number) => void;
addGainRange: (start: number, end: number, gainDb: number) => void; addGainRange: (start: number, end: number, gainDb: number) => void;
addSpeedRange: (start: number, end: number, speed: number) => void;
updateCutRange: (id: string, start: number, end: number) => void; updateCutRange: (id: string, start: number, end: number) => void;
updateMuteRange: (id: string, start: number, end: number) => void; updateMuteRange: (id: string, start: number, end: number) => void;
updateGainRangeBounds: (id: string, start: number, end: number) => void; updateGainRangeBounds: (id: string, start: number, end: number) => void;
updateGainRange: (id: string, gainDb: number) => void; updateGainRange: (id: string, gainDb: number) => void;
updateSpeedRangeBounds: (id: string, start: number, end: number) => void;
updateSpeedRange: (id: string, speed: number) => void;
removeCutRange: (id: string) => void; removeCutRange: (id: string) => void;
removeMuteRange: (id: string) => void; removeMuteRange: (id: string) => void;
removeGainRange: (id: string) => void; removeGainRange: (id: string) => void;
removeSpeedRange: (id: string) => void;
setGlobalGainDb: (gainDb: number) => void; setGlobalGainDb: (gainDb: number) => void;
applySilenceTrimGroup: (args: { applySilenceTrimGroup: (args: {
groupId?: string; groupId?: string;
@ -95,6 +101,7 @@ const initialState: EditorState = {
cutRanges: [], cutRanges: [],
muteRanges: [], muteRanges: [],
gainRanges: [], gainRanges: [],
speedRanges: [],
globalGainDb: 0, globalGainDb: 0,
silenceTrimGroups: [], silenceTrimGroups: [],
transcriptionModel: null, transcriptionModel: null,
@ -152,7 +159,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setTranscriptionModel: (model) => set({ transcriptionModel: model }), setTranscriptionModel: (model) => set({ transcriptionModel: model }),
saveProject: (): ProjectFile => { saveProject: (): ProjectFile => {
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get(); const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
if (!videoPath) throw new Error('No video loaded'); if (!videoPath) throw new Error('No video loaded');
const now = new Date().toISOString(); const now = new Date().toISOString();
// Strip globalStartIndex (runtime-only field) before persisting. // Strip globalStartIndex (runtime-only field) before persisting.
@ -172,6 +179,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
speedRanges,
globalGainDb, globalGainDb,
silenceTrimGroups, silenceTrimGroups,
language, language,
@ -286,6 +294,17 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ gainRanges: [...gainRanges, newRange] }); set({ gainRanges: [...gainRanges, newRange] });
}, },
addSpeedRange: (start, end, speed) => {
const { speedRanges } = get();
const newRange: SpeedRange = {
id: `speed_${nextRangeId++}`,
start,
end,
speed: Math.max(0.25, Math.min(4, speed)),
};
set({ speedRanges: [...speedRanges, newRange] });
},
updateCutRange: (id, start, end) => { updateCutRange: (id, start, end) => {
const { cutRanges } = get(); const { cutRanges } = get();
set({ set({
@ -322,6 +341,24 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}); });
}, },
updateSpeedRangeBounds: (id, start, end) => {
const { speedRanges } = get();
set({
speedRanges: speedRanges.map((r) =>
r.id === id ? { ...r, start, end } : r
),
});
},
updateSpeedRange: (id, speed) => {
const { speedRanges } = get();
set({
speedRanges: speedRanges.map((r) =>
r.id === id ? { ...r, speed: Math.max(0.25, Math.min(4, speed)) } : r
),
});
},
removeCutRange: (id) => { removeCutRange: (id) => {
const { cutRanges } = get(); const { cutRanges } = get();
set({ cutRanges: cutRanges.filter((r) => r.id !== id) }); set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
@ -337,6 +374,11 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ gainRanges: gainRanges.filter((r) => r.id !== id) }); set({ gainRanges: gainRanges.filter((r) => r.id !== id) });
}, },
removeSpeedRange: (id) => {
const { speedRanges } = get();
set({ speedRanges: speedRanges.filter((r) => r.id !== id) });
},
setGlobalGainDb: (gainDb) => { setGlobalGainDb: (gainDb) => {
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) }); set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
}, },
@ -470,6 +512,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
cutRanges: data.cutRanges || [], cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [], muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [], gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [],
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0, globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [], silenceTrimGroups: data.silenceTrimGroups || [],
transcriptionModel: data.transcriptionModel ?? null, transcriptionModel: data.transcriptionModel ?? null,

View File

@ -40,6 +40,11 @@ export interface GainRange extends TimeRange {
gainDb: number; gainDb: number;
} }
export interface SpeedRange extends TimeRange {
id: string;
speed: number;
}
export interface SilenceDetectionRange extends TimeRange { export interface SilenceDetectionRange extends TimeRange {
duration: number; duration: number;
} }
@ -70,6 +75,7 @@ export interface ProjectFile {
cutRanges: CutRange[]; cutRanges: CutRange[];
muteRanges: MuteRange[]; muteRanges: MuteRange[];
gainRanges?: GainRange[]; gainRanges?: GainRange[];
speedRanges?: SpeedRange[];
globalGainDb?: number; globalGainDb?: number;
silenceTrimGroups?: SilenceTrimGroup[]; silenceTrimGroups?: SilenceTrimGroup[];
language: string; language: string;