You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1332 lines
46 KiB
1332 lines
46 KiB
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
anim_optimize.py - Headless Blender: Unreal-FBX-Animationen entbacken -> GLB.
|
|
|
|
Ordnerstruktur (neben dieser Datei):
|
|
Original\\ <- FBX aus Unreal hier reinlegen
|
|
Optimized\\ <- GLB landen hier, gleiche Basisnamen
|
|
optimize_log.txt <- vollstaendiges Protokoll jedes Laufs
|
|
|
|
Aufruf ueber optimize_all.bat, oder direkt:
|
|
blender -b -noaudio --factory-startup --python anim_optimize.py -- --root "C:\\pfad"
|
|
|
|
Pipeline pro Datei:
|
|
1. FBX importieren
|
|
2. Optional Bones nach Praefix / Nicht-Deform-Teilbaeume loeschen
|
|
3. Meshes/Materialien verwerfen (--anim-only)
|
|
4. Samplerate messen und gegen --expect-fps pruefen
|
|
5. Konstante Kanaele entfernen (Scale, Location auf Ruhewert)
|
|
6. Fehlerbegrenzte Dezimierung (RDP), Fehler in GRAD bzw. Blender-Einheiten
|
|
7. Alle Keys LINEAR, Werte runden
|
|
8. GLB exportieren mit export_force_sampling=False -> Keys bleiben erhalten
|
|
9. Optional gltfpack: Quantisierung + EXT_meshopt_compression
|
|
"""
|
|
|
|
import bpy
|
|
import sys
|
|
import os
|
|
import re
|
|
import math
|
|
import time
|
|
import shutil
|
|
import argparse
|
|
import contextlib
|
|
import traceback
|
|
from mathutils import Quaternion, Vector, Euler
|
|
|
|
|
|
# ============================================================================
|
|
# Argumente
|
|
# ============================================================================
|
|
|
|
def get_args():
|
|
argv = sys.argv
|
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
|
|
p = argparse.ArgumentParser(prog="anim_optimize")
|
|
|
|
# Ordner
|
|
p.add_argument("--root", default=os.path.dirname(os.path.abspath(__file__)),
|
|
help="Basisordner mit Original/ und Optimized/")
|
|
p.add_argument("--in-dir", default="Original")
|
|
p.add_argument("--out-dir", default="Optimized")
|
|
p.add_argument("--force", action="store_true",
|
|
help="bestehende Ziele ueberschreiben statt ueberspringen")
|
|
p.add_argument("--format", default="GLB", choices=["GLB", "GLTF", "FBX"])
|
|
|
|
# Fehlertoleranzen
|
|
p.add_argument("--rot-tol", type=float, default=0.25,
|
|
help="max. Winkelfehler in GRAD (Default 0.25)")
|
|
p.add_argument("--loc-tol-mm", type=float, default=0.5,
|
|
help="max. Positionsfehler in MILLIMETERN (Default 0.5). "
|
|
"Wird pro Datei anhand der Armature-Scale in "
|
|
"Blender-Einheiten umgerechnet.")
|
|
p.add_argument("--loc-tol", type=float, default=None,
|
|
help="max. Positionsfehler direkt in BLENDER-EINHEITEN. "
|
|
"Ueberschreibt --loc-tol-mm. Nur nutzen, wenn man die "
|
|
"Skalierung der Datei kennt.")
|
|
p.add_argument("--loose-rot-tol", type=float, default=1.0,
|
|
help="lockere Winkeltoleranz fuer Bones aus --loose-bones")
|
|
p.add_argument("--loose-bones", default="twist",
|
|
help="Komma-Liste von Teilstrings -> bekommen --loose-rot-tol. "
|
|
"Finger sind bewusst NICHT dabei.")
|
|
p.add_argument("--scale-tol", type=float, default=0.0005,
|
|
help="max. Skalierungsfehler (Default 0.0005)")
|
|
p.add_argument("--scale-snap", type=float, default=0.001,
|
|
help="Skalierungswerte naeher als dies an 1.0 werden auf "
|
|
"exakt 1.0 gesetzt. Faengt Float-Rauschen aus dem "
|
|
"Export ab (0 = aus)")
|
|
p.add_argument("--expect-fps", type=float, default=30.0,
|
|
help="erwartete Samplerate; Abweichung wird gemeldet (0 = aus)")
|
|
|
|
# Aufraeumen
|
|
p.add_argument("--anim-only", action="store_true",
|
|
help="nur Armature exportieren, Meshes/Materialien verwerfen")
|
|
p.add_argument("--deform-only", action="store_true",
|
|
help="Bone-Teilbaeume ohne Deform-Bones loeschen (IK/Control). "
|
|
"Wirkungslos, wenn kein geskinntes Mesh dabei ist.")
|
|
p.add_argument("--drop-bones", default="",
|
|
help="Komma-Liste: Bones mit diesem Praefix werden geloescht")
|
|
|
|
# gltfpack
|
|
p.add_argument("--gltfpack", default="",
|
|
help="Pfad zu gltfpack.exe -> zweiter Durchlauf mit Quantisierung")
|
|
p.add_argument("--gltfpack-args", default="-cc -kn -af 30",
|
|
help="Argumente fuer gltfpack. -kn ist PFLICHT: ohne das prunt "
|
|
"gltfpack die Bone-Hierarchie weg, weil in einer "
|
|
"Anim-only-GLB nichts die Nodes referenziert.")
|
|
|
|
# Ausgabe
|
|
p.add_argument("--round", dest="nd", type=int, default=4,
|
|
help="Nachkommastellen der Keyframe-Werte (0 = nicht runden)")
|
|
p.add_argument("--log", default="optimize_log.txt",
|
|
help="Logdatei relativ zu --root (leer = kein Log)")
|
|
p.add_argument("--report", action="store_true",
|
|
help="Key-Verteilung pro Bone ins Log schreiben")
|
|
p.add_argument("--report-top", type=int, default=20,
|
|
help="wie viele Bones im Report (Default 20)")
|
|
p.add_argument("--verbose", action="store_true",
|
|
help="Blender-Import/Export-Ausgabe nicht unterdruecken")
|
|
p.add_argument("--no-progress", action="store_true",
|
|
help="Fortschrittsbalken abschalten (z.B. fuer CI-Logs)")
|
|
p.add_argument("--no-color", action="store_true",
|
|
help="ANSI-Farben abschalten")
|
|
|
|
return p.parse_args(argv)
|
|
|
|
|
|
ARGS = get_args()
|
|
|
|
|
|
# ============================================================================
|
|
# Konsole, Farben, Logdatei
|
|
# ============================================================================
|
|
|
|
CONSOLE = sys.stdout # echte Konsole, bleibt immer erreichbar
|
|
LOGFH = None # Dateihandle des Protokolls
|
|
|
|
|
|
def _enable_vt():
|
|
"""Windows-Konsolen brauchen ENABLE_VIRTUAL_TERMINAL_PROCESSING explizit."""
|
|
if os.name != "nt":
|
|
return True
|
|
try:
|
|
import ctypes
|
|
k = ctypes.windll.kernel32
|
|
h = k.GetStdHandle(-11)
|
|
mode = ctypes.c_uint32()
|
|
if not k.GetConsoleMode(h, ctypes.byref(mode)):
|
|
return False
|
|
return bool(k.SetConsoleMode(h, mode.value | 0x0004))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
USE_COLOR = (not ARGS.no_color) and _enable_vt()
|
|
|
|
DIM, CYAN, GREEN, RED, YELLOW, BOLD = "90", "96", "92", "91", "93", "1"
|
|
|
|
ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
|
|
|
|
def c(code, text):
|
|
return "\033[%sm%s\033[0m" % (code, text) if USE_COLOR else text
|
|
|
|
|
|
def strip_ansi(s):
|
|
return ANSI_RE.sub("", s)
|
|
|
|
|
|
def logf(text=""):
|
|
"""Nur ins Protokoll."""
|
|
if LOGFH:
|
|
LOGFH.write(strip_ansi(text) + "\n")
|
|
LOGFH.flush()
|
|
|
|
|
|
def log(*a):
|
|
"""Konsole und Protokoll."""
|
|
text = " ".join(str(x) for x in a)
|
|
print(text, file=CONSOLE)
|
|
CONSOLE.flush()
|
|
logf(text)
|
|
|
|
|
|
def open_log(path):
|
|
global LOGFH
|
|
if not path:
|
|
return
|
|
try:
|
|
LOGFH = open(path, "w", encoding="utf-8")
|
|
except Exception as e:
|
|
print("Logdatei nicht schreibbar: %s" % e, file=CONSOLE)
|
|
LOGFH = None
|
|
|
|
|
|
def close_log():
|
|
global LOGFH
|
|
if LOGFH:
|
|
try:
|
|
LOGFH.close()
|
|
except Exception:
|
|
pass
|
|
LOGFH = None
|
|
|
|
|
|
class _NullWriter:
|
|
"""Schluckt alles. Ersetzt sys.stdout waehrend Import/Export."""
|
|
encoding = "utf-8"
|
|
errors = "replace"
|
|
|
|
def write(self, s):
|
|
return len(s)
|
|
|
|
def writelines(self, lines):
|
|
pass
|
|
|
|
def flush(self):
|
|
pass
|
|
|
|
def isatty(self):
|
|
return False
|
|
|
|
def fileno(self):
|
|
raise OSError("kein Dateideskriptor")
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
@property
|
|
def closed(self):
|
|
return False
|
|
|
|
|
|
class _TeeWriter(_NullWriter):
|
|
"""Schreibt Blender-Ausgabe ins Protokoll, aber nicht auf die Konsole."""
|
|
|
|
def write(self, s):
|
|
if LOGFH and s.strip():
|
|
LOGFH.write(s if s.endswith("\n") else s + "\n")
|
|
return len(s)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def quiet_stdout():
|
|
"""Blender-Chatter von der Konsole nehmen, damit der Fortschrittsbalken
|
|
sauber bleibt - aber ins Protokoll durchreichen.
|
|
|
|
WICHTIG: kein os.dup2 auf fd 1. Unter Windows haengt sys.stdout an einem
|
|
_WindowsConsoleIO, das WriteConsoleW benutzt. Sobald fd 1 keine Konsole
|
|
mehr ist, scheitert jeder Schreibzugriff mit WinError 1. Stattdessen wird
|
|
sys.stdout auf Python-Ebene getauscht - Importer und Exporter sind Addons,
|
|
deren print() sys.stdout zur Laufzeit aufloest.
|
|
"""
|
|
if ARGS.verbose:
|
|
yield
|
|
return
|
|
old = sys.stdout
|
|
sys.stdout = _TeeWriter()
|
|
try:
|
|
yield
|
|
finally:
|
|
sys.stdout = old
|
|
|
|
|
|
# ============================================================================
|
|
# Fortschrittsanzeige
|
|
# ============================================================================
|
|
|
|
def fmt_time(sec):
|
|
if sec is None or sec < 0 or sec != sec or sec == float("inf"):
|
|
return "--:--"
|
|
sec = int(round(sec))
|
|
h, rem = divmod(sec, 3600)
|
|
m, s = divmod(rem, 60)
|
|
return "%d:%02d:%02d" % (h, m, s) if h else "%02d:%02d" % (m, s)
|
|
|
|
|
|
def fmt_size(n):
|
|
"""Bytes lesbar: KB unterhalb von 1 MB, sonst MB."""
|
|
if n < 1024 * 1024:
|
|
return "%.0f KB" % (n / 1024.0)
|
|
return "%.2f MB" % (n / 1e6)
|
|
|
|
|
|
def ellipsis(text, width):
|
|
text = str(text)
|
|
return text if len(text) <= width else text[:width - 1] + "~"
|
|
|
|
|
|
class Progress:
|
|
"""Byte-gewichteter Fortschritt. Grosse Dateien dauern laenger als kleine,
|
|
deshalb ist die ETA ueber verarbeitete Bytes stabiler als ueber Dateizahl."""
|
|
|
|
BAR_W = 20
|
|
NAME_W = 22
|
|
|
|
def __init__(self, total_files, total_bytes, enabled=True):
|
|
self.total_files = total_files
|
|
self.total_bytes = max(1, total_bytes)
|
|
self.done_bytes = 0
|
|
self.done_files = 0
|
|
self.t0 = time.time()
|
|
self.enabled = bool(enabled)
|
|
self.last_len = 0
|
|
self.idx_w = len(str(total_files))
|
|
|
|
def file_done(self, size):
|
|
self.done_bytes += size
|
|
self.done_files += 1
|
|
|
|
def file_skipped(self, size):
|
|
"""Uebersprungene Dateien kosten keine Zeit -> aus der Basis nehmen,
|
|
sonst laeuft die ETA systematisch zu hoch."""
|
|
self.total_bytes = max(1, self.total_bytes - size)
|
|
self.done_files += 1
|
|
|
|
def eta(self):
|
|
elapsed = time.time() - self.t0
|
|
if self.done_bytes > 0 and elapsed > 1.0:
|
|
rate = self.done_bytes / elapsed
|
|
return (self.total_bytes - self.done_bytes) / rate
|
|
return None
|
|
|
|
def _bar(self):
|
|
frac = min(1.0, self.done_bytes / self.total_bytes)
|
|
n = int(round(frac * self.BAR_W))
|
|
return "[" + "#" * n + "-" * (self.BAR_W - n) + "]", frac
|
|
|
|
def status(self, idx, name, phase):
|
|
if not self.enabled:
|
|
return
|
|
bar, frac = self._bar()
|
|
head = " [%*d/%d]" % (self.idx_w, idx, self.total_files)
|
|
line = "%s %s %3d%% ETA %s %-*s %s" % (
|
|
c(DIM, head), c(CYAN, bar), int(frac * 100),
|
|
fmt_time(self.eta()), self.NAME_W, ellipsis(name, self.NAME_W),
|
|
c(DIM, phase))
|
|
self._write(line, visible=len(head) + len(bar) + 24 + self.NAME_W + len(phase))
|
|
|
|
def _write(self, line, visible=None):
|
|
vis = visible if visible is not None else len(line)
|
|
pad = max(0, self.last_len - vis)
|
|
CONSOLE.write("\r" + line + " " * pad)
|
|
CONSOLE.flush()
|
|
self.last_len = vis
|
|
|
|
def clear(self):
|
|
if self.enabled and self.last_len:
|
|
CONSOLE.write("\r" + " " * self.last_len + "\r")
|
|
CONSOLE.flush()
|
|
self.last_len = 0
|
|
|
|
def line(self, text):
|
|
"""Bleibende Zeile: Konsole und Protokoll, ohne den Balken zu zerhacken."""
|
|
self.clear()
|
|
print(text, file=CONSOLE)
|
|
CONSOLE.flush()
|
|
logf(text)
|
|
|
|
|
|
# ============================================================================
|
|
# Blender-Kompatibilitaet
|
|
# ============================================================================
|
|
|
|
def filter_kwargs(op, kwargs):
|
|
"""Entfernt Parameter, die diese Blender-Version nicht kennt."""
|
|
try:
|
|
valid = {p.identifier for p in op.get_rna_type().properties}
|
|
except Exception:
|
|
return kwargs
|
|
dropped = sorted(k for k in kwargs if k not in valid)
|
|
if dropped:
|
|
logf(" Exporter-Parameter in dieser Blender-Version unbekannt, "
|
|
"ignoriert: %s" % ", ".join(dropped))
|
|
return {k: v for k, v in kwargs.items() if k in valid}
|
|
|
|
|
|
def action_fcurves(obj):
|
|
"""FCurve-Collection der aktiven Action, auch bei slotted actions (4.4+)."""
|
|
ad = obj.animation_data
|
|
if not ad or not ad.action:
|
|
return None
|
|
act = ad.action
|
|
try:
|
|
if hasattr(act, "layers") and len(act.layers) > 0:
|
|
strip = act.layers[0].strips[0]
|
|
slot = getattr(ad, "action_slot", None)
|
|
cb = strip.channelbag(slot) if slot else None
|
|
if cb is not None:
|
|
return cb.fcurves
|
|
except Exception:
|
|
pass
|
|
return act.fcurves
|
|
|
|
|
|
def remove_key(kps, index):
|
|
try:
|
|
kps.remove(kps[index], do_fast=True)
|
|
except TypeError:
|
|
kps.remove(kps[index])
|
|
|
|
|
|
# ============================================================================
|
|
# Ramer-Douglas-Peucker, fehlerbegrenzt
|
|
# ============================================================================
|
|
|
|
def rdp(n, err_fn, tol):
|
|
if n <= 2:
|
|
return list(range(n))
|
|
keep = {0, n - 1}
|
|
stack = [(0, n - 1)]
|
|
while stack:
|
|
a, b = stack.pop()
|
|
if b - a < 2:
|
|
continue
|
|
worst_i, worst_e = -1, -1.0
|
|
for i in range(a + 1, b):
|
|
e = err_fn(a, b, i)
|
|
if e > worst_e:
|
|
worst_e, worst_i = e, i
|
|
if worst_e > tol:
|
|
keep.add(worst_i)
|
|
stack.append((a, worst_i))
|
|
stack.append((worst_i, b))
|
|
return sorted(keep)
|
|
|
|
|
|
def quat_error_fn(qs):
|
|
"""glTF interpoliert Rotation per Slerp -> exakt diese Metrik."""
|
|
def f(a, b, i):
|
|
t = (i - a) / (b - a)
|
|
q = qs[a].slerp(qs[b], t)
|
|
d = min(1.0, abs(q.dot(qs[i])))
|
|
return 2.0 * math.degrees(math.acos(d))
|
|
return f
|
|
|
|
|
|
def euler_error_fn(es):
|
|
qs = [e.to_quaternion() for e in es]
|
|
|
|
def f(a, b, i):
|
|
t = (i - a) / (b - a)
|
|
e = Euler(tuple(es[a][k] + (es[b][k] - es[a][k]) * t for k in range(3)),
|
|
es[a].order)
|
|
d = min(1.0, abs(e.to_quaternion().dot(qs[i])))
|
|
return 2.0 * math.degrees(math.acos(d))
|
|
return f
|
|
|
|
|
|
def vec_error_fn(vs):
|
|
def f(a, b, i):
|
|
t = (i - a) / (b - a)
|
|
return (vs[a].lerp(vs[b], t) - vs[i]).length
|
|
return f
|
|
|
|
|
|
# ============================================================================
|
|
# Kurven
|
|
# ============================================================================
|
|
|
|
DEFAULTS = {"location": 0.0, "rotation_quaternion": None,
|
|
"rotation_euler": 0.0, "scale": 1.0}
|
|
EPS_CONST = 1e-6
|
|
|
|
|
|
def bone_name_of(data_path):
|
|
if 'pose.bones["' not in data_path:
|
|
return None
|
|
s = data_path.index('pose.bones["') + len('pose.bones["')
|
|
return data_path[s:data_path.index('"]', s)]
|
|
|
|
|
|
def channel_of(data_path):
|
|
return data_path.rsplit(".", 1)[-1]
|
|
|
|
|
|
def default_value(channel, array_index):
|
|
if channel == "rotation_quaternion":
|
|
return 1.0 if array_index == 0 else 0.0
|
|
return DEFAULTS.get(channel, 0.0)
|
|
|
|
|
|
def is_constant(fc):
|
|
kps = fc.keyframe_points
|
|
if len(kps) <= 1:
|
|
return True
|
|
vals = [kp.co[1] for kp in kps]
|
|
return (max(vals) - min(vals)) < EPS_CONST
|
|
|
|
|
|
def group_curves(fcurves):
|
|
groups = {}
|
|
for fc in fcurves:
|
|
groups.setdefault(fc.data_path, {})[fc.array_index] = fc
|
|
return groups
|
|
|
|
|
|
def remove_dead_fcurves(arm, fcurves):
|
|
bones = set(arm.pose.bones.keys())
|
|
dead = [fc for fc in fcurves
|
|
if bone_name_of(fc.data_path) is not None
|
|
and bone_name_of(fc.data_path) not in bones]
|
|
for fc in dead:
|
|
fcurves.remove(fc)
|
|
return len(dead)
|
|
|
|
|
|
def collapse_constants(fcurves, scale_snap=0.0):
|
|
"""Konstante Kanalgruppen entfernen bzw. auf einen Key reduzieren.
|
|
|
|
WICHTIG: das laeuft gruppenweise, nicht pro Kurve. Wuerde hier eine
|
|
einzelne Quaternion-Komponente verschwinden, haette die Gruppe nur noch
|
|
3 statt 4 Kurven - dann greift die Dezimierung nicht mehr, und der Bone
|
|
behaelt saemtliche Keys."""
|
|
removed = collapsed = 0
|
|
for path, curves in group_curves(fcurves).items():
|
|
ch = channel_of(path)
|
|
if ch not in DEFAULTS:
|
|
continue
|
|
|
|
# Float-Rauschen aus dem Export auf exakt 1.0 ziehen
|
|
if ch == "scale" and scale_snap > 0:
|
|
for fc in curves.values():
|
|
touched = False
|
|
for kp in fc.keyframe_points:
|
|
if kp.co[1] != 1.0 and abs(kp.co[1] - 1.0) < scale_snap:
|
|
kp.co[1] = 1.0
|
|
touched = True
|
|
if touched:
|
|
fc.update()
|
|
|
|
if not all(is_constant(fc) for fc in curves.values()):
|
|
continue
|
|
|
|
def val(fc):
|
|
kps = fc.keyframe_points
|
|
return kps[0].co[1] if len(kps) else default_value(ch, fc.array_index)
|
|
|
|
at_rest = all(abs(val(fc) - default_value(ch, fc.array_index)) < EPS_CONST
|
|
for fc in curves.values())
|
|
if at_rest:
|
|
for fc in list(curves.values()):
|
|
fcurves.remove(fc)
|
|
removed += 1
|
|
else:
|
|
for fc in curves.values():
|
|
kps = fc.keyframe_points
|
|
for i in range(len(kps) - 1, 0, -1):
|
|
remove_key(kps, i)
|
|
fc.update()
|
|
collapsed += 1
|
|
return removed, collapsed
|
|
|
|
|
|
def prune_group(curves, keep_idx):
|
|
keep = set(keep_idx)
|
|
for fc in curves.values():
|
|
kps = fc.keyframe_points
|
|
for i in range(len(kps) - 1, -1, -1):
|
|
if i not in keep:
|
|
remove_key(kps, i)
|
|
fc.update()
|
|
|
|
|
|
def decimate(fcurves, args, loc_tol, tick=None):
|
|
loose = [s.strip().lower() for s in args.loose_bones.split(",") if s.strip()]
|
|
groups = group_curves(fcurves)
|
|
before = sum(len(fc.keyframe_points) for fc in fcurves)
|
|
n_groups = max(1, len(groups))
|
|
per_channel = {}
|
|
|
|
for gi, (path, curves) in enumerate(groups.items()):
|
|
if tick and gi % 8 == 0:
|
|
tick(gi / n_groups)
|
|
|
|
ch = channel_of(path)
|
|
bone = (bone_name_of(path) or "").lower()
|
|
n = min(len(fc.keyframe_points) for fc in curves.values())
|
|
per_channel.setdefault(ch, [0, 0])
|
|
per_channel[ch][0] += n * len(curves)
|
|
if n < 3:
|
|
per_channel[ch][1] += n * len(curves)
|
|
continue
|
|
|
|
rot_tol = args.loose_rot_tol if any(t in bone for t in loose) else args.rot_tol
|
|
|
|
def col(idx, i):
|
|
return curves[idx].keyframe_points[i].co[1]
|
|
|
|
kept = n
|
|
if ch == "rotation_quaternion" and len(curves) == 4:
|
|
qs = [Quaternion((col(0, i), col(1, i), col(2, i), col(3, i)))
|
|
for i in range(n)]
|
|
for i in range(1, n): # Vorzeichen-Kontinuitaet
|
|
if qs[i].dot(qs[i - 1]) < 0.0:
|
|
qs[i].negate()
|
|
for k in range(4):
|
|
curves[k].keyframe_points[i].co[1] = qs[i][k]
|
|
keep = rdp(n, quat_error_fn(qs), rot_tol)
|
|
prune_group(curves, keep)
|
|
kept = len(keep)
|
|
|
|
elif ch == "rotation_euler" and len(curves) == 3:
|
|
es = [Euler((col(0, i), col(1, i), col(2, i))) for i in range(n)]
|
|
keep = rdp(n, euler_error_fn(es), rot_tol)
|
|
prune_group(curves, keep)
|
|
kept = len(keep)
|
|
|
|
elif ch in ("location", "scale") and len(curves) == 3:
|
|
vs = [Vector((col(0, i), col(1, i), col(2, i))) for i in range(n)]
|
|
tol = loc_tol if ch == "location" else args.scale_tol
|
|
keep = rdp(n, vec_error_fn(vs), tol)
|
|
prune_group(curves, keep)
|
|
kept = len(keep)
|
|
|
|
per_channel[ch][1] += kept * len(curves)
|
|
|
|
after = sum(len(fc.keyframe_points) for fc in fcurves)
|
|
return before, after, per_channel
|
|
|
|
|
|
def align_group_times(fcurves):
|
|
"""glTF braucht pro Kanal identische Key-Zeiten ueber alle Komponenten."""
|
|
fixed = 0
|
|
for path, curves in group_curves(fcurves).items():
|
|
times = [tuple(round(kp.co[0], 5) for kp in fc.keyframe_points)
|
|
for fc in curves.values()]
|
|
if len(set(times)) <= 1:
|
|
continue
|
|
union = sorted(set().union(*[set(t) for t in times]))
|
|
for fc in curves.values():
|
|
have = {round(kp.co[0], 5) for kp in fc.keyframe_points}
|
|
for t in union:
|
|
if t not in have:
|
|
fc.keyframe_points.insert(t, fc.evaluate(t), options={'FAST'})
|
|
fc.update()
|
|
fixed += 1
|
|
return fixed
|
|
|
|
|
|
def finalize_curves(fcurves, nd):
|
|
for fc in fcurves:
|
|
for kp in fc.keyframe_points:
|
|
kp.interpolation = 'LINEAR'
|
|
if nd > 0:
|
|
kp.co[1] = round(kp.co[1], nd)
|
|
fc.update()
|
|
|
|
|
|
def bone_key_histogram(fcurves):
|
|
"""Keys pro Bone, absteigend. Zeigt, wo die Daten wirklich stecken."""
|
|
hist = {}
|
|
for fc in fcurves:
|
|
b = bone_name_of(fc.data_path) or "<object>"
|
|
hist[b] = hist.get(b, 0) + len(fc.keyframe_points)
|
|
return sorted(hist.items(), key=lambda kv: -kv[1])
|
|
|
|
|
|
# ============================================================================
|
|
# Samplerate
|
|
# ============================================================================
|
|
|
|
def effective_loc_tol(args, unit_scale, obj_scale):
|
|
"""Rechnet --loc-tol-mm in Blender-Einheiten um.
|
|
|
|
Der FBX-Import legt die Umrechnung Unreal-cm -> Meter NICHT in die
|
|
Bone-Werte, sondern in die Objekt-Skalierung des Armatures (typisch 0.01).
|
|
Ein Keyframe-Wert von 1.0 entspricht daher 1.0 * obj_scale * scale_length
|
|
Metern. Wer das uebersieht, arbeitet um Faktor 100 zu streng.
|
|
"""
|
|
if args.loc_tol is not None:
|
|
return args.loc_tol, "direkt vorgegeben"
|
|
|
|
factor = abs(obj_scale) * abs(unit_scale)
|
|
if factor < 1e-9:
|
|
return args.loc_tol_mm / 1000.0, "Skalierung unbrauchbar, 1 BU = 1 m angenommen"
|
|
tol = (args.loc_tol_mm / 1000.0) / factor
|
|
return tol, "aus %.4g mm" % args.loc_tol_mm
|
|
|
|
|
|
def mean_scale(vec):
|
|
"""Mittlere Achsskalierung. Nicht-uniforme Rigs sind selten, aber moeglich."""
|
|
vals = [abs(v) for v in vec]
|
|
return sum(vals) / len(vals) if vals else 1.0
|
|
|
|
|
|
def detect_rate(scene, fcurves):
|
|
"""Liefert (fps, key_step, effektive Keys pro Sekunde).
|
|
|
|
Unreal backt normalerweise einen Key pro Frame, dann ist key_step 1.0 und
|
|
die effektive Rate gleich der Szenen-FPS. Ein key_step von 2.0 bedeutet,
|
|
dass die Quelle schon halbiert wurde."""
|
|
fps = scene.render.fps / max(1e-9, scene.render.fps_base)
|
|
step = 1.0
|
|
best = None
|
|
for path, curves in group_curves(fcurves).items():
|
|
if channel_of(path) not in ("rotation_quaternion", "rotation_euler"):
|
|
continue
|
|
fc = next(iter(curves.values()))
|
|
if best is None or len(fc.keyframe_points) > len(best.keyframe_points):
|
|
best = fc
|
|
if best is not None and len(best.keyframe_points) > 2:
|
|
frames = [kp.co[0] for kp in best.keyframe_points]
|
|
diffs = sorted(round(b - a, 4) for a, b in zip(frames, frames[1:]))
|
|
step = diffs[len(diffs) // 2] or 1.0
|
|
return fps, step, fps / step
|
|
|
|
|
|
# ============================================================================
|
|
# Szene / Bones
|
|
# ============================================================================
|
|
|
|
def wipe_scene():
|
|
with quiet_stdout():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
for coll in (bpy.data.actions, bpy.data.objects, bpy.data.armatures,
|
|
bpy.data.meshes, bpy.data.materials, bpy.data.images,
|
|
bpy.data.textures, bpy.data.node_groups):
|
|
for item in list(coll):
|
|
try:
|
|
coll.remove(item)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def enter_edit(arm):
|
|
bpy.context.view_layer.objects.active = arm
|
|
arm.select_set(True)
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
|
|
|
|
def drop_bones_by_prefix(arm, prefixes):
|
|
prefixes = [p.strip() for p in prefixes.split(",") if p.strip()]
|
|
if not prefixes:
|
|
return 0
|
|
enter_edit(arm)
|
|
eb = arm.data.edit_bones
|
|
victims = [b for b in eb if any(b.name.startswith(p) for p in prefixes)]
|
|
for b in victims:
|
|
eb.remove(b)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
return len(victims)
|
|
|
|
|
|
def drop_nondeform_subtrees(arm):
|
|
"""Loescht nur Bones, deren KOMPLETTER Teilbaum keinen Deform-Bone enthaelt.
|
|
Zwischenknoten bleiben stehen - sonst waeren die lokalen Transformationen
|
|
der Kinder falsch."""
|
|
enter_edit(arm)
|
|
eb = arm.data.edit_bones
|
|
cache = {}
|
|
|
|
def has_deform(b):
|
|
if b.name in cache:
|
|
return cache[b.name]
|
|
cache[b.name] = True # Zyklusschutz
|
|
r = bool(b.use_deform) or any(has_deform(ch) for ch in b.children)
|
|
cache[b.name] = r
|
|
return r
|
|
|
|
victims = [b for b in eb if not has_deform(b)]
|
|
names = {b.name for b in victims}
|
|
roots = [b for b in victims if b.parent is None or b.parent.name not in names]
|
|
for b in roots:
|
|
eb.remove(b)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
return len(victims)
|
|
|
|
|
|
def purge_non_armature():
|
|
for ob in list(bpy.data.objects):
|
|
if ob.type != 'ARMATURE':
|
|
bpy.data.objects.remove(ob, do_unlink=True)
|
|
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images,
|
|
bpy.data.textures, bpy.data.node_groups):
|
|
for item in list(coll):
|
|
if item.users == 0:
|
|
coll.remove(item)
|
|
|
|
|
|
# ============================================================================
|
|
# Export
|
|
# ============================================================================
|
|
|
|
EXT_OF = {"GLB": ".glb", "GLTF": ".gltf", "FBX": ".fbx"}
|
|
|
|
|
|
def export_gltf(dst, args):
|
|
kw = dict(
|
|
filepath=dst,
|
|
export_format='GLB' if args.format == 'GLB' else 'GLTF_SEPARATE',
|
|
use_selection=False,
|
|
export_apply=False,
|
|
export_yup=True,
|
|
export_extras=False,
|
|
export_cameras=False,
|
|
export_lights=False,
|
|
export_materials='NONE',
|
|
export_image_format='NONE',
|
|
export_texcoords=False,
|
|
export_normals=False,
|
|
export_tangents=False,
|
|
export_colors=False,
|
|
export_morph=False,
|
|
export_skins=not args.anim_only,
|
|
export_animations=True,
|
|
export_animation_mode='ACTIVE_ACTIONS',
|
|
export_force_sampling=False, # Kernpunkt: unsere Keys bleiben
|
|
export_bake_animation=False,
|
|
export_anim_single_armature=True,
|
|
export_optimize_animation_size=True,
|
|
export_optimize_animation_keep_anim_armature=False,
|
|
export_optimize_animation_keep_anim_object=False,
|
|
export_current_frame=False,
|
|
export_nla_strips=False,
|
|
export_def_bones=False,
|
|
export_hierarchy_full_collections=False,
|
|
)
|
|
with quiet_stdout():
|
|
bpy.ops.export_scene.gltf(**filter_kwargs(bpy.ops.export_scene.gltf, kw))
|
|
|
|
|
|
def export_fbx(dst, args):
|
|
kw = dict(
|
|
filepath=dst,
|
|
use_selection=False,
|
|
object_types={'ARMATURE'} if args.anim_only else {'ARMATURE', 'MESH'},
|
|
add_leaf_bones=False,
|
|
use_custom_props=False,
|
|
use_mesh_modifiers=False,
|
|
path_mode='STRIP',
|
|
embed_textures=False,
|
|
bake_anim=True,
|
|
bake_anim_use_all_bones=False,
|
|
bake_anim_use_nla_strips=False,
|
|
bake_anim_use_all_actions=False,
|
|
bake_anim_force_startend_keying=False,
|
|
bake_anim_step=1.0,
|
|
bake_anim_simplify_factor=1.0,
|
|
axis_forward='-Z',
|
|
axis_up='Y',
|
|
)
|
|
with quiet_stdout():
|
|
bpy.ops.export_scene.fbx(**filter_kwargs(bpy.ops.export_scene.fbx, kw))
|
|
|
|
|
|
def resolve_written(path, fmt):
|
|
"""Blenders Exporter erzwingen ihre Endung via bpy.path.ensure_ext().
|
|
Aus 'Name.glb.part' wird dadurch 'Name.glb.part.glb'. Hier wird ermittelt,
|
|
welche Datei wirklich auf der Platte gelandet ist."""
|
|
if os.path.exists(path):
|
|
return path
|
|
ext = EXT_OF[fmt]
|
|
for cand in (path + ext, os.path.splitext(path)[0] + ext):
|
|
if os.path.exists(cand):
|
|
return cand
|
|
raise RuntimeError("Export hat keine Datei erzeugt (%s)" % os.path.basename(path))
|
|
|
|
|
|
# ============================================================================
|
|
# gltfpack
|
|
# ============================================================================
|
|
|
|
def gltfpack_help(exe):
|
|
"""Hilfetext von gltfpack, um vorhandene Optionen zu erkennen."""
|
|
import subprocess
|
|
try:
|
|
r = subprocess.run([exe, "-h"], capture_output=True, text=True, timeout=30)
|
|
except Exception:
|
|
return ""
|
|
return (r.stdout or "") + (r.stderr or "")
|
|
|
|
|
|
def adjust_pack_args(exe, args_str):
|
|
"""Passt die gltfpack-Argumente an das an, was das Binary wirklich kann,
|
|
und erzwingt -kn.
|
|
|
|
-kn ist bei Anim-only-GLB nicht optional: ohne Mesh und Skin referenziert
|
|
nichts die Bone-Nodes, und gltfpack wuerde die Hierarchie wegprunen.
|
|
Danach findet three.js die Tracks nicht mehr."""
|
|
import shlex
|
|
text = gltfpack_help(exe)
|
|
toks = shlex.split(args_str)
|
|
notes = []
|
|
|
|
if text:
|
|
for i, t in enumerate(toks):
|
|
if t in ("-cz", "-ce") and t not in text:
|
|
toks[i] = "-cc"
|
|
notes.append("%s wird von diesem gltfpack nicht unterstuetzt, "
|
|
"ersetzt durch -cc" % t)
|
|
if "-cz" in text and not any(t in ("-c", "-cc", "-cz", "-ce") for t in toks):
|
|
notes.append("keine Kompressionsoption gesetzt - -cc oder -cz waere "
|
|
"deutlich kleiner")
|
|
|
|
if "-kn" not in toks:
|
|
toks.append("-kn")
|
|
notes.append("-kn ergaenzt (Pflicht, sonst prunt gltfpack die Bones weg)")
|
|
|
|
return " ".join(toks), notes
|
|
|
|
|
|
def gltfpack_version(exe):
|
|
import subprocess
|
|
try:
|
|
r = subprocess.run([exe, "-v"], capture_output=True, text=True, timeout=30)
|
|
out = (r.stdout or r.stderr or "").strip().splitlines()
|
|
return out[0] if out else "unbekannt"
|
|
except Exception as e:
|
|
return "nicht aufrufbar (%s)" % e
|
|
|
|
|
|
def run_gltfpack(path, exe, extra_args):
|
|
"""Zweiter Durchlauf: Quantisierung + EXT_meshopt_compression.
|
|
Bei Fehlschlag bleibt die unkomprimierte Datei erhalten."""
|
|
import subprocess
|
|
import shlex
|
|
tmp = path + ".pack.glb"
|
|
cmd = [exe, "-i", path, "-o", tmp] + shlex.split(extra_args)
|
|
logf(" gltfpack: %s" % " ".join(cmd))
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
|
except Exception as e:
|
|
return None, "gltfpack nicht ausfuehrbar: %s" % e
|
|
if (r.stdout or "").strip():
|
|
logf(" " + (r.stdout or "").strip().replace("\n", "\n "))
|
|
if (r.stderr or "").strip():
|
|
logf(" stderr: " + (r.stderr or "").strip().replace("\n", "\n "))
|
|
if r.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0:
|
|
if os.path.exists(tmp):
|
|
try:
|
|
os.remove(tmp)
|
|
except Exception:
|
|
pass
|
|
err = (r.stderr or r.stdout or "").strip().splitlines()
|
|
return None, err[-1] if err else "gltfpack rc=%d" % r.returncode
|
|
before = os.path.getsize(path)
|
|
os.remove(path)
|
|
shutil.move(tmp, path)
|
|
return before, None
|
|
|
|
|
|
# ============================================================================
|
|
# Eine Datei
|
|
# ============================================================================
|
|
|
|
def process_one(src, dst, args, phase=lambda s: None):
|
|
phase("Szene leeren")
|
|
wipe_scene()
|
|
|
|
phase("Import")
|
|
with quiet_stdout():
|
|
bpy.ops.import_scene.fbx(**filter_kwargs(bpy.ops.import_scene.fbx, dict(
|
|
filepath=src,
|
|
use_anim=True,
|
|
ignore_leaf_bones=True,
|
|
automatic_bone_orientation=False,
|
|
use_custom_props=False,
|
|
)))
|
|
|
|
arms = [o for o in bpy.data.objects if o.type == 'ARMATURE']
|
|
if not arms:
|
|
raise RuntimeError("kein Armature im FBX")
|
|
arm = arms[0]
|
|
n_bones_in = len(arm.data.bones)
|
|
|
|
phase("Bones")
|
|
dropped = 0
|
|
if args.drop_bones:
|
|
dropped += drop_bones_by_prefix(arm, args.drop_bones)
|
|
if args.deform_only:
|
|
dropped += drop_nondeform_subtrees(arm)
|
|
if args.anim_only:
|
|
purge_non_armature()
|
|
|
|
fcurves = action_fcurves(arm)
|
|
if fcurves is None:
|
|
raise RuntimeError("keine Action / keine Animation")
|
|
|
|
fps, step, rate = detect_rate(bpy.context.scene, fcurves)
|
|
scn = bpy.context.scene
|
|
frame_span = (scn.frame_start, scn.frame_end)
|
|
unit_scale = scn.unit_settings.scale_length or 1.0
|
|
obj_scale = tuple(round(v, 6) for v in arm.scale)
|
|
loc_tol, loc_tol_src = effective_loc_tol(args, unit_scale, mean_scale(arm.scale))
|
|
nonuniform = max(obj_scale) - min(obj_scale) > 1e-6
|
|
n_curves_in = len(fcurves)
|
|
|
|
phase("Bereinigen")
|
|
dead = remove_dead_fcurves(arm, fcurves)
|
|
removed, collapsed = collapse_constants(action_fcurves(arm),
|
|
args.scale_snap)
|
|
|
|
before, _after_dec, per_ch = decimate(
|
|
action_fcurves(arm), args, loc_tol,
|
|
tick=lambda f: phase("Dezimieren %d%%" % int(f * 100)))
|
|
|
|
phase("Finalisieren")
|
|
aligned = align_group_times(action_fcurves(arm))
|
|
# erst jetzt zaehlen: das Angleichen kann Keys hinzufuegen
|
|
after = sum(len(fc.keyframe_points) for fc in action_fcurves(arm))
|
|
finalize_curves(action_fcurves(arm), args.nd)
|
|
|
|
hist = bone_key_histogram(action_fcurves(arm)) if args.report else []
|
|
|
|
phase("Export")
|
|
if args.format == 'FBX':
|
|
export_fbx(dst, args)
|
|
else:
|
|
export_gltf(dst, args)
|
|
|
|
return dict(
|
|
bones_in=n_bones_in, bones_dropped=dropped, bones_out=len(arm.data.bones),
|
|
curves_in=n_curves_in, curves_out=len(action_fcurves(arm)),
|
|
dead_curves=dead, const_removed=removed, const_collapsed=collapsed,
|
|
aligned=aligned, keys_before=before, keys_after=after,
|
|
per_channel=per_ch, histogram=hist,
|
|
fps=fps, key_step=step, rate=rate, frame_span=frame_span,
|
|
unit_scale=unit_scale, obj_scale=obj_scale,
|
|
loc_tol=loc_tol, loc_tol_src=loc_tol_src, nonuniform=nonuniform,
|
|
written=resolve_written(dst, args.format))
|
|
|
|
|
|
# ============================================================================
|
|
# Main
|
|
# ============================================================================
|
|
|
|
def sanitize_root(raw):
|
|
"""Faengt den klassischen Windows-Batch-Fehler ab: '%~dp0' endet mit einem
|
|
Backslash, der in "..\\" das Anfuehrungszeichen escapet - dann landet die
|
|
halbe Kommandozeile im Pfad."""
|
|
root = raw.strip().strip('"').rstrip("\\/")
|
|
if " --" in root or "\n" in root:
|
|
cut = root.split(" --")[0].rstrip('" \\/')
|
|
log("")
|
|
log(" ! Das --root Argument wurde von cmd.exe zerschossen:")
|
|
log(" %s" % ellipsis(raw, 70))
|
|
log(" Ursache: ein Backslash direkt vor dem schliessenden \".")
|
|
log(" In der .bat --root \"%ROOTA%\" statt --root \"%ROOT%\" verwenden.")
|
|
log(" Ich arbeite jetzt mit: %s" % cut)
|
|
log("")
|
|
root = cut
|
|
return os.path.abspath(root)
|
|
|
|
|
|
def short_error(exc):
|
|
"""Blender packt ganze Tracebacks in die Exception-Message. Sucht die
|
|
Zeile, die tatsaechlich die Ursache benennt."""
|
|
lines = [l.strip() for l in str(exc).splitlines() if l.strip()]
|
|
if not lines:
|
|
return exc.__class__.__name__
|
|
lines = [re.sub(r"[A-Za-z]:[\\/][^'\"]*[\\/]", lambda m: "...\\", l)
|
|
for l in lines]
|
|
for l in reversed(lines):
|
|
if l.startswith(("File ", "Location:", "Traceback", "Error: Python")):
|
|
continue
|
|
if "Error" in l or "error" in l or ":" not in l:
|
|
return l
|
|
return lines[0]
|
|
|
|
|
|
def log_environment(args, in_dir, out_dir):
|
|
logf("=" * 78)
|
|
logf(" anim_optimize Protokoll")
|
|
logf(" Zeit : %s" % time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
logf(" Blender : %s" % bpy.app.version_string)
|
|
logf(" Python : %s" % sys.version.split()[0])
|
|
logf(" Plattform : %s" % sys.platform)
|
|
logf(" Quelle : %s" % in_dir)
|
|
logf(" Ziel : %s" % out_dir)
|
|
logf("-" * 78)
|
|
logf(" Einstellungen")
|
|
for k in sorted(vars(args)):
|
|
logf(" %-18s %s" % (k, getattr(args, k)))
|
|
if args.gltfpack:
|
|
logf(" %-18s %s" % ("gltfpack-version", gltfpack_version(args.gltfpack)))
|
|
logf("=" * 78)
|
|
logf("")
|
|
|
|
|
|
def log_file_detail(base, src, dst, st, s_in, s_out, pack_before, secs, args):
|
|
logf("")
|
|
logf("-" * 78)
|
|
logf(" DATEI %s" % base)
|
|
logf("-" * 78)
|
|
logf(" Quelle %s (%.3f MB)" % (os.path.basename(src), s_in / 1e6))
|
|
logf(" Ziel %s (%.3f MB)" % (os.path.basename(dst), s_out / 1e6))
|
|
if pack_before:
|
|
logf(" gltfpack %.3f MB -> %.3f MB (%.1f%%)"
|
|
% (pack_before / 1e6, s_out / 1e6, s_out / pack_before * 100))
|
|
logf(" Dauer %.2f s" % secs)
|
|
logf("")
|
|
logf(" Samplerate %.4g fps, Key-Step %.4g -> %.4g Keys/s"
|
|
% (st["fps"], st["key_step"], st["rate"]))
|
|
logf(" Frame-Bereich %s bis %s" % st["frame_span"])
|
|
sc = sum(abs(v) for v in st["obj_scale"]) / 3.0
|
|
mm = st["loc_tol"] * sc * st["unit_scale"] * 1000.0
|
|
logf(" Einheiten scale_length %.6g, Armature-Scale %s"
|
|
% (st["unit_scale"], st["obj_scale"]))
|
|
logf(" 1 Keyframe-Einheit = %.4g mm"
|
|
% (sc * st["unit_scale"] * 1000.0))
|
|
logf(" Positionstol. %.6g BU = %.4g mm (%s)"
|
|
% (st["loc_tol"], mm, st["loc_tol_src"]))
|
|
if st["nonuniform"]:
|
|
logf(" ! Armature-Scale ist nicht uniform - die mm-Umrechnung nutzt "
|
|
"den Mittelwert")
|
|
logf(" Bones %d importiert, %d entfernt, %d exportiert"
|
|
% (st["bones_in"], st["bones_dropped"], st["bones_out"]))
|
|
logf(" F-Curves %d -> %d (%d verwaist, %d konstant entfernt, "
|
|
"%d auf 1 Key)" % (st["curves_in"], st["curves_out"], st["dead_curves"],
|
|
st["const_removed"], st["const_collapsed"]))
|
|
if st["aligned"]:
|
|
logf(" ! Zeiten angeglichen in %d Kanalgruppen - diese Gruppen waren"
|
|
% st["aligned"])
|
|
logf(" inkonsistent und wurden NICHT dezimiert. Sollte 0 sein.")
|
|
kb, ka = st["keys_before"], st["keys_after"]
|
|
logf(" Keys %d -> %d (%.1f%% entfernt)"
|
|
% (kb, ka, (1 - ka / max(1, kb)) * 100))
|
|
for ch, (b, a) in sorted(st["per_channel"].items()):
|
|
logf(" %-22s %7d -> %7d (%.1f%%)"
|
|
% (ch, b, a, (1 - a / max(1, b)) * 100))
|
|
|
|
if st["histogram"]:
|
|
logf("")
|
|
logf(" Keys pro Bone (Top %d von %d):"
|
|
% (min(args.report_top, len(st["histogram"])), len(st["histogram"])))
|
|
total = sum(v for _, v in st["histogram"]) or 1
|
|
for name, cnt in st["histogram"][:args.report_top]:
|
|
logf(" %-34s %6d %5.1f%%" % (ellipsis(name, 34), cnt,
|
|
cnt / total * 100))
|
|
|
|
|
|
def main():
|
|
args = ARGS
|
|
root = sanitize_root(args.root)
|
|
in_dir = os.path.join(root, args.in_dir)
|
|
out_dir = os.path.join(root, args.out_dir)
|
|
ext = EXT_OF[args.format]
|
|
|
|
os.makedirs(in_dir, exist_ok=True)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
log_path = os.path.join(root, args.log) if args.log else ""
|
|
open_log(log_path)
|
|
|
|
# Reste abgebrochener Laeufe entfernen
|
|
for f in os.listdir(out_dir):
|
|
if ".part" in f:
|
|
try:
|
|
os.remove(os.path.join(out_dir, f))
|
|
except Exception:
|
|
pass
|
|
|
|
if args.gltfpack:
|
|
fixed, notes = adjust_pack_args(args.gltfpack, args.gltfpack_args)
|
|
if fixed != args.gltfpack_args:
|
|
args.gltfpack_args = fixed
|
|
pack_notes = notes
|
|
else:
|
|
pack_notes = []
|
|
|
|
log_environment(args, in_dir, out_dir)
|
|
for n in pack_notes:
|
|
logf(" gltfpack-Hinweis: %s" % n)
|
|
|
|
files = sorted(f for f in os.listdir(in_dir) if f.lower().endswith(".fbx"))
|
|
if not files:
|
|
log("\n Keine FBX in %s gefunden.\n" % in_dir)
|
|
close_log()
|
|
return
|
|
|
|
sizes = {f: os.path.getsize(os.path.join(in_dir, f)) for f in files}
|
|
todo = [f for f in files
|
|
if args.force or not os.path.exists(
|
|
os.path.join(out_dir, os.path.splitext(f)[0] + ext))]
|
|
skipped_upfront = [f for f in files if f not in todo]
|
|
|
|
log("")
|
|
log(c(CYAN, "=" * 78))
|
|
log(" Quelle : %s" % c(DIM, in_dir))
|
|
log(" Ziel : %s (%s)" % (c(DIM, out_dir), c(BOLD, args.format)))
|
|
log(" Dateien: %d gesamt, %d zu bauen, %d uebersprungen"
|
|
% (len(files), len(todo), len(skipped_upfront)))
|
|
loc_txt = ("%.5f BU (fest)" % args.loc_tol) if args.loc_tol is not None \
|
|
else ("%.4g mm" % args.loc_tol_mm)
|
|
log(" Tol. : rot %.3f deg | loc %s | loose %.3f deg (%s)"
|
|
% (args.rot_tol, loc_txt, args.loose_rot_tol, args.loose_bones))
|
|
if args.gltfpack:
|
|
log(" Packing: %s %s" % (c(GREEN, "aktiv"), c(DIM, args.gltfpack_args)))
|
|
for n in pack_notes:
|
|
log(" %s" % c(YELLOW, n))
|
|
log(c(CYAN, "=" * 78))
|
|
|
|
if not todo:
|
|
log(" Nichts zu tun - alle Ziele existieren bereits.")
|
|
log(c(CYAN, "=" * 78) + "\n")
|
|
close_log()
|
|
return
|
|
|
|
prog = Progress(len(files), sum(sizes[f] for f in files),
|
|
enabled=not args.no_progress)
|
|
for f in skipped_upfront:
|
|
prog.file_skipped(sizes[f])
|
|
|
|
done = failed = 0
|
|
tot_in = tot_out = 0
|
|
packed_saved = packed_fail = 0
|
|
out_sizes = [] # (Basisname, Groesse) je geschriebener Datei
|
|
warnings = []
|
|
idx = len(skipped_upfront)
|
|
|
|
for name in todo:
|
|
idx += 1
|
|
base = os.path.splitext(name)[0]
|
|
src = os.path.join(in_dir, name)
|
|
dst = os.path.join(out_dir, base + ext)
|
|
# Endung MUSS schon stimmen, sonst haengt ensure_ext() im Exporter
|
|
# noch ein '.glb' an und die Datei liegt woanders als erwartet.
|
|
tmp = os.path.join(out_dir, base + ".part" + ext)
|
|
|
|
t0 = time.time()
|
|
pack_before = None
|
|
try:
|
|
st = process_one(src, tmp, args,
|
|
phase=lambda p, i=idx, b=base: prog.status(i, b, p))
|
|
written = st["written"]
|
|
if os.path.exists(dst):
|
|
os.remove(dst)
|
|
shutil.move(written, dst)
|
|
|
|
if args.gltfpack and args.format != 'FBX':
|
|
prog.status(idx, base, "gltfpack")
|
|
pack_before, err = run_gltfpack(dst, args.gltfpack,
|
|
args.gltfpack_args)
|
|
if err:
|
|
packed_fail += 1
|
|
warnings.append("%s: gltfpack -> %s" % (base, ellipsis(err, 44)))
|
|
else:
|
|
packed_saved += pack_before - os.path.getsize(dst)
|
|
|
|
if args.expect_fps > 0 and abs(st["rate"] - args.expect_fps) > 0.5:
|
|
warnings.append("%s: %.4g Keys/s statt %.4g (%.4g fps, Step %.4g)"
|
|
% (base, st["rate"], args.expect_fps,
|
|
st["fps"], st["key_step"]))
|
|
|
|
except Exception as e:
|
|
sys.stdout = CONSOLE # falls ein Kontext haengen blieb
|
|
for leftover in (tmp, tmp + ext, os.path.splitext(tmp)[0] + ext):
|
|
if os.path.exists(leftover):
|
|
try:
|
|
os.remove(leftover)
|
|
except Exception:
|
|
pass
|
|
prog.line(" %s %-22s %s"
|
|
% (c(RED, "FAIL"), ellipsis(base, 22),
|
|
c(DIM, ellipsis(short_error(e), 50))))
|
|
logf("")
|
|
logf("-" * 78)
|
|
logf(" FEHLER bei %s" % base)
|
|
logf("-" * 78)
|
|
logf(traceback.format_exc())
|
|
failed += 1
|
|
prog.file_done(sizes[name])
|
|
continue
|
|
|
|
s_in = sizes[name]
|
|
s_out = os.path.getsize(dst)
|
|
tot_in += s_in
|
|
tot_out += s_out
|
|
out_sizes.append((base, s_out))
|
|
done += 1
|
|
prog.file_done(s_in)
|
|
secs = time.time() - t0
|
|
|
|
if pack_before:
|
|
mid = "%6.2f ->%7s" % (pack_before / 1e6, fmt_size(s_out))
|
|
else:
|
|
mid = "%14s" % fmt_size(s_out)
|
|
prog.line(" %s %-22s %7.2f MB ->%s %s keys %6d -> %-6d %s"
|
|
% (c(GREEN, "OK "), ellipsis(base, 22), s_in / 1e6, mid,
|
|
c(BOLD, "%5.1f%%" % ((s_out / s_in * 100) if s_in else 0.0)),
|
|
st["keys_before"], st["keys_after"],
|
|
c(DIM, "%5.1fs" % secs)))
|
|
|
|
log_file_detail(base, src, dst, st, s_in, s_out, pack_before, secs, args)
|
|
|
|
prog.clear()
|
|
log(c(DIM, "-" * 78))
|
|
log(" %d gebaut, %d uebersprungen, %d fehlgeschlagen Laufzeit %s"
|
|
% (done, len(skipped_upfront), failed, fmt_time(time.time() - prog.t0)))
|
|
if tot_in:
|
|
log(" Gesamt: %.2f MB -> %.2f MB (%.1f%%, %.2f MB gespart)"
|
|
% (tot_in / 1e6, tot_out / 1e6, tot_out / tot_in * 100,
|
|
(tot_in - tot_out) / 1e6))
|
|
if packed_saved:
|
|
log(" davon durch gltfpack: %.2f MB" % (packed_saved / 1e6))
|
|
|
|
if out_sizes:
|
|
vals = sorted(sz for _, sz in out_sizes)
|
|
avg = sum(vals) / len(vals)
|
|
med = vals[len(vals) // 2] if len(vals) % 2 else \
|
|
(vals[len(vals) // 2 - 1] + vals[len(vals) // 2]) / 2
|
|
smallest = min(out_sizes, key=lambda kv: kv[1])
|
|
largest = max(out_sizes, key=lambda kv: kv[1])
|
|
log("")
|
|
log(c(BOLD, " Ausgabe"))
|
|
log(" %d Datei(en) %s gesamt %s im Schnitt %s Median"
|
|
% (len(out_sizes), c(BOLD, fmt_size(sum(vals))),
|
|
c(BOLD, fmt_size(avg)), fmt_size(med)))
|
|
log(" kleinste %9s %s" % (fmt_size(smallest[1]),
|
|
c(DIM, ellipsis(smallest[0], 46))))
|
|
log(" groesste %9s %s" % (fmt_size(largest[1]),
|
|
c(DIM, ellipsis(largest[0], 46))))
|
|
logf("")
|
|
logf(" Alle Ergebnisdateien nach Groesse:")
|
|
for base, sz in sorted(out_sizes, key=lambda kv: -kv[1]):
|
|
logf(" %9s %s" % (fmt_size(sz), base))
|
|
if packed_fail:
|
|
log(" gltfpack fehlgeschlagen bei %d Datei(en)" % packed_fail)
|
|
if warnings:
|
|
log("")
|
|
log(c(YELLOW, " Hinweise:"))
|
|
for w in warnings[:12]:
|
|
log(" - %s" % w)
|
|
if len(warnings) > 12:
|
|
log(" - ... und %d weitere" % (len(warnings) - 12))
|
|
if log_path:
|
|
log("")
|
|
log(" Protokoll: %s" % c(DIM, log_path))
|
|
log(c(CYAN, "=" * 78) + "\n")
|
|
|
|
close_log()
|
|
if failed:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
finally:
|
|
close_log()
|
|
|