Initial CutScript release - Open-source AI-powered text-based video editor
CutScript is a local-first, Descript-like video editor where you edit video by editing text. Delete a word from the transcript and it's cut from the video. Features: - Word-level transcription with WhisperX - Text-based video editing with undo/redo - AI filler word removal (Ollama/OpenAI/Claude) - AI clip creation for shorts - Waveform timeline with virtualized transcript - FFmpeg stream-copy (fast) and re-encode (4K) export - Caption burn-in and sidecar SRT generation - Studio Sound audio enhancement (DeepFilterNet) - Keyboard shortcuts (J/K/L, Space, Delete, Ctrl+Z/S/E) - Encrypted API key storage - Project save/load (.aive files) Architecture: - Electron + React + Tailwind (frontend) - FastAPI + Python (backend) - WhisperX for transcription - FFmpeg for video processing - Multi-provider AI support Performance optimizations: - RAF-throttled time updates - Zustand selectors for granular subscriptions - Dual-canvas waveform rendering - Virtualized transcript with react-virtuoso Built on top of DataAnts-AI/VideoTranscriber, completely rewritten as a desktop application. License: MIT
This commit is contained in:
0
backend/utils/__init__.py
Normal file
0
backend/utils/__init__.py
Normal file
59
backend/utils/audio_processing.py
Normal file
59
backend/utils/audio_processing.py
Normal file
@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
import logging
|
||||
|
||||
try:
|
||||
from moviepy import AudioFileClip
|
||||
except ImportError:
|
||||
from moviepy.editor import AudioFileClip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_temp_audio_files = []
|
||||
|
||||
|
||||
def extract_audio(video_path: Path):
|
||||
"""Extract audio from a video file into a temp directory for automatic cleanup."""
|
||||
try:
|
||||
audio = AudioFileClip(str(video_path))
|
||||
temp_dir = tempfile.mkdtemp(prefix="videotranscriber_")
|
||||
audio_path = Path(temp_dir) / f"{video_path.stem}_audio.wav"
|
||||
try:
|
||||
audio.write_audiofile(str(audio_path), logger=None)
|
||||
except TypeError:
|
||||
# moviepy 1.x uses verbose parameter; moviepy 2.x removed it
|
||||
audio.write_audiofile(str(audio_path), verbose=False, logger=None)
|
||||
audio.close()
|
||||
_temp_audio_files.append(str(audio_path))
|
||||
return audio_path
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Audio extraction failed: {e}")
|
||||
|
||||
|
||||
def cleanup_temp_audio():
|
||||
"""Remove all temporary audio files created during processing."""
|
||||
cleaned = 0
|
||||
for fpath in _temp_audio_files:
|
||||
try:
|
||||
if os.path.exists(fpath):
|
||||
os.remove(fpath)
|
||||
parent = os.path.dirname(fpath)
|
||||
if os.path.isdir(parent) and not os.listdir(parent):
|
||||
os.rmdir(parent)
|
||||
cleaned += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove temp file {fpath}: {e}")
|
||||
_temp_audio_files.clear()
|
||||
return cleaned
|
||||
|
||||
|
||||
def get_video_duration(video_path: Path):
|
||||
"""Get duration of a video/audio file in seconds."""
|
||||
try:
|
||||
clip = AudioFileClip(str(video_path))
|
||||
duration = clip.duration
|
||||
clip.close()
|
||||
return duration
|
||||
except Exception:
|
||||
return None
|
||||
205
backend/utils/cache.py
Normal file
205
backend/utils/cache.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""
|
||||
Caching utilities for the OBS Recording Transcriber.
|
||||
Provides functions to cache and retrieve transcription and summarization results.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import time
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default cache directory
|
||||
CACHE_DIR = Path.home() / ".obs_transcriber_cache"
|
||||
|
||||
|
||||
def get_file_hash(file_path):
|
||||
"""
|
||||
Generate a hash for a file based on its content and modification time.
|
||||
|
||||
Args:
|
||||
file_path (Path): Path to the file
|
||||
|
||||
Returns:
|
||||
str: Hash string representing the file
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
# Get file stats
|
||||
stats = file_path.stat()
|
||||
file_size = stats.st_size
|
||||
mod_time = stats.st_mtime
|
||||
|
||||
# Create a hash based on path, size and modification time
|
||||
# This is faster than hashing the entire file content
|
||||
hash_input = f"{file_path.absolute()}|{file_size}|{mod_time}"
|
||||
return hashlib.md5(hash_input.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_cache_path(file_path, model=None, operation=None):
|
||||
"""
|
||||
Get the cache file path for a given input file and operation.
|
||||
|
||||
Args:
|
||||
file_path (Path): Path to the original file
|
||||
model (str, optional): Model used for processing
|
||||
operation (str, optional): Operation type (e.g., 'transcribe', 'summarize')
|
||||
|
||||
Returns:
|
||||
Path: Path to the cache file
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
file_hash = get_file_hash(file_path)
|
||||
|
||||
if not file_hash:
|
||||
return None
|
||||
|
||||
# Create cache directory if it doesn't exist
|
||||
cache_dir = CACHE_DIR
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a cache filename based on the hash and optional parameters
|
||||
cache_name = file_hash
|
||||
if model:
|
||||
cache_name += f"_{model}"
|
||||
if operation:
|
||||
cache_name += f"_{operation}"
|
||||
|
||||
return cache_dir / f"{cache_name}.json"
|
||||
|
||||
|
||||
def save_to_cache(file_path, data, model=None, operation=None):
|
||||
"""
|
||||
Save data to cache.
|
||||
|
||||
Args:
|
||||
file_path (Path): Path to the original file
|
||||
data (dict): Data to cache
|
||||
model (str, optional): Model used for processing
|
||||
operation (str, optional): Operation type
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
cache_path = get_cache_path(file_path, model, operation)
|
||||
if not cache_path:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add metadata to the cached data
|
||||
cache_data = {
|
||||
"original_file": str(Path(file_path).absolute()),
|
||||
"timestamp": time.time(),
|
||||
"model": model,
|
||||
"operation": operation,
|
||||
"data": data
|
||||
}
|
||||
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"Cached data saved to {cache_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving cache: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def load_from_cache(file_path, model=None, operation=None, max_age=None):
|
||||
"""
|
||||
Load data from cache if available and not expired.
|
||||
|
||||
Args:
|
||||
file_path (Path): Path to the original file
|
||||
model (str, optional): Model used for processing
|
||||
operation (str, optional): Operation type
|
||||
max_age (float, optional): Maximum age of cache in seconds
|
||||
|
||||
Returns:
|
||||
dict or None: Cached data or None if not available
|
||||
"""
|
||||
cache_path = get_cache_path(file_path, model, operation)
|
||||
if not cache_path or not cache_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Check if cache is expired
|
||||
if max_age is not None:
|
||||
cache_time = cache_data.get("timestamp", 0)
|
||||
if time.time() - cache_time > max_age:
|
||||
logger.info(f"Cache expired for {file_path}")
|
||||
return None
|
||||
|
||||
logger.info(f"Loaded data from cache: {cache_path}")
|
||||
return cache_data.get("data")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def clear_cache(max_age=None):
|
||||
"""
|
||||
Clear all cache files or only expired ones.
|
||||
|
||||
Args:
|
||||
max_age (float, optional): Maximum age of cache in seconds
|
||||
|
||||
Returns:
|
||||
int: Number of files deleted
|
||||
"""
|
||||
if not CACHE_DIR.exists():
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for cache_file in CACHE_DIR.glob("*.json"):
|
||||
try:
|
||||
if max_age is not None:
|
||||
# Check if file is expired
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
cache_time = cache_data.get("timestamp", 0)
|
||||
if time.time() - cache_time <= max_age:
|
||||
continue # Skip non-expired files
|
||||
|
||||
# Delete the file
|
||||
os.remove(cache_file)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting cache file {cache_file}: {e}")
|
||||
|
||||
logger.info(f"Cleared {count} cache files")
|
||||
return count
|
||||
|
||||
|
||||
def get_cache_size():
|
||||
"""
|
||||
Get the total size of the cache directory.
|
||||
|
||||
Returns:
|
||||
tuple: (size_bytes, file_count)
|
||||
"""
|
||||
if not CACHE_DIR.exists():
|
||||
return 0, 0
|
||||
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
for cache_file in CACHE_DIR.glob("*.json"):
|
||||
try:
|
||||
total_size += cache_file.stat().st_size
|
||||
file_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return total_size, file_count
|
||||
196
backend/utils/gpu_utils.py
Normal file
196
backend/utils/gpu_utils.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""
|
||||
GPU utilities for the Video Transcriber.
|
||||
Provides functions to detect and configure GPU acceleration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import torch
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_gpu_info():
|
||||
"""
|
||||
Get information about available GPUs.
|
||||
|
||||
Returns:
|
||||
dict: Information about available GPUs
|
||||
"""
|
||||
gpu_info = {
|
||||
"cuda_available": torch.cuda.is_available(),
|
||||
"cuda_device_count": torch.cuda.device_count() if torch.cuda.is_available() else 0,
|
||||
"cuda_devices": [],
|
||||
"mps_available": hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
||||
}
|
||||
|
||||
# Get CUDA device information
|
||||
if gpu_info["cuda_available"]:
|
||||
for i in range(gpu_info["cuda_device_count"]):
|
||||
device_props = torch.cuda.get_device_properties(i)
|
||||
gpu_info["cuda_devices"].append({
|
||||
"index": i,
|
||||
"name": device_props.name,
|
||||
"total_memory": device_props.total_memory,
|
||||
"compute_capability": f"{device_props.major}.{device_props.minor}"
|
||||
})
|
||||
|
||||
return gpu_info
|
||||
|
||||
|
||||
def get_optimal_device():
|
||||
"""
|
||||
Get the optimal device for computation.
|
||||
|
||||
Returns:
|
||||
torch.device: The optimal device (cuda, mps, or cpu)
|
||||
"""
|
||||
if torch.cuda.is_available():
|
||||
# If multiple GPUs are available, select the one with the most memory
|
||||
if torch.cuda.device_count() > 1:
|
||||
max_memory = 0
|
||||
best_device = 0
|
||||
for i in range(torch.cuda.device_count()):
|
||||
device_props = torch.cuda.get_device_properties(i)
|
||||
if device_props.total_memory > max_memory:
|
||||
max_memory = device_props.total_memory
|
||||
best_device = i
|
||||
return torch.device(f"cuda:{best_device}")
|
||||
return torch.device("cuda:0")
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return torch.device("mps")
|
||||
else:
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def set_memory_limits(memory_fraction=0.8):
|
||||
"""
|
||||
Set memory limits for GPU usage.
|
||||
|
||||
Args:
|
||||
memory_fraction (float): Fraction of GPU memory to use (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
if not torch.cuda.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Set memory fraction for each device
|
||||
for i in range(torch.cuda.device_count()):
|
||||
torch.cuda.set_per_process_memory_fraction(memory_fraction, i)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting memory limits: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def optimize_for_inference():
|
||||
"""
|
||||
Apply optimizations for inference.
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Set deterministic algorithms for reproducibility
|
||||
torch.backends.cudnn.deterministic = True
|
||||
|
||||
# Enable cuDNN benchmark mode for optimized performance
|
||||
torch.backends.cudnn.benchmark = True
|
||||
|
||||
# Disable gradient calculation for inference
|
||||
torch.set_grad_enabled(False)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error optimizing for inference: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_recommended_batch_size(model_size="base"):
|
||||
"""
|
||||
Get recommended batch size based on available GPU memory.
|
||||
|
||||
Args:
|
||||
model_size (str): Size of the model (tiny, base, small, medium, large)
|
||||
|
||||
Returns:
|
||||
int: Recommended batch size
|
||||
"""
|
||||
# Default batch sizes for CPU
|
||||
default_batch_sizes = {
|
||||
"tiny": 16,
|
||||
"base": 8,
|
||||
"small": 4,
|
||||
"medium": 2,
|
||||
"large": 1
|
||||
}
|
||||
|
||||
# If CUDA is not available, return default CPU batch size
|
||||
if not torch.cuda.is_available():
|
||||
return default_batch_sizes.get(model_size, 1)
|
||||
|
||||
# Approximate memory requirements in GB for different model sizes
|
||||
memory_requirements = {
|
||||
"tiny": 1,
|
||||
"base": 2,
|
||||
"small": 4,
|
||||
"medium": 8,
|
||||
"large": 16
|
||||
}
|
||||
|
||||
# Get available GPU memory
|
||||
device = get_optimal_device()
|
||||
if device.type == "cuda":
|
||||
device_idx = device.index
|
||||
device_props = torch.cuda.get_device_properties(device_idx)
|
||||
available_memory_gb = device_props.total_memory / (1024 ** 3)
|
||||
|
||||
# Calculate batch size based on available memory
|
||||
model_memory = memory_requirements.get(model_size, 2)
|
||||
max_batch_size = int(available_memory_gb / model_memory)
|
||||
|
||||
# Ensure batch size is at least 1
|
||||
return max(1, max_batch_size)
|
||||
|
||||
# For MPS or other devices, return default
|
||||
return default_batch_sizes.get(model_size, 1)
|
||||
|
||||
|
||||
def configure_gpu(model_size="base", memory_fraction=0.8):
|
||||
"""
|
||||
Configure GPU settings for optimal performance.
|
||||
|
||||
Args:
|
||||
model_size (str): Size of the model (tiny, base, small, medium, large)
|
||||
memory_fraction (float): Fraction of GPU memory to use (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
dict: Configuration information
|
||||
"""
|
||||
gpu_info = get_gpu_info()
|
||||
device = get_optimal_device()
|
||||
|
||||
# Set memory limits if using CUDA
|
||||
if device.type == "cuda":
|
||||
set_memory_limits(memory_fraction)
|
||||
|
||||
# Apply inference optimizations
|
||||
optimize_for_inference()
|
||||
|
||||
# Get recommended batch size
|
||||
batch_size = get_recommended_batch_size(model_size)
|
||||
|
||||
config = {
|
||||
"device": device,
|
||||
"batch_size": batch_size,
|
||||
"gpu_info": gpu_info,
|
||||
"memory_fraction": memory_fraction if device.type == "cuda" else None
|
||||
}
|
||||
|
||||
logger.info(f"GPU configuration: Using {device} with batch size {batch_size}")
|
||||
return config
|
||||
Reference in New Issue
Block a user