個人的にあると便利なので作ってみました。指定フォルダを監視して自動リサイズするだけのツールです。
Web-UIで設定を変更出来ます。

LXDコンテナにインストール
LXDコンテナにインストールします。Tailscale ServeでHTTPS化しています。
インストール時に監視フォルダや画像サイズを指定しますが、これは起動後にWeb-UIで変更可能です。
#!/usr/bin/env bash
# =============================================================================
# Image Resize Service — インストールスクリプト
# - 特定フォルダを監視し、画像(jpg/jpeg/png)をリサイズしてresizeサブフォルダに保存
# - Web-UIで監視フォルダ・リサイズサイズを設定可能
# - nextExplorer等のアップロード(.uploading一時ファイル→リネーム)にも対応
# =============================================================================
set -euo pipefail
INSTALL_DIR="/opt/image-resize"
SERVICE_NAME="image-resize"
PORT=3324
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
ok() { echo -e "\033[1;32m[ OK ]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
die() { echo -e "\033[1;31m[ERR ]\033[0m $*" >&2; exit 1; }
# ── 前提チェック ───────────────────────────────────────────────────────────────
info "前提確認..."
command -v python3 >/dev/null 2>&1 || die "python3 が見つかりません"
TS_AVAILABLE=false
if command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1; then
TS_AVAILABLE=true
TS_HOSTNAME=$(tailscale status --json \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['Self']['DNSName'].rstrip('.'))" \
2>/dev/null) || TS_AVAILABLE=false
fi
ok "前提 OK"
# ── 監視フォルダの入力 ────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " 監視するフォルダを指定してください。"
echo " (空 Enter でデフォルト: /opt/lxd-data/images)"
echo " 存在しない場合は自動で作成します。"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
read -rp " 監視フォルダ: " WATCH_FOLDER
WATCH_FOLDER="${WATCH_FOLDER:-/opt/lxd-data/images}"
if [[ ! -d "${WATCH_FOLDER}" ]]; then
info "フォルダが存在しないため自動作成します: ${WATCH_FOLDER}"
fi
mkdir -p "${WATCH_FOLDER}"
ok "監視フォルダ: ${WATCH_FOLDER}"
# ── リサイズサイズの入力 ─────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " リサイズ後の最大サイズを指定してください。"
echo " (空 Enter でデフォルト: 幅1024 x 高さ1024)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
read -rp " 最大幅 (px) [1024]: " RESIZE_W
RESIZE_W="${RESIZE_W:-1024}"
read -rp " 最大高さ (px) [1024]: " RESIZE_H
RESIZE_H="${RESIZE_H:-1024}"
ok "リサイズ: ${RESIZE_W} x ${RESIZE_H}"
# ── ポート番号の入力 ─────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Web-UI のポート番号を指定してください。"
echo " (空 Enter でデフォルト: 3324)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
read -rp " ポート [3324]: " INPUT_PORT
PORT="${INPUT_PORT:-3324}"
ok "ポート: ${PORT}"
# ── ディレクトリ作成 ──────────────────────────────────────────────────────────
info "ディレクトリ作成..."
mkdir -p "${INSTALL_DIR}"
# ── venv 作成 & 依存パッケージインストール ────────────────────────────────────
info "Python venv を作成..."
# 既存venvが壊れている場合は削除
if [[ -d "${INSTALL_DIR}/venv" ]]; then
if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
warn "既存のvenvが壊れています。削除して再作成します..."
rm -rf "${INSTALL_DIR}/venv"
fi
fi
# venv作成テスト
VENV_OK=false
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
# まずテストでvenv作成を試みる
TMPDIR_VENV=$(mktemp -d)
if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
VENV_OK=true
rm -rf "${TMPDIR_VENV}"
else
rm -rf "${TMPDIR_VENV}"
fi
fi
# venvがまだ無い場合、必要に応じてパッケージインストール
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
if [[ "${VENV_OK}" == "false" ]]; then
warn "venv が利用できません。python3-venv をインストールします..."
PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null)
# sudo確認
SUDO_CMD=""
if [[ $EUID -ne 0 ]]; then
SUDO_CMD="sudo"
fi
${SUDO_CMD} apt-get update -qq 2>/dev/null
${SUDO_CMD} apt-get install -y -qq "python3.${PYVER}-venv" 2>/dev/null || \
${SUDO_CMD} apt-get install -y -qq python3-venv 2>/dev/null || \
die "python3-venv のインストールに失敗しました。手動でインストールしてください: sudo apt install python3-venv"
# インストール後、再度テスト
TMPDIR_VENV=$(mktemp -d)
if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
VENV_OK=true
fi
rm -rf "${TMPDIR_VENV}"
fi
if [[ "${VENV_OK}" == "true" ]]; then
python3 -m venv "${INSTALL_DIR}/venv" || die "venv の作成に失敗しました"
else
die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
fi
fi
# 最終確認
if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
fi
ok "venv 作成完了 ($("${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)"))"
info "依存パッケージをインストール..."
"${INSTALL_DIR}/venv/bin/pip" install --quiet --upgrade Pillow watchdog flask
ok "依存パッケージインストール完了"
# ── アプリケーションスクリプト生成 ────────────────────────────────────────────
info "アプリケーションを生成..."
cat > "${INSTALL_DIR}/app.py" << 'PYEOF'
#!/usr/bin/env python3
"""Image Resize Service — Watch a folder and resize images to a subfolder."""
import json
import os
import sys
import time
import threading
from pathlib import Path
from flask import Flask, render_template_string, request, jsonify
from PIL import Image
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
DEFAULT_CONFIG = {
"watch_folder": "/opt/lxd-data/images",
"resize_width": 1024,
"resize_height": 1024,
"keep_aspect_ratio": True,
"output_suffix": "_resized",
"extensions": [".jpg", ".jpeg", ".png"],
"enabled": True,
"port": 3324,
}
app = Flask(__name__)
config = {}
observer = None
def load_config():
global config
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
else:
config = DEFAULT_CONFIG.copy()
save_config()
def save_config():
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
def resize_image(src_path, dst_path, width, height, keep_aspect):
try:
img = Image.open(src_path)
if keep_aspect:
img.thumbnail((width, height), Image.LANCZOS)
else:
img = img.resize((width, height), Image.LANCZOS)
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
img.save(dst_path, quality=90)
return True
except Exception as e:
print(f"[ERROR] Failed to resize {src_path}: {e}", file=sys.stderr)
return False
def get_output_path(src_path):
p = Path(src_path)
resize_dir = p.parent / "resize"
ext = p.suffix.lower()
stem = p.stem + config.get("output_suffix", "_resized")
return str(resize_dir / (stem + ext))
class ImageHandler(FileSystemEventHandler):
def __init__(self):
self._pending = {}
def on_created(self, event):
if event.is_directory:
return
print(f"[DEBUG] File created: {event.src_path}", flush=True)
self._handle(event.src_path)
def on_modified(self, event):
if event.is_directory:
return
print(f"[DEBUG] File modified: {event.src_path}", flush=True)
self._handle(event.src_path)
def on_moved(self, event):
if event.is_directory:
return
print(f"[DEBUG] File moved: {event.src_path} -> {event.dest_path}", flush=True)
self._handle(event.dest_path)
def _handle(self, path):
ext = Path(path).suffix.lower()
if ext not in config.get("extensions", [".jpg", ".jpeg", ".png"]):
return
if "resize" in Path(path).parts:
return
self._pending[path] = time.time()
threading.Thread(target=self._delayed_process, args=(path,), daemon=True).start()
def _delayed_process(self, path):
time.sleep(0.5)
if path in self._pending:
del self._pending[path]
if not os.path.exists(path):
return
dst = get_output_path(path)
ok = resize_image(
path, dst,
config.get("resize_width", 1024),
config.get("resize_height", 1024),
config.get("keep_aspect_ratio", True),
)
if ok:
print(f"[OK] Resized: {os.path.basename(path)} -> {dst}", flush=True)
def start_watching():
global observer
if observer and observer.is_alive():
print(f"[INFO] Stopping observer on: {observer._handlers}", flush=True)
observer.stop()
observer.join(timeout=5)
observer = None
if not config.get("enabled", True):
print("[INFO] Watching disabled", flush=True)
return
watch_dir = config.get("watch_folder", "")
if not watch_dir or not os.path.isdir(watch_dir):
print(f"[WARN] Watch folder not found: {watch_dir}", flush=True)
return
observer = Observer()
observer.schedule(ImageHandler(), watch_dir, recursive=False)
observer.start()
print(f"[INFO] Watching: {watch_dir}", flush=True)
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Resize Service</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #333; }
.header { background: #1a73e8; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
.header h1 { font-size: 20px; }
.status { margin-left: auto; padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600; }
.status.on { background: #34a853; }
.status.off { background: #ea4335; }
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
.card { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 24px; margin-bottom: 16px; }
.card h2 { font-size: 16px; margin-bottom: 16px; color: #555; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #666; }
.field input[type="text"], .field input[type="number"] {
width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 15px;
}
.field input:focus { outline: none; border-color: #1a73e8; }
.row { display: flex; gap: 16px; }
.row .field { flex: 1; }
.toggle { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.toggle input[type="checkbox"] { width: 20px; height: 20px; }
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
.btn-primary { background: #1a73e8; color: white; }
.btn-primary:hover { background: #1557b0; }
.log { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 12px; font-family: 'Courier New', monospace; font-size: 13px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; }
.log .ok { color: #4ec9b0; }
.log .err { color: #f44747; }
.log .info { color: #569cd6; }
</style>
</head>
<body>
<div class="header">
<h1>Image Resize Service</h1>
<span class="status {{ 'on' if config.enabled else 'off' }}" id="statusBadge">
{{ '監視中' if config.enabled else '停止中' }}
</span>
</div>
<div class="container">
<div class="card">
<h2>設定</h2>
<div class="field">
<label>監視フォルダ</label>
<input type="text" id="watchFolder" value="{{ config.watch_folder }}">
</div>
<div class="row">
<div class="field">
<label>リサイズ幅 (px)</label>
<input type="number" id="resizeWidth" value="{{ config.resize_width }}" min="1">
</div>
<div class="field">
<label>リサイズ高さ (px)</label>
<input type="number" id="resizeHeight" value="{{ config.resize_height }}" min="1">
</div>
</div>
<div class="toggle">
<input type="checkbox" id="keepAspect" {{ 'checked' if config.keep_aspect_ratio }}>
<label for="keepAspect">アスペクト比を保持</label>
</div>
<div class="field">
<label>出力サフィックス</label>
<input type="text" id="outputSuffix" value="{{ config.output_suffix }}">
</div>
<div class="toggle">
<input type="checkbox" id="enabled" {{ 'checked' if config.enabled }}>
<label for="enabled">監視を有効にする</label>
</div>
<button class="btn btn-primary" onclick="saveConfig()">保存して適用</button>
</div>
<div class="card">
<h2>現在のフォルダ内容</h2>
<div id="folderContents" class="log">読み込み中...</div>
<div style="margin-top:12px">
<button class="btn btn-primary" onclick="refreshFolder()">更新</button>
<button class="btn btn-primary" onclick="addTestImage()">テスト画像を追加</button>
</div>
</div>
</div>
<script>
function refreshFolder() {
fetch('/api/folder').then(r => r.json()).then(data => {
const el = document.getElementById('folderContents');
el.innerHTML = '';
if (data.items.length === 0) { el.textContent = '(空)'; return; }
data.items.forEach(item => {
const line = document.createElement('span');
line.className = item.type === 'resize' ? 'ok' : 'info';
line.textContent = `[${item.type}] ${item.name} (${item.size})\\n`;
el.appendChild(line);
});
});
}
function saveConfig() {
const folder = document.getElementById('watchFolder').value;
const cfg = {
watch_folder: folder,
resize_width: parseInt(document.getElementById('resizeWidth').value),
resize_height: parseInt(document.getElementById('resizeHeight').value),
keep_aspect_ratio: document.getElementById('keepAspect').checked,
output_suffix: document.getElementById('outputSuffix').value,
enabled: document.getElementById('enabled').checked,
};
fetch('/api/check-folder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: folder}),
}).then(r => r.json()).then(data => {
if (!data.exists) {
if (!confirm('フォルダ「' + folder + '」が存在しません。\\n作成しますか?')) {
return;
}
cfg.create_folder = true;
}
return fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cfg),
});
}).then(r => r && r.json()).then(() => {
document.getElementById('statusBadge').className = 'status ' + (cfg.enabled ? 'on' : 'off');
document.getElementById('statusBadge').textContent = cfg.enabled ? '監視中' : '停止中';
refreshFolder();
});
}
function addTestImage() {
fetch('/api/test-image', {method: 'POST'}).then(r => r.json()).then(() => {
setTimeout(refreshFolder, 1000);
});
}
refreshFolder();
</script>
</body>
</html>"""
@app.route("/")
def index():
return render_template_string(HTML_TEMPLATE, config=config)
@app.route("/api/config", methods=["GET", "POST"])
def api_config():
global config
if request.method == "POST":
data = request.json
create_folder = data.pop("create_folder", False)
if create_folder:
watch_dir = data.get("watch_folder", "")
if watch_dir:
os.makedirs(watch_dir, exist_ok=True)
print(f"[INFO] Created folder: {watch_dir}", flush=True)
config.update(data)
save_config()
start_watching()
return jsonify({"ok": True})
return jsonify(config)
@app.route("/api/check-folder", methods=["POST"])
def api_check_folder():
path = request.json.get("path", "")
exists = os.path.isdir(path) if path else False
return jsonify({"exists": exists, "path": path})
@app.route("/api/folder")
def api_folder():
items = []
watch = config.get("watch_folder", "")
if os.path.isdir(watch):
for f in sorted(os.listdir(watch)):
fp = os.path.join(watch, f)
if os.path.isdir(fp):
if f == "resize":
for rf in sorted(os.listdir(fp)):
rfp = os.path.join(fp, rf)
if os.path.isfile(rfp):
sz = os.path.getsize(rfp)
items.append({"name": f"resize/{rf}", "size": f"{sz//1024}KB", "type": "resize"})
else:
ext = Path(f).suffix.lower()
if ext in config.get("extensions", []):
sz = os.path.getsize(fp)
items.append({"name": f, "size": f"{sz//1024}KB", "type": "image"})
return jsonify({"items": items})
@app.route("/api/test-image", methods=["POST"])
def api_test_image():
import random
watch = config.get("watch_folder", "")
os.makedirs(watch, exist_ok=True)
w, h = random.randint(1600, 4000), random.randint(1200, 3000)
img = Image.new("RGB", (w, h), (
random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
))
from PIL import ImageDraw
draw = ImageDraw.Draw(img)
for _ in range(20):
x1, y1 = random.randint(0, w), random.randint(0, h)
x2, y2 = x1 + random.randint(50, 300), y1 + random.randint(50, 300)
draw.rectangle([x1, y1, x2, y2], fill=(
random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
))
ts = int(time.time() * 1000)
path = os.path.join(watch, f"test_{ts}.jpg")
img.save(path, quality=95)
return jsonify({"ok": True, "path": path})
def main():
load_config()
watch_dir = config.get("watch_folder", "/tmp")
if not os.path.isdir(watch_dir):
print(f"[INFO] Creating watch folder: {watch_dir}", flush=True)
os.makedirs(watch_dir, exist_ok=True)
start_watching()
port = config.get("port", 3324)
print(f"[INFO] Web UI: http://127.0.0.1:{port}", flush=True)
app.run(host="0.0.0.0", port=port, debug=False)
if __name__ == "__main__":
main()
PYEOF
ok "アプリケーションスクリプト生成完了"
# ── config.json 生成 ──────────────────────────────────────────────────────────
cat > "${INSTALL_DIR}/config.json" << CFGEOF
{
"watch_folder": "${WATCH_FOLDER}",
"resize_width": ${RESIZE_W},
"resize_height": ${RESIZE_H},
"keep_aspect_ratio": true,
"output_suffix": "_resized",
"extensions": [".jpg", ".jpeg", ".png"],
"enabled": true,
"port": ${PORT}
}
CFGEOF
ok "config.json 生成完了"
# ── systemd ユニットファイル生成 ───────────────────────────────────────────────
info "systemd ユニットファイルを生成..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=Image Resize Service
After=network.target
[Service]
Type=simple
ExecStart=${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/app.py
WorkingDirectory=${INSTALL_DIR}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
ok "systemd ユニットファイル生成完了"
# ── サービス起動(Tailscale Serve の前に起動) ────────────────────────────────
info "サービスを起動..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
# サービスが起動するまで待機(最大10秒)
for i in $(seq 1 10); do
if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
break
fi
sleep 1
done
if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
ok "サービス起動完了"
else
die "サービスの起動に失敗しました。journalctl -u ${SERVICE_NAME} -n 20 で確認してください"
fi
# ── Tailscale Serve 設定(サービス起動後に設定) ──────────────────────────────
if [[ "${TS_AVAILABLE}" == "true" ]]; then
info "Tailscale Serve にポート ${PORT} を追加..."
# 既存の同ポート設定を削除
tailscale serve --https="${PORT}" off 2>/dev/null || true
tailscale serve --bg --https="${PORT}" "http://127.0.0.1:${PORT}"
ok "Tailscale Serve 設定追加完了"
else
warn "Tailscale が利用できないため、Tailscale Serve 設定をスキップします。"
warn "Web-UI は http://127.0.0.1:${PORT} でアクセスできます。"
fi
# ── 完了サマリー ──────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "セットアップ完了!"
echo ""
if [[ "${TS_AVAILABLE}" == "true" ]]; then
echo " Web UI : https://${TS_HOSTNAME}:${PORT}"
else
echo " Web UI : http://127.0.0.1:${PORT}"
fi
echo " 設定ファイル: ${INSTALL_DIR}/config.json"
echo " 監視フォルダ: ${WATCH_FOLDER}"
echo " リサイズサイズ: ${RESIZE_W} x ${RESIZE_H}"
echo ""
echo " 管理コマンド:"
echo " systemctl status ${SERVICE_NAME}"
echo " systemctl restart ${SERVICE_NAME}"
echo " journalctl -u ${SERVICE_NAME} -f"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
指定フォルダに保存した画像が自動リサイズ
監視フォルダに画像を入れれば設定したサイズに自動でリサイズされます。
これはnextExplorerで確認したところ。これでアップロードした画像もリサイズされます。



