magicciv/tools/audio-reorganize.py

193 lines
7.8 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""One-shot reorganization: move audio assets from theme-specific dir to
shared `public/resources/audio/`, and normalize the SFX layout into a
consistent flat-categorical structure.
Idempotent enough to re-run if interrupted, but designed for a single pass.
Old layout:
public/games/age-of-dwarves/assets/audio/sfx/turn_started.ogg (flat)
public/games/age-of-dwarves/assets/audio/sfx/buildings/... (nested)
public/games/age-of-dwarves/assets/audio/sfx/units/melee_spawn.ogg (flat-but-named)
public/games/age-of-dwarves/assets/audio/sfx/units/melee/... (nested)
public/games/age-of-dwarves/assets/audio/music/...
New layout:
public/resources/audio/sfx/<category>/<event>.ogg
public/resources/audio/music/<track_id>.ogg
public/resources/audio/sources.csv
public/resources/audio/LICENSES.md
Categories: ui, city, combat, era, buildings, units/<class>, fauna,
weather, generic.
"""
import json, csv, io, shutil, sys, re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
OLD_ROOT = REPO_ROOT / "public/games/age-of-dwarves/assets/audio"
NEW_ROOT = REPO_ROOT / "public/resources/audio"
MANIFEST = REPO_ROOT / "public/games/age-of-dwarves/data/audio.json"
# Map of old asset-relative path -> new asset-relative path. The asset-relative
# part is what shows up as `audio/...` in the manifest streams + sources.csv.
RENAMES: dict[str, str] = {
# ── flat sfx top-level → categorical subfolders ─────────────────────
"audio/sfx/turn_started.ogg": "audio/sfx/ui/turn_started.ogg",
"audio/sfx/turn_ended.ogg": "audio/sfx/ui/turn_ended.ogg",
"audio/sfx/unit_moved.ogg": "audio/sfx/ui/unit_moved.ogg",
"audio/sfx/unit_promoted.ogg": "audio/sfx/ui/unit_promoted.ogg",
"audio/sfx/border_expanded.ogg": "audio/sfx/ui/border_expanded.ogg",
"audio/sfx/research_start.ogg": "audio/sfx/ui/research_start.ogg",
"audio/sfx/tech_researched.ogg": "audio/sfx/ui/tech_researched.ogg",
"audio/sfx/culture_researched.ogg": "audio/sfx/ui/culture_researched.ogg",
"audio/sfx/city_founded.ogg": "audio/sfx/city/city_founded.ogg",
"audio/sfx/combat_hit.ogg": "audio/sfx/combat/combat_hit.ogg",
"audio/sfx/unit_killed.ogg": "audio/sfx/combat/unit_killed.ogg",
"audio/sfx/unit_defeated.ogg": "audio/sfx/combat/unit_defeated.ogg",
"audio/sfx/unit_victorious.ogg": "audio/sfx/combat/unit_victorious.ogg",
"audio/sfx/era_advanced.ogg": "audio/sfx/era/era_advanced.ogg",
"audio/sfx/golden_age_swell.ogg": "audio/sfx/era/golden_age_swell.ogg",
"audio/sfx/victory_fanfare.ogg": "audio/sfx/era/victory_fanfare.ogg",
"audio/sfx/defeat_stinger.ogg": "audio/sfx/era/defeat_stinger.ogg",
"audio/sfx/wonder_built.ogg": "audio/sfx/buildings/wonder_built.ogg",
"audio/sfx/wonder_built_own.ogg": "audio/sfx/buildings/wonder_built_own.ogg",
"audio/sfx/wonder_built_rival.ogg": "audio/sfx/buildings/wonder_built_rival.ogg",
# ── normalize unit subcategory paths (flat-but-named → nested) ──────
"audio/sfx/units/melee_spawn.ogg": "audio/sfx/units/melee/spawn.ogg",
"audio/sfx/units/ranged_spawn.ogg": "audio/sfx/units/ranged/spawn.ogg",
"audio/sfx/units/siege_hit.ogg": "audio/sfx/units/siege/hit.ogg",
"audio/sfx/units/siege_death.ogg": "audio/sfx/units/siege/death.ogg",
"audio/sfx/units/support_attack.ogg":"audio/sfx/units/support/attack.ogg",
"audio/sfx/units/support_hit.ogg": "audio/sfx/units/support/hit.ogg",
"audio/sfx/units/support_death.ogg": "audio/sfx/units/support/death.ogg",
}
def step_move_files() -> tuple[int, int]:
"""Move every audio file from OLD_ROOT to NEW_ROOT, applying RENAMES."""
moved = 0
skipped = 0
for src in OLD_ROOT.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(OLD_ROOT)
# The manifest path is "audio/..." — equivalent to the relative path
# under OLD_ROOT prefixed with "audio/". So a file at
# OLD_ROOT/sfx/turn_started.ogg matches "audio/sfx/turn_started.ogg".
manifest_key = f"audio/{rel.as_posix()}"
new_manifest_key = RENAMES.get(manifest_key, manifest_key)
# Special-case: LICENSES.md and sources.csv at OLD_ROOT root keep
# their basename, no "audio/" prefix.
if rel.parent == Path("."):
new_path = NEW_ROOT / rel.name
else:
assert new_manifest_key.startswith("audio/")
new_path = NEW_ROOT / new_manifest_key[len("audio/"):]
new_path.parent.mkdir(parents=True, exist_ok=True)
if new_path.exists():
skipped += 1
continue
shutil.move(str(src), str(new_path))
moved += 1
return moved, skipped
def step_update_manifest() -> int:
"""Rewrite audio.json stream paths."""
with open(MANIFEST) as f:
data = json.load(f)
rewrites = 0
for key, entry in data.get("sfx", {}).items():
if "stream" in entry and entry["stream"] in RENAMES:
entry["stream"] = RENAMES[entry["stream"]]
rewrites += 1
if "streams" in entry:
new_list = []
for s in entry["streams"]:
if s in RENAMES:
new_list.append(RENAMES[s])
rewrites += 1
else:
new_list.append(s)
entry["streams"] = new_list
# Music tracks didn't move (they were never in the flat-confusion zone),
# but defensive.
for track in data.get("music", {}).get("tracks", []):
if "stream" in track and track["stream"] in RENAMES:
track["stream"] = RENAMES[track["stream"]]
rewrites += 1
with open(MANIFEST, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
return rewrites
def step_update_sources_csv() -> int:
"""Rewrite output_path column in sources.csv."""
csv_path = NEW_ROOT / "sources.csv"
if not csv_path.exists():
return 0
head_lines: list[str] = []
body_lines: list[str] = []
with open(csv_path) as f:
for ln in f:
if ln.startswith("#"):
head_lines.append(ln)
else:
body_lines.append(ln)
reader = csv.reader(io.StringIO("".join(body_lines)))
rows = list(reader)
hdr, body = rows[0], rows[1:]
op_idx = hdr.index("output_path")
rewrites = 0
for r in body:
if r[op_idx] in RENAMES:
r[op_idx] = RENAMES[r[op_idx]]
rewrites += 1
with open(csv_path, "w") as f:
f.writelines(head_lines)
w = csv.writer(f)
w.writerow(hdr)
w.writerows(body)
return rewrites
def step_remove_old_root() -> None:
"""Delete OLD_ROOT if empty after the move."""
if not OLD_ROOT.exists():
return
leftover = list(OLD_ROOT.rglob("*"))
leftover_files = [p for p in leftover if p.is_file()]
if leftover_files:
print(f" WARN: {len(leftover_files)} files remain at {OLD_ROOT}")
for p in leftover_files[:5]:
print(f" {p}")
return
shutil.rmtree(OLD_ROOT)
# Walk up and prune empty parent dirs (assets/) but never above public/games/age-of-dwarves
parent = OLD_ROOT.parent # .../assets
if parent.is_dir() and not any(parent.iterdir()):
parent.rmdir()
def main() -> None:
NEW_ROOT.mkdir(parents=True, exist_ok=True)
moved, skipped = step_move_files()
print(f"moved {moved} files, skipped {skipped} (already at destination)")
rewrites = step_update_manifest()
print(f"manifest rewrites: {rewrites}")
csv_rewrites = step_update_sources_csv()
print(f"sources.csv rewrites: {csv_rewrites}")
step_remove_old_root()
print("done.")
if __name__ == "__main__":
main()