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

#!/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()