一键部署自己的图像转换站

兰科勒布劳恩斯基
2025-06-19 / 0 评论 / 163 阅读 / 正在检测是否收录...

有时候,当我们拍摄了一个照片,是JPG格式的,但是上传到某些网站作为自定义背景或头像,但是该网站只接受PNG格式的图片,还有有些时候可能需要压缩图片的体积,转成WebP格式,那这个时候就可以部署一个这样的工具来实现啦!
兰科自己部署了一个: https://img.xmlans.com
为了防止滥用,兰科限制了每次转换的图片数量不超过二十张,如果你自己部署的话可以自由调整这个限制或者直接去除哦!
实现功能

前端页面
转换png, jpeg, webp, avif, tiff, gif格式
自由选择WebP图片压缩率
批量转换并打包成zip

部署教程
首先先访问项目存储库,下载项目包: https://github.com/xmlans/Image-conversion
这个项目在Python3.13.1经过测试,你应该也可以使用较旧的其他Python版本,首先安装Python环境:

apt install -y python3 python3-venv python3-pip

安装项目所需的依赖:

pip install --no-cache-dir fastapi uvicorn wand

这个项目的server.py其实就是FastAPI,用于转换图片,你可以直接使用启动命令启动它:

python server.py

如果有时后无法启动的话,你可能还需要安装额外的依赖:

pip install python-multipart

默认情况下是监听9998端口,如果需要前端页面可以访问的话,请将网页的/convert 目录反向代理到9998端口,如果是本地的话就反代到localhost:9998
然后访问127.0.0.1,就可以使用啦!
效果图
效果图

源代码
FastAPI Python源码 server.py

# By Star Dream
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse
import shutil
import os
import uuid
from wand.image import Image
import uvicorn

app = FastAPI()

UPLOAD_DIR = "uploads"
OUTPUT_DIR = "outputs"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)

@app.post("/convert")
async def convert(
    file: UploadFile = File(...),
    format: str = Form(...),
    quality: int = Form(None)
):
    SUPPORTED = {"png", "jpeg", "webp", "avif", "tiff", "gif"}
    target = format.lower()
    if target not in SUPPORTED:
        raise HTTPException(status_code=400, detail=f"不支持的格式: {target}")

    uid = str(uuid.uuid4())
    in_path = os.path.join(UPLOAD_DIR, f"{uid}_{file.filename}")
    out_name = f"{uid}.{target}"
    out_path = os.path.join(OUTPUT_DIR, out_name)

    with open(in_path, "wb") as f:
        shutil.copyfileobj(file.file, f)

    try:
        with Image(filename=in_path) as img:
            img.format = target.upper()
            if target == "webp" and quality is not None:
                img.compression_quality = quality
            img.save(filename=out_path)
    except Exception as e:
        os.remove(in_path)
        raise HTTPException(status_code=500, detail=f"转换失败: {e}")

    try:
        os.remove(in_path)
    except:
        pass

    return FileResponse(
        out_path,
        media_type=f"image/{target}",
        filename=out_name
    )

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=9998) #9998可以换成你想要的端口,不用和兰科一样

前端index.html

<!-- By Star Dream -->

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>title</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <meta name="robots" content="index,follow">
  <link rel="canonical" href="https://img.xmlans.com/">
  <link rel="icon" href="/favicon.ico" type="image/x-icon">
  <!-- JSZip & FileSaver for batch download -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
  <style>
    body { background: radial-gradient(ellipse at center, #e0c3fc 0%, #8ec5fc 100%); min-height:100vh; overflow-x:hidden; }
    .hero { background-attachment: fixed; background-position:center; background-size:cover; }
    .reveal { opacity:0; transform:translateY(30px); transition:opacity .6s ease-out,transform .6s ease-out; }
    .reveal.in-view { opacity:1; transform:translateY(0); }
    .btn { position:relative; overflow:hidden; }
    .btn .ripple { position:absolute; border-radius:50%; transform:scale(0); animation:ripple .6s linear; background:rgba(255,255,255,0.6); }
    @keyframes ripple { to{transform:scale(4);opacity:0;} }
    .upload-area { border:2px dashed #aaa; padding:2rem; border-radius:1rem; cursor:pointer; transition:border-color .3s; }
    .upload-area:hover { border-color:#6b21a8; }
    #progressContainer { display:none; width:100%; background:#e5e7eb; border-radius:9999px; height:.5rem; margin-top:.5rem; position:relative; }
    #progressBar { height:100%; width:0; background:#8b5cf6; transition:width .2s; border-radius:9999px; }
    #percentLabel { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:.875rem; color:#374151; }
    #preview img { margin:.25rem; max-height:100px; border-radius:.5rem; }
    footer { text-align:center; margin-top:2rem; font-size:.75rem; color:#9ca3af; }
  </style>
</head>
<body class="hero flex items-center justify-center min-h-screen px-4 text-gray-800">
  <div class="bg-white bg-opacity-90 backdrop-blur-lg shadow-xl rounded-3xl max-w-2xl w-full p-8 space-y-6 text-center reveal" data-reveal>
    <h1 class="text-4xl sm:text-5xl font-bold text-purple-700 mb-4">图片格式转换器</h1>
    <div id="upload" class="upload-area text-gray-500 reveal" data-reveal>
      <p>点击或拖拽上传图片 (可多选,最多20张)</p>
      <input id="fileInput" type="file" accept="image/*" class="hidden" multiple />
    </div>
    <div id="preview" class="reveal flex flex-wrap justify-center" data-reveal></div>
    <div class="space-y-2 reveal" data-reveal>
      <label for="format" class="block text-left text-gray-700">目标格式:</label>
      <select id="format" class="w-full border border-gray-300 rounded p-2">
        <option value="png">PNG</option>
        <option value="jpeg">JPEG</option>
        <option value="webp">WebP</option>
        <option value="avif">AVIF</option>
        <option value="tiff">TIFF</option>
        <option value="gif">GIF</option>
      </select>
    </div>
    <div id="qualityContainer" class="space-y-2 reveal hidden" data-reveal>
      <label for="quality" class="block text-left text-gray-700">WebP 压缩率 (<span id="qualityValue">80</span>):</label>
      <input type="range" id="quality" min="1" max="100" value="80" class="w-full" />
    </div>
    <div class="flex justify-center gap-4 reveal" data-reveal>
      <button id="convertBtn" class="btn bg-pink-400 hover:bg-pink-500 text-white py-2 px-6 rounded-full shadow transition disabled:opacity-50" disabled>转换</button>
    </div>
    <div id="progressContainer" class="reveal" data-reveal>
      <div id="progressBar"></div>
      <span id="percentLabel">0%</span>
    </div>
    <p id="msg" class="text-sm text-red-500 reveal" data-reveal>这是一项免费服务,由Star Dream™运营</p>
    <footer>© 2025 Star Dream™ All rights reserved.</footer>
  </div>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      const MAX_FILES = 20;
      const reveals = document.querySelectorAll('[data-reveal]');
      const obs = new IntersectionObserver((entries) => {
        entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in-view'); obs.unobserve(e.target); }});
      }, { threshold: 0.2 }); reveals.forEach(el => obs.observe(el));

      document.querySelectorAll('.btn').forEach(btn => btn.addEventListener('click', e => {
        const c = document.createElement('span'), d = Math.max(btn.clientWidth, btn.clientHeight);
        c.style.width = c.style.height = d + 'px'; c.className = 'ripple'; btn.appendChild(c);
        const r = btn.getBoundingClientRect(); c.style.left = e.clientX - r.left - d/2 + 'px';
        c.style.top = e.clientY - r.top - d/2 + 'px'; setTimeout(() => c.remove(), 600);
      }));

      const uploadArea = document.getElementById('upload'), fileInput = document.getElementById('fileInput');
      const preview = document.getElementById('preview'), formatSelect = document.getElementById('format');
      const qualityContainer = document.getElementById('qualityContainer'), qualityInput = document.getElementById('quality');
      const qualityValue = document.getElementById('qualityValue'), convertBtn = document.getElementById('convertBtn');
      const progressContainer = document.getElementById('progressContainer'), progressBar = document.getElementById('progressBar');
      const percentLabel = document.getElementById('percentLabel'), msg = document.getElementById('msg');
      let selectedFiles = [];

      formatSelect.addEventListener('change', () => {
        formatSelect.value === 'webp'
          ? qualityContainer.classList.remove('hidden')
          : qualityContainer.classList.add('hidden');
      }); qualityInput.addEventListener('input', () => qualityValue.textContent = qualityInput.value);

      function handleFile() {
        const files = Array.from(fileInput.files);
        if (!files.length) return;
        if (files.length > MAX_FILES) {
          msg.textContent = `最多只能上传 ${MAX_FILES} 张图片!`;
          preview.innerHTML = '';
          convertBtn.disabled = true;
          return;
        }
        preview.innerHTML = '';
        msg.textContent = '';
        selectedFiles = files.filter(f => f.type.startsWith('image/'));
        if (!selectedFiles.length) { msg.textContent = '请选择图片文件!'; return; }
        selectedFiles.forEach(file => {
          const rd = new FileReader();
          rd.onload = e => { const img = document.createElement('img'); img.src = e.target.result; preview.appendChild(img); };
          rd.readAsDataURL(file);
        });
        convertBtn.disabled = false;
        convertBtn.textContent = selectedFiles.length > 1 ? '批量转换' : '转换';
      }

      uploadArea.addEventListener('click', () => fileInput.click());
      uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('border-purple-600'); });
      uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('border-purple-600'));
      uploadArea.addEventListener('drop', e => { e.preventDefault(); uploadArea.classList.remove('border-purple-600'); fileInput.files = e.dataTransfer.files; handleFile(); });
      fileInput.addEventListener('change', handleFile);

      convertBtn.addEventListener('click', () => {
        if (!selectedFiles.length) return;
        convertBtn.disabled = true; msg.textContent = '';
        progressContainer.style.display = 'block';
        let completed = 0, total = selectedFiles.length;
        progressBar.style.width = '0%'; percentLabel.textContent = '0%';
        const zip = new JSZip();
        selectedFiles.forEach(file => {
          const xhr = new XMLHttpRequest(); xhr.open('POST', '/convert'); xhr.responseType = 'blob';
          xhr.onload = () => {
            if (xhr.status === 200) {
              const blob = xhr.response; let ext = formatSelect.value;
              if (total > 1) {
                zip.file(file.name.replace(/\.[^.]+$/, '') + '.' + ext, blob);
              } else {
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a'); a.href = url;
                a.download = selectedFiles[0].name.replace(/\.[^.]+$/, '') + '.' + ext;
                document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
              }
            }
            completed++;
            const pct = Math.round(completed / total * 100);
            progressBar.style.width = pct + '%'; percentLabel.textContent = pct + '%';
            if (completed === total) {
              if (total > 1) {
                zip.generateAsync({ type: 'blob' }).then(content => { saveAs(content, 'converted_images.zip'); msg.textContent = '批量下载已开始'; });
              }
              convertBtn.disabled = false;
            }
          };
          const form = new FormData(); form.append('file', file); form.append('format', formatSelect.value);
          if (formatSelect.value === 'webp') form.append('quality', qualityInput.value);
          xhr.send(form);
        });
      });
    });
  </script>
</body>
</html>
0

评论 (0)

取消