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