wa-dev-tools/public/video.html

1169 lines
48 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WA Dev Tools — Видео конвертер</title>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'video';</script>
<script src="/shared.js?v=3"></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" style="text-align:center;">
<div class="inline-flex items-center gap-2 mb-3 px-3 py-1 rounded-full border dark:border-surface-600 border-gray-300 dark:bg-surface-800/60 bg-white/60 text-xs font-mono dark:text-gray-400 text-gray-500 tracking-wide">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
MP4 · WebM · AVI · MKV · GIF
</div>
<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 — до <span class="max-size-hint">200 МБ</span></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" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Выходной формат</span>
<div class="btn-group" id="compressFormats">
<button class="btn-option selected" data-value="mp4">MP4</button>
<button class="btn-option" data-value="avi">AVI</button>
<button class="btn-option" data-value="mkv">MKV</button>
</div>
</div>
</div>
<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;
var formatMult = { mp4: 1.0, avi: 1.35, mkv: 1.05 };
var fm = formatMult[state.compressFormat || 'mp4'] || 1.0;
var ratios = { high: 0.70, medium: 0.40, low: 0.20 };
for (var q in ratios) {
var el = document.getElementById('est-' + q);
if (el) el.textContent = '~' + formatSize(state.originalSize * ratios[q] * fm);
}
}
// ─── 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'];
let MAX_SIZE = 200 * 1024 * 1024;
// Admin gets 1GB limit
(function(){ fetch('/auth/me').then(function(r){return r.ok?r.json():null}).then(function(u){
if(u&&u.role==='admin'){MAX_SIZE=1024*1024*1024;document.querySelectorAll('.max-size-hint').forEach(function(e){e.textContent='1 ГБ'})}
}).catch(function(){}); })();
if (file.size > MAX_SIZE) {
showError('Файл слишком большой. Максимальный размер — '+(MAX_SIZE>=1024*1024*1024?'1 ГБ':'200 МБ')+'.');
return;
}
// Show uploading state with progress
dropContent.innerHTML = `
<div class="uploading-overlay">
<div class="drop-label">Загрузка файла...</div>
<div style="font-size:24px;font-weight:800;color:var(--accent);font-family:'JetBrains Mono',monospace;" id="uploadPercent">0%</div>
<div class="progress-bar-track" style="width:100%;max-width:300px;">
<div class="progress-bar-fill" id="uploadFill" style="width:0%"></div>
</div>
<div class="drop-hint" style="margin-top:8px;">${file.name} (${formatSize(file.size)})</div>
</div>`;
const formData = new FormData();
formData.append('video', file);
try {
const data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/video/upload');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
const el = document.getElementById('uploadPercent');
const fill = document.getElementById('uploadFill');
if (el) el.textContent = pct + '%';
if (fill) fill.style.width = pct + '%';
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error('Ошибка разбора ответа')); }
} else {
try { reject(new Error(JSON.parse(xhr.responseText).error)); }
catch { reject(new Error('Ошибка загрузки')); }
}
};
xhr.onerror = () => reject(new Error('Сеть недоступна'));
xhr.send(formData);
});
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 — до <span class="max-size-hint">200 МБ</span></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; });
initBtnGroup('compressFormats', val => { state.compressFormat = val; updateEstimates(); });
// ─── 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 === 'compress' ? (state.compressFormat || 'mp4')
: 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 || 'Ошибка запуска');
}
// Server starts processing in background, poll for progress
startPolling();
} catch (err) {
showConversionError(err.message || 'Ошибка конвертации');
}
}
function startPolling() {
clearPollTimer();
subscribeWS(state.jobId);
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
}
}, wsConnected ? 3000 : 1000);
}
function clearPollTimer() {
if (state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
// ─── WebSocket progress (upgrade from polling) ─────────────────────────
let wsConn = null;
let wsConnected = false;
function connectWS() {
try {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
wsConn = new WebSocket(proto + '//' + location.host + '/ws');
wsConn.onopen = () => { wsConnected = true; };
wsConn.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.jobId !== state.jobId) return;
if (data.type === 'progress') {
const pct = Math.min(99, Math.max(0, data.progress || 0));
setProgressUI(pct, 'Обработка... ' + pct + '%');
} else if (data.type === 'done') {
clearPollTimer();
onConversionDone(data);
} else if (data.type === 'error') {
clearPollTimer();
showConversionError(data.error || 'Ошибка обработки');
}
} catch {}
};
wsConn.onclose = () => { wsConnected = false; };
wsConn.onerror = () => { wsConnected = false; };
} catch { wsConnected = false; }
}
function subscribeWS(jobId) {
if (wsConn && wsConn.readyState === 1) {
wsConn.send(JSON.stringify({ type: 'subscribe', jobId }));
}
}
// Connect WS on load
connectWS();
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 — до <span class="max-size-hint">200 МБ</span></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>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</body>
</html>