changed to python312
This commit is contained in:
30
.gitignore
vendored
30
.gitignore
vendored
@ -6,10 +6,15 @@ frontend/dist/
|
|||||||
|
|
||||||
# Python
|
# Python
|
||||||
venv/
|
venv/
|
||||||
|
.venv312/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
# IDE / Editor
|
# IDE / Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
@ -18,16 +23,33 @@ __pycache__/
|
|||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.tmp
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
# Lock files (root only — frontend lock is committed)
|
# Build output
|
||||||
/package-lock.json
|
frontend/dist/
|
||||||
|
|
||||||
# Electron build output
|
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.asar
|
*.asar
|
||||||
|
target/
|
||||||
|
src-tauri/target/
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Lock files (root only — frontend lock is committed)
|
||||||
|
/package-lock.json
|
||||||
|
|||||||
@ -61,18 +61,37 @@ async def serve_local_file(request: Request, path: str = Query(...)):
|
|||||||
"""Stream a local file with HTTP Range support (required for video seeking)."""
|
"""Stream a local file with HTTP Range support (required for video seeking)."""
|
||||||
file_path = Path(path)
|
file_path = Path(path)
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
|
logger.warning(f"[serve_file] File not found: {path}")
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||||
|
|
||||||
file_size = file_path.stat().st_size
|
file_size = file_path.stat().st_size
|
||||||
content_type = MIME_MAP.get(file_path.suffix.lower(), "application/octet-stream")
|
content_type = MIME_MAP.get(file_path.suffix.lower(), "application/octet-stream")
|
||||||
|
|
||||||
range_header = request.headers.get("range")
|
range_header = request.headers.get("range")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[serve_file] {file_path.name} | size={file_size} | "
|
||||||
|
f"type={content_type} | range={range_header or 'none'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if content_type == "application/octet-stream":
|
||||||
|
logger.warning(
|
||||||
|
f"[serve_file] Unknown MIME type for extension '{file_path.suffix}' — "
|
||||||
|
f"browser may fail to decode audio/video for '{file_path.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if file_size == 0:
|
||||||
|
logger.error(f"[serve_file] File is empty: {path}")
|
||||||
|
raise HTTPException(status_code=422, detail=f"File is empty: {path}")
|
||||||
if range_header:
|
if range_header:
|
||||||
|
try:
|
||||||
range_spec = range_header.replace("bytes=", "")
|
range_spec = range_header.replace("bytes=", "")
|
||||||
range_start_str, range_end_str = range_spec.split("-")
|
range_start_str, range_end_str = range_spec.split("-")
|
||||||
range_start = int(range_start_str) if range_start_str else 0
|
range_start = int(range_start_str) if range_start_str else 0
|
||||||
range_end = int(range_end_str) if range_end_str else file_size - 1
|
range_end = int(range_end_str) if range_end_str else file_size - 1
|
||||||
range_end = min(range_end, file_size - 1)
|
range_end = min(range_end, file_size - 1)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.error(f"[serve_file] Malformed Range header '{range_header}': {e}")
|
||||||
|
raise HTTPException(status_code=416, detail=f"Invalid Range header: {range_header}")
|
||||||
content_length = range_end - range_start + 1
|
content_length = range_end - range_start + 1
|
||||||
|
|
||||||
def iter_range():
|
def iter_range():
|
||||||
|
|||||||
@ -1,33 +1,164 @@
|
|||||||
# FastAPI backend
|
aiohappyeyeballs==2.6.1
|
||||||
fastapi>=0.115.0
|
aiohttp==3.13.4
|
||||||
uvicorn[standard]>=0.32.0
|
aiosignal==1.4.0
|
||||||
websockets>=14.0
|
alembic==1.18.4
|
||||||
python-multipart>=0.0.12
|
annotated-doc==0.0.4
|
||||||
|
annotated-types==0.7.0
|
||||||
# Transcription (WhisperX for word-level alignment)
|
anthropic==0.86.0
|
||||||
whisperx>=3.1.0
|
antlr4-python3-runtime==4.9.3
|
||||||
faster-whisper>=1.0.0
|
anyio==4.13.0
|
||||||
|
appdirs==1.4.4
|
||||||
# Audio / Video processing
|
asteroid-filterbanks==0.4.0
|
||||||
moviepy>=1.0.3
|
attrs==26.1.0
|
||||||
ffmpeg-python>=0.2.0
|
av==17.0.0
|
||||||
soundfile>=0.10.3
|
certifi==2026.2.25
|
||||||
|
cffi==2.0.0
|
||||||
# ML / GPU
|
charset-normalizer==3.4.6
|
||||||
torch>=2.0.0
|
click==8.3.1
|
||||||
torchaudio>=2.0.0
|
colorlog==6.10.1
|
||||||
numpy>=1.24.0
|
contourpy==1.3.3
|
||||||
|
ctranslate2==4.7.1
|
||||||
# Speaker diarization
|
cuda-bindings==12.9.4
|
||||||
pyannote.audio>=3.1.1
|
cuda-pathfinder==1.2.2
|
||||||
|
cuda-toolkit==12.6.3
|
||||||
# AI providers
|
cycler==0.12.1
|
||||||
openai>=1.50.0
|
Cython==0.29.37
|
||||||
anthropic>=0.39.0
|
decorator==5.2.1
|
||||||
requests>=2.28.0
|
DeepFilterLib==0.5.6
|
||||||
|
DeepFilterNet==0.5.6
|
||||||
# Audio cleanup
|
distro==1.9.0
|
||||||
deepfilternet>=0.5.0
|
docstring_parser==0.17.0
|
||||||
|
einops==0.8.2
|
||||||
# Utilities
|
fastapi==0.135.2
|
||||||
pydantic>=2.0.0
|
faster-whisper==1.2.1
|
||||||
|
ffmpeg-python==0.2.0
|
||||||
|
filelock==3.25.2
|
||||||
|
flatbuffers==25.12.19
|
||||||
|
fonttools==4.62.1
|
||||||
|
frozenlist==1.8.0
|
||||||
|
fsspec==2026.2.0
|
||||||
|
future==1.0.0
|
||||||
|
googleapis-common-protos==1.73.1
|
||||||
|
greenlet==3.3.2
|
||||||
|
grpcio==1.78.0
|
||||||
|
h11==0.16.0
|
||||||
|
hf-xet==1.4.2
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.7.1
|
||||||
|
httpx==0.28.1
|
||||||
|
huggingface_hub==0.36.2
|
||||||
|
HyperPyYAML==1.2.3
|
||||||
|
idna==3.11
|
||||||
|
ImageIO==2.37.3
|
||||||
|
imageio-ffmpeg==0.6.0
|
||||||
|
importlib_metadata==8.7.1
|
||||||
|
Jinja2==3.1.6
|
||||||
|
jiter==0.13.0
|
||||||
|
joblib==1.5.3
|
||||||
|
julius==0.2.7
|
||||||
|
kiwisolver==1.5.0
|
||||||
|
lightning==2.6.1
|
||||||
|
lightning-utilities==0.15.3
|
||||||
|
loguru==0.7.3
|
||||||
|
Mako==1.3.10
|
||||||
|
markdown-it-py==4.0.0
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
matplotlib==3.10.8
|
||||||
|
maturin==1.12.6
|
||||||
|
mdurl==0.1.2
|
||||||
|
moviepy==2.2.1
|
||||||
|
mpmath==1.3.0
|
||||||
|
multidict==6.7.1
|
||||||
|
networkx==3.6.1
|
||||||
|
nltk==3.9.4
|
||||||
|
numpy==2.4.3
|
||||||
|
nvidia-cublas-cu12==12.8.4.1
|
||||||
|
nvidia-cuda-cupti-cu12==12.8.90
|
||||||
|
nvidia-cuda-nvrtc-cu12==12.8.93
|
||||||
|
nvidia-cuda-runtime-cu12==12.8.90
|
||||||
|
nvidia-cudnn-cu12==9.10.2.21
|
||||||
|
nvidia-cufft-cu12==11.3.3.83
|
||||||
|
nvidia-cufile-cu12==1.13.1.3
|
||||||
|
nvidia-curand-cu12==10.3.9.90
|
||||||
|
nvidia-cusolver-cu12==11.7.3.90
|
||||||
|
nvidia-cusparse-cu12==12.5.8.93
|
||||||
|
nvidia-cusparselt-cu12==0.7.1
|
||||||
|
nvidia-nccl-cu12==2.27.3
|
||||||
|
nvidia-nvjitlink-cu12==12.8.93
|
||||||
|
nvidia-nvshmem-cu12==3.4.5
|
||||||
|
nvidia-nvtx-cu12==12.8.90
|
||||||
|
omegaconf==2.3.0
|
||||||
|
onnxruntime==1.24.4
|
||||||
|
openai==2.30.0
|
||||||
|
opentelemetry-api==1.40.0
|
||||||
|
opentelemetry-exporter-otlp==1.40.0
|
||||||
|
opentelemetry-exporter-otlp-proto-common==1.40.0
|
||||||
|
opentelemetry-exporter-otlp-proto-grpc==1.40.0
|
||||||
|
opentelemetry-exporter-otlp-proto-http==1.40.0
|
||||||
|
opentelemetry-proto==1.40.0
|
||||||
|
opentelemetry-sdk==1.40.0
|
||||||
|
opentelemetry-semantic-conventions==0.61b0
|
||||||
|
optuna==4.8.0
|
||||||
|
packaging==23.2
|
||||||
|
pandas==3.0.1
|
||||||
|
pillow==11.3.0
|
||||||
|
primePy==1.3
|
||||||
|
proglog==0.1.12
|
||||||
|
propcache==0.4.1
|
||||||
|
protobuf==6.33.6
|
||||||
|
pyannote-audio==4.0.4
|
||||||
|
pyannote-core==6.0.1
|
||||||
|
pyannote-database==6.1.1
|
||||||
|
pyannote-metrics==4.0.0
|
||||||
|
pyannote-pipeline==4.0.0
|
||||||
|
pyannoteai-sdk==0.4.0
|
||||||
|
pycparser==3.0
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic_core==2.41.5
|
||||||
|
Pygments==2.19.2
|
||||||
|
pyparsing==3.3.2
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.22
|
||||||
|
pytorch-lightning==2.6.1
|
||||||
|
pytorch-metric-learning==2.9.0
|
||||||
|
PyYAML==6.0.3
|
||||||
|
regex==2026.2.28
|
||||||
|
requests==2.33.0
|
||||||
|
rich==14.3.3
|
||||||
|
ruamel.yaml==0.18.17
|
||||||
|
ruamel.yaml.clib==0.2.15
|
||||||
|
safetensors==0.7.0
|
||||||
|
scikit-learn==1.8.0
|
||||||
|
scipy==1.17.1
|
||||||
|
setuptools==70.2.0
|
||||||
|
shellingham==1.5.4
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
sortedcontainers==2.4.0
|
||||||
|
soundfile==0.13.1
|
||||||
|
SQLAlchemy==2.0.48
|
||||||
|
starlette==1.0.0
|
||||||
|
sympy==1.14.0
|
||||||
|
threadpoolctl==3.6.0
|
||||||
|
tokenizers==0.22.2
|
||||||
|
torch==2.8.0
|
||||||
|
torch-audiomentations==0.12.0
|
||||||
|
torch_pitch_shift==1.2.5
|
||||||
|
torchaudio==2.8.0
|
||||||
|
torchmetrics==1.9.0
|
||||||
|
tqdm==4.67.3
|
||||||
|
transformers==4.57.6
|
||||||
|
triton==3.4.0
|
||||||
|
typer==0.24.1
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.6.3
|
||||||
|
uvicorn==0.42.0
|
||||||
|
uvloop==0.22.1
|
||||||
|
watchfiles==1.1.1
|
||||||
|
websockets==16.0
|
||||||
|
wheel==0.46.3
|
||||||
|
whisperx==3.8.4
|
||||||
|
yarl==1.23.0
|
||||||
|
zipp==3.23.0
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
"""Audio processing endpoint (noise reduction / Studio Sound)."""
|
"""Audio processing endpoint (noise reduction / Studio Sound)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services.audio_cleaner import clean_audio, is_deepfilter_available
|
from services.audio_cleaner import clean_audio, is_deepfilter_available
|
||||||
@ -11,6 +16,9 @@ from services.audio_cleaner import clean_audio, is_deepfilter_available
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Simple in-process cache: video path → extracted WAV path
|
||||||
|
_waveform_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
class AudioCleanRequest(BaseModel):
|
class AudioCleanRequest(BaseModel):
|
||||||
input_path: str
|
input_path: str
|
||||||
@ -36,3 +44,58 @@ async def audio_capabilities():
|
|||||||
return {
|
return {
|
||||||
"deepfilternet_available": is_deepfilter_available(),
|
"deepfilternet_available": is_deepfilter_available(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audio/waveform")
|
||||||
|
async def get_waveform_audio(path: str = Query(...)):
|
||||||
|
"""
|
||||||
|
Extract audio from any video/audio file and return it as a WAV.
|
||||||
|
The WAV is cached on disk for subsequent requests.
|
||||||
|
Uses FFmpeg directly so it works with MKV, MOV, AVI, MP4, etc.
|
||||||
|
"""
|
||||||
|
file_path = Path(path)
|
||||||
|
if not file_path.is_file():
|
||||||
|
logger.warning(f"[waveform] File not found: {path}")
|
||||||
|
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||||
|
|
||||||
|
# Cache key based on path + mtime so stale cache is auto-invalidated
|
||||||
|
mtime = file_path.stat().st_mtime
|
||||||
|
cache_key = hashlib.md5(f"{path}:{mtime}".encode()).hexdigest()
|
||||||
|
|
||||||
|
if cache_key in _waveform_cache:
|
||||||
|
cached = Path(_waveform_cache[cache_key])
|
||||||
|
if cached.exists():
|
||||||
|
logger.info(f"[waveform] Cache hit for {file_path.name}")
|
||||||
|
return FileResponse(str(cached), media_type="audio/wav")
|
||||||
|
else:
|
||||||
|
del _waveform_cache[cache_key]
|
||||||
|
|
||||||
|
logger.info(f"[waveform] Extracting audio from: {file_path.name}")
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="talkedit_waveform_")
|
||||||
|
out_wav = Path(tmp_dir) / f"{cache_key}.wav"
|
||||||
|
|
||||||
|
# Downsample to mono 22050 Hz — enough for waveform drawing, small file
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", str(file_path),
|
||||||
|
"-vn", # drop video
|
||||||
|
"-ac", "1", # mono
|
||||||
|
"-ar", "22050", # 22 kHz sample rate
|
||||||
|
"-acodec", "pcm_s16le", # 16-bit PCM WAV
|
||||||
|
str(out_wav),
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"[waveform] FFmpeg failed for {file_path.name}: {result.stderr[-500:]}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to extract audio: {result.stderr[-300:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not out_wav.exists() or out_wav.stat().st_size == 0:
|
||||||
|
logger.error(f"[waveform] FFmpeg produced empty WAV for {file_path.name}")
|
||||||
|
raise HTTPException(status_code=500, detail="Audio extraction produced empty file")
|
||||||
|
|
||||||
|
logger.info(f"[waveform] Extracted {out_wav.stat().st_size} bytes for {file_path.name}")
|
||||||
|
_waveform_cache[cache_key] = str(out_wav)
|
||||||
|
return FileResponse(str(out_wav), media_type="audio/wav")
|
||||||
|
|||||||
@ -15,8 +15,13 @@ _temp_audio_files = []
|
|||||||
|
|
||||||
def extract_audio(video_path: Path):
|
def extract_audio(video_path: Path):
|
||||||
"""Extract audio from a video file into a temp directory for automatic cleanup."""
|
"""Extract audio from a video file into a temp directory for automatic cleanup."""
|
||||||
|
logger.info(f"[extract_audio] Extracting audio from: {video_path}")
|
||||||
try:
|
try:
|
||||||
audio = AudioFileClip(str(video_path))
|
audio = AudioFileClip(str(video_path))
|
||||||
|
if audio.duration is None or audio.duration == 0:
|
||||||
|
logger.error(f"[extract_audio] File has no audio track or zero duration: {video_path}")
|
||||||
|
raise RuntimeError(f"File has no audio track: {video_path}")
|
||||||
|
logger.info(f"[extract_audio] Duration: {audio.duration:.2f}s, fps: {audio.fps}")
|
||||||
temp_dir = tempfile.mkdtemp(prefix="videotranscriber_")
|
temp_dir = tempfile.mkdtemp(prefix="videotranscriber_")
|
||||||
audio_path = Path(temp_dir) / f"{video_path.stem}_audio.wav"
|
audio_path = Path(temp_dir) / f"{video_path.stem}_audio.wav"
|
||||||
try:
|
try:
|
||||||
@ -25,9 +30,16 @@ def extract_audio(video_path: Path):
|
|||||||
# moviepy 1.x uses verbose parameter; moviepy 2.x removed it
|
# moviepy 1.x uses verbose parameter; moviepy 2.x removed it
|
||||||
audio.write_audiofile(str(audio_path), verbose=False, logger=None)
|
audio.write_audiofile(str(audio_path), verbose=False, logger=None)
|
||||||
audio.close()
|
audio.close()
|
||||||
|
if not audio_path.exists() or audio_path.stat().st_size == 0:
|
||||||
|
logger.error(f"[extract_audio] Output WAV is empty or missing: {audio_path}")
|
||||||
|
raise RuntimeError(f"Audio extraction produced empty file: {audio_path}")
|
||||||
|
logger.info(f"[extract_audio] Extracted to: {audio_path} ({audio_path.stat().st_size} bytes)")
|
||||||
_temp_audio_files.append(str(audio_path))
|
_temp_audio_files.append(str(audio_path))
|
||||||
return audio_path
|
return audio_path
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"[extract_audio] Failed for '{video_path}': {e}", exc_info=True)
|
||||||
raise RuntimeError(f"Audio extraction failed: {e}")
|
raise RuntimeError(f"Audio extraction failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
@ -54,6 +66,9 @@ def get_video_duration(video_path: Path):
|
|||||||
clip = AudioFileClip(str(video_path))
|
clip = AudioFileClip(str(video_path))
|
||||||
duration = clip.duration
|
duration = clip.duration
|
||||||
clip.close()
|
clip.close()
|
||||||
|
if duration is None or duration == 0:
|
||||||
|
logger.warning(f"[get_video_duration] Zero or null duration for: {video_path}")
|
||||||
return duration
|
return duration
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(f"[get_video_duration] Failed for '{video_path}': {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export default function WaveformTimeline() {
|
|||||||
|
|
||||||
const videoUrl = useEditorStore((s) => s.videoUrl);
|
const videoUrl = useEditorStore((s) => s.videoUrl);
|
||||||
const videoPath = useEditorStore((s) => s.videoPath);
|
const videoPath = useEditorStore((s) => s.videoPath);
|
||||||
|
const backendUrl = useEditorStore((s) => s.backendUrl);
|
||||||
const duration = useEditorStore((s) => s.duration);
|
const duration = useEditorStore((s) => s.duration);
|
||||||
const deletedRanges = useEditorStore((s) => s.deletedRanges);
|
const deletedRanges = useEditorStore((s) => s.deletedRanges);
|
||||||
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
||||||
@ -25,18 +26,62 @@ export default function WaveformTimeline() {
|
|||||||
|
|
||||||
const loadAudio = async () => {
|
const loadAudio = async () => {
|
||||||
try {
|
try {
|
||||||
|
const waveformUrl = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath!)}`;
|
||||||
|
console.log('[WaveformTimeline] Loading audio from waveform endpoint:', waveformUrl);
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
audioContextRef.current = ctx;
|
audioContextRef.current = ctx;
|
||||||
|
|
||||||
const response = await fetch(videoUrl);
|
const response = await fetch(waveformUrl);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '');
|
||||||
|
console.error(
|
||||||
|
`[WaveformTimeline] Fetch failed — HTTP ${response.status} ${response.statusText}`,
|
||||||
|
{ url: waveformUrl, body }
|
||||||
|
);
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? 'unknown';
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
console.log(
|
||||||
|
`[WaveformTimeline] Fetch OK — content-type: ${contentType}, size: ${contentLength ?? 'unknown'} bytes`
|
||||||
|
);
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
console.log(`[WaveformTimeline] ArrayBuffer size: ${arrayBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
|
if (arrayBuffer.byteLength === 0) {
|
||||||
|
throw new Error('Server returned an empty file');
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioBuffer: AudioBuffer;
|
||||||
|
try {
|
||||||
|
audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
||||||
|
} catch (decodeErr) {
|
||||||
|
console.error(
|
||||||
|
'[WaveformTimeline] decodeAudioData failed — browser cannot decode this format.',
|
||||||
|
{
|
||||||
|
contentType,
|
||||||
|
byteLength: arrayBuffer.byteLength,
|
||||||
|
videoPath,
|
||||||
|
error: decodeErr,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Browser could not decode audio (${contentType}). ` +
|
||||||
|
`For best compatibility use MP4/AAC or WebM/Opus. Raw error: ${decodeErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[WaveformTimeline] Decoded OK — duration: ${audioBuffer.duration.toFixed(2)}s, ` +
|
||||||
|
`channels: ${audioBuffer.numberOfChannels}, sampleRate: ${audioBuffer.sampleRate}Hz`
|
||||||
|
);
|
||||||
audioBufferRef.current = audioBuffer;
|
audioBufferRef.current = audioBuffer;
|
||||||
drawStaticWaveform();
|
drawStaticWaveform();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Could not decode audio for waveform:', err);
|
console.error('[WaveformTimeline] Waveform load failed:', err);
|
||||||
setAudioError('Waveform unavailable — audio could not be decoded');
|
setAudioError(`Waveform unavailable — ${err instanceof Error ? err.message : 'audio could not be decoded'}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -45,7 +90,7 @@ export default function WaveformTimeline() {
|
|||||||
return () => {
|
return () => {
|
||||||
audioContextRef.current?.close();
|
audioContextRef.current?.close();
|
||||||
};
|
};
|
||||||
}, [videoUrl, videoPath]);
|
}, [videoUrl, videoPath, backendUrl]);
|
||||||
|
|
||||||
const drawStaticWaveform = useCallback(() => {
|
const drawStaticWaveform = useCallback(() => {
|
||||||
const canvas = waveCanvasRef.current;
|
const canvas = waveCanvasRef.current;
|
||||||
|
|||||||
46
open
46
open
@ -1,4 +1,50 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Open TalkEdit app (Tauri dev mode)
|
# Open TalkEdit app (Tauri dev mode)
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
PROJECT_DIR="$PWD"
|
||||||
|
|
||||||
|
BACKEND_PORT=8000
|
||||||
|
BACKEND_URL="http://127.0.0.1:${BACKEND_PORT}/health"
|
||||||
|
|
||||||
|
# Check if backend is already running
|
||||||
|
if curl -sf "$BACKEND_URL" > /dev/null 2>&1; then
|
||||||
|
echo "Backend already running on port ${BACKEND_PORT}."
|
||||||
|
else
|
||||||
|
echo "Backend not running — starting it in a new terminal..."
|
||||||
|
VENV_PYTHON="${PROJECT_DIR}/.venv312/bin/python"
|
||||||
|
BACKEND_DIR="${PROJECT_DIR}/backend"
|
||||||
|
|
||||||
|
# Try common terminal emulators in order
|
||||||
|
if command -v ghostty &>/dev/null; then
|
||||||
|
ghostty -e bash -c "cd '${BACKEND_DIR}' && '${VENV_PYTHON}' -m uvicorn main:app --host 127.0.0.1 --port ${BACKEND_PORT}; exec bash" &
|
||||||
|
elif command -v kitty &>/dev/null; then
|
||||||
|
kitty --title "TalkEdit Backend" -- bash -c "cd '${BACKEND_DIR}' && '${VENV_PYTHON}' -m uvicorn main:app --host 127.0.0.1 --port ${BACKEND_PORT}; exec bash" &
|
||||||
|
elif command -v alacritty &>/dev/null; then
|
||||||
|
alacritty --title "TalkEdit Backend" -e bash -c "cd '${BACKEND_DIR}' && '${VENV_PYTHON}' -m uvicorn main:app --host 127.0.0.1 --port ${BACKEND_PORT}; exec bash" &
|
||||||
|
elif command -v konsole &>/dev/null; then
|
||||||
|
konsole --new-tab -e bash -c "cd '${BACKEND_DIR}' && '${VENV_PYTHON}' -m uvicorn main:app --host 127.0.0.1 --port ${BACKEND_PORT}; exec bash" &
|
||||||
|
elif command -v gnome-terminal &>/dev/null; then
|
||||||
|
gnome-terminal --title "TalkEdit Backend" -- bash -c "cd '${BACKEND_DIR}' && '${VENV_PYTHON}' -m uvicorn main:app --host 127.0.0.1 --port ${BACKEND_PORT}; exec bash" &
|
||||||
|
elif command -v xterm &>/dev/null; then
|
||||||
|
xterm -T "TalkEdit Backend" -e bash -c "cd '${BACKEND_DIR}' && '${VENV_PYTHON}' -m uvicorn main:app --host 127.0.0.1 --port ${BACKEND_PORT}; exec bash" &
|
||||||
|
else
|
||||||
|
echo "No supported terminal emulator found. Starting backend in background..."
|
||||||
|
cd "${BACKEND_DIR}" && "${VENV_PYTHON}" -m uvicorn main:app --host 127.0.0.1 --port ${BACKEND_PORT} &
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait up to 15s for backend to become ready
|
||||||
|
echo -n "Waiting for backend"
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
sleep 1
|
||||||
|
echo -n "."
|
||||||
|
if curl -sf "$BACKEND_URL" > /dev/null 2>&1; then
|
||||||
|
echo " ready!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ $i -eq 15 ]]; then
|
||||||
|
echo " timed out. Check the backend terminal for errors."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
npx tauri dev
|
npx tauri dev
|
||||||
|
|||||||
@ -23,8 +23,8 @@
|
|||||||
"python-shell": "^5.0.0"
|
"python-shell": "^5.0.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.dataants.cutscript",
|
"appId": "com.talkedit.app",
|
||||||
"productName": "CutScript",
|
"productName": "TalkEdit",
|
||||||
"files": [
|
"files": [
|
||||||
"electron/**/*",
|
"electron/**/*",
|
||||||
"frontend/dist/**/*",
|
"frontend/dist/**/*",
|
||||||
|
|||||||
@ -1,22 +1,60 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Resolve the project root from the executable path.
|
/// Resolve the project root at runtime.
|
||||||
/// In dev mode, the binary lives at: <root>/src-tauri/target/debug/<bin>
|
///
|
||||||
/// So the project root is 4 levels above the binary.
|
/// Dev layout: <root>/src-tauri/target/debug/<bin> → walk up 4 levels
|
||||||
|
/// Packaged: use TAURI_RESOURCE_DIR env var set by the Tauri runtime,
|
||||||
|
/// falling back to a sibling `resources/` directory next to the exe.
|
||||||
pub fn project_root() -> PathBuf {
|
pub fn project_root() -> PathBuf {
|
||||||
|
// Tauri sets this env var when running packaged; prefer it.
|
||||||
|
if let Ok(res) = std::env::var("TAURI_RESOURCE_DIR") {
|
||||||
|
return PathBuf::from(res);
|
||||||
|
}
|
||||||
let exe = std::env::current_exe().expect("Failed to get executable path");
|
let exe = std::env::current_exe().expect("Failed to get executable path");
|
||||||
// exe -> debug/ -> target/ -> src-tauri/ -> root
|
// Dev: exe is at <root>/src-tauri/target/debug/<bin>, walk up 4 levels.
|
||||||
|
if let Some(root) = exe
|
||||||
|
.parent() // debug/
|
||||||
|
.and_then(|p| p.parent()) // target/
|
||||||
|
.and_then(|p| p.parent()) // src-tauri/
|
||||||
|
.and_then(|p| p.parent()) // project root
|
||||||
|
{
|
||||||
|
if root.join("backend").exists() {
|
||||||
|
return root.to_path_buf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Packaged fallback: resources/ lives next to the exe.
|
||||||
exe.parent()
|
exe.parent()
|
||||||
.and_then(|p| p.parent())
|
.map(|p| p.join("resources"))
|
||||||
.and_then(|p| p.parent())
|
.unwrap_or_else(|| PathBuf::from("resources"))
|
||||||
.and_then(|p| p.parent())
|
|
||||||
.map(|p| p.to_path_buf())
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute path to the venv Python 3.10 interpreter.
|
/// Absolute path to the bundled Python interpreter.
|
||||||
|
/// Tries .venv312 first (new), falls back to .venv (legacy).
|
||||||
pub fn python_exe() -> PathBuf {
|
pub fn python_exe() -> PathBuf {
|
||||||
project_root().join(".venv/bin/python3.10")
|
let root = project_root();
|
||||||
|
// Packaged layout: resources/python/bin/python3
|
||||||
|
let bundled = root.join("python").join("bin").join("python3");
|
||||||
|
if bundled.exists() {
|
||||||
|
return bundled;
|
||||||
|
}
|
||||||
|
// Dev: prefer .venv312 (Python 3.12), fall back to .venv
|
||||||
|
let venv312 = root.join(".venv312").join("bin").join("python3.12");
|
||||||
|
if venv312.exists() {
|
||||||
|
return venv312;
|
||||||
|
}
|
||||||
|
root.join(".venv").join("bin").join("python3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path to the bundled ffmpeg binary.
|
||||||
|
/// Uses a sidecar in resources/bin/ when packaged, otherwise expects it on PATH.
|
||||||
|
pub fn ffmpeg_exe() -> PathBuf {
|
||||||
|
let root = project_root();
|
||||||
|
let bundled = root.join("bin").join("ffmpeg");
|
||||||
|
if bundled.exists() {
|
||||||
|
return bundled;
|
||||||
|
}
|
||||||
|
// Fallback to system ffmpeg during development
|
||||||
|
PathBuf::from("ffmpeg")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute path to a script in the backend directory.
|
/// Absolute path to a script in the backend directory.
|
||||||
|
|||||||
Reference in New Issue
Block a user