diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a6ff04 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# nodex.pw converter + +конвертирует всё что возможно — картинки, видео, аудио, архивы +веб-интерфейс, drag & drop, ctrl+v, автоопределение форматов + +--- + +## что нужно + +- python 3.10+ +- ffmpeg (для видео и аудио, см. ниже) + +--- + +## установка + +``` +pip install -r requirements.txt +``` + +ffmpeg на **windows** — скачается сам при первом запуске (кнопка в шапке) +или вручную: + +``` +python app.py setup-ffmpeg +``` + +на **linux/mac**: + +``` +sudo apt install ffmpeg +# или +brew install ffmpeg +``` + +--- + +## запуск + +``` +python app.py +``` + +открывай `http://localhost:5000` + +порт можно поменять через `PORT=8080 python app.py` + +--- + +## что конвертирует + +**изображения** + +| формат | → | +|--------|---| +| jpg, png, webp, bmp, gif, tiff, ico, avif, heic | любой из этих | + +webp → png, heic → jpg, gif → webp — всё работает через pillow + +**видео** + +| формат | → | +|--------|---| +| mp4, avi, mov, mkv, webm, flv, wmv, m4v... | mp4, avi, mov, mkv, webm, gif | +| любое видео | mp3, wav, flac, aac, opus (вытащить аудио) | + +**аудио** + +| формат | → | +|--------|---| +| mp3, wav, flac, ogg, m4a, opus, aac, wma, aiff | любой из этих | + +**архивы** + +| формат | → | +|--------|---| +| zip, tar.gz, tar.bz2, tar.xz, 7z, rar | zip, tar.gz, tar.bz2, 7z | + +для rar нужен системный `unrar` — `apt install unrar` +7z работает без внешних зависимостей (py7zr) + +--- + +## как использовать + +1. открываешь браузер +2. кидаешь файл в зону или нажимаешь "выбери файлы" +3. ctrl+v — вставить картинку из буфера (скриншоты работают) +4. выбираешь целевой формат +5. жмёшь конвертировать, скачиваешь + +--- + +## структура + +``` +app.py — сервер flask, эндпоинты upload/convert/download +converter.py — логика конвертации (pillow + ffmpeg + архивы) +templates/ + index.html — фронт +static/ + style.css + app.js +uploads/ — временные загрузки +outputs/ — результаты +ffmpeg/ — ffmpeg.exe / ffprobe.exe (windows) +requirements.txt +``` + +--- + +## зависимости + +``` +flask — веб-сервер +pillow — изображения +py7zr — 7z архивы (чистый python) +rarfile — rar архивы (нужен системный unrar) +``` + +ffmpeg ставится отдельно — через пакетный менеджер или кнопку в интерфейсе diff --git a/app.py b/app.py new file mode 100644 index 0000000..41438bf --- /dev/null +++ b/app.py @@ -0,0 +1,163 @@ +import os +import sys +import uuid +import zipfile +import platform +import urllib.request +from pathlib import Path +from flask import Flask, render_template, request, jsonify, send_file +import converter + +FFMPEG_ZIP_URL = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.0.1-essentials_build.zip" +FFMPEG_EXES = {"ffmpeg.exe", "ffprobe.exe"} + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 * 1024 + +UPLOAD_DIR = Path("uploads") +OUTPUT_DIR = Path("outputs") +FFMPEG_DIR = Path("ffmpeg") + +UPLOAD_DIR.mkdir(exist_ok=True) +OUTPUT_DIR.mkdir(exist_ok=True) + + +def get_ffmpeg(): + # на windows ищем локальный бинарь + if platform.system() == "Windows": + local = FFMPEG_DIR / "ffmpeg.exe" + if local.exists(): + return str(local) + return "ffmpeg" + + +def ffmpeg_ok(): + import subprocess + try: + subprocess.run([get_ffmpeg(), "-version"], capture_output=True, check=True, timeout=5) + return True + except Exception: + return False + + +def do_setup_ffmpeg(): + if platform.system() != "Windows": + return False, "поставь через apt install ffmpeg или brew install ffmpeg" + + FFMPEG_DIR.mkdir(exist_ok=True) + zip_path = FFMPEG_DIR / "ffmpeg_dl.zip" + + print("качаю ffmpeg...", flush=True) + urllib.request.urlretrieve(FFMPEG_ZIP_URL, zip_path) + + print("распаковываю...", flush=True) + with zipfile.ZipFile(zip_path, "r") as z: + for name in z.namelist(): + fname = Path(name).name + if fname in FFMPEG_EXES: + (FFMPEG_DIR / fname).write_bytes(z.read(name)) + + zip_path.unlink() + return True, "готово" + + +@app.route("/") +def index(): + return render_template("index.html", ffmpeg_ready=ffmpeg_ok()) + + +@app.route("/api/upload", methods=["POST"]) +def upload(): + if "file" not in request.files: + return jsonify({"error": "нет файла"}), 400 + + f = request.files["file"] + if not f.filename: + return jsonify({"error": "имя файла пустое"}), 400 + + uid = str(uuid.uuid4())[:8] + ext = Path(f.filename).suffix + save_name = uid + ext + save_path = UPLOAD_DIR / save_name + f.save(save_path) + + detected = converter.detect_type(save_path) + targets = converter.get_targets(detected) + + return jsonify({ + "id": uid, + "filename": f.filename, + "saved": save_name, + "type": detected, + "size": save_path.stat().st_size, + "targets": targets, + }) + + +# тут все доступные форматы для валидации +ALL_TARGETS = set( + converter.IMAGE_TARGETS + + converter.VIDEO_TARGETS + + converter.VIDEO_TO_AUDIO + + converter.AUDIO_TARGETS + + converter.ARCHIVE_TARGETS +) + + +@app.route("/api/convert", methods=["POST"]) +def convert_file(): + data = request.json or {} + uid = data.get("id", "") + saved = data.get("saved", "") + target = data.get("target", "") + original = data.get("filename", "file") + + if not all([uid, saved, target]): + return jsonify({"error": "плохие параметры"}), 400 + + if target not in ALL_TARGETS: + return jsonify({"error": "недопустимый формат"}), 400 + + src = UPLOAD_DIR / Path(saved).name + if not src.exists(): + return jsonify({"error": "файл не найден"}), 404 + + out_name = f"{uid}_out.{target}" + out_path = OUTPUT_DIR / out_name + + ok, err = converter.convert(src, out_path, target, get_ffmpeg()) + if not ok: + return jsonify({"error": err}), 500 + + # чистое имя для скачивания — убираем все суффиксы кроме последнего + base = Path(Path(original).stem).stem + result_filename = f"{base}.{target}" + + return jsonify({"result": out_name, "filename": result_filename}) + + +@app.route("/api/download/") +def download(filename): + # только имя файла, без traversal + safe = Path(filename).name + path = OUTPUT_DIR / safe + if not path.exists(): + return "не найдено", 404 + return send_file(path, as_attachment=True) + + +@app.route("/api/setup-ffmpeg", methods=["POST"]) +def setup_ffmpeg_route(): + ok, msg = do_setup_ffmpeg() + return jsonify({"ok": ok, "msg": msg, "ffmpeg": ffmpeg_ok()}) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "setup-ffmpeg": + ok, msg = do_setup_ffmpeg() + print(msg) + sys.exit(0 if ok else 1) + + port = int(os.environ.get("PORT", 5000)) + print(f"http://localhost:{port}") + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/converter.py b/converter.py new file mode 100644 index 0000000..58db57a --- /dev/null +++ b/converter.py @@ -0,0 +1,210 @@ +import subprocess +import shutil +import tempfile +from pathlib import Path + +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tiff", ".tif", ".ico", ".avif", ".heic", ".heif"} +VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv", ".wmv", ".m4v", ".3gp", ".ts", ".mts", ".mpeg", ".mpg"} +AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".opus", ".aac", ".wma", ".aiff", ".alac"} +ARCHIVE_EXTS = {".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz"} + +IMAGE_TARGETS = ["png", "jpg", "webp", "bmp", "gif", "tiff", "ico"] +VIDEO_TARGETS = ["mp4", "avi", "mov", "mkv", "webm", "gif"] +VIDEO_TO_AUDIO = ["mp3", "wav", "flac", "aac", "opus"] +AUDIO_TARGETS = ["mp3", "wav", "flac", "ogg", "m4a", "opus", "aac"] +ARCHIVE_TARGETS = ["zip", "tar.gz", "tar.bz2", "7z"] + + +def detect_type(path: Path) -> str: + name = path.name.lower() + + # двойные суффиксы — tar.gz и т.д. + if name.endswith((".tar.gz", ".tar.bz2", ".tar.xz", ".tgz", ".tbz2")): + return "archive" + + suf = path.suffix.lower() + if suf in IMAGE_EXTS: + return "image" + if suf in VIDEO_EXTS: + return "video" + if suf in AUDIO_EXTS: + return "audio" + if suf in ARCHIVE_EXTS: + return "archive" + + return "unknown" + + +def get_targets(file_type: str) -> list: + if file_type == "image": + return IMAGE_TARGETS + if file_type == "video": + return VIDEO_TARGETS + VIDEO_TO_AUDIO + if file_type == "audio": + return AUDIO_TARGETS + if file_type == "archive": + return ARCHIVE_TARGETS + return [] + + +def _convert_image(src: Path, dst: Path) -> tuple[bool, str]: + try: + from PIL import Image + + img = Image.open(src) + ext = dst.suffix.lower() + + # jpeg не понимает прозрачность — заливаем белым + if ext in (".jpg", ".jpeg"): + if img.mode in ("RGBA", "LA", "P"): + bg = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + mask = img.split()[-1] if img.mode in ("RGBA", "LA") else None + bg.paste(img, mask=mask) + img = bg + elif img.mode != "RGB": + img = img.convert("RGB") + + if ext == ".ico": + img.save(dst, sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128)]) + return True, "" + + kwargs = {} + if ext in (".jpg", ".jpeg"): + kwargs["quality"] = 95 + elif ext == ".webp": + kwargs["quality"] = 90 + + img.save(dst, **kwargs) + return True, "" + except Exception as e: + return False, str(e) + + +def _convert_ffmpeg(src: Path, dst: Path, ffmpeg_bin: str) -> tuple[bool, str]: + ext = dst.suffix.lower() + + # gif из видео нормальный — с палитрой + if ext == ".gif": + cmd = [ + ffmpeg_bin, "-i", str(src), "-y", + "-vf", "fps=12,scale=640:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + str(dst), + ] + else: + cmd = [ffmpeg_bin, "-i", str(src), "-y"] + + if ext == ".mp3": + cmd += ["-codec:a", "libmp3lame", "-qscale:a", "2", "-vn"] + elif ext == ".flac": + cmd += ["-codec:a", "flac", "-vn"] + elif ext == ".ogg": + cmd += ["-codec:a", "libvorbis", "-qscale:a", "6", "-vn"] + elif ext == ".opus": + cmd += ["-codec:a", "libopus", "-b:a", "192k", "-vn"] + elif ext == ".aac": + cmd += ["-codec:a", "aac", "-b:a", "256k", "-vn"] + elif ext == ".wav": + cmd += ["-codec:a", "pcm_s16le", "-vn"] + elif ext == ".m4a": + cmd += ["-codec:a", "aac", "-b:a", "256k", "-vn"] + + cmd.append(str(dst)) + + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + if r.returncode != 0: + return False, r.stderr[-1000:] + return True, "" + except subprocess.TimeoutExpired: + return False, "таймаут конвертации" + except FileNotFoundError: + return False, "ffmpeg не найден — запусти setup-ffmpeg или поставь через пакетный менеджер" + except Exception as e: + return False, str(e) + + +def _convert_archive(src: Path, dst: Path) -> tuple[bool, str]: + import zipfile + import tarfile + + tmp = Path(tempfile.mkdtemp()) + + try: + src_name = src.name.lower() + + # распаковываем что пришло + if src_name.endswith(".zip"): + with zipfile.ZipFile(src) as z: + z.extractall(tmp) + elif src_name.endswith((".tar.gz", ".tgz")): + with tarfile.open(src, "r:gz") as t: + t.extractall(tmp) + elif src_name.endswith((".tar.bz2", ".tbz2")): + with tarfile.open(src, "r:bz2") as t: + t.extractall(tmp) + elif src_name.endswith((".tar.xz", ".txz")): + with tarfile.open(src, "r:xz") as t: + t.extractall(tmp) + elif src_name.endswith(".tar"): + with tarfile.open(src, "r:") as t: + t.extractall(tmp) + elif src_name.endswith(".7z"): + import py7zr + with py7zr.SevenZipFile(src, mode="r") as z: + z.extractall(tmp) + elif src_name.endswith(".rar"): + import rarfile + with rarfile.RarFile(src) as r: + r.extractall(tmp) + else: + return False, "неизвестный формат архива" + + # пакуем в нужное + dst_name = dst.name.lower() + if dst_name.endswith(".zip"): + with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z: + for f in tmp.rglob("*"): + if f.is_file(): + z.write(f, f.relative_to(tmp)) + elif dst_name.endswith(".tar.gz"): + with tarfile.open(dst, "w:gz") as t: + for f in tmp.rglob("*"): + if f.is_file(): + t.add(f, arcname=str(f.relative_to(tmp))) + elif dst_name.endswith(".tar.bz2"): + with tarfile.open(dst, "w:bz2") as t: + for f in tmp.rglob("*"): + if f.is_file(): + t.add(f, arcname=str(f.relative_to(tmp))) + elif dst_name.endswith(".7z"): + import py7zr + with py7zr.SevenZipFile(dst, "w") as z: + for f in tmp.rglob("*"): + if f.is_file(): + z.write(f, str(f.relative_to(tmp))) + else: + return False, "неизвестный целевой формат" + + return True, "" + except Exception as e: + return False, str(e) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def convert(src: Path, dst: Path, target: str, ffmpeg_bin: str = "ffmpeg") -> tuple[bool, str]: + file_type = detect_type(src) + + if file_type == "image": + return _convert_image(src, dst) + + if file_type in ("video", "audio"): + return _convert_ffmpeg(src, dst, ffmpeg_bin) + + if file_type == "archive": + return _convert_archive(src, dst) + + # хуй знает что за файл — пусть ffmpeg попробует + return _convert_ffmpeg(src, dst, ffmpeg_bin) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..782cd44 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +pillow +py7zr +rarfile diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..5008c03 --- /dev/null +++ b/static/app.js @@ -0,0 +1,214 @@ +const dropzone = document.getElementById('dropzone'); +const fileInput = document.getElementById('fileInput'); +const fileList = document.getElementById('fileList'); +const setupBtn = document.getElementById('setupFfmpegBtn'); + +const TYPE_ICONS = { + image: '🖼️', + video: '🎬', + audio: '🎵', + archive: '📦', + unknown: '📄', +}; + +// drag & drop +dropzone.addEventListener('dragover', e => { + e.preventDefault(); + dropzone.classList.add('drag-over'); +}); + +dropzone.addEventListener('dragleave', e => { + if (!dropzone.contains(e.relatedTarget)) { + dropzone.classList.remove('drag-over'); + } +}); + +dropzone.addEventListener('drop', e => { + e.preventDefault(); + dropzone.classList.remove('drag-over'); + handleFiles(Array.from(e.dataTransfer.files)); +}); + +// клик на зону — открываем файловый диалог +dropzone.addEventListener('click', e => { + if (e.target.tagName === 'LABEL' || e.target.closest('label')) return; + fileInput.click(); +}); + +fileInput.addEventListener('change', () => { + handleFiles(Array.from(fileInput.files)); + fileInput.value = ''; +}); + +// ctrl+v — вставка картинки из буфера (скриншоты и т.д.) +document.addEventListener('paste', e => { + const items = Array.from(e.clipboardData?.items || []); + const files = items + .filter(i => i.kind === 'file') + .map(i => i.getAsFile()) + .filter(Boolean); + if (files.length > 0) handleFiles(files); +}); + +function handleFiles(files) { + if (files.length === 0) return; + fileList.classList.remove('hidden'); + files.forEach(f => uploadFile(f)); +} + +async function uploadFile(file) { + const card = makeCard(file.name, 'unknown'); + fileList.prepend(card); + + const status = card.querySelector('.file-status'); + + const fd = new FormData(); + fd.append('file', file); + + let data; + try { + const r = await fetch('/api/upload', { method: 'POST', body: fd }); + data = await r.json(); + if (!r.ok) throw new Error(data.error || r.statusText); + } catch (err) { + setStatus(status, 'ошибка: ' + err.message, 'error'); + return; + } + + // обновляем иконку и мета-инфо + const icon = card.querySelector('.file-icon'); + icon.textContent = TYPE_ICONS[data.type] || '📄'; + icon.className = `file-icon ${data.type}`; + + card.querySelector('.file-meta').textContent = data.type + ' · ' + fmtSize(file.size); + + if (data.targets.length === 0) { + setStatus(status, 'формат не поддерживается', 'error'); + return; + } + + // строим select + кнопку конвертации + const actions = card.querySelector('.file-actions'); + actions.innerHTML = ''; + + const sel = document.createElement('select'); + sel.className = 'format-select'; + data.targets.forEach(t => { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t; + sel.appendChild(opt); + }); + + const btn = document.createElement('button'); + btn.className = 'btn btn-primary'; + btn.textContent = 'конвертировать'; + btn.onclick = () => doConvert(data, sel.value, card); + + setStatus(status, 'готов'); + + actions.appendChild(sel); + actions.appendChild(btn); +} + +async function doConvert(uploadData, target, card) { + const btn = card.querySelector('.btn-primary'); + const status = card.querySelector('.file-status'); + + btn.disabled = true; + status.innerHTML = 'конвертирую...'; + status.className = 'file-status'; + + // берём актуальное значение select на момент нажатия + const sel = card.querySelector('.format-select'); + const actualTarget = sel ? sel.value : target; + + let result; + try { + const r = await fetch('/api/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: uploadData.id, + saved: uploadData.saved, + target: actualTarget, + filename: uploadData.filename, + }), + }); + result = await r.json(); + if (!r.ok) throw new Error(result.error || r.statusText); + } catch (err) { + const msg = err.message.slice(0, 220); + setStatus(status, 'ошибка: ' + msg, 'error'); + btn.disabled = false; + return; + } + + setStatus(status, 'готово', 'success'); + btn.disabled = false; + + // кнопка скачать — заменяем если уже есть + const actions = card.querySelector('.file-actions'); + actions.querySelector('.btn-download')?.remove(); + + const dl = document.createElement('a'); + dl.href = '/api/download/' + encodeURIComponent(result.result); + dl.download = result.filename; + dl.className = 'btn btn-download'; + dl.textContent = 'скачать'; + actions.appendChild(dl); +} + +function makeCard(filename, type) { + const card = document.createElement('div'); + card.className = 'file-card'; + card.innerHTML = ` +
${TYPE_ICONS[type] || '📄'}
+
+
${escHtml(filename)}
+
загрузка...
+
+
+
—
+ `; + return card; +} + +function setStatus(el, text, cls = '') { + el.textContent = text; + el.className = 'file-status' + (cls ? ' ' + cls : ''); +} + +function fmtSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 ** 3) return (bytes / 1024 / 1024).toFixed(1) + ' MB'; + return (bytes / 1024 ** 3).toFixed(2) + ' GB'; +} + +function escHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// кнопка setup ffmpeg (только на windows, показывается в шапке) +if (setupBtn) { + setupBtn.addEventListener('click', async () => { + setupBtn.textContent = 'качаю...'; + setupBtn.disabled = true; + + try { + const r = await fetch('/api/setup-ffmpeg', { method: 'POST' }); + const d = await r.json(); + + if (d.ffmpeg) { + setupBtn.closest('.ffmpeg-warn').textContent = '✓ ffmpeg готов — перезагрузи страницу'; + } else { + setupBtn.textContent = d.msg || 'ошибка'; + setupBtn.disabled = false; + } + } catch (e) { + setupBtn.textContent = 'ошибка сети'; + setupBtn.disabled = false; + } + }); +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..05a9ece --- /dev/null +++ b/static/style.css @@ -0,0 +1,326 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --blue: #1a73e8; + --blue-hover: #1557b0; + --green: #0f9d58; + --green-hover: #0b8043; + --red: #d93025; + --bg: #f8f9fa; + --border: #dadce0; + --muted: #5f6368; + --soft: #9aa0a6; + --text: #202124; +} + +body { + font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + font-size: 14px; +} + +/* шапка */ +header { + background: #fff; + border-bottom: 1px solid var(--border); + padding: 13px 24px; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.logo { + font-size: 19px; + font-weight: 500; + letter-spacing: -0.3px; + line-height: 1; +} + +.logo .n { color: #4285f4; } +.logo .o { color: #ea4335; } +.logo .d { color: #fbbc04; } +.logo .e { color: #4285f4; } +.logo .x { color: #34a853; } +.logo .dot { color: #ea4335; } +.logo .pw { color: #34a853; } +.logo .sep { color: var(--border); margin: 0 6px; font-weight: 300; } +.logo .name { color: var(--muted); font-weight: 400; font-size: 16px; } + +.ffmpeg-warn { + font-size: 12px; + color: #7a5c00; + background: #fef9c3; + padding: 6px 12px; + border-radius: 4px; + border: 1px solid #f5c400; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.link-btn { + background: none; + border: none; + color: var(--blue); + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: underline; + font-family: inherit; +} + +.link-btn:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +code { + font-family: 'Roboto Mono', 'Consolas', monospace; + font-size: 11px; + background: #f1f3f4; + padding: 2px 5px; + border-radius: 3px; + color: var(--text); +} + +/* основная область */ +main { + max-width: 860px; + margin: 40px auto; + padding: 0 16px 60px; +} + +/* зона загрузки */ +.dropzone { + background: #fff; + border: 2px dashed var(--border); + border-radius: 12px; + padding: 56px 40px 48px; + text-align: center; + cursor: pointer; + transition: border-color .18s, background .18s; + user-select: none; +} + +.dropzone:hover, +.dropzone.drag-over { + border-color: var(--blue); + background: #f0f6ff; +} + +.dropzone.drag-over .drop-icon { + color: var(--blue); + transform: translateY(-4px); +} + +.drop-icon { + color: #c9cdd0; + margin-bottom: 14px; + transition: color .18s, transform .18s; + display: inline-block; +} + +.drop-title { + font-size: 17px; + font-weight: 500; + color: var(--text); + margin-bottom: 8px; +} + +.drop-sub { + font-size: 13px; + color: var(--muted); + margin-bottom: 14px; +} + +.file-label { + color: var(--blue); + cursor: pointer; + text-decoration: underline; +} + +.drop-formats { + font-size: 11px; + color: var(--soft); + letter-spacing: .2px; +} + +/* список файлов */ +.file-list { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* карточка файла */ +.file-card { + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 18px; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + transition: box-shadow .15s; +} + +.file-card:hover { + box-shadow: 0 1px 6px rgba(0,0,0,.08); +} + +.file-icon { + width: 40px; + height: 40px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.file-icon.image { background: #e8f5e9; } +.file-icon.video { background: #fce4ec; } +.file-icon.audio { background: #e3f2fd; } +.file-icon.archive { background: #fff8e1; } +.file-icon.unknown { background: #f3e5f5; } + +.file-info { + flex: 1; + min-width: 0; +} + +.file-name { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-meta { + font-size: 11px; + color: var(--muted); + margin-top: 2px; +} + +/* действия */ +.file-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.format-select { + height: 34px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 12px; + color: var(--text); + background: #fff; + cursor: pointer; + outline: none; + font-family: inherit; + transition: border-color .15s; +} + +.format-select:focus { + border-color: var(--blue); +} + +/* кнопки */ +.btn { + height: 34px; + padding: 0 18px; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + font-family: inherit; + letter-spacing: .2px; + transition: background .15s; + text-decoration: none; + display: inline-flex; + align-items: center; + white-space: nowrap; +} + +.btn-primary { + background: var(--blue); + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: var(--blue-hover); +} + +.btn-primary:disabled { + background: #e0e0e0; + color: var(--muted); + cursor: not-allowed; +} + +.btn-download { + background: var(--green); + color: #fff; +} + +.btn-download:hover { + background: var(--green-hover); +} + +/* статус */ +.file-status { + font-size: 11px; + color: var(--muted); + min-width: 80px; +} + +.file-status.error { + color: var(--red); +} + +.file-status.success { + color: var(--green); +} + +/* спиннер */ +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid #e0e0e0; + border-top-color: var(--blue); + border-radius: 50%; + animation: spin .65s linear infinite; + vertical-align: middle; + margin-right: 4px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.hidden { display: none !important; } + +/* адаптив */ +@media (max-width: 600px) { + main { margin: 20px auto; } + .dropzone { padding: 40px 20px; } + .file-card { flex-direction: column; align-items: flex-start; } + .file-actions { width: 100%; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..8b9f00e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,50 @@ + + + + + + Nodex.pw Converter + + + + + + +
+ + {% if not ffmpeg_ready %} +
+ ffmpeg не найден — + +  или  apt install ffmpeg / brew install ffmpeg +
+ {% endif %} +
+ +
+
+
+ + + + +
+

перетащи файлы сюда

+

или  ·  ctrl+v чтобы вставить

+ +

изображения  ·  видео  ·  аудио  ·  архивы  ·  webp  ·  и всё остальное

+
+ + +
+ + + + +