-
2026-09-08 -- the constant-channel compaction is now part of every conversion, and stopped leaking one accessor a run (
file_utils/mesh_convert/_mesh_convert.py,export_verify.py,net_utils/preview/viewer.html). Review pass over the day's GLB work. Wired:MeshConvert.compact_glb_animationsruns insidefbx_to_glb's session afterprune_glb_animations-- its own docstring already described that position, and a byte-exact saving that only an ad-hoc script performs is exactly the export/preview driftGlbPipelineexists to close.drop_glb_texture_fallbacksdeliberately stays unwired: the pipeline already owns that decision one step earlier throughoptimize_glb_textures(ktx2_fallback=False), whose policy note says why it belongs to the consumer, so the new pass is documented as the RETROFIT for a finished file. The leak: a refusal is a property of the SAMPLER, so an output one clip accepted can be refused by a later clip's CUBICSPLINE -- and a clip left with nothing to collapse still minted a two-key time input no sampler would read. An accessor keeps its bufferView alive, so_compact_bincould not reclaim it and every re-run added another; measured on the new fixture at 5 accessors where 4 are live. Fail loudly:fbx_to_glbvalidatesclip_modebefore converting -- the rebuild runs under a warn-and-continue guard, so a typo caught only there downgraded silently to shipping Maya's lossy split takes. Gate:check_glb_imagesno longer WARNs about a missing PNG fallback when the file declaresKHR_texture_basisuREQUIRED -- that is the shapedrop_glb_texture_fallbacksproduces on purpose, and warning on a declared contract every run trains the reader past the gate that would name a real defect. A truncated BIN is now declined, not rewritten short:_compact_binslices each surviving view out of the buffer, and slicing past the end yields a shorter string in SILENCE while the view keeps thebyteLengthit declared -- so a file that arrived truncated came out merely lying about its offsets, no longer detectable as damaged. It checks every survivor is fully backed before writing anything and otherwise reclaims nothing; wiring the compaction is what made this run on every conversion, so declining is the only safe answer. Viewer:buildSequencerequires BOTH bounds of every declared shot. The manifest reads them with.get, so a take the carrier wrote without anendreaches the page asend_frame: null-- andMath.maxcoerces that to 0, so the SPAN stays right and nothing gives the fault away: the shot is placed at its own start offset inside a span that never accounted for it (7.0s of a 5.00s sequence on the fixture), where the scrubber cannot reach it while the picker counts it as a shot you can watch. A shot the page was not told the end of is one it cannot place.test_mesh_convert+4 -- the accessor leak and the short buffer both red first, plus the first coverage of the pass running inside an ALREADY-OPEN session, which is the form the conversion uses and the one that repacks the BIN under the other passes (it pins that an image, which names its bufferView directly rather than through an accessor, still survives).test_export_verify+2,test_preview_viewer_live+1 (also red first). -
2026-09-07 -- WebXR preview: a shots-only deliverable now plays and scrubs as one sequence (
net_utils/preview/viewer.html). WithAnimation Clips: Shots Onlythe file ships no continuous clip -- deliberately, since it holds the same performance the shots do -- and the page had no way to watch the whole thing. It now synthesizes aFULL SEQUENCEentry, first in the picker and addressable throughplayClip, driven by one playhead across every shot. The shots are placed at their AUTHORED frames rather than end to end: on the production assembly they sit 15 frames apart, and concatenating them would run the sequence ~3 s short and put every shot at the wrong time. A gap holds the previous shot's last pose, as the whole-timeline clip did, and a declared shot that bakes no curve still counts toward the span -- dropping it would end the sequence early. The segment action is posed by ASSIGNING its time and never advanced by the mixer, so two actions cannot accumulate time at once. Offered only when the file ships no continuous clip of its own: two ways to watch the same thing, one a reconstruction, is a worse picker than one.setupAnimationnow reports the FILE's clip count on theloadevent rather than the picker's row count, so a script reading it still learns what the deliverable carries. Verified against the real page in headless Edge (test_preview_viewer_live+6): the span, the pose inside the second shot, the gap hold, and the empty-shot tail are all read back off the model. -
2026-09-07 -- a GLB can now ship the shots, the whole sequence, or both -- and the clip rebuild stopped leaking the clips it replaces (
file_utils/mesh_convert/_mesh_convert.py,glb_clips.py,glb_pipeline.py).apply_glb_clipstakes amodefrom the newANIMATION_CLIP_MODES, threaded throughfbx_to_glbandGlbPipeline.buildasclip_mode. The two halves hold the SAME performance -- the shots are cut from the sequence -- so a consumer that plays one never reads the other; measured on VDATS_ASSEMBLY,shotssaves 66.8 MB andfull43.8 MB, and composed with the two compaction passes below the deliverable goes 215.29 MB -> 92.04 MB (-57%).fullcuts nothing but still renames and stamps the stack, becausezero_frameis what every later pass reads to place a clip in time and it does not become unknowable just because nothing was cut. The leak: the rebuild REPLACES the animations it supersedes -- Maya's own lossy split takes among them -- but dropping a clip frees nothing on its own, since an accessor keeps its bufferView alive whether or not anything still reads the accessor. Every conversion therefore shipped the discarded split takes' payload, and re-running the pass added another full set of shots (+38.8 MB a run, measured)._release_animation_payloadnow drops the orphaned accessors, renumbers what survives and lets_compact_bincollect: a second run returns the file to EXACTLY its first-run size. Accessors are named by a dozen different keys rather than one, so collector and renumberer share a single_map_accessor_refswalk and cannot disagree about a site, and the pass refuses outright on an extension that may hold accessor indices of its own -- the same bail-whole contract as the image prune.test_mesh_convert+5. -
2026-09-07 --
StrUtils.to_legal_nameis the one owner of the objectName rule (str_utils/_str_utils.py). Every non-alphanumeric becomes_, positionally: one character in, one out. Deliberately NOTsanitize, which lowercases, collapses runs and strips trailing separators -- that would make"a b"and"a b"the same objectName, and this result is an IDENTITY two sides derive INDEPENDENTLY and must agree on: the widget uitk builds from a definition (SwitchboardNameMixin.convert_to_legal_name) and the key a headless reader looks that widget up by (ExportProfile.widget_key). It lives on the generic string class because uitk cannot own it (pythontk is downstream of nothing) and a Scene-Exporter class is the wrong owner for a rule the whole switchboard depends on; both callers now delegate.test_strcovers it, andtest/surface_snapshot.jsonrecords the new published name. -
2026-09-07 -- GLB delivery: two new compaction passes cut a production deliverable 26% (
file_utils/mesh_convert/_mesh_convert.py). Measured on VDATS_ASSEMBLY, 215.29 MB.MeshConvert.compact_glb_animationscollapses every animation channel that never moves to two keys: a clip must PIN the pose of what it does not animate (a viewer playing clip B after clip A leaves any node B omits wherever A left it), which is why a baked export writes every node into every clip -- but pinning needs two keys, not one per frame. 13,187 of 22,654 channels held a single value throughout, costing 16.60 MB to say a node stood still. Byte-exact: a channel collapses only when every element has the same bit pattern, the two keys span the clip's own ends so no duration changes, and a CUBICSPLINE sampler is refused outright (three elements per key, so two keys is six elements, not two).MeshConvert.drop_glb_texture_fallbacksdrops the PNG/JPEG twin of every texture that also ships KTX2 and movesKHR_texture_basisuintoextensionsRequired: written as an OPTIONAL extension, every map shipped BOTH encodings, so the KTX2 pass was adding 40.66 MB to the texture budget it exists to cut (the export log read75.1 MB -> 79.5 MB). That one is a delivery decision, not a cleanup -- the result needs a reader with the extension. Only the animation pass is wired into the conversion (fbx_to_glb, afterprune_glb_animations): it is byte-exact, so it needs no dial and no caller can forget it. The fallback drop is NOT, and deliberately -- the pipeline already owns that choice one step earlier throughoptimize_glb_textures(ktx2_fallback=False), whose policy note says why it belongs to the consumer (the preview page wires aKTX2Loaderand passesFalse; an exporter's deliverable must stay importable and passesTrue). It ships as the RETROFIT for a finished file whose consumer turned out to be known, which is how the 215.29 MB -> 92.04 MB figure above was measured: the two composed by hand in oneopen_glbsession, in 1.8 s. New_compact_bincollects orphaned payload by reading liveness off the whole file, whichprune_glb_unreferenced_texturesnow shares -- 82 lines of duplicated repack deleted, and it collects payload orphaned by any earlier pass rather than only what its own images read. The sweep walks EVERYbufferViewkey, not just accessors: images name their view directly, and an accessor-only sweep read all 75.78 MB of them as garbage while this was being sized. The glTF accessor-layout tables are now single-sourced onMeshConvert(ACCESSOR_COMPONENT_TYPES,ACCESSOR_TYPE_COUNT) and read byGlbReader, which had its own copy.test_mesh_convert+12. -
2026-09-07 -- deliverable verification: a new
clip_origingate catches clips cut against the wrong frame, and the gates now read the scene's frame rate (file_utils/mesh_convert/export_verify.py). Reported: a shipped GLB played its shots "overlapping previous shots". Every existing gate passed it -- each clip kept the LENGTH its take declared and no shot was missing, soclips_vs_takesandcross_clipshad nothing to say; the clips were simply cut from the wrong PLACE.ExportVerifier.check_clip_origincompares the whole-timeline stack's carried length against theclip_span["*"]its clips were cut against: the stack's length is independent of the published origin, so the divergence IS visible in the bytes (an earlier note claimed no in-file check could see it). On the reported file: "stack carries 4201f but clips were cut against a 4114f span (161-4275)". Alsofpsnow defaults to the rate the sidecar publishes rather than a hardcoded 30 --TaskManager.verify_deliverablespasses only paths, so every frame each gate quoted was scaled by the ratio on a 24 or 60 fps scene._undeclared_clipsis shared withclips_vs_takesso the two cannot disagree about which clip is the stack.test_export_verify47. -
2026-09-07 -- shot store: gap locks survive the shots around them changing, and undo (
core_utils/engines/shots/shot_model.py). Reported: "I locked all gaps and they automatically became unlocked a few operations later." A lock names its gap by the flanking shot ids, and nothing ever unlocked it -- the pair simply stopped being adjacent: a shot inserted into a locked gap left both gaps that replaced it unlocked, a shot deleted or merged from between two locked gaps took both pairs with it while the gap that took their place opened unlocked, and an undone delete re-created the shot without its locks (the boundary snapshot carried shots and the ledger only)._rekey_gap_locks()now runs after everydefine_shot/remove_shot: a still-adjacent pair stands; a pair whose shots both exist becomes every adjacent pair between them (an inserted or split-off shot inherits the lock on both sides); a pair that lost a shot becomes the gap its survivor now flanks on that side; one that lost both is dropped._restore_point/_apply_boundary_snapshotcarrylocked_gaps, so undo and redo put back the locks of their moment. Topology events only -- neverupdate_shot, whose one-shot-at-a-time ripples pass through transient orders that would re-key a lock onto the wrong gap.test_shots_core148.