HEIC to JPG Converter

A simple Python/Flask/Apache2 webpage to convert Apple HEIC format images to JPG. It should look like this https://allpike.net/heic2jpg

Step 1. Lets make a new directory for this application. I’m using a system with Virtualmin installed so, for me, this is in the home directory of one of the vhosts, but somewhere in /var/www/ would make sense too. Also set up some dependancies and get into the virtual environment.

sudo apt update
sudo apt install -y libheif-examples
mkdir /home/domain.com/heic2jpg
cd /home/domain.com/heic2jpg
python3 -m venv venv
. venv/bin/activate
pip install –upgrade pip
pip install Flask

Create app.py and put this in it

import os
import uuid
import shutil
import tempfile
import subprocess
import shutil, tempfile, subprocess, logging, sys
from threading import BoundedSemaphore
from pathlib import Path
from flask import Flask, request, send_file, abort, after_this_request, render_template_string
from werkzeug.utils import secure_filename

app = Flask(__name__)

# Safety limits
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024  # 50 MB
ALLOWED_EXTS = {".heic", ".heif"}

# Absolute path to heif-convert (don’t rely on PATH under Apache)
HEIF_CONVERT = shutil.which("heif-convert") or "/usr/bin/heif-convert"
if not os.path.exists(HEIF_CONVERT):
    raise RuntimeError("heif-convert not found. `sudo apt install libheif-examples`")

# Keep at most 2 concurrent conversions
CONVERT_SEM = BoundedSemaphore(2)

# Log to Apache error log
app.logger.setLevel(logging.INFO)
_handler = logging.StreamHandler(sys.stderr)
_handler.setLevel(logging.INFO)
app.logger.addHandler(_handler)

# Temp working dir for this process
WORKDIR = Path(tempfile.mkdtemp(prefix="heic2jpg_"))
UPLOADS = WORKDIR / "uploads"
CONVERTED = WORKDIR / "converted"
UPLOADS.mkdir(exist_ok=True, parents=True)
CONVERTED.mkdir(exist_ok=True, parents=True)

def allowed_file(filename: str) -> bool:
    ext = Path(filename).suffix.lower()
    return ext in ALLOWED_EXTS

def have_heif_convert() -> bool:
    return shutil.which("heif-convert") is not None

INDEX_HTML = """
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>HEIC → JPG</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
     :root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
  body { margin: 0; padding: 2rem; background: #f6f7f9; color: #222; }
  .wrap { max-width: 680px; margin: 0 auto; }
  h1 { margin-bottom: .5rem; }
  .drop {
    display: block;               /* ensure full-width box */
    width: 100%;
    margin-top: 1rem; border: 2px dashed #bbb; border-radius: 12px; padding: 40px;
    background: #fff; text-align: center; transition: border-color .2s, background .2s;
  }
  .drop.dragover { border-color: #4a90e2; background: #f0f7ff; }
  .hint { color: #666; font-size: .95rem; margin-top: .5rem; }
  #result { margin-top: 1rem; display: none; }
  button, .btn {
    display: inline-block; border: 0; background: #4a90e2; color: #fff; padding: 10px 16px;
    border-radius: 8px; cursor: pointer; font-weight: 600; text-decoration: none;
  }
  #preview { max-width: 100%; margin-top: .75rem; border-radius: 8px; display: none; }
  input[type=file] { display: none; }
  .row { margin-top: 1rem; }
  .error { color: #b00020; margin-top: .75rem; }
    </style>
  </head>
  <body>
    <div class="wrap">
      <h1>HEIC → JPG Converter</h1>
      <p class="hint">Drop a <strong>.heic</strong> file here or click to choose. We’ll return a JPEG.</p>

      <label class="drop" id="drop">
        <input id="file" type="file" accept=".heic,.heif,image/heic,image/heif">
        <div>📄 Drop HEIC here (or click)</div>
        <div class="hint">Max 50 MB</div>
      </label>

      <div class="row">
        <a id="download" class="btn" style="display:none;">Download JPG</a>
        <span id="status" class="hint"></span>
        <div id="err" class="error"></div>
      </div>

      <img id="preview" alt="Preview">
    </div>

<script>
  const drop = document.getElementById('drop');
  const fileInput = document.getElementById('file');
  const statusEl = document.getElementById('status');
  const errEl = document.getElementById('err');
  const dl = document.getElementById('download');
  const preview = document.getElementById('preview');

  function setBusy(msg) { statusEl.textContent = msg || 'Converting…'; errEl.textContent=''; dl.style.display='none'; preview.style.display='none'; }
  function setError(msg) { errEl.textContent = msg; statusEl.textContent=''; }
  function done() { statusEl.textContent='Done.'; }

  // Build the correct relative URL no matter where we’re mounted (e.g. /heic2jpg/)
  function convertUrl() {
    const p = window.location.pathname.endsWith('/') ? window.location.pathname : window.location.pathname + '/';
    return p + 'convert';
  }

  async function sendFile(file) {
    if (!file) return;
    const name = file.name || 'image.heic';
    if (!name.toLowerCase().match(/\.(heic|heif)$/)) {
      setError('Please choose a .heic file.');
      return;
    }
    setBusy('Uploading & converting…');

    const fd = new FormData();
    fd.append('file', file, name);

    try {
      const res = await fetch(convertUrl(), { method: 'POST', body: fd });
      const ctype = res.headers.get('Content-Type') || '';
      if (!res.ok || !ctype.includes('image/jpeg')) {
        const text = await res.text();
        throw new Error(text.slice(0, 300) || ('HTTP ' + res.status));
      }
      const blob = await res.blob();
      const url = URL.createObjectURL(blob);
      dl.href = url;
      dl.download = name.replace(/\.(heic|heif)$/i, '.jpg');
      dl.style.display = 'inline-block';
      preview.src = url; preview.style.display = 'block';
      done();
    } catch (e) {
      setError(e.message);
    }
  }

  // Do NOT add an extra click handler—label already opens the picker
  fileInput.addEventListener('change', (e) => sendFile(e.target.files[0]));

  // Drag-and-drop UX
  ['dragenter','dragover'].forEach(evt => drop.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); drop.classList.add('dragover'); }));
  ['dragleave','drop'].forEach(evt => drop.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); drop.classList.remove('dragover'); }));
  drop.addEventListener('drop', (e) => { const file = e.dataTransfer.files[0]; sendFile(file); });
</script>
  </body>
</html>
"""

@app.get("/")
def index():
    if not have_heif_convert():
        return ("<h2>heif-convert not found</h2>"
                "<p>Install it first: <code>sudo apt install libheif-examples</code></p>"), 500
    return render_template_string(INDEX_HTML)

from flask import abort, request, send_file
from werkzeug.utils import secure_filename
from pathlib import Path

ALLOWED_EXTS = {".heic", ".heif"}

def allowed_file(fn: str) -> bool:
    return Path(fn).suffix.lower() in ALLOWED_EXTS

@app.post("/convert")
def convert():
    f = request.files.get("file")
    if not f or not f.filename:
        abort(400, "No file uploaded")
    if not allowed_file(f.filename):
        abort(400, "Only .heic/.heif files are accepted")

    base = secure_filename(f.filename)
    stem = Path(base).stem

    # Per-request temp dir; auto-cleaned
    with tempfile.TemporaryDirectory(prefix="heic2jpg_") as tmpd:
        tmp = Path(tmpd)
        src = tmp / f"{stem}.heic"
        dst = tmp / f"{stem}.jpg"
        f.save(src)

        # Run the external converter with tight limits
        try:
            if not CONVERT_SEM.acquire(timeout=5):
                abort(503, "Busy, please try again in a moment.")

            app.logger.info("Converting %s -> %s via %s", src, dst, HEIF_CONVERT)
            proc = subprocess.run(
                [HEIF_CONVERT, str(src), str(dst)],
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                timeout=60,           # hard cap per request
                check=False
            )
        except subprocess.TimeoutExpired:
            app.logger.error("heif-convert timed out on %s", src)
            abort(504, "Conversion timed out")
        finally:
            # Always release the slot
            try: CONVERT_SEM.release()
            except Exception: pass

        if proc.returncode != 0:
            app.logger.error("heif-convert failed (%s):\n%s", proc.returncode, proc.stdout)
            abort(500, "Conversion failed")

        if not dst.exists() or dst.stat().st_size < 1024:  # guard against empty/truncated files
            app.logger.error("Output JPG missing or too small: %s\n%s", dst, proc.stdout)
            abort(500, "Invalid output produced")

        # Success: stream the JPG back
        return send_file(
            dst,
            mimetype="image/jpeg",
            as_attachment=True,
            download_name=f"{stem}.jpg",
            max_age=0
        )

Create app.wsgi and put this in it

import os, sys
APP_DIR = "/home/domain.com/heic2jpg"
if APP_DIR not in sys.path:
    sys.path.insert(0, APP_DIR)

# Ensure heif-convert is visible under Apache
os.environ["PATH"] = "/usr/local/bin:/usr/bin:/bin:" + os.environ.get("PATH","")

os.chdir(APP_DIR)
from app import app as application

Edit your apache2 vhost file /etc/apache2/sites-enabled/domain.com.conf
and add this in somewhere. I like SSL so i’ve put this in my :443 VirtualHost section.

    # ---- HEIC2JPG Flask app ----
    # Run this app in its own daemon group
    WSGIDaemonProcess heic2jpg \
        user=domain.com group=domain.com \
        python-home=/home/domain.com/heic2jpg/venv \
        python-path=/home/domain.com/heic2jpg \
        processes=1 threads=5 display-name=%{GROUP} \
        inactivity-timeout=60 request-timeout=120 socket-timeout=15 maximum-requests=500

    # Mount the WSGI script at /heic2jpg and bind to the daemon
    WSGIScriptAlias /heic2jpg /home/domain.com/heic2jpg/app.wsgi \
        process-group=heic2jpg application-group=%{GLOBAL}

    # Allow access and cap upload size to ~50 MB
    <Directory /home/domain.com/heic2jpg>
        Require all granted
    </Directory>
    <Location "/heic2jpg">
        LimitRequestBody 52428800
    </Location>
    # ---- end HEIC→JPG block ----

Test and restart Apache2

sudo apache2ctl -t
sudo apache2ctl restart

If you don’t already have the Apache2 wsgi module installed…

sudo apt install -y libapache2-mod-wsgi-py3
sudo a2enmod wsgi
sudo apache2ctl restart

Optionally you could do the conversion in Python and not use heif-convert

sudo apt install -y libheif1
pip install pillow pillow-heif

Remove the heif-convert check section at the start of app.py and replace /convert with this.

from PIL import Image
import pillow_heif

@app.post("/convert")
def convert():
    f = request.files.get("file")
    if not f or f.filename.strip() == "":
        abort(400, "No file uploaded")
    if not allowed_file(f.filename):
        abort(400, "Only .heic/.heif files are accepted")

    base = secure_filename(f.filename)
    stem = Path(base).stem
    uid = uuid.uuid4().hex[:8]
    src_path = UPLOADS / f"{stem}_{uid}.heic"
    dst_path = CONVERTED / f"{stem}.jpg"
    f.save(src_path)

    @after_this_request
    def cleanup(response):
        for p in (src_path, dst_path):
            try:
                if p.exists(): p.unlink()
            except Exception:
                pass
        return response

    # Decode HEIC and save as JPG (quality ~90)
    heif = pillow_heif.read_heif(src_path)
    img = Image.frombytes(heif.mode, heif.size, heif.data, "raw")
    img.save(dst_path, format="JPEG", quality=90, optimize=True)
    return send_file(dst_path, mimetype="image/jpeg", as_attachment=True, download_name=f"{stem}.jpg", max_age=0)

Comments

Leave a Reply