Files
openroller/tools/dds_to_png.py
T
tsuki 831d96e562 Initial public source release
Split reusable rendering and format support into vectorail-core and vectorail-gc.
2026-08-02 17:05:27 +02:00

177 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""
Convert all .dds files under a directory to .png into a single output folder.
We intentionally use ffmpeg for decoding DDS, since ImageMagick often lacks a DDS delegate.
Example:
python tools/dds_to_png.py GC/data /tmp/gc_dds_png --jobs 8
"""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Tuple
@dataclass(frozen=True)
class Task:
src: Path
dst: Path
def _iter_dds(root: Path) -> Iterable[Path]:
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
if fn.lower().endswith(".dds"):
yield Path(dirpath) / fn
def _safe_flat_name(root: Path, p: Path) -> str:
rel = p.relative_to(root).as_posix()
# Flatten path to a filename. Keep ASCII-ish and avoid huge names.
flat = rel.replace("/", "__").replace("\\", "__")
if len(flat) > 180:
h = hashlib.sha1(rel.encode("utf-8")).hexdigest()[:10]
base = Path(flat).stem[:120]
flat = f"{base}__{h}.dds"
return Path(flat).with_suffix(".png").name
def _run_ffmpeg(src: Path, dst: Path) -> Tuple[bool, str]:
# -frames:v 1 ensures we only keep the first image if ffmpeg treats it as a sequence.
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-frames:v",
"1",
str(dst),
]
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
except FileNotFoundError:
return False, "ffmpeg not found"
if p.returncode != 0:
msg = (p.stderr or p.stdout or "").strip()
if not msg:
msg = f"ffmpeg failed with code {p.returncode}"
return False, msg
return True, ""
def _convert_one(t: Task, overwrite: bool) -> Tuple[bool, str]:
if t.dst.exists():
if not overwrite:
return True, "skip"
try:
t.dst.unlink()
except Exception as e:
return False, f"unlink failed: {e}"
t.dst.parent.mkdir(parents=True, exist_ok=True)
ok, err = _run_ffmpeg(t.src, t.dst)
if not ok:
return False, err
return True, ""
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("input_dir", help="Root directory to scan for .dds (e.g. GC/data)")
ap.add_argument("output_dir", help="Output directory for flat .png files")
ap.add_argument("--jobs", type=int, default=8, help="Parallel jobs (default: 8)")
ap.add_argument("--overwrite", action="store_true", help="Overwrite existing .png")
ap.add_argument("--max", type=int, default=0, help="Only convert first N files (0 = all)")
ap.add_argument("--list", action="store_true", help="List discovered files and exit")
args = ap.parse_args(argv)
root = Path(args.input_dir).resolve()
out = Path(args.output_dir).resolve()
if not root.exists():
print(f"input_dir does not exist: {root}", file=sys.stderr)
return 2
if shutil.which("ffmpeg") is None:
print("ffmpeg not found in PATH", file=sys.stderr)
return 2
sources = sorted(_iter_dds(root))
if args.max and args.max > 0:
sources = sources[: args.max]
if args.list:
for p in sources:
print(p)
return 0
tasks: List[Task] = []
used = set()
collisions = 0
for p in sources:
name = _safe_flat_name(root, p)
if name in used:
collisions += 1
h = hashlib.sha1(str(p).encode("utf-8")).hexdigest()[:10]
name = Path(name).with_suffix("").name + f"__{h}.png"
used.add(name)
tasks.append(Task(src=p, dst=out / name))
print(f"Found {len(tasks)} DDS files under {root}")
if collisions:
print(f"Name collisions: {collisions} (resolved with hashes)")
print(f"Output dir: {out}")
ok = 0
fail = 0
skipped = 0
errors: List[Tuple[Path, str]] = []
jobs = max(1, int(args.jobs))
with ThreadPoolExecutor(max_workers=jobs) as ex:
futs = {ex.submit(_convert_one, t, args.overwrite): t for t in tasks}
done = 0
for f in as_completed(futs):
t = futs[f]
done += 1
try:
success, msg = f.result()
except Exception as e:
success, msg = False, f"exception: {e}"
if success:
if msg == "skip":
skipped += 1
else:
ok += 1
else:
fail += 1
errors.append((t.src, msg))
if done % 200 == 0 or done == len(tasks):
print(f"Progress: {done}/{len(tasks)} ok={ok} skip={skipped} fail={fail}")
if errors:
log = out / "_errors.txt"
out.mkdir(parents=True, exist_ok=True)
with log.open("w", encoding="utf-8") as fp:
for src, msg in errors[:2000]:
fp.write(f"{src}\t{msg}\n")
print(f"Failures: {fail} (see {log})")
print(f"Done: ok={ok} skip={skipped} fail={fail}")
return 0 if fail == 0 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))