volume panel; copilot instructions
This commit is contained in:
81
.github/copilot-instructions.md
vendored
Normal file
81
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
# TalkEdit Copilot Instructions (Living Project Context)
|
||||
|
||||
Purpose: give AI assistants immediate, accurate context for this repository and define what must be kept in sync when the project evolves.
|
||||
|
||||
## How To Use This File
|
||||
|
||||
- This is a workspace instruction file for VS Code Chat/Copilot.
|
||||
- Treat this as the first source of truth for architecture and workflow expectations.
|
||||
- If your code changes make any section outdated, update this file in the same change.
|
||||
|
||||
## Project Snapshot
|
||||
|
||||
- Name: TalkEdit
|
||||
- Product: local-first, AI-powered, text-based audio/video editor.
|
||||
- Primary runtime today: Tauri + React frontend + Python FastAPI backend.
|
||||
- Legacy/transition artifacts may still exist (for example Electron paths/APIs), but default implementation direction is TalkEdit + Tauri.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Frontend: React 19, TypeScript, Vite, Tailwind, Zustand.
|
||||
- Desktop bridge: Tauri API with compatibility shim exposing `window.electronAPI` in `frontend/src/lib/tauri-bridge.ts`.
|
||||
- Backend: FastAPI + Uvicorn (`backend/main.py`) with routers in `backend/routers` and core services in `backend/services`.
|
||||
- Media tooling: FFmpeg for edit/export and codec operations.
|
||||
- AI tooling: WhisperX/faster-whisper for transcription; provider layer supports OpenAI/Anthropic/Ollama.
|
||||
|
||||
## Code Map
|
||||
|
||||
- `frontend/src/components`: editor UI (player, transcript, waveform, settings, export, AI panel).
|
||||
- `frontend/src/store`: Zustand state (`editorStore`, `aiStore`).
|
||||
- `frontend/src/hooks`: keyboard/video sync behavior.
|
||||
- `backend/routers`: API surface (`/transcribe`, `/export`, `/ai/*`, `/captions`, `/audio/*`).
|
||||
- `backend/services`: heavy operations (transcription, captioning, diarization, video editing, cleanup).
|
||||
- `shared/project-schema.json`: saved project schema contract.
|
||||
- `src-tauri`: Rust/Tauri host code and app configuration.
|
||||
|
||||
## Run And Build (Preferred)
|
||||
|
||||
- Frontend dev: `npm run dev`
|
||||
- Backend dev: `npm run dev:backend`
|
||||
- Tauri dev: `npm run dev:tauri`
|
||||
- Tauri build: `npm run build:tauri`
|
||||
|
||||
Use project virtualenvs where available (`.venv312`, `.venv`, or `venv`) for backend execution.
|
||||
|
||||
## Working Conventions
|
||||
|
||||
- Keep router files thin; put heavy logic in `backend/services`.
|
||||
- Preserve response compatibility for existing frontend callers unless task explicitly allows API breakage.
|
||||
- Keep frontend bridge compatibility stable: if desktop APIs change, update both Tauri-side implementation and the `window.electronAPI` shim contract.
|
||||
- Prefer small, focused edits over broad refactors.
|
||||
|
||||
## Known Risk Areas
|
||||
|
||||
- Startup/rendering on Linux WebKit can regress when reintroducing remote fonts/CSP allowances; prefer local font assets.
|
||||
- Media URL handling between project load paths should remain consistent to avoid format-specific regressions (especially WAV/MP3 behavior).
|
||||
- Export pipeline changes must preserve caption modes (`none`, `sidecar`, `burn-in`) and audio enhancement behavior.
|
||||
|
||||
## Update Rules (Important)
|
||||
|
||||
When a task changes architecture, app wiring, commands, API shape, project schema, or major conventions, update this file before finishing.
|
||||
|
||||
Always update these sections if affected:
|
||||
|
||||
- `Project Snapshot`
|
||||
- `Tech Stack`
|
||||
- `Code Map`
|
||||
- `Run And Build (Preferred)`
|
||||
- `Known Risk Areas`
|
||||
|
||||
If behavior changed significantly, add a short note under a new `Recent Changes` section with:
|
||||
|
||||
- Date (`YYYY-MM-DD`)
|
||||
- What changed
|
||||
- What future edits should preserve
|
||||
|
||||
## Assistant Behavior For This Repo
|
||||
|
||||
- Validate assumptions against current files before editing.
|
||||
- Prefer existing patterns in neighboring files over introducing new patterns.
|
||||
- Call out uncertainty explicitly when code and docs disagree.
|
||||
- If you discover stale docs, fix them as part of the same task when reasonable.
|
||||
80
FEATURES.md
80
FEATURES.md
@ -6,61 +6,61 @@ Features are grouped by priority. Check off items as they are implemented.
|
||||
|
||||
## 🔴 High Priority — Core editing gaps
|
||||
|
||||
- [x] **Cut / Mute sections** — select a time range and choose to cut (remove entirely) or mute (silence audio while video continues). Cut sections show as red overlays, mute sections as transparent blue overlays on the timeline over the transcript text and audio waveform. Backend: `ffmpeg -af volume=0` for mute, time-based cutting for removal.
|
||||
- [x] [#001] **Cut / Mute sections** — select a time range and choose to cut (remove entirely) or mute (silence audio while video continues). Cut sections show as red overlays, mute sections as transparent blue overlays on the timeline over the transcript text and audio waveform. Backend: `ffmpeg -af volume=0` for mute, time-based cutting for removal.
|
||||
|
||||
- [x] **Silence / pause trimmer (in progress)** — detect pauses using min duration (ms) + amplitude threshold (dB), then apply detected pauses as cut ranges. Initial endpoint: `/audio/detect-silence`; UI includes filter controls and an "Apply As Cuts" action.
|
||||
- [x] [#002] **Silence / pause trimmer (in progress)** — detect pauses using min duration (ms) + amplitude threshold (dB), then apply detected pauses as cut ranges. Initial endpoint: `/audio/detect-silence`; UI includes filter controls and an "Apply As Cuts" action.
|
||||
|
||||
- [x] **Operation-level undo for batch actions** — explicit undo entry for actions like "Apply Silence Trim" so one shortcut/click reverts the whole operation, while still allowing normal fine-grained undo/redo steps.
|
||||
- [x] [#003] **Operation-level undo for batch actions** — explicit undo entry for actions like "Apply Silence Trim" so one shortcut/click reverts the whole operation, while still allowing normal fine-grained undo/redo steps.
|
||||
|
||||
- [x] **Grouped silence-trim zones (editable batch)** — when pauses are applied, tag them as a batch (`trim_group_id`) so the user can: (1) delete all zones from that auto-trim pass at once, and (2) still select/resize/delete individual zones independently.
|
||||
- [x] [#004] **Grouped silence-trim zones (editable batch)** — when pauses are applied, tag them as a batch (`trim_group_id`) so the user can: (1) delete all zones from that auto-trim pass at once, and (2) still select/resize/delete individual zones independently.
|
||||
|
||||
- [x] **Edit silence-trim group settings after apply** — allow reopening a trim group and changing its detection settings (min pause ms, threshold dB, pre/post buffers), then reapplying updates to that group without affecting unrelated edits.
|
||||
- [x] [#005] **Edit silence-trim group settings after apply** — allow reopening a trim group and changing its detection settings (min pause ms, threshold dB, pre/post buffers), then reapplying updates to that group without affecting unrelated edits.
|
||||
|
||||
- [ ] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`.
|
||||
- [x] [#006] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`.
|
||||
|
||||
- [ ] **Speed adjustment** — slow down or speed up a selection or the whole clip. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
||||
- [ ] [#007] **Speed adjustment** — slow down or speed up a selection or the whole clip. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
||||
|
||||
- [ ] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join.
|
||||
- [ ] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join.
|
||||
|
||||
- [ ] **Timeline shows output length** — deleted regions should visually collapse (or show as narrow gaps) so the user sees the *output* duration, not just the source duration.
|
||||
- [ ] [#009] **Timeline shows output length** — deleted regions should visually collapse (or show as narrow gaps) so the user sees the *output* duration, not just the source duration.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Medium Priority — Widely expected features
|
||||
|
||||
- [ ] **Transcript search (Ctrl+F)** — find words/phrases in the transcript and highlight matches. Pure frontend. Critical for long-form content. Jump between matches with Enter.
|
||||
- [ ] [#010] **Transcript search (Ctrl+F)** — find words/phrases in the transcript and highlight matches. Pure frontend. Critical for long-form content. Jump between matches with Enter.
|
||||
|
||||
- [ ] **Mark In / Out + delete (I / O keys)** — keyboard shortcuts to mark a time range on the timeline, then delete it. Faster than click-dragging words. Store the in/out points in state, `Delete` removes them.
|
||||
- [ ] [#011] **Mark In / Out + delete (I / O keys)** — keyboard shortcuts to mark a time range on the timeline, then delete it. Faster than click-dragging words. Store the in/out points in state, `Delete` removes them.
|
||||
|
||||
- [ ] **Low-confidence word highlighting** — WhisperX already returns `confidence` per word. Words below a threshold (e.g. < 0.6) should be visually underlined or tinted so the user knows where to double-check.
|
||||
- [ ] [#012] **Low-confidence word highlighting** — WhisperX already returns `confidence` per word. Words below a threshold (e.g. < 0.6) should be visually underlined or tinted so the user knows where to double-check.
|
||||
|
||||
- [ ] **Re-transcribe selection** — if Whisper gets a section wrong, let the user select a word range and re-run transcription on just that segment (optionally with a different model or language).
|
||||
- [ ] [#013] **Re-transcribe selection** — if Whisper gets a section wrong, let the user select a word range and re-run transcription on just that segment (optionally with a different model or language).
|
||||
|
||||
- [ ] **Optional VibeVoice-ASR-HF transcription backend (future)** — evaluate as an alternate transcription mode for long-form, speaker-attributed transcripts. Keep WhisperX as the default for word-level timestamp editing.
|
||||
- [ ] [#014] **Optional VibeVoice-ASR-HF transcription backend (future)** — evaluate as an alternate transcription mode for long-form, speaker-attributed transcripts. Keep WhisperX as the default for word-level timestamp editing.
|
||||
|
||||
- [ ] **Word text correction** — allow editing the transcript text of a word without affecting its timing. Whisper gets homophones/proper nouns wrong constantly. Pure frontend state change; no backend needed.
|
||||
- [ ] [#015] **Word text correction** — allow editing the transcript text of a word without affecting its timing. Whisper gets homophones/proper nouns wrong constantly. Pure frontend state change; no backend needed.
|
||||
|
||||
- [ ] **Named timeline markers** — drop named marker pins on the waveform (like Resolve markers). Store as `{ id, time, label, color }` in the project. Rendered as colored triangles on the timeline canvas.
|
||||
- [ ] [#016] **Named timeline markers** — drop named marker pins on the waveform (like Resolve markers). Store as `{ id, time, label, color }` in the project. Rendered as colored triangles on the timeline canvas.
|
||||
|
||||
- [ ] **Chapters** — group markers into named chapter ranges. Useful for podcasts and lectures. Exportable as YouTube chapter timestamps in the description.
|
||||
- [ ] [#017] **Chapters** — group markers into named chapter ranges. Useful for podcasts and lectures. Exportable as YouTube chapter timestamps in the description.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Lower Priority — Differentiating / power features
|
||||
|
||||
- [ ] **Audio normalization / loudness targeting** — single "Normalize" button that targets a LUFS level (-14 for YouTube, -16 for Spotify). Backend: `ffmpeg -af loudnorm`. Very high value for podcasters, ~2–3 hours of work.
|
||||
- [ ] [#018] **Audio normalization / loudness targeting** — single "Normalize" button that targets a LUFS level (-14 for YouTube, -16 for Spotify). Backend: `ffmpeg -af loudnorm`. Very high value for podcasters, ~2–3 hours of work.
|
||||
|
||||
- [ ] **Background music track** — a second audio track for background music with volume ducking. Major gap in Descript that TalkEdit could own. Backend: `ffmpeg` amix + `asendcmd` for auto-ducking.
|
||||
- [ ] [#019] **Background music track** — a second audio track for background music with volume ducking. Major gap in Descript that TalkEdit could own. Backend: `ffmpeg` amix + `asendcmd` for auto-ducking.
|
||||
|
||||
- [ ] **Video zoom / punch-in** — scale and position the video (crop, zoom, pan). Used constantly on talking-head videos for emphasis. Backend: `ffmpeg -vf crop/scale/zoompan`.
|
||||
- [ ] [#020] **Video zoom / punch-in** — scale and position the video (crop, zoom, pan). Used constantly on talking-head videos for emphasis. Backend: `ffmpeg -vf crop/scale/zoompan`.
|
||||
|
||||
- [ ] **Multi-clip / append** — load a second video and append it to the timeline. Even without a full multi-track timeline, "append clip" is a heavily used workflow.
|
||||
- [ ] [#021] **Multi-clip / append** — load a second video and append it to the timeline. Even without a full multi-track timeline, "append clip" is a heavily used workflow.
|
||||
|
||||
- [ ] **Clip thumbnail strip** — video frame thumbnails along the timeline so users can navigate visually, not only by waveform. Backend: `ffmpeg` thumbnail extraction at regular intervals.
|
||||
- [ ] [#022] **Clip thumbnail strip** — video frame thumbnails along the timeline so users can navigate visually, not only by waveform. Backend: `ffmpeg` thumbnail extraction at regular intervals.
|
||||
|
||||
- [ ] **Batch silence removal** — full-file scan + remove all pauses above threshold in one click. Distinct from the manual trimmer above; this is a "fix the whole file" operation.
|
||||
- [ ] [#023] **Batch silence removal** — full-file scan + remove all pauses above threshold in one click. Distinct from the manual trimmer above; this is a "fix the whole file" operation.
|
||||
|
||||
- [ ] **Export to transcript text / SRT only** — some users just want a clean `.txt` or `.srt` of the edited transcript without rendering video.
|
||||
- [ ] [#024] **Export to transcript text / SRT only** — some users just want a clean `.txt` or `.srt` of the edited transcript without rendering video.
|
||||
|
||||
---
|
||||
|
||||
@ -77,19 +77,19 @@ These aren't features to build — they're things to make more visible in the UI
|
||||
|
||||
## ✅ Already Implemented
|
||||
|
||||
- Word-level transcript editing (select, drag, shift-click, delete)
|
||||
- Ctrl+click word → seek timeline to that position
|
||||
- Waveform timeline with zoom (Ctrl+scroll), scroll, drag-to-scrub playhead
|
||||
- Auto-scroll waveform when playhead goes off-screen
|
||||
- AI filler word detection and removal (Ollama / OpenAI / Claude)
|
||||
- AI clip suggestions for social media
|
||||
- Noise reduction (DeepFilterNet or FFmpeg ANLMDN)
|
||||
- Export: fast stream-copy or full reencode (MP4/MOV/WebM, 720p/1080p/4K)
|
||||
- Captions: SRT, VTT, ASS burn-in with font/color/position options
|
||||
- Speaker diarization
|
||||
- Project save / load (.aive JSON format)
|
||||
- Undo / redo (100-level history via Zundo)
|
||||
- Multi-format input (MP4, MKV, MOV, AVI, WebM, M4A)
|
||||
- Keyboard shortcuts (Space, J/K/L, arrows, Ctrl+Z/Shift+Z, Ctrl+S, Ctrl+E)
|
||||
- Settings panel: AI provider config (Ollama, OpenAI, Claude)
|
||||
- Cut/mute range creation on timeline with draggable zone edits and Delete-to-remove
|
||||
- [#025] Word-level transcript editing (select, drag, shift-click, delete)
|
||||
- [#026] Ctrl+click word → seek timeline to that position
|
||||
- [#027] Waveform timeline with zoom (Ctrl+scroll), scroll, drag-to-scrub playhead
|
||||
- [#028] Auto-scroll waveform when playhead goes off-screen
|
||||
- [#029] AI filler word detection and removal (Ollama / OpenAI / Claude)
|
||||
- [#030] AI clip suggestions for social media
|
||||
- [#031] Noise reduction (DeepFilterNet or FFmpeg ANLMDN)
|
||||
- [#032] Export: fast stream-copy or full reencode (MP4/MOV/WebM, 720p/1080p/4K)
|
||||
- [#033] Captions: SRT, VTT, ASS burn-in with font/color/position options
|
||||
- [#034] Speaker diarization
|
||||
- [#035] Project save / load (.aive JSON format)
|
||||
- [#036] Undo / redo (100-level history via Zundo)
|
||||
- [#037] Multi-format input (MP4, MKV, MOV, AVI, WebM, M4A)
|
||||
- [#038] Keyboard shortcuts (Space, J/K/L, arrows, Ctrl+Z/Shift+Z, Ctrl+S, Ctrl+E)
|
||||
- [#039] Settings panel: AI provider config (Ollama, OpenAI, Claude)
|
||||
- [#040] Cut/mute range creation on timeline with draggable zone edits and Delete-to-remove
|
||||
|
||||
@ -21,6 +21,10 @@ class SegmentModel(BaseModel):
|
||||
end: float
|
||||
|
||||
|
||||
class GainRangeModel(SegmentModel):
|
||||
gain_db: float
|
||||
|
||||
|
||||
class ExportWordModel(BaseModel):
|
||||
word: str
|
||||
start: float
|
||||
@ -33,6 +37,8 @@ class ExportRequest(BaseModel):
|
||||
output_path: str
|
||||
keep_segments: List[SegmentModel]
|
||||
mute_ranges: Optional[List[SegmentModel]] = None
|
||||
gain_ranges: Optional[List[GainRangeModel]] = None
|
||||
global_gain_db: float = 0.0
|
||||
mode: str = "fast"
|
||||
resolution: str = "1080p"
|
||||
format: str = "mp4"
|
||||
@ -42,6 +48,42 @@ class ExportRequest(BaseModel):
|
||||
deleted_indices: Optional[List[int]] = None
|
||||
|
||||
|
||||
def _map_ranges_to_output_timeline(
|
||||
ranges: List[dict],
|
||||
keep_segments: List[dict],
|
||||
) -> List[dict]:
|
||||
"""Map source-time ranges to output timeline after cuts are applied."""
|
||||
if not ranges or not keep_segments:
|
||||
return []
|
||||
|
||||
mapped: List[dict] = []
|
||||
output_cursor = 0.0
|
||||
for keep in keep_segments:
|
||||
keep_start = float(keep["start"])
|
||||
keep_end = float(keep["end"])
|
||||
keep_len = max(0.0, keep_end - keep_start)
|
||||
if keep_len <= 0:
|
||||
continue
|
||||
|
||||
for src_range in ranges:
|
||||
overlap_start = max(keep_start, float(src_range["start"]))
|
||||
overlap_end = min(keep_end, float(src_range["end"]))
|
||||
if overlap_end <= overlap_start:
|
||||
continue
|
||||
|
||||
mapped_range = {
|
||||
"start": output_cursor + (overlap_start - keep_start),
|
||||
"end": output_cursor + (overlap_end - keep_start),
|
||||
}
|
||||
if "gain_db" in src_range:
|
||||
mapped_range["gain_db"] = float(src_range["gain_db"])
|
||||
mapped.append(mapped_range)
|
||||
|
||||
output_cursor += keep_len
|
||||
|
||||
return mapped
|
||||
|
||||
|
||||
def _mux_audio(video_path: str, audio_path: str, output_path: str) -> str:
|
||||
"""Replace video's audio track with cleaned audio using FFmpeg."""
|
||||
import subprocess
|
||||
@ -66,15 +108,19 @@ async def export_video(req: ExportRequest):
|
||||
try:
|
||||
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
|
||||
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
|
||||
|
||||
if not segments and not mute_segments:
|
||||
raise HTTPException(status_code=400, detail="No segments to export")
|
||||
|
||||
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_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)
|
||||
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments and not has_gain
|
||||
needs_reencode_for_subs = req.captions == "burn-in"
|
||||
|
||||
# Burn-in captions or mute ranges require re-encode
|
||||
if needs_reencode_for_subs or mute_segments:
|
||||
# Burn-in captions or audio filters require re-encode
|
||||
if needs_reencode_for_subs or mute_segments or has_gain:
|
||||
use_stream_copy = False
|
||||
|
||||
words_dicts = [w.model_dump() for w in req.words] if req.words else []
|
||||
@ -101,6 +147,8 @@ async def export_video(req: ExportRequest):
|
||||
resolution=req.resolution,
|
||||
format_hint=req.format,
|
||||
mute_ranges=mute_segments,
|
||||
gain_ranges=mapped_gain_segments,
|
||||
global_gain_db=req.global_gain_db,
|
||||
)
|
||||
else:
|
||||
output = export_reencode(
|
||||
@ -110,6 +158,8 @@ async def export_video(req: ExportRequest):
|
||||
resolution=req.resolution,
|
||||
format_hint=req.format,
|
||||
mute_ranges=mute_segments,
|
||||
gain_ranges=mapped_gain_segments,
|
||||
global_gain_db=req.global_gain_db,
|
||||
)
|
||||
finally:
|
||||
if ass_path and os.path.exists(ass_path):
|
||||
|
||||
@ -112,6 +112,8 @@ def export_reencode(
|
||||
resolution: str = "1080p",
|
||||
format_hint: str = "mp4",
|
||||
mute_ranges: List[dict] = None,
|
||||
gain_ranges: List[dict] = None,
|
||||
global_gain_db: float = 0.0,
|
||||
) -> str:
|
||||
"""
|
||||
Export video with full re-encode. Slower but supports resolution changes,
|
||||
@ -128,21 +130,29 @@ def export_reencode(
|
||||
"4k": "scale=-2:2160",
|
||||
}
|
||||
|
||||
# Handle muting case - keep full video but silence audio ranges
|
||||
if mute_ranges and len(mute_ranges) > 0:
|
||||
# Build volume filter for muting
|
||||
volume_filters = []
|
||||
for i, mute_range in enumerate(mute_ranges):
|
||||
def build_audio_filter() -> str:
|
||||
filters = []
|
||||
if abs(float(global_gain_db)) > 1e-6:
|
||||
filters.append(f"volume={float(global_gain_db)}dB")
|
||||
|
||||
for gain_range in gain_ranges or []:
|
||||
start = gain_range['start']
|
||||
end = gain_range['end']
|
||||
gain_db = gain_range.get('gain_db', 0.0)
|
||||
filters.append(f"volume={float(gain_db)}dB:enable='between(t,{start},{end})'")
|
||||
|
||||
for mute_range in mute_ranges or []:
|
||||
start = mute_range['start']
|
||||
end = mute_range['end']
|
||||
# Use volume=0 to mute, enable to specify time range
|
||||
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
|
||||
filters.append(f"volume=0:enable='between(t,{start},{end})'")
|
||||
|
||||
# Combine all volume filters
|
||||
if volume_filters:
|
||||
audio_filter = ",".join(volume_filters)
|
||||
else:
|
||||
audio_filter = "anull" # No muting needed
|
||||
return ",".join(filters) if filters else "anull"
|
||||
|
||||
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)
|
||||
if has_audio_filters:
|
||||
audio_filter = build_audio_filter()
|
||||
|
||||
# Video filter - just scaling if needed
|
||||
scale = scale_map.get(resolution, "")
|
||||
@ -170,7 +180,14 @@ def export_reencode(
|
||||
output_path,
|
||||
]
|
||||
|
||||
logger.info(f"Re-encoding with {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
|
||||
logger.info(
|
||||
"Re-encoding with audio filters (mute=%s gain=%s global=%s) -> %s (%s)",
|
||||
len(mute_ranges or []),
|
||||
len(gain_ranges or []),
|
||||
global_gain_db,
|
||||
output_path,
|
||||
resolution,
|
||||
)
|
||||
else:
|
||||
# Original cutting logic
|
||||
if not keep_segments:
|
||||
@ -228,6 +245,8 @@ def export_reencode_with_subs(
|
||||
resolution: str = "1080p",
|
||||
format_hint: str = "mp4",
|
||||
mute_ranges: List[dict] = None,
|
||||
gain_ranges: List[dict] = None,
|
||||
global_gain_db: float = 0.0,
|
||||
) -> str:
|
||||
"""
|
||||
Export video with re-encode and burn-in subtitles (ASS format).
|
||||
@ -245,19 +264,29 @@ def export_reencode_with_subs(
|
||||
"4k": "scale=-2:2160",
|
||||
}
|
||||
|
||||
# Handle muting case - keep full video but silence audio ranges
|
||||
if mute_ranges and len(mute_ranges) > 0:
|
||||
# Build volume filter for muting
|
||||
volume_filters = []
|
||||
for i, mute_range in enumerate(mute_ranges):
|
||||
def build_audio_filter() -> str:
|
||||
filters = []
|
||||
if abs(float(global_gain_db)) > 1e-6:
|
||||
filters.append(f"volume={float(global_gain_db)}dB")
|
||||
|
||||
for gain_range in gain_ranges or []:
|
||||
start = gain_range['start']
|
||||
end = gain_range['end']
|
||||
gain_db = gain_range.get('gain_db', 0.0)
|
||||
filters.append(f"volume={float(gain_db)}dB:enable='between(t,{start},{end})'")
|
||||
|
||||
for mute_range in mute_ranges or []:
|
||||
start = mute_range['start']
|
||||
end = mute_range['end']
|
||||
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
|
||||
filters.append(f"volume=0:enable='between(t,{start},{end})'")
|
||||
|
||||
if volume_filters:
|
||||
audio_filter = ",".join(volume_filters)
|
||||
else:
|
||||
audio_filter = "anull"
|
||||
return ",".join(filters) if filters else "anull"
|
||||
|
||||
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)
|
||||
if has_audio_filters:
|
||||
audio_filter = build_audio_filter()
|
||||
|
||||
# Video filter with subtitles
|
||||
escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
|
||||
@ -284,7 +313,14 @@ def export_reencode_with_subs(
|
||||
output_path,
|
||||
]
|
||||
|
||||
logger.info(f"Re-encoding with subtitles and {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
|
||||
logger.info(
|
||||
"Re-encoding with subtitles and audio filters (mute=%s gain=%s global=%s) -> %s (%s)",
|
||||
len(mute_ranges or []),
|
||||
len(gain_ranges or []),
|
||||
global_gain_db,
|
||||
output_path,
|
||||
resolution,
|
||||
)
|
||||
else:
|
||||
# Original cutting logic with subtitles
|
||||
if not keep_segments:
|
||||
|
||||
@ -8,6 +8,17 @@ let pythonBackend = null;
|
||||
const isDev = !app.isPackaged;
|
||||
const BACKEND_PORT = 8642;
|
||||
|
||||
function getProjectDirectory() {
|
||||
const fs = require('fs');
|
||||
// Keep project files alongside the TalkEdit workspace in development.
|
||||
// electron/main.js lives under <repo>/electron, so repo root is ../
|
||||
const projectsDir = path.join(__dirname, '..', 'Projects');
|
||||
if (!fs.existsSync(projectsDir)) {
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
}
|
||||
return projectsDir;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
@ -92,7 +103,9 @@ ipcMain.handle('dialog:saveFile', async (_event, options) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:openProject', async () => {
|
||||
const projectDir = getProjectDirectory();
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
defaultPath: projectDir,
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'AI Video Editor Project', extensions: ['aive'] },
|
||||
@ -101,6 +114,18 @@ ipcMain.handle('dialog:openProject', async () => {
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:saveProject', async (_event, options) => {
|
||||
const projectDir = getProjectDirectory();
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
defaultPath: path.join(projectDir, 'project.aive'),
|
||||
filters: [
|
||||
{ name: 'AI Video Editor Project', extensions: ['aive'] },
|
||||
],
|
||||
...options,
|
||||
});
|
||||
return result.canceled ? null : result.filePath;
|
||||
});
|
||||
|
||||
ipcMain.handle('safe-storage:encrypt', (_event, data) => {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return safeStorage.encryptString(data).toString('base64');
|
||||
@ -119,6 +144,48 @@ ipcMain.handle('get-backend-url', () => {
|
||||
return `http://localhost:${BACKEND_PORT}`;
|
||||
});
|
||||
|
||||
ipcMain.handle('backend:ensureModel', async (_event, modelName) => {
|
||||
// Python backend downloads models lazily during transcription.
|
||||
// Keep this IPC for compatibility with existing renderer flow.
|
||||
return modelName;
|
||||
});
|
||||
|
||||
ipcMain.handle('backend:transcribe', async (_event, payload) => {
|
||||
const filePath = payload?.filePath;
|
||||
const modelName = payload?.modelName || 'base';
|
||||
const language = payload?.language;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('Missing file path for transcription');
|
||||
}
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${BACKEND_PORT}/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
model: modelName,
|
||||
language,
|
||||
use_gpu: true,
|
||||
use_cache: true,
|
||||
diarize: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.detail) detail = `${detail}: ${body.detail}`;
|
||||
} catch (_err) {
|
||||
// ignore JSON parse errors for non-JSON responses
|
||||
}
|
||||
throw new Error(`Transcription failed: ${detail}`);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
});
|
||||
|
||||
ipcMain.handle('fs:readFile', async (_event, filePath) => {
|
||||
const fs = require('fs');
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
@ -4,7 +4,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openFile: (options) => ipcRenderer.invoke('dialog:openFile', options),
|
||||
saveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options),
|
||||
openProject: () => ipcRenderer.invoke('dialog:openProject'),
|
||||
saveProject: (options) => ipcRenderer.invoke('dialog:saveProject', options),
|
||||
getBackendUrl: () => ipcRenderer.invoke('get-backend-url'),
|
||||
ensureModel: (modelName) => ipcRenderer.invoke('backend:ensureModel', modelName),
|
||||
transcribe: (filePath, modelName, language) => ipcRenderer.invoke('backend:transcribe', { filePath, modelName, language }),
|
||||
encryptString: (data) => ipcRenderer.invoke('safe-storage:encrypt', data),
|
||||
decryptString: (encrypted) => ipcRenderer.invoke('safe-storage:decrypt', encrypted),
|
||||
readFile: (path) => ipcRenderer.invoke('fs:readFile', path),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useEditorStore } from './store/editorStore';
|
||||
import VideoPlayer from './components/VideoPlayer';
|
||||
import TranscriptEditor from './components/TranscriptEditor';
|
||||
@ -8,6 +8,7 @@ import ExportDialog from './components/ExportDialog';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import DevPanel from './components/DevPanel';
|
||||
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||
import VolumePanel from './components/VolumePanel';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import {
|
||||
Film,
|
||||
@ -20,6 +21,7 @@ import {
|
||||
Save,
|
||||
Scissors,
|
||||
VolumeX,
|
||||
Volume2,
|
||||
FilePlus2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
@ -27,13 +29,22 @@ import {
|
||||
const IS_ELECTRON = !!window.electronAPI;
|
||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | null;
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null;
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
isTranscribing,
|
||||
transcriptionProgress,
|
||||
transcriptionStatus,
|
||||
@ -54,8 +65,72 @@ export default function App() {
|
||||
const [cutMode, setCutMode] = useState(false);
|
||||
const [muteMode, setMuteMode] = useState(false);
|
||||
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
|
||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const projectSignature = useMemo(() => {
|
||||
if (!videoPath) return null;
|
||||
return JSON.stringify({
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
});
|
||||
}, [
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
]);
|
||||
|
||||
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
||||
|
||||
const loadProjectFromData = (data: any) => {
|
||||
useEditorStore.getState().loadProject(data);
|
||||
const loadedSignature = JSON.stringify({
|
||||
videoPath: data.videoPath,
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
words: data.words || [],
|
||||
segments: data.segments || [],
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
cutRanges: data.cutRanges || [],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
transcriptionModel: data.transcriptionModel ?? null,
|
||||
language: data.language || '',
|
||||
});
|
||||
setLastSavedSignature(loadedSignature);
|
||||
};
|
||||
|
||||
const runGuarded = async (action: () => Promise<void>) => {
|
||||
if (!IS_ELECTRON || !hasUnsavedChanges) {
|
||||
await action();
|
||||
return;
|
||||
}
|
||||
setPendingProceedAction(() => action);
|
||||
setShowUnsavedPrompt(true);
|
||||
};
|
||||
|
||||
useKeyboardShortcuts();
|
||||
|
||||
// Handle Escape key to exit cut/mute modes
|
||||
@ -99,36 +174,46 @@ export default function App() {
|
||||
|
||||
const handleLoadProject = async () => {
|
||||
if (!IS_ELECTRON) return;
|
||||
await runGuarded(async () => {
|
||||
try {
|
||||
const projectPath = await window.electronAPI!.openProject();
|
||||
if (!projectPath) return;
|
||||
const content = await window.electronAPI!.readFile(projectPath);
|
||||
const data = JSON.parse(content);
|
||||
useEditorStore.getState().loadProject(data);
|
||||
loadProjectFromData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err);
|
||||
alert(`Failed to load project: ${err}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveProject = async () => {
|
||||
if (!IS_ELECTRON) return;
|
||||
const handleSaveProject = async (): Promise<boolean> => {
|
||||
if (!IS_ELECTRON) return false;
|
||||
try {
|
||||
const savePath = await window.electronAPI!.saveProject();
|
||||
if (!savePath) return;
|
||||
if (!savePath) return false;
|
||||
const data = useEditorStore.getState().saveProject();
|
||||
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
|
||||
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
|
||||
if (projectSignature) {
|
||||
setLastSavedSignature(projectSignature);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to save project:', err);
|
||||
alert(`Failed to save project: ${err}`);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleOpenFile = async () => {
|
||||
await runGuarded(async () => {
|
||||
if (IS_ELECTRON) {
|
||||
const path = await window.electronAPI!.openFile();
|
||||
if (path) {
|
||||
setLastSavedSignature(null);
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
@ -140,17 +225,18 @@ export default function App() {
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewProject = () => {
|
||||
const shouldReset = window.confirm('Start a new project? Unsaved changes in the current session will be lost.');
|
||||
if (!shouldReset) return;
|
||||
|
||||
runGuarded(async () => {
|
||||
useEditorStore.getState().reset();
|
||||
setLastSavedSignature(null);
|
||||
setActivePanel(null);
|
||||
setManualPath('');
|
||||
setCutMode(false);
|
||||
setMuteMode(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleManualSubmit = async (e: React.FormEvent) => {
|
||||
@ -209,7 +295,9 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
await runGuarded(async () => {
|
||||
setShowReprocessConfirm(true);
|
||||
});
|
||||
};
|
||||
|
||||
const confirmReprocessProject = async () => {
|
||||
@ -218,6 +306,33 @@ export default function App() {
|
||||
await transcribeVideo(videoPath);
|
||||
};
|
||||
|
||||
const handleUnsavedSaveAndContinue = async () => {
|
||||
const action = pendingProceedAction;
|
||||
if (!action) {
|
||||
setShowUnsavedPrompt(false);
|
||||
return;
|
||||
}
|
||||
const didSave = await handleSaveProject();
|
||||
if (!didSave) return;
|
||||
setShowUnsavedPrompt(false);
|
||||
setPendingProceedAction(null);
|
||||
await action();
|
||||
};
|
||||
|
||||
const handleUnsavedDiscardAndContinue = async () => {
|
||||
const action = pendingProceedAction;
|
||||
setShowUnsavedPrompt(false);
|
||||
setPendingProceedAction(null);
|
||||
if (action) {
|
||||
await action();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsavedCancel = () => {
|
||||
setShowUnsavedPrompt(false);
|
||||
setPendingProceedAction(null);
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Panel) => {
|
||||
setActivePanel((prev) => (prev === panel ? null : panel));
|
||||
};
|
||||
@ -403,6 +518,13 @@ export default function App() {
|
||||
onClick={handleMute}
|
||||
active={muteMode}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Volume2 className="w-4 h-4" />}
|
||||
label="Volume"
|
||||
active={activePanel === 'volume'}
|
||||
onClick={() => togglePanel('volume')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||
label="Pause Trim"
|
||||
@ -520,6 +642,7 @@ export default function App() {
|
||||
{/* Right panel (AI / Export / Settings) */}
|
||||
{activePanel && (
|
||||
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
||||
{activePanel === 'volume' && <VolumePanel />}
|
||||
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
@ -559,6 +682,43 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnsavedPrompt && (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 px-4"
|
||||
onClick={handleUnsavedCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Save changes first?</h3>
|
||||
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||
There are unsaved changes in this project. Save before continuing?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleUnsavedCancel}
|
||||
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnsavedDiscardAndContinue}
|
||||
className="px-3 py-1.5 rounded-md text-xs text-editor-text hover:bg-editor-surface"
|
||||
>
|
||||
Don't Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnsavedSaveAndContinue}
|
||||
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
|
||||
import type { ExportOptions } from '../types/project';
|
||||
|
||||
export default function ExportDialog() {
|
||||
const { videoPath, words, deletedRanges, cutRanges, muteRanges, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
||||
const { videoPath, words, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
||||
useEditorStore();
|
||||
|
||||
const hasCuts = deletedRanges.length > 0;
|
||||
@ -47,6 +47,8 @@ export default function ExportDialog() {
|
||||
output_path: outputPath,
|
||||
keep_segments: keepSegments,
|
||||
mute_ranges: muteRanges,
|
||||
gain_ranges: gainRanges,
|
||||
global_gain_db: globalGainDb,
|
||||
words: options.captions !== 'none' ? words : undefined,
|
||||
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
||||
...options,
|
||||
|
||||
134
frontend/src/components/VolumePanel.tsx
Normal file
134
frontend/src/components/VolumePanel.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Trash2, Volume2 } from 'lucide-react';
|
||||
|
||||
export default function VolumePanel() {
|
||||
const {
|
||||
words,
|
||||
selectedWordIndices,
|
||||
globalGainDb,
|
||||
gainRanges,
|
||||
setGlobalGainDb,
|
||||
addGainRange,
|
||||
updateGainRange,
|
||||
removeGainRange,
|
||||
} = useEditorStore();
|
||||
|
||||
const [selectionGainDb, setSelectionGainDb] = useState(3);
|
||||
|
||||
const canApplySelection = selectedWordIndices.length > 0;
|
||||
|
||||
const selectedRange = useMemo(() => {
|
||||
if (!canApplySelection) return null;
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startWord = words[sorted[0]];
|
||||
const endWord = words[sorted[sorted.length - 1]];
|
||||
if (!startWord || !endWord) return null;
|
||||
return {
|
||||
start: startWord.start,
|
||||
end: endWord.end,
|
||||
};
|
||||
}, [canApplySelection, selectedWordIndices, words]);
|
||||
|
||||
const applySelectionGain = () => {
|
||||
if (!selectedRange) return;
|
||||
addGainRange(selectedRange.start, selectedRange.end, selectionGainDb);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Volume2 className="w-4 h-4" />
|
||||
Volume / Gain
|
||||
</h3>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Apply global gain or per-selection gain ranges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Global Gain (dB)</label>
|
||||
<input
|
||||
type="range"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={globalGainDb}
|
||||
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-editor-text-muted">-24 dB</span>
|
||||
<span className="font-medium text-editor-text">{globalGainDb.toFixed(1)} dB</span>
|
||||
<span className="text-editor-text-muted">+24 dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={selectionGainDb}
|
||||
onChange={(e) => setSelectionGainDb(Number(e.target.value) || 0)}
|
||||
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={applySelectionGain}
|
||||
disabled={!canApplySelection || !selectedRange}
|
||||
className="px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40"
|
||||
>
|
||||
Apply To Selection
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-editor-text-muted">
|
||||
{canApplySelection
|
||||
? `${selectedWordIndices.length} selected words${selectedRange ? ` (${selectedRange.start.toFixed(2)}s - ${selectedRange.end.toFixed(2)}s)` : ''}`
|
||||
: 'Select transcript words to apply a gain range.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{gainRanges.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<div className="text-xs font-medium">Gain Ranges</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
|
||||
{gainRanges.map((range) => (
|
||||
<div
|
||||
key={range.id}
|
||||
className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs flex items-center gap-2"
|
||||
>
|
||||
<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">{range.id}</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={range.gainDb}
|
||||
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
||||
className="w-20 px-2 py-1 text-xs bg-editor-bg border border-editor-border rounded"
|
||||
title="Gain dB"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeGainRange(range.id)}
|
||||
className="p-1 rounded hover:bg-editor-danger/20 text-editor-danger"
|
||||
title="Delete gain range"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
DeletedRange,
|
||||
CutRange,
|
||||
MuteRange,
|
||||
GainRange,
|
||||
TranscriptionResult,
|
||||
ProjectFile,
|
||||
SilenceDetectionRange,
|
||||
@ -22,6 +23,8 @@ interface EditorState {
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
gainRanges: GainRange[];
|
||||
globalGainDb: number;
|
||||
silenceTrimGroups: SilenceTrimGroup[];
|
||||
transcriptionModel: string | null;
|
||||
language: string;
|
||||
@ -59,10 +62,14 @@ interface EditorActions {
|
||||
restoreRange: (rangeId: string) => void;
|
||||
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
|
||||
addMuteRange: (start: number, end: number) => void;
|
||||
addGainRange: (start: number, end: number, gainDb: number) => void;
|
||||
updateCutRange: (id: string, start: number, end: number) => void;
|
||||
updateMuteRange: (id: string, start: number, end: number) => void;
|
||||
updateGainRange: (id: string, gainDb: number) => void;
|
||||
removeCutRange: (id: string) => void;
|
||||
removeMuteRange: (id: string) => void;
|
||||
removeGainRange: (id: string) => void;
|
||||
setGlobalGainDb: (gainDb: number) => void;
|
||||
applySilenceTrimGroup: (args: {
|
||||
groupId?: string;
|
||||
sourceRanges: SilenceDetectionRange[];
|
||||
@ -86,6 +93,8 @@ const initialState: EditorState = {
|
||||
deletedRanges: [],
|
||||
cutRanges: [],
|
||||
muteRanges: [],
|
||||
gainRanges: [],
|
||||
globalGainDb: 0,
|
||||
silenceTrimGroups: [],
|
||||
transcriptionModel: null,
|
||||
language: '',
|
||||
@ -142,7 +151,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||
|
||||
saveProject: (): ProjectFile => {
|
||||
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
||||
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
||||
if (!videoPath) throw new Error('No video loaded');
|
||||
const now = new Date().toISOString();
|
||||
// Strip globalStartIndex (runtime-only field) before persisting
|
||||
@ -157,6 +166,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
language,
|
||||
createdAt: now, // will be overwritten if we track original creation time later
|
||||
@ -259,6 +270,17 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
set({ muteRanges: [...muteRanges, newRange] });
|
||||
},
|
||||
|
||||
addGainRange: (start, end, gainDb) => {
|
||||
const { gainRanges } = get();
|
||||
const newRange: GainRange = {
|
||||
id: `gain_${nextRangeId++}`,
|
||||
start,
|
||||
end,
|
||||
gainDb,
|
||||
};
|
||||
set({ gainRanges: [...gainRanges, newRange] });
|
||||
},
|
||||
|
||||
updateCutRange: (id, start, end) => {
|
||||
const { cutRanges } = get();
|
||||
set({
|
||||
@ -277,6 +299,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
});
|
||||
},
|
||||
|
||||
updateGainRange: (id, gainDb) => {
|
||||
const { gainRanges } = get();
|
||||
set({
|
||||
gainRanges: gainRanges.map((r) =>
|
||||
r.id === id ? { ...r, gainDb } : r
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
removeCutRange: (id) => {
|
||||
const { cutRanges } = get();
|
||||
set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
|
||||
@ -287,6 +318,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
set({ muteRanges: muteRanges.filter((r) => r.id !== id) });
|
||||
},
|
||||
|
||||
removeGainRange: (id) => {
|
||||
const { gainRanges } = get();
|
||||
set({ gainRanges: gainRanges.filter((r) => r.id !== id) });
|
||||
},
|
||||
|
||||
setGlobalGainDb: (gainDb) => {
|
||||
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
||||
},
|
||||
|
||||
applySilenceTrimGroup: ({ groupId, sourceRanges, settings }) => {
|
||||
const { duration, cutRanges, silenceTrimGroups } = get();
|
||||
const now = new Date().toISOString();
|
||||
@ -415,6 +455,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
cutRanges: data.cutRanges || [],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
transcriptionModel: data.transcriptionModel ?? null,
|
||||
language: data.language || '',
|
||||
|
||||
@ -35,6 +35,11 @@ export interface MuteRange extends TimeRange {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GainRange extends TimeRange {
|
||||
id: string;
|
||||
gainDb: number;
|
||||
}
|
||||
|
||||
export interface SilenceDetectionRange extends TimeRange {
|
||||
duration: number;
|
||||
}
|
||||
@ -64,6 +69,8 @@ export interface ProjectFile {
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
gainRanges?: GainRange[];
|
||||
globalGainDb?: number;
|
||||
silenceTrimGroups?: SilenceTrimGroup[];
|
||||
language: string;
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user