#!/usr/bin/env python3 """bake_skirt_gait.py — SPIKE: bake a cloth-simulated skirt onto the skirt_* ring. Headless Blender. Simulates the long tapa skirt against a SkirtRig body running the game's own run cycle (UAL1 Jog_Fwd_Loop == the game's "run" clip), then bakes the simulated cloth motion onto the skirt_* strand bones and exports ONE looped cycle as a glTF animation containing ONLY skirt_* rotation tracks — so the game can layer it over the body clip instead of (or under) the runtime spring sim. /Applications/Blender.app/Contents/MacOS/Blender --background \ --python clothing/skirt_gait_spike/bake_skirt_gait.py -- \ [--clip Jog_Fwd_Loop] [--cycles 3] [--out clothing/skirt_gait_spike/out] Stages: import -> bin -> cloth-sim -> fit chains -> skirt-only action -> renders (sim A/B + baked turntable) -> export GLB -> verify tracks. See NOTES.md. Bake mapping (cloth -> bones), in one paragraph: every garment vertex is binned ONCE from its REST position into (strand, boundary) cells — strand by azimuth around the pelvis axis, boundary by nearest ring-knot height, the same conventions as skirt_garment_weights.py. Per simulated frame each cell's world centroid becomes the target point for that strand's boundary knot. Bone pose is then solved analytically down each chain: root head stays glued to the pelvis (the waistband is pinned there), each segment takes the minimal rotation from its parent-propagated rest orientation that aims head->tail at its target centroid, twist carried from the parent so strands don't spin. Rotation-only: bone lengths never stretch, cloth compression shows up as curl, not scale. """ import argparse import json import math import os import struct import sys import bpy from mathutils import Matrix, Vector ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] HERE = os.path.dirname(os.path.abspath(__file__)) ANIM_ROOT = os.path.dirname(os.path.dirname(HERE)) GAME = os.environ.get("ARIKI_GAME_ROOT") or os.path.join( os.path.dirname(ANIM_ROOT), "ariki-game") ap = argparse.ArgumentParser() ap.add_argument("--body", default=os.path.join( GAME, "assets/quaternius/derived-bodies/Regular_Female_SkirtRig24.glb")) ap.add_argument("--garment", default=os.path.join( GAME, "assets/quaternius/outfits/chamorro/Female_Tapa_Skirt_Regular.gltf")) ap.add_argument("--anim-pack", default=os.path.join( GAME, "assets/quaternius/anim/UAL1.glb")) ap.add_argument("--clip", default="Jog_Fwd_Loop") ap.add_argument("--cycles", type=int, default=3) ap.add_argument("--settle", type=int, default=20) ap.add_argument("--fps", type=int, default=30) ap.add_argument("--out", default=os.path.join(HERE, "out")) ap.add_argument("--renders", default=os.path.join(HERE, "renders")) ap.add_argument("--clip-name", default=None, help="exported animation name (default derived from --clip)") ap.add_argument("--skip-renders", action="store_true") ARGS = ap.parse_args(ARGS) FPS = ARGS.fps PIN_BAND = 0.04 # top 4 cm of the garment is the pinned waistband SEAM_BLEND = 6 # frames at the cycle tail blended toward frame 0 (loop seam) def log(msg): print(f"[skirt_gait] {msg}", flush=True) def find_armature(): for o in bpy.data.objects: if o.type == "ARMATURE": return o return None def iter_fcurves(act): """Blender 5.x: actions are slotted/layered; there is no Action.fcurves.""" for layer in act.layers: for strip in layer.strips: for bag in strip.channelbags: yield from bag.fcurves def action_frame_range(act): lo, hi = 1e9, -1e9 for fc in iter_fcurves(act): for kp in fc.keyframe_points: lo = min(lo, kp.co.x) hi = max(hi, kp.co.x) return (int(round(lo)), int(round(hi))) def set_active_action(rig, act): ad = rig.animation_data_create() ad.action = act try: if hasattr(ad, "action_slot") and len(act.slots): ad.action_slot = act.slots[0] except Exception as e: log(f" action_slot bind skipped: {e}") def push_nla(rig, act, name): ad = rig.animation_data_create() tr = ad.nla_tracks.new() tr.name = name st = tr.strips.new(name, 1, act) try: if hasattr(st, "action_slot") and len(act.slots): st.action_slot = act.slots[0] except Exception as e: log(f" nla slot bind skipped: {e}") return tr def new_keyframe_action(name, rig): """Create a slotted action bound to `rig`; return (action, fcurve-new).""" act = bpy.data.actions.new(name) slot = act.slots.new("OBJECT", name) layer = act.layers.new("Layer") strip = layer.strips.new(type="KEYFRAME") bag = strip.channelbags.new(slot=slot) ad = rig.animation_data_create() ad.action = act ad.action_slot = slot def fc_new(data_path, index, action_group): return bag.fcurves.new(data_path=data_path, index=index, group_name=action_group) return act, fc_new def smooth01(x): x = max(0.0, min(1.0, x)) return x * x * (3 - 2 * x) # ───────────────────────────────────────────────────────────────────────────── log(f"body = {ARGS.body}") log(f"garment = {ARGS.garment}") log(f"anim = {ARGS.anim_pack} :: {ARGS.clip}") os.makedirs(ARGS.out, exist_ok=True) os.makedirs(ARGS.renders, exist_ok=True) bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene scene.render.fps = FPS # BEFORE the anim import, so times map to frames # ── import body ────────────────────────────────────────────────────────────── bpy.ops.import_scene.gltf(filepath=ARGS.body) rig = find_armature() meshes = [o for o in bpy.data.objects if o.type == "MESH"] body = max(meshes, key=lambda m: len(m.data.vertices)) log(f"body: rig={rig.name} bones={len(rig.data.bones)} " f"main mesh={body.name} ({len(body.data.vertices)} verts)") # ── ring geometry (armature space, Blender Z-up) ───────────────────────────── pelvis_bone = rig.data.bones["pelvis"] pelvis_head = pelvis_bone.head_local.copy() strands = {} for b in rig.data.bones: if not b.name.startswith("skirt_"): continue parts = b.name.split("_") if len(parts) < 2 or not parts[1].isdigit(): continue strands.setdefault(int(parts[1]), []).append(b.name) for k in strands: def depth(nm): d, b = 0, rig.data.bones[nm] while b.parent and b.parent.name.startswith("skirt_"): d += 1 b = b.parent return d strands[k] = sorted(strands[k], key=depth) K = sorted(strands) N_SEG = len(strands[K[0]]) log(f"ring: {len(K)} strands x {N_SEG} segments; strand 0 chain: {strands[K[0]]}") knots = {} azimuths = {} for k in K: chain = strands[k] zs = [rig.data.bones[nm].head_local.z for nm in chain] zs.append(rig.data.bones[chain[-1]].tail_local.z) knots[k] = zs h = rig.data.bones[chain[0]].head_local azimuths[k] = math.atan2(h.y - pelvis_head.y, h.x - pelvis_head.x) ring_top = knots[K[0]][0] log(f"ring top z={ring_top:.3f} hem z={knots[K[0]][-1]:.3f} " f"knots: {[f'{z:.3f}' for z in knots[K[0]]]}") # ── import garment, retarget its skin to the body rig ───────────────────────── pre = set(bpy.data.objects) bpy.ops.import_scene.gltf(filepath=ARGS.garment) new_objs = [o for o in bpy.data.objects if o not in pre] garment = next(o for o in new_objs if o.type == "MESH") gar_arm = next((o for o in new_objs if o.type == "ARMATURE"), None) log(f"garment: {garment.name} ({len(garment.data.vertices)} verts), " f"carrier armature: {gar_arm.name if gar_arm else None}") for m in garment.modifiers: if m.type == "ARMATURE": m.object = rig if not any(m.type == "ARMATURE" for m in garment.modifiers): garment.modifiers.new("Armature", "ARMATURE").object = rig if gar_arm: bpy.data.objects.remove(gar_arm, do_unlink=True) garment.parent = rig # WELD: the exported garment has per-face verts (flat-shaded islands of ONE # quad each — run 3: the cloth sim shredded it into confetti and per-island # pinning meant nothing). remove_doubles reconnects the tube and the rope as # two real islands. Weights are (re)assigned after this, nothing to preserve. import bmesh bm = bmesh.new() bm.from_mesh(garment.data) before = len(bm.verts) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4) bm.to_mesh(garment.data) bm.free() garment.data.update() log(f"welded garment: {before} -> {len(garment.data.vertices)} verts") # Garment rest verts in ARMATURE space (no action assigned yet -> rest pose). g2r = rig.matrix_world.inverted() @ garment.matrix_world rest_arm = [g2r @ v.co for v in garment.data.vertices] top_z = max(p.z for p in rest_arm) log(f"garment rest z span: {top_z:.3f} .. {min(p.z for p in rest_arm):.3f}") # ── bin verts: (strand by azimuth, boundary by nearest knot) — rest positions ── def nearest_strand(az): return min(K, key=lambda k: abs((az - azimuths[k] + math.pi) % (2 * math.pi) - math.pi)) # Pin per ISLAND, not per mesh: the garment is the tapa tube PLUS the separate # rope ring, and the rope's back-rise rides ~5 cm above the tube's top row. A # single pin line off the mesh top leaves the tube's top row unpinned and the # whole skirt falls to the floor (run 2 — sim renders showed only the rope). adj = [set() for _ in rest_arm] for e in garment.data.edges: adj[e.vertices[0]].add(e.vertices[1]) adj[e.vertices[1]].add(e.vertices[0]) island_of = [-1] * len(rest_arm) islands = [] for i in range(len(rest_arm)): if island_of[i] >= 0: continue idx = len(islands) stack, members = [i], [] island_of[i] = idx while stack: v = stack.pop() members.append(v) for w in adj[v]: if island_of[w] < 0: island_of[w] = idx stack.append(w) islands.append(members) pin_line_by_island = {idx: max(rest_arm[v].z for v in members) - PIN_BAND for idx, members in enumerate(islands)} log(f"garment islands: {[len(m) for m in islands]} verts, " f"pin lines: {[f'{z:.3f}' for z in pin_line_by_island.values()]}") bins = {} n_pinned = 0 pin_idxs = [] for i, p in enumerate(rest_arm): az = math.atan2(p.y - pelvis_head.y, p.x - pelvis_head.x) k = nearest_strand(az) j = min(range(N_SEG + 1), key=lambda jj: abs(p.z - knots[k][jj])) if j == 0: j = 1 # above-ring waistband verts inform the first boundary bins.setdefault((k, j), []).append(i) if p.z >= pin_line_by_island[island_of[i]]: n_pinned += 1 pin_idxs.append(i) empty = [(k, j) for k in K for j in range(1, N_SEG + 1) if not bins.get((k, j))] log(f"bins: {len(bins)} cells, {len(empty)} empty; waistband pin verts: {n_pinned}") # ── import the anim pack, keep only the wanted clip ────────────────────────── pre_actions = set(bpy.data.actions) bpy.ops.import_scene.gltf(filepath=ARGS.anim_pack) pack_objs = [o for o in bpy.data.objects if o not in pre and o is not garment] pack_actions = [a for a in bpy.data.actions if a not in pre_actions] run_act = next((a for a in pack_actions if a.name == ARGS.clip), None) if run_act is None: cand = [a.name for a in pack_actions if ARGS.clip.split("_")[0] in a.name] raise SystemExit(f"[skirt_gait] FATAL: clip '{ARGS.clip}' not found; " f"similar: {cand[:10]}") run_act.use_fake_user = True f0, f1 = action_frame_range(run_act) CYCLE = f1 - f0 log(f"clip '{run_act.name}': frames {f0}..{f1} -> cycle {CYCLE}f @ {FPS}fps " f"({CYCLE / FPS:.3f}s)") for fc in iter_fcurves(run_act): if not any(m.type == "CYCLES" for m in fc.modifiers): fc.modifiers.new("CYCLES") for o in pack_objs: bpy.data.objects.remove(o, do_unlink=True) for a in pack_actions: if a is not run_act: bpy.data.actions.remove(a) set_active_action(rig, run_act) scene.frame_start = 1 sim_end = ARGS.settle + ARGS.cycles * CYCLE scene.frame_end = sim_end log(f"sim: settle {ARGS.settle}f + {ARGS.cycles} cycles = frames 1..{sim_end}") # ── cloth setup (build_pugu_tifi.py idioms) ─────────────────────────────────── # Waistband: pinned, and pelvis-weighted so the pin FOLLOWS the animated hip # (Armature modifier sits above Cloth; pinned verts track the deformed input). pin_vg = garment.vertex_groups.get("ClothPin") or garment.vertex_groups.new(name="ClothPin") pelvis_vg = garment.vertex_groups.get("pelvis") or garment.vertex_groups.new(name="pelvis") # Pinned verts must ride the pelvis ALONE: zero their other weights first, or # the inherited thigh blend drags the waistband with the stride. for vg0 in garment.vertex_groups: if vg0.name not in ("ClothPin", "pelvis"): vg0.add(pin_idxs, 0.0, "REPLACE") pin_vg.add(pin_idxs, 1.0, "REPLACE") pelvis_vg.add(pin_idxs, 1.0, "REPLACE") arm_mod = next(m for m in garment.modifiers if m.type == "ARMATURE") bpy.context.view_layer.objects.active = garment while garment.modifiers.find(arm_mod.name) > 0: bpy.ops.object.modifier_move_up(modifier=arm_mod.name) body.modifiers.new("Collision", "COLLISION") body.collision.thickness_outer = 0.008 cloth = garment.modifiers.new("Cloth", "CLOTH") cs = cloth.settings cs.vertex_group_mass = "ClothPin" cs.quality = 8 cs.mass = 0.25 cs.tension_stiffness = 8.0 cs.compression_stiffness = 8.0 cs.shear_stiffness = 4.0 cs.bending_stiffness = 0.06 cloth.collision_settings.collision_quality = 4 cloth.collision_settings.distance_min = 0.006 cloth.collision_settings.use_self_collision = False # ── render rig (set up BEFORE the sim: sim stills are shot DURING the capture # loop — jumping scene.frame_set BACKWARD into a cached cloth range re-evaluates # stale state and produced two byte-identical "different phase" stills in run 4) cam = None if not ARGS.skip_renders: cam_data = bpy.data.cameras.new("Cam") cam = bpy.data.objects.new("Cam", cam_data) scene.collection.objects.link(cam) sun_data = bpy.data.lights.new("Sun", "SUN") sun_data.energy = 3.0 sun = bpy.data.objects.new("Sun", sun_data) sun.rotation_euler = (math.radians(50), 0, math.radians(30)) scene.collection.objects.link(sun) gnd = bpy.data.meshes.new("Ground") gnd.from_pydata([(-3, -3, 0), (3, -3, 0), (3, 3, 0), (-3, 3, 0)], [], [(0, 1, 2, 3)]) gnd_obj = bpy.data.objects.new("Ground", gnd) scene.collection.objects.link(gnd_obj) for eng in ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_WORKBENCH"): try: scene.render.engine = eng break except Exception: continue log(f"render engine: {scene.render.engine}") scene.render.resolution_x = 640 scene.render.resolution_y = 760 world = bpy.data.worlds.new("W") world.use_nodes = True world.node_tree.nodes["Background"].inputs[0].default_value = (0.7, 0.7, 0.75, 1) world.node_tree.nodes["Background"].inputs[1].default_value = 0.6 scene.world = world VIEWS = {"front45": math.radians(30), "side": math.radians(120), "back45": math.radians(240)} def aim(cam_obj, az): r, h = 2.6, 1.15 cam_obj.location = (r * math.cos(az), r * math.sin(az), h) look = Vector((0, 0, 0.80)) - cam_obj.location cam_obj.rotation_euler = look.to_track_quat("-Z", "Y").to_euler() def render(tag, f, view): scene.frame_set(f) aim(cam, VIEWS[view]) scene.camera = cam scene.render.filepath = os.path.join( ARGS.renders, f"{tag}_f{f - cap0 + 1:02d}_{view}.png") bpy.ops.render.render(write_still=True) # ── simulate, capturing the LAST cycle ──────────────────────────────────────── cap0 = sim_end - CYCLE + 1 frames = list(range(cap0, sim_end + 1)) SIM_SHOTS = {cap0, cap0 + CYCLE // 2} log(f"simulating frames 1..{sim_end}; capture {cap0}..{sim_end}") r2a = rig.matrix_world.inverted() pelvis_pb = rig.pose.bones["pelvis"] pelvis_rest_inv = pelvis_bone.matrix_local.inverted() targets = [] # [frame][(k,j)] -> Vector (armature space) pelvis_delta = [] # [frame] -> Matrix (pelvis rest->pose, armature space) for f in range(1, sim_end + 1): scene.frame_set(f) if f < cap0: continue deps = bpy.context.evaluated_depsgraph_get() ev = garment.evaluated_get(deps) me = ev.to_mesh() mw = ev.matrix_world acc = {} for (k, j), idxs in bins.items(): s = Vector((0, 0, 0)) for i in idxs: s += mw @ me.vertices[i].co acc[(k, j)] = r2a @ (s / len(idxs)) ev.to_mesh_clear() targets.append(acc) pelvis_delta.append(pelvis_pb.matrix @ pelvis_rest_inv) if cam is not None and f in SIM_SHOTS: render("sim", f, "front45") render("sim", f, "side") scene.frame_set(f) # render() re-set the frame; stay in sync log("sim done") # ── fit strand chains to the cloth targets (analytic, rotation-only) ───────── def fit_frame(ti): acc, d_pel = targets[ti], pelvis_delta[ti] out = {} for k in K: chain = strands[k] d_par_rot = d_pel.to_3x3() # inherited orientation delta root_rest = rig.data.bones[chain[0]].matrix_local parent_chain = d_pel @ root_rest # parent_pose @ parent_rest^-1 @ rest H = parent_chain.to_translation() # root head carried by pelvis for s, nm in enumerate(chain): bone = rig.data.bones[nm] T = acc.get((k, s + 1)) if T is None: # empty cell: hang straight down T = H + d_par_rot @ Vector((0, 0, -(knots[k][s] - knots[k][s + 1]))) want = T - H if want.length < 1e-6: want = d_par_rot @ Vector((0, 0, -1)) want.normalize() rest_rot = bone.matrix_local.to_3x3() O = d_par_rot @ rest_rot q = (O @ Vector((0, 1, 0))).rotation_difference(want) R = q.to_matrix() @ O M = Matrix.Translation(H) @ R.to_4x4() basis = (parent_chain.inverted() @ M).to_quaternion() out[nm] = basis H = H + R @ Vector((0, bone.length, 0)) d_par_rot = R @ rest_rot.inverted() parent_chain = M return out log("fitting bone chains to cloth targets...") pose_seq = [fit_frame(ti) for ti in range(len(frames))] for nm in pose_seq[0]: # quaternion sign continuity for ti in range(1, len(pose_seq)): if pose_seq[ti][nm].dot(pose_seq[ti - 1][nm]) < 0: pose_seq[ti][nm].negate() for ti in range(len(frames) - SEAM_BLEND, len(frames)): # loop seam blend w = smooth01((ti - (len(frames) - SEAM_BLEND - 1)) / SEAM_BLEND) for nm in pose_seq[ti]: pose_seq[ti][nm] = pose_seq[ti][nm].slerp(pose_seq[0][nm], w) # ── write the skirt-only action ─────────────────────────────────────────────── clip_name = ARGS.clip_name or "Skirt" + "".join( w.title() for w in ARGS.clip.split("_") if w.lower() != "loop") log(f"writing action '{clip_name}': {len(pose_seq[0])} bones x {len(frames)} frames") rig.animation_data_clear() skirt_act, fc_new = new_keyframe_action(clip_name, rig) for pb in rig.pose.bones: if pb.name in pose_seq[0]: pb.rotation_mode = "QUATERNION" curves = {} for nm in pose_seq[0]: dp = f'pose.bones["{nm}"].rotation_quaternion' curves[nm] = [fc_new(data_path=dp, index=ch, action_group=nm) for ch in range(4)] for ti in range(len(frames)): for nm, fcs in curves.items(): q = pose_seq[ti][nm] for ch in range(4): fcs[ch].keyframe_points.insert(ti + 1, q[ch], options={"FAST"}) for fcs in curves.values(): for fc in fcs: fc.update() # ── renders of the BAKED result (the eyeball gate; sim stills were shot # during the capture loop above) ───────────────────────────────────────────── if not ARGS.skip_renders: # ── switch the garment from cloth-sim to SKIRT-RING SKINNING ── garment.modifiers.remove(cloth) body.modifiers.remove(next(m for m in body.modifiers if m.type == "COLLISION")) if "ClothPin" in garment.vertex_groups: garment.vertex_groups.remove(garment.vertex_groups["ClothPin"]) # classic 2-strand x 2-segment weights (skirt_garment_weights.py conventions) garment.vertex_groups.clear() vg = {nm: garment.vertex_groups.new(name=nm) for k in K for nm in strands[k]} vg["pelvis"] = garment.vertex_groups.new(name="pelvis") BAND = 0.02 for i, p in enumerate(rest_arm): if p.z >= ring_top - BAND: vg["pelvis"].add([i], 1.0, "REPLACE") continue az = math.atan2(p.y - pelvis_head.y, p.x - pelvis_head.x) ks = sorted(K, key=lambda k: abs((az - azimuths[k] + math.pi) % (2 * math.pi) - math.pi))[:2] d = abs((azimuths[ks[1]] - azimuths[ks[0]] + math.pi) % (2 * math.pi) - math.pi) off = (az - azimuths[ks[0]] + math.pi) % (2 * math.pi) - math.pi frac = min(1.0, max(0.0, abs(off) / d)) if d > 1e-9 else 0.0 # height -> continuous segment position over the strand's knots zs = knots[ks[0]] fseg = float(N_SEG - 1) for sgi in range(N_SEG - 1): if p.z > zs[sgi + 1]: h = zs[sgi] - zs[sgi + 1] fseg = sgi + (0.0 if h < 1e-9 else max(0.0, min(1.0, (zs[sgi] - p.z) / h))) break i0 = min(N_SEG - 1, int(fseg)) i1 = min(N_SEG - 1, i0 + 1) fs = fseg - i0 for kk, azw in ((ks[0], 1.0 - frac), (ks[1], frac)): if azw <= 0.0: continue for si, sw in ((i0, 1.0 - fs), (i1, fs)): if sw > 0.0: vg[strands[kk][si]].add([i], azw * sw, "REPLACE") # body runs the gait; skirt ring runs the bake (disjoint channels, NLA) set_active_action(rig, run_act) push_nla(rig, skirt_act, "skirt_bake") scene.frame_start = 1 scene.frame_end = CYCLE cap0 = 1 # render tag math: actions now keyed at 1..CYCLE phases = [1, CYCLE // 4, CYCLE // 2, 3 * CYCLE // 4, CYCLE - 2, CYCLE] for f in phases: for view in VIEWS: render("baked", f, view) log("baked renders done") # ── export the skirt-only clip GLB (armature only, active action) ──────────── # Same scene — a file reload would purge skirt_act with the rest of bpy.data. # Save the work blend first (scratch/debug), then strip it down to rig+action. bpy.context.preferences.filepaths.save_version = 0 # working-files rule: no .blend1 bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ARGS.out, "work.blend")) ad = rig.animation_data for tr in list(ad.nla_tracks): ad.nla_tracks.remove(tr) set_active_action(rig, skirt_act) if run_act is not None: bpy.data.actions.remove(run_act) for o in [o for o in bpy.data.objects if o is not rig]: bpy.data.objects.remove(o, do_unlink=True) for pb in rig.pose.bones: pb.rotation_mode = "QUATERNION" scene.frame_start = 1 scene.frame_end = CYCLE rig2 = rig glb_out = os.path.join(ARGS.out, f"{clip_name}.glb") bpy.ops.object.select_all(action="DESELECT") rig2.select_set(True) try: bpy.ops.export_scene.gltf( filepath=glb_out, export_format="GLB", use_selection=True, export_animations=True, export_animation_mode="ACTIVE_ACTIONS", # sampling exports TRS for EVERY bone (run 3: 555 tracks incl. body); # direct fcurve export keeps it to the skirt_* rotation tracks only. export_force_sampling=False, export_skins=False, export_yup=True) except TypeError as e: log(f"export mode fallback: {e}") bpy.ops.export_scene.gltf( filepath=glb_out, export_format="GLB", use_selection=True, export_animations=True, export_skins=False, export_yup=True) log(f"EXPORTED {glb_out} ({os.path.getsize(glb_out)} bytes)") # ── verify the GLB: skirt-only tracks, right duration ───────────────────────── with open(glb_out, "rb") as fh: blob = fh.read() clen, _ = struct.unpack_from(" '{clip_name}'") log("VERIFY OK: skirt-only tracks") log("DONE")