- New tool: /video with 4 modes (convert formats, compress, extract audio, GIF) - Backend: ffmpeg-based processing with progress tracking - Supports MP4, WebM, MOV, AVI, MKV input, up to 200MB - Frontend: drag-drop upload, mode tabs, progress bar, download - Added to sidebar, dashboard (Images category), WA_CATEGORIES - Total tools: 14
1083 lines
43 KiB
HTML
1083 lines
43 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>WA Dev Tools — Видео конвертер</title>
|
||
<script src="/vendor/tailwind.js"></script>
|
||
<script>window.WA_TOOL_ID = 'video';</script>
|
||
<script src="/shared.js"></script>
|
||
<link href="/vendor/fonts.css" rel="stylesheet">
|
||
<link href="/shared.css" rel="stylesheet">
|
||
<style>
|
||
/* Drop zone */
|
||
.drop-zone {
|
||
border: 2px dashed var(--surface-600);
|
||
border-radius: 16px;
|
||
padding: 48px 24px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color .2s, background .2s;
|
||
background: var(--surface-700);
|
||
}
|
||
.drop-zone:hover,
|
||
.drop-zone.drag-over {
|
||
border-color: var(--accent);
|
||
background: var(--accent-bg);
|
||
}
|
||
.drop-zone.drag-over .drop-icon {
|
||
color: var(--accent);
|
||
}
|
||
.drop-icon {
|
||
color: var(--text-muted);
|
||
transition: color .2s;
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
.drop-label {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: 6px;
|
||
}
|
||
.drop-hint {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
/* Video info card */
|
||
.info-card {
|
||
background: var(--surface-700);
|
||
border: 1px solid var(--surface-600);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
}
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px 24px;
|
||
}
|
||
.info-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.info-key {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.info-val {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
.info-filename {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
margin-bottom: 14px;
|
||
word-break: break-all;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.info-filename-icon {
|
||
flex-shrink: 0;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Mode tabs */
|
||
.mode-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
background: var(--surface-700);
|
||
border: 1px solid var(--surface-600);
|
||
border-radius: 10px;
|
||
padding: 4px;
|
||
}
|
||
.mode-tab {
|
||
flex: 1;
|
||
padding: 9px 12px;
|
||
border-radius: 7px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-muted);
|
||
font-family: 'Manrope', sans-serif;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
white-space: nowrap;
|
||
}
|
||
.mode-tab:hover {
|
||
color: var(--text-secondary);
|
||
background: var(--surface-600);
|
||
}
|
||
.mode-tab.active {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
}
|
||
.mode-tab:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Mode panels */
|
||
.mode-panel { display: none; }
|
||
.mode-panel.active { display: block; }
|
||
|
||
/* Option group */
|
||
.option-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.option-label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
/* Button group (format selector) */
|
||
.btn-group {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.btn-option {
|
||
padding: 8px 18px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--surface-600);
|
||
background: var(--surface-700);
|
||
color: var(--text-secondary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
}
|
||
.btn-option:hover {
|
||
border-color: var(--accent);
|
||
color: var(--text-primary);
|
||
}
|
||
.btn-option.selected {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
.btn-option:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Quality row (with estimated size) */
|
||
.quality-option {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--surface-600);
|
||
background: var(--surface-700);
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
}
|
||
.quality-option:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
.quality-option.selected {
|
||
border-color: var(--accent);
|
||
background: var(--accent-bg);
|
||
}
|
||
.quality-option input[type="radio"] {
|
||
display: none;
|
||
}
|
||
.quality-label {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
.quality-desc {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
margin-top: 2px;
|
||
}
|
||
.quality-estimate {
|
||
font-size: 12px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: var(--accent-bright);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Time range inputs (GIF mode) */
|
||
.time-inputs {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
.time-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.time-input-label {
|
||
font-size: 11px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.gif-note {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
background: var(--surface-700);
|
||
border: 1px solid var(--surface-600);
|
||
border-radius: 8px;
|
||
padding: 10px 14px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Progress */
|
||
.progress-wrap {
|
||
background: var(--surface-700);
|
||
border: 1px solid var(--surface-600);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
}
|
||
.progress-bar-track {
|
||
background: var(--surface-600);
|
||
border-radius: 99px;
|
||
height: 8px;
|
||
overflow: hidden;
|
||
margin: 14px 0 10px;
|
||
}
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
border-radius: 99px;
|
||
transition: width .3s ease;
|
||
width: 0%;
|
||
}
|
||
.progress-status {
|
||
font-size: 13px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: var(--text-secondary);
|
||
}
|
||
.progress-percent {
|
||
font-size: 13px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* Result card */
|
||
.result-card {
|
||
background: var(--surface-700);
|
||
border: 1px solid var(--surface-600);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
}
|
||
.result-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.result-stat {
|
||
text-align: center;
|
||
}
|
||
.result-stat-val {
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
color: var(--text-primary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
.result-stat-val.accent {
|
||
color: var(--accent);
|
||
}
|
||
.result-stat-val.green {
|
||
color: #22c55e;
|
||
}
|
||
.result-stat-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Error */
|
||
.error-box {
|
||
background: rgba(255, 82, 82, 0.08);
|
||
border: 1px solid rgba(255, 82, 82, 0.25);
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
font-size: 13px;
|
||
color: #ff5252;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
/* Separator */
|
||
.step-separator {
|
||
height: 1px;
|
||
background: var(--surface-600);
|
||
margin: 4px 0;
|
||
}
|
||
|
||
/* Upload progress overlay in drop zone */
|
||
.uploading-overlay {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.spinner {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 3px solid var(--surface-600);
|
||
border-top-color: var(--accent);
|
||
border-radius: 50%;
|
||
animation: spin .7s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* Section labels */
|
||
.section-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.section-num {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 50%;
|
||
background: var(--accent-bg);
|
||
color: var(--accent);
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.mode-tabs { flex-wrap: wrap; }
|
||
.mode-tab { flex: 1 1 calc(50% - 4px); }
|
||
.result-stats { grid-template-columns: 1fr 1fr; }
|
||
.result-stats .result-stat:last-child { grid-column: 1 / -1; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="font-sans antialiased">
|
||
|
||
<div class="main-content">
|
||
<div class="tool-container max-w-3xl mx-auto">
|
||
|
||
<!-- Page header -->
|
||
<div class="page-header">
|
||
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
|
||
Видео <span class="text-accent">конвертер</span>
|
||
</h1>
|
||
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">конвертация, сжатие, извлечение аудио, GIF</p>
|
||
</div>
|
||
|
||
<!-- Error display -->
|
||
<div id="errorBox" class="error-box" style="display:none; margin-bottom:20px;"></div>
|
||
|
||
<!-- Step 1: Upload -->
|
||
<div id="stepUpload" style="margin-bottom:24px;">
|
||
<div class="section-title">
|
||
<span class="section-num">1</span>
|
||
Загрузка файла
|
||
</div>
|
||
|
||
<!-- Drop zone (default state) -->
|
||
<div id="dropZone" class="drop-zone" tabindex="0" role="button" aria-label="Загрузить видео">
|
||
<div id="dropZoneContent">
|
||
<div class="drop-icon">
|
||
<svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="drop-label">Перетащите видео или нажмите для выбора</div>
|
||
<div class="drop-hint" style="margin-top:6px;">MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до 200 МБ</div>
|
||
</div>
|
||
<input type="file" id="fileInput" accept=".mp4,.webm,.mov,.avi,.mkv,.mpeg,.mpg,.3gp,.ogg,.flv,.wmv" style="display:none;" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2+: shown after upload -->
|
||
<div id="stepConfig" style="display:none;">
|
||
|
||
<!-- Video info card -->
|
||
<div style="margin-bottom:24px;">
|
||
<div class="section-title">
|
||
<span class="section-num">1</span>
|
||
Информация о файле
|
||
</div>
|
||
<div class="info-card">
|
||
<div class="info-filename">
|
||
<svg class="info-filename-icon" width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"/>
|
||
</svg>
|
||
<span id="infoFilename"></span>
|
||
</div>
|
||
<div class="info-grid">
|
||
<div class="info-row">
|
||
<span class="info-key">Размер</span>
|
||
<span class="info-val" id="infoSize">—</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-key">Длительность</span>
|
||
<span class="info-val" id="infoDuration">—</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-key">Разрешение</span>
|
||
<span class="info-val" id="infoResolution">—</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-key">Видео кодек</span>
|
||
<span class="info-val" id="infoCodec">—</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-key">Аудио кодек</span>
|
||
<span class="info-val" id="infoAudioCodec">—</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-key">Битрейт</span>
|
||
<span class="info-val" id="infoBitrate">—</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:14px; padding-top:14px; border-top:1px solid var(--surface-600);">
|
||
<button id="btnChangeFile" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);font-size:12px;padding:7px 14px;">
|
||
Выбрать другой файл
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode selection -->
|
||
<div style="margin-bottom:24px;">
|
||
<div class="section-title">
|
||
<span class="section-num">2</span>
|
||
Режим обработки
|
||
</div>
|
||
<div class="mode-tabs" role="tablist">
|
||
<button class="mode-tab active" data-mode="convert" role="tab" aria-selected="true">Конвертация</button>
|
||
<button class="mode-tab" data-mode="compress" role="tab" aria-selected="false">Сжатие</button>
|
||
<button class="mode-tab" data-mode="audio" role="tab" aria-selected="false">Аудио</button>
|
||
<button class="mode-tab" data-mode="gif" role="tab" aria-selected="false">GIF</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode panel: Конвертация -->
|
||
<div id="panel-convert" class="mode-panel active" style="margin-bottom:24px;">
|
||
<div class="wa-card">
|
||
<div class="option-group">
|
||
<span class="option-label">Выходной формат</span>
|
||
<div class="btn-group" id="convertFormats">
|
||
<button class="btn-option selected" data-value="mp4">MP4</button>
|
||
<button class="btn-option" data-value="webm">WebM</button>
|
||
<button class="btn-option" data-value="avi">AVI</button>
|
||
<button class="btn-option" data-value="mkv">MKV</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode panel: Сжатие -->
|
||
<div id="panel-compress" class="mode-panel" style="margin-bottom:24px;">
|
||
<div class="wa-card">
|
||
<div class="option-group">
|
||
<span class="option-label">Качество</span>
|
||
<label class="quality-option selected" data-value="high">
|
||
<input type="radio" name="quality" value="high" checked />
|
||
<div>
|
||
<div class="quality-label">Высокое</div>
|
||
<div class="quality-desc">минимальные потери, ~70% от оригинала</div>
|
||
</div>
|
||
<div class="quality-estimate" id="est-high">—</div>
|
||
</label>
|
||
<label class="quality-option" data-value="medium">
|
||
<input type="radio" name="quality" value="medium" />
|
||
<div>
|
||
<div class="quality-label">Среднее</div>
|
||
<div class="quality-desc">баланс качества и размера, ~40%</div>
|
||
</div>
|
||
<div class="quality-estimate" id="est-medium">—</div>
|
||
</label>
|
||
<label class="quality-option" data-value="low">
|
||
<input type="radio" name="quality" value="low" />
|
||
<div>
|
||
<div class="quality-label">Низкое</div>
|
||
<div class="quality-desc">максимальное сжатие, ~20%</div>
|
||
</div>
|
||
<div class="quality-estimate" id="est-low">—</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode panel: Аудио -->
|
||
<div id="panel-audio" class="mode-panel" style="margin-bottom:24px;">
|
||
<div class="wa-card">
|
||
<div class="option-group">
|
||
<span class="option-label">Формат аудио</span>
|
||
<div class="btn-group" id="audioFormats">
|
||
<button class="btn-option selected" data-value="mp3">MP3</button>
|
||
<button class="btn-option" data-value="aac">AAC</button>
|
||
</div>
|
||
<div class="gif-note" style="margin-top:8px;">
|
||
Аудиодорожка будет извлечена из видео без перекодирования, где возможно.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode panel: GIF -->
|
||
<div id="panel-gif" class="mode-panel" style="margin-bottom:24px;">
|
||
<div class="wa-card">
|
||
<div class="option-group">
|
||
<span class="option-label">Временной диапазон</span>
|
||
<div class="time-inputs">
|
||
<div class="time-field">
|
||
<span class="time-input-label">Начало (сек)</span>
|
||
<input type="number" id="gifStart" class="wa-input" min="0" step="0.1" value="0" placeholder="0" />
|
||
</div>
|
||
<div class="time-field">
|
||
<span class="time-input-label">Конец (сек)</span>
|
||
<input type="number" id="gifEnd" class="wa-input" min="0" step="0.1" placeholder="авто" />
|
||
</div>
|
||
</div>
|
||
<div class="gif-note">
|
||
Максимальная длительность GIF — 15 секунд. Разрешение уменьшается до 480px по ширине для оптимального размера.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Convert button + progress -->
|
||
<div style="margin-bottom:24px;">
|
||
<div class="section-title">
|
||
<span class="section-num">3</span>
|
||
Обработка
|
||
</div>
|
||
|
||
<!-- Convert button -->
|
||
<div id="convertAction">
|
||
<button id="btnConvert" class="wa-btn" style="width:100%;padding:14px 24px;font-size:15px;">
|
||
Конвертировать
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Progress -->
|
||
<div id="progressWrap" class="progress-wrap" style="display:none;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||
<span class="progress-status" id="progressStatus">Подготовка...</span>
|
||
<span class="progress-percent" id="progressPercent">0%</span>
|
||
</div>
|
||
<div class="progress-bar-track">
|
||
<div class="progress-bar-fill" id="progressFill"></div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;">
|
||
Не закрывайте вкладку во время обработки
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 4: Result -->
|
||
<div id="stepResult" style="display:none;">
|
||
<div class="section-title">
|
||
<span class="section-num">4</span>
|
||
Результат
|
||
</div>
|
||
<div class="result-card">
|
||
<div class="result-stats">
|
||
<div class="result-stat">
|
||
<div class="result-stat-val" id="resultSize">—</div>
|
||
<div class="result-stat-label">размер файла</div>
|
||
</div>
|
||
<div class="result-stat">
|
||
<div class="result-stat-val green" id="resultSavings">—</div>
|
||
<div class="result-stat-label">экономия</div>
|
||
</div>
|
||
<div class="result-stat">
|
||
<div class="result-stat-val accent" id="resultDuration">—</div>
|
||
<div class="result-stat-label">длительность</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<a id="downloadLink" href="#" class="wa-btn" style="flex:1;text-align:center;text-decoration:none;padding:12px 24px;">
|
||
Скачать файл
|
||
</a>
|
||
<button id="btnReset" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);flex:0 0 auto;padding:12px 18px;">
|
||
Конвертировать ещё
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /stepConfig -->
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ─── State ───────────────────────────────────────────────────────────────
|
||
let state = {
|
||
jobId: null,
|
||
originalSize: 0,
|
||
duration: 0,
|
||
mode: 'convert',
|
||
format: 'mp4',
|
||
quality: 'high',
|
||
audioFormat: 'mp3',
|
||
pollTimer: null,
|
||
};
|
||
|
||
// ─── DOM refs ─────────────────────────────────────────────────────────────
|
||
const dropZone = document.getElementById('dropZone');
|
||
const dropContent = document.getElementById('dropZoneContent');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const stepUpload = document.getElementById('stepUpload');
|
||
const stepConfig = document.getElementById('stepConfig');
|
||
const stepResult = document.getElementById('stepResult');
|
||
const errorBox = document.getElementById('errorBox');
|
||
const btnConvert = document.getElementById('btnConvert');
|
||
const convertAction = document.getElementById('convertAction');
|
||
const progressWrap = document.getElementById('progressWrap');
|
||
const progressFill = document.getElementById('progressFill');
|
||
const progressStatus = document.getElementById('progressStatus');
|
||
const progressPercent= document.getElementById('progressPercent');
|
||
const downloadLink = document.getElementById('downloadLink');
|
||
const btnReset = document.getElementById('btnReset');
|
||
const btnChangeFile = document.getElementById('btnChangeFile');
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||
function formatSize(bytes) {
|
||
if (!bytes || bytes === 0) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||
const val = bytes / Math.pow(1024, i);
|
||
return val.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
if (!seconds && seconds !== 0) return '—';
|
||
const s = Math.floor(seconds);
|
||
const m = Math.floor(s / 60);
|
||
const h = Math.floor(m / 60);
|
||
const ss = String(s % 60).padStart(2, '0');
|
||
const mm = String(m % 60).padStart(2, '0');
|
||
if (h > 0) return h + ':' + mm + ':' + ss;
|
||
return mm + ':' + ss;
|
||
}
|
||
|
||
function formatBitrate(bps) {
|
||
if (!bps) return '—';
|
||
if (bps >= 1_000_000) return (bps / 1_000_000).toFixed(1) + ' Mbps';
|
||
return Math.round(bps / 1000) + ' kbps';
|
||
}
|
||
|
||
function showError(msg) {
|
||
errorBox.textContent = msg;
|
||
errorBox.style.display = 'block';
|
||
errorBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
|
||
function clearError() {
|
||
errorBox.style.display = 'none';
|
||
errorBox.textContent = '';
|
||
}
|
||
|
||
function setControlsDisabled(disabled) {
|
||
btnConvert.disabled = disabled;
|
||
document.querySelectorAll('.mode-tab').forEach(el => el.disabled = disabled);
|
||
document.querySelectorAll('.btn-option').forEach(el => el.disabled = disabled);
|
||
document.querySelectorAll('.quality-option').forEach(el => {
|
||
el.style.pointerEvents = disabled ? 'none' : '';
|
||
el.style.opacity = disabled ? '0.5' : '';
|
||
});
|
||
document.getElementById('gifStart').disabled = disabled;
|
||
document.getElementById('gifEnd').disabled = disabled;
|
||
}
|
||
|
||
// ─── Estimate sizes for compress mode ─────────────────────────────────────
|
||
function updateEstimates() {
|
||
if (!state.originalSize) return;
|
||
const ratios = { high: 0.70, medium: 0.40, low: 0.20 };
|
||
for (const [q, ratio] of Object.entries(ratios)) {
|
||
const el = document.getElementById('est-' + q);
|
||
if (el) el.textContent = '~' + formatSize(state.originalSize * ratio);
|
||
}
|
||
}
|
||
|
||
// ─── Drop zone ────────────────────────────────────────────────────────────
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
dropZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
|
||
|
||
dropZone.addEventListener('dragover', e => {
|
||
e.preventDefault();
|
||
dropZone.classList.add('drag-over');
|
||
});
|
||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||
dropZone.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('drag-over');
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) handleFile(file);
|
||
});
|
||
|
||
fileInput.addEventListener('change', () => {
|
||
if (fileInput.files[0]) handleFile(fileInput.files[0]);
|
||
});
|
||
|
||
btnChangeFile.addEventListener('click', () => {
|
||
resetToUpload();
|
||
fileInput.click();
|
||
});
|
||
|
||
// ─── Upload ───────────────────────────────────────────────────────────────
|
||
async function handleFile(file) {
|
||
clearError();
|
||
|
||
const ACCEPTED = ['video/mp4','video/webm','video/quicktime','video/x-msvideo','video/x-matroska',
|
||
'video/mpeg','video/3gpp','video/ogg','video/x-flv','video/x-ms-wmv'];
|
||
const MAX_SIZE = 200 * 1024 * 1024;
|
||
|
||
if (file.size > MAX_SIZE) {
|
||
showError('Файл слишком большой. Максимальный размер — 200 МБ.');
|
||
return;
|
||
}
|
||
|
||
// Show uploading state
|
||
dropContent.innerHTML = `
|
||
<div class="uploading-overlay">
|
||
<div class="spinner"></div>
|
||
<div class="drop-label">Загрузка файла...</div>
|
||
<div class="drop-hint">${file.name}</div>
|
||
</div>`;
|
||
|
||
const formData = new FormData();
|
||
formData.append('video', file);
|
||
|
||
try {
|
||
const res = await fetch('/video/upload', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({ error: 'Ошибка загрузки' }));
|
||
throw new Error(err.error || 'Ошибка загрузки файла');
|
||
}
|
||
|
||
const data = await res.json();
|
||
onUploadSuccess(data);
|
||
} catch (err) {
|
||
// Restore drop zone
|
||
dropContent.innerHTML = `
|
||
<div class="drop-icon">
|
||
<svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="drop-label">Перетащите видео или нажмите для выбора</div>
|
||
<div class="drop-hint" style="margin-top:6px;">MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до 200 МБ</div>`;
|
||
showError(err.message || 'Не удалось загрузить файл');
|
||
}
|
||
}
|
||
|
||
function onUploadSuccess(data) {
|
||
state.jobId = data.jobId;
|
||
state.originalSize = data.size || 0;
|
||
|
||
const info = data.info || {};
|
||
state.duration = info.duration || 0;
|
||
|
||
// Fill info card
|
||
document.getElementById('infoFilename').textContent = data.originalName || '—';
|
||
document.getElementById('infoSize').textContent = formatSize(data.size);
|
||
document.getElementById('infoDuration').textContent = formatDuration(info.duration);
|
||
document.getElementById('infoResolution').textContent =
|
||
(info.width && info.height) ? info.width + '×' + info.height : '—';
|
||
document.getElementById('infoCodec').textContent = info.codec || '—';
|
||
document.getElementById('infoAudioCodec').textContent = info.audioCodec || '—';
|
||
document.getElementById('infoBitrate').textContent = formatBitrate(info.bitrate);
|
||
|
||
// Update estimates
|
||
updateEstimates();
|
||
|
||
// Set GIF end default to min(duration, 15)
|
||
const gifEndEl = document.getElementById('gifEnd');
|
||
if (state.duration > 0) {
|
||
gifEndEl.value = Math.min(state.duration, 15).toFixed(1);
|
||
gifEndEl.max = state.duration;
|
||
document.getElementById('gifStart').max = state.duration;
|
||
}
|
||
|
||
// Switch views
|
||
stepConfig.style.display = 'block';
|
||
stepUpload.style.display = 'none';
|
||
}
|
||
|
||
// ─── Mode tabs ────────────────────────────────────────────────────────────
|
||
document.querySelectorAll('.mode-tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
const mode = tab.dataset.mode;
|
||
state.mode = mode;
|
||
|
||
document.querySelectorAll('.mode-tab').forEach(t => {
|
||
t.classList.remove('active');
|
||
t.setAttribute('aria-selected', 'false');
|
||
});
|
||
tab.classList.add('active');
|
||
tab.setAttribute('aria-selected', 'true');
|
||
|
||
document.querySelectorAll('.mode-panel').forEach(p => p.classList.remove('active'));
|
||
const panel = document.getElementById('panel-' + mode);
|
||
if (panel) panel.classList.add('active');
|
||
});
|
||
});
|
||
|
||
// ─── Format button groups ─────────────────────────────────────────────────
|
||
function initBtnGroup(containerId, onSelect) {
|
||
const container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
container.addEventListener('click', e => {
|
||
const btn = e.target.closest('.btn-option');
|
||
if (!btn || btn.disabled) return;
|
||
container.querySelectorAll('.btn-option').forEach(b => b.classList.remove('selected'));
|
||
btn.classList.add('selected');
|
||
onSelect(btn.dataset.value);
|
||
});
|
||
}
|
||
|
||
initBtnGroup('convertFormats', val => { state.format = val; });
|
||
initBtnGroup('audioFormats', val => { state.audioFormat = val; });
|
||
|
||
// ─── Quality options ───────────────────────────────────────────────────────
|
||
document.querySelectorAll('.quality-option').forEach(label => {
|
||
label.addEventListener('click', () => {
|
||
document.querySelectorAll('.quality-option').forEach(l => l.classList.remove('selected'));
|
||
label.classList.add('selected');
|
||
state.quality = label.dataset.value;
|
||
});
|
||
});
|
||
|
||
// ─── Convert ──────────────────────────────────────────────────────────────
|
||
btnConvert.addEventListener('click', startConversion);
|
||
|
||
async function startConversion() {
|
||
clearError();
|
||
setControlsDisabled(true);
|
||
|
||
convertAction.style.display = 'none';
|
||
progressWrap.style.display = 'block';
|
||
stepResult.style.display = 'none';
|
||
|
||
setProgressUI(0, 'Запуск обработки...');
|
||
|
||
const body = {
|
||
jobId: state.jobId,
|
||
mode: state.mode,
|
||
format: state.mode === 'convert' ? state.format
|
||
: state.mode === 'audio' ? state.audioFormat
|
||
: state.mode === 'gif' ? 'gif'
|
||
: 'mp4',
|
||
quality: state.quality,
|
||
startTime: state.mode === 'gif' ? parseFloat(document.getElementById('gifStart').value || 0) : undefined,
|
||
endTime: state.mode === 'gif' ? parseFloat(document.getElementById('gifEnd').value || 0) : undefined,
|
||
};
|
||
|
||
try {
|
||
const res = await fetch('/video/convert', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({ error: 'Ошибка запуска конвертации' }));
|
||
throw new Error(err.error || 'Ошибка запуска');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Server may return immediately (sync) or poll (async)
|
||
if (data.status === 'done' || data.downloadUrl) {
|
||
setProgressUI(100, 'Готово!');
|
||
onConversionDone(data);
|
||
} else {
|
||
startPolling();
|
||
}
|
||
} catch (err) {
|
||
showConversionError(err.message || 'Ошибка конвертации');
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
clearPollTimer();
|
||
state.pollTimer = setInterval(async () => {
|
||
try {
|
||
const res = await fetch('/video/progress/' + state.jobId);
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
|
||
const pct = Math.min(100, Math.max(0, data.progress || 0));
|
||
const statusMap = {
|
||
pending: 'В очереди...',
|
||
processing: 'Обработка...',
|
||
done: 'Готово!',
|
||
error: 'Ошибка',
|
||
};
|
||
setProgressUI(pct, (statusMap[data.status] || 'Обработка...') + (pct > 0 && pct < 100 ? ' ' + pct + '%' : ''));
|
||
|
||
if (data.status === 'done') {
|
||
clearPollTimer();
|
||
onConversionDone(data);
|
||
} else if (data.status === 'error') {
|
||
clearPollTimer();
|
||
showConversionError(data.error || 'Ошибка обработки');
|
||
}
|
||
} catch {
|
||
// Network hiccup — keep polling
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function clearPollTimer() {
|
||
if (state.pollTimer) {
|
||
clearInterval(state.pollTimer);
|
||
state.pollTimer = null;
|
||
}
|
||
}
|
||
|
||
function setProgressUI(percent, statusText) {
|
||
progressFill.style.width = percent + '%';
|
||
progressPercent.textContent = percent + '%';
|
||
progressStatus.textContent = statusText;
|
||
}
|
||
|
||
function onConversionDone(data) {
|
||
setProgressUI(100, 'Готово!');
|
||
|
||
// Fill result card
|
||
const outputSize = data.outputSize || 0;
|
||
const savings = data.savings || 0;
|
||
|
||
document.getElementById('resultSize').textContent = formatSize(outputSize);
|
||
document.getElementById('resultSavings').textContent = savings > 0 ? '−' + savings + '%' : '—';
|
||
document.getElementById('resultDuration').textContent = formatDuration(state.duration);
|
||
|
||
if (data.downloadUrl) {
|
||
downloadLink.href = data.downloadUrl;
|
||
downloadLink.setAttribute('download', '');
|
||
}
|
||
|
||
stepResult.style.display = 'block';
|
||
stepResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
setControlsDisabled(false);
|
||
}
|
||
|
||
function showConversionError(msg) {
|
||
clearPollTimer();
|
||
setControlsDisabled(false);
|
||
progressWrap.style.display = 'none';
|
||
convertAction.style.display = 'block';
|
||
showError(msg);
|
||
}
|
||
|
||
// ─── Reset ────────────────────────────────────────────────────────────────
|
||
btnReset.addEventListener('click', resetAll);
|
||
|
||
function resetToUpload() {
|
||
clearPollTimer();
|
||
state.jobId = null;
|
||
state.originalSize = 0;
|
||
state.duration = 0;
|
||
|
||
stepConfig.style.display = 'none';
|
||
stepResult.style.display = 'none';
|
||
stepUpload.style.display = 'block';
|
||
convertAction.style.display = 'block';
|
||
progressWrap.style.display = 'none';
|
||
|
||
setProgressUI(0, 'Подготовка...');
|
||
clearError();
|
||
fileInput.value = '';
|
||
|
||
// Reset drop zone content
|
||
dropContent.innerHTML = `
|
||
<div class="drop-icon">
|
||
<svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="drop-label">Перетащите видео или нажмите для выбора</div>
|
||
<div class="drop-hint" style="margin-top:6px;">MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до 200 МБ</div>`;
|
||
}
|
||
|
||
function resetAll() {
|
||
resetToUpload();
|
||
|
||
// Reset mode tabs
|
||
document.querySelectorAll('.mode-tab').forEach(t => {
|
||
t.classList.remove('active');
|
||
t.setAttribute('aria-selected', 'false');
|
||
});
|
||
document.querySelector('[data-mode="convert"]').classList.add('active');
|
||
document.querySelector('[data-mode="convert"]').setAttribute('aria-selected', 'true');
|
||
document.querySelectorAll('.mode-panel').forEach(p => p.classList.remove('active'));
|
||
document.getElementById('panel-convert').classList.add('active');
|
||
state.mode = 'convert';
|
||
|
||
// Reset format selections
|
||
document.querySelectorAll('#convertFormats .btn-option').forEach((b, i) => {
|
||
b.classList.toggle('selected', i === 0);
|
||
});
|
||
document.querySelectorAll('#audioFormats .btn-option').forEach((b, i) => {
|
||
b.classList.toggle('selected', i === 0);
|
||
});
|
||
state.format = 'mp4';
|
||
state.audioFormat = 'mp3';
|
||
|
||
// Reset quality
|
||
document.querySelectorAll('.quality-option').forEach((l, i) => {
|
||
l.classList.toggle('selected', i === 0);
|
||
});
|
||
document.querySelector('input[name="quality"][value="high"]').checked = true;
|
||
state.quality = 'high';
|
||
|
||
// Reset GIF fields
|
||
document.getElementById('gifStart').value = '0';
|
||
document.getElementById('gifEnd').value = '';
|
||
|
||
setControlsDisabled(false);
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|