forked from tsuki/openroller
177 lines
5.3 KiB
Python
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:]))
|