UI improvements, moved file name and moved buttons left
This commit is contained in:
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 · {deletedRanges.length} cuts · {cutRanges.length} cut ranges · {muteRanges.length} mute ranges · {gainRanges.length} gain ranges
|
{words.length} words · {deletedRanges.length} cuts · {cutRanges.length} cut ranges · {muteRanges.length} mute ranges · {gainRanges.length} gain ranges
|
||||||
|
· {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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user