feat: implement file export history, native folder reveal, and robust FFmpeg/torchcodec environment management.
This commit is contained in:
+169
-25
@@ -14,10 +14,31 @@ from contextlib import asynccontextmanager
|
||||
from typing import Optional, List
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import numpy as np
|
||||
import os
|
||||
import sys
|
||||
import site
|
||||
import shutil
|
||||
|
||||
# [CRITICAL PRODUCTION PATCH]
|
||||
# Systematically purge 'torchcodec' from the environment before PyTorch loads.
|
||||
# This violently neutralizes PyTorch's broken dynamic linkage sequence against
|
||||
# fragile Homebrew FFmpeg (.dylibs) on macOS, mandating a safe fallback to `soundfile`.
|
||||
for sp in [sys.prefix] + site.getsitepackages():
|
||||
if "site-packages" not in sp: sp = os.path.join(sp, "lib", f"python3.{sys.version_info.minor}", "site-packages")
|
||||
tc_path = os.path.join(sp, "torchcodec")
|
||||
if os.path.exists(tc_path):
|
||||
try: shutil.rmtree(tc_path); print("Sanitized broken torchcodec module.")
|
||||
except: pass
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torchaudio
|
||||
|
||||
# Enforce fully static soundfile backend
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
torchaudio.set_audio_backend("soundfile")
|
||||
|
||||
from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -192,6 +213,13 @@ def _init_db():
|
||||
created_at REAL,
|
||||
updated_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS export_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT,
|
||||
destination_path TEXT,
|
||||
mode TEXT,
|
||||
created_at REAL
|
||||
);
|
||||
""")
|
||||
# Safe migrations for existing databases
|
||||
for col, typedef in [
|
||||
@@ -281,6 +309,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="OmniVoice Studio API", version="0.4.0", lifespan=lifespan)
|
||||
|
||||
from fastapi import Request
|
||||
import traceback
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
with open("crash_log.txt", "w") as f:
|
||||
f.write(f"Request: {request.url}\n")
|
||||
f.write(traceback.format_exc())
|
||||
return JSONResponse({"detail": str(exc)}, status_code=500)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], allow_credentials=True,
|
||||
@@ -552,9 +589,102 @@ async def clean_audio(audio: UploadFile = File(...)):
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# GENERATION HISTORY (SQLite + disk)
|
||||
# GENERATION HISTORY & EXPORTS (SQLite + disk)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
source_filename: str
|
||||
destination_path: str
|
||||
mode: str = "history"
|
||||
|
||||
@app.post("/export")
|
||||
def export_file(req: ExportRequest):
|
||||
src_paths = [
|
||||
os.path.join(OUTPUTS_DIR, req.source_filename),
|
||||
os.path.join("dub/outputs", req.source_filename)
|
||||
]
|
||||
|
||||
found = False
|
||||
for sp in src_paths:
|
||||
if os.path.exists(sp):
|
||||
try:
|
||||
shutil.copy2(sp, req.destination_path)
|
||||
found = True
|
||||
break
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="Source file not found")
|
||||
|
||||
export_id = str(uuid.uuid4())[:8]
|
||||
conn = _get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(export_id, req.source_filename, req.destination_path, req.mode, time.time())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
class ExportRecordRequest(BaseModel):
|
||||
filename: str
|
||||
destination_path: str = "~/Downloads"
|
||||
mode: str = "file"
|
||||
|
||||
@app.post("/export/record")
|
||||
def record_export(req: ExportRecordRequest):
|
||||
"""Record a blob-based download in export history (no file copy)."""
|
||||
export_id = str(uuid.uuid4())[:8]
|
||||
conn = _get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(export_id, req.filename, req.destination_path, req.mode, time.time())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
@app.get("/export/history")
|
||||
def get_export_history():
|
||||
conn = _get_db()
|
||||
rows = conn.execute("SELECT * FROM export_history ORDER BY created_at DESC LIMIT 50").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
class RevealRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
@app.post("/export/reveal")
|
||||
def reveal_in_folder(req: RevealRequest):
|
||||
"""Open the containing folder of a file in the native OS file manager."""
|
||||
import platform
|
||||
target = os.path.expanduser(req.path)
|
||||
|
||||
# If the path is a file, reveal it selected; if dir, just open it
|
||||
folder = target if os.path.isdir(target) else os.path.dirname(target)
|
||||
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Darwin":
|
||||
# macOS: open Finder with file selected
|
||||
if os.path.isfile(target):
|
||||
subprocess.Popen(["open", "-R", target])
|
||||
else:
|
||||
subprocess.Popen(["open", folder])
|
||||
elif system == "Windows":
|
||||
# Windows: Explorer with file selected
|
||||
if os.path.isfile(target):
|
||||
subprocess.Popen(["explorer", "/select,", target.replace("/", "\\")])
|
||||
else:
|
||||
subprocess.Popen(["explorer", folder.replace("/", "\\")])
|
||||
else:
|
||||
# Linux: xdg-open the containing folder
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/history")
|
||||
def list_history():
|
||||
conn = _get_db()
|
||||
@@ -798,18 +928,18 @@ def _get_diarization_pipeline():
|
||||
return None
|
||||
|
||||
def _find_ffmpeg():
|
||||
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
|
||||
if shutil.which(path):
|
||||
return path
|
||||
raise RuntimeError("ffmpeg not found")
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
# This will natively extract and return an architecture-specific static FFmpeg binary!
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except Exception as e:
|
||||
logger.warning(f"imageio_ffmpeg failed to provide static binary: {e}. Falling back to default system path.")
|
||||
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
|
||||
if shutil.which(path):
|
||||
return path
|
||||
raise RuntimeError("ffmpeg not found in bundle or system path")
|
||||
|
||||
|
||||
def _find_ffprobe():
|
||||
for path in ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe", "ffprobe"]:
|
||||
if shutil.which(path):
|
||||
return path
|
||||
raise RuntimeError("ffprobe not found")
|
||||
|
||||
|
||||
# ── Preview file proxy (avoids blob: URLs which fail in Tauri's WebKit) ────────
|
||||
PREVIEW_DIR = os.path.join(DATA_DIR, "preview")
|
||||
@@ -887,17 +1017,8 @@ async def dub_upload(video: UploadFile = File(...)):
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"ffmpeg failed: {str(e)}")
|
||||
|
||||
ffprobe = _find_ffprobe()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ffprobe, "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "json", video_path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise Exception("ffprobe failed")
|
||||
dur = float(json.loads(stdout.decode())["format"]["duration"])
|
||||
dur = float(sf.info(audio_path).frames) / float(sf.info(audio_path).samplerate)
|
||||
except Exception:
|
||||
dur = 0.0
|
||||
|
||||
@@ -984,15 +1105,19 @@ async def dub_transcribe(job_id: str):
|
||||
|
||||
def _transcribe():
|
||||
import re
|
||||
# Load pure vocal audio as numpy array for vastly improved Whisper accuracy
|
||||
asr_audio_target = job.get("vocals_path", job.get("audio_path"))
|
||||
import traceback
|
||||
|
||||
# Safe fallback check if user omitted Demucs or the pipeline completely crashed during source vocal separation!
|
||||
asr_audio_target = job.get("vocals_path")
|
||||
if not asr_audio_target or not os.path.exists(asr_audio_target):
|
||||
asr_audio_target = job.get("audio_path")
|
||||
|
||||
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
|
||||
if audio_np.ndim > 1:
|
||||
audio_np = audio_np.mean(axis=1)
|
||||
audio_input = {"array": audio_np, "sampling_rate": sr}
|
||||
|
||||
bs = 16 if torch.cuda.is_available() else (2 if torch.backends.mps.is_available() else 1)
|
||||
|
||||
# Use chunk-level timestamps
|
||||
result = _model._asr_pipe(
|
||||
audio_input, return_timestamps=True,
|
||||
@@ -1251,6 +1376,25 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# Calculate lip-sync score natively from tensor
|
||||
generated_dur = audio_tensor.shape[-1] / _model.sampling_rate
|
||||
sync_ratio = round(generated_dur / max(seg_duration, 0.01), 3)
|
||||
|
||||
# Auto time-stretch to fit segment window if off by >5%
|
||||
if sync_ratio > 1.05 or sync_ratio < 0.95:
|
||||
target_samples = int(seg_duration * _model.sampling_rate)
|
||||
current_samples = audio_tensor.shape[-1]
|
||||
if target_samples > 0 and current_samples > 0:
|
||||
# Resample to effectively time-stretch: change "perceived" sample rate
|
||||
# then resample back to actual rate
|
||||
stretch_ratio = current_samples / target_samples
|
||||
# Use interpolation for clean time-stretching
|
||||
audio_tensor = torch.nn.functional.interpolate(
|
||||
audio_tensor.unsqueeze(0), # add batch dim
|
||||
size=target_samples,
|
||||
mode='linear',
|
||||
align_corners=False,
|
||||
).squeeze(0) # remove batch dim
|
||||
generated_dur = audio_tensor.shape[-1] / _model.sampling_rate
|
||||
sync_ratio = round(generated_dur / max(seg_duration, 0.01), 3)
|
||||
|
||||
sync_scores.append(sync_ratio)
|
||||
|
||||
# Save individual segment WAV for preview
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
@@ -168,6 +169,8 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
Generated
+68
@@ -84,6 +84,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
]
|
||||
|
||||
@@ -2177,6 +2178,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2884,6 +2886,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
@@ -3668,6 +3694,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
|
||||
@@ -23,3 +23,4 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.10.3", features = ["macos-private-api", "protocol-asset"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-close"
|
||||
"core:window:allow-close",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-ask"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ fn port_in_use(port: u16) -> bool {
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
|
||||
+221
-42
@@ -10,7 +10,7 @@ import {
|
||||
FileText, Loader, Check, AlertCircle, Plus, User, Save, Languages, Headphones,
|
||||
FolderOpen, FolderPlus, Pencil, Clock, Lock, Unlock, Mic, MicOff, Square,
|
||||
CheckCircle, Circle, ChevronRight, Target, PanelLeftClose, PanelLeftOpen, Scale,
|
||||
Layers, Music, Package
|
||||
Layers, Music, Package, DownloadCloud
|
||||
} from 'lucide-react';
|
||||
|
||||
// Tauri: pre-import window API to avoid async delays in event handlers
|
||||
@@ -175,9 +175,10 @@ function App() {
|
||||
const [langSearch, setLangSearch] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [exportHistory, setExportHistory] = useState([]);
|
||||
|
||||
const [speed, setSpeed] = useState(1.0);
|
||||
const [steps, setSteps] = useState(16);
|
||||
const [steps, setSteps] = useState(16); // Must be ~16 to prevent ODE destabilization
|
||||
const [cfg, setCfg] = useState(2.0);
|
||||
const [showOverrides, setShowOverrides] = useState(false);
|
||||
const [denoise, setDenoise] = useState(true);
|
||||
@@ -349,6 +350,13 @@ function App() {
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
const loadExportHistory = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/export/history`);
|
||||
if (res.ok) setExportHistory(await res.json());
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for backend to come alive before loading data (handles Tauri startup race)
|
||||
let cancelled = false;
|
||||
@@ -368,6 +376,7 @@ function App() {
|
||||
loadHistory();
|
||||
loadDubHistory();
|
||||
loadProjects();
|
||||
loadExportHistory();
|
||||
};
|
||||
loadAll();
|
||||
// Restore local UI state
|
||||
@@ -905,14 +914,71 @@ function App() {
|
||||
} catch (err) { setDubError(err.message); setDubStep('editing'); }
|
||||
};
|
||||
|
||||
const triggerDownload = (url, fallbackName) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fallbackName || 'download';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
|
||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const ext = fallbackName.includes('.') ? fallbackName.split('.').pop() : 'wav';
|
||||
const destPath = await save({ defaultPath: fallbackName, filters: [{ name: 'Media', extensions: [ext] }] });
|
||||
if (!destPath) return; // User cancelled
|
||||
|
||||
// Tell Python backend to natively execute the copy bypassing Blob serialization!
|
||||
const res = await fetch(`${API}/export`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ source_filename: sourceIdentifier, destination_path: destPath, mode })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Export failed');
|
||||
}
|
||||
const data = await res.json();
|
||||
toast.success(`Exported: ${fallbackName}`);
|
||||
loadExportHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to bridge save dialog to rust/python backend.');
|
||||
}
|
||||
};
|
||||
const revealInFolder = async (filePath) => {
|
||||
try {
|
||||
const res = await fetch(`${API}/export/reveal`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ path: filePath })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to open folder');
|
||||
} catch (err) {
|
||||
toast.error(`Could not open folder: ${err.message}`);
|
||||
}
|
||||
};
|
||||
const triggerDownload = async (url, fallbackName) => {
|
||||
try {
|
||||
toast.loading(`Processing ${fallbackName}...`, { id: fallbackName });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const localUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = localUrl;
|
||||
a.download = fallbackName || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
toast.success(`Downloaded ${fallbackName}`, { id: fallbackName });
|
||||
// Record to export history
|
||||
try {
|
||||
const ext = fallbackName.split('.').pop() || '';
|
||||
const mode = ['mp4','mov','mkv','webm'].includes(ext) ? 'video' : ['wav','mp3','flac'].includes(ext) ? 'audio' : 'file';
|
||||
await fetch(`${API}/export/record`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ filename: fallbackName, destination_path: `~/Downloads/${fallbackName}`, mode })
|
||||
});
|
||||
loadExportHistory();
|
||||
} catch (_) {}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Download error: ${err.message}`, { id: fallbackName });
|
||||
}
|
||||
};
|
||||
const handleDubDownload = () => {
|
||||
// Build selected tracks from all known tracks, matching the checkbox `!== false` logic
|
||||
@@ -973,9 +1039,10 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadProject = async (project) => {
|
||||
const loadProject = async (projectOrId) => {
|
||||
const pid = typeof projectOrId === 'string' ? projectOrId : projectOrId?.id;
|
||||
try {
|
||||
const res = await fetch(`${API}/projects/${project.id}`);
|
||||
const res = await fetch(`${API}/projects/${pid}`);
|
||||
if (!res.ok) throw new Error('Failed to load project');
|
||||
const data = await res.json();
|
||||
const s = data.state || {};
|
||||
@@ -2034,32 +2101,42 @@ function App() {
|
||||
{/* ── SIDEBAR ── */}
|
||||
{
|
||||
<div className="glass-panel history-panel" style={{display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', gap:'4px', padding:'6px', borderBottom:'1px solid var(--glass-border)', background:'rgba(0,0,0,0.15)', flexShrink:0}}>
|
||||
<div style={{display:'flex', gap:0, padding:0, borderBottom:'1px solid var(--glass-border)', background:'rgba(0,0,0,0.2)', flexShrink:0, flexDirection: isSidebarCollapsed ? 'column' : 'row'}}>
|
||||
<button onClick={() => setSidebarTab('projects')} style={{
|
||||
flex:1, padding: isSidebarCollapsed ? '4px 0' : '4px 6px', fontSize:'0.72rem', fontWeight:600, cursor:'pointer', border:`1px solid ${sidebarTab === 'projects' ? 'rgba(184,187,38,0.3)' : 'transparent'}`,
|
||||
background: sidebarTab === 'projects' ? 'rgba(184,187,38,0.15)' : 'transparent',
|
||||
color: sidebarTab === 'projects' ? '#b8bb26' : '#a89984',
|
||||
borderRadius:4, whiteSpace: 'nowrap', transition:'all 0.2s ease', overflow:'hidden'
|
||||
}} title={isSidebarCollapsed ? `Projects (${mode === 'dub' ? studioProjects.length : (mode === 'clone' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length)})` : undefined}><FolderOpen size={12} style={{verticalAlign:'middle', marginRight: isSidebarCollapsed ? 0 : 4}}/>
|
||||
{!isSidebarCollapsed && `Projects (${mode === 'dub' ? studioProjects.length : (mode === 'clone' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length)})`}
|
||||
flex:1, padding: isSidebarCollapsed ? '6px 0' : '4px 0', fontSize:'0.65rem', fontWeight:700, cursor:'pointer', borderLeft:'none', borderRight: isSidebarCollapsed ? 'none' : '1px solid rgba(255,255,255,0.06)', borderTop:'none',
|
||||
borderBottom: sidebarTab === 'projects' ? '2px solid #b8bb26' : '2px solid transparent',
|
||||
background: sidebarTab === 'projects' ? 'rgba(184,187,38,0.08)' : 'transparent',
|
||||
color: sidebarTab === 'projects' ? '#b8bb26' : '#7c6f64',
|
||||
borderRadius:0, whiteSpace:'nowrap', transition:'all 0.15s ease', overflow:'hidden', display:'flex', justifyContent:'center', alignItems:'center', gap:3, letterSpacing:'0.02em'
|
||||
}} title="Projects"><FolderOpen size={11}/>
|
||||
{!isSidebarCollapsed && <><strong>Projects</strong> <span style={{opacity:0.6, fontWeight:400}}>·</span> <span style={{fontWeight:400}}>{mode === 'dub' ? studioProjects.length : (mode === 'clone' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length)}</span></>}
|
||||
</button>
|
||||
<button onClick={() => setSidebarTab('history')} style={{
|
||||
flex:1, padding: isSidebarCollapsed ? '4px 0' : '4px 6px', fontSize:'0.72rem', fontWeight:600, cursor:'pointer', border:`1px solid ${sidebarTab === 'history' ? 'rgba(211,134,155,0.3)' : 'transparent'}`,
|
||||
background: sidebarTab === 'history' ? 'rgba(211,134,155,0.15)' : 'transparent',
|
||||
color: sidebarTab === 'history' ? '#d3869b' : '#a89984',
|
||||
borderRadius:4, whiteSpace: 'nowrap', transition:'all 0.2s ease', overflow:'hidden'
|
||||
}} title={isSidebarCollapsed ? `History (${history.length + dubHistory.length})` : undefined}><History size={12} style={{verticalAlign:'middle', marginRight: isSidebarCollapsed ? 0 : 4}}/>
|
||||
{!isSidebarCollapsed && `History (${history.length + dubHistory.length})`}
|
||||
flex:1, padding: isSidebarCollapsed ? '6px 0' : '4px 0', fontSize:'0.65rem', fontWeight:700, cursor:'pointer', borderLeft:'none', borderRight: isSidebarCollapsed ? 'none' : '1px solid rgba(255,255,255,0.06)', borderTop:'none',
|
||||
borderBottom: sidebarTab === 'history' ? '2px solid #d3869b' : '2px solid transparent',
|
||||
background: sidebarTab === 'history' ? 'rgba(211,134,155,0.08)' : 'transparent',
|
||||
color: sidebarTab === 'history' ? '#d3869b' : '#7c6f64',
|
||||
borderRadius:0, whiteSpace:'nowrap', transition:'all 0.15s ease', overflow:'hidden', display:'flex', justifyContent:'center', alignItems:'center', gap:3, letterSpacing:'0.02em'
|
||||
}} title="History"><History size={11}/>
|
||||
{!isSidebarCollapsed && <><strong>History</strong> <span style={{opacity:0.6, fontWeight:400}}>·</span> <span style={{fontWeight:400}}>{history.length + dubHistory.length}</span></>}
|
||||
</button>
|
||||
<button onClick={() => setSidebarTab('downloads')} style={{
|
||||
flex:1, padding: isSidebarCollapsed ? '6px 0' : '4px 0', fontSize:'0.65rem', fontWeight:700, cursor:'pointer', border:'none', borderTop:'none',
|
||||
borderBottom: sidebarTab === 'downloads' ? '2px solid #8ec07c' : '2px solid transparent',
|
||||
background: sidebarTab === 'downloads' ? 'rgba(142,192,124,0.08)' : 'transparent',
|
||||
color: sidebarTab === 'downloads' ? '#8ec07c' : '#7c6f64',
|
||||
borderRadius:0, whiteSpace:'nowrap', transition:'all 0.15s ease', overflow:'hidden', display:'flex', justifyContent:'center', alignItems:'center', gap:3, letterSpacing:'0.02em'
|
||||
}} title="Downloads"><DownloadCloud size={11}/>
|
||||
{!isSidebarCollapsed && <><strong>Exports</strong> <span style={{opacity:0.6, fontWeight:400}}>·</span> <span style={{fontWeight:400}}>{exportHistory.length}</span></>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{flex:1, overflowY:'auto', padding:'8px', display: isSidebarCollapsed ? 'none' : 'block'}}>
|
||||
<div style={{flex:1, overflowY:'auto', padding: isSidebarCollapsed ? '8px 4px' : '8px', display: 'flex', flexDirection: 'column', alignItems: isSidebarCollapsed ? 'center' : 'stretch', gap: isSidebarCollapsed ? 8 : 0}}>
|
||||
{/* ── PROJECTS TAB ── */}
|
||||
{sidebarTab === 'projects' && (
|
||||
<>
|
||||
|
||||
{/* Save current work as dub project button (only in dub mode) */}
|
||||
{mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && (
|
||||
{mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && !isSidebarCollapsed && (
|
||||
<button onClick={saveProject} style={{
|
||||
width:'100%', marginBottom:10, padding:'7px 12px', display:'flex', alignItems:'center', justifyContent:'center', gap:6,
|
||||
background: activeProjectId ? 'rgba(184,187,38,0.15)' : 'rgba(131,165,152,0.15)',
|
||||
@@ -2070,16 +2147,22 @@ function App() {
|
||||
<Save size={13}/> {activeProjectId ? 'Save Dub Project' : 'Save as New Dub Project'}
|
||||
</button>
|
||||
)}
|
||||
{mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && isSidebarCollapsed && (
|
||||
<button onClick={saveProject} title={activeProjectId ? 'Save Dub Project' : 'Save as New Dub Project'} style={{
|
||||
width:'32px', height:'32px', padding:0, display:'flex', alignItems:'center', justifyContent:'center', marginBottom:8, flexShrink:0,
|
||||
background: activeProjectId ? 'rgba(184,187,38,0.15)' : 'rgba(131,165,152,0.15)', border: `1px solid ${activeProjectId ? 'rgba(184,187,38,0.35)' : 'rgba(131,165,152,0.3)'}`,
|
||||
borderRadius:6, cursor:'pointer', color: activeProjectId ? '#b8bb26' : '#83a598',
|
||||
}}><Save size={14}/></button>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8, display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:'2px 0'}}
|
||||
onClick={() => setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)}
|
||||
>
|
||||
<span>{mode === 'dub' ? 'Studio Projects (Dubbing)' : (mode === 'clone' ? 'Voice Clones (Audio)' : 'Designed Voices (Synthetic)')}</span>
|
||||
{isSidebarProjectsCollapsed ? <ChevronDown size={12}/> : <ChevronUp size={12}/>}
|
||||
</div>
|
||||
{!isSidebarCollapsed && (
|
||||
<div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8, display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:'2px 0'}} onClick={() => setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)}>
|
||||
<span>{mode === 'dub' ? 'Studio Projects (Dubbing)' : (mode === 'clone' ? 'Voice Clones (Audio)' : 'Designed Voices (Synthetic)')}</span>
|
||||
{isSidebarProjectsCollapsed ? <ChevronDown size={12}/> : <ChevronUp size={12}/>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSidebarProjectsCollapsed && (
|
||||
{!isSidebarProjectsCollapsed && !isSidebarCollapsed && (
|
||||
<>
|
||||
{mode === 'dub' && (
|
||||
<>
|
||||
@@ -2168,13 +2251,34 @@ function App() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSidebarCollapsed && mode === 'dub' && studioProjects.map(proj => (
|
||||
<div key={proj.id} title={`Load: ${proj.name}`} onClick={() => loadProject(proj.id)} style={{
|
||||
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer',
|
||||
background: activeProjectId === proj.id ? 'rgba(184,187,38,0.2)' : 'rgba(255,255,255,0.05)', border:`1px solid ${activeProjectId === proj.id ? 'rgba(184,187,38,0.5)' : 'transparent'}`,
|
||||
color: activeProjectId === proj.id ? '#b8bb26' : '#a89984'
|
||||
}}>
|
||||
<Film size={14}/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isSidebarCollapsed && (mode === 'clone' || mode === 'design') && (mode === 'clone' ? profiles.filter(p => !p.instruct) : profiles.filter(p => !!p.instruct)).map(proj => (
|
||||
<div key={proj.id} title={`Select: ${proj.name}`} onClick={() => handleSelectProfile(proj)} style={{
|
||||
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', position:'relative',
|
||||
background: selectedProfile === proj.id ? 'rgba(184,187,38,0.2)' : 'rgba(255,255,255,0.05)', border:`1px solid ${selectedProfile === proj.id ? 'rgba(184,187,38,0.5)' : 'transparent'}`,
|
||||
color: selectedProfile === proj.id ? '#b8bb26' : '#a89984'
|
||||
}}>
|
||||
{mode === 'clone' ? <Fingerprint size={14}/> : <Wand2 size={14}/>}
|
||||
{proj.is_locked && <Lock size={8} style={{position:'absolute', bottom:2, right:2, color:'#b8bb26'}}/>}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── HISTORY TAB ── */}
|
||||
{sidebarTab === 'history' && (
|
||||
<>
|
||||
<div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>Generation history · Stored in SQLite</div>
|
||||
{!isSidebarCollapsed && <div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>Generation history · Stored in SQLite</div>}
|
||||
{(history.length + dubHistory.length) === 0 ? (
|
||||
<div style={{color:'var(--text-secondary)', textAlign:'center', padding:'24px 12px'}}>
|
||||
<History size={28} style={{opacity:0.3, marginBottom:8}} />
|
||||
@@ -2184,7 +2288,7 @@ function App() {
|
||||
) : (
|
||||
<>
|
||||
{/* Dub history */}
|
||||
{dubHistory.map(item => (
|
||||
{!isSidebarCollapsed && dubHistory.map(item => (
|
||||
<div key={`dub-${item.id}`} className="history-item">
|
||||
<div className="history-header">
|
||||
<div className="history-badge" style={{background:'rgba(131,165,152,0.15)', color:'#83a598'}}>
|
||||
@@ -2216,7 +2320,7 @@ function App() {
|
||||
))}
|
||||
|
||||
{/* Clone/Design history */}
|
||||
{history.map(item => (
|
||||
{!isSidebarCollapsed && history.map(item => (
|
||||
<div key={item.id} className="history-item">
|
||||
<div className="history-header">
|
||||
<div className="history-badge">
|
||||
@@ -2239,9 +2343,9 @@ function App() {
|
||||
<Lock size={10}/> Lock
|
||||
</button>
|
||||
)}
|
||||
<a href={`${API}/audio/${item.audio_path}`} download style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px', textDecoration:'none'}}>
|
||||
<button onClick={(e) => handleNativeExport(e, item.audio_path, item.audio_path, item.mode)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
|
||||
<DownloadIcon size={10}/>
|
||||
</a>
|
||||
</button>
|
||||
<button onClick={() => restoreHistory(item)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
|
||||
<FolderOpen size={10}/>
|
||||
</button>
|
||||
@@ -2254,9 +2358,26 @@ function App() {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSidebarCollapsed && dubHistory.map(item => (
|
||||
<div key={`dub-${item.id}`} title={`Dub: ${item.filename}`} onClick={() => restoreDubHistory(item)} style={{
|
||||
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color:'#83a598'
|
||||
}}>
|
||||
<Film size={14}/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(history.length + dubHistory.length) > 0 && (
|
||||
{isSidebarCollapsed && history.map(item => (
|
||||
<div key={item.id} title={`${item.mode||'history'}: ${item.text}`} onClick={() => restoreHistory(item)} style={{
|
||||
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color: item.mode === 'clone' ? '#d3869b' : '#b8bb26'
|
||||
}}>
|
||||
{item.mode === 'clone' ? <Fingerprint size={14}/> : <Wand2 size={14}/>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(history.length + dubHistory.length) > 0 && !isSidebarCollapsed && (
|
||||
<button onClick={async () => { if (!confirm(`Clear all ${history.length + dubHistory.length} history items? This cannot be undone.`)) return; await fetch(`${API}/history`, {method:'DELETE'}); await fetch(`${API}/dub/history`, {method:'DELETE'}); await loadHistory(); await loadDubHistory(); toast.success('History cleared'); }}
|
||||
|
||||
style={{width:'100%', marginTop:10, padding:5, background:'transparent', border:'1px solid rgba(255,255,255,0.06)', borderRadius:6, color:'#665c54', cursor:'pointer', fontSize:'0.65rem', transition:'all 0.2s ease'}}
|
||||
onMouseEnter={e => { e.target.style.borderColor = 'rgba(251,73,52,0.3)'; e.target.style.color = '#fb4934'; }}
|
||||
onMouseLeave={e => { e.target.style.borderColor = 'rgba(255,255,255,0.06)'; e.target.style.color = '#665c54'; }}>
|
||||
@@ -2265,6 +2386,64 @@ function App() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── DOWNLOADS TAB ── */}
|
||||
{sidebarTab === 'downloads' && (
|
||||
<>
|
||||
{!isSidebarCollapsed && <div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>History of natively exported media</div>}
|
||||
{exportHistory.length === 0 ? (
|
||||
<div style={{color:'var(--text-secondary)', textAlign:'center', padding:'24px 12px'}}>
|
||||
<DownloadCloud size={28} style={{opacity:0.3, marginBottom:8}} />
|
||||
<p style={{fontSize:'0.78rem', margin:0, marginBottom:4}}>No downloaded outputs</p>
|
||||
<p style={{fontSize:'0.62rem', margin:0, opacity:0.6}}>Export a file via Tauri to see it tracked here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isSidebarCollapsed && exportHistory.map(item => (
|
||||
<div key={item.id} className="history-item">
|
||||
<div className="history-header">
|
||||
<div className="history-badge" style={{background:'rgba(142,192,124,0.15)', color:'#8ec07c'}}>
|
||||
<DownloadCloud size={10}/> {item.mode.toUpperCase()}
|
||||
</div>
|
||||
<div className="history-text" style={{margin:0, opacity:0.6}}>{new Date(item.created_at * 1000).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', marginTop:6, wordWrap:'break-word', fontWeight:600}}>
|
||||
{item.filename}
|
||||
</div>
|
||||
<div style={{fontSize:'0.58rem', color:'#8ec07c', opacity:0.8, marginTop:4, wordWrap:'break-word', background:'rgba(0,0,0,0.15)', padding:'3px 5px', borderRadius:3, fontFamily:'monospace'}}>
|
||||
{item.destination_path}
|
||||
</div>
|
||||
<div style={{display:'flex', gap:4, marginTop:6}}>
|
||||
<button onClick={() => revealInFolder(item.destination_path)} style={{
|
||||
flex:1, padding:'3px 6px', background:'rgba(142,192,124,0.1)', border:'1px solid rgba(142,192,124,0.25)',
|
||||
color:'#8ec07c', borderRadius:4, fontSize:'0.62rem', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:4, fontWeight:600, transition:'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.2)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.4)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background='rgba(142,192,124,0.1)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.25)'; }}
|
||||
>
|
||||
<FolderOpen size={10}/> Open Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isSidebarCollapsed && exportHistory.map(item => (
|
||||
<div key={item.id} title={`Exported: ${item.filename}\nTo: ${item.destination_path}\nClick to open folder`}
|
||||
onClick={() => revealInFolder(item.destination_path)}
|
||||
style={{
|
||||
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color:'#8ec07c',
|
||||
transition:'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.15)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background='rgba(255,255,255,0.05)'; e.currentTarget.style.borderColor='transparent'; }}
|
||||
>
|
||||
<FolderOpen size={14}/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+23
-15
@@ -125,11 +125,12 @@ h1, h2, h3, h4 { font-family: 'Outfit', sans-serif; margin: 0; }
|
||||
.header-area {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px 6px 80px; /* left padding for macOS traffic lights */
|
||||
background: rgba(30, 30, 30, 0.65);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(12px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(120%);
|
||||
padding: 12px 16px 12px 80px; /* Thicker header like macOS native */
|
||||
background: rgba(15, 15, 15, 0.6);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
box-shadow: 0 1px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(24px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
||||
user-select: none; /* prevent text selection when dragging */
|
||||
}
|
||||
.header-area h1 {
|
||||
@@ -143,24 +144,31 @@ h1, h2, h3, h4 { font-family: 'Outfit', sans-serif; margin: 0; }
|
||||
/* ═══ TABS ═══ */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 2px; border-radius: var(--radius); border: 1px solid rgba(255,255,255,0.05);
|
||||
margin-bottom: 4px;
|
||||
background: rgba(0,0,0,0.35);
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.03);
|
||||
margin-bottom: 0;
|
||||
box-shadow: inset 0 1px 4px rgba(0,0,0,0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab {
|
||||
flex: 1; padding: 4px 6px; background: transparent; border: none; border-radius: 4px;
|
||||
color: var(--text-secondary); font-weight: 600; font-size: 0.72rem; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||
transition: all var(--transition-smooth);
|
||||
flex: 1; padding: 5px 12px; background: transparent; border: 1px solid transparent; border-radius: 6px;
|
||||
color: #8a8a93; font-weight: 500; font-size: 0.74rem; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.5);
|
||||
}
|
||||
.tab:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--glass-bg); color: white;
|
||||
background: rgba(255,255,255,0.12); color: #ffffff;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab:not(.active):hover { color: white; background: rgba(255,255,255,0.05); }
|
||||
|
||||
/* ═══ GRID SYSTEMS ═══ */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
resolve: {
|
||||
preserveSymlinks: false,
|
||||
alias: {
|
||||
'@tauri-apps/plugin-dialog': path.resolve(__dirname, 'node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
|
||||
@@ -43,6 +43,7 @@ dependencies = [
|
||||
"psutil>=7.2.2",
|
||||
"pyannote-audio>=4.0.4",
|
||||
"pyinstaller>=6.19.0",
|
||||
"imageio-ffmpeg>=0.6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.abspath("backend"))
|
||||
import asyncio
|
||||
from backend.main import _get_db, _dub_jobs, dub_transcribe, _find_ffmpeg
|
||||
import httpx
|
||||
|
||||
print(f"FFmpeg path: {_find_ffmpeg()}")
|
||||
try:
|
||||
resp = httpx.post("http://localhost:8000/dub/transcribe/a2ea109c")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(resp.text)
|
||||
except Exception as e:
|
||||
print(f"Error calling local server: {e}")
|
||||
@@ -0,0 +1,7 @@
|
||||
import sys, types
|
||||
sys.modules['torchcodec'] = types.ModuleType('torchcodec')
|
||||
import torchaudio
|
||||
from transformers import pipeline
|
||||
print("Loading Whisper...")
|
||||
pipe = pipeline("automatic-speech-recognition", model="openai/whisper-tiny")
|
||||
print("Whisper loaded successfully!")
|
||||
@@ -0,0 +1,8 @@
|
||||
import torch
|
||||
import torchaudio
|
||||
from pyannote.audio import Pipeline
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
# Try to mock the pipeline
|
||||
class Mock: pass
|
||||
@@ -0,0 +1,17 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.abspath("backend"))
|
||||
|
||||
from backend.main import _get_db, _dub_jobs, dub_transcribe, _find_ffmpeg
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
print(f"FFmpeg path: {_find_ffmpeg()}")
|
||||
try:
|
||||
await dub_transcribe("312a7661")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore")
|
||||
asyncio.run(test())
|
||||
@@ -0,0 +1,7 @@
|
||||
import subprocess
|
||||
import soundfile as sf
|
||||
from transformers import pipeline
|
||||
|
||||
print("Loading pipeline...")
|
||||
pipe = pipeline("automatic-speech-recognition", model="openai/whisper-tiny")
|
||||
print("Pipeline loaded!")
|
||||
@@ -1562,6 +1562,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imageio-ffmpeg"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.1"
|
||||
@@ -2696,6 +2710,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "accelerate" },
|
||||
{ name = "gradio" },
|
||||
{ name = "imageio-ffmpeg" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "psutil" },
|
||||
@@ -2748,6 +2763,7 @@ requires-dist = [
|
||||
{ name = "gradio" },
|
||||
{ name = "gradio", marker = "extra == 'ui'" },
|
||||
{ name = "gradio-client", marker = "extra == 'ui'" },
|
||||
{ name = "imageio-ffmpeg", specifier = ">=0.6.0" },
|
||||
{ name = "jiwer", marker = "extra == 'eval'", specifier = "==3.1.0" },
|
||||
{ name = "librosa", marker = "extra == 'eval'" },
|
||||
{ name = "numpy" },
|
||||
|
||||
Reference in New Issue
Block a user