Compare commits
8 Commits
main
..
183cf27bfd
| Author | SHA1 | Date | |
|---|---|---|---|
| 183cf27bfd | |||
| 1766feef73 | |||
| 4960f05704 | |||
| 9b6ffcce21 | |||
| c22075e8aa | |||
| 0c46962cac | |||
| d00250948a | |||
| f05ec04f47 |
@@ -2,51 +2,16 @@
|
|||||||
|
|
||||||
Voicext is a high-performance desktop application built with **Electron** and **React** that provides local, private, and fast speech-to-text transcription. It leverages the power of GPU-accelerated AI to convert audio files into professional subtitle formats.
|
Voicext is a high-performance desktop application built with **Electron** and **React** that provides local, private, and fast speech-to-text transcription. It leverages the power of GPU-accelerated AI to convert audio files into professional subtitle formats.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 📸 Screenshots
|
## 🚀 Features
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="50%"><img src="docs/ss/dashboard.png" alt="Dashboard"/><br/><sub><b>Main Dashboard</b></sub></td>
|
|
||||||
<td width="50%"><img src="docs/ss/start-transcripting.png" alt="Start Transcription"/><br/><sub><b>New Transcription</b></sub></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="50%"><img src="docs/ss/transcription-complete.png" alt="Transcription Progress"/><br/><sub><b>Processing & Results</b></sub></td>
|
|
||||||
<td width="50%"><img src="docs/ss/editor.png" alt="SRT Editor"/><br/><sub><b>Built-in SRT Editor</b></sub></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="50%"><img src="docs/ss/model-management.png" alt="Model Management"/><br/><sub><b>AI Model Management</b></sub></td>
|
|
||||||
<td width="50%"><img src="docs/ss/ss-youtube.jpeg" alt="Use Case"/><br/><sub><b>Professional Output</b></sub></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
*A modern, glassmorphic UI designed for speed and clarity.*
|
|
||||||
|
|
||||||
## 🚀 Key Features
|
|
||||||
|
|
||||||
- **Local Processing:** Your data never leaves your machine. Privacy by design.
|
- **Local Processing:** Your data never leaves your machine. Privacy by design.
|
||||||
- **GPU Acceleration:** Utilizes DirectCompute and CUDA for lightning-fast transcription using the Windows-optimized Whisper implementation.
|
- **GPU Acceleration:** Utilizes DirectCompute and CUDA for lightning-fast transcription using the Const-me Whisper implementation.
|
||||||
- **Built-in SRT Editor:** Edit your transcriptions instantly within the app with a real-time preview.
|
|
||||||
- **Smart SRT Fixing:** Custom algorithm to ensure SRT files are 100% compatible with Adobe Premiere Pro and other NLEs.
|
- **Smart SRT Fixing:** Custom algorithm to ensure SRT files are 100% compatible with Adobe Premiere Pro and other NLEs.
|
||||||
- **Model Management:** Download and switch between different AI models (Tiny, Base, Small, Medium, Large) directly from the interface.
|
- **Multi-format Output:** Supports SRT, VTT, and TXT exports.
|
||||||
- **Modern UI:** A sleek, responsive interface built with React, Tailwind CSS, and Framer Motion.
|
- **Modern UI:** A sleek, responsive interface built with Tailwind CSS and Framer Motion.
|
||||||
- **Turkish Optimization:** Specialized handling for Turkish character encoding and language models.
|
- **Optimized for Turkish:** Specialized handling for Turkish character encoding and language models.
|
||||||
|
|
||||||
## 🧠 How It Works
|
|
||||||
|
|
||||||
The transcription process follows a robust pipeline to ensure accuracy and compatibility:
|
|
||||||
|
|
||||||
1. **Hardware Detection:** The app automatically detects if an NVIDIA GPU is available to enable hardware acceleration.
|
|
||||||
2. **Engine Initialization:** It spawns a background process using the `whisper.exe` CLI, passing the audio file and selected model.
|
|
||||||
3. **Stream Processing:** Real-time feedback is captured from the engine's stdout, providing the user with live progress updates.
|
|
||||||
4. **Post-Processing (SRT Fixer):**
|
|
||||||
- **Timestamp Normalization:** Corrects overlapping or negative duration segments.
|
|
||||||
- **Segment Merging:** Automatically merges micro-segments that often occur in long-form audio.
|
|
||||||
- **Encoding Correction:** Ensures the output is UTF-8 encoded, specifically handling Turkish characters to prevent encoding errors.
|
|
||||||
- **NLE Compatibility:** Formats the SRT specifically for high-end video editors like Adobe Premiere Pro.
|
|
||||||
|
|
||||||
## 🛠️ Technology Stack
|
## 🛠️ Technology Stack
|
||||||
|
|
||||||
@@ -55,38 +20,69 @@ The transcription process follows a robust pipeline to ensure accuracy and compa
|
|||||||
- **Styling:** [Tailwind CSS](https://tailwindcss.com/) & [Framer Motion](https://www.framer.com/motion/)
|
- **Styling:** [Tailwind CSS](https://tailwindcss.com/) & [Framer Motion](https://www.framer.com/motion/)
|
||||||
- **Engine:** [Const-me/Whisper](https://github.com/Const-me/Whisper) (Windows-optimized C++ implementation)
|
- **Engine:** [Const-me/Whisper](https://github.com/Const-me/Whisper) (Windows-optimized C++ implementation)
|
||||||
- **State Management:** [Zustand](https://github.com/pmndrs/zustand)
|
- **State Management:** [Zustand](https://github.com/pmndrs/zustand)
|
||||||
|
- **Utilities:** `ffmpeg-static`, `iconv-lite`, `adm-zip`
|
||||||
|
|
||||||
|
## 🧠 How It Works (Algorithm)
|
||||||
|
|
||||||
|
The transcription process follows a robust pipeline to ensure accuracy and compatibility:
|
||||||
|
|
||||||
|
1. **Hardware Detection:** The app automatically detects if an NVIDIA GPU is available to enable hardware acceleration.
|
||||||
|
2. **Engine Initialization:** It spawns a background process using the `whisper.exe` CLI, passing the audio file and selected model (e.g., `base`, `small`).
|
||||||
|
3. **Stream Processing:** Real-time feedback is captured from the engine's stdout/stderr, providing the user with live progress updates.
|
||||||
|
4. **Post-Processing (SRT Fixer):**
|
||||||
|
- **Timestamp Normalization:** Corrects overlapping or negative duration segments.
|
||||||
|
- **Segment Merging:** Automatically merges micro-segments that often occur in long-form audio.
|
||||||
|
- **Encoding Correction:** Ensures the output is UTF-8 encoded, specifically handling Turkish characters (CP857 conversion) to prevent "mojibake".
|
||||||
|
- **NLE Compatibility:** Formats the SRT specifically for high-end video editors like Adobe Premiere Pro.
|
||||||
|
|
||||||
## ⚙️ Setup & Installation
|
## ⚙️ Setup & Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) (v18 or higher)
|
- [Node.js](https://nodejs.org/) (v18 or higher)
|
||||||
|
- [Git](https://git-scm.com/)
|
||||||
- NVIDIA GPU (Recommended for speed, but supports CPU)
|
- NVIDIA GPU (Recommended for speed, but supports CPU)
|
||||||
|
|
||||||
### Installation Steps
|
### Installation Steps
|
||||||
|
|
||||||
1. **Clone & Install:**
|
1. **Clone the Repository:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/truncgil/voicext.git
|
git clone https://github.com/truncgil/voicext.git
|
||||||
cd voicext
|
cd voicext
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install Dependencies:**
|
||||||
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Setup Binaries:**
|
3. **Download Binaries & Models:**
|
||||||
The application requires the Whisper executable and AI models. Run the automated setup script:
|
The application requires the Whisper executable and AI models. Run the automated setup script:
|
||||||
```bash
|
```bash
|
||||||
node scripts/setup-binaries.js
|
node scripts/setup-binaries.js
|
||||||
```
|
```
|
||||||
|
*This will download the Const-me Whisper CLI and the `ggml-base.bin` model into the `bin/` directory.*
|
||||||
|
|
||||||
3. **Run Application:**
|
4. **Run in Development Mode:**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
5. **Build for Production:**
|
||||||
|
```bash
|
||||||
|
npm run package
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📂 Project Structure
|
||||||
|
|
||||||
|
- `src/main`: Electron main process logic and transcription engine integration.
|
||||||
|
- `src/renderer`: React-based UI components and state management.
|
||||||
|
- `bin/`: (Generated) Contains `whisper.exe` and `.bin` models.
|
||||||
|
- `scripts/`: Maintenance and setup scripts for binaries.
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
Developed with ❤️ by **Truncgil Software**.
|
Developed with ❤️ by Voicext Team.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 962 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 653 KiB |
|
Before Width: | Height: | Size: 814 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 610 KiB |
@@ -1,34 +0,0 @@
|
|||||||
appId: com.voicext.app
|
|
||||||
productName: VoiceXt
|
|
||||||
directories:
|
|
||||||
output: out/make
|
|
||||||
buildResources: resources
|
|
||||||
|
|
||||||
files:
|
|
||||||
- out/main/index.js
|
|
||||||
- out/preload/index.js
|
|
||||||
- out/renderer/**/*
|
|
||||||
- package.json
|
|
||||||
|
|
||||||
extraResources:
|
|
||||||
- from: bin
|
|
||||||
to: bin
|
|
||||||
filter:
|
|
||||||
- "**/*"
|
|
||||||
|
|
||||||
win:
|
|
||||||
target: nsis
|
|
||||||
icon: resources/icon.png
|
|
||||||
artifactName: "${productName}-Setup-${version}.${ext}"
|
|
||||||
|
|
||||||
nsis:
|
|
||||||
oneClick: false
|
|
||||||
allowToChangeInstallationDirectory: true
|
|
||||||
shortcutName: VoiceXt
|
|
||||||
createDesktopShortcut: true
|
|
||||||
|
|
||||||
publish:
|
|
||||||
provider: github
|
|
||||||
v3ApiUrl: https://git.truncgil.com/api/v1
|
|
||||||
owner: truncgit
|
|
||||||
repo: voicext
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const electron = require("electron");
|
const electron = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
|
||||||
const utils = require("@electron-toolkit/utils");
|
const utils = require("@electron-toolkit/utils");
|
||||||
const child_process = require("child_process");
|
const child_process = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
const iconv = require("iconv-lite");
|
const iconv = require("iconv-lite");
|
||||||
const icon = path.join(__dirname, "../../resources/icon.png");
|
const icon = path.join(__dirname, "../../resources/icon.png");
|
||||||
const BIN_PATH = electron.app.isPackaged ? path.join(process.resourcesPath, "bin") : path.join(electron.app.getAppPath(), "bin");
|
const BIN_PATH = electron.app.isPackaged ? path.join(process.resourcesPath, "bin") : path.join(electron.app.getAppPath(), "bin");
|
||||||
@@ -231,44 +231,6 @@ function createWindow() {
|
|||||||
electron.ipcMain.on("open-explorer", (event, path2) => {
|
electron.ipcMain.on("open-explorer", (event, path2) => {
|
||||||
electron.shell.showItemInFolder(path2);
|
electron.shell.showItemInFolder(path2);
|
||||||
});
|
});
|
||||||
electron.ipcMain.handle("read-srt", async (event, filePath) => {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
return content;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Read SRT Error:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
electron.ipcMain.handle("save-srt", async (event, { filePath, content }) => {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(filePath, content, "utf-8");
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Save SRT Error:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
electron.ipcMain.handle("select-file", async (event, filters) => {
|
|
||||||
const { dialog } = require("electron");
|
|
||||||
const result = await dialog.showOpenDialog({
|
|
||||||
properties: ["openFile"],
|
|
||||||
filters: filters || []
|
|
||||||
});
|
|
||||||
if (result.canceled) return null;
|
|
||||||
return result.filePaths[0];
|
|
||||||
});
|
|
||||||
electron.ipcMain.handle("optimize-srt", async (event, filePath) => {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
const optimized = fixSrt(content);
|
|
||||||
fs.writeFileSync(filePath, optimized, "utf-8");
|
|
||||||
return { success: true, content: optimized };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Optimize SRT Error:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
electron.app.whenReady().then(() => {
|
electron.app.whenReady().then(() => {
|
||||||
utils.electronApp.setAppUserModelId("com.voicext.app");
|
utils.electronApp.setAppUserModelId("com.voicext.app");
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ const api = {
|
|||||||
onTranscriptionProgress: (callback) => electron.ipcRenderer.on("transcription-progress", (_, progress) => callback(progress)),
|
onTranscriptionProgress: (callback) => electron.ipcRenderer.on("transcription-progress", (_, progress) => callback(progress)),
|
||||||
onTranscriptionData: (callback) => electron.ipcRenderer.on("transcription-data", (_, data) => callback(data)),
|
onTranscriptionData: (callback) => electron.ipcRenderer.on("transcription-data", (_, data) => callback(data)),
|
||||||
detectHardware: () => electron.ipcRenderer.invoke("detect-hardware"),
|
detectHardware: () => electron.ipcRenderer.invoke("detect-hardware"),
|
||||||
openExplorer: (path) => electron.ipcRenderer.send("open-explorer", path),
|
openExplorer: (path) => electron.ipcRenderer.send("open-explorer", path)
|
||||||
readSrt: (filePath) => electron.ipcRenderer.invoke("read-srt", filePath),
|
|
||||||
saveSrt: (filePath, content) => electron.ipcRenderer.invoke("save-srt", { filePath, content }),
|
|
||||||
selectFile: (filters) => electron.ipcRenderer.invoke("select-file", filters),
|
|
||||||
optimizeSrt: (filePath) => electron.ipcRenderer.invoke("optimize-srt", filePath)
|
|
||||||
};
|
};
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "voicext",
|
"name": "voicext",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"productName": "VoiceXt",
|
"description": "Local AI Transcription Tool",
|
||||||
"description": "High Performance Local AI Transcription Tool",
|
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Truncgil Software",
|
"author": "Voicext",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"package": "electron-vite build && electron-builder",
|
"package": "electron-vite build && electron-builder",
|
||||||
"release": "electron-vite build && electron-builder --publish always",
|
|
||||||
"postinstall": "electron-builder install-app-deps"
|
"postinstall": "electron-builder install-app-deps"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -31,7 +29,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"electron": "^31.0.1",
|
"electron": "^31.0.1",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^24.13.3",
|
||||||
"electron-vite": "^2.3.0",
|
"electron-vite": "^2.3.0",
|
||||||
"vite": "^5.3.1"
|
"vite": "^5.3.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,15 +67,9 @@ async function setup() {
|
|||||||
fs.unlinkSync(whisperZipPath)
|
fs.unlinkSync(whisperZipPath)
|
||||||
|
|
||||||
console.log('Downloading Base Model (140MB)...')
|
console.log('Downloading Base Model (140MB)...')
|
||||||
const baseModelPath = path.join(modelsDir, 'ggml-base.bin')
|
const modelPath = path.join(modelsDir, 'ggml-base.bin')
|
||||||
if (!fs.existsSync(baseModelPath)) {
|
if (!fs.existsSync(modelPath)) {
|
||||||
await downloadFile('https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', baseModelPath)
|
await downloadFile('https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', modelPath)
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Downloading Small Model (460MB)...')
|
|
||||||
const smallModelPath = path.join(modelsDir, 'ggml-small.bin')
|
|
||||||
if (!fs.existsSync(smallModelPath)) {
|
|
||||||
await downloadFile('https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin', smallModelPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Setup Complete!')
|
console.log('Setup Complete!')
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import fs from 'fs'
|
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
|
|
||||||
import { detectHardware, transcribe, fixSrt } from './engine/transcription'
|
import { detectHardware, transcribe } from './engine/transcription'
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
@@ -77,52 +76,6 @@ function createWindow() {
|
|||||||
ipcMain.on('open-explorer', (event, path) => {
|
ipcMain.on('open-explorer', (event, path) => {
|
||||||
shell.showItemInFolder(path)
|
shell.showItemInFolder(path)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle SRT Reading
|
|
||||||
ipcMain.handle('read-srt', async (event, filePath) => {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8')
|
|
||||||
return content
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Read SRT Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle SRT Saving
|
|
||||||
ipcMain.handle('save-srt', async (event, { filePath, content }) => {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(filePath, content, 'utf-8')
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Save SRT Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle File Dialog
|
|
||||||
ipcMain.handle('select-file', async (event, filters) => {
|
|
||||||
const { dialog } = require('electron')
|
|
||||||
const result = await dialog.showOpenDialog({
|
|
||||||
properties: ['openFile'],
|
|
||||||
filters: filters || []
|
|
||||||
})
|
|
||||||
if (result.canceled) return null
|
|
||||||
return result.filePaths[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle SRT Optimization
|
|
||||||
ipcMain.handle('optimize-srt', async (event, filePath) => {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8')
|
|
||||||
const optimized = fixSrt(content)
|
|
||||||
fs.writeFileSync(filePath, optimized, 'utf-8')
|
|
||||||
return { success: true, content: optimized }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Optimize SRT Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ const api = {
|
|||||||
onTranscriptionProgress: (callback) => ipcRenderer.on('transcription-progress', (_, progress) => callback(progress)),
|
onTranscriptionProgress: (callback) => ipcRenderer.on('transcription-progress', (_, progress) => callback(progress)),
|
||||||
onTranscriptionData: (callback) => ipcRenderer.on('transcription-data', (_, data) => callback(data)),
|
onTranscriptionData: (callback) => ipcRenderer.on('transcription-data', (_, data) => callback(data)),
|
||||||
detectHardware: () => ipcRenderer.invoke('detect-hardware'),
|
detectHardware: () => ipcRenderer.invoke('detect-hardware'),
|
||||||
openExplorer: (path) => ipcRenderer.send('open-explorer', path),
|
openExplorer: (path) => ipcRenderer.send('open-explorer', path)
|
||||||
readSrt: (filePath) => ipcRenderer.invoke('read-srt', filePath),
|
|
||||||
saveSrt: (filePath, content) => ipcRenderer.invoke('save-srt', { filePath, content }),
|
|
||||||
selectFile: (filters) => ipcRenderer.invoke('select-file', filters),
|
|
||||||
optimizeSrt: (filePath) => ipcRenderer.invoke('optimize-srt', filePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import logo from './assets/logo.png'
|
|||||||
|
|
||||||
import CustomSelect from './components/CustomSelect'
|
import CustomSelect from './components/CustomSelect'
|
||||||
import ResultModal from './components/ResultModal'
|
import ResultModal from './components/ResultModal'
|
||||||
import SrtEditor from './components/SrtEditor'
|
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ id: 'auto', name: 'AUTO (Recommended)' },
|
{ id: 'auto', name: 'AUTO (Recommended)' },
|
||||||
@@ -52,7 +51,6 @@ function App() {
|
|||||||
format: 'srt'
|
format: 'srt'
|
||||||
})
|
})
|
||||||
const [showResult, setShowResult] = useState(null)
|
const [showResult, setShowResult] = useState(null)
|
||||||
const [selectedSrt, setSelectedSrt] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.api) {
|
if (!window.api) {
|
||||||
@@ -122,14 +120,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectSrt = async () => {
|
|
||||||
const filters = [{ name: 'Subtitle Files', extensions: ['srt'] }]
|
|
||||||
const path = await window.api.selectFile(filters)
|
|
||||||
if (path) {
|
|
||||||
setSelectedSrt(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
{/* Titlebar */}
|
{/* Titlebar */}
|
||||||
@@ -149,7 +139,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="nav-tabs">
|
<nav className="nav-tabs">
|
||||||
{['dashboard', 'models', 'editor', 'settings'].map((tab) => (
|
{['dashboard', 'models', 'queue', 'settings'].map((tab) => (
|
||||||
<div
|
<div
|
||||||
key={tab}
|
key={tab}
|
||||||
className={`nav-item ${activeTab === tab ? 'active' : ''}`}
|
className={`nav-item ${activeTab === tab ? 'active' : ''}`}
|
||||||
@@ -157,7 +147,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
{tab === 'dashboard' && <LayoutDashboard size={18} />}
|
{tab === 'dashboard' && <LayoutDashboard size={18} />}
|
||||||
{tab === 'models' && <Box size={18} />}
|
{tab === 'models' && <Box size={18} />}
|
||||||
{tab === 'editor' && <ListMusic size={18} />}
|
{tab === 'queue' && <ListMusic size={18} />}
|
||||||
{tab === 'settings' && <Settings size={18} />}
|
{tab === 'settings' && <Settings size={18} />}
|
||||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||||
</div>
|
</div>
|
||||||
@@ -306,35 +296,6 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'editor' && (
|
|
||||||
<motion.div
|
|
||||||
key="editor"
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 1.05 }}
|
|
||||||
className="queue-view"
|
|
||||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
|
||||||
>
|
|
||||||
{selectedSrt ? (
|
|
||||||
<SrtEditor filePath={selectedSrt} onClose={() => setSelectedSrt(null)} />
|
|
||||||
) : (
|
|
||||||
<div className="queue-empty glass" style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '20px' }}>
|
|
||||||
<div className="drop-icon-container">
|
|
||||||
<div className="drop-icon-bg"></div>
|
|
||||||
<ListMusic size={80} color="white" style={{ position: 'relative', zIndex: 1 }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<h2>SRT EDITOR</h2>
|
|
||||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px' }}>Select an SRT file to fix timestamps and edit text.</p>
|
|
||||||
<button className="btn-primary" style={{ maxWidth: '300px', margin: '0 auto' }} onClick={handleSelectSrt}>
|
|
||||||
Browse SRT Files
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -357,10 +318,6 @@ function App() {
|
|||||||
isOpen={!!showResult}
|
isOpen={!!showResult}
|
||||||
path={showResult}
|
path={showResult}
|
||||||
onClose={() => setShowResult(null)}
|
onClose={() => setShowResult(null)}
|
||||||
onEdit={(path) => {
|
|
||||||
setSelectedSrt(path)
|
|
||||||
setActiveTab('editor')
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { CheckCircle2, FolderOpen, X, FileEdit } from 'lucide-react'
|
import { CheckCircle2, FolderOpen, X } from 'lucide-react'
|
||||||
|
|
||||||
export default function ResultModal({ isOpen, path, onClose, onEdit }) {
|
export default function ResultModal({ isOpen, path, onClose }) {
|
||||||
const handleOpenFolder = () => {
|
const handleOpenFolder = () => {
|
||||||
if (window.api && window.api.openExplorer) {
|
if (window.api && window.api.openExplorer) {
|
||||||
window.api.openExplorer(path)
|
window.api.openExplorer(path)
|
||||||
@@ -45,12 +45,6 @@ export default function ResultModal({ isOpen, path, onClose, onEdit }) {
|
|||||||
<FolderOpen size={18} />
|
<FolderOpen size={18} />
|
||||||
OPEN FOLDER
|
OPEN FOLDER
|
||||||
</button>
|
</button>
|
||||||
{path && path.toLowerCase().endsWith('.srt') && (
|
|
||||||
<button className="btn-primary-small" onClick={() => { onEdit(path); onClose(); }}>
|
|
||||||
<FileEdit size={18} />
|
|
||||||
EDIT
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
|
||||||
import { Save, Download, Zap, ChevronUp, ChevronDown, CheckCircle, ArrowLeft } from 'lucide-react'
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
|
|
||||||
const SrtEditor = ({ filePath, onClose }) => {
|
|
||||||
const [segments, setSegments] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [fileName, setFileName] = useState('')
|
|
||||||
const scrollRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (filePath) {
|
|
||||||
loadSrt(filePath)
|
|
||||||
setFileName(filePath.split(/[\\/]/).pop())
|
|
||||||
}
|
|
||||||
}, [filePath])
|
|
||||||
|
|
||||||
const loadSrt = async (path) => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const content = await window.api.readSrt(path)
|
|
||||||
const parsed = parseSrt(content)
|
|
||||||
setSegments(parsed)
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error loading SRT: ' + error.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseSrt = (content) => {
|
|
||||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
||||||
const blocks = normalized.trim().split(/\n\n+/)
|
|
||||||
return blocks.map((block, idx) => {
|
|
||||||
const lines = block.trim().split('\n')
|
|
||||||
if (lines.length < 2) return null
|
|
||||||
|
|
||||||
const timeLineIdx = lines.findIndex(l => l.includes('-->'))
|
|
||||||
if (timeLineIdx === -1) return null
|
|
||||||
|
|
||||||
const times = lines[timeLineIdx].split('-->').map(t => t.trim())
|
|
||||||
const text = lines.slice(timeLineIdx + 1).join('\n')
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: idx,
|
|
||||||
start: times[0],
|
|
||||||
end: times[1],
|
|
||||||
text: text
|
|
||||||
}
|
|
||||||
}).filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stringifySrt = (segs) => {
|
|
||||||
return segs.map((s, i) => {
|
|
||||||
return `${i + 1}\n${s.start} --> ${s.end}\n${s.text}`
|
|
||||||
}).join('\n\n') + '\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
const [showToast, setShowToast] = useState(false)
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const content = stringifySrt(segments)
|
|
||||||
await window.api.saveSrt(filePath, content)
|
|
||||||
setShowToast(true)
|
|
||||||
setTimeout(() => setShowToast(false), 3000)
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error saving SRT: ' + error.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSegment = (id, field, value) => {
|
|
||||||
setSegments(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey || e.shiftKey) && e.key.toLowerCase() === 's') {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [segments])
|
|
||||||
|
|
||||||
if (loading) return <div className="editor-loading">Loading SRT...</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="srt-editor-container glass">
|
|
||||||
<AnimatePresence>
|
|
||||||
{showToast && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20, x: '-50%' }}
|
|
||||||
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
|
||||||
exit={{ opacity: 0, y: 20, x: '-50%' }}
|
|
||||||
className="save-toast"
|
|
||||||
>
|
|
||||||
<CheckCircle size={18} />
|
|
||||||
SRT Saved Successfully!
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="editor-header">
|
|
||||||
<button className="btn-back" onClick={onClose}>
|
|
||||||
<ArrowLeft size={18} />
|
|
||||||
</button>
|
|
||||||
<div className="file-info">
|
|
||||||
<span className="file-label">FILE:</span>
|
|
||||||
<span className="file-name">{fileName}</span>
|
|
||||||
<span className="preview-label">(Preview)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="segments-list" ref={scrollRef}>
|
|
||||||
<div className="segments-header">
|
|
||||||
<div className="col-index">Index</div>
|
|
||||||
<div className="col-start">Start</div>
|
|
||||||
<div className="col-end">End</div>
|
|
||||||
<div className="col-text">Subtitle Text</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{segments.map((seg, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={seg.id}
|
|
||||||
className="segment-row"
|
|
||||||
initial={{ opacity: 0, y: 5 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: Math.min(idx * 0.02, 0.5) }}
|
|
||||||
>
|
|
||||||
<div className="col-index">{(idx + 1).toString().padStart(2, '0')}</div>
|
|
||||||
<div className="col-start">
|
|
||||||
<div className="time-input-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={seg.start}
|
|
||||||
onChange={(e) => updateSegment(seg.id, 'start', e.target.value)}
|
|
||||||
className="time-input"
|
|
||||||
/>
|
|
||||||
<div className="time-controls">
|
|
||||||
<ChevronUp size={10} />
|
|
||||||
<ChevronDown size={10} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-end">
|
|
||||||
<div className="time-input-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={seg.end}
|
|
||||||
onChange={(e) => updateSegment(seg.id, 'end', e.target.value)}
|
|
||||||
className="time-input"
|
|
||||||
/>
|
|
||||||
<div className="time-controls">
|
|
||||||
<ChevronUp size={10} />
|
|
||||||
<ChevronDown size={10} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-text">
|
|
||||||
<textarea
|
|
||||||
value={seg.text}
|
|
||||||
onChange={(e) => updateSegment(seg.id, 'text', e.target.value)}
|
|
||||||
className="text-input"
|
|
||||||
rows={1}
|
|
||||||
onInput={(e) => {
|
|
||||||
e.target.style.height = 'auto'
|
|
||||||
e.target.style.height = e.target.scrollHeight + 'px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="editor-footer">
|
|
||||||
<button className="btn-one-click-export" style={{ maxWidth: '100%' }} onClick={handleSave} disabled={saving}>
|
|
||||||
<div className="btn-content">
|
|
||||||
<span className="main-text">{saving ? 'SAVING...' : 'SAVE ALL CHANGES'}</span>
|
|
||||||
<span className="sub-text">(Ctrl + S to save instantly)</span>
|
|
||||||
</div>
|
|
||||||
<div className="btn-icon">
|
|
||||||
<Save size={24} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SrtEditor
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #0b0c14;
|
--bg-color: #0b0c14;
|
||||||
@@ -90,8 +90,6 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
min-height: 0;
|
|
||||||
/* Important for flex children to scroll */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -218,20 +216,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% { transform: scale(1); opacity: 0.3; }
|
||||||
transform: scale(1);
|
50% { transform: scale(1.1); opacity: 0.5; }
|
||||||
opacity: 0.3;
|
100% { transform: scale(1); opacity: 0.3; }
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone h2 {
|
.drop-zone h2 {
|
||||||
@@ -359,72 +346,22 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Select Styles */
|
/* Custom Select Styles */
|
||||||
.relative {
|
.relative { position: relative; }
|
||||||
position: relative;
|
.absolute { position: absolute; }
|
||||||
}
|
.inset-y-0 { top: 0; bottom: 0; }
|
||||||
|
.left-0 { left: 0; }
|
||||||
.absolute {
|
.right-0 { right: 0; }
|
||||||
position: absolute;
|
.z-50 { z-index: 50; }
|
||||||
}
|
.mt-1 { margin-top: 4px; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
.inset-y-0 {
|
.flex { display: flex; }
|
||||||
top: 0;
|
.items-center { align-items: center; }
|
||||||
bottom: 0;
|
.justify-between { justify-content: space-between; }
|
||||||
}
|
.text-left { text-align: left; }
|
||||||
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.left-0 {
|
.block { display: block; }
|
||||||
left: 0;
|
.h-4 { height: 16px; }
|
||||||
}
|
.w-4 { width: 16px; }
|
||||||
|
|
||||||
.right-0 {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.z-50 {
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-1 {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-full {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-between {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-4 {
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-4 {
|
|
||||||
width: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-dark-glass {
|
.bg-dark-glass {
|
||||||
background: rgba(15, 15, 25, 0.95);
|
background: rgba(15, 15, 25, 0.95);
|
||||||
@@ -436,106 +373,45 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.max-height-60 {
|
.max-height-60 { max-height: 240px; }
|
||||||
max-height: 240px;
|
.overflow-auto { overflow: auto; }
|
||||||
}
|
.rounded-md { border-radius: 8px; }
|
||||||
|
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
|
||||||
|
.py-1 { padding-top: 4px; padding-bottom: 4px; }
|
||||||
|
.py-2 { padding-top: 8px; padding-bottom: 8px; }
|
||||||
|
.pl-3 { padding-left: 12px; }
|
||||||
|
.pl-10 { padding-left: 40px; }
|
||||||
|
.pr-4 { padding-right: 16px; }
|
||||||
|
|
||||||
.overflow-auto {
|
.select-none { user-select: none; }
|
||||||
overflow: auto;
|
.cursor-default { cursor: default; }
|
||||||
}
|
.font-normal { font-weight: 400; }
|
||||||
|
.font-medium { font-weight: 500; }
|
||||||
|
|
||||||
.rounded-md {
|
.text-primary-cyan { color: var(--primary-cyan); }
|
||||||
border-radius: 8px;
|
.text-white { color: #ffffff; }
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-lg {
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.py-1 {
|
|
||||||
padding-top: 4px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.py-2 {
|
|
||||||
padding-top: 8px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pl-3 {
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pl-10 {
|
|
||||||
padding-left: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-4 {
|
|
||||||
padding-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-none {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-default {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-normal {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-medium {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-primary-cyan {
|
|
||||||
color: var(--primary-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-white {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar styling for dropdown */
|
/* Scrollbar styling for dropdown */
|
||||||
.bg-dark-glass::-webkit-scrollbar {
|
.bg-dark-glass::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-dark-glass::-webkit-scrollbar-track {
|
.bg-dark-glass::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-dark-glass::-webkit-scrollbar-thumb {
|
.bg-dark-glass::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-dark-glass::-webkit-scrollbar-thumb:hover {
|
.bg-dark-glass::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animation for select dropdown */
|
/* Animation for select dropdown */
|
||||||
.transition {
|
.transition { transition-property: opacity, transform; }
|
||||||
transition-property: opacity, transform;
|
.ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); }
|
||||||
}
|
.duration-100 { transition-duration: 100ms; }
|
||||||
|
.opacity-0 { opacity: 0; }
|
||||||
.ease-in {
|
.opacity-100 { opacity: 1; }
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-100 {
|
|
||||||
transition-duration: 100ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opacity-0 {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opacity-100 {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone.drag-active {
|
.drop-zone.drag-active {
|
||||||
border-color: var(--primary-cyan);
|
border-color: var(--primary-cyan);
|
||||||
@@ -573,13 +449,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% { filter: hue-rotate(0deg); }
|
||||||
filter: hue-rotate(0deg);
|
100% { filter: hue-rotate(360deg); }
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
filter: hue-rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Models View */
|
/* Models View */
|
||||||
@@ -727,264 +598,3 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SRT Editor Styles */
|
|
||||||
.srt-editor-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 25px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(0, 242, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-header {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-back {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: var(--text-muted);
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-back:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: white;
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-label {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name {
|
|
||||||
color: var(--primary-cyan);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-label {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segments-list {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segments-list::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segments-list::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segments-list::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 242, 255, 0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segments-list::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(0, 242, 255, 0.4);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segments-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 60px 120px 120px 1fr;
|
|
||||||
padding: 10px 15px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-bottom: 1px solid var(--glass-border);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 60px 120px 120px 1fr;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 15px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-row:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-row:focus-within {
|
|
||||||
border-color: var(--primary-cyan);
|
|
||||||
background: rgba(0, 242, 255, 0.03);
|
|
||||||
box-shadow: 0 0 15px rgba(0, 242, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-index {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-input-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-input {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--text-main);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: monospace;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-controls {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-input {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-main);
|
|
||||||
font-size: 14px;
|
|
||||||
resize: none;
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-input:focus {
|
|
||||||
border-color: var(--primary-cyan);
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.btn-one-click-export {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 450px;
|
|
||||||
height: 80px;
|
|
||||||
background: linear-gradient(135deg, #4b1d3f 0%, #1d1b4b 100%);
|
|
||||||
border: 2px solid #ff00e5;
|
|
||||||
border-radius: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 30px;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 0 30px rgba(255, 0, 229, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-one-click-export:hover {
|
|
||||||
transform: translateY(-3px) scale(1.02);
|
|
||||||
box-shadow: 0 0 50px rgba(255, 0, 229, 0.4);
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-one-click-export .btn-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-one-click-export .main-text {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-one-click-export .sub-text {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-one-click-export .btn-icon {
|
|
||||||
color: #ffaa00;
|
|
||||||
filter: drop-shadow(0 0 8px #ffaa00);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--primary-cyan);
|
|
||||||
}
|
|
||||||
.save-toast {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 120px;
|
|
||||||
left: 50%;
|
|
||||||
background: rgba(0, 242, 255, 0.95);
|
|
||||||
color: #0b0c14;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 242, 255, 0.4);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|||||||