Add files via upload
This commit is contained in:
parent
e978f1a25a
commit
f6fd82e934
7 changed files with 1088 additions and 0 deletions
121
README.md
Normal file
121
README.md
Normal file
|
|
@ -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 ставится отдельно — через пакетный менеджер или кнопку в интерфейсе
|
||||||
163
app.py
Normal file
163
app.py
Normal file
|
|
@ -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/<path:filename>")
|
||||||
|
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)
|
||||||
210
converter.py
Normal file
210
converter.py
Normal file
|
|
@ -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)
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
flask
|
||||||
|
pillow
|
||||||
|
py7zr
|
||||||
|
rarfile
|
||||||
214
static/app.js
Normal file
214
static/app.js
Normal file
|
|
@ -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 = '<span class="spinner"></span>конвертирую...';
|
||||||
|
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 = `
|
||||||
|
<div class="file-icon ${type}">${TYPE_ICONS[type] || '📄'}</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name" title="${escHtml(filename)}">${escHtml(filename)}</div>
|
||||||
|
<div class="file-meta">загрузка...</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions"></div>
|
||||||
|
<div class="file-status">—</div>
|
||||||
|
`;
|
||||||
|
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, '>').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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
326
static/style.css
Normal file
326
static/style.css
Normal file
|
|
@ -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%; }
|
||||||
|
}
|
||||||
50
templates/index.html
Normal file
50
templates/index.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nodex.pw Converter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<span class="n">N</span><span class="o">o</span><span class="d">d</span><span class="e">e</span><span class="x">x</span><span class="dot">.</span><span class="pw">pw</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span class="name">converter</span>
|
||||||
|
</div>
|
||||||
|
{% if not ffmpeg_ready %}
|
||||||
|
<div class="ffmpeg-warn">
|
||||||
|
ffmpeg не найден —
|
||||||
|
<button id="setupFfmpegBtn" class="link-btn">скачать автоматически (Windows)</button>
|
||||||
|
или <code>apt install ffmpeg</code> / <code>brew install ffmpeg</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="dropzone" class="dropzone">
|
||||||
|
<div class="drop-icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="44" height="44" fill="none">
|
||||||
|
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" fill="currentColor" opacity=".3"/>
|
||||||
|
<path d="M14 13v4h-4v-4H7l5-5 5 5h-3z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="drop-title">перетащи файлы сюда</p>
|
||||||
|
<p class="drop-sub">или <label for="fileInput" class="file-label">выбери файлы</label> · ctrl+v чтобы вставить</p>
|
||||||
|
<input type="file" id="fileInput" multiple hidden>
|
||||||
|
<p class="drop-formats">изображения · видео · аудио · архивы · webp · и всё остальное</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fileList" class="file-list hidden"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.FFMPEG_READY = {{ 'true' if ffmpeg_ready else 'false' }};
|
||||||
|
</script>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue