refactor(audio-tools): ♻️ Update audio asset scripts to support cross-theme resources and improve URL processing for batch fetching, options handling, and license rendering
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8d9eb02d99
commit
0be86d58ca
5 changed files with 402 additions and 77 deletions
|
|
@ -18,8 +18,8 @@ set -uo pipefail
|
|||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
THEME="age-of-dwarves"
|
||||
ASSETS_ROOT="$REPO_ROOT/public/games/$THEME/assets"
|
||||
# Audio assets live in the shared cross-theme pool, not under any one theme.
|
||||
ASSETS_ROOT="$REPO_ROOT/public/resources"
|
||||
SOURCES_CSV="$ASSETS_ROOT/audio/sources.csv"
|
||||
STAGING="$REPO_ROOT/.local/audio-staging"
|
||||
TODAY="$(date -u +%Y-%m-%d)"
|
||||
|
|
@ -56,31 +56,71 @@ while IFS=$'\t' read -r output_path source_url licence attribution edits; do
|
|||
mkdir -p "$(dirname "$full_path")"
|
||||
|
||||
stem="$(basename "$output_path" .ogg)"
|
||||
src_ext="${source_url##*.}"
|
||||
case "$src_ext" in
|
||||
wav|ogg|mp3|flac) ;;
|
||||
*) src_ext="bin" ;;
|
||||
esac
|
||||
staged="$STAGING/${stem}.${src_ext}"
|
||||
|
||||
# Convert github.com blob URLs to raw URLs automatically (still allow
|
||||
# raw URLs and other hosts unchanged).
|
||||
# Source URL may be one of:
|
||||
# 1. direct file URL ending in .wav/.ogg/.mp3/.flac
|
||||
# 2. github.com blob URL (auto-converted to raw)
|
||||
# 3. ZIP archive with an inner path: "<zip_url>#<inner/path/inside.wav>"
|
||||
fetch_url="$source_url"
|
||||
case "$source_url" in
|
||||
inner_path=""
|
||||
if [[ "$source_url" == *"#"* ]]; then
|
||||
fetch_url="${source_url%%#*}"
|
||||
inner_path="${source_url#*#}"
|
||||
fi
|
||||
case "$fetch_url" in
|
||||
https://github.com/*/blob/*)
|
||||
fetch_url="$(echo "$source_url" | sed -e 's|github.com|raw.githubusercontent.com|' -e 's|/blob/|/|')"
|
||||
fetch_url="$(echo "$fetch_url" | sed -e 's|github.com|raw.githubusercontent.com|' -e 's|/blob/|/|')"
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! curl -sfL -o "$staged" "$fetch_url"; then
|
||||
echo " ✗ download failed: $fetch_url" >&2
|
||||
fail=$((fail + 1))
|
||||
continue
|
||||
if [ -n "$inner_path" ]; then
|
||||
# ZIP path: cache the archive once per URL, extract a single
|
||||
# member into staging.
|
||||
zip_hash="$(printf '%s' "$fetch_url" | shasum | cut -c1-8)"
|
||||
zip_cache="$STAGING/_zip_${zip_hash}.zip"
|
||||
zip_extract_dir="$STAGING/_zip_${zip_hash}"
|
||||
if [ ! -f "$zip_cache" ]; then
|
||||
if ! curl -sfL -o "$zip_cache" "$fetch_url"; then
|
||||
echo " ✗ ZIP download failed: $fetch_url" >&2
|
||||
fail=$((fail + 1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
if [ ! -d "$zip_extract_dir" ]; then
|
||||
mkdir -p "$zip_extract_dir"
|
||||
if ! unzip -q -o "$zip_cache" -d "$zip_extract_dir"; then
|
||||
echo " ✗ ZIP extract failed: $zip_cache" >&2
|
||||
fail=$((fail + 1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
staged="$zip_extract_dir/$inner_path"
|
||||
if [ ! -f "$staged" ]; then
|
||||
echo " ✗ ZIP missing inner file: $inner_path" >&2
|
||||
fail=$((fail + 1))
|
||||
continue
|
||||
fi
|
||||
else
|
||||
src_ext="${fetch_url##*.}"
|
||||
case "$src_ext" in
|
||||
wav|ogg|mp3|flac) ;;
|
||||
*) src_ext="bin" ;;
|
||||
esac
|
||||
staged="$STAGING/${stem}.${src_ext}"
|
||||
if ! curl -sfL -o "$staged" "$fetch_url"; then
|
||||
echo " ✗ download failed: $fetch_url" >&2
|
||||
fail=$((fail + 1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# loudnorm two-pass would be more accurate, but for SFX one-pass is fine
|
||||
# at this scale. Music tracks should be normalised manually with two-pass.
|
||||
if ! ffmpeg -y -hide_banner -loglevel error \
|
||||
# `-nostdin` is critical: without it ffmpeg consumes characters from the
|
||||
# mapping file (the script's stdin), corrupting subsequent iterations'
|
||||
# output_path values. This caused dirs like `c/`, `sic/`, `music/`,
|
||||
# `udio/` to appear instead of the intended `audio/...` paths.
|
||||
if ! ffmpeg -y -nostdin -hide_banner -loglevel error \
|
||||
-i "$staged" \
|
||||
-af "loudnorm=I=-16:TP=-3:LRA=11,aresample=44100" \
|
||||
-c:a libvorbis -b:a 128k \
|
||||
|
|
|
|||
125
tools/audio-fetch-options.sh
Executable file
125
tools/audio-fetch-options.sh
Executable file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env bash
|
||||
# Pre-fetch every alternative declared in audio-options.json so the
|
||||
# design app at /audio can preview them via its dropdown selector.
|
||||
#
|
||||
# Files land at .local/audio-alternatives/<safe_path>/<idx>.ogg —
|
||||
# outside the shipped assets tree, so audio-validate.py's orphan scan
|
||||
# is not affected. The design app exposes them via Vite's `@audio-alts`
|
||||
# alias.
|
||||
#
|
||||
# Idempotent: skips alternatives whose .ogg already exists.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
THEME="${AUDIO_THEME:-age-of-dwarves}"
|
||||
OPTIONS_JSON="$REPO_ROOT/public/games/$THEME/data/audio-options.json"
|
||||
ALT_ROOT="$REPO_ROOT/.local/audio-alternatives"
|
||||
STAGING="$REPO_ROOT/.local/audio-staging"
|
||||
|
||||
if [ ! -f "$OPTIONS_JSON" ]; then
|
||||
echo "audio-options.json not found at $OPTIONS_JSON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ALT_ROOT" "$STAGING"
|
||||
|
||||
ok=0
|
||||
skip=0
|
||||
fail=0
|
||||
|
||||
# Walk every (output_path, idx, source_url) triple via python (jq isn't
|
||||
# guaranteed everywhere; python3 is). One line per triple, tab-separated.
|
||||
mapfile -t ROWS < <(python3 -c "
|
||||
import json
|
||||
data = json.load(open('$OPTIONS_JSON'))
|
||||
for output_path, opts in data.get('options', {}).items():
|
||||
for i, opt in enumerate(opts):
|
||||
url = opt.get('source_url', '')
|
||||
if not url:
|
||||
continue
|
||||
print(f'{output_path}\t{i}\t{url}')
|
||||
")
|
||||
|
||||
for row in "${ROWS[@]}"; do
|
||||
IFS=$'\t' read -r output_path idx source_url <<< "$row"
|
||||
[ -z "$output_path" ] && continue
|
||||
|
||||
safe_key="$(printf '%s' "$output_path" | tr '/' '_' | sed 's/\.ogg$//')"
|
||||
out_dir="$ALT_ROOT/$safe_key"
|
||||
out_file="$out_dir/$idx.ogg"
|
||||
|
||||
if [ -f "$out_file" ]; then
|
||||
skip=$((skip + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "→ $output_path[$idx]"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
fetch_url="$source_url"
|
||||
inner_path=""
|
||||
if [[ "$source_url" == *"#"* ]]; then
|
||||
fetch_url="${source_url%%#*}"
|
||||
inner_path="${source_url#*#}"
|
||||
fi
|
||||
case "$fetch_url" in
|
||||
https://github.com/*/blob/*)
|
||||
fetch_url="$(echo "$fetch_url" | sed -e 's|github.com|raw.githubusercontent.com|' -e 's|/blob/|/|')"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$inner_path" ]; then
|
||||
zip_hash="$(printf '%s' "$fetch_url" | shasum | cut -c1-8)"
|
||||
zip_cache="$STAGING/_zip_${zip_hash}.zip"
|
||||
zip_extract_dir="$STAGING/_zip_${zip_hash}"
|
||||
if [ ! -f "$zip_cache" ]; then
|
||||
if ! curl -sfL -o "$zip_cache" "$fetch_url"; then
|
||||
echo " ✗ ZIP download failed: $fetch_url" >&2
|
||||
fail=$((fail + 1)); continue
|
||||
fi
|
||||
fi
|
||||
if [ ! -d "$zip_extract_dir" ]; then
|
||||
mkdir -p "$zip_extract_dir"
|
||||
if ! unzip -q -o "$zip_cache" -d "$zip_extract_dir"; then
|
||||
echo " ✗ ZIP extract failed" >&2
|
||||
fail=$((fail + 1)); continue
|
||||
fi
|
||||
fi
|
||||
staged="$zip_extract_dir/$inner_path"
|
||||
if [ ! -f "$staged" ]; then
|
||||
echo " ✗ ZIP missing inner file: $inner_path" >&2
|
||||
fail=$((fail + 1)); continue
|
||||
fi
|
||||
else
|
||||
src_ext="${fetch_url##*.}"
|
||||
case "$src_ext" in
|
||||
wav|ogg|mp3|flac) ;;
|
||||
*) src_ext="bin" ;;
|
||||
esac
|
||||
staged="$STAGING/_alt_${safe_key}_${idx}.${src_ext}"
|
||||
if ! curl -sfL -o "$staged" "$fetch_url"; then
|
||||
echo " ✗ download failed: $fetch_url" >&2
|
||||
fail=$((fail + 1)); continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! ffmpeg -y -hide_banner -loglevel error \
|
||||
-i "$staged" \
|
||||
-af "loudnorm=I=-16:TP=-3:LRA=11,aresample=44100" \
|
||||
-c:a libvorbis -b:a 128k \
|
||||
"$out_file"; then
|
||||
echo " ✗ encode failed" >&2
|
||||
fail=$((fail + 1)); continue
|
||||
fi
|
||||
ok=$((ok + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "── alternatives summary ────"
|
||||
echo " ok: $ok"
|
||||
echo " skip: $skip"
|
||||
echo " fail: $fail"
|
||||
[ $fail -gt 0 ] && exit 1
|
||||
exit 0
|
||||
|
|
@ -168,7 +168,8 @@ def load_rows(csv_path: Path) -> list[dict]:
|
|||
|
||||
def validate_rows(rows: list[dict], theme: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
asset_root = REPO / "public" / "games" / theme / "assets"
|
||||
# Audio is shared — lives at public/resources/audio/, not under any theme.
|
||||
asset_root = REPO / "public" / "resources"
|
||||
seen_paths: set[str] = set()
|
||||
required = {"output_path", "source_url", "license", "attribution",
|
||||
"edits", "added"}
|
||||
|
|
@ -217,7 +218,10 @@ def main() -> int:
|
|||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asset_dir = REPO / "public" / "games" / args.theme / "assets" / "audio"
|
||||
# Audio assets are shared cross-theme; LICENSES.md and sources.csv live
|
||||
# at public/resources/audio/. The `--theme` flag is retained only for
|
||||
# the human-readable header in the rendered file.
|
||||
asset_dir = REPO / "public" / "resources" / "audio"
|
||||
sources_csv = asset_dir / "sources.csv"
|
||||
licenses_md = asset_dir / "LICENSES.md"
|
||||
|
||||
|
|
|
|||
192
tools/audio-reorganize.py
Normal file
192
tools/audio-reorganize.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/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()
|
||||
|
|
@ -5,19 +5,14 @@ Checks (per pack under `public/games/<theme>/data/audio.json`):
|
|||
|
||||
1. JSON validates against the schema at
|
||||
`public/games/<theme>/data/schemas/audio.schema.json`.
|
||||
2. Every `stream` / `streams[]` path that the manifest references either
|
||||
resolves to a real file under `public/games/<theme>/assets/audio/...`
|
||||
OR is reachable via a `fallback` chain that terminates at the
|
||||
`_silent` sentinel. Files that don't exist trigger a warning, not an
|
||||
error — `p2-16` is the asset-acquisition objective and lands files
|
||||
incrementally.
|
||||
2. Every `stream` / `streams[]` path resolves to a real file under
|
||||
`public/games/<theme>/assets/audio/...`. Missing files warn (the
|
||||
runtime fails loud at playback time via `audio_asset_missing`).
|
||||
3. No orphan `.ogg` files: every `.ogg` under `assets/audio/**` is
|
||||
referenced by at least one manifest entry. Orphans warn; not fatal.
|
||||
4. Every `fallback` reference is a valid sound key in the same manifest
|
||||
(or `_silent`).
|
||||
5. The `_silent` sentinel exists with an empty `streams: []`.
|
||||
4. `streams[]` arrays are non-empty (no silent-sentinel pattern).
|
||||
|
||||
Exit status: non-zero iff a structural problem (1, 4, 5) is found.
|
||||
Exit status: non-zero iff a structural problem (1, 4) is found.
|
||||
Missing files (2) and orphans (3) only warn — they're a normal
|
||||
intermediate state while assets land.
|
||||
|
||||
|
|
@ -33,7 +28,6 @@ from pathlib import Path
|
|||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_THEMES = ["age-of-dwarves"]
|
||||
SILENT_KEY = "_silent"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -83,7 +77,7 @@ def validate_schema(manifest: dict, report: Report) -> None:
|
|||
# Each SFX entry shape
|
||||
allowed_entry_keys = {
|
||||
"stream", "streams", "volume_db", "bus", "pitch_jitter",
|
||||
"fallback", "description",
|
||||
"description",
|
||||
}
|
||||
for key, entry in sfx.items():
|
||||
if not isinstance(entry, dict):
|
||||
|
|
@ -97,6 +91,19 @@ def validate_schema(manifest: dict, report: Report) -> None:
|
|||
f"sfx[{key!r}] declares both `stream` and `streams[]` — "
|
||||
f"streams[] takes precedence at runtime; drop `stream`"
|
||||
)
|
||||
if "streams" in entry:
|
||||
raw = entry["streams"]
|
||||
if isinstance(raw, list) and len(raw) == 0:
|
||||
report.fail(
|
||||
f"sfx[{key!r}] declares empty `streams: []` — fail-loud "
|
||||
f"policy forbids silent sentinels; remove the entry or "
|
||||
f"ship at least one stream"
|
||||
)
|
||||
if "stream" not in entry and "streams" not in entry:
|
||||
report.fail(
|
||||
f"sfx[{key!r}] declares neither `stream` nor `streams[]` — "
|
||||
f"every entry must point at at least one path"
|
||||
)
|
||||
if "pitch_jitter" in entry:
|
||||
j = entry["pitch_jitter"]
|
||||
if not isinstance(j, (int, float)) or j < 0.0 or j > 0.5:
|
||||
|
|
@ -104,51 +111,6 @@ def validate_schema(manifest: dict, report: Report) -> None:
|
|||
f"sfx[{key!r}].pitch_jitter must be a number in [0.0, 0.5]"
|
||||
)
|
||||
|
||||
# Sentinel
|
||||
if SILENT_KEY not in sfx:
|
||||
report.fail(
|
||||
f"sfx[{SILENT_KEY!r}] sentinel missing — fallback chains "
|
||||
f"need a terminator"
|
||||
)
|
||||
else:
|
||||
sentinel = sfx[SILENT_KEY]
|
||||
if not (isinstance(sentinel.get("streams"), list)
|
||||
and len(sentinel["streams"]) == 0):
|
||||
report.fail(
|
||||
f"sfx[{SILENT_KEY!r}] must declare `streams: []` (empty array)"
|
||||
)
|
||||
|
||||
# Fallback chain references resolve
|
||||
for key, entry in sfx.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fb = entry.get("fallback")
|
||||
if fb is None:
|
||||
continue
|
||||
if not isinstance(fb, str) or not fb:
|
||||
report.fail(f"sfx[{key!r}].fallback must be a non-empty string")
|
||||
continue
|
||||
if fb not in sfx:
|
||||
report.fail(
|
||||
f"sfx[{key!r}].fallback={fb!r} is not a valid sound key"
|
||||
)
|
||||
|
||||
# Cycle check on fallback graph
|
||||
for key in sfx:
|
||||
seen: set[str] = set()
|
||||
cur = key
|
||||
while cur in sfx:
|
||||
if cur in seen:
|
||||
report.fail(
|
||||
f"sfx[{key!r}] fallback chain cycles through {cur!r}"
|
||||
)
|
||||
break
|
||||
seen.add(cur)
|
||||
nxt = sfx[cur].get("fallback") if isinstance(sfx[cur], dict) else None
|
||||
if not nxt:
|
||||
break
|
||||
cur = str(nxt)
|
||||
|
||||
# Music tracks
|
||||
tracks = music.get("tracks", [])
|
||||
if not isinstance(tracks, list):
|
||||
|
|
@ -189,7 +151,9 @@ def collect_referenced_streams(manifest: dict) -> set[str]:
|
|||
|
||||
|
||||
def check_assets(theme: str, refs: set[str], report: Report) -> None:
|
||||
assets_root = REPO / "public" / "games" / theme / "assets"
|
||||
# Audio is shared across themes — lives in public/resources/audio/.
|
||||
# `theme` is retained for the warning prefix only.
|
||||
assets_root = REPO / "public" / "resources"
|
||||
missing: list[str] = []
|
||||
for rel in sorted(refs):
|
||||
if not rel:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue