feat: implement Whisper transcription engine and project documentation
This commit is contained in:
@@ -2,13 +2,13 @@ import { spawn } from 'child_process'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { app } from 'electron'
|
||||
import iconv from 'iconv-lite'
|
||||
|
||||
const BIN_PATH = app.isPackaged
|
||||
const BIN_PATH = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(app.getAppPath(), 'bin')
|
||||
|
||||
export async function detectHardware() {
|
||||
// Simple check for NVIDIA GPU via nvidia-smi
|
||||
return new Promise((resolve) => {
|
||||
const smi = spawn('nvidia-smi')
|
||||
smi.on('error', () => resolve({ gpu: false, name: 'CPU' }))
|
||||
@@ -22,63 +22,194 @@ export async function detectHardware() {
|
||||
})
|
||||
}
|
||||
|
||||
// ── SRT Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function srtTimeToMs(timeStr) {
|
||||
const match = timeStr.match(/(\d+):(\d+):(\d+)[,.](\d+)/)
|
||||
if (!match) return 0
|
||||
const [, h, m, s, ms] = match
|
||||
return parseInt(h) * 3600000 + parseInt(m) * 60000 + parseInt(s) * 1000 + parseInt(ms)
|
||||
}
|
||||
|
||||
function msToSrtTime(ms) {
|
||||
const h = Math.floor(ms / 3600000)
|
||||
ms %= 3600000
|
||||
const m = Math.floor(ms / 60000)
|
||||
ms %= 60000
|
||||
const s = Math.floor(ms / 1000)
|
||||
const rem = ms % 1000
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(rem).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
// ── SRT Post-Processor ──────────────────────────────────────────────
|
||||
// Const-me Whisper sometimes produces broken timestamps when splitting
|
||||
// long segments. This function fixes them for Premiere Pro compatibility.
|
||||
export function fixSrt(srtContent) {
|
||||
// CRITIK DÜZELTME: Tüm Windows (\r\n) ve eski Mac (\r) satır sonlarını Linux (\n) standardına çevir
|
||||
const normalizedContent = srtContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const blocks = normalizedContent.trim().split(/\n\n+/)
|
||||
const entries = []
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split('\n')
|
||||
if (lines.length < 2) continue
|
||||
|
||||
const timeLine = lines.find((l) => l.includes('-->'))
|
||||
if (!timeLine) continue
|
||||
|
||||
const textLines = lines.filter((l) => !l.includes('-->') && !/^\d+$/.test(l.trim()))
|
||||
const text = textLines.join(' ').trim()
|
||||
if (!text) continue
|
||||
|
||||
const [startStr, endStr] = timeLine.split('-->').map((s) => s.trim())
|
||||
const startMs = srtTimeToMs(startStr)
|
||||
let endMs = srtTimeToMs(endStr)
|
||||
|
||||
entries.push({ startMs, endMs, text })
|
||||
}
|
||||
|
||||
// Pass 1: Fix broken timestamps (end < start) by merging with next entry
|
||||
const merged = []
|
||||
let i = 0
|
||||
while (i < entries.length) {
|
||||
const entry = { ...entries[i] }
|
||||
|
||||
if (entry.endMs <= entry.startMs && i + 1 < entries.length) {
|
||||
const next = entries[i + 1]
|
||||
entry.text = (entry.text + ' ' + next.text).trim()
|
||||
entry.endMs = next.endMs
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
|
||||
if (entry.endMs <= entry.startMs) {
|
||||
entry.endMs = entry.startMs + 3000
|
||||
}
|
||||
|
||||
merged.push(entry)
|
||||
}
|
||||
|
||||
// Pass 2: Ensure timestamps are sequential
|
||||
for (let j = 1; j < merged.length; j++) {
|
||||
if (merged[j].startMs < merged[j - 1].endMs) {
|
||||
merged[j].startMs = merged[j - 1].endMs
|
||||
}
|
||||
if (merged[j].endMs <= merged[j].startMs) {
|
||||
merged[j].endMs = merged[j].startMs + 3000
|
||||
}
|
||||
}
|
||||
|
||||
// Build clean SRT
|
||||
let srt = ''
|
||||
merged.forEach((entry, idx) => {
|
||||
srt += `${idx + 1}\n`
|
||||
srt += `${msToSrtTime(entry.startMs)} --> ${msToSrtTime(entry.endMs)}\n`
|
||||
srt += `${entry.text}\n\n`
|
||||
})
|
||||
|
||||
return srt.trim() + '\n'
|
||||
}
|
||||
|
||||
// ── Transcription Engine ────────────────────────────────────────────────
|
||||
|
||||
export function transcribe(filePath, options, onProgress, onData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { model = 'base', language = 'tr', outputFormat = 'srt' } = options
|
||||
|
||||
// 1. FFmpeg Pre-processing (Convert to 16kHz mono WAV)
|
||||
const tempWav = path.join(app.getPath('temp'), `voicext_${Date.now()}.wav`)
|
||||
const ffmpegPath = path.join(BIN_PATH, 'ffmpeg.exe')
|
||||
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
'-i', filePath,
|
||||
'-ar', '16000',
|
||||
'-ac', '1',
|
||||
'-c:a', 'pcm_s16le',
|
||||
tempWav,
|
||||
'-y'
|
||||
])
|
||||
const { model = 'small', language = 'tr', format = 'srt' } = options
|
||||
const whisperPath = path.join(BIN_PATH, 'whisper.exe')
|
||||
const modelPath = path.join(BIN_PATH, 'models', `ggml-${model}.bin`)
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error('FFmpeg conversion failed'))
|
||||
if (!fs.existsSync(whisperPath)) {
|
||||
return reject(new Error(`Whisper executable not found at: ${whisperPath}`))
|
||||
}
|
||||
if (!fs.existsSync(modelPath)) {
|
||||
return reject(new Error(`Model not found at: ${modelPath}. Run: node scripts/setup-binaries.js`))
|
||||
}
|
||||
|
||||
const whisperArgs = ['-m', modelPath, '-f', filePath]
|
||||
if (format === 'srt') whisperArgs.push('-osrt')
|
||||
else if (format === 'vtt') whisperArgs.push('-ovtt')
|
||||
else if (format === 'txt') whisperArgs.push('-otxt')
|
||||
|
||||
if (language && language !== 'auto') {
|
||||
whisperArgs.push('-l', language)
|
||||
}
|
||||
|
||||
console.log('[Voicext] Whisper args:', whisperArgs.join(' '))
|
||||
|
||||
const whisper = spawn(whisperPath, whisperArgs)
|
||||
let stderrOutput = ''
|
||||
|
||||
// Windows console encoding usually requires special handling for Turkish characters
|
||||
// Using iconv-lite to decode the buffer from CP857 (Turkish DOS) or CP1254 (Turkish Windows)
|
||||
// Const-me Whisper CLI usually outputs in the system codepage.
|
||||
whisper.stdout.on('data', (data) => {
|
||||
// Decode buffer using Turkish Windows codepage (win1254) which covers Turkish characters
|
||||
const text = iconv.decode(data, 'cp857')
|
||||
console.log('[Whisper stdout]', text)
|
||||
onData(text)
|
||||
})
|
||||
|
||||
whisper.stderr.on('data', (data) => {
|
||||
const text = iconv.decode(data, 'cp857')
|
||||
stderrOutput += text
|
||||
console.log('[Whisper stderr]', text)
|
||||
|
||||
// Progress parsing
|
||||
if (text.includes('Loaded model')) {
|
||||
onProgress(20)
|
||||
} else if (text.includes('Loaded audio') || text.includes('source reader')) {
|
||||
onProgress(40)
|
||||
} else if (text.includes('RunComplete') || text.includes('CPU Tasks')) {
|
||||
onProgress(80)
|
||||
} else if (text.includes('Memory Usage')) {
|
||||
onProgress(95)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Whisper.cpp Inference
|
||||
const whisperPath = path.join(BIN_PATH, 'whisper.exe')
|
||||
const modelPath = path.join(BIN_PATH, 'models', `ggml-${model}.bin`)
|
||||
|
||||
const whisper = spawn(whisperPath, [
|
||||
'-m', modelPath,
|
||||
'-f', tempWav,
|
||||
`-o${outputFormat}`,
|
||||
'-l', language,
|
||||
'--max-len', '42'
|
||||
])
|
||||
whisper.on('error', (err) => {
|
||||
console.error('[Voicext] Failed to start Whisper process:', err)
|
||||
reject(new Error(`Failed to start Whisper: ${err.message}`))
|
||||
})
|
||||
|
||||
whisper.stdout.on('data', (data) => {
|
||||
onData(data.toString())
|
||||
})
|
||||
whisper.on('close', (code) => {
|
||||
console.log(`[Voicext] Whisper exited with code: ${code}`)
|
||||
const expectedOutput = filePath.replace(/\.[^/.]+$/, `.${format}`)
|
||||
|
||||
whisper.stderr.on('data', (data) => {
|
||||
// Whisper.cpp outputs progress to stderr
|
||||
const output = data.toString()
|
||||
const progressMatch = output.match(/progress\s*=\s*(\d+)%/)
|
||||
if (progressMatch) {
|
||||
onProgress(parseInt(progressMatch[1]))
|
||||
if (code === 0) {
|
||||
if (format === 'srt' && fs.existsSync(expectedOutput)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(expectedOutput, 'utf-8')
|
||||
const fixed = fixSrt(raw)
|
||||
fs.writeFileSync(expectedOutput, fixed, 'utf-8')
|
||||
console.log('[Voicext] SRT post-processed and fixed')
|
||||
} catch (e) {
|
||||
console.warn('[Voicext] SRT post-processing warning:', e.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
whisper.on('close', (code) => {
|
||||
// Cleanup temp file
|
||||
if (fs.existsSync(tempWav)) fs.unlinkSync(tempWav)
|
||||
|
||||
if (code === 0) {
|
||||
resolve({ success: true, outputPath: filePath.replace(/\.[^/.]+$/, `.${outputFormat}`) })
|
||||
} else {
|
||||
reject(new Error('Whisper transcription failed'))
|
||||
// Handle cases where whisper outputs to a different path
|
||||
if (!fs.existsSync(expectedOutput)) {
|
||||
const baseName = path.basename(filePath).replace(/\.[^/.]+$/, `.${format}`)
|
||||
const altOutput = path.join(process.cwd(), baseName)
|
||||
if (fs.existsSync(altOutput)) {
|
||||
if (format === 'srt') {
|
||||
const raw = fs.readFileSync(altOutput, 'utf-8')
|
||||
const fixed = fixSrt(raw)
|
||||
fs.writeFileSync(expectedOutput, fixed, 'utf-8')
|
||||
} else {
|
||||
fs.copyFileSync(altOutput, expectedOutput)
|
||||
}
|
||||
fs.unlinkSync(altOutput)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onProgress(100)
|
||||
resolve({ success: true, outputPath: expectedOutput })
|
||||
} else {
|
||||
reject(new Error(`Whisper failed (code ${code}). Details:\n${stderrOutput}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-11
@@ -13,7 +13,7 @@ function createWindow() {
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.mjs'),
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
},
|
||||
frame: false,
|
||||
@@ -43,10 +43,14 @@ function createWindow() {
|
||||
filePath,
|
||||
options,
|
||||
(progress) => {
|
||||
mainWindow.webContents.send('transcription-progress', progress)
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('transcription-progress', progress)
|
||||
}
|
||||
},
|
||||
(data) => {
|
||||
mainWindow.webContents.send('transcription-data', data)
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('transcription-data', data)
|
||||
}
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -60,6 +64,14 @@ function createWindow() {
|
||||
ipcMain.handle('detect-hardware', async () => {
|
||||
return await detectHardware()
|
||||
})
|
||||
|
||||
// Handle window controls
|
||||
ipcMain.on('window-controls', (event, action) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (action === 'minimize') win.minimize()
|
||||
if (action === 'maximize') win.isMaximized() ? win.unmaximize() : win.maximize()
|
||||
if (action === 'close') win.close()
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
@@ -69,14 +81,6 @@ app.whenReady().then(() => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// IPC handlers
|
||||
ipcMain.on('window-controls', (event, action) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (action === 'minimize') win.minimize()
|
||||
if (action === 'maximize') win.isMaximized() ? win.unmaximize() : win.maximize()
|
||||
if (action === 'close') win.close()
|
||||
})
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Voicext</title>
|
||||
<meta
|
||||
<!-- <meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset:;"
|
||||
/>
|
||||
/> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+49
-29
@@ -16,6 +16,27 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
import logo from './assets/logo.png'
|
||||
|
||||
import CustomSelect from './components/CustomSelect'
|
||||
|
||||
const languages = [
|
||||
{ id: 'auto', name: 'AUTO (Recommended)' },
|
||||
{ id: 'tr', name: 'Turkish' },
|
||||
{ id: 'en', name: 'English' }
|
||||
]
|
||||
|
||||
const models = [
|
||||
{ id: 'small', name: 'SMALL (Recommended, 460MB)' },
|
||||
{ id: 'base', name: 'BASE (Fast, Lower Quality, 140MB)' },
|
||||
{ id: 'medium', name: 'MEDIUM (Best Quality, 1.5GB)' },
|
||||
{ id: 'large', name: 'LARGE (Highest Quality, 3GB)' }
|
||||
]
|
||||
|
||||
const formats = [
|
||||
{ id: 'srt', name: '.SRT (Premiere Pro Compatible)' },
|
||||
{ id: 'vtt', name: '.VTT (Web Standard)' },
|
||||
{ id: 'txt', name: '.TXT (Plain Text)' }
|
||||
]
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('dashboard')
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
@@ -25,11 +46,15 @@ function App() {
|
||||
const [hardware, setHardware] = useState({ gpu: false, name: 'Detecting...' })
|
||||
const [config, setConfig] = useState({
|
||||
language: 'tr',
|
||||
model: 'base',
|
||||
model: 'small',
|
||||
format: 'srt'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.api) {
|
||||
console.error('Electron API not found!')
|
||||
return
|
||||
}
|
||||
window.api.detectHardware().then(setHardware)
|
||||
|
||||
window.api.onTranscriptionProgress((p) => {
|
||||
@@ -38,6 +63,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
const handleControl = (action) => {
|
||||
if (!window.api) return
|
||||
window.api.windowControls(action)
|
||||
}
|
||||
|
||||
@@ -208,37 +234,30 @@ function App() {
|
||||
Quick Configuration
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Language:</label>
|
||||
<select className="select-custom">
|
||||
<option>AUTO (Recommended)</option>
|
||||
<option>Turkish</option>
|
||||
<option>English</option>
|
||||
</select>
|
||||
</div>
|
||||
<CustomSelect
|
||||
label="Language:"
|
||||
options={languages}
|
||||
value={config.language}
|
||||
onChange={(val) => setConfig({ ...config, language: val })}
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Model:</label>
|
||||
<select className="select-custom">
|
||||
<option>BASE (Fastest, 140MB)</option>
|
||||
<option>SMALL (Balanced)</option>
|
||||
<option>MEDIUM (Accurate)</option>
|
||||
<option>LARGE (Best Quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
<CustomSelect
|
||||
label="Model:"
|
||||
options={models}
|
||||
value={config.model}
|
||||
onChange={(val) => setConfig({ ...config, model: val })}
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Format:</label>
|
||||
<select className="select-custom">
|
||||
<option>.SRT (Premiere Pro Compatible)</option>
|
||||
<option>.VTT (Web Standard)</option>
|
||||
<option>.TXT (Plain Text)</option>
|
||||
</select>
|
||||
</div>
|
||||
<CustomSelect
|
||||
label="Format:"
|
||||
options={formats}
|
||||
value={config.format}
|
||||
onChange={(val) => setConfig({ ...config, format: val })}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ marginTop: '10px', opacity: file ? 1 : 0.5, cursor: file ? 'pointer' : 'not-allowed' }}
|
||||
style={{ marginTop: '20px', opacity: file ? 1 : 0.5, cursor: file ? 'pointer' : 'not-allowed' }}
|
||||
disabled={!file || isTranscribing}
|
||||
onClick={startTranscription}
|
||||
>
|
||||
@@ -281,10 +300,11 @@ function App() {
|
||||
{/* Status Bar */}
|
||||
<footer className="status-bar">
|
||||
<div className="status-indicator">
|
||||
<span>SYSTEM: RTX 5080 DETECTED (GPU Acceleration ON)</span>
|
||||
<div className="indicator-dot" style={{ backgroundColor: hardware.gpu ? '#00ff88' : '#ffaa00', boxShadow: hardware.gpu ? '0 0 8px #00ff88' : '0 0 8px #ffaa00' }}></div>
|
||||
<span>SYSTEM: {hardware.name.toUpperCase()} {hardware.gpu ? 'DETECTED (GPU ACCELERATION ON)' : 'DETECTED (CPU ONLY)'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'center' }}>
|
||||
<span>READY</span>
|
||||
<span>{isTranscribing ? 'PROCESSING...' : 'READY'}</span>
|
||||
<div className="offline-badge">
|
||||
OFFLINE MODE ACTIVE
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import { Listbox, Transition } from '@headlessui/react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
export default function CustomSelect({ value, onChange, options, label }) {
|
||||
const selectedOption = options.find(opt => opt.id === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="input-group">
|
||||
{label && <label>{label}</label>}
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="select-custom text-left flex justify-between items-center w-full">
|
||||
<span className="block truncate">{selectedOption.name}</span>
|
||||
<span className="pointer-events-none">
|
||||
<ChevronDown className="h-4 w-4 text-muted" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-50 mt-1 max-height-60 w-full overflow-auto rounded-md bg-dark-glass py-1 text-base shadow-lg ring-1 ring-white/10 focus:outline-none sm:text-sm backdrop-blur-xl border border-white/10">
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-white/10 text-primary-cyan' : 'text-white'
|
||||
}`
|
||||
}
|
||||
value={option.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-cyan">
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -330,6 +330,44 @@ body {
|
||||
box-shadow: 0 0 8px #00ff88;
|
||||
}
|
||||
|
||||
/* Custom Select Styles */
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
.z-50 { z-index: 50; }
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.w-full { width: 100%; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.bg-dark-glass {
|
||||
background: rgba(15, 15, 25, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.max-height-60 { max-height: 240px; }
|
||||
.py-1 { padding-top: 4px; padding-bottom: 4px; }
|
||||
.py-2 { padding-top: 8px; padding-bottom: 8px; }
|
||||
.pl-10 { padding-left: 40px; }
|
||||
.pr-4 { padding-right: 16px; }
|
||||
.pl-3 { padding-left: 12px; }
|
||||
|
||||
.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); }
|
||||
|
||||
/* Animation for select dropdown */
|
||||
.transition { 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; }
|
||||
.opacity-100 { opacity: 1; }
|
||||
|
||||
.drop-zone.drag-active {
|
||||
border-color: var(--primary-cyan);
|
||||
background: rgba(0, 242, 255, 0.05);
|
||||
|
||||
Reference in New Issue
Block a user