Compare commits

..

15 Commits

Author SHA1 Message Date
Ümit Tunç 65bbdcfa06 fix sss 2026-04-23 12:19:56 +03:00
Ümit Tunç d9f14b958a docs: add interface screenshots to README and streamline setup documentation 2026-04-23 12:18:18 +03:00
Ümit Tunç 247e62d53d chore: prepare release configuration and finalize build scripts 2026-04-23 11:53:42 +03:00
Ümit Tunç c0be0f1b4d feat: add SrtEditor component and initial dark-themed glassmorphism styles 2026-04-23 11:09:52 +03:00
Ümit Tunç 2351ba72ed feat: implement initial renderer dashboard UI and transcription workflow integration 2026-04-23 11:03:19 +03:00
Ümit Tunç e267fca6cb feat: implement electron-based audio transcription service with whisper integration and srt processing 2026-04-23 10:00:41 +03:00
Ümit Tunç 3f1f4f4608 feat: implement main renderer application with file upload, transcription configuration, and Electron integration 2026-04-23 09:14:49 +03:00
Ümit Tunç 4a7e23de79 feat: implement whisper transcription engine with hardware detection and IPC bridge 2026-04-23 09:05:10 +03:00
Ümit Tunç 16ae1a8345 feat: initialize Electron project with transcription engine integration and UI shell 2026-04-23 09:03:33 +03:00
Ümit Tunç 436fe02d83 feat: implement CustomSelect component and add global application styles 2026-04-23 09:01:26 +03:00
Ümit Tunç a720260c30 feat: implement main dashboard UI with file upload, configuration, and transcription progress tracking 2026-04-23 08:58:41 +03:00
Ümit Tunç 9cf716168c feat: implement Whisper transcription engine and project documentation 2026-04-23 08:56:57 +03:00
Ümit Tunç e15d47efc2 feat: implement main dashboard UI with drag-and-drop file upload and transcription controls 2026-04-23 07:40:13 +03:00
Ümit Tunç 9dfc3ad9bf feat: initialize project scaffold with Electron structure, transcription engine, and dark-themed UI components 2026-04-23 07:40:05 +03:00
Ümit Tunç 89c69114bd first commit 2026-04-23 07:34:37 +03:00
18 changed files with 871 additions and 98 deletions
+47 -43
View File
@@ -2,16 +2,51 @@
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.
![Voicext Logo](resources/icon.png) ![Voicext Logo](docs/logo/voicext-logo.png)
## 🚀 Features ## 📸 Screenshots
<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 Const-me Whisper implementation. - **GPU Acceleration:** Utilizes DirectCompute and CUDA for lightning-fast transcription using the Windows-optimized 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.
- **Multi-format Output:** Supports SRT, VTT, and TXT exports. - **Model Management:** Download and switch between different AI models (Tiny, Base, Small, Medium, Large) directly from the interface.
- **Modern UI:** A sleek, responsive interface built with Tailwind CSS and Framer Motion. - **Modern UI:** A sleek, responsive interface built with React, Tailwind CSS, and Framer Motion.
- **Optimized for Turkish:** Specialized handling for Turkish character encoding and language models. - **Turkish Optimization:** 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
@@ -20,69 +55,38 @@ Voicext is a high-performance desktop application built with **Electron** and **
- **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 the Repository:** 1. **Clone & Install:**
```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
``` ```
3. **Download Binaries & Models:** 2. **Setup Binaries:**
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.*
4. **Run in Development Mode:** 3. **Run Application:**
```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 - see the LICENSE file for details. This project is licensed under the MIT License.
--- ---
Developed with ❤️ by Voicext Team. Developed with ❤️ by **Truncgil Software**.
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+33
View File
@@ -0,0 +1,33 @@
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
owner: truncgil
repo: voicext
+39 -1
View File
@@ -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,6 +231,44 @@ 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");
+5 -1
View File
@@ -7,7 +7,11 @@ 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 {
+3 -2
View File
@@ -1,9 +1,10 @@
{ {
"name": "voicext", "name": "voicext",
"version": "1.0.0", "version": "1.0.0",
"description": "Local AI Transcription Tool", "productName": "VoiceXt",
"description": "High Performance Local AI Transcription Tool",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Voicext", "author": "Truncgil Software",
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "electron-vite build", "build": "electron-vite build",
+9 -3
View File
@@ -67,9 +67,15 @@ async function setup() {
fs.unlinkSync(whisperZipPath) fs.unlinkSync(whisperZipPath)
console.log('Downloading Base Model (140MB)...') console.log('Downloading Base Model (140MB)...')
const modelPath = path.join(modelsDir, 'ggml-base.bin') const baseModelPath = path.join(modelsDir, 'ggml-base.bin')
if (!fs.existsSync(modelPath)) { if (!fs.existsSync(baseModelPath)) {
await downloadFile('https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', modelPath) await downloadFile('https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', baseModelPath)
}
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!')
+48 -1
View File
@@ -1,9 +1,10 @@
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 } from './engine/transcription' import { detectHardware, transcribe, fixSrt } from './engine/transcription'
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
@@ -76,6 +77,52 @@ 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(() => {
+5 -1
View File
@@ -7,7 +7,11 @@ 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) {
+45 -2
View File
@@ -18,6 +18,7 @@ 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)' },
@@ -51,6 +52,7 @@ 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) {
@@ -120,6 +122,14 @@ 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 */}
@@ -139,7 +149,7 @@ function App() {
</div> </div>
<nav className="nav-tabs"> <nav className="nav-tabs">
{['dashboard', 'models', 'queue', 'settings'].map((tab) => ( {['dashboard', 'models', 'editor', 'settings'].map((tab) => (
<div <div
key={tab} key={tab}
className={`nav-item ${activeTab === tab ? 'active' : ''}`} className={`nav-item ${activeTab === tab ? 'active' : ''}`}
@@ -147,7 +157,7 @@ function App() {
> >
{tab === 'dashboard' && <LayoutDashboard size={18} />} {tab === 'dashboard' && <LayoutDashboard size={18} />}
{tab === 'models' && <Box size={18} />} {tab === 'models' && <Box size={18} />}
{tab === 'queue' && <ListMusic size={18} />} {tab === 'editor' && <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>
@@ -296,6 +306,35 @@ 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>
@@ -318,6 +357,10 @@ function App() {
isOpen={!!showResult} isOpen={!!showResult}
path={showResult} path={showResult}
onClose={() => setShowResult(null)} onClose={() => setShowResult(null)}
onEdit={(path) => {
setSelectedSrt(path)
setActiveTab('editor')
}}
/> />
</div> </div>
) )
+8 -2
View File
@@ -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 } from 'lucide-react' import { CheckCircle2, FolderOpen, X, FileEdit } from 'lucide-react'
export default function ResultModal({ isOpen, path, onClose }) { export default function ResultModal({ isOpen, path, onClose, onEdit }) {
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,6 +45,12 @@ export default function ResultModal({ isOpen, path, onClose }) {
<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>
+197
View File
@@ -0,0 +1,197 @@
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
+432 -42
View File
@@ -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,6 +90,8 @@ 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 {
@@ -216,9 +218,20 @@ body {
} }
@keyframes pulse { @keyframes pulse {
0% { transform: scale(1); opacity: 0.3; } 0% {
50% { transform: scale(1.1); opacity: 0.5; } transform: scale(1);
100% { transform: scale(1); opacity: 0.3; } opacity: 0.3;
}
50% {
transform: scale(1.1);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 0.3;
}
} }
.drop-zone h2 { .drop-zone h2 {
@@ -346,22 +359,72 @@ body {
} }
/* Custom Select Styles */ /* Custom Select Styles */
.relative { position: relative; } .relative {
.absolute { position: absolute; } position: relative;
.inset-y-0 { top: 0; bottom: 0; } }
.left-0 { left: 0; }
.right-0 { right: 0; } .absolute {
.z-50 { z-index: 50; } position: absolute;
.mt-1 { margin-top: 4px; } }
.w-full { width: 100%; }
.flex { display: flex; } .inset-y-0 {
.items-center { align-items: center; } top: 0;
.justify-between { justify-content: space-between; } bottom: 0;
.text-left { text-align: left; } }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.block { display: block; } .left-0 {
.h-4 { height: 16px; } left: 0;
.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);
@@ -373,45 +436,106 @@ body {
overflow: hidden; overflow: hidden;
} }
.max-height-60 { max-height: 240px; } .max-height-60 {
.overflow-auto { overflow: auto; } max-height: 240px;
.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; }
.select-none { user-select: none; } .overflow-auto {
.cursor-default { cursor: default; } overflow: auto;
.font-normal { font-weight: 400; } }
.font-medium { font-weight: 500; }
.text-primary-cyan { color: var(--primary-cyan); } .rounded-md {
.text-white { color: #ffffff; } 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;
}
.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-property: opacity, transform; } .transition {
.ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); } transition-property: opacity, transform;
.duration-100 { transition-duration: 100ms; } }
.opacity-0 { opacity: 0; }
.opacity-100 { opacity: 1; } .ease-in {
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);
@@ -449,8 +573,13 @@ body {
} }
@keyframes shimmer { @keyframes shimmer {
0% { filter: hue-rotate(0deg); } 0% {
100% { filter: hue-rotate(360deg); } filter: hue-rotate(0deg);
}
100% {
filter: hue-rotate(360deg);
}
} }
/* Models View */ /* Models View */
@@ -598,3 +727,264 @@ 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);
}