feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s
Build Windows Installer / build (push) Successful in 4m11s
This commit is contained in:
40 files changed
+6084
-266
No files matched your search
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ConvertGUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1173
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "convert-gui-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"test": "node --experimental-strip-types --test src/timecode.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export type Lang = 'ru' | 'en';
|
||||
|
||||
export const dict = {
|
||||
ru: {
|
||||
openVideo: 'Открыть видео',
|
||||
dropTitle: 'Перетащите видео сюда',
|
||||
dropHint: 'MP4, MKV, MOV, AVI, WebM — или нажмите «Открыть видео»',
|
||||
ffmpegOk: 'FFmpeg готов',
|
||||
ffmpegMissing: 'FFmpeg не найден (указать)',
|
||||
setStart: 'Начало [I]',
|
||||
setEnd: 'Конец [O]',
|
||||
loopTrim: 'Повтор фрагмента',
|
||||
previewMix: 'Микс дорожек в превью',
|
||||
proxyLoading: 'Подготовка совместимого превью...',
|
||||
trimStart: 'Начало (ss)',
|
||||
trimEnd: 'Конец (to)',
|
||||
clipDur: 'Длительность',
|
||||
outDur: 'Итог (со скор.)',
|
||||
resetTrim: 'Сбросить',
|
||||
sectionVideo: 'Видео и качество',
|
||||
encoder: 'Кодек',
|
||||
hwDecode: 'Аппар. декодинг',
|
||||
crf: 'Качество (CRF / CQ)',
|
||||
preset: 'Пресет скорости',
|
||||
tune: 'Тонкая настройка',
|
||||
fps: 'FPS',
|
||||
fpsOriginal: 'Оригинал',
|
||||
sectionSpeed: 'Скорость воспроизведения',
|
||||
sectionAudio: 'Аудиодорожки',
|
||||
audioMerge: 'Объединить (amix)',
|
||||
audioTrack: 'Одна дорожка',
|
||||
audioMute: 'Без звука (-an)',
|
||||
noAudio: 'В видео нет аудиодорожек',
|
||||
sectionCrop: 'Кадрирование (Crop)',
|
||||
cropEnable: 'Включить обрезку кадра',
|
||||
sectionOutput: 'Выходной файл',
|
||||
browse: 'Обзор...',
|
||||
showCli: 'Команда FFmpeg',
|
||||
copy: 'Копировать',
|
||||
copied: 'Скопировано',
|
||||
convertBtn: 'Конвертировать',
|
||||
cancelBtn: 'Отмена',
|
||||
doneTitle: 'Готово!',
|
||||
openFolder: 'Открыть папку',
|
||||
playOutput: 'Открыть файл',
|
||||
crfHintBest: 'Почти без потерь',
|
||||
crfHintBal: 'Оптимальный баланс',
|
||||
crfHintComp: 'Сильное сжатие',
|
||||
crfHintSmall: 'Минимальный размер',
|
||||
eta: 'Осталось',
|
||||
},
|
||||
en: {
|
||||
openVideo: 'Open Video',
|
||||
dropTitle: 'Drop a video file here',
|
||||
dropHint: 'MP4, MKV, MOV, AVI, WebM — or click "Open Video"',
|
||||
ffmpegOk: 'FFmpeg ready',
|
||||
ffmpegMissing: 'FFmpeg missing (locate)',
|
||||
setStart: 'Set In [I]',
|
||||
setEnd: 'Set Out [O]',
|
||||
loopTrim: 'Loop Trim',
|
||||
previewMix: 'Mix Audio in Preview',
|
||||
proxyLoading: 'Preparing compatible preview...',
|
||||
trimStart: 'Start (ss)',
|
||||
trimEnd: 'End (to)',
|
||||
clipDur: 'Selection',
|
||||
outDur: 'Output (w/ speed)',
|
||||
resetTrim: 'Reset',
|
||||
sectionVideo: 'Video & Quality',
|
||||
encoder: 'Encoder',
|
||||
hwDecode: 'HW Decode',
|
||||
crf: 'Quality (CRF / CQ)',
|
||||
preset: 'Preset',
|
||||
tune: 'Tune',
|
||||
fps: 'FPS',
|
||||
fpsOriginal: 'Original',
|
||||
sectionSpeed: 'Playback Speed',
|
||||
sectionAudio: 'Audio Streams',
|
||||
audioMerge: 'Merge Tracks (amix)',
|
||||
audioTrack: 'Single Track',
|
||||
audioMute: 'Mute (-an)',
|
||||
noAudio: 'No audio streams in source',
|
||||
sectionCrop: 'Frame Crop',
|
||||
cropEnable: 'Enable spatial crop',
|
||||
sectionOutput: 'Output File',
|
||||
browse: 'Browse...',
|
||||
showCli: 'FFmpeg CLI',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
convertBtn: 'Convert Video',
|
||||
cancelBtn: 'Cancel',
|
||||
doneTitle: 'Conversion Complete!',
|
||||
openFolder: 'Show in Folder',
|
||||
playOutput: 'Open File',
|
||||
crfHintBest: 'Near lossless',
|
||||
crfHintBal: 'Sweet spot',
|
||||
crfHintComp: 'Compact size',
|
||||
crfHintSmall: 'Heavy compression',
|
||||
eta: 'ETA',
|
||||
},
|
||||
} as const;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,684 @@
|
||||
:root {
|
||||
--bg: #0b0f17;
|
||||
--panel: #111827;
|
||||
--panel-2: #161f30;
|
||||
--border: #243044;
|
||||
--text: #f1f5f9;
|
||||
--muted: #94a3b8;
|
||||
--cyan: #06b6d4;
|
||||
--cyan-glow: rgba(6, 182, 212, 0.25);
|
||||
--violet: #8b5cf6;
|
||||
--green: #10b981;
|
||||
--red: #ef4444;
|
||||
--amber: #f59e0b;
|
||||
--radius: 10px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Top Bar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, var(--cyan), var(--violet));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.topbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-pill strong {
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
padding: 6px 12px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: all 0.14s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--cyan);
|
||||
background: #1c283d;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, #0891b2, #4f46e5);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
button.pill-active {
|
||||
background: var(--cyan-glow);
|
||||
border-color: var(--cyan);
|
||||
color: #cffafe;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 11px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.status-pill.warn {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
/* Main Layout */
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 390px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Left Stage */
|
||||
.stage-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
background: #05070b;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-stage {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: rgba(11, 15, 23, 0.85);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-overlay.drag-over {
|
||||
border-color: var(--cyan);
|
||||
background: rgba(6, 182, 212, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.drop-overlay h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.proxy-banner {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid var(--cyan);
|
||||
color: #cffafe;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Crop Overlay */
|
||||
.crop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--cyan);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.58);
|
||||
cursor: move;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--cyan);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.crop-handle.nw { top: -6px; left: -6px; cursor: nwse-resize; }
|
||||
.crop-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
|
||||
.crop-handle.sw { bottom: -6px; left: -6px; cursor: nesw-resize; }
|
||||
.crop-handle.se { bottom: -6px; right: -6px; cursor: nwse-resize; }
|
||||
|
||||
/* Transport Bar */
|
||||
.transport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.transport-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-readout {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Timeline & Trim */
|
||||
.timeline-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
position: relative;
|
||||
height: 54px;
|
||||
background: #070a10;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filmstrip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.filmstrip img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trim-dim-left,
|
||||
.trim-dim-right {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(5, 7, 11, 0.72);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trim-dim-left { left: 0; border-radius: 6px 0 0 6px; }
|
||||
.trim-dim-right { right: 0; border-radius: 0 6px 6px 0; }
|
||||
|
||||
.trim-selection {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-top: 2px solid var(--cyan);
|
||||
border-bottom: 2px solid var(--cyan);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 12px;
|
||||
transform: translateX(-50%);
|
||||
cursor: ew-resize;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.trim-handle::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.trim-handle.start::before {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 8px var(--cyan);
|
||||
}
|
||||
|
||||
.trim-handle.end::before {
|
||||
background: var(--violet);
|
||||
box-shadow: 0 0 8px var(--violet);
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
bottom: -5px;
|
||||
width: 2px;
|
||||
background: #fff;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.playhead::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -4px;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #fff;
|
||||
}
|
||||
|
||||
.trim-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field-mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.field-mini label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stat-chip {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 9px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Right Settings Panel */
|
||||
.settings-col {
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='number'],
|
||||
select {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type='text']:focus,
|
||||
input[type='number']:focus,
|
||||
select:focus {
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
|
||||
input.mono {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: var(--cyan);
|
||||
}
|
||||
|
||||
.pill-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cli-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: #a5f3fc;
|
||||
word-break: break-all;
|
||||
max-height: 90px;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Sticky Bottom Action */
|
||||
.action-footer {
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
height: 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--cyan), var(--violet));
|
||||
transition: width 0.15s linear;
|
||||
}
|
||||
|
||||
.progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.done-banner {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border: 1px solid rgba(16, 185, 129, 0.45);
|
||||
border-radius: 8px;
|
||||
padding: 9px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.45);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: #fca5a5;
|
||||
font-size: 11px;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatTimecode, parseTimecode, formatBytes, clampTrimRange } from './timecode.ts';
|
||||
|
||||
describe('timecode helpers', () => {
|
||||
it('formats seconds to HH:MM:SS.mmm', () => {
|
||||
assert.equal(formatTimecode(0), '00:00:00.000');
|
||||
assert.equal(formatTimecode(65.25), '00:01:05.250');
|
||||
assert.equal(formatTimecode(3661.5), '01:01:01.500');
|
||||
assert.equal(formatTimecode(125, false), '02:05');
|
||||
});
|
||||
|
||||
it('parses numeric seconds and HH:MM:SS.mmm', () => {
|
||||
assert.equal(parseTimecode('42.5'), 42.5);
|
||||
assert.equal(parseTimecode('01:05.250'), 65.25);
|
||||
assert.equal(parseTimecode('01:02:03.5'), 3723.5);
|
||||
assert.equal(parseTimecode('invalid'), null);
|
||||
assert.equal(parseTimecode('00:75:00'), null);
|
||||
});
|
||||
|
||||
it('clamps trim range cleanly', () => {
|
||||
assert.deepEqual(clampTrimRange(-5, 200, 60), { start: 0, end: 60 });
|
||||
assert.deepEqual(clampTrimRange(30, 30, 60, 0.5), { start: 30, end: 30.5 });
|
||||
assert.deepEqual(clampTrimRange(59.9, 60, 60, 0.5), { start: 59.5, end: 60 });
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
assert.equal(formatBytes(0), '0 B');
|
||||
assert.equal(formatBytes(1024 * 1024 * 25.4), '25.4 MB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
export function formatTimecode(seconds: number, includeMillis = true): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
|
||||
const totalMs = Math.round(seconds * 1000);
|
||||
const ms = totalMs % 1000;
|
||||
const totalSec = Math.floor(totalMs / 1000);
|
||||
const s = totalSec % 60;
|
||||
const totalMin = Math.floor(totalSec / 60);
|
||||
const m = totalMin % 60;
|
||||
const h = Math.floor(totalMin / 60);
|
||||
|
||||
const hh = String(h).padStart(2, '0');
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const ss = String(s).padStart(2, '0');
|
||||
if (!includeMillis) {
|
||||
return h > 0 ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
}
|
||||
const mmm = String(ms).padStart(3, '0');
|
||||
return `${hh}:${mm}:${ss}.${mmm}`;
|
||||
}
|
||||
|
||||
export function parseTimecode(input: string): number | null {
|
||||
const s = input.trim();
|
||||
if (!s) return null;
|
||||
|
||||
if (/^\d+(\.\d+)?$/.test(s)) {
|
||||
const val = Number(s);
|
||||
return Number.isFinite(val) && val >= 0 ? val : null;
|
||||
}
|
||||
|
||||
const parts = s.split(':');
|
||||
if (parts.length < 2 || parts.length > 3) return null;
|
||||
|
||||
let h = 0;
|
||||
let m = 0;
|
||||
let sec = 0;
|
||||
if (parts.length === 2) {
|
||||
m = Number(parts[0]);
|
||||
sec = Number(parts[1]);
|
||||
} else {
|
||||
h = Number(parts[0]);
|
||||
m = Number(parts[1]);
|
||||
sec = Number(parts[2]);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isFinite(h) ||
|
||||
!Number.isFinite(m) ||
|
||||
!Number.isFinite(sec) ||
|
||||
h < 0 ||
|
||||
m < 0 ||
|
||||
m >= 60 ||
|
||||
sec < 0 ||
|
||||
sec >= 60
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return h * 3600 + m * 60 + sec;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let idx = 0;
|
||||
let val = bytes;
|
||||
while (val >= 1024 && idx < units.length - 1) {
|
||||
val /= 1024;
|
||||
idx++;
|
||||
}
|
||||
return `${val >= 100 || idx === 0 ? val.toFixed(0) : val.toFixed(1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
export function clampTrimRange(
|
||||
start: number,
|
||||
end: number,
|
||||
duration: number,
|
||||
minGap = 0.05
|
||||
): { start: number; end: number } {
|
||||
const dur = Math.max(0, duration);
|
||||
let s = Math.max(0, Math.min(start, dur));
|
||||
let e = Math.max(0, Math.min(end, dur));
|
||||
if (dur <= minGap) {
|
||||
return { start: 0, end: dur };
|
||||
}
|
||||
if (e - s < minGap) {
|
||||
if (s + minGap <= dur) {
|
||||
e = s + minGap;
|
||||
} else {
|
||||
s = Math.max(0, dur - minGap);
|
||||
e = dur;
|
||||
}
|
||||
}
|
||||
return { start: s, end: e };
|
||||
}
|
||||
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
export interface AudioStream {
|
||||
index: number;
|
||||
audioOrder: number;
|
||||
codec: string;
|
||||
channels: number;
|
||||
channelLayout: string;
|
||||
sampleRate: string;
|
||||
language: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
path: string;
|
||||
fileName: string;
|
||||
extension: string;
|
||||
container: string;
|
||||
sizeBytes: number;
|
||||
duration: number;
|
||||
bitRate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
videoCodec: string;
|
||||
pixFmt: string;
|
||||
audioStreams: AudioStream[];
|
||||
browserNative: boolean;
|
||||
}
|
||||
|
||||
export interface Binaries {
|
||||
ffmpeg: string;
|
||||
ffprobe: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
language: 'ru' | 'en' | string;
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
defaultEncoder: string;
|
||||
defaultHwDecode: string;
|
||||
defaultPreset: string;
|
||||
defaultCrf: number;
|
||||
defaultFps: number;
|
||||
defaultTune: string;
|
||||
defaultMergeAudio: boolean;
|
||||
outputSuffix: string;
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
version: string;
|
||||
binaries: Binaries;
|
||||
config: AppConfig;
|
||||
initialFile: string;
|
||||
}
|
||||
|
||||
export interface LoadedVideo {
|
||||
info: VideoInfo;
|
||||
videoId: string;
|
||||
previewUrl: string;
|
||||
proxyUrl: string;
|
||||
proxyMergedUrl: string;
|
||||
thumbBaseUrl: string;
|
||||
defaultOutputPath: string;
|
||||
}
|
||||
|
||||
export interface CropRect {
|
||||
enabled: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface ConvertRequest {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
encoder: string;
|
||||
hwDecode: string;
|
||||
preset: string;
|
||||
crf: number;
|
||||
tune: string;
|
||||
fps: number;
|
||||
speed: number;
|
||||
audioMode: 'merge' | 'track' | 'mute';
|
||||
audioTrack: number;
|
||||
selectedTracks: number[];
|
||||
crop: CropRect;
|
||||
fastStart: boolean;
|
||||
overwrite: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressEvent {
|
||||
percent: number;
|
||||
outTimeSec: number;
|
||||
totalSec: number;
|
||||
fps: number;
|
||||
speed: string;
|
||||
totalSize: number;
|
||||
etaSec: number;
|
||||
done: boolean;
|
||||
error?: string;
|
||||
outputPath?: string;
|
||||
outputBytes?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
go?: {
|
||||
main?: {
|
||||
App?: {
|
||||
GetAppInfo(): Promise<AppInfo>;
|
||||
RefreshBinaries(): Promise<Binaries>;
|
||||
SaveConfig(cfg: AppConfig): Promise<AppInfo>;
|
||||
OpenVideoDialog(): Promise<string>;
|
||||
SaveVideoDialog(defaultPath: string): Promise<string>;
|
||||
SelectFFmpegDialog(): Promise<AppInfo>;
|
||||
LoadVideo(path: string): Promise<LoadedVideo>;
|
||||
PreviewCommand(req: ConvertRequest): Promise<string>;
|
||||
StartConvert(req: ConvertRequest): Promise<void>;
|
||||
CancelConvert(): Promise<boolean>;
|
||||
OpenInExplorer(path: string): Promise<void>;
|
||||
OpenOutputFile(path: string): Promise<void>;
|
||||
};
|
||||
};
|
||||
};
|
||||
runtime?: {
|
||||
EventsOn(eventName: string, callback: (...args: any[]) => void): () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
target: 'es2022',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user